├── web ├── src │ ├── main.js │ ├── App.vue │ ├── stores │ │ ├── auth.js │ │ └── services.js │ ├── router │ │ └── index.js │ ├── utils │ │ ├── formatters.js │ │ └── api.js │ ├── views │ │ ├── Login.vue │ │ ├── Home.vue │ │ ├── Hy2Setting.vue │ │ ├── Detail.vue │ │ ├── PortDetail.vue │ │ └── UserDetail.vue │ ├── components │ │ ├── EditNameModal.vue │ │ └── ServiceCard.vue │ └── assets │ │ └── main.css ├── public │ ├── site.webmanifest │ └── favicon.svg ├── index.html ├── package.json └── vite.config.js ├── docker-compose.yml ├── .gitignore ├── .dockerignore ├── .github └── workflows │ ├── release.yml │ └── docker-build-push.yml ├── backend ├── go.mod ├── database │ ├── auth.go │ └── database.go ├── go.sum └── main.go ├── Dockerfile ├── openssl_add_dataset.sh └── README.md /web/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import { createPinia } from 'pinia' 3 | import router from './router' 4 | import App from './App.vue' 5 | import './assets/main.css' 6 | 7 | const app = createApp(App) 8 | const pinia = createPinia() 9 | 10 | app.use(pinia) 11 | app.use(router) 12 | 13 | app.mount('#app') -------------------------------------------------------------------------------- /web/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "XTrafficDash", 3 | "short_name": "XTrafficDash", 4 | "description": "3X-UI聚合监控面板", 5 | "icons": [ 6 | { 7 | "src": "/favicon.svg", 8 | "sizes": "any", 9 | "type": "image/svg+xml" 10 | } 11 | ], 12 | "theme_color": "#3A77D9", 13 | "background_color": "#ffffff", 14 | "display": "standalone", 15 | "start_url": "/" 16 | } -------------------------------------------------------------------------------- /web/src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | 19 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | XTrafficDash 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "XTrafficDash", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "preview": "vite preview" 9 | }, 10 | "dependencies": { 11 | "axios": "^1.6.0", 12 | "chart.js": "^4.4.0", 13 | "pinia": "^2.1.0", 14 | "vue": "^3.4.0", 15 | "vue-chartjs": "^5.2.0", 16 | "vue-router": "^4.2.0" 17 | }, 18 | "devDependencies": { 19 | "@vitejs/plugin-vue": "^5.0.0", 20 | "vite": "^6.0.0" 21 | }, 22 | "overrides": { 23 | "rollup": "^4.9.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /web/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import { resolve } from 'path' 4 | 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | resolve: { 8 | alias: { 9 | '@': resolve(__dirname, 'src') 10 | } 11 | }, 12 | server: { 13 | port: 3000, 14 | proxy: { 15 | '/api': { 16 | target: 'http://localhost:37022', 17 | changeOrigin: true 18 | } 19 | } 20 | }, 21 | build: { 22 | outDir: 'dist', 23 | assetsDir: 'assets', 24 | rollupOptions: { 25 | output: { 26 | manualChunks: { 27 | vendor: ['vue', 'vue-router', 'pinia'], 28 | chart: ['chart.js', 'vue-chartjs'] 29 | } 30 | } 31 | } 32 | }, 33 | optimizeDeps: { 34 | include: ['vue', 'vue-router', 'pinia', 'axios'] 35 | } 36 | }) -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | xtrafficdash: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | container_name: xtrafficdash 9 | restart: unless-stopped 10 | ports: 11 | - "37022:37022" 12 | environment: 13 | - LISTEN_PORT=37022 14 | - DATABASE_PATH=/app/data/xtrafficdash.db 15 | - PASSWORD=${PASSWORD:-admin123} 16 | - DEBUG_MODE=${DEBUG_MODE:-false} 17 | - LOG_LEVEL=${LOG_LEVEL:-info} 18 | - TZ=Asia/Shanghai 19 | networks: 20 | - xui_network 21 | healthcheck: 22 | test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:37022/health"] 23 | interval: 30s 24 | timeout: 10s 25 | retries: 3 26 | start_period: 40s 27 | 28 | 29 | volumes: 30 | xtrafficdash_data: 31 | driver: local 32 | 33 | networks: 34 | xtrafficdash_network: 35 | driver: bridge -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # 编译产物 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | *.test 8 | *.out 9 | xtrafficdash 10 | # 可执行文件(无扩展名) 11 | xtrafficdash 12 | main 13 | 14 | # 数据库文件 15 | *.db 16 | *.sqlite 17 | *.sqlite3 18 | data/ 19 | 20 | # 前端构建产物 21 | web/dist/ 22 | web/node_modules/ 23 | web/.vite/ 24 | 25 | # 环境变量文件 26 | .env 27 | configs/.env 28 | 29 | # 日志文件 30 | *.log 31 | logs/ 32 | 33 | # IDE文件 34 | .vscode/ 35 | .idea/ 36 | *.swp 37 | *.swo 38 | *~ 39 | 40 | # 操作系统文件 41 | .DS_Store 42 | .DS_Store? 43 | ._* 44 | .Spotlight-V100 45 | .Trashes 46 | ehthumbs.db 47 | Thumbs.db 48 | 49 | # 临时文件 50 | *.tmp 51 | *.temp 52 | temp/ 53 | tmp/ 54 | 55 | # 备份文件 56 | *.bak 57 | *.backup 58 | *.old 59 | 60 | # 测试覆盖率 61 | coverage/ 62 | *.cover 63 | 64 | # 测试文件 65 | test_*.sh 66 | *_test.sh 67 | test_*.go 68 | *_test.go 69 | 70 | # 调试文件 71 | debug/ 72 | screenshots/ 73 | *.png 74 | *.jpg 75 | *.jpeg 76 | *.gif 77 | 78 | # Go相关 79 | go.work 80 | 81 | # 脚本文件(保留部署脚本) 82 | scripts/ 83 | !scripts/docker-deploy.sh 84 | !scripts/start.sh backend/xtrafficdash.db-shm 85 | backend/xtrafficdash.db-wal 86 | backend/xtrafficdash.db-shm -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git 2 | .git 3 | .gitignore 4 | .gitattributes 5 | 6 | # Documentation 7 | README.md 8 | CHANGELOG.md 9 | *.md 10 | 11 | # Development files 12 | .env 13 | .env.local 14 | .env.*.local 15 | *.log 16 | logs/ 17 | .txt 18 | 19 | # IDE 20 | .vscode/ 21 | .idea/ 22 | *.swp 23 | *.swo 24 | *~ 25 | 26 | # OS 27 | .DS_Store 28 | Thumbs.db 29 | 30 | # Node.js (for frontend) 31 | node_modules/ 32 | npm-debug.log* 33 | yarn-debug.log* 34 | yarn-error.log* 35 | 36 | # Build artifacts 37 | dist/ 38 | build/ 39 | *.exe 40 | *.dll 41 | *.so 42 | *.dylib 43 | 44 | # Test files 45 | test_*.sh 46 | *.test.js 47 | *.spec.js 48 | coverage/ 49 | *_test.sh 50 | test_*.go 51 | *_test.go 52 | 53 | # Temporary files 54 | tmp/ 55 | temp/ 56 | *.tmp 57 | 58 | # Database files (will be created in container) 59 | *.db 60 | *.sqlite 61 | *.sqlite3 62 | 63 | # Config files that might be overridden 64 | configs/ 65 | 66 | # Debug and development files 67 | debug/ 68 | screenshots/ 69 | *.png 70 | *.jpg 71 | *.jpeg 72 | *.gif 73 | 74 | # Binary files 75 | xtrafficdash 76 | *.exe 77 | *.dll 78 | *.so 79 | *.dylib 80 | 81 | # Scripts (except deployment scripts) 82 | scripts/ 83 | !scripts/docker-deploy.sh 84 | !scripts/start.sh 85 | 86 | # Logs 87 | *.log 88 | logs/ -------------------------------------------------------------------------------- /web/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' # 只有推送 tag 时才触发 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build-and-release: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: 检出代码 17 | uses: actions/checkout@v4 18 | 19 | # 后端 Go 构建 20 | - name: 设置 Go 环境 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: '1.21' 24 | 25 | - name: 编译 Go 后端 (Linux amd64, 启用CGO) 26 | run: | 27 | cd backend 28 | go mod tidy 29 | CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o xtrafficdash 30 | mv xtrafficdash ../ 31 | 32 | # 前端 Vue 构建 33 | - name: 设置 Node.js 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: '18' 37 | 38 | - name: 安装前端依赖并构建 39 | run: | 40 | cd web 41 | npm ci 42 | npm run build 43 | 44 | - name: 打包前端 dist 目录 45 | run: | 46 | cd web 47 | zip -r ../dist.zip dist 48 | 49 | # 打包发布 50 | # - name: 打包发布文件 51 | # run: | 52 | # mkdir release 53 | # cp xtrafficdash release/ 54 | # cp -r web/dist release/web-dist 55 | 56 | - name: 创建 Release 并上传资源 57 | uses: softprops/action-gh-release@v2 58 | with: 59 | files: | 60 | xtrafficdash 61 | dist.zip 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module xtrafficdash 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/gin-gonic/gin v1.9.1 7 | github.com/golang-jwt/jwt/v5 v5.2.3 8 | github.com/mattn/go-sqlite3 v1.14.22 9 | github.com/sirupsen/logrus v1.9.3 10 | ) 11 | 12 | require ( 13 | github.com/bytedance/sonic v1.9.1 // indirect 14 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect 15 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 16 | github.com/gin-contrib/sse v0.1.0 // indirect 17 | github.com/go-playground/locales v0.14.1 // indirect 18 | github.com/go-playground/universal-translator v0.18.1 // indirect 19 | github.com/go-playground/validator/v10 v10.14.0 // indirect 20 | github.com/goccy/go-json v0.10.2 // indirect 21 | github.com/json-iterator/go v1.1.12 // indirect 22 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect 23 | github.com/leodido/go-urn v1.2.4 // indirect 24 | github.com/mattn/go-isatty v0.0.19 // indirect 25 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 26 | github.com/modern-go/reflect2 v1.0.2 // indirect 27 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect 28 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 29 | github.com/ugorji/go/codec v1.2.11 // indirect 30 | golang.org/x/arch v0.3.0 // indirect 31 | golang.org/x/crypto v0.14.0 // indirect 32 | golang.org/x/net v0.17.0 // indirect 33 | golang.org/x/sys v0.13.0 // indirect 34 | golang.org/x/text v0.13.0 // indirect 35 | google.golang.org/protobuf v1.30.0 // indirect 36 | gopkg.in/yaml.v3 v3.0.1 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /web/src/stores/auth.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { ref, computed } from 'vue' 3 | import { authAPI } from '@/utils/api' 4 | 5 | export const useAuthStore = defineStore('auth', () => { 6 | const isAuthenticated = ref(false) 7 | const isLoading = ref(false) 8 | const error = ref(null) 9 | 10 | const login = async (password) => { 11 | isLoading.value = true 12 | error.value = null 13 | 14 | try { 15 | const response = await authAPI.login(password) 16 | if (response.data.success) { 17 | isAuthenticated.value = true 18 | localStorage.setItem('auth_token', response.data.token) 19 | return { success: true } 20 | } else { 21 | error.value = response.data.message || '登录失败' 22 | return { success: false, error: error.value } 23 | } 24 | } catch (err) { 25 | error.value = err.response?.data?.message || '网络错误' 26 | return { success: false, error: error.value } 27 | } finally { 28 | isLoading.value = false 29 | } 30 | } 31 | 32 | const logout = () => { 33 | isAuthenticated.value = false 34 | localStorage.removeItem('auth_token') 35 | } 36 | 37 | const checkAuth = () => { 38 | const token = localStorage.getItem('auth_token') 39 | if (token) { 40 | isAuthenticated.value = true 41 | } 42 | } 43 | 44 | return { 45 | isAuthenticated: computed(() => isAuthenticated.value), 46 | isLoading: computed(() => isLoading.value), 47 | error: computed(() => error.value), 48 | login, 49 | logout, 50 | checkAuth 51 | } 52 | }) -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 构建阶段 - 前端 2 | FROM node:18.19-alpine AS frontend-builder 3 | 4 | WORKDIR /app/web 5 | 6 | # 复制package文件并安装依赖 7 | COPY web/package*.json ./ 8 | RUN npm ci --silent 9 | 10 | # 复制前端源代码并构建 11 | COPY web/ ./ 12 | RUN npm run build 13 | 14 | # 构建阶段 - 后端 15 | FROM golang:1.21-alpine AS backend-builder 16 | 17 | WORKDIR /app 18 | 19 | # 安装系统依赖 20 | RUN apk add --no-cache build-base musl-dev sqlite-dev 21 | 22 | # 复制Go模块文件 23 | COPY backend/go.mod backend/go.sum ./ 24 | RUN go mod download 25 | 26 | # 复制后端源代码 27 | COPY backend/ ./ 28 | 29 | # 静态编译后端(使用SQLite兼容性标签) 30 | RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build \ 31 | -tags "sqlite_omit_load_extension" \ 32 | -ldflags="-w -s -extldflags '-static'" \ 33 | -o main . 34 | 35 | # 最终运行镜像 36 | FROM alpine:3.18 37 | 38 | # 安装运行时依赖(静态编译后不需要sqlite) 39 | RUN apk --no-cache add ca-certificates tzdata 40 | 41 | # 创建非root用户 42 | RUN addgroup -g 1001 -S appgroup && \ 43 | adduser -u 1001 -S appuser -G appgroup 44 | 45 | WORKDIR /app 46 | 47 | # 从构建阶段复制文件 48 | COPY --from=frontend-builder /app/web/dist ./web/dist 49 | COPY --from=backend-builder /app/main . 50 | 51 | # 创建数据目录并设置权限 52 | RUN mkdir -p /app/data && \ 53 | chown -R appuser:appgroup /app && \ 54 | chmod +x /app/main 55 | 56 | # 切换到非root用户 57 | USER appuser 58 | 59 | # 设置环境变量 60 | ENV DATABASE_PATH=/app/data/xtrafficdash.db 61 | # 设置后端登录密码和JWT密钥基础(必须与docker-compose一致) 62 | ENV PASSWORD=admin123 63 | ENV LISTEN_PORT=37022 64 | ENV DEBUG_MODE=true 65 | ENV LOG_LEVEL=debug 66 | ENV TZ=Asia/Shanghai 67 | 68 | # 暴露端口 69 | EXPOSE 37022 70 | 71 | # 健康检查 72 | HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ 73 | CMD wget --no-verbose --tries=1 --spider http://localhost:37022/health || exit 1 74 | 75 | # 启动命令 76 | CMD ["./main"] -------------------------------------------------------------------------------- /web/src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import { useAuthStore } from '../stores/auth' 3 | import Login from '../views/Login.vue' 4 | import Home from '../views/Home.vue' 5 | import Detail from '../views/Detail.vue' 6 | import PortDetail from '../views/PortDetail.vue' 7 | import UserDetail from '../views/UserDetail.vue' 8 | import Hy2Setting from '../views/Hy2Setting.vue' 9 | 10 | const routes = [ 11 | { 12 | path: '/', 13 | redirect: '/home' 14 | }, 15 | { 16 | path: '/login', 17 | name: 'Login', 18 | component: Login, 19 | meta: { requiresAuth: false } 20 | }, 21 | { 22 | path: '/home', 23 | name: 'Home', 24 | component: Home, 25 | meta: { requiresAuth: true } 26 | }, 27 | { 28 | path: '/detail/:serviceId', 29 | name: 'Detail', 30 | component: Detail, 31 | meta: { requiresAuth: true } 32 | }, 33 | { 34 | path: '/port/:serviceId/:tag', 35 | name: 'PortDetail', 36 | component: PortDetail, 37 | meta: { requiresAuth: true } 38 | }, 39 | { 40 | path: '/user/:serviceId/:email', 41 | name: 'UserDetail', 42 | component: UserDetail, 43 | meta: { requiresAuth: true } 44 | }, 45 | { 46 | path: '/hy2-setting', 47 | name: 'Hy2Setting', 48 | component: Hy2Setting, 49 | meta: { requiresAuth: true } 50 | } 51 | ] 52 | 53 | const router = createRouter({ 54 | history: createWebHistory(), 55 | routes 56 | }) 57 | 58 | // 路由守卫 59 | router.beforeEach((to, from, next) => { 60 | const authStore = useAuthStore() 61 | 62 | if (to.meta.requiresAuth && !authStore.isAuthenticated) { 63 | next('/login') 64 | } else if (to.path === '/login' && authStore.isAuthenticated) { 65 | next('/home') 66 | } else { 67 | next() 68 | } 69 | }) 70 | 71 | export default router -------------------------------------------------------------------------------- /web/src/utils/formatters.js: -------------------------------------------------------------------------------- 1 | export const formatBytes = (bytes) => { 2 | if (bytes === 0) return '0 B' 3 | const k = 1024 4 | const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] 5 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 6 | return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] 7 | } 8 | 9 | export const formatDate = (dateString) => { 10 | const date = new Date(dateString) 11 | return date.toLocaleDateString('zh-CN') 12 | } 13 | 14 | // 智能时间格式化:根据时间差自动选择“刚刚”、“xx分钟前”、“xx小时前”、“xx天前”、“xx个月前”、“xx年前”或 yyyy-MM-dd HH:mm 15 | export function formatSmartTime(dateStr) { 16 | if (!dateStr) return '' 17 | let date 18 | if (typeof dateStr === 'string') { 19 | if (dateStr.includes('T') || dateStr.includes('Z')) { 20 | date = new Date(dateStr) 21 | } else { 22 | date = new Date(dateStr.replace(' ', 'T')) 23 | } 24 | } else { 25 | date = new Date(dateStr) 26 | } 27 | const now = new Date() 28 | const diffMs = now - date 29 | const diffMin = Math.floor(diffMs / (1000 * 60)) 30 | const diffHour = Math.floor(diffMs / (1000 * 60 * 60)) 31 | const diffDay = Math.floor(diffMs / (1000 * 60 * 60 * 24)) 32 | if (diffMin < 1) return '刚刚' 33 | if (diffMin < 60) return `${diffMin}分钟前` 34 | if (diffHour < 24) return `${diffHour}小时前` 35 | if (diffDay < 30) return `${diffDay}天前` 36 | if (diffDay < 365) return `${Math.floor(diffDay / 30)}个月前` 37 | if (diffDay >= 365) return `${Math.floor(diffDay / 365)}年前` 38 | // 超过24小时,显示 yyyy-MM-dd HH:mm 39 | const y = date.getFullYear() 40 | const m = (date.getMonth() + 1).toString().padStart(2, '0') 41 | const d = date.getDate().toString().padStart(2, '0') 42 | const hh = date.getHours().toString().padStart(2, '0') 43 | const mm = date.getMinutes().toString().padStart(2, '0') 44 | return `${y}-${m}-${d} ${hh}:${mm}` 45 | } -------------------------------------------------------------------------------- /.github/workflows/docker-build-push.yml: -------------------------------------------------------------------------------- 1 | # 工作流名称 2 | name: CI-CD - Build and Push Docker Image 3 | 4 | # 触发工作流的事件 5 | on: 6 | push: 7 | tags: 8 | - 'v*' # 只在推送 v 开头的标签时触发,如 v1.0.0, v2.1.3 9 | workflow_dispatch: # 允许手动触发 10 | 11 | # 工作流执行的任务 12 | jobs: 13 | build-and-push: 14 | # 任务运行的环境 15 | runs-on: ubuntu-latest 16 | 17 | # 任务执行的步骤 18 | steps: 19 | # 步骤 1: 检出代码 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | # 步骤 2: 登录到 Docker Hub 24 | - name: Log in to Docker Hub 25 | uses: docker/login-action@v3 26 | with: 27 | registry: docker.io 28 | username: ${{ secrets.DOCKERHUB_USERNAME }} 29 | password: ${{ secrets.DOCKERHUB_TOKEN }} 30 | 31 | # 步骤 3: 提取元数据(标签和注释) 32 | - name: Extract metadata (tags, labels) for Docker 33 | id: meta 34 | uses: docker/metadata-action@v5 35 | with: 36 | images: docker.io/sanqi37/xtrafficdash 37 | tags: | 38 | # 使用 Git 标签作为 Docker 标签(去掉 v 前缀) 39 | type=ref,event=tag 40 | # 生成 latest 标签(仅对最新版本) 41 | type=raw,value=latest,enable={{is_default_branch}} 42 | # 生成 major 标签(如 v1.0.0 -> 1) 43 | type=semver,pattern={{major}} 44 | # 生成 major.minor 标签(如 v1.0.0 -> 1.0) 45 | type=semver,pattern={{major}}.{{minor}} 46 | 47 | # 步骤 4: 设置 Docker Buildx 48 | - name: Set up Docker Buildx 49 | uses: docker/setup-buildx-action@v3 50 | 51 | # 步骤 5: 构建并推送到 Docker Hub 52 | - name: Build and push Docker image 53 | uses: docker/build-push-action@v5 54 | with: 55 | context: . 56 | file: ./Dockerfile 57 | push: true 58 | tags: ${{ steps.meta.outputs.tags }} 59 | labels: ${{ steps.meta.outputs.labels }} 60 | cache-from: type=gha 61 | cache-to: type=gha,mode=max 62 | 63 | -------------------------------------------------------------------------------- /web/src/utils/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | // 创建axios实例 4 | const api = axios.create({ 5 | baseURL: '/api', 6 | timeout: 15000 7 | }) 8 | 9 | // 请求拦截器 - 添加认证token 10 | api.interceptors.request.use( 11 | (config) => { 12 | const token = localStorage.getItem('auth_token') 13 | if (token) { 14 | config.headers.Authorization = `Bearer ${token}` 15 | } 16 | return config 17 | }, 18 | (error) => { 19 | console.error('请求拦截器错误:', error) 20 | return Promise.reject(error) 21 | } 22 | ) 23 | 24 | // 响应拦截器 - 处理认证错误 25 | api.interceptors.response.use( 26 | (response) => { 27 | return response 28 | }, 29 | (error) => { 30 | // 处理401认证错误 31 | if (error.response?.status === 401) { 32 | localStorage.removeItem('auth_token') 33 | window.location.href = '/login' 34 | return Promise.reject(error) 35 | } 36 | 37 | console.error('API请求失败:', error) 38 | return Promise.reject(error) 39 | } 40 | ) 41 | 42 | export const servicesAPI = { 43 | // 获取服务列表 44 | getServices: () => api.get('/db/services'), 45 | 46 | // 获取服务详情 47 | getServiceDetail: (serviceId) => api.get(`/db/services/${serviceId}/traffic`), 48 | 49 | // 删除服务 50 | deleteService: (serviceId) => api.delete(`/db/services/${serviceId}`), 51 | 52 | // 获取7天流量数据 53 | getWeeklyTraffic: (serviceId) => api.get(`/db/traffic/weekly/${serviceId}`), 54 | 55 | // 获取30天流量数据 56 | getMonthlyTraffic: (serviceId) => api.get(`/db/traffic/monthly/${serviceId}`), 57 | 58 | // 获取端口详情 59 | getPortDetail: (serviceId, tag, days = 7) => api.get(`/db/port-detail/${serviceId}/${tag}?days=${days}`), 60 | 61 | // 获取用户详情 62 | getUserDetail: (serviceId, email, days = 7) => api.get(`/db/user-detail/${serviceId}/${email}?days=${days}`), 63 | 64 | // 更新服务自定义名称 65 | updateServiceCustomName: (serviceId, customName) => api.put(`/db/services/${serviceId}/custom-name`, { custom_name: customName }), 66 | 67 | // 更新入站端口自定义名称 68 | updateInboundCustomName: (serviceId, tag, customName) => api.put(`/db/inbound/${serviceId}/${tag}/custom-name`, { custom_name: customName }), 69 | 70 | // 更新客户端自定义名称 71 | updateClientCustomName: (serviceId, email, customName) => api.put(`/db/client/${serviceId}/${email}/custom-name`, { custom_name: customName }), 72 | 73 | // 下载端口历史数据 74 | downloadPortHistory: (serviceId, tag) => api.get(`/db/download/port-history/${serviceId}/${tag}`, { responseType: 'blob' }), 75 | 76 | // 下载用户历史数据 77 | downloadUserHistory: (serviceId, email) => api.get(`/db/download/user-history/${serviceId}/${email}`, { responseType: 'blob' }) 78 | } 79 | 80 | export const authAPI = { 81 | // 登录 82 | login: (password) => api.post('/auth/login', { password }), 83 | 84 | // 验证token 85 | verifyToken: () => api.get('/auth/verify') 86 | } 87 | 88 | export default api -------------------------------------------------------------------------------- /web/src/stores/services.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { ref, computed } from 'vue' 3 | import { servicesAPI } from '@/utils/api' 4 | 5 | export const useServicesStore = defineStore('services', () => { 6 | const services = ref([]) 7 | const selectedService = ref(null) 8 | const loading = ref(false) 9 | const error = ref(null) 10 | const refreshInterval = ref(null) 11 | 12 | const loadServices = async () => { 13 | loading.value = true 14 | error.value = null 15 | 16 | try { 17 | const response = await servicesAPI.getServices() 18 | if (response.data.success) { 19 | services.value = response.data.data 20 | } else { 21 | error.value = response.data.message || '加载服务列表失败' 22 | } 23 | } catch (err) { 24 | console.error('加载服务列表失败:', err) 25 | if (err.response?.status === 401) { 26 | error.value = '认证失败,请重新登录' 27 | } else { 28 | error.value = '网络错误,请检查服务器连接' 29 | } 30 | } finally { 31 | loading.value = false 32 | } 33 | } 34 | 35 | const selectService = (service) => { 36 | selectedService.value = service 37 | } 38 | 39 | const loadServiceDetail = async (serviceId) => { 40 | try { 41 | const response = await servicesAPI.getServiceDetail(serviceId) 42 | if (response.data.success) { 43 | selectedService.value = { 44 | ...selectedService.value, 45 | ...response.data.data 46 | } 47 | } 48 | } catch (error) { 49 | console.error('加载服务详情失败:', error) 50 | } 51 | } 52 | 53 | const deleteService = async (serviceId) => { 54 | try { 55 | const response = await servicesAPI.deleteService(serviceId) 56 | if (response.data.success) { 57 | services.value = services.value.filter(s => s.id !== serviceId) 58 | return { success: true } 59 | } else { 60 | return { success: false, error: response.data.message } 61 | } 62 | } catch (error) { 63 | console.error('删除服务失败:', error) 64 | return { success: false, error: '删除失败,请重试' } 65 | } 66 | } 67 | 68 | // 开始自动刷新 69 | const startAutoRefresh = () => { 70 | if (refreshInterval.value) { 71 | clearInterval(refreshInterval.value) 72 | } 73 | // 每60秒刷新一次 74 | refreshInterval.value = setInterval(() => { 75 | loadServices() 76 | }, 60000) 77 | } 78 | 79 | // 停止自动刷新 80 | const stopAutoRefresh = () => { 81 | if (refreshInterval.value) { 82 | clearInterval(refreshInterval.value) 83 | refreshInterval.value = null 84 | } 85 | } 86 | 87 | // 强制刷新 88 | const forceRefresh = () => { 89 | loadServices() 90 | if (selectedService.value) { 91 | loadServiceDetail(selectedService.value.id) 92 | } 93 | } 94 | 95 | return { 96 | services: computed(() => services.value), 97 | selectedService: computed(() => selectedService.value), 98 | loading: computed(() => loading.value), 99 | error: computed(() => error.value), 100 | loadServices, 101 | selectService, 102 | loadServiceDetail, 103 | deleteService, 104 | startAutoRefresh, 105 | stopAutoRefresh, 106 | forceRefresh 107 | } 108 | }) -------------------------------------------------------------------------------- /openssl_add_dataset.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DB="backend/xtrafficdash.db" 4 | 5 | # 随机生成IP 6 | rand_ip() { 7 | echo "$((RANDOM%223+1)).$((RANDOM%255)).$((RANDOM%255)).$((RANDOM%255))" 8 | } 9 | 10 | # 随机生成端口 11 | rand_port() { 12 | echo "$((RANDOM%55535+10000))" 13 | } 14 | 15 | # 随机邮箱 16 | rand_email() { 17 | echo "user$((RANDOM%1000000))@example.com" 18 | } 19 | 20 | # 生成30天日期(2025-06-23 ~ 2025-07-22) 21 | DATES=( 22 | "2025-06-23" "2025-06-24" "2025-06-25" "2025-06-26" "2025-06-27" 23 | "2025-06-28" "2025-06-29" "2025-06-30" "2025-07-01" "2025-07-02" 24 | "2025-07-03" "2025-07-04" "2025-07-05" "2025-07-06" "2025-07-07" 25 | "2025-07-08" "2025-07-09" "2025-07-10" "2025-07-11" "2025-07-12" 26 | "2025-07-13" "2025-07-14" "2025-07-15" "2025-07-16" "2025-07-17" 27 | "2025-07-18" "2025-07-19" "2025-07-20" "2025-07-21" "2025-07-22" 28 | ) 29 | 30 | # 2GB~20GB 高质量随机下载(openssl+bc) 31 | gen_random_down() { 32 | min=2147483648 33 | max=21474836480 34 | range=$((max - min + 1)) 35 | # openssl 生成8字节二进制,转为十进制 36 | n=$(openssl rand -hex 8) 37 | # 只取前15位,防止溢出 38 | n=${n:0:15} 39 | # 用 bash 10进制处理(去掉前导0和非数字) 40 | n=$((10#${n//[^0-9]/})) 41 | echo $(( min + n % range )) 42 | } 43 | # 9%~11% 随机上传 44 | gen_random_up() { 45 | local down=$1 46 | percent=$(( 9 + RANDOM % 3 )) 47 | echo $(( down * percent / 100 )) 48 | } 49 | 50 | for ((svc=1; svc<=2; svc++)); do 51 | IP=$(rand_ip) 52 | NAME="测试节点$RANDOM" 53 | sqlite3 $DB "INSERT INTO services (ip_address, custom_name, first_seen, last_seen, status) VALUES ('$IP', '$NAME', '2025-06-23 00:00:00', '2025-07-22 23:59:59', 'active');" 54 | SERVICE_ID=$(sqlite3 $DB "SELECT id FROM services WHERE ip_address='$IP' ORDER BY id DESC LIMIT 1;") 55 | 56 | # 随机2个端口 57 | declare -a PORTS 58 | declare -a INBOUND_IDS 59 | for ((p=1; p<=2; p++)); do 60 | PORT=$(rand_port) 61 | TAG="inbound-$PORT" 62 | sqlite3 $DB "INSERT INTO inbound_traffics (service_id, tag, port, last_updated, status) VALUES ($SERVICE_ID, '$TAG', $PORT, '2025-07-22 23:59:59', 'active');" 63 | INBOUND_ID=$(sqlite3 $DB "SELECT id FROM inbound_traffics WHERE service_id=$SERVICE_ID AND tag='$TAG' ORDER BY id DESC LIMIT 1;") 64 | PORTS[$p]=$PORT 65 | INBOUND_IDS[$p]=$INBOUND_ID 66 | done 67 | 68 | # 随机2个用户 69 | declare -a USERS 70 | declare -a CLIENT_IDS 71 | for ((u=1; u<=2; u++)); do 72 | EMAIL=$(rand_email) 73 | sqlite3 $DB "INSERT INTO client_traffics (service_id, email, last_updated, status) VALUES ($SERVICE_ID, '$EMAIL', '2025-07-22 23:59:59', 'active');" 74 | CLIENT_ID=$(sqlite3 $DB "SELECT id FROM client_traffics WHERE service_id=$SERVICE_ID AND email='$EMAIL' ORDER BY id DESC LIMIT 1;") 75 | USERS[$u]=$EMAIL 76 | CLIENT_IDS[$u]=$CLIENT_ID 77 | done 78 | 79 | # 填充端口流量历史 80 | for ((p=1; p<=2; p++)); do 81 | INBOUND_ID=${INBOUND_IDS[$p]} 82 | PORT=${PORTS[$p]} 83 | TAG="inbound-$PORT" 84 | for ((i=0;i<30;i++)); do 85 | DAY=${DATES[$i]} 86 | DOWN=$(gen_random_down) 87 | UP=$(gen_random_up $DOWN) 88 | sqlite3 $DB "INSERT INTO inbound_traffic_history (inbound_traffic_id, service_id, tag, date, daily_up, daily_down, created_at) VALUES ($INBOUND_ID, $SERVICE_ID, '$TAG', '$DAY', $UP, $DOWN, '2025-07-22 23:59:59');" 89 | done 90 | done 91 | 92 | # 填充用户流量历史 93 | for ((u=1; u<=2; u++)); do 94 | CLIENT_ID=${CLIENT_IDS[$u]} 95 | EMAIL=${USERS[$u]} 96 | for ((i=0;i<30;i++)); do 97 | DAY=${DATES[$i]} 98 | DOWN=$(gen_random_down) 99 | UP=$(gen_random_up $DOWN) 100 | sqlite3 $DB "INSERT INTO client_traffic_history (client_traffic_id, service_id, email, date, daily_up, daily_down, created_at) VALUES ($CLIENT_ID, $SERVICE_ID, '$EMAIL', '$DAY', $UP, $DOWN, '2025-07-22 23:59:59');" 101 | done 102 | done 103 | 104 | done 105 | 106 | echo "测试数据已写入 $DB" -------------------------------------------------------------------------------- /web/src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 58 | 59 | -------------------------------------------------------------------------------- /backend/database/auth.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "net/http" 7 | "os" 8 | "time" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/golang-jwt/jwt/v5" 12 | ) 13 | 14 | type LoginRequest struct { 15 | Password string `json:"password"` 16 | } 17 | 18 | type LoginResponse struct { 19 | Success bool `json:"success"` 20 | Message string `json:"message"` 21 | Token string `json:"token,omitempty"` 22 | } 23 | 24 | type Claims struct { 25 | UserID string `json:"user_id"` 26 | jwt.RegisteredClaims 27 | } 28 | 29 | var jwtSecret []byte 30 | 31 | // 生成带16位固定后缀的JWT密钥 32 | func getPasswordWithSuffix() string { 33 | password := os.Getenv("PASSWORD") 34 | if password == "" { 35 | return "" 36 | } 37 | h := md5.New() 38 | h.Write([]byte(password)) 39 | hash := hex.EncodeToString(h.Sum(nil)) 40 | return password + hash[:16] 41 | } 42 | 43 | // 初始化JWT密钥 44 | func InitJWT() { 45 | // 从环境变量读取密钥并加上16位固定后缀 46 | secret := getPasswordWithSuffix() 47 | if secret == "" { 48 | panic("必须设置环境变量 PASSWORD 作为 JWT 密钥和登录密码") 49 | } 50 | jwtSecret = []byte(secret) 51 | } 52 | 53 | // 验证密码 54 | func validatePassword(password string) bool { 55 | // 从环境变量读取密码 56 | envPassword := os.Getenv("PASSWORD") 57 | if envPassword == "" { 58 | // 未设置密码,直接返回 false 59 | return false 60 | } 61 | return password == envPassword 62 | } 63 | 64 | // 生成JWT token 65 | func generateToken() (string, error) { 66 | claims := Claims{ 67 | UserID: "admin", 68 | RegisteredClaims: jwt.RegisteredClaims{ 69 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), 70 | IssuedAt: jwt.NewNumericDate(time.Now()), 71 | }, 72 | } 73 | 74 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 75 | return token.SignedString(jwtSecret) 76 | } 77 | 78 | // 验证JWT token 79 | func validateToken(tokenString string) (*Claims, error) { 80 | token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { 81 | return jwtSecret, nil 82 | }) 83 | 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | if claims, ok := token.Claims.(*Claims); ok && token.Valid { 89 | return claims, nil 90 | } 91 | 92 | return nil, jwt.ErrSignatureInvalid 93 | } 94 | 95 | // 登录处理 96 | func HandleLogin(c *gin.Context) { 97 | var req LoginRequest 98 | if err := c.ShouldBindJSON(&req); err != nil { 99 | c.JSON(http.StatusBadRequest, LoginResponse{ 100 | Success: false, 101 | Message: "请求参数错误", 102 | }) 103 | return 104 | } 105 | 106 | if !validatePassword(req.Password) { 107 | c.JSON(http.StatusUnauthorized, LoginResponse{ 108 | Success: false, 109 | Message: "密码错误", 110 | }) 111 | return 112 | } 113 | 114 | token, err := generateToken() 115 | if err != nil { 116 | c.JSON(http.StatusInternalServerError, LoginResponse{ 117 | Success: false, 118 | Message: "生成token失败", 119 | }) 120 | return 121 | } 122 | 123 | c.JSON(http.StatusOK, LoginResponse{ 124 | Success: true, 125 | Message: "登录成功", 126 | Token: token, 127 | }) 128 | } 129 | 130 | // 验证token中间件 131 | func AuthMiddleware() gin.HandlerFunc { 132 | return func(c *gin.Context) { 133 | authHeader := c.GetHeader("Authorization") 134 | if authHeader == "" { 135 | c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未提供认证token"}) 136 | c.Abort() 137 | return 138 | } 139 | 140 | // 提取Bearer token 141 | if len(authHeader) < 7 || authHeader[:7] != "Bearer " { 142 | c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "token格式错误"}) 143 | c.Abort() 144 | return 145 | } 146 | 147 | tokenString := authHeader[7:] 148 | claims, err := validateToken(tokenString) 149 | if err != nil { 150 | c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "token无效"}) 151 | c.Abort() 152 | return 153 | } 154 | 155 | // 将用户信息存储到上下文中 156 | c.Set("user_id", claims.UserID) 157 | c.Next() 158 | } 159 | } 160 | 161 | // 验证token接口 162 | func HandleVerifyToken(c *gin.Context) { 163 | c.JSON(http.StatusOK, gin.H{ 164 | "success": true, 165 | "message": "token有效", 166 | }) 167 | } 168 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # XTrafficDash 2 | 3 | [![Go Version](https://img.shields.io/badge/Go-1.21+-blue.svg)](https://golang.org/) 4 | [![Vue Version](https://img.shields.io/badge/Vue-3.0+-green.svg)](https://vuejs.org/) 5 | [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) 6 | [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) 7 | 8 | 一个现代化的3X-UI流量统计面板,使用Vue3 + Go构建,支持多服务器流量监控和可视化。 9 | 10 | 11 | 12 | 13 | 14 | ## 🚀 快速开始 15 | 16 | ### docker run 17 | 18 | ```sh 19 | mkdir -p /usr/xtrafficdash/data && \ 20 | chmod 777 /usr/xtrafficdash/data && \ 21 | docker run -d \ 22 | --name xtrafficdash \ 23 | -p 37022:37022 \ 24 | -v /usr/xtrafficdash/data:/app/data \ 25 | -e TZ=Asia/Shanghai \ 26 | -e PASSWORD=admin123 \ 27 | --log-opt max-size=5m \ 28 | --log-opt max-file=3 \ 29 | --restart unless-stopped \ 30 | sanqi37/xtrafficdash 31 | ``` 32 | ### docker compose 部署 33 | 34 | 同样首先创建文件夹,给读写权限,否则容器无法创建数据库! 35 | ```sh 36 | mkdir -p /usr/xtrafficdash/data && \ 37 | chmod 777 /usr/xtrafficdash/data 38 | ``` 39 | 40 | ``` 41 | version: '3.8' 42 | 43 | services: 44 | xtrafficdash: 45 | image: sanqi37/xtrafficdash 46 | container_name: xtrafficdash 47 | restart: unless-stopped 48 | ports: 49 | - "37022:37022" 50 | environment: 51 | - TZ=Asia/Shanghai 52 | - DATABASE_PATH=/app/data/xtrafficdash.db 53 | - PASSWORD=admin123 54 | volumes: 55 | - /usr/xtrafficdash/data:/app/data 56 | logging: 57 | options: 58 | max-size: "5m" 59 | max-file: "3" 60 | ``` 61 | 62 | - 修改 `PASSWORD` ,前端 web 密码,不修改则默认为 admin123 63 | 64 | ### 3x-ui 接入(需要较新版本) 65 | - -> 面板设置 66 | - -> 常规 67 | - -> 外部流量 68 | - -> 外部流量通知URL 69 | - -> `http://111.111.111.111:37022/api/traffic` 70 | 71 | - 改为自己服务器地址 72 | 73 | 74 | ### hysteria2 接入 75 | 76 | #### 1. 修改配置文件 77 | ```sh 78 | nano /etc/hysteria/config.yaml 79 | ``` 80 | ##### 添加(请修改 passwd) 81 | 82 | ```yml 83 | trafficStats: 84 | listen: :37023 85 | secret: passwd 86 | ``` 87 | ```sh 88 | # 重启 hy2 89 | systemctl restart hysteria-server.service 90 | ``` 91 | #### 2. 在首页点击 `HY2设置` 进行添加 92 | 93 | 94 | 95 | ## 🚀 更新(数据库迁移) 96 | ```bash 97 | # 1. 停止正在运行的容器 98 | docker stop xtrafficdash 99 | 100 | # 2. 删除旧容器(不会影响备份的数据库文件) 101 | docker rm xtrafficdash 102 | 103 | # 3. 删除旧镜像(确保拉取最新镜像) 104 | docker rmi sanqi37/xtrafficdash 105 | 106 | # 4. 给权限,确保新容器可读写 107 | chmod 777 /usr/xtrafficdash/data 108 | 109 | # 5. 重新运行新容器,挂载数据库文件 110 | docker run -d \ 111 | --name xtrafficdash \ 112 | -p 37022:37022 \ 113 | -v /usr/xtrafficdash/data:/app/data \ 114 | -e TZ=Asia/Shanghai \ 115 | -e PASSWORD=admin123 \ 116 | --log-opt max-size=5m \ 117 | --log-opt max-file=3 \ 118 | --restart unless-stopped \ 119 | sanqi37/xtrafficdash 120 | 121 | ``` 122 | 123 | ## 🛠️ 技术栈 124 | 125 | ### 后端 126 | - **Go 1.21+**: 高性能后端服务 127 | - **Gin**: Web框架,支持中间件和路由 128 | - **SQLite**: 轻量级数据库,支持连接池优化 129 | - **JWT**: 身份认证和会话管理 130 | - **Logrus**: 结构化日志记录 131 | 132 | ### 前端 133 | - **Vue 3**: 渐进式JavaScript框架,使用Composition API 134 | - **Vite 6.x**: 快速构建工具,支持热重载 135 | - **Pinia**: 状态管理,替代Vuex 136 | - **Vue Router**: 客户端路由 137 | - **Chart.js**: 数据可视化图表库 138 | - **Axios**: HTTP客户端 139 | - **Tailwind CSS**: 原子化CSS框架 140 | 141 | ## 🏗️ 项目结构 142 | 143 | ``` 144 | xtrafficdash/ 145 | │ 146 | ├── backend/ # Go 后端服务 147 | │ ├── main.go # 后端主入口,静态文件服务 148 | │ ├── database/ # 数据库相关 149 | │ │ ├── api.go # API 处理 150 | │ │ ├── auth.go # JWT 认证 151 | │ │ └── database.go # 数据库连接与操作 152 | │ ├── go.mod # Go 依赖管理 153 | │ └── go.sum # Go 依赖锁定 154 | │ 155 | ├── web/ # Vue3 前端 156 | │ ├── src/ 157 | │ │ ├── assets/ # 静态资源与样式 158 | │ │ │ └── main.css 159 | │ │ ├── components/ # 通用组件 160 | │ │ │ ├── EditNameModal.vue # 改名 161 | │ │ │ └── ServiceCard.vue # 首页卡片 162 | │ │ ├── router/ # 路由配置 163 | │ │ │ └── index.js 164 | │ │ ├── stores/ # Pinia 状态管理 165 | │ │ │ ├── auth.js 166 | │ │ │ └── services.js 167 | │ │ ├── utils/ # 工具函数 168 | │ │ │ ├── api.js 169 | │ │ │ └── formatters.js 170 | │ │ ├── views/ # 页面组件 171 | │ │ │ ├── Detail.vue 172 | │ │ │ ├── Home.vue 173 | │ │ │ ├── Hy2Setting.vue 174 | │ │ │ ├── Login.vue 175 | │ │ │ ├── PortDetail.vue 176 | │ │ │ └── UserDetail.vue 177 | │ │ ├── App.vue # 根组件 178 | │ │ └── main.js # 前端入口 179 | │ ├── public/ # 公共静态资源 180 | │ │ ├── favicon.svg 181 | │ │ └── site.webmanifest 182 | │ ├── index.html # HTML 入口 183 | │ ├── package.json # 前端依赖 184 | │ ├── package-lock.json # 依赖锁定 185 | │ └── vite.config.js # Vite 配置 186 | │ 187 | ├── Dockerfile # Docker 构建文件 188 | ├── docker-compose.yml # Docker Compose 根配置 189 | └── README.md # 项目说明文档 190 | ``` 191 | 192 | ## 🛠️ 开发相关 193 | 194 | ### Docker自己编译部署 195 | 196 | ```bash 197 | # 1. 克隆项目 198 | git clone 199 | cd xtrafficdash 200 | 201 | # 2. 启动服务 202 | docker-compose up -d 203 | 204 | # 3. 访问面板 205 | # 打开浏览器访问: http://localhost:37022 206 | # 默认密码: admin123 207 | ``` 208 | 209 | ### 本地开发 210 | 211 | ```bash 212 | # 1. 启动后端 213 | cd backend 214 | export PASSWORD=admin123 215 | go run main.go 216 | 217 | # 2. 启动前端(新终端) 218 | cd web 219 | npm install 220 | npm run dev 221 | 222 | # 3. 访问前端 223 | # 打开浏览器访问: http://localhost:3000 224 | # 后端API地址: http://localhost:37022 225 | ``` 226 | 227 | ### 编译可执行文件 228 | 229 | ```bash 230 | # 编译后端服务 231 | cd backend 232 | go build -o main main.go 233 | 234 | # 或者指定输出文件名 235 | go build -o xtrafficdash main.go 236 | 237 | # 运行编译后的程序 238 | ./main 239 | # 或 240 | ./xtrafficdash 241 | ``` 242 | 243 | ## 🔧 配置说明 244 | 245 | ### 环境变量 246 | 247 | | 变量名 | 默认值 | 说明 | 248 | |--------|--------|------| 249 | | `PASSWORD` | `admin123` | 登录密码(必填) | 250 | | `TZ` | `Asia/Shanghai` | 容器/服务时区设置 | 251 | | `LISTEN_PORT` | `37022` | 服务监听端口 | 252 | | `DEBUG_MODE` | `true` | 调试模式 | 253 | | `LOG_LEVEL` | `info` | 日志级别 | 254 | | `DATABASE_PATH` | `xtrafficdash.db` | 数据库文件路径 | 255 | 256 | ### 静态文件服务 257 | 258 | 后端支持智能路径检测,自动适配不同部署环境: 259 | - **开发环境**: 从backend目录运行时使用 `../web/dist` 260 | - **项目根目录**: 从项目根目录运行时使用 `./web/dist` 261 | - **Docker环境**: 容器内使用 `/app/web/dist` 262 | 263 | 264 | ## 🔒 安全说明 265 | 266 | - 所有API接口(除登录外)都需要JWT认证 267 | - 密码通过环境变量配置,支持Docker部署 268 | - 支持CORS跨域配置 269 | - 数据库使用SQLite,数据文件可持久化 270 | 271 | 272 | ### 日志查看 273 | ```bash 274 | # 查看Docker容器日志 275 | docker-compose logs -f 276 | 277 | # 查看前端构建日志 278 | cd web && npm run build 279 | 280 | # 查看后端启动日志 281 | cd backend && go run main.go 282 | 283 | # 测试静态文件服务 284 | curl http://localhost:37022/favicon.svg 285 | curl http://localhost:37022/site.webmanifest 286 | ``` 287 | 288 | ## 📄 许可证 289 | 290 | 本项目采用 MIT 许可证。 291 | 292 | -------------------------------------------------------------------------------- /web/src/components/EditNameModal.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 106 | 107 | -------------------------------------------------------------------------------- /web/src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 141 | 142 | -------------------------------------------------------------------------------- /web/src/components/ServiceCard.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 235 | 236 | -------------------------------------------------------------------------------- /backend/go.sum: -------------------------------------------------------------------------------- 1 | github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= 2 | github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= 3 | github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= 4 | github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= 5 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= 6 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= 11 | github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= 12 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 13 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 14 | github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= 15 | github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= 16 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 17 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 18 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 19 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 20 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 21 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 22 | github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= 23 | github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 24 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 25 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 26 | github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 27 | github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 28 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 29 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 30 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 31 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 32 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 33 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 34 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 35 | github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= 36 | github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= 37 | github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= 38 | github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= 39 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 40 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 41 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 42 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 43 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 44 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 45 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 46 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 47 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 48 | github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= 49 | github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= 50 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 51 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 52 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 53 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 54 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 55 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 56 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 57 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 58 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 59 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 60 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 61 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 62 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 63 | github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= 64 | github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 65 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 66 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 67 | github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= 68 | github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 69 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 70 | golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= 71 | golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 72 | golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= 73 | golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= 74 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= 75 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 76 | golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 77 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 78 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 79 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 80 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 81 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 82 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 83 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 84 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 85 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 86 | google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= 87 | google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 88 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 89 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 90 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 91 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 92 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 93 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 94 | -------------------------------------------------------------------------------- /web/src/assets/main.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 9 | background: #F5F6FA; 10 | min-height: 100vh; 11 | color: #222; 12 | } 13 | 14 | .container { 15 | max-width: 1400px; 16 | margin: 0 auto; 17 | padding: 20px; 18 | } 19 | 20 | .header { 21 | text-align: center; 22 | margin-bottom: 40px; 23 | color: white; 24 | } 25 | 26 | .header h1 { 27 | font-size: 2.5rem; 28 | margin-bottom: 10px; 29 | text-shadow: 2px 2px 4px rgba(0,0,0,0.3); 30 | } 31 | 32 | .header p { 33 | font-size: 1.1rem; 34 | opacity: 0.9; 35 | } 36 | 37 | .cards-grid { 38 | display: grid; 39 | grid-template-columns: repeat(4, 1fr); 40 | gap: 18px; 41 | margin-bottom: 25px; 42 | } 43 | 44 | .service-card { 45 | background: white; 46 | border-radius: 12px; 47 | padding: 18px; 48 | box-shadow: 0 8px 25px rgba(0,0,0,0.15); 49 | transition: transform 0.3s ease, box-shadow 0.3s ease; 50 | cursor: pointer; 51 | position: relative; 52 | min-width: 0; 53 | } 54 | 55 | .service-card:hover { 56 | transform: translateY(-5px); 57 | box-shadow: 0 15px 40px rgba(0,0,0,0.3); 58 | } 59 | 60 | .card-header { 61 | margin-bottom: 12px; 62 | } 63 | 64 | .ip-address { 65 | font-size: 1rem; 66 | font-weight: bold; 67 | color: #2c3e50; 68 | display: flex; 69 | align-items: center; 70 | gap: 8px; 71 | } 72 | 73 | .status-badge { 74 | padding: 2px 8px; 75 | border-radius: 12px; 76 | font-size: 0.7rem; 77 | font-weight: bold; 78 | white-space: nowrap; 79 | } 80 | 81 | .status-active { 82 | background: #e8f5e8; 83 | color: #27ae60; 84 | } 85 | 86 | .status-inactive { 87 | background: #ffeaea; 88 | color: #e74c3c; 89 | } 90 | 91 | .stats-grid { 92 | display: grid; 93 | grid-template-columns: 1fr 1fr; 94 | gap: 12px; 95 | margin-bottom: 15px; 96 | } 97 | 98 | .stat-item { 99 | text-align: center; 100 | padding: 8px; 101 | background: #f8f9fa; 102 | border-radius: 6px; 103 | } 104 | 105 | .stat-value { 106 | font-size: 1.1rem; 107 | font-weight: bold; 108 | color: #2c3e50; 109 | } 110 | 111 | .stat-label { 112 | font-size: 0.85rem; 113 | color: #7f8c8d; 114 | margin-top: 4px; 115 | } 116 | 117 | .chart-container { 118 | height: 150px; 119 | position: relative; 120 | margin-top: 12px; 121 | margin-bottom: 8px; 122 | } 123 | 124 | .loading { 125 | text-align: center; 126 | padding: 40px; 127 | color: white; 128 | font-size: 1.1rem; 129 | } 130 | 131 | .error { 132 | text-align: center; 133 | padding: 40px; 134 | color: #e74c3c; 135 | background: white; 136 | border-radius: 10px; 137 | margin: 20px 0; 138 | } 139 | 140 | .back-button { 141 | position: fixed; 142 | top: 20px; 143 | left: 20px; 144 | background: rgba(255,255,255,0.9); 145 | border: none; 146 | padding: 10px 20px; 147 | border-radius: 25px; 148 | cursor: pointer; 149 | font-size: 1rem; 150 | box-shadow: 0 4px 15px rgba(0,0,0,0.2); 151 | transition: all 0.3s ease; 152 | z-index: 1000; 153 | } 154 | 155 | .back-button:hover { 156 | background: white; 157 | transform: translateY(-2px); 158 | box-shadow: 0 6px 20px rgba(0,0,0,0.3); 159 | } 160 | 161 | .delete-button { 162 | position: absolute; 163 | top: 15px; 164 | right: 15px; 165 | background: #e74c3c; 166 | color: white; 167 | border: none; 168 | width: 28px; 169 | height: 28px; 170 | border-radius: 50%; 171 | cursor: pointer; 172 | font-size: 14px; 173 | font-weight: bold; 174 | display: flex; 175 | align-items: center; 176 | justify-content: center; 177 | transition: all 0.3s ease; 178 | z-index: 10; 179 | } 180 | 181 | .delete-button:hover { 182 | background: #c0392b; 183 | transform: scale(1.1); 184 | } 185 | 186 | .traffic-tables { 187 | display: grid; 188 | grid-template-columns: 1fr 1fr; 189 | gap: 25px; 190 | } 191 | 192 | .traffic-table { 193 | background: white; 194 | border-radius: 12px; 195 | padding: 20px; 196 | box-shadow: 0 4px 15px rgba(0,0,0,0.1); 197 | } 198 | 199 | .table-title { 200 | font-size: 1.2rem; 201 | font-weight: bold; 202 | margin-bottom: 12px; 203 | color: #2c3e50; 204 | } 205 | 206 | .table-row { 207 | display: flex; 208 | justify-content: flex-end; 209 | align-items: center; 210 | padding: 12px 16px; 211 | margin-bottom: 8px; 212 | background: #f8f9fa; 213 | border-radius: 8px; 214 | border-left: 4px solid #74b9ff; 215 | transition: all 0.3s ease; 216 | cursor: pointer; 217 | gap: 20px; 218 | } 219 | 220 | .table-row:hover { 221 | transform: translateY(-2px); 222 | box-shadow: 0 4px 12px rgba(116, 185, 255, 0.2); 223 | border-left-color: #0984e3; 224 | } 225 | 226 | .table-row:last-child { 227 | margin-bottom: 0; 228 | } 229 | 230 | .table-row .table-label { 231 | flex-shrink: 0; 232 | } 233 | 234 | .table-row .table-value { 235 | flex: 1; 236 | display: flex; 237 | justify-content: flex-end; 238 | gap: 20px; 239 | align-items: center; 240 | } 241 | 242 | .table-label { 243 | font-weight: 600; 244 | color: #2d3436; 245 | font-size: 1rem; 246 | } 247 | 248 | .table-value { 249 | display: flex; 250 | gap: 20px; 251 | align-items: center; 252 | justify-content: flex-end; 253 | font-size: 0.95rem; 254 | font-weight: 500; 255 | text-align: right; 256 | } 257 | 258 | .upload-traffic { 259 | color: #74b9ff; 260 | display: flex; 261 | align-items: center; 262 | gap: 4px; 263 | } 264 | 265 | .download-traffic { 266 | color: #00b894; 267 | display: flex; 268 | align-items: center; 269 | gap: 4px; 270 | } 271 | 272 | .traffic-icon { 273 | font-size: 1.1rem; 274 | font-weight: bold; 275 | } 276 | 277 | .clickable { 278 | cursor: pointer; 279 | color: #007bff; 280 | text-decoration: underline; 281 | transition: color 0.2s; 282 | } 283 | 284 | .clickable:hover { 285 | color: #0056b3; 286 | } 287 | 288 | @media (max-width: 1200px) { 289 | .cards-grid { 290 | grid-template-columns: repeat(3, 1fr); 291 | } 292 | } 293 | 294 | @media (max-width: 900px) { 295 | .cards-grid { 296 | grid-template-columns: repeat(2, 1fr); 297 | } 298 | } 299 | 300 | @media (max-width: 768px) { 301 | .cards-grid { 302 | grid-template-columns: repeat(2, 1fr); 303 | gap: 12px; 304 | } 305 | 306 | .service-card { 307 | padding: 15px; 308 | } 309 | 310 | .chart-container { 311 | height: 120px; 312 | } 313 | 314 | .stat-value { 315 | font-size: 1rem; 316 | } 317 | 318 | .ip-address { 319 | font-size: 0.9rem; 320 | } 321 | 322 | .traffic-tables { 323 | grid-template-columns: 1fr; 324 | } 325 | 326 | .header h1 { 327 | font-size: 2rem; 328 | } 329 | } 330 | 331 | @media (max-width: 480px) { 332 | .cards-grid { 333 | grid-template-columns: 1fr; 334 | gap: 10px; 335 | } 336 | 337 | .service-card { 338 | padding: 12px; 339 | } 340 | 341 | .chart-container { 342 | height: 100px; 343 | } 344 | } 345 | 346 | /* 刷新按钮样式 */ 347 | .refresh-button { 348 | background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%); 349 | color: white; 350 | border: none; 351 | padding: 10px 20px; 352 | border-radius: 25px; 353 | cursor: pointer; 354 | font-size: 0.9rem; 355 | font-weight: 600; 356 | display: flex; 357 | align-items: center; 358 | gap: 8px; 359 | box-shadow: 0 4px 15px rgba(116, 185, 255, 0.3); 360 | transition: all 0.3s ease; 361 | position: relative; 362 | overflow: hidden; 363 | } 364 | 365 | .refresh-button::before { 366 | content: "🔄"; 367 | font-size: 1rem; 368 | transition: transform 0.3s ease; 369 | } 370 | 371 | .refresh-button:hover { 372 | transform: translateY(-2px); 373 | box-shadow: 0 6px 20px rgba(116, 185, 255, 0.4); 374 | background: linear-gradient(135deg, #0984e3 0%, #74b9ff 100%); 375 | } 376 | 377 | .refresh-button:hover::before { 378 | transform: rotate(180deg); 379 | } 380 | 381 | .refresh-button:active { 382 | transform: translateY(0); 383 | box-shadow: 0 2px 10px rgba(116, 185, 255, 0.3); 384 | } 385 | 386 | .refresh-button:disabled { 387 | background: #bdc3c7; 388 | cursor: not-allowed; 389 | transform: none; 390 | box-shadow: none; 391 | } 392 | 393 | .refresh-button:disabled::before { 394 | transform: none; 395 | animation: spin 1s linear infinite; 396 | } 397 | 398 | @keyframes spin { 399 | from { 400 | transform: rotate(0deg); 401 | } 402 | to { 403 | transform: rotate(360deg); 404 | } 405 | } 406 | 407 | /* 详情页头部样式 */ 408 | .detail-header { 409 | display: flex; 410 | justify-content: space-between; 411 | align-items: center; 412 | margin-bottom: 20px; 413 | background: white; 414 | padding: 20px; 415 | border-radius: 12px; 416 | box-shadow: 0 4px 15px rgba(0,0,0,0.1); 417 | } 418 | 419 | .detail-title { 420 | font-size: 1.3rem; 421 | font-weight: bold; 422 | color: #2c3e50; 423 | } -------------------------------------------------------------------------------- /web/src/views/Hy2Setting.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 140 | 141 | -------------------------------------------------------------------------------- /backend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | "regexp" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "xtrafficdash/database" 16 | 17 | "github.com/gin-gonic/gin" 18 | "github.com/sirupsen/logrus" 19 | ) 20 | 21 | // 配置结构体 22 | type Config struct { 23 | ListenPort int `json:"listen_port"` 24 | DebugMode bool `json:"debug_mode"` 25 | LogLevel string `json:"log_level"` 26 | DatabasePath string `json:"database_path"` 27 | } 28 | 29 | // 响应数据结构体 30 | type ResponseData struct { 31 | Success bool `json:"success"` 32 | Message string `json:"message"` 33 | Data interface{} `json:"data,omitempty"` 34 | Error string `json:"error,omitempty"` 35 | } 36 | 37 | var ( 38 | config *Config 39 | logger *logrus.Logger 40 | db *database.Database 41 | ) 42 | 43 | // 环境变量读取函数 44 | func getEnv(key, defaultValue string) string { 45 | if value := os.Getenv(key); value != "" { 46 | return value 47 | } 48 | return defaultValue 49 | } 50 | 51 | func getEnvAsInt(key string, defaultValue int) int { 52 | if value := os.Getenv(key); value != "" { 53 | if intValue, err := strconv.Atoi(value); err == nil { 54 | return intValue 55 | } 56 | } 57 | return defaultValue 58 | } 59 | 60 | func getEnvAsBool(key string, defaultValue bool) bool { 61 | if value := os.Getenv(key); value != "" { 62 | return strings.ToLower(value) == "true" 63 | } 64 | return defaultValue 65 | } 66 | 67 | func setTimezone() { 68 | tz := os.Getenv("TZ") 69 | if tz == "" { 70 | tz = "Asia/Shanghai" 71 | } 72 | loc, err := time.LoadLocation(tz) 73 | if err != nil { 74 | panic("无效的时区: " + tz) 75 | } 76 | time.Local = loc 77 | } 78 | 79 | func init() { 80 | setTimezone() 81 | // 初始化日志 82 | logger = logrus.New() 83 | logger.SetFormatter(&logrus.JSONFormatter{}) 84 | logger.SetOutput(os.Stdout) 85 | 86 | // 从环境变量读取配置 87 | config = &Config{ 88 | ListenPort: getEnvAsInt("LISTEN_PORT", 37022), 89 | DebugMode: getEnvAsBool("DEBUG_MODE", false), 90 | LogLevel: getEnv("LOG_LEVEL", "info"), 91 | DatabasePath: getEnv("DATABASE_PATH", "xtrafficdash.db"), 92 | } 93 | 94 | // 设置日志级别 95 | switch config.LogLevel { 96 | case "debug": 97 | logger.SetLevel(logrus.DebugLevel) 98 | case "info": 99 | logger.SetLevel(logrus.InfoLevel) 100 | case "warn": 101 | logger.SetLevel(logrus.WarnLevel) 102 | case "error": 103 | logger.SetLevel(logrus.ErrorLevel) 104 | default: 105 | logger.SetLevel(logrus.InfoLevel) 106 | } 107 | 108 | // 初始化JWT 109 | database.InitJWT() 110 | 111 | // 初始化数据库 112 | var err error 113 | db, err = database.OpenDatabase(config.DatabasePath) 114 | if err != nil { 115 | logger.Errorf("初始化数据库失败: %v", err) 116 | } else { 117 | logger.Info("数据库初始化成功") 118 | } 119 | 120 | // 初始化hy2配置表 121 | if db != nil { 122 | err := db.InitHy2ConfigTable() 123 | if err != nil { 124 | logger.Errorf("初始化hy2配置表失败: %v", err) 125 | } 126 | } 127 | } 128 | 129 | func main() { 130 | logger.Info("启动XTrafficDash...") 131 | logger.Infof("监听端口: %d", config.ListenPort) 132 | logger.Infof("数据库路径: %s", config.DatabasePath) 133 | 134 | // 设置Gin模式 135 | if config.DebugMode { 136 | gin.SetMode(gin.DebugMode) 137 | } else { 138 | gin.SetMode(gin.ReleaseMode) 139 | } 140 | 141 | // 创建Gin路由 142 | r := gin.New() 143 | 144 | // 使用中间件 145 | r.Use(gin.Logger()) 146 | r.Use(gin.Recovery()) 147 | r.Use(corsMiddleware()) 148 | 149 | // 设置路由 150 | setupRoutes(r) 151 | 152 | // 启动服务器 153 | addr := fmt.Sprintf("0.0.0.0:%d", config.ListenPort) 154 | logger.Infof("服务器启动在地址 %s", addr) 155 | 156 | // 启动hy2流量同步定时任务(自动执行) 157 | go startHy2SyncTask() 158 | 159 | if err := r.Run(addr); err != nil { 160 | logger.Fatalf("服务器启动失败: %v", err) 161 | } 162 | } 163 | 164 | // 设置路由 165 | func setupRoutes(r *gin.Engine) { 166 | // 健康检查 167 | r.GET("/health", healthCheck) 168 | r.GET("/api/health", healthCheck) 169 | 170 | // 认证相关路由(不需要认证) 171 | r.POST("/api/auth/login", database.HandleLogin) 172 | r.GET("/api/auth/verify", database.HandleVerifyToken) 173 | 174 | // 接收流量数据的API接口 175 | r.POST("/api/traffic", handleTraffic) 176 | 177 | // 注册数据库API路由(需要认证) 178 | if db != nil { 179 | dbAPI := database.NewDatabaseAPI(db) 180 | dbAPI.RegisterRoutes(r) 181 | } 182 | 183 | // 静态文件服务(用于前端) 184 | // 尝试多个可能的路径 185 | webDistPaths := []string{ 186 | "../web/dist", // 开发环境(从backend目录运行) 187 | "./web/dist", // 开发环境(从项目根目录运行) 188 | "/app/web/dist", // Docker环境 189 | } 190 | 191 | var webDistPath string 192 | for _, path := range webDistPaths { 193 | if _, err := os.Stat(path); err == nil { 194 | webDistPath = path 195 | logger.Infof("找到web/dist目录: %s", path) 196 | break 197 | } 198 | } 199 | 200 | if webDistPath != "" { 201 | r.Static("/assets", webDistPath+"/assets") 202 | r.StaticFile("/", webDistPath+"/index.html") 203 | r.StaticFile("/favicon.svg", webDistPath+"/favicon.svg") 204 | r.StaticFile("/site.webmanifest", webDistPath+"/site.webmanifest") 205 | } else { 206 | logger.Warn("未找到web/dist目录,静态文件服务将不可用") 207 | } 208 | 209 | // 添加调试路由 210 | r.GET("/debug", func(c *gin.Context) { 211 | c.JSON(200, gin.H{ 212 | "message": "Debug endpoint working", 213 | "time": time.Now(), 214 | "path": c.Request.URL.Path, 215 | }) 216 | }) 217 | 218 | r.GET("/api/hy2-configs", getAllHy2ConfigsHandler) 219 | r.POST("/api/hy2-configs", saveAllHy2ConfigsHandler) 220 | r.POST("/api/hy2-configs/add", addHy2ConfigHandler) 221 | r.POST("/api/hy2-configs/update", updateHy2ConfigHandler) 222 | r.DELETE("/api/hy2-configs/:id", deleteHy2ConfigHandler) 223 | 224 | // 处理所有其他静态文件请求 225 | r.NoRoute(func(c *gin.Context) { 226 | logger.Infof("NoRoute: %s", c.Request.URL.Path) 227 | // 如果不是API请求,返回index.html(用于SPA路由) 228 | if !strings.HasPrefix(c.Request.URL.Path, "/api") { 229 | c.File("/app/web/dist/index.html") 230 | } else { 231 | c.JSON(404, gin.H{"error": "API endpoint not found"}) 232 | } 233 | }) 234 | } 235 | 236 | // CORS中间件 237 | func corsMiddleware() gin.HandlerFunc { 238 | return func(c *gin.Context) { 239 | c.Header("Access-Control-Allow-Origin", "*") 240 | c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") 241 | c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") 242 | 243 | if c.Request.Method == "OPTIONS" { 244 | c.AbortWithStatus(204) 245 | return 246 | } 247 | 248 | c.Next() 249 | } 250 | } 251 | 252 | // 健康检查 253 | func healthCheck(c *gin.Context) { 254 | dbStatus := "disconnected" 255 | if db != nil { 256 | dbStatus = "connected" 257 | } 258 | 259 | c.JSON(200, ResponseData{ 260 | Success: true, 261 | Message: "服务正常运行", 262 | Data: map[string]interface{}{ 263 | "timestamp": time.Now(), 264 | "version": "2.0.0", 265 | "status": "healthy", 266 | "database": dbStatus, 267 | }, 268 | }) 269 | } 270 | 271 | // 处理流量数据的专用处理器 272 | func handleTraffic(c *gin.Context) { 273 | // 读取请求体 274 | bodyBytes, err := c.GetRawData() 275 | if err != nil { 276 | logger.Errorf("读取请求体失败: %v", err) 277 | c.JSON(400, ResponseData{ 278 | Success: false, 279 | Error: "读取请求体失败", 280 | }) 281 | return 282 | } 283 | 284 | // 优先读取 X-Real-Ip header 285 | realIP := c.GetHeader("X-Real-Ip") 286 | if realIP == "" { 287 | realIP = c.ClientIP() 288 | } 289 | 290 | // 构建请求数据 291 | requestData := map[string]interface{}{ 292 | "timestamp": time.Now(), 293 | "method": c.Request.Method, 294 | "path": c.Request.URL.Path, 295 | "headers": c.Request.Header, 296 | "query_params": c.Request.URL.Query(), 297 | "raw_body": string(bodyBytes), 298 | "client_ip": realIP, 299 | "user_agent": c.Request.UserAgent(), 300 | } 301 | 302 | // 简化日志输出 303 | logger.Infof("收到流量数据请求 - IP: %s, 数据长度: %d bytes", requestData["client_ip"], len(requestData["raw_body"].(string))) 304 | 305 | // 处理数据库存储 306 | if db != nil { 307 | // 尝试解析为流量数据 308 | var trafficData database.TrafficData 309 | if err := json.Unmarshal(bodyBytes, &trafficData); err == nil { 310 | // 成功解析为流量数据,存储到数据库 311 | err = db.ProcessTrafficData(requestData["client_ip"].(string), requestData["user_agent"].(string), requestData["raw_body"].(string), &trafficData) 312 | if err != nil { 313 | logger.Errorf("存储流量数据失败: %v", err) 314 | } else { 315 | logger.Infof("流量数据已存储到数据库") 316 | } 317 | } else { 318 | logger.Warnf("请求体不是有效的流量数据格式: %v", err) 319 | } 320 | } 321 | 322 | c.JSON(200, ResponseData{ 323 | Success: true, 324 | Message: "流量数据接收成功", 325 | Data: map[string]interface{}{ 326 | "timestamp": requestData["timestamp"], 327 | }, 328 | }) 329 | } 330 | 331 | // 获取hy2配置 332 | func getHy2ConfigHandler(c *gin.Context) { 333 | if db == nil { 334 | c.JSON(500, gin.H{"success": false, "error": "数据库未初始化"}) 335 | return 336 | } 337 | cfgs, err := db.GetAllHy2Configs() 338 | if err != nil { 339 | c.JSON(500, gin.H{"success": false, "error": err.Error()}) 340 | return 341 | } 342 | if len(cfgs) == 0 { 343 | c.JSON(404, gin.H{"success": false, "error": "未找到hy2配置"}) 344 | return 345 | } 346 | c.JSON(200, gin.H{"success": true, "data": cfgs[0]}) // 假设只有一个hy2配置 347 | } 348 | 349 | // 更新hy2配置 350 | func updateHy2ConfigHandler(c *gin.Context) { 351 | if db == nil { 352 | c.JSON(500, gin.H{"success": false, "error": "数据库未初始化"}) 353 | return 354 | } 355 | var cfg database.Hy2Config 356 | if err := c.ShouldBindJSON(&cfg); err != nil { 357 | c.JSON(400, gin.H{"success": false, "error": "参数错误: " + err.Error()}) 358 | return 359 | } 360 | err := db.UpdateHy2Config(&cfg) 361 | if err != nil { 362 | c.JSON(500, gin.H{"success": false, "error": err.Error()}) 363 | return 364 | } 365 | c.JSON(200, gin.H{"success": true, "message": "保存成功"}) 366 | } 367 | 368 | // 删除单条 369 | func deleteHy2ConfigHandler(c *gin.Context) { 370 | if db == nil { 371 | c.JSON(500, gin.H{"success": false, "error": "数据库未初始化"}) 372 | return 373 | } 374 | idStr := c.Param("id") 375 | id, err := strconv.Atoi(idStr) 376 | if err != nil { 377 | c.JSON(400, gin.H{"success": false, "error": "参数错误: id无效"}) 378 | return 379 | } 380 | err = db.DeleteHy2Config(id) 381 | if err != nil { 382 | c.JSON(500, gin.H{"success": false, "error": err.Error()}) 383 | return 384 | } 385 | c.JSON(200, gin.H{"success": true, "message": "删除成功"}) 386 | } 387 | 388 | // 获取全部hy2配置 389 | func getAllHy2ConfigsHandler(c *gin.Context) { 390 | if db == nil { 391 | c.JSON(500, gin.H{"success": false, "error": "数据库未初始化"}) 392 | return 393 | } 394 | cfgs, err := db.GetAllHy2Configs() 395 | if err != nil { 396 | c.JSON(500, gin.H{"success": false, "error": err.Error()}) 397 | return 398 | } 399 | c.JSON(200, gin.H{"success": true, "data": cfgs}) 400 | } 401 | 402 | func isValidHost(host string) bool { 403 | if host == "" { 404 | return false 405 | } 406 | // 简单IP或域名校验 407 | ipRe := regexp.MustCompile(`^([0-9]{1,3}\.){3}[0-9]{1,3}$`) 408 | domainRe := regexp.MustCompile(`^([a-zA-Z0-9\-]+\.)+[a-zA-Z]{2,}$`) 409 | return ipRe.MatchString(host) || domainRe.MatchString(host) 410 | } 411 | 412 | func isValidPort(port string) bool { 413 | p, err := strconv.Atoi(port) 414 | return err == nil && p > 0 && p <= 65535 415 | } 416 | 417 | func isValidURL(url string) bool { 418 | return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") 419 | } 420 | 421 | // 批量保存(全量覆盖) 422 | func saveAllHy2ConfigsHandler(c *gin.Context) { 423 | if db == nil { 424 | c.JSON(500, gin.H{"success": false, "error": "数据库未初始化"}) 425 | return 426 | } 427 | var cfgs []database.Hy2Config 428 | if err := c.ShouldBindJSON(&cfgs); err != nil { 429 | c.JSON(400, gin.H{"success": false, "error": "参数错误: " + err.Error()}) 430 | return 431 | } 432 | // 校验 433 | if len(cfgs) > 0 { 434 | // 统一目标地址 435 | targetURL := cfgs[0].TargetAPIURL 436 | if !isValidURL(targetURL) { 437 | c.JSON(400, gin.H{"success": false, "error": "目标API地址无效,必须以http://或https://开头"}) 438 | return 439 | } 440 | for i, cfg := range cfgs { 441 | if !isValidHost(cfg.SourceAPIHost) { 442 | c.JSON(400, gin.H{"success": false, "error": "第" + strconv.Itoa(i+1) + "行:hy2服务端IP/域名无效"}) 443 | return 444 | } 445 | if !isValidPort(cfg.SourceAPIPort) { 446 | c.JSON(400, gin.H{"success": false, "error": "第" + strconv.Itoa(i+1) + "行:hy2服务端端口无效"}) 447 | return 448 | } 449 | if strings.TrimSpace(cfg.SourceAPIPassword) == "" { 450 | c.JSON(400, gin.H{"success": false, "error": "第" + strconv.Itoa(i+1) + "行:hy2服务端密码不能为空"}) 451 | return 452 | } 453 | if cfg.TargetAPIURL != targetURL { 454 | c.JSON(400, gin.H{"success": false, "error": "所有配置的目标API地址必须一致"}) 455 | return 456 | } 457 | } 458 | } 459 | // 先清空表再插入 460 | err := db.DeleteAllHy2Configs() 461 | if err != nil { 462 | c.JSON(500, gin.H{"success": false, "error": err.Error()}) 463 | return 464 | } 465 | for _, cfg := range cfgs { 466 | db.AddHy2Config(&cfg) 467 | } 468 | c.JSON(200, gin.H{"success": true, "message": "保存成功"}) 469 | } 470 | 471 | // 新增单条 472 | func addHy2ConfigHandler(c *gin.Context) { 473 | if db == nil { 474 | c.JSON(500, gin.H{"success": false, "error": "数据库未初始化"}) 475 | return 476 | } 477 | var cfg database.Hy2Config 478 | if err := c.ShouldBindJSON(&cfg); err != nil { 479 | c.JSON(400, gin.H{"success": false, "error": "参数错误: " + err.Error()}) 480 | return 481 | } 482 | err := db.AddHy2Config(&cfg) 483 | if err != nil { 484 | c.JSON(500, gin.H{"success": false, "error": err.Error()}) 485 | return 486 | } 487 | c.JSON(200, gin.H{"success": true, "message": "添加成功"}) 488 | } 489 | 490 | // hy2流量同步定时任务(自动执行,支持多配置) 491 | func startHy2SyncTask() { 492 | for { 493 | if db == nil { 494 | time.Sleep(10 * time.Second) 495 | continue 496 | } 497 | cfgs, err := db.GetAllHy2Configs() 498 | if err != nil { 499 | logger.Errorf("读取hy2配置失败: %v", err) 500 | time.Sleep(10 * time.Second) 501 | continue 502 | } 503 | 504 | // 如果没有配置,跳过本次执行 505 | if len(cfgs) == 0 { 506 | time.Sleep(10 * time.Second) 507 | continue 508 | } 509 | 510 | // 从第一个配置中获取目标地址,所有配置共享同一个目标 511 | targetURL := cfgs[0].TargetAPIURL 512 | if targetURL == "" { 513 | logger.Warnf("[HY2] 目标地址为空,跳过本次同步") 514 | time.Sleep(10 * time.Second) 515 | continue 516 | } 517 | 518 | // 为每个配置执行同步,但都发送到同一个目标地址 519 | for _, cfg := range cfgs { 520 | // 跳过无效配置 521 | if cfg.SourceAPIHost == "" || cfg.SourceAPIPort == "" || cfg.SourceAPIPassword == "" { 522 | continue 523 | } 524 | 525 | // 创建配置副本,使用统一的目标地址 526 | syncCfg := database.Hy2Config{ 527 | ID: cfg.ID, 528 | SourceAPIPassword: cfg.SourceAPIPassword, 529 | SourceAPIHost: cfg.SourceAPIHost, 530 | SourceAPIPort: cfg.SourceAPIPort, 531 | TargetAPIURL: targetURL, // 使用统一的目标地址 532 | } 533 | 534 | go hy2SyncOnce(&syncCfg) 535 | } 536 | time.Sleep(10 * time.Second) 537 | } 538 | } 539 | 540 | // hy2流量同步单次执行逻辑 541 | func hy2SyncOnce(cfg *database.Hy2Config) { 542 | client := &http.Client{Timeout: 15 * time.Second} 543 | // 构建源API URL 544 | sourceURL := "http://" + cfg.SourceAPIHost + ":" + cfg.SourceAPIPort + "/traffic?clear=1" 545 | // 1. 拉取源API流量 546 | req, err := http.NewRequest("GET", sourceURL, nil) 547 | if err != nil { 548 | logger.Errorf("[HY2] 创建请求失败: %v", err) 549 | return 550 | } 551 | req.Header.Set("Authorization", cfg.SourceAPIPassword) 552 | resp, err := client.Do(req) 553 | if err != nil { 554 | logger.Errorf("[HY2] 请求源API失败: %v", err) 555 | return 556 | } 557 | defer resp.Body.Close() 558 | if resp.StatusCode != 200 { 559 | body, _ := io.ReadAll(resp.Body) 560 | logger.Errorf("[HY2] 源API返回状态码: %d, 响应: %s", resp.StatusCode, string(body)) 561 | return 562 | } 563 | 564 | var raw struct { 565 | User struct { 566 | Tx int64 `json:"tx"` 567 | Rx int64 `json:"rx"` 568 | } `json:"user"` 569 | } 570 | body, err := io.ReadAll(resp.Body) 571 | if err != nil { 572 | logger.Errorf("[HY2] 读取源API响应失败: %v", err) 573 | return 574 | } 575 | if err := json.Unmarshal(body, &raw); err != nil { 576 | logger.Errorf("[HY2] 解析源API响应失败: %v", err) 577 | return 578 | } 579 | logger.Infof("[HY2] 获取到流量数据: tx=%d, rx=%d", raw.User.Tx, raw.User.Rx) 580 | 581 | // 2. 转换格式 582 | postData := map[string]interface{}{ 583 | "inboundTraffics": []map[string]interface{}{ 584 | { 585 | "IsInbound": true, 586 | "IsOutbound": false, 587 | "Tag": "hysteria2", 588 | "Up": raw.User.Tx, 589 | "Down": raw.User.Rx, 590 | }, 591 | }, 592 | } 593 | jsonBytes, _ := json.Marshal(postData) 594 | 595 | // 3. POST到目标API 596 | postReq, err := http.NewRequest("POST", cfg.TargetAPIURL, bytes.NewBuffer(jsonBytes)) 597 | if err != nil { 598 | logger.Errorf("[HY2] 创建POST请求失败: %v", err) 599 | return 600 | } 601 | postReq.Header.Set("Content-Type", "application/json") 602 | // 新增:带上真实IP 603 | postReq.Header.Set("X-Real-Ip", cfg.SourceAPIHost) 604 | postResp, err := client.Do(postReq) 605 | if err != nil { 606 | logger.Errorf("[HY2] 发送POST到目标API失败: %v", err) 607 | return 608 | } 609 | defer postResp.Body.Close() 610 | if postResp.StatusCode != 200 { 611 | respBody, _ := io.ReadAll(postResp.Body) 612 | logger.Errorf("[HY2] 目标API返回状态码: %d, 响应: %s", postResp.StatusCode, string(respBody)) 613 | return 614 | } 615 | logger.Infof("[HY2] 流量数据已成功推送到目标API") 616 | } 617 | -------------------------------------------------------------------------------- /web/src/views/Detail.vue: -------------------------------------------------------------------------------- 1 | 170 | 171 | 525 | 526 | -------------------------------------------------------------------------------- /web/src/views/PortDetail.vue: -------------------------------------------------------------------------------- 1 | 171 | 172 | 502 | 503 | -------------------------------------------------------------------------------- /web/src/views/UserDetail.vue: -------------------------------------------------------------------------------- 1 | 176 | 177 | 510 | 511 | -------------------------------------------------------------------------------- /backend/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | _ "github.com/mattn/go-sqlite3" 13 | ) 14 | 15 | // 数据库结构体 16 | type Database struct { 17 | db *sql.DB 18 | } 19 | 20 | // 流量数据结构体 21 | type TrafficData struct { 22 | ClientTraffics []ClientTraffic `json:"clientTraffics"` 23 | InboundTraffics []InboundTraffic `json:"inboundTraffics"` 24 | } 25 | 26 | // 客户端流量结构体 27 | type ClientTraffic struct { 28 | ID int `json:"id"` 29 | InboundID int `json:"inboundId"` 30 | Enable bool `json:"enable"` 31 | Email string `json:"email"` 32 | Up int64 `json:"up"` 33 | Down int64 `json:"down"` 34 | ExpiryTime int64 `json:"expiryTime"` 35 | Total int64 `json:"total"` 36 | Reset int64 `json:"reset"` 37 | } 38 | 39 | // 入站流量结构体 40 | type InboundTraffic struct { 41 | IsInbound bool `json:"IsInbound"` 42 | IsOutbound bool `json:"IsOutbound"` 43 | Tag string `json:"Tag"` 44 | Up int64 `json:"Up"` 45 | Down int64 `json:"Down"` 46 | } 47 | 48 | // 服务信息结构体 49 | type Service struct { 50 | ID int `json:"id"` 51 | IPAddress string `json:"ip_address"` 52 | FirstSeen time.Time `json:"first_seen"` 53 | LastSeen time.Time `json:"last_seen"` 54 | Status string `json:"status"` 55 | } 56 | 57 | // 入站流量记录结构体 58 | type InboundTrafficRecord struct { 59 | ID int `json:"id"` 60 | ServiceID int `json:"service_id"` 61 | Tag string `json:"tag"` 62 | Port int `json:"port"` 63 | CustomName string `json:"custom_name"` 64 | Up int64 `json:"up"` 65 | Down int64 `json:"down"` 66 | LastUpdated time.Time `json:"last_updated"` 67 | Status string `json:"status"` 68 | } 69 | 70 | // 客户端流量记录结构体 71 | type ClientTrafficRecord struct { 72 | ID int `json:"id"` 73 | ServiceID int `json:"service_id"` 74 | Email string `json:"email"` 75 | CustomName string `json:"custom_name"` 76 | Up int64 `json:"up"` 77 | Down int64 `json:"down"` 78 | LastUpdated time.Time `json:"last_updated"` 79 | Status string `json:"status"` 80 | } 81 | 82 | // HY2配置结构体 83 | // 用于存储hy2主动流量同步的参数 84 | 85 | type Hy2Config struct { 86 | ID int `json:"id"` 87 | SourceAPIPassword string `json:"source_api_password"` 88 | SourceAPIHost string `json:"source_api_host"` 89 | SourceAPIPort string `json:"source_api_port"` 90 | TargetAPIURL string `json:"target_api_url"` 91 | } 92 | 93 | // 打开数据库连接 94 | func OpenDatabase(dbPath string) (*Database, error) { 95 | db, err := sql.Open("sqlite3", dbPath) 96 | if err != nil { 97 | return nil, fmt.Errorf("打开数据库失败: %v", err) 98 | } 99 | 100 | // 配置数据库连接池 101 | db.SetMaxOpenConns(25) // 最大连接数 102 | db.SetMaxIdleConns(10) // 最大空闲连接数 103 | db.SetConnMaxLifetime(5 * time.Minute) // 连接最大生命周期 104 | db.SetConnMaxIdleTime(3 * time.Minute) // 空闲连接最大生命周期 105 | 106 | // 测试连接 107 | if err := db.Ping(); err != nil { 108 | return nil, fmt.Errorf("数据库连接测试失败: %v", err) 109 | } 110 | 111 | // 设置时区为本地时间 112 | if _, err := db.Exec("PRAGMA timezone = 'local'"); err != nil { 113 | return nil, fmt.Errorf("设置时区失败: %v", err) 114 | } 115 | 116 | // 优化SQLite性能 117 | if _, err := db.Exec("PRAGMA journal_mode = WAL"); err != nil { 118 | return nil, fmt.Errorf("设置WAL模式失败: %v", err) 119 | } 120 | if _, err := db.Exec("PRAGMA synchronous = NORMAL"); err != nil { 121 | return nil, fmt.Errorf("设置同步模式失败: %v", err) 122 | } 123 | if _, err := db.Exec("PRAGMA cache_size = 10000"); err != nil { 124 | return nil, fmt.Errorf("设置缓存大小失败: %v", err) 125 | } 126 | if _, err := db.Exec("PRAGMA temp_store = MEMORY"); err != nil { 127 | return nil, fmt.Errorf("设置临时存储失败: %v", err) 128 | } 129 | 130 | // 初始化数据库表 131 | if err := initDatabase(db); err != nil { 132 | return nil, fmt.Errorf("初始化数据库失败: %v", err) 133 | } 134 | 135 | return &Database{db: db}, nil 136 | } 137 | 138 | // 关闭数据库连接 139 | func (d *Database) Close() error { 140 | return d.db.Close() 141 | } 142 | 143 | // 初始化数据库表 144 | func initDatabase(db *sql.DB) error { 145 | // 读取SQL文件内容 146 | schemaSQL := ` 147 | -- XTrafficDash 流量数据数据库表结构 148 | -- 创建时间: 2024-01-01 149 | -- 描述: 存储X-UI服务的流量数据,包括入站流量和客户端流量 150 | 151 | -- 1. 服务表 - 记录每个IP对应的X-UI服务 152 | CREATE TABLE IF NOT EXISTS services ( 153 | id INTEGER PRIMARY KEY AUTOINCREMENT, 154 | ip_address TEXT NOT NULL UNIQUE, 155 | custom_name TEXT, 156 | first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 157 | last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 158 | status TEXT DEFAULT 'active' 159 | ); 160 | 161 | -- 2. 入站流量表 - 记录每个入站端口的流量数据 162 | CREATE TABLE IF NOT EXISTS inbound_traffics ( 163 | id INTEGER PRIMARY KEY AUTOINCREMENT, 164 | service_id INTEGER NOT NULL, 165 | tag TEXT NOT NULL, 166 | port INTEGER, 167 | custom_name TEXT, 168 | last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 169 | status TEXT DEFAULT 'active' 170 | ); 171 | 172 | -- 3. 客户端流量表 - 记录每个用户的流量数据 173 | CREATE TABLE IF NOT EXISTS client_traffics ( 174 | id INTEGER PRIMARY KEY AUTOINCREMENT, 175 | service_id INTEGER NOT NULL, 176 | email TEXT NOT NULL, 177 | custom_name TEXT, 178 | last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 179 | status TEXT DEFAULT 'active' 180 | ); 181 | 182 | -- 4. 入站流量历史记录表 - 每日流量统计 183 | CREATE TABLE IF NOT EXISTS inbound_traffic_history ( 184 | id INTEGER PRIMARY KEY AUTOINCREMENT, 185 | inbound_traffic_id INTEGER NOT NULL, 186 | service_id INTEGER NOT NULL, 187 | tag TEXT NOT NULL, 188 | date DATE NOT NULL, 189 | daily_up BIGINT DEFAULT 0, 190 | daily_down BIGINT DEFAULT 0, 191 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 192 | FOREIGN KEY (inbound_traffic_id) REFERENCES inbound_traffics(id) ON DELETE CASCADE, 193 | FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE, 194 | UNIQUE(inbound_traffic_id, date) 195 | ); 196 | 197 | -- 5. 客户端流量历史记录表 - 每日流量统计 198 | CREATE TABLE IF NOT EXISTS client_traffic_history ( 199 | id INTEGER PRIMARY KEY AUTOINCREMENT, 200 | client_traffic_id INTEGER NOT NULL, 201 | service_id INTEGER NOT NULL, 202 | email TEXT NOT NULL, 203 | date DATE NOT NULL, 204 | daily_up BIGINT DEFAULT 0, 205 | daily_down BIGINT DEFAULT 0, 206 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 207 | FOREIGN KEY (client_traffic_id) REFERENCES client_traffics(id) ON DELETE CASCADE, 208 | FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE, 209 | UNIQUE(client_traffic_id, date) 210 | ); 211 | 212 | -- 6. HY2配置表 213 | CREATE TABLE IF NOT EXISTS hy2_config ( 214 | id INTEGER PRIMARY KEY AUTOINCREMENT, 215 | source_api_password TEXT NOT NULL DEFAULT '', 216 | source_api_host TEXT NOT NULL DEFAULT '', 217 | source_api_port TEXT NOT NULL DEFAULT '', 218 | target_api_url TEXT NOT NULL DEFAULT '' 219 | ); 220 | 221 | 222 | -- 创建索引 223 | CREATE INDEX IF NOT EXISTS idx_services_ip ON services(ip_address); 224 | CREATE INDEX IF NOT EXISTS idx_inbound_traffics_service_tag ON inbound_traffics(service_id, tag); 225 | CREATE INDEX IF NOT EXISTS idx_client_traffics_service_email ON client_traffics(service_id, email); 226 | CREATE INDEX IF NOT EXISTS idx_inbound_history_date ON inbound_traffic_history(date); 227 | CREATE INDEX IF NOT EXISTS idx_client_history_date ON client_traffic_history(date); 228 | ` 229 | 230 | // 执行SQL语句 231 | _, err := db.Exec(schemaSQL) 232 | return err 233 | } 234 | 235 | // 处理流量数据 236 | func (d *Database) ProcessTrafficData(clientIP string, userAgent string, requestBody string, trafficData *TrafficData) error { 237 | // 开始事务 238 | tx, err := d.db.Begin() 239 | if err != nil { 240 | return fmt.Errorf("开始事务失败: %v", err) 241 | } 242 | defer tx.Rollback() 243 | 244 | // 1. 获取或创建服务记录 245 | serviceID, err := d.getOrCreateService(tx, clientIP) 246 | if err != nil { 247 | return fmt.Errorf("获取或创建服务失败: %v", err) 248 | } 249 | 250 | // 2. 处理入站流量数据并记录有流量的端口 251 | err = d.processInboundTraffics(tx, serviceID, trafficData.InboundTraffics) 252 | if err != nil { 253 | return fmt.Errorf("处理入站流量失败: %v", err) 254 | } 255 | 256 | // 3. 处理客户端流量数据 257 | err = d.processClientTraffics(tx, serviceID, trafficData.ClientTraffics) 258 | if err != nil { 259 | return fmt.Errorf("处理客户端流量失败: %v", err) 260 | } 261 | 262 | // 4. 只要有数据包发来就更新节点最后活跃时间(包括心跳数据) 263 | err = d.updateServiceLastSeen(tx, serviceID) 264 | if err != nil { 265 | return fmt.Errorf("更新服务最后活跃时间失败: %v", err) 266 | } 267 | 268 | // 提交事务 269 | return tx.Commit() 270 | } 271 | 272 | // 获取或创建服务记录 273 | func (d *Database) getOrCreateService(tx *sql.Tx, ipAddress string) (int, error) { 274 | var serviceID int 275 | 276 | // 先尝试查找现有服务 277 | err := tx.QueryRow("SELECT id FROM services WHERE ip_address = ?", ipAddress).Scan(&serviceID) 278 | if err == sql.ErrNoRows { 279 | now := time.Now() 280 | result, err := tx.Exec(` 281 | INSERT INTO services (ip_address, custom_name, first_seen, last_seen, status) 282 | VALUES (?, ?, ?, ?, 'active') 283 | `, ipAddress, "", now, now) 284 | if err != nil { 285 | return 0, err 286 | } 287 | serviceID64, err := result.LastInsertId() 288 | if err != nil { 289 | return 0, err 290 | } 291 | serviceID = int(serviceID64) 292 | } else if err != nil { 293 | return 0, err 294 | } 295 | 296 | return serviceID, nil 297 | } 298 | 299 | // 处理入站流量数据 300 | func (d *Database) processInboundTraffics(tx *sql.Tx, serviceID int, inboundTraffics []InboundTraffic) error { 301 | var activePorts []string 302 | for _, traffic := range inboundTraffics { 303 | if !traffic.IsInbound { 304 | continue 305 | } 306 | port := d.extractPortFromTag(traffic.Tag) 307 | if traffic.Up > 0 || traffic.Down > 0 { 308 | activePorts = append(activePorts, fmt.Sprintf("端口%d(上传:%s,下载:%s)", port, d.formatBytes(traffic.Up), d.formatBytes(traffic.Down))) 309 | } 310 | // 获取或创建入站流量记录 311 | var recordID int 312 | err := tx.QueryRow(`SELECT id FROM inbound_traffics WHERE service_id = ? AND tag = ?`, serviceID, traffic.Tag).Scan(&recordID) 313 | if err == sql.ErrNoRows { 314 | now := time.Now() 315 | result, err := tx.Exec(`INSERT INTO inbound_traffics (service_id, tag, port, last_updated, status) VALUES (?, ?, ?, ?, 'active')`, serviceID, traffic.Tag, port, now) 316 | if err != nil { 317 | return err 318 | } 319 | recordID64, _ := result.LastInsertId() 320 | recordID = int(recordID64) 321 | } else if err != nil { 322 | return err 323 | } 324 | // upsert 到历史表,写入 date 用 localtime 325 | if traffic.Up > 0 || traffic.Down > 0 { 326 | _, err := tx.Exec(` 327 | INSERT INTO inbound_traffic_history (inbound_traffic_id, service_id, tag, date, daily_up, daily_down, created_at) 328 | VALUES (?, ?, ?, DATE('now', 'localtime'), ?, ?, ?) 329 | ON CONFLICT(inbound_traffic_id, date) DO UPDATE SET 330 | daily_up = daily_up + excluded.daily_up, 331 | daily_down = daily_down + excluded.daily_down 332 | `, recordID, serviceID, traffic.Tag, traffic.Up, traffic.Down, time.Now()) 333 | if err != nil { 334 | return err 335 | } 336 | // 新增:有流量时更新 last_updated 337 | _, err = tx.Exec(`UPDATE inbound_traffics SET last_updated = ? WHERE id = ?`, time.Now(), recordID) 338 | if err != nil { 339 | return err 340 | } 341 | } 342 | } 343 | if len(activePorts) > 0 { 344 | fmt.Printf("活跃端口: %s\n", strings.Join(activePorts, ", ")) 345 | } 346 | return nil 347 | } 348 | 349 | // 处理客户端流量数据 350 | func (d *Database) processClientTraffics(tx *sql.Tx, serviceID int, clientTraffics []ClientTraffic) error { 351 | for _, traffic := range clientTraffics { 352 | var recordID int 353 | err := tx.QueryRow(`SELECT id FROM client_traffics WHERE service_id = ? AND email = ?`, serviceID, traffic.Email).Scan(&recordID) 354 | if err == sql.ErrNoRows { 355 | now := time.Now() 356 | result, err := tx.Exec(`INSERT INTO client_traffics (service_id, email, last_updated, status) VALUES (?, ?, ?, 'active')`, serviceID, traffic.Email, now) 357 | if err != nil { 358 | return err 359 | } 360 | recordID64, _ := result.LastInsertId() 361 | recordID = int(recordID64) 362 | } else if err != nil { 363 | return err 364 | } 365 | // upsert 到历史表,写入 date 用 localtime 366 | if traffic.Up > 0 || traffic.Down > 0 { 367 | _, err := tx.Exec(` 368 | INSERT INTO client_traffic_history (client_traffic_id, service_id, email, date, daily_up, daily_down, created_at) 369 | VALUES (?, ?, ?, DATE('now', 'localtime'), ?, ?, ?) 370 | ON CONFLICT(client_traffic_id, date) DO UPDATE SET 371 | daily_up = daily_up + excluded.daily_up, 372 | daily_down = daily_down + excluded.daily_down 373 | `, recordID, serviceID, traffic.Email, traffic.Up, traffic.Down, time.Now()) 374 | if err != nil { 375 | return err 376 | } 377 | // 新增:有流量时更新 last_updated 378 | _, err = tx.Exec(`UPDATE client_traffics SET last_updated = ? WHERE id = ?`, time.Now(), recordID) 379 | if err != nil { 380 | return err 381 | } 382 | } 383 | } 384 | return nil 385 | } 386 | 387 | // 更新服务最后活跃时间 388 | func (d *Database) updateServiceLastSeen(tx *sql.Tx, serviceID int) error { 389 | now := time.Now() 390 | _, err := tx.Exec(` 391 | UPDATE services 392 | SET last_seen = ? 393 | WHERE id = ? 394 | `, now, serviceID) 395 | return err 396 | } 397 | 398 | // 从tag中提取端口号 399 | func (d *Database) extractPortFromTag(tag string) int { 400 | re := regexp.MustCompile(`inbound-(\d+)`) 401 | matches := re.FindStringSubmatch(tag) 402 | if len(matches) > 1 { 403 | if port, err := strconv.Atoi(matches[1]); err == nil { 404 | return port 405 | } 406 | } 407 | return 0 408 | } 409 | 410 | // 格式化字节数 411 | func (d *Database) formatBytes(bytes int64) string { 412 | const unit = 1024 413 | if bytes < unit { 414 | return fmt.Sprintf("%d B", bytes) 415 | } 416 | div, exp := int64(unit), 0 417 | for n := bytes / unit; n >= unit; n /= unit { 418 | div *= unit 419 | exp++ 420 | } 421 | return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) 422 | } 423 | 424 | // 获取服务汇总信息 425 | func (d *Database) GetServiceSummary() ([]map[string]interface{}, error) { 426 | // 一次性查询所有统计信息,避免N+1问题 427 | rows, err := d.db.Query(` 428 | SELECT 429 | s.id, 430 | s.ip_address, 431 | s.custom_name, 432 | s.last_seen, 433 | CASE 434 | WHEN (strftime('%s', 'now') - strftime('%s', s.last_seen)) <= 30 THEN 'active' 435 | ELSE 'inactive' 436 | END as status, 437 | COALESCE(it_counts.inbound_count, 0) as inbound_count, 438 | COALESCE(ct_counts.client_count, 0) as client_count, 439 | COALESCE(today_traffic.today_up, 0) as today_inbound_up, 440 | COALESCE(today_traffic.today_down, 0) as today_inbound_down 441 | FROM 442 | services s 443 | LEFT JOIN ( 444 | SELECT service_id, COUNT(id) as inbound_count FROM inbound_traffics WHERE status = 'active' GROUP BY service_id 445 | ) it_counts ON s.id = it_counts.service_id 446 | LEFT JOIN ( 447 | SELECT service_id, COUNT(id) as client_count FROM client_traffics WHERE status = 'active' GROUP BY service_id 448 | ) ct_counts ON s.id = ct_counts.service_id 449 | LEFT JOIN ( 450 | SELECT service_id, SUM(daily_up) as today_up, SUM(daily_down) as today_down FROM inbound_traffic_history WHERE date = DATE('now', 'localtime') GROUP BY service_id 451 | ) today_traffic ON s.id = today_traffic.service_id 452 | ORDER BY 453 | s.last_seen DESC; 454 | `) 455 | if err != nil { 456 | return nil, err 457 | } 458 | defer rows.Close() 459 | 460 | var results []map[string]interface{} 461 | for rows.Next() { 462 | var id int 463 | var ipAddress, lastSeen, status string 464 | var customName sql.NullString 465 | var inboundCount, clientCount int 466 | var todayInboundUp, todayInboundDown int64 467 | 468 | err := rows.Scan(&id, &ipAddress, &customName, &lastSeen, &status, &inboundCount, &clientCount, &todayInboundUp, &todayInboundDown) 469 | if err != nil { 470 | return nil, err 471 | } 472 | result := map[string]interface{}{ 473 | "id": id, 474 | "ip": ipAddress, 475 | "custom_name": customName.String, 476 | "last_seen": lastSeen, 477 | "status": status, 478 | "inbound_count": inboundCount, 479 | "client_count": clientCount, 480 | "today_inbound_up": todayInboundUp, 481 | "today_inbound_down": todayInboundDown, 482 | } 483 | results = append(results, result) 484 | } 485 | return results, nil 486 | } 487 | 488 | // 获取指定服务的详细流量信息 489 | func (d *Database) GetServiceTraffic(serviceID int) (map[string]interface{}, error) { 490 | // 获取服务基本信息 491 | var service Service 492 | var rawIPAddress string 493 | var customName sql.NullString 494 | err := d.db.QueryRow(` 495 | SELECT id, ip_address, custom_name, first_seen, last_seen, status 496 | FROM services WHERE id = ? 497 | `, serviceID).Scan(&service.ID, &rawIPAddress, &customName, 498 | &service.FirstSeen, &service.LastSeen, &service.Status) 499 | if err != nil { 500 | return nil, err 501 | } 502 | service.IPAddress = rawIPAddress 503 | 504 | // 批量查询所有入站端口的今日流量 505 | inboundTrafficMap := make(map[int]struct{ Up, Down int64 }) 506 | inboundTrafficRows, err := d.db.Query(` 507 | SELECT inbound_traffic_id, COALESCE(daily_up,0), COALESCE(daily_down,0) 508 | FROM inbound_traffic_history 509 | WHERE service_id = ? AND date = DATE('now', 'localtime') 510 | `, serviceID) 511 | if err == nil { 512 | defer inboundTrafficRows.Close() 513 | for inboundTrafficRows.Next() { 514 | var inboundID int 515 | var up, down int64 516 | if err := inboundTrafficRows.Scan(&inboundID, &up, &down); err == nil { 517 | inboundTrafficMap[inboundID] = struct{ Up, Down int64 }{up, down} 518 | } 519 | } 520 | } 521 | 522 | // 获取入站流量(基础信息) 523 | inboundRows, err := d.db.Query(` 524 | SELECT id, service_id, tag, port, custom_name, last_updated, status 525 | FROM inbound_traffics WHERE service_id = ? AND status = 'active' 526 | ORDER BY tag 527 | `, serviceID) 528 | if err != nil { 529 | return nil, err 530 | } 531 | defer inboundRows.Close() 532 | 533 | var inboundTraffics []InboundTrafficRecord 534 | for inboundRows.Next() { 535 | var record InboundTrafficRecord 536 | var customName sql.NullString 537 | err := inboundRows.Scan(&record.ID, &record.ServiceID, &record.Tag, &record.Port, &customName, 538 | &record.LastUpdated, &record.Status) 539 | if err != nil { 540 | return nil, err 541 | } 542 | record.CustomName = customName.String 543 | // 从 map 获取今日流量 544 | if v, ok := inboundTrafficMap[record.ID]; ok { 545 | record.Up = v.Up 546 | record.Down = v.Down 547 | } else { 548 | record.Up = 0 549 | record.Down = 0 550 | } 551 | inboundTraffics = append(inboundTraffics, record) 552 | } 553 | 554 | // 批量查询所有客户端的今日流量 555 | clientTrafficMap := make(map[int]struct{ Up, Down int64 }) 556 | clientTrafficRows, err := d.db.Query(` 557 | SELECT client_traffic_id, COALESCE(daily_up,0), COALESCE(daily_down,0) 558 | FROM client_traffic_history 559 | WHERE service_id = ? AND date = DATE('now', 'localtime') 560 | `, serviceID) 561 | if err == nil { 562 | defer clientTrafficRows.Close() 563 | for clientTrafficRows.Next() { 564 | var clientID int 565 | var up, down int64 566 | if err := clientTrafficRows.Scan(&clientID, &up, &down); err == nil { 567 | clientTrafficMap[clientID] = struct{ Up, Down int64 }{up, down} 568 | } 569 | } 570 | } 571 | 572 | // 获取客户端流量(基础信息) 573 | clientRows, err := d.db.Query(` 574 | SELECT id, service_id, email, custom_name, last_updated, status 575 | FROM client_traffics WHERE service_id = ? AND status = 'active' 576 | ORDER BY email 577 | `, serviceID) 578 | if err != nil { 579 | return nil, err 580 | } 581 | defer clientRows.Close() 582 | 583 | var clientTraffics []ClientTrafficRecord 584 | for clientRows.Next() { 585 | var record ClientTrafficRecord 586 | var customName sql.NullString 587 | err := clientRows.Scan(&record.ID, &record.ServiceID, &record.Email, &customName, &record.LastUpdated, &record.Status) 588 | if err != nil { 589 | return nil, err 590 | } 591 | record.CustomName = customName.String 592 | // 从 map 获取今日流量 593 | if v, ok := clientTrafficMap[record.ID]; ok { 594 | record.Up = v.Up 595 | record.Down = v.Down 596 | } else { 597 | record.Up = 0 598 | record.Down = 0 599 | } 600 | clientTraffics = append(clientTraffics, record) 601 | } 602 | 603 | result := map[string]interface{}{ 604 | "service": service, 605 | "inbound_traffics": inboundTraffics, 606 | "client_traffics": clientTraffics, 607 | } 608 | return result, nil 609 | } 610 | 611 | // 删除服务及其所有相关数据 612 | func (d *Database) DeleteService(serviceID int) error { 613 | log.Printf("开始删除服务ID: %d", serviceID) 614 | 615 | tx, err := d.db.Begin() 616 | if err != nil { 617 | return err 618 | } 619 | defer tx.Rollback() 620 | 621 | // 删除历史记录 622 | _, err = tx.Exec("DELETE FROM inbound_traffic_history WHERE service_id = ?", serviceID) 623 | if err != nil { 624 | return fmt.Errorf("删除历史记录失败: %v", err) 625 | } 626 | 627 | // 删除入站流量记录 628 | _, err = tx.Exec("DELETE FROM inbound_traffics WHERE service_id = ?", serviceID) 629 | if err != nil { 630 | return fmt.Errorf("删除入站流量记录失败: %v", err) 631 | } 632 | 633 | // 删除客户端流量记录 634 | _, err = tx.Exec("DELETE FROM client_traffics WHERE service_id = ?", serviceID) 635 | if err != nil { 636 | return fmt.Errorf("删除客户端流量记录失败: %v", err) 637 | } 638 | 639 | // 删除服务记录 640 | _, err = tx.Exec("DELETE FROM services WHERE id = ?", serviceID) 641 | if err != nil { 642 | return fmt.Errorf("删除服务记录失败: %v", err) 643 | } 644 | 645 | log.Printf("服务ID %d 删除成功", serviceID) 646 | return tx.Commit() 647 | } 648 | 649 | // 通用:处理每日流量统计 650 | func (d *Database) processDailyTraffic(tx *sql.Tx, table string, historyTable string, idField string, extraField string) error { 651 | var query string 652 | if table == "inbound_traffics" { 653 | query = `SELECT id, service_id, tag, up, down FROM inbound_traffics WHERE status = 'active'` 654 | } else if table == "client_traffics" { 655 | query = `SELECT id, service_id, email, up, down FROM client_traffics WHERE status = 'active'` 656 | } else { 657 | return fmt.Errorf("不支持的表: %s", table) 658 | } 659 | rows, err := tx.Query(query) 660 | if err != nil { 661 | return err 662 | } 663 | defer rows.Close() 664 | 665 | for rows.Next() { 666 | var id, serviceID int 667 | var extra string 668 | var dailyUp, dailyDown int64 669 | err := rows.Scan(&id, &serviceID, &extra, &dailyUp, &dailyDown) 670 | if err != nil { 671 | return err 672 | } 673 | if dailyUp > 0 || dailyDown > 0 { 674 | var insertQuery string 675 | if table == "inbound_traffics" { 676 | insertQuery = `INSERT OR REPLACE INTO inbound_traffic_history (inbound_traffic_id, service_id, tag, date, daily_up, daily_down, created_at) VALUES (?, ?, ?, DATE('now', 'localtime'), ?, ?, CURRENT_TIMESTAMP)` 677 | _, err = tx.Exec(insertQuery, id, serviceID, extra, dailyUp, dailyDown) 678 | } else { 679 | insertQuery = `INSERT OR REPLACE INTO client_traffic_history (client_traffic_id, service_id, email, date, daily_up, daily_down, created_at) VALUES (?, ?, ?, DATE('now', 'localtime'), ?, ?, CURRENT_TIMESTAMP)` 680 | _, err = tx.Exec(insertQuery, id, serviceID, extra, dailyUp, dailyDown) 681 | } 682 | if err != nil { 683 | return err 684 | } 685 | } 686 | // 清零今日流量 687 | _, err = tx.Exec("UPDATE "+table+" SET up = 0, down = 0 WHERE id = ?", id) 688 | if err != nil { 689 | return err 690 | } 691 | } 692 | return nil 693 | } 694 | 695 | // 每日流量统计任务(需要在每日0点执行) 696 | func (d *Database) DailyTrafficSummary() error { 697 | log.Println("开始执行每日流量统计...") 698 | 699 | tx, err := d.db.Begin() 700 | if err != nil { 701 | return err 702 | } 703 | defer tx.Rollback() 704 | 705 | // 处理入站流量记录 706 | err = d.processDailyTraffic(tx, "inbound_traffics", "inbound_traffic_history", "tag", "tag") 707 | if err != nil { 708 | return err 709 | } 710 | 711 | // 处理客户端流量记录 712 | err = d.processDailyTraffic(tx, "client_traffics", "client_traffic_history", "email", "email") 713 | if err != nil { 714 | return err 715 | } 716 | 717 | log.Println("每日流量统计完成") 718 | return tx.Commit() 719 | } 720 | 721 | // 创建/更新hy2配置表(支持多条配置) 722 | func (d *Database) InitHy2ConfigTable() error { 723 | sql := ` 724 | CREATE TABLE IF NOT EXISTS hy2_config ( 725 | id INTEGER PRIMARY KEY AUTOINCREMENT, 726 | source_api_password TEXT NOT NULL DEFAULT '', 727 | source_api_host TEXT NOT NULL DEFAULT '', 728 | source_api_port TEXT NOT NULL DEFAULT '', 729 | target_api_url TEXT NOT NULL DEFAULT '' 730 | ); 731 | ` 732 | _, err := d.db.Exec(sql) 733 | return err 734 | } 735 | 736 | // 获取全部hy2配置 737 | func (d *Database) GetAllHy2Configs() ([]Hy2Config, error) { 738 | rows, err := d.db.Query(`SELECT id, source_api_password, source_api_host, source_api_port, target_api_url FROM hy2_config`) 739 | if err != nil { 740 | return nil, err 741 | } 742 | defer rows.Close() 743 | var configs []Hy2Config 744 | for rows.Next() { 745 | var cfg Hy2Config 746 | err := rows.Scan(&cfg.ID, &cfg.SourceAPIPassword, &cfg.SourceAPIHost, &cfg.SourceAPIPort, &cfg.TargetAPIURL) 747 | if err != nil { 748 | return nil, err 749 | } 750 | configs = append(configs, cfg) 751 | } 752 | return configs, nil 753 | } 754 | 755 | // 新增hy2配置 756 | func (d *Database) AddHy2Config(cfg *Hy2Config) error { 757 | _, err := d.db.Exec(`INSERT INTO hy2_config (source_api_password, source_api_host, source_api_port, target_api_url) VALUES (?, ?, ?, ?)`, 758 | cfg.SourceAPIPassword, cfg.SourceAPIHost, cfg.SourceAPIPort, cfg.TargetAPIURL) 759 | return err 760 | } 761 | 762 | // 更新hy2配置 763 | func (d *Database) UpdateHy2Config(cfg *Hy2Config) error { 764 | _, err := d.db.Exec(`UPDATE hy2_config SET source_api_password=?, source_api_host=?, source_api_port=?, target_api_url=? WHERE id=?`, 765 | cfg.SourceAPIPassword, cfg.SourceAPIHost, cfg.SourceAPIPort, cfg.TargetAPIURL, cfg.ID) 766 | return err 767 | } 768 | 769 | // 删除hy2配置 770 | func (d *Database) DeleteHy2Config(id int) error { 771 | _, err := d.db.Exec(`DELETE FROM hy2_config WHERE id=?`, id) 772 | return err 773 | } 774 | 775 | // 删除全部hy2配置 776 | func (d *Database) DeleteAllHy2Configs() error { 777 | _, err := d.db.Exec("DELETE FROM hy2_config") 778 | return err 779 | } 780 | --------------------------------------------------------------------------------