59 Commits

Author SHA1 Message Date
admin 9aebfd352a debug: 添加登录调试日志
Build and Push / build (push) Failing after 16s
Docker Build / Build and Push Docker Image (push) Successful in 3m0s
2026-06-25 02:41:18 +08:00
admin 11e9d5a6c6 fix: 移除 Landing.tsx 中未使用的 Check 导入
Build and Push / build (push) Failing after 16s
Docker Build / Build and Push Docker Image (push) Successful in 3m2s
2026-06-25 02:24:41 +08:00
admin ec996c233a feat: 添加官网首页、定价和文档页面
Build and Push / build (push) Failing after 14s
Docker Build / Build and Push Docker Image (push) Failing after 12s
- 新增 Landing.tsx 官网首页,包含产品介绍和功能特性展示

- 新增 Pricing.tsx 定价页面,展示套餐功能和常见问题

- 新增 Docs.tsx 文档页面,包含文档分类和安装指南

- 调整路由结构,首页改为公开的官网页面

- 管理后台路径从 / 改为 /dashboard

- 登录成功后跳转到 /dashboard
2026-06-25 00:33:52 +08:00
admin 16b3b048bf fix: 修复登录验证和前端认证守卫 - 修复密码验证逻辑 - 修复前端获取token字段名 - 添加路由认证守卫
Build and Push / build (push) Failing after 15s
Docker Build / Build and Push Docker Image (push) Successful in 3m7s
2026-06-24 23:15:31 +08:00
admin f4a0745032 fix: 部署前强制停止并删除独立的 nuyue 容器避免端口冲突
Build and Push / build (push) Failing after 15s
Docker Build / Build and Push Docker Image (push) Successful in 3m1s
2026-06-24 14:47:33 +00:00
admin 0442e38a6b fix: 使用正确的docker compose项目名称status
Build and Push / build (push) Failing after 16s
Docker Build / Build and Push Docker Image (push) Failing after 3m0s
2026-06-24 22:27:10 +08:00
admin bf0edfd500 fix: 部署前强制释放8080端口
Build and Push / build (push) Failing after 16s
Docker Build / Build and Push Docker Image (push) Failing after 2m58s
2026-06-24 22:24:53 +08:00
admin 7f728a7d1e fix: 部署时清理孤儿容器和停止所有容器
Build and Push / build (push) Failing after 16s
Docker Build / Build and Push Docker Image (push) Failing after 3m2s
2026-06-24 22:13:08 +08:00
admin 2bcaaa1634 fix: 部署前先停止旧容器避免端口冲突
Build and Push / build (push) Failing after 15s
Docker Build / Build and Push Docker Image (push) Failing after 3m0s
2026-06-24 22:02:51 +08:00
admin 75097f4351 fix: 解决 net 包名冲突,使用别名 gopsnet
Build and Push / build (push) Failing after 16s
Docker Build / Build and Push Docker Image (push) Failing after 2m58s
2026-06-24 21:43:15 +08:00
admin 0ccd7efdad fix: 添加 agent go.sum 和 proto 文件
Build and Push / build (push) Failing after 15s
Docker Build / Build and Push Docker Image (push) Failing after 19s
2026-06-24 21:32:44 +08:00
admin 4a8c9050f6 fix: 修复 main.go 路由注册参数不匹配问题
Build and Push / build (push) Failing after 15s
Docker Build / Build and Push Docker Image (push) Failing after 14s
2026-06-24 20:56:08 +08:00
admin a00d172c21 fix: 添加缺失的 Publish 和 ListEvents 方法
Build and Push / build (push) Failing after 16s
Docker Build / Build and Push Docker Image (push) Failing after 13s
2026-06-24 20:47:35 +08:00
admin 4abbf05d22 fix: 修复编译错误 - 删除重复定义,添加缺失方法
Build and Push / build (push) Failing after 16s
Docker Build / Build and Push Docker Image (push) Failing after 14s
2026-06-24 20:41:42 +08:00
admin 553f36eb72 feat: 完善服务端核心模块 - 新增admin/alert/backup/monitor/notify/payment/subscription/user/scheduler/tgbot模块 - 完善gRPC和middleware - 修复语法错误
Build and Push / build (push) Failing after 19s
Docker Build / Build and Push Docker Image (push) Failing after 15s
2026-06-24 20:31:53 +08:00
admin a1a0c743a5 fix: 完善数据库表结构和模型定义
Build and Push / build (push) Failing after 15s
Docker Build / Build and Push Docker Image (push) Failing after 2m53s
2026-06-23 14:08:10 +00:00
admin 3898abcd4d fix: 使用 _setCodes/_setPlans 替代 setCodes/setPlans
Build and Push / build (push) Failing after 16s
Docker Build / Build and Push Docker Image (push) Successful in 2m53s
2026-06-23 13:58:33 +00:00
admin a1aa9f4581 fix: 移除 AdminPanel 未使用的 imports 和变量
Build and Push / build (push) Failing after 13s
Docker Build / Build and Push Docker Image (push) Failing after 10s
2026-06-23 13:56:28 +00:00
admin ce5f5a2696 fix: 移除所有未使用的 imports
Build and Push / build (push) Failing after 13s
Docker Build / Build and Push Docker Image (push) Failing after 11s
2026-06-23 13:52:32 +00:00
admin f84657c398 fix: 添加缺失的导入到 ProbePageConfig
Build and Push / build (push) Failing after 14s
Docker Build / Build and Push Docker Image (push) Failing after 11s
2026-06-23 13:49:08 +00:00
admin cc336576c3 fix: 移除 TypeScript unused imports
Build and Push / build (push) Failing after 13s
Docker Build / Build and Push Docker Image (push) Failing after 11s
2026-06-23 13:42:18 +00:00
admin b264356e73 fix: 更新 alert_rules 和 probe_pages 表结构
Build and Push / build (push) Failing after 12s
Docker Build / Build and Push Docker Image (push) Failing after 11s
2026-06-23 13:31:06 +00:00
admin b770e963c1 fix: 更新数据库表结构,添加 user_id 等字段
Build and Push / build (push) Failing after 14s
Docker Build / Build and Push Docker Image (push) Successful in 2m54s
2026-06-23 12:50:20 +00:00
admin 09702ceaec fix: 添加完整数据库表结构,修复前端 API 调用
Build and Push / build (push) Failing after 15s
Docker Build / Build and Push Docker Image (push) Failing after 2m48s
2026-06-23 12:36:45 +00:00
admin 2a28b1ce57 fix: 部署前先停止占用 8080 端口的容器
Build and Push / build (push) Failing after 15s
Docker Build / Build and Push Docker Image (push) Failing after 2m50s
2026-06-23 12:16:06 +00:00
admin 316e8d9906 fix: Dockerfile 内构建前端,解决 CI 缓存问题
Build and Push / build (push) Failing after 15s
Docker Build / Build and Push Docker Image (push) Failing after 2m49s
2026-06-23 12:06:14 +00:00
admin 3410ae9c8f trigger: 重新触发 CI 构建
Build and Push / build (push) Failing after 15s
Docker Build / Build and Push Docker Image (push) Failing after 2m15s
2026-06-23 11:55:21 +00:00
admin 6a6a7a19ad fix: 登录接口接收 password 字段,兼容明文密码验证
Build and Push / build (push) Failing after 15s
Docker Build / Build and Push Docker Image (push) Failing after 2m17s
2026-06-23 11:45:37 +00:00
admin 62fab1b97a fix: 业务路由始终注册,中间件动态检查安装状态
Build and Push / build (push) Failing after 15s
Docker Build / Build and Push Docker Image (push) Successful in 2m16s
解决安装完成后无法立即登录的问题
2026-06-23 11:03:37 +00:00
admin 22f21ea4d1 fix: resolve TypeScript unused variable errors
Build and Push / build (push) Failing after 15s
Docker Build / Build and Push Docker Image (push) Successful in 2m17s
2026-06-23 10:49:52 +00:00
admin d3f74112e6 fix: restore Dockerfile and fix CI workflow with frontend build
Build and Push / build (push) Failing after 13s
Docker Build / Build and Push Docker Image (push) Failing after 11s
2026-06-23 10:41:22 +00:00
admin 0b02917a05 fix: add frontend build step to Dockerfile
Build and Push Docker Image / build (push) Failing after 6s
Docker Build / Build and Push Docker Image (push) Failing after 10s
2026-06-23 09:50:16 +00:00
admin 8c6e9a5e42 feat: add CI workflow for building Docker image
Build and Push Docker Image / build (push) Failing after 2m25s
Docker Build / Build and Push Docker Image (push) Failing after 10s
2026-06-23 09:33:34 +00:00
admin 67744085f2 fix: remove backend /install route, let frontend handle it
Docker Build / Build and Push Docker Image (push) Failing after 10s
2026-06-23 09:15:47 +00:00
admin 86cdbffb22 fix: add proper Install page with step-by-step form
Docker Build / Build and Push Docker Image (push) Failing after 11s
2026-06-23 09:07:44 +00:00
admin 65118e62b2 fix: add debug logs to GetStatus
Docker Build / Build and Push Docker Image (push) Failing after 10s
2026-06-23 09:05:28 +00:00
admin 6eb9e27855 fix: return error instead of false when IsInstalled fails
Docker Build / Build and Push Docker Image (push) Failing after 11s
2026-06-23 08:49:32 +00:00
admin ca053bd2f3 fix: add debug logs for IsInstalled check
Docker Build / Build and Push Docker Image (push) Failing after 11s
2026-06-23 08:35:40 +00:00
admin 92dc194212 fix: read installed status from database in GetStatus
Docker Build / Build and Push Docker Image (push) Failing after 11s
2026-06-23 07:58:35 +00:00
admin b03cf49623 fix: check installation status from database instead of config file
Docker Build / Build and Push Docker Image (push) Failing after 10s
2026-06-23 07:53:12 +00:00
admin 592e0d14f8 feat: register all module routes and fix compilation errors
Docker Build / Build and Push Docker Image (push) Failing after 10s
2026-06-23 07:47:19 +00:00
admin 9c9b7f7e58 feat: configure shadcn/ui with Termius design spec
Docker Build / Build and Push Docker Image (push) Failing after 10s
2026-06-23 07:29:31 +00:00
admin 8fcc8e5f53 feat: complete frontend pages - Dashboard, Servers, Login, ProbePage
Docker Build / Build and Push Docker Image (push) Successful in 2m12s
2026-06-23 05:22:49 +00:00
admin 61a287cec3 fix: set correct MIME types for static assets
Docker Build / Build and Push Docker Image (push) Successful in 2m13s
2026-06-23 04:11:07 +00:00
admin 036e48b1b3 feat: embed React frontend with vite build
Docker Build / Build and Push Docker Image (push) Successful in 2m12s
2026-06-23 04:07:24 +00:00
admin 70837c9108 fix: remove unnecessary middleware, routes work directly
Docker Build / Build and Push Docker Image (push) Successful in 2m13s
2026-06-23 03:52:51 +00:00
admin 50faa12991 feat: add dashboard and login pages
Docker Build / Build and Push Docker Image (push) Successful in 2m14s
2026-06-23 03:48:59 +00:00
admin 8c89b8468e feat: redirect to install page when not installed
Docker Build / Build and Push Docker Image (push) Successful in 2m12s
2026-06-23 03:07:03 +00:00
admin 64fc59e49f fix: ensure tables exist before checking install status
Docker Build / Build and Push Docker Image (push) Successful in 2m15s
2026-06-23 02:54:18 +00:00
admin 72e366f39d fix: use persistent SQLite for install mode, create /data directory
Docker Build / Build and Push Docker Image (push) Successful in 2m10s
Release / Build and Release (push) Successful in 1m1s
2026-06-22 22:52:09 +00:00
admin 92730a7f45 fix: accept plain password in admin creation, hash server-side
Docker Build / Build and Push Docker Image (push) Successful in 2m11s
Release / Build and Release (push) Successful in 1m1s
2026-06-22 22:45:09 +00:00
admin d4d2d74811 fix: auto-create system_settings and users tables on first use
Docker Build / Build and Push Docker Image (push) Successful in 2m13s
Release / Build and Release (push) Successful in 1m0s
2026-06-22 22:31:19 +00:00
admin 530cfed686 fix: add install page HTML and handle missing table error
Docker Build / Build and Push Docker Image (push) Successful in 2m11s
Release / Build and Release (push) Successful in 1m0s
2026-06-22 22:04:31 +00:00
admin c7c10c7c17 fix: correct install handler initialization
Docker Build / Build and Push Docker Image (push) Successful in 2m10s
Release / Build and Release (push) Successful in 1m0s
2026-06-22 21:40:09 +00:00
admin ee54689fe9 fix: remove duplicate /install route registration
Docker Build / Build and Push Docker Image (push) Failing after 10s
Release / Build and Release (push) Failing after 9s
2026-06-22 21:28:15 +00:00
admin f1b01ec18f fix: enable CGO for SQLite3 in Docker build
Docker Build / Build and Push Docker Image (push) Successful in 2m12s
Release / Build and Release (push) Successful in 1m0s
2026-06-22 21:18:41 +00:00
admin d271a786d2 fix: use Go 1.24 with GOTOOLCHAIN=auto for Docker build
Docker Build / Build and Push Docker Image (push) Failing after 1m33s
Release / Build and Release (push) Successful in 1m0s
2026-06-22 21:02:28 +00:00
admin 005d0aa683 fix: update Dockerfile to Go 1.25
Docker Build / Build and Push Docker Image (push) Failing after 1m17s
Release / Build and Release (push) Successful in 1m0s
2026-06-22 20:57:36 +00:00
admin 57624f5953 feat: add Dockerfile for CI build
Docker Build / Build and Push Docker Image (push) Failing after 24s
Release / Build and Release (push) Successful in 1m2s
2026-06-22 20:50:09 +00:00
104 changed files with 14452 additions and 561 deletions
+55
View File
@@ -0,0 +1,55 @@
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build Frontend
run: |
cd web
npm ci
npm run build
mkdir -p ../server/embed/dist
cp -r dist/* ../server/embed/dist/
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: git.viaeon.com
username: ${{ secrets.PACKAGES_TOKEN }}
password: ${{ secrets.PACKAGES_TOKEN }}
- name: Build and Push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: git.viaeon.com/admin/nuyue:latest
- name: Deploy
run: |
sshpass -p '${{ secrets.DEPLOY_SSH_PASS }}' ssh -o StrictHostKeyChecking=no ${{ secrets.DEPLOY_SSH_USER }}@${{ secrets.DEPLOY_SSH_HOST }} '
# 停止所有占用 8080 端口的容器
for container in $(docker ps -q --filter "publish=8080"); do
docker stop $container && docker rm $container
done
cd /opt/ace/compose/status
docker compose pull
docker compose up -d --force-recreate
'
+1 -1
View File
@@ -133,4 +133,4 @@ jobs:
elif command -v apt-get &> /dev/null; then
apt-get update && apt-get install -y sshpass openssh-client
fi
sshpass -p "${{ secrets.DEPLOY_SSH_PASS }}" ssh -o StrictHostKeyChecking=no -p "${{ secrets.DEPLOY_SSH_PORT }}" "${{ secrets.DEPLOY_SSH_USER }}"@"${{ secrets.DEPLOY_SSH_HOST }}" "cd ${{ secrets.DEPLOY_DIR }} && docker compose pull && docker compose up -d"
sshpass -p "${{ secrets.DEPLOY_SSH_PASS }}" ssh -o StrictHostKeyChecking=no -p "${{ secrets.DEPLOY_SSH_PORT }}" "${{ secrets.DEPLOY_SSH_USER }}"@"${{ secrets.DEPLOY_SSH_HOST }}" "cd ${{ secrets.DEPLOY_DIR }} && docker stop nuyue 2>/dev/null || true && docker rm nuyue 2>/dev/null || true && docker compose -p status down --remove-orphans && docker compose -p status pull && docker compose -p status up -d"
+57
View File
@@ -0,0 +1,57 @@
# Build frontend
FROM node:20-alpine AS frontend-builder
WORKDIR /app/web
COPY web/package*.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# Build stage for Go
FROM golang:1.24-alpine AS builder
ENV GOTOOLCHAIN=auto
# Install build dependencies for SQLite3
RUN apk add --no-cache git gcc musl-dev
WORKDIR /app
# Copy go mod files
COPY server/go.mod server/go.sum ./
RUN go mod download
# Copy source code
COPY server/ ./
COPY agent/ ../agent/
# Copy frontend dist from previous stage
COPY --from=frontend-builder /app/web/dist ./embed/dist
# Build server with CGO enabled for SQLite3
RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w -linkmode external -extldflags '-static'" -o /nuyue-server ./cmd/server
# Build agent (no CGO needed)
RUN cd ../agent && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /nuyue-agent ./cmd/agent
# Runtime stage
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
# Copy binaries
COPY --from=builder /nuyue-server /app/
COPY --from=builder /nuyue-agent /app/
# Copy frontend
COPY --from=builder /app/embed/dist /app/embed/dist
# Expose ports
EXPOSE 8080 9090
# Run server
CMD ["/app/nuyue-server"]
+43
View File
@@ -0,0 +1,43 @@
# Build frontend
FROM node:20-alpine AS frontend
WORKDIR /app/web
COPY web/package*.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# Build backend
FROM golang:1.24-alpine AS backend
ENV GOTOOLCHAIN=auto
RUN apk add --no-cache git gcc musl-dev
WORKDIR /app/server
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY server/ ./
COPY agent/ ../agent/
# Copy frontend build
COPY --from=frontend /app/web/dist ./embed/dist
# Build server
RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o /nuyue-server ./cmd/server
# Build agent
RUN cd ../agent && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /nuyue-agent ./cmd/agent
# Runtime
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=backend /nuyue-server /app/
COPY --from=backend /nuyue-agent /app/
COPY --from=frontend /app/web/dist /app/embed/dist
EXPOSE 8080 9090
CMD ["/app/nuyue-server"]
+252 -58
View File
@@ -3,13 +3,27 @@ package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net"
"os"
"os/signal"
"runtime"
"syscall"
"time"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/host"
"github.com/shirou/gopsutil/v3/load"
"github.com/shirou/gopsutil/v3/mem"
gopsnet "github.com/shirou/gopsutil/v3/net"
"github.com/shirou/gopsutil/v3/process"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
// 版本信息
@@ -20,54 +34,73 @@ var (
// Config Agent 配置
type Config struct {
ServerAddr string
AgentToken string
ReportInteval int
ServerAddr string
AgentToken string
ReportInterval int
TcpingInterval int
ConfigVersion string
}
func main() {
// 打印启动信息
fmt.Printf("怒月 (Nuyue) Agent v%s\n", Version)
fmt.Printf("构建时间: %s\n\n", BuildDate)
// 解析命令行参数
serverAddr := flag.String("server", "localhost:9090", "服务端地址")
agentToken := flag.String("token", "", "Agent Token")
reportInterval := flag.Int("interval", 10, "上报间隔秒数")
flag.Parse()
// 检查必要参数
if *agentToken == "" {
log.Fatal("请提供 Agent Token: -token <token>")
}
cfg := &Config{
ServerAddr: *serverAddr,
AgentToken: *agentToken,
ReportInteval: *reportInterval,
ServerAddr: *serverAddr,
AgentToken: *agentToken,
ReportInterval: *reportInterval,
TcpingInterval: 60,
}
// 创建上下文
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 启动采集器
// 连接 gRPC 服务
conn, err := grpc.NewClient(cfg.ServerAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatalf("连接服务端失败: %v", err)
}
defer conn.Close()
// 创建采集器
collector := NewCollector()
go collector.Start(ctx, time.Duration(*reportInterval)*time.Second)
// 启动上报器
reporter := NewReporter(cfg, collector)
go reporter.Start(ctx)
// 创建上报器
reporter := NewReporter(cfg, collector, conn)
// 启动采集
go collector.Start(ctx, time.Duration(cfg.ReportInterval)*time.Second)
// 启动上报流
go reporter.StartStream(ctx)
// 启动心跳
go reporter.StartHeartbeat(ctx)
// 打印运行信息
fmt.Printf("服务端: %s\n", cfg.ServerAddr)
fmt.Printf("上报间隔: %d 秒\n\n", cfg.ReportInteval)
fmt.Printf("上报间隔: %d 秒\n\n", cfg.ReportInterval)
// 等待中断信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
fmt.Println("\n正在关闭 Agent...")
cancel()
time.Sleep(time.Second)
@@ -76,7 +109,9 @@ func main() {
// Collector 指标采集器
type Collector struct {
metrics *SystemMetrics
metrics *SystemMetrics
lastNetIO gopsnet.IOCountersStat
lastNetTime time.Time
}
// SystemMetrics 系统指标
@@ -84,10 +119,11 @@ type SystemMetrics struct {
CPUUsage float64
MemoryTotal uint64
MemoryUsed uint64
MemoryFree uint64
DiskTotal uint64
DiskUsed uint64
NetworkRx uint64
NetworkTx uint64
NetworkRx uint64 // bytes/s
NetworkTx uint64 // bytes/s
Load1 float64
Load5 float64
Load15 float64
@@ -95,6 +131,16 @@ type SystemMetrics struct {
OSInfo string
ProcessCount int
CPUTemp float64
GPUInfo []GPUInfo
}
// GPUInfo GPU 信息
type GPUInfo struct {
Name string
Temp float64
Usage float64
MemoryTotal uint64
MemoryUsed uint64
}
// NewCollector 创建采集器
@@ -108,7 +154,10 @@ func NewCollector() *Collector {
func (c *Collector) Start(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
// 初始采集
c.collect()
for {
select {
case <-ctx.Done():
@@ -121,20 +170,75 @@ func (c *Collector) Start(ctx context.Context, interval time.Duration) {
// collect 采集指标
func (c *Collector) collect() {
// TODO: 使用 gopsutil 采集真实指标
// 目前使用模拟数据
c.metrics.CPUUsage = 45.5
c.metrics.MemoryTotal = 8192
c.metrics.MemoryUsed = 4096
c.metrics.DiskTotal = 100
c.metrics.DiskUsed = 50
c.metrics.Load1 = 1.5
c.metrics.Load5 = 1.2
c.metrics.Load15 = 1.0
c.metrics.Uptime = 86400
c.metrics.OSInfo = "Linux 5.10.0 x86_64"
c.metrics.ProcessCount = 120
c.metrics.CPUTemp = 65.5
// CPU 使用率
if cpuPercent, err := cpu.Percent(time.Second, false); err == nil && len(cpuPercent) > 0 {
c.metrics.CPUUsage = cpuPercent[0]
}
// 内存信息
if vmStat, err := mem.VirtualMemory(); err == nil {
c.metrics.MemoryTotal = vmStat.Total / 1024 / 1024 // MB
c.metrics.MemoryUsed = vmStat.Used / 1024 / 1024
c.metrics.MemoryFree = vmStat.Free / 1024 / 1024
}
// 磁盘信息
if diskStat, err := disk.Usage("/"); err == nil {
c.metrics.DiskTotal = diskStat.Total / 1024 / 1024 / 1024 // GB
c.metrics.DiskUsed = diskStat.Used / 1024 / 1024 / 1024
}
// 网络流量
if netIO, err := gopsnet.IOCounters(false); err == nil && len(netIO) > 0 {
now := time.Now()
if !c.lastNetTime.IsZero() {
duration := now.Sub(c.lastNetTime).Seconds()
if duration > 0 {
c.metrics.NetworkRx = (netIO[0].BytesRecv - c.lastNetIO.BytesRecv) / uint64(duration)
c.metrics.NetworkTx = (netIO[0].BytesSent - c.lastNetIO.BytesSent) / uint64(duration)
}
}
c.lastNetIO = netIO[0]
c.lastNetTime = now
}
// 系统负载
if loadStat, err := load.Avg(); err == nil {
c.metrics.Load1 = loadStat.Load1
c.metrics.Load5 = loadStat.Load5
c.metrics.Load15 = loadStat.Load15
}
// 系统运行时间和信息
if hostInfo, err := host.Info(); err == nil {
c.metrics.Uptime = hostInfo.Uptime
c.metrics.OSInfo = fmt.Sprintf("%s %s %s", hostInfo.OS, hostInfo.KernelArch, hostInfo.KernelVersion)
}
// 进程数
if procs, err := process.Pids(); err == nil {
c.metrics.ProcessCount = len(procs)
}
// CPU 温度 (Linux)
c.metrics.CPUTemp = c.getCPUTemp()
// GPU 信息
c.metrics.GPUInfo = c.getGPUInfo()
}
// getCPUTemp 获取 CPU 温度
func (c *Collector) getCPUTemp() float64 {
// Linux 读取 thermal_zone
// 简化实现,实际需要读取 /sys/class/thermal/thermal_zone*/temp
return -1 // 不支持返回 -1
}
// getGPUInfo 获取 GPU 信息
func (c *Collector) getGPUInfo() []GPUInfo {
// GPU 信息需要调用 nvidia-smi 或 rocm-smi
// 简化实现
return nil
}
// GetMetrics 获取指标
@@ -146,43 +250,133 @@ func (c *Collector) GetMetrics() *SystemMetrics {
type Reporter struct {
cfg *Config
collector *Collector
conn *grpc.ClientConn
}
// NewReporter 创建上报器
func NewReporter(cfg *Config, collector *Collector) *Reporter {
func NewReporter(cfg *Config, collector *Collector, conn *grpc.ClientConn) *Reporter {
return &Reporter{
cfg: cfg,
collector: collector,
conn: conn,
}
}
// Start 开始上报
func (r *Reporter) Start(ctx context.Context) {
ticker := time.NewTicker(time.Duration(r.cfg.ReportInteval) * time.Second)
// StartStream 启动双向流上报
func (r *Reporter) StartStream(ctx context.Context) {
// 创建带认证的上下文
md := metadata.New(map[string]string{"authorization": r.cfg.AgentToken})
ctx = metadata.NewOutgoingContext(ctx, md)
// 创建双向流
stream, err := r.conn.NewStream(ctx, &grpc.StreamDesc{
StreamName: "ReportStream",
ServerStreams: true,
ClientStreams: true,
}, "/nuyue.AgentService/ReportStream")
if err != nil {
log.Printf("创建流失败: %v", err)
return
}
// 启动接收协程
go func() {
for {
var cmd map[string]interface{}
if err := stream.RecvMsg(&cmd); err != nil {
log.Printf("接收命令失败: %v", err)
return
}
log.Printf("收到服务端命令: %v", cmd)
// TODO: 处理服务端下发的命令
}
}()
// 定时发送指标
ticker := time.NewTicker(time.Duration(r.cfg.ReportInterval) * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := r.report(ctx); err != nil {
log.Printf("上报失败: %v", err)
metrics := r.collector.GetMetrics()
if err := r.sendMetrics(stream, metrics); err != nil {
log.Printf("发送指标失败: %v", err)
}
}
}
}
// report 上报指标
func (r *Reporter) report(ctx context.Context) error {
metrics := r.collector.GetMetrics()
// TODO: 通过 gRPC 上报到服务端
// 目前只是打印日志
log.Printf("上报指标: CPU=%.1f%%, 内存=%d/%d MB, 磁盘=%d/%d GB",
metrics.CPUUsage,
metrics.MemoryUsed, metrics.MemoryTotal,
metrics.DiskUsed, metrics.DiskTotal)
// sendMetrics 发送指标
func (r *Reporter) sendMetrics(stream grpc.ClientStream, m *SystemMetrics) error {
osInfoJSON, _ := json.Marshal(map[string]string{
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"kernel": m.OSInfo,
})
gpuInfoJSON, _ := json.Marshal(m.GPUInfo)
req := map[string]interface{}{
"agent_token": r.cfg.AgentToken,
"metrics": map[string]interface{}{
"cpu_usage": m.CPUUsage,
"memory_total": m.MemoryTotal,
"memory_used": m.MemoryUsed,
"disk_total": m.DiskTotal,
"disk_used": m.DiskUsed,
"network_rx": m.NetworkRx,
"network_tx": m.NetworkTx,
"load_1": m.Load1,
"load_5": m.Load5,
"load_15": m.Load15,
"uptime": m.Uptime,
"os_info": string(osInfoJSON),
"process_count": m.ProcessCount,
"cpu_temp": m.CPUTemp,
"gpu_info": string(gpuInfoJSON),
},
}
return stream.SendMsg(req)
}
// StartHeartbeat 启动心跳
func (r *Reporter) StartHeartbeat(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := r.heartbeat(ctx); err != nil {
log.Printf("心跳失败: %v", err)
}
}
}
}
// heartbeat 发送心跳
func (r *Reporter) heartbeat(ctx context.Context) error {
md := metadata.New(map[string]string{"authorization": r.cfg.AgentToken})
ctx = metadata.NewOutgoingContext(ctx, md)
// 简化实现,直接打印日志
log.Printf("[心跳] Agent %s 正常运行", r.cfg.AgentToken[:8])
return nil
}
}
// TCPing TCP 延迟检测
func TCPing(host string, port int) (float64, bool) {
start := time.Now()
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 5*time.Second)
if err != nil {
return -1, false
}
defer conn.Close()
return float64(time.Since(start).Milliseconds()), true
}
+21 -1
View File
@@ -1,3 +1,23 @@
module github.com/nuyue/agent
go 1.24
go 1.25.0
require (
github.com/shirou/gopsutil/v3 v3.24.5
google.golang.org/grpc v1.81.1
)
require (
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+70
View File
@@ -0,0 +1,70 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+878
View File
@@ -0,0 +1,878 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// source: proto/agent.proto
package proto
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// 系统指标
type SystemMetrics struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
CpuUsage float64 `protobuf:"fixed64,1,opt,name=cpu_usage,json=cpuUsage,proto3" json:"cpu_usage,omitempty"` // CPU 使用率 %
MemoryTotal int64 `protobuf:"varint,2,opt,name=memory_total,json=memoryTotal,proto3" json:"memory_total,omitempty"` // 内存总量 MB
MemoryUsed int64 `protobuf:"varint,3,opt,name=memory_used,json=memoryUsed,proto3" json:"memory_used,omitempty"` // 内存已用 MB
DiskTotal int64 `protobuf:"varint,4,opt,name=disk_total,json=diskTotal,proto3" json:"disk_total,omitempty"` // 磁盘总量 GB
DiskUsed int64 `protobuf:"varint,5,opt,name=disk_used,json=diskUsed,proto3" json:"disk_used,omitempty"` // 磁盘已用 GB
NetworkRx int64 `protobuf:"varint,6,opt,name=network_rx,json=networkRx,proto3" json:"network_rx,omitempty"` // 网络接收 bytes/s
NetworkTx int64 `protobuf:"varint,7,opt,name=network_tx,json=networkTx,proto3" json:"network_tx,omitempty"` // 网络发送 bytes/s
Load_1 float64 `protobuf:"fixed64,8,opt,name=load_1,json=load1,proto3" json:"load_1,omitempty"` // 1 分钟负载
Load_5 float64 `protobuf:"fixed64,9,opt,name=load_5,json=load5,proto3" json:"load_5,omitempty"` // 5 分钟负载
Load_15 float64 `protobuf:"fixed64,10,opt,name=load_15,json=load15,proto3" json:"load_15,omitempty"` // 15 分钟负载
Uptime int64 `protobuf:"varint,11,opt,name=uptime,proto3" json:"uptime,omitempty"` // 运行时间 秒
OsInfo string `protobuf:"bytes,12,opt,name=os_info,json=osInfo,proto3" json:"os_info,omitempty"` // OS 信息 JSON
ProcessCount int32 `protobuf:"varint,13,opt,name=process_count,json=processCount,proto3" json:"process_count,omitempty"` // 进程数
CpuTemp float64 `protobuf:"fixed64,14,opt,name=cpu_temp,json=cpuTemp,proto3" json:"cpu_temp,omitempty"` // CPU 温度 ℃,-1 表示不支持
Gpus []*GpuInfo `protobuf:"bytes,15,rep,name=gpus,proto3" json:"gpus,omitempty"` // GPU 信息列表
}
func (x *SystemMetrics) Reset() {
*x = SystemMetrics{}
}
func (x *SystemMetrics) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SystemMetrics) ProtoMessage() {}
func (x *SystemMetrics) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *SystemMetrics) GetCpuUsage() float64 {
if x != nil {
return x.CpuUsage
}
return 0
}
func (x *SystemMetrics) GetMemoryTotal() int64 {
if x != nil {
return x.MemoryTotal
}
return 0
}
func (x *SystemMetrics) GetMemoryUsed() int64 {
if x != nil {
return x.MemoryUsed
}
return 0
}
func (x *SystemMetrics) GetDiskTotal() int64 {
if x != nil {
return x.DiskTotal
}
return 0
}
func (x *SystemMetrics) GetDiskUsed() int64 {
if x != nil {
return x.DiskUsed
}
return 0
}
func (x *SystemMetrics) GetNetworkRx() int64 {
if x != nil {
return x.NetworkRx
}
return 0
}
func (x *SystemMetrics) GetNetworkTx() int64 {
if x != nil {
return x.NetworkTx
}
return 0
}
func (x *SystemMetrics) GetLoad_1() float64 {
if x != nil {
return x.Load_1
}
return 0
}
func (x *SystemMetrics) GetLoad_5() float64 {
if x != nil {
return x.Load_5
}
return 0
}
func (x *SystemMetrics) GetLoad_15() float64 {
if x != nil {
return x.Load_15
}
return 0
}
func (x *SystemMetrics) GetUptime() int64 {
if x != nil {
return x.Uptime
}
return 0
}
func (x *SystemMetrics) GetOsInfo() string {
if x != nil {
return x.OsInfo
}
return ""
}
func (x *SystemMetrics) GetProcessCount() int32 {
if x != nil {
return x.ProcessCount
}
return 0
}
func (x *SystemMetrics) GetCpuTemp() float64 {
if x != nil {
return x.CpuTemp
}
return 0
}
func (x *SystemMetrics) GetGpus() []*GpuInfo {
if x != nil {
return x.Gpus
}
return nil
}
// GPU 信息
type GpuInfo struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // GPU 名称
Temp float64 `protobuf:"fixed64,2,opt,name=temp,proto3" json:"temp,omitempty"` // GPU 温度 ℃
Usage float64 `protobuf:"fixed64,3,opt,name=usage,proto3" json:"usage,omitempty"` // GPU 使用率 %
MemoryTotal int64 `protobuf:"varint,4,opt,name=memory_total,json=memoryTotal,proto3" json:"memory_total,omitempty"` // 显存总量 MB
MemoryUsed int64 `protobuf:"varint,5,opt,name=memory_used,json=memoryUsed,proto3" json:"memory_used,omitempty"` // 显存已用 MB
}
func (x *GpuInfo) Reset() {
*x = GpuInfo{}
}
func (x *GpuInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GpuInfo) ProtoMessage() {}
func (x *GpuInfo) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *GpuInfo) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *GpuInfo) GetTemp() float64 {
if x != nil {
return x.Temp
}
return 0
}
func (x *GpuInfo) GetUsage() float64 {
if x != nil {
return x.Usage
}
return 0
}
func (x *GpuInfo) GetMemoryTotal() int64 {
if x != nil {
return x.MemoryTotal
}
return 0
}
func (x *GpuInfo) GetMemoryUsed() int64 {
if x != nil {
return x.MemoryUsed
}
return 0
}
// TCPing 结果
type TcpingResult struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
TargetHost string `protobuf:"bytes,1,opt,name=target_host,json=targetHost,proto3" json:"target_host,omitempty"`
TargetPort int32 `protobuf:"varint,2,opt,name=target_port,json=targetPort,proto3" json:"target_port,omitempty"`
LatencyMs float64 `protobuf:"fixed64,3,opt,name=latency_ms,json=latencyMs,proto3" json:"latency_ms,omitempty"` // 延迟 ms-1 表示超时
Success bool `protobuf:"varint,4,opt,name=success,proto3" json:"success,omitempty"`
}
func (x *TcpingResult) Reset() {
*x = TcpingResult{}
}
func (x *TcpingResult) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TcpingResult) ProtoMessage() {}
func (x *TcpingResult) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *TcpingResult) GetTargetHost() string {
if x != nil {
return x.TargetHost
}
return ""
}
func (x *TcpingResult) GetTargetPort() int32 {
if x != nil {
return x.TargetPort
}
return 0
}
func (x *TcpingResult) GetLatencyMs() float64 {
if x != nil {
return x.LatencyMs
}
return 0
}
func (x *TcpingResult) GetSuccess() bool {
if x != nil {
return x.Success
}
return false
}
type TcpingResults struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Results []*TcpingResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"`
}
func (x *TcpingResults) Reset() {
*x = TcpingResults{}
}
func (x *TcpingResults) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TcpingResults) ProtoMessage() {}
func (x *TcpingResults) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *TcpingResults) GetResults() []*TcpingResult {
if x != nil {
return x.Results
}
return nil
}
type ReportRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
AgentToken string `protobuf:"bytes,1,opt,name=agent_token,json=agentToken,proto3" json:"agent_token,omitempty"`
// Types that are assignable to Payload:
//
// *ReportRequest_Metrics
// *ReportRequest_Tcping
Payload isReportRequest_Payload `protobuf_oneof:"payload"`
}
func (x *ReportRequest) Reset() {
*x = ReportRequest{}
}
func (x *ReportRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ReportRequest) ProtoMessage() {}
func (x *ReportRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *ReportRequest) GetAgentToken() string {
if x != nil {
return x.AgentToken
}
return ""
}
func (m *ReportRequest) GetPayload() isReportRequest_Payload {
if m != nil {
return m.Payload
}
return nil
}
func (x *ReportRequest) GetMetrics() *SystemMetrics {
if x, ok := x.GetPayload().(*ReportRequest_Metrics); ok {
return x.Metrics
}
return nil
}
func (x *ReportRequest) GetTcping() *TcpingResults {
if x, ok := x.GetPayload().(*ReportRequest_Tcping); ok {
return x.Tcping
}
return nil
}
type isReportRequest_Payload interface {
isReportRequest_Payload()
}
type ReportRequest_Metrics struct {
Metrics *SystemMetrics `protobuf:"bytes,2,opt,name=metrics,proto3,oneof"`
}
type ReportRequest_Tcping struct {
Tcping *TcpingResults `protobuf:"bytes,3,opt,name=tcping,proto3,oneof"`
}
func (*ReportRequest_Metrics) isReportRequest_Payload() {}
func (*ReportRequest_Tcping) isReportRequest_Payload() {}
// ============ 服务端指令 ============
type ServerCommand struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// Types that are assignable to Command:
//
// *ServerCommand_ConfigUpdate
// *ServerCommand_Restart
// *ServerCommand_TcpingUpdate
Command isServerCommand_Command `protobuf_oneof:"command"`
}
func (x *ServerCommand) Reset() {
*x = ServerCommand{}
}
func (x *ServerCommand) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ServerCommand) ProtoMessage() {}
func (x *ServerCommand) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (m *ServerCommand) GetCommand() isServerCommand_Command {
if m != nil {
return m.Command
}
return nil
}
func (x *ServerCommand) GetConfigUpdate() *ConfigUpdateCommand {
if x, ok := x.GetCommand().(*ServerCommand_ConfigUpdate); ok {
return x.ConfigUpdate
}
return nil
}
func (x *ServerCommand) GetRestart() *RestartCommand {
if x, ok := x.GetCommand().(*ServerCommand_Restart); ok {
return x.Restart
}
return nil
}
func (x *ServerCommand) GetTcpingUpdate() *TcpingUpdateCommand {
if x, ok := x.GetCommand().(*ServerCommand_TcpingUpdate); ok {
return x.TcpingUpdate
}
return nil
}
type isServerCommand_Command interface {
isServerCommand_Command()
}
type ServerCommand_ConfigUpdate struct {
ConfigUpdate *ConfigUpdateCommand `protobuf:"bytes,1,opt,name=config_update,json=configUpdate,proto3,oneof"`
}
type ServerCommand_Restart struct {
Restart *RestartCommand `protobuf:"bytes,2,opt,name=restart,proto3,oneof"`
}
type ServerCommand_TcpingUpdate struct {
TcpingUpdate *TcpingUpdateCommand `protobuf:"bytes,3,opt,name=tcping_update,json=tcpingUpdate,proto3,oneof"`
}
func (*ServerCommand_ConfigUpdate) isServerCommand_Command() {}
func (*ServerCommand_Restart) isServerCommand_Command() {}
func (*ServerCommand_TcpingUpdate) isServerCommand_Command() {}
type ConfigUpdateCommand struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
ConfigVersion string `protobuf:"bytes,1,opt,name=config_version,json=configVersion,proto3" json:"config_version,omitempty"`
}
func (x *ConfigUpdateCommand) Reset() {
*x = ConfigUpdateCommand{}
}
func (x *ConfigUpdateCommand) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ConfigUpdateCommand) ProtoMessage() {}
func (x *ConfigUpdateCommand) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *ConfigUpdateCommand) GetConfigVersion() string {
if x != nil {
return x.ConfigVersion
}
return ""
}
type RestartCommand struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
}
func (x *RestartCommand) Reset() {
*x = RestartCommand{}
}
func (x *RestartCommand) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RestartCommand) ProtoMessage() {}
func (x *RestartCommand) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
type TcpingUpdateCommand struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Targets []*TcpingTarget `protobuf:"bytes,1,rep,name=targets,proto3" json:"targets,omitempty"`
}
func (x *TcpingUpdateCommand) Reset() {
*x = TcpingUpdateCommand{}
}
func (x *TcpingUpdateCommand) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TcpingUpdateCommand) ProtoMessage() {}
func (x *TcpingUpdateCommand) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *TcpingUpdateCommand) GetTargets() []*TcpingTarget {
if x != nil {
return x.Targets
}
return nil
}
type TcpingTarget struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
Port int32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"`
}
func (x *TcpingTarget) Reset() {
*x = TcpingTarget{}
}
func (x *TcpingTarget) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TcpingTarget) ProtoMessage() {}
func (x *TcpingTarget) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *TcpingTarget) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *TcpingTarget) GetPort() int32 {
if x != nil {
return x.Port
}
return 0
}
// ============ 配置拉取 ============
type ConfigRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
AgentToken string `protobuf:"bytes,1,opt,name=agent_token,json=agentToken,proto3" json:"agent_token,omitempty"`
CurrentConfigVersion string `protobuf:"bytes,2,opt,name=current_config_version,json=currentConfigVersion,proto3" json:"current_config_version,omitempty"`
}
func (x *ConfigRequest) Reset() {
*x = ConfigRequest{}
}
func (x *ConfigRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ConfigRequest) ProtoMessage() {}
func (x *ConfigRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *ConfigRequest) GetAgentToken() string {
if x != nil {
return x.AgentToken
}
return ""
}
func (x *ConfigRequest) GetCurrentConfigVersion() string {
if x != nil {
return x.CurrentConfigVersion
}
return ""
}
type AgentConfig struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
ConfigVersion string `protobuf:"bytes,1,opt,name=config_version,json=configVersion,proto3" json:"config_version,omitempty"`
ReportIntervalSeconds int32 `protobuf:"varint,2,opt,name=report_interval_seconds,json=reportIntervalSeconds,proto3" json:"report_interval_seconds,omitempty"`
TcpingIntervalSeconds int32 `protobuf:"varint,3,opt,name=tcping_interval_seconds,json=tcpingIntervalSeconds,proto3" json:"tcping_interval_seconds,omitempty"`
TcpingTargets []*TcpingTarget `protobuf:"bytes,4,rep,name=tcping_targets,json=tcpingTargets,proto3" json:"tcping_targets,omitempty"`
ConfigChanged bool `protobuf:"varint,5,opt,name=config_changed,json=configChanged,proto3" json:"config_changed,omitempty"`
}
func (x *AgentConfig) Reset() {
*x = AgentConfig{}
}
func (x *AgentConfig) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AgentConfig) ProtoMessage() {}
func (x *AgentConfig) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *AgentConfig) GetConfigVersion() string {
if x != nil {
return x.ConfigVersion
}
return ""
}
func (x *AgentConfig) GetReportIntervalSeconds() int32 {
if x != nil {
return x.ReportIntervalSeconds
}
return 0
}
func (x *AgentConfig) GetTcpingIntervalSeconds() int32 {
if x != nil {
return x.TcpingIntervalSeconds
}
return 0
}
func (x *AgentConfig) GetTcpingTargets() []*TcpingTarget {
if x != nil {
return x.TcpingTargets
}
return nil
}
func (x *AgentConfig) GetConfigChanged() bool {
if x != nil {
return x.ConfigChanged
}
return false
}
// ============ 心跳 ============
type HeartbeatRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
AgentToken string `protobuf:"bytes,1,opt,name=agent_token,json=agentToken,proto3" json:"agent_token,omitempty"`
}
func (x *HeartbeatRequest) Reset() {
*x = HeartbeatRequest{}
}
func (x *HeartbeatRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HeartbeatRequest) ProtoMessage() {}
func (x *HeartbeatRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *HeartbeatRequest) GetAgentToken() string {
if x != nil {
return x.AgentToken
}
return ""
}
type HeartbeatResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
ConfigVersion string `protobuf:"bytes,2,opt,name=config_version,json=configVersion,proto3" json:"config_version,omitempty"`
}
func (x *HeartbeatResponse) Reset() {
*x = HeartbeatResponse{}
}
func (x *HeartbeatResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HeartbeatResponse) ProtoMessage() {}
func (x *HeartbeatResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *HeartbeatResponse) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
func (x *HeartbeatResponse) GetConfigVersion() string {
if x != nil {
return x.ConfigVersion
}
return ""
}
var File_proto_agent_proto protoreflect.FileDescriptor
var file_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 14)
var file_proto_agent_proto_goTypes = []interface{}{
(*SystemMetrics)(nil), // 0: nuyue.SystemMetrics
(*GpuInfo)(nil), // 1: nuyue.GpuInfo
(*TcpingResult)(nil), // 2: nuyue.TcpingResult
(*TcpingResults)(nil), // 3: nuyue.TcpingResults
(*ReportRequest)(nil), // 4: nuyue.ReportRequest
(*ServerCommand)(nil), // 5: nuyue.ServerCommand
(*ConfigUpdateCommand)(nil), // 6: nuyue.ConfigUpdateCommand
(*RestartCommand)(nil), // 7: nuyue.RestartCommand
(*TcpingUpdateCommand)(nil), // 8: nuyue.TcpingUpdateCommand
(*TcpingTarget)(nil), // 9: nuyue.TcpingTarget
(*ConfigRequest)(nil), // 10: nuyue.ConfigRequest
(*AgentConfig)(nil), // 11: nuyue.AgentConfig
(*HeartbeatRequest)(nil), // 12: nuyue.HeartbeatRequest
(*HeartbeatResponse)(nil), // 13: nuyue.HeartbeatResponse
}
func init() { file_proto_agent_proto_init() }
func file_proto_agent_proto_init() {
// Message initialization is done in the generated file
}
+194
View File
@@ -0,0 +1,194 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
package proto
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// AgentServiceClient is the client API for AgentService service.
type AgentServiceClient interface {
// 双向流:Agent 上报指标,服务端下发指令
ReportStream(ctx context.Context, opts ...grpc.CallOption) (AgentService_ReportStreamClient, error)
// Agent 拉取配置
FetchConfig(ctx context.Context, in *ConfigRequest, opts ...grpc.CallOption) (*AgentConfig, error)
// 心跳(轻量级保活)
Heartbeat(ctx context.Context, in *HeartbeatRequest, opts ...grpc.CallOption) (*HeartbeatResponse, error)
}
type agentServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAgentServiceClient(cc grpc.ClientConnInterface) AgentServiceClient {
return &agentServiceClient{cc}
}
func (c *agentServiceClient) ReportStream(ctx context.Context, opts ...grpc.CallOption) (AgentService_ReportStreamClient, error) {
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[0], "/nuyue.AgentService/ReportStream", opts...)
if err != nil {
return nil, err
}
x := &agentServiceReportStreamClient{stream}
return x, nil
}
type AgentService_ReportStreamClient interface {
Send(*ReportRequest) error
Recv() (*ServerCommand, error)
grpc.ClientStream
}
type agentServiceReportStreamClient struct {
grpc.ClientStream
}
func (x *agentServiceReportStreamClient) Send(m *ReportRequest) error {
return x.ClientStream.SendMsg(m)
}
func (x *agentServiceReportStreamClient) Recv() (*ServerCommand, error) {
m := new(ServerCommand)
if err := x.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
func (c *agentServiceClient) FetchConfig(ctx context.Context, in *ConfigRequest, opts ...grpc.CallOption) (*AgentConfig, error) {
out := new(AgentConfig)
err := c.cc.Invoke(ctx, "/nuyue.AgentService/FetchConfig", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *agentServiceClient) Heartbeat(ctx context.Context, in *HeartbeatRequest, opts ...grpc.CallOption) (*HeartbeatResponse, error) {
out := new(HeartbeatResponse)
err := c.cc.Invoke(ctx, "/nuyue.AgentService/Heartbeat", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// AgentServiceServer is the server API for AgentService service.
type AgentServiceServer interface {
// 双向流:Agent 上报指标,服务端下发指令
ReportStream(AgentService_ReportStreamServer) error
// Agent 拉取配置
FetchConfig(context.Context, *ConfigRequest) (*AgentConfig, error)
// 心跳(轻量级保活)
Heartbeat(context.Context, *HeartbeatRequest) (*HeartbeatResponse, error)
}
// UnimplementedAgentServiceServer can be embedded to have forward compatible implementations.
type UnimplementedAgentServiceServer struct {
}
func (UnimplementedAgentServiceServer) ReportStream(AgentService_ReportStreamServer) error {
return status.Errorf(codes.Unimplemented, "method ReportStream not implemented")
}
func (UnimplementedAgentServiceServer) FetchConfig(context.Context, *ConfigRequest) (*AgentConfig, error) {
return nil, status.Errorf(codes.Unimplemented, "method FetchConfig not implemented")
}
func (UnimplementedAgentServiceServer) Heartbeat(context.Context, *HeartbeatRequest) (*HeartbeatResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Heartbeat not implemented")
}
func RegisterAgentServiceServer(s grpc.ServiceRegistrar, srv AgentServiceServer) {
s.RegisterService(&AgentService_ServiceDesc, srv)
}
func _AgentService_FetchConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServiceServer).FetchConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/nuyue.AgentService/FetchConfig",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServiceServer).FetchConfig(ctx, req.(*ConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AgentService_Heartbeat_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HeartbeatRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServiceServer).Heartbeat(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/nuyue.AgentService/Heartbeat",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServiceServer).Heartbeat(ctx, req.(*HeartbeatRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AgentService_ReportStream_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(AgentServiceServer).ReportStream(&agentServiceReportStreamServer{stream})
}
type AgentService_ReportStreamServer interface {
Send(*ServerCommand) error
Recv() (*ReportRequest, error)
grpc.ServerStream
}
type agentServiceReportStreamServer struct {
grpc.ServerStream
}
func (x *agentServiceReportStreamServer) Send(m *ServerCommand) error {
return x.ServerStream.SendMsg(m)
}
func (x *agentServiceReportStreamServer) Recv() (*ReportRequest, error) {
m := new(ReportRequest)
if err := x.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// AgentService_ServiceDesc is the grpc.ServiceDesc for AgentService service.
var AgentService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "nuyue.AgentService",
HandlerType: (*AgentServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "FetchConfig",
Handler: _AgentService_FetchConfig_Handler,
},
{
MethodName: "Heartbeat",
Handler: _AgentService_Heartbeat_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "ReportStream",
Handler: _AgentService_ReportStream_Handler,
ServerStreams: true,
ClientStreams: true,
},
},
Metadata: "proto/agent.proto",
}
+132 -15
View File
@@ -24,21 +24,138 @@
---
## 2. 技术选型
| 组件 | 技术方案 | 说明 |
|------|---------|------|
| 服务端框架 | Go (Gin) | 高性能、gRPC 原生支持 |
| 客户端 | Go | 单二进制、跨平台、低资源占用 |
| 通信协议 | gRPC + Protobuf | 双向流、高效序列化 |
|| 数据库 | PostgreSQL (生产) / SQLite (单机) | 主数据存储,SQLite 适合小型部署,零依赖 |
|| 缓存 | Redis (生产可选) | 配置缓存、限流;小部署可用内存缓存替代,Redis 非必选 |
| 前端 | Vue 3 + TypeScript | 管理后台 + 用户面板 |
| UI 组件库 | Naive UI | 遵循 Termius 设计规范:深色终端风格 + 绿色品牌色 + JetBrains Mono 字体 |
| 探针页面 | Nuxt 3 (SSR) | 用户公开监控页面,支持自定义 |
| 消息通知 | Telegram Bot API | 告警通知 |
| 定时任务 | Go 内置 ticker + cron | 客户端本地调度 |
| 对象存储 | S3 兼容 | 备份文件存储 |
## 2. 技术选型
| 组件 | 技术方案 | 说明 |
|------|---------|------|
| 服务端框架 | Go (Gin) | 高性能、gRPC 原生支持 |
| 客户端 | Go | 单二进制、跨平台、低资源占用 |
| 通信协议 | gRPC + Protobuf | 双向流、高效序列化 |
| 数据库 | PostgreSQL (生产) / SQLite (单机) | 主数据存储,SQLite 适合小型部署,零依赖 |
| 缓存 | Redis (生产可选) | 配置缓存、限流;小部署可用内存缓存替代,Redis 非必选 |
| 前端 | React 19 + TypeScript | 管理后台 + 用户面板 |
| UI 组件库 | shadcn/ui | 基于 Radix UI 的无样式组件,完全可定制 |
| 样式方案 | Tailwind CSS | 原子化 CSS,遵循 Termius 设计规范 |
| 探针页面 | React 19 (SPA) | 用户公开监控页面,支持自定义主题 |
| 消息通知 | Telegram Bot API | 告警通知 |
| 定时任务 | Go 内置 ticker + cron | 客户端本地调度 |
| 对象存储 | S3 兼容 | 备份文件存储 |
### 2.1 前端设计规范 (Termius 风格)
**核心原则**:深色终端风格 + 绿色品牌色 + JetBrains Mono 等宽字体
#### 2.1.1 配色方案
```css
/* Termius 深色主题 */
:root {
/* 背景层级 */
--background: 220 13% 9%; /* #0d1117 - 主背景 */
--foreground: 220 13% 91%; /* #e6edf3 - 主文字 */
/* 卡片/面板 */
--card: 220 13% 11%; /* #161b22 */
--card-foreground: 220 13% 91%;
/* 弹出层 */
--popover: 220 13% 11%;
--popover-foreground: 220 13% 91%;
/* 主色调 - 绿色 */
--primary: 142 71% 45%; /* #4ade80 - Termius 绿 */
--primary-foreground: 220 13% 9%;
/* 次要 */
--secondary: 220 13% 15%; /* #21262d */
--secondary-foreground: 220 13% 91%;
/* 静音 */
--muted: 220 13% 15%;
--muted-foreground: 215 16% 47%; /* #8b949e */
/* 强调 */
--accent: 142 71% 45%;
--accent-foreground: 220 13% 9%;
/* 状态色 */
--destructive: 0 72% 51%; /* #f85149 */
--warning: 38 92% 50%; /* #fbbf24 */
--success: 142 71% 45%; /* #4ade80 */
/* 边框 */
--border: 220 13% 18%; /* #30363d */
--input: 220 13% 18%;
--ring: 142 71% 45%;
/* 圆角 */
--radius: 0.5rem;
}
```
#### 2.1.2 字体
```css
/* 代码/终端风格 - 主字体 */
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Menlo', monospace;
/* UI 文字 - 次要字体 */
font-family: 'Inter', 'SF Pro Text', -apple-system, sans-serif;
```
#### 2.1.3 组件规范
| 组件 | 样式 |
|------|------|
| 背景 | 深色 `#0d1117`,卡片 `#161b22` |
| 边框 | 细边框 `1px`,颜色 `#30363d` |
| 圆角 | 统一 `0.5rem` (8px) |
| 按钮 | 主按钮绿色填充,次要按钮边框 |
| 输入框 | 深色背景,绿色 focus 边框 |
| 状态指示 | 在线绿色,离线红色,警告黄色 |
| 进度条 | 绿色填充,深色轨道 |
| 代码块 | 等宽字体,深色背景 |
#### 2.1.4 shadcn/ui 配置
```typescript
// components.json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
```
#### 2.1.5 页面结构
```
┌─────────────────────────────────────────────────────────┐
│ 侧边栏 (w-60) │ 主内容区 │
│ ┌─────────────────────┐ │ ┌──────────────────────┐ │
│ │ Logo: 怒月 │ │ │ Header (h-16) │ │
│ ├─────────────────────┤ │ ├──────────────────────┤ │
│ │ 📊 控制台 │ │ │ │ │
│ │ 🖥️ 服务器 │ │ │ Content │ │
│ │ 🔔 告警 │ │ │ │ │
│ │ 🌐 探针页面 │ │ │ │ │
│ │ ⚙️ 设置 │ │ │ │ │
│ ├─────────────────────┤ │ └──────────────────────┘ │
│ │ 退出登录 │ │ │
│ └─────────────────────┘ │ │
└─────────────────────────────────────────────────────────┘
```
---
+129 -22
View File
@@ -4,6 +4,7 @@ package main
import (
"context"
"fmt"
"io/fs"
"log"
"net/http"
"os"
@@ -14,10 +15,19 @@ import (
"github.com/gin-gonic/gin"
"github.com/nuyue/server/internal/config"
"github.com/nuyue/server/internal/mod/alert"
"github.com/nuyue/server/internal/mod/auth"
"github.com/nuyue/server/internal/mod/install"
"github.com/nuyue/server/internal/mod/plan"
"github.com/nuyue/server/internal/mod/probepage"
"github.com/nuyue/server/internal/mod/server"
"github.com/nuyue/server/internal/mod/setting"
"github.com/nuyue/server/pkg/database"
"github.com/nuyue/server/pkg/jwt"
"github.com/nuyue/server/pkg/response"
"gorm.io/gorm"
"github.com/nuyue/server/embed"
)
// 版本信息
@@ -54,10 +64,14 @@ func main() {
log.Fatalf("初始化数据库失败: %v", err)
}
} else {
// 安装模式:使用内存数据库或临时文件
// 安装模式:使用临时 SQLite 文件(持久化)
// 确保数据目录存在
if err := os.MkdirAll("/data", 0755); err != nil {
log.Fatalf("创建数据目录失败: %v", err)
}
db, err = database.New(&database.DatabaseConfig{
Type: "sqlite",
DSNValue: ":memory:",
DSNValue: "/data/nuyue.db",
})
if err != nil {
log.Fatalf("初始化临时数据库失败: %v", err)
@@ -192,40 +206,133 @@ func setupRouter(app *Application) *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
// 安装状态检查器
installRepo := install.NewRepository(app.DB)
// 健康检查
r.GET("/health", func(c *gin.Context) {
ctx := c.Request.Context()
isInstalled, _ := installRepo.IsInstalled(ctx)
response.Success(c, gin.H{
"status": "ok",
"version": Version,
"installed": app.Config.IsInstalled(),
"installed": isInstalled,
})
})
// API v1
if !app.Config.IsInstalled() {
// 安装向导(未安装时可用)
installRepo := install.NewRepository(app.DB)
// API 路由
api := r.Group("/api/v1")
{
// 安装 API(始终可用)
installSvc := install.NewService(installRepo, "./config.yaml")
installHandler := install.NewHandler(installSvc)
install.RegisterRoutes(r, installHandler)
install.RegisterAPIRoutes(api, installHandler)
}
// 已安装后的 API
if app.Config.IsInstalled() {
// TODO: 添加其他模块路由
}
// 安装页面
r.GET("/install", func(c *gin.Context) {
if app.Config.IsInstalled() {
c.Redirect(http.StatusFound, "/login")
// 业务路由(始终注册,但在中间件中检查安装状态)
// 安装检查中间件
installCheckMiddleware := func(c *gin.Context) {
// 跳过安装相关路由
path := c.Request.URL.Path
if len(path) >= len("/api/v1/install") && path[:len("/api/v1/install")] == "/api/v1/install" {
c.Next()
return
}
// TODO: 返回前端安装页面
c.JSON(http.StatusOK, gin.H{
"message": "安装向导页面(前端待实现)",
})
isInstalled, _ := installRepo.IsInstalled(c.Request.Context())
if !isInstalled {
response.Forbidden(c, "系统未安装,请先完成安装")
c.Abort()
return
}
c.Next()
}
api.Use(installCheckMiddleware)
// Auth 模块
authRepo := auth.NewRepository(app.DB)
jwtSvc := jwt.NewJWTService(&jwt.JWTConfig{
Secret: app.Config.JWT.Secret,
AccessExpire: app.Config.JWT.AccessExpire,
RefreshExpire: app.Config.JWT.RefreshExpire,
})
authSvc := auth.NewService(authRepo, jwtSvc)
authHandler := auth.NewHandler(authSvc)
auth.RegisterRoutes(api, authHandler)
// Server 模块
serverRepo := server.NewRepository(app.DB)
serverSvc := server.NewService(serverRepo)
serverHandler := server.NewHandler(serverSvc)
server.RegisterRoutes(api, serverHandler, jwtSvc)
// Alert 模块
alertSvc := alert.NewService(app.DB)
alertHandler := alert.NewHandler(alertSvc)
alert.RegisterRoutes(api, alertHandler, jwtSvc)
// ProbePage 模块
probeRepo := probepage.NewRepository(app.DB)
probeSvc := probepage.NewService(probeRepo)
probeHandler := probepage.NewHandler(probeSvc)
probepage.RegisterRoutes(api, probeHandler, jwtSvc)
// Setting 模块
settingRepo := setting.NewRepository(app.DB)
settingSvc := setting.NewService(settingRepo)
settingHandler := setting.NewHandler(settingSvc)
setting.RegisterRoutes(api, settingHandler)
// Plan 模块
planRepo := plan.NewRepository(app.DB)
planSvc := plan.NewService(planRepo)
planHandler := plan.NewHandler(planSvc)
plan.RegisterRoutes(api, planHandler)
// 静态文件服务(前端 SPA
distFS, _ := fs.Sub(embed.DistFS, "dist")
r.NoRoute(func(c *gin.Context) {
// 尝试读取静态文件
path := c.Request.URL.Path
if path == "/" {
path = "/index.html"
}
data, err := fs.ReadFile(distFS, path[1:])
if err == nil {
// 根据 extension 设置 Content-Type
contentType := "text/html; charset=utf-8"
switch {
case len(path) > 3 && path[len(path)-3:] == ".js":
contentType = "application/javascript"
case len(path) > 4 && path[len(path)-4:] == ".css":
contentType = "text/css"
case len(path) > 5 && path[len(path)-5:] == ".json":
contentType = "application/json"
case len(path) > 4 && path[len(path)-4:] == ".svg":
contentType = "image/svg+xml"
case len(path) > 4 && path[len(path)-4:] == ".png":
contentType = "image/png"
case len(path) > 4 && path[len(path)-4:] == ".jpg":
contentType = "image/jpeg"
case len(path) > 5 && path[len(path)-5:] == ".woff":
contentType = "font/woff"
case len(path) > 6 && path[len(path)-6:] == ".woff2":
contentType = "font/woff2"
}
c.Data(http.StatusOK, contentType, data)
return
}
// SPA fallback - 返回 index.html
indexData, err := fs.ReadFile(distFS, "index.html")
if err != nil {
c.String(http.StatusNotFound, "404 page not found")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", indexData)
})
return r
}
}
+7
View File
@@ -0,0 +1,7 @@
// Package embed 静态文件嵌入
package embed
import "embed"
//go:embed all:dist
var DistFS embed.FS
+286 -38
View File
@@ -3,29 +3,282 @@ package grpc
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"sync"
"time"
"github.com/google/uuid"
"github.com/nuyue/server/internal/mod/server"
"github.com/nuyue/server/proto"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"gorm.io/gorm"
)
// AgentServer Agent gRPC 服务
type AgentServer struct {
proto.UnimplementedAgentServiceServer
serverRepo server.ServerRepository
db *gorm.DB
// 连接管理
connections sync.Map // agent_token -> *AgentConnection
}
// AgentConnection Agent 连接状态
type AgentConnection struct {
ServerID string
LastSeen time.Time
Stream proto.AgentService_ReportStreamServer
CancelFunc context.CancelFunc
}
// NewAgentServer 创建 Agent 服务
func NewAgentServer(repo server.ServerRepository) *AgentServer {
return &AgentServer{serverRepo: repo}
func NewAgentServer(repo server.ServerRepository, db *gorm.DB) *AgentServer {
return &AgentServer{
serverRepo: repo,
db: db,
}
}
// Register 注册 gRPC 服务
func (s *AgentServer) Register(gs *grpc.Server) {
// TODO: 注册 protobuf 生成的服务
// pb.RegisterAgentServiceServer(gs, s)
// ReportStream 双向流:Agent 上报指标,服务端下发指令
func (s *AgentServer) ReportStream(stream proto.AgentService_ReportStreamServer) error {
ctx := stream.Context()
// 获取 agent_token
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return status.Error(codes.Unauthenticated, "缺少认证信息")
}
tokens := md.Get("authorization")
if len(tokens) == 0 {
return status.Error(codes.Unauthenticated, "缺少 Token")
}
agentToken := tokens[0]
// 验证 token 并获取服务器信息
srv, err := s.serverRepo.GetByAgentToken(ctx, agentToken)
if err != nil {
return status.Error(codes.Unauthenticated, "无效的 Token")
}
// 注册连接
connCtx, cancel := context.WithCancel(ctx)
conn := &AgentConnection{
ServerID: srv.ID,
LastSeen: time.Now(),
Stream: stream,
CancelFunc: cancel,
}
s.connections.Store(agentToken, conn)
defer s.connections.Delete(agentToken)
// 更新服务器状态为在线
s.serverRepo.UpdateStatus(ctx, srv.ID, "online", time.Now())
log.Printf("[gRPC] 服务器 %s (%s) 已连接", srv.Name, srv.ID)
// 启动心跳检测
go s.heartbeatCheck(connCtx, agentToken, srv.ID)
// 接收消息循环
for {
select {
case <-connCtx.Done():
return connCtx.Err()
default:
req, err := stream.Recv()
if err != nil {
if status.Code(err) == codes.Canceled {
log.Printf("[gRPC] 服务器 %s 断开连接", srv.Name)
} else {
log.Printf("[gRPC] 服务器 %s 接收错误: %v", srv.Name, err)
}
// 更新服务器状态为离线
s.serverRepo.UpdateStatus(ctx, srv.ID, "offline", time.Now())
return err
}
// 更新最后活动时间
conn.LastSeen = time.Now()
// 处理上报数据
switch payload := req.Payload.(type) {
case *proto.ReportRequest_Metrics:
s.handleMetrics(ctx, srv.ID, payload.Metrics)
case *proto.ReportRequest_Tcping:
s.handleTcping(ctx, srv.ID, payload.Tcping)
}
}
}
}
// handleMetrics 处理系统指标上报
func (s *AgentServer) handleMetrics(ctx context.Context, serverID string, metrics *proto.SystemMetrics) {
// 构建指标记录
m := &server.ServerMetrics{
ID: uuid.New().String(),
ServerID: serverID,
CPUUsage: metrics.CpuUsage,
MemoryTotal: metrics.MemoryTotal,
MemoryUsed: metrics.MemoryUsed,
DiskTotal: metrics.DiskTotal,
DiskUsed: metrics.DiskUsed,
NetworkRx: metrics.NetworkRx,
NetworkTx: metrics.NetworkTx,
Load1: metrics.Load_1,
Load5: metrics.Load_5,
Load15: metrics.Load_15,
Uptime: metrics.Uptime,
OSInfo: metrics.OsInfo,
ProcessCount: metrics.ProcessCount,
CPUTemp: metrics.CpuTemp,
Timestamp: time.Now(),
}
// GPU 信息
if len(metrics.Gpus) > 0 {
gpuJSON, _ := json.Marshal(metrics.Gpus)
m.GPUInfo = string(gpuJSON)
}
// 存储到数据库
if err := s.db.Create(m).Error; err != nil {
log.Printf("[gRPC] 保存指标失败: %v", err)
}
}
// handleTcping 处理 TCPing 结果上报
func (s *AgentServer) handleTcping(ctx context.Context, serverID string, results *proto.TcpingResults) {
for _, r := range results.Results {
record := &server.TCPingResult{
ID: uuid.New().String(),
ServerID: serverID,
TargetHost: r.TargetHost,
TargetPort: int(r.TargetPort),
LatencyMs: r.LatencyMs,
Success: r.Success,
Timestamp: time.Now(),
}
if err := s.db.Create(record).Error; err != nil {
log.Printf("[gRPC] 保存 TCPing 结果失败: %v", err)
}
}
}
// heartbeatCheck 心跳检测
func (s *AgentServer) heartbeatCheck(ctx context.Context, agentToken string, serverID string) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
conn, ok := s.connections.Load(agentToken)
if !ok {
return
}
c := conn.(*AgentConnection)
// 检查是否超时(60秒无活动)
if time.Since(c.LastSeen) > 60*time.Second {
log.Printf("[gRPC] 服务器 %s 心跳超时,断开连接", serverID)
s.serverRepo.UpdateStatus(context.Background(), serverID, "offline", time.Now())
c.CancelFunc()
return
}
}
}
}
// FetchConfig Agent 拉取配置
func (s *AgentServer) FetchConfig(ctx context.Context, req *proto.ConfigRequest) (*proto.AgentConfig, error) {
// 验证 token
srv, err := s.serverRepo.GetByAgentToken(ctx, req.AgentToken)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "无效的 Token")
}
// 更新最后活动时间
s.serverRepo.UpdateStatus(ctx, srv.ID, "online", time.Now())
// 获取用户配置
// TODO: 从数据库获取用户的配置,包括上报频率、TCPing 目标等
// 这里返回默认配置
config := &proto.AgentConfig{
ConfigVersion: srv.ConfigVersion,
ReportIntervalSeconds: 10,
TcpingIntervalSeconds: 60,
TcpingTargets: []*proto.TcpingTarget{},
ConfigChanged: req.CurrentConfigVersion != srv.ConfigVersion,
}
// 如果配置有变化,更新配置版本
if config.ConfigChanged {
log.Printf("[gRPC] 服务器 %s 配置已更新", srv.Name)
}
return config, nil
}
// Heartbeat 心跳
func (s *AgentServer) Heartbeat(ctx context.Context, req *proto.HeartbeatRequest) (*proto.HeartbeatResponse, error) {
// 验证 token
srv, err := s.serverRepo.GetByAgentToken(ctx, req.AgentToken)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "无效的 Token")
}
// 更新最后活动时间
s.serverRepo.UpdateStatus(ctx, srv.ID, "online", time.Now())
return &proto.HeartbeatResponse{
Ok: true,
ConfigVersion: srv.ConfigVersion,
}, nil
}
// SendCommand 向 Agent 发送命令
func (s *AgentServer) SendCommand(agentToken string, cmd *proto.ServerCommand) error {
conn, ok := s.connections.Load(agentToken)
if !ok {
return fmt.Errorf("agent 未连接")
}
c := conn.(*AgentConnection)
return c.Stream.Send(cmd)
}
// StartGRPCServer 启动 gRPC 服务
func StartGRPCServer(addr string, repo server.ServerRepository, db *gorm.DB) (*grpc.Server, error) {
lis, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("监听失败: %w", err)
}
// 创建 gRPC 服务
s := grpc.NewServer(
grpc.UnaryInterceptor(authInterceptor(repo)),
grpc.StreamInterceptor(streamAuthInterceptor(repo)),
)
// 注册服务
agentServer := NewAgentServer(repo, db)
proto.RegisterAgentServiceServer(s, agentServer)
// 启动服务
go func() {
log.Printf("[gRPC] 服务启动: %s", addr)
if err := s.Serve(lis); err != nil {
log.Printf("[gRPC] 服务错误: %v", err)
}
}()
return s, nil
}
// authInterceptor 认证拦截器
@@ -36,51 +289,46 @@ func authInterceptor(repo server.ServerRepository) grpc.UnaryServerInterceptor {
if !ok {
return nil, status.Error(codes.Unauthenticated, "缺少认证信息")
}
tokens := md.Get("authorization")
if len(tokens) == 0 {
return nil, status.Error(codes.Unauthenticated, "缺少 Token")
}
token := tokens[0]
// 验证 token
_, err := repo.GetByAgentToken(ctx, token)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "无效的 Token")
}
return handler(ctx, req)
}
}
// StartGRPCServer 启动 gRPC 服务
func StartGRPCServer(addr string, repo server.ServerRepository) error {
lis, err := net.Listen("tcp", addr)
if err != nil {
return err
}
// 创建 gRPC 服务
s := grpc.NewServer(
grpc.UnaryInterceptor(authInterceptor(repo)),
)
// 注册服务
agentServer := NewAgentServer(repo)
agentServer.Register(s)
// 启动服务
go func() {
if err := s.Serve(lis); err != nil {
log.Printf("gRPC 服务错误: %v", err)
// streamAuthInterceptor 流认证拦截器
func streamAuthInterceptor(repo server.ServerRepository) grpc.StreamServerInterceptor {
return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
// 从 metadata 获取 token
md, ok := metadata.FromIncomingContext(ss.Context())
if !ok {
return status.Error(codes.Unauthenticated, "缺少认证信息")
}
}()
return nil
}
import (
"log"
"net"
)
tokens := md.Get("authorization")
if len(tokens) == 0 {
return status.Error(codes.Unauthenticated, "缺少 Token")
}
token := tokens[0]
// 验证 token
_, err := repo.GetByAgentToken(ss.Context(), token)
if err != nil {
return status.Error(codes.Unauthenticated, "无效的 Token")
}
return handler(srv, ss)
}
}
+175
View File
@@ -0,0 +1,175 @@
// Package middleware HTTP 中间件
package middleware
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/nuyue/server/pkg/jwt"
"github.com/nuyue/server/pkg/response"
)
// AuthMiddleware JWT 认证中间件
func AuthMiddleware(jwtSvc *jwt.JWTService) gin.HandlerFunc {
return func(c *gin.Context) {
// 获取 Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
response.Unauthorized(c, "未提供认证信息")
c.Abort()
return
}
// 解析 Bearer token
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
response.Unauthorized(c, "认证格式错误")
c.Abort()
return
}
tokenString := parts[1]
// 验证 token
claims, err := jwtSvc.ParseToken(tokenString)
if err != nil {
response.Unauthorized(c, "无效的 Token")
c.Abort()
return
}
// 检查 token 类型
if claims.Type != "access" {
response.Unauthorized(c, "Token 类型错误")
c.Abort()
return
}
// 将用户信息存入上下文
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Next()
}
}
// AdminMiddleware 管理员权限中间件
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("role")
if !exists {
response.Unauthorized(c, "未认证")
c.Abort()
return
}
if role.(string) != "admin" {
response.Forbidden(c, "需要管理员权限")
c.Abort()
return
}
c.Next()
}
}
// CORSMiddleware CORS 跨域中间件
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
// RateLimitMiddleware 简单的内存限流中间件
func RateLimitMiddleware(requestsPerMinute int) gin.HandlerFunc {
// 使用内存存储请求计数
type clientInfo struct {
count int
resetTime time.Time
}
clients := make(map[string]*clientInfo)
return func(c *gin.Context) {
// 使用 IP 作为限流键
key := c.ClientIP()
now := time.Now()
info, exists := clients[key]
if !exists || now.After(info.resetTime) {
// 新窗口
clients[key] = &clientInfo{
count: 1,
resetTime: now.Add(time.Minute),
}
} else {
// 当前窗口内
if info.count >= requestsPerMinute {
response.Error(c, http.StatusTooManyRequests, "请求过于频繁,请稍后再试")
c.Abort()
return
}
info.count++
}
c.Next()
}
}
// RecoveryMiddleware Panic 恢复中间件
func RecoveryMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
response.InternalError(c, "服务器内部错误")
c.Abort()
}
}()
c.Next()
}
}
// GetUserID 从上下文获取用户ID
func GetUserID(c *gin.Context) string {
userID, _ := c.Get("user_id")
if userID == nil {
return ""
}
return userID.(string)
}
// GetUsername 从上下文获取用户名
func GetUsername(c *gin.Context) string {
username, _ := c.Get("username")
if username == nil {
return ""
}
return username.(string)
}
// GetRole 从上下文获取用户角色
func GetRole(c *gin.Context) string {
role, _ := c.Get("role")
if role == nil {
return ""
}
return role.(string)
}
// IsAdmin 检查是否是管理员
func IsAdmin(c *gin.Context) bool {
return GetRole(c) == "admin"
}
+117
View File
@@ -0,0 +1,117 @@
// Package admin 管理员功能
package admin
import (
"time"
)
// UserListRequest 用户列表请求
type UserListRequest struct {
Page int `form:"page" binding:"omitempty,min=1"`
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"`
Search string `form:"search"`
Role string `form:"role" binding:"omitempty,oneof=admin user"`
Status string `form:"status" binding:"omitempty,oneof=active disabled"`
}
// UserListResponse 用户列表响应
type UserListResponse struct {
List []UserItem `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
}
// UserItem 用户列表项
type UserItem struct {
ID string `json:"id"`
Username string `json:"username"`
Email *string `json:"email"`
Role string `json:"role"`
Status string `json:"status"`
ServerCount int64 `json:"server_count"`
CreatedAt time.Time `json:"created_at"`
LastSeenAt *time.Time `json:"last_seen_at"`
}
// UpdateUserRequest 更新用户请求
type UpdateUserRequest struct {
Role string `json:"role" binding:"omitempty,oneof=admin user"`
Status string `json:"status" binding:"omitempty,oneof=active disabled"`
}
// SystemStatsResponse 系统统计响应
type SystemStatsResponse struct {
TotalUsers int64 `json:"total_users"`
ActiveUsers int64 `json:"active_users"`
TotalServers int64 `json:"total_servers"`
OnlineServers int64 `json:"online_servers"`
TotalOrders int64 `json:"total_orders"`
TotalRevenue float64 `json:"total_revenue"`
ActiveSubscriptions int64 `json:"active_subscriptions"`
}
// RedeemCodeGenerateRequest 生成兑换码请求
type RedeemCodeGenerateRequest struct {
PlanID string `json:"plan_id" binding:"required"`
BillingType string `json:"billing_type" binding:"required,oneof=monthly yearly lifetime"`
Count int `json:"count" binding:"required,min=1,max=100"`
MaxUseCount int `json:"max_use_count" binding:"min=1"`
ExpiresAt *time.Time `json:"expires_at"`
}
// RedeemCodeListResponse 兑换码列表响应
type RedeemCodeListResponse struct {
List []RedeemCodeItem `json:"list"`
Total int64 `json:"total"`
}
// RedeemCodeItem 兑换码项
type RedeemCodeItem struct {
ID string `json:"id"`
Code string `json:"code"`
PlanName string `json:"plan_name"`
BillingType string `json:"billing_type"`
MaxUseCount int `json:"max_use_count"`
UsedCount int `json:"used_count"`
ExpiresAt *time.Time `json:"expires_at"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// OrderListRequest 订单列表请求
type OrderListRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Status string `form:"status"`
Method string `form:"method"`
}
// OrderListResponse 订单列表响应
type OrderListResponse struct {
List []OrderItem `json:"list"`
Total int64 `json:"total"`
}
// OrderItem 订单项
type OrderItem struct {
ID string `json:"id"`
UserUsername string `json:"user_username"`
PlanName string `json:"plan_name"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Method string `json:"method"`
Status string `json:"status"`
TradeNo string `json:"trade_no"`
CreatedAt time.Time `json:"created_at"`
PaidAt *time.Time `json:"paid_at"`
}
// PaymentStatsResponse 支付统计响应
type PaymentStatsResponse struct {
TotalRevenue float64 `json:"total_revenue"`
TodayRevenue float64 `json:"today_revenue"`
MonthRevenue float64 `json:"month_revenue"`
TotalOrders int64 `json:"total_orders"`
PendingOrders int64 `json:"pending_orders"`
MethodStats map[string]float64 `json:"method_stats"`
}
+171
View File
@@ -0,0 +1,171 @@
// Package admin 管理员模块
package admin
import (
"time"
"github.com/google/uuid"
)
// User 管理员视图的用户模型
type User struct {
ID string `json:"id" gorm:"primaryKey"`
Username string `json:"username" gorm:"size:50;not null"`
Email *string `json:"email" gorm:"size:255"`
EmailVerified bool `json:"email_verified" gorm:"default:false"`
Role string `json:"role" gorm:"size:20;default:user"`
Status string `json:"status" gorm:"size:20;default:active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (User) TableName() string { return "users" }
// Plan 套餐模型
type Plan struct {
ID string `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"size:100;not null"`
Description string `json:"description" gorm:"type:text"`
MaxClients int `json:"max_clients" gorm:"default:5"`
MaxTCPingNodes int `json:"max_tcping_nodes" gorm:"default:10"`
AllowCustomTheme bool `json:"allow_custom_theme" gorm:"default:true"`
AllowTGNotify bool `json:"allow_tg_notify" gorm:"default:true"`
AllowBackup bool `json:"allow_backup" gorm:"default:false"`
MaxBackupCount int `json:"max_backup_count" gorm:"default:0"`
MaxBackupRepos int `json:"max_backup_repos" gorm:"default:0"`
AllowBackupDocker bool `json:"allow_backup_docker" gorm:"default:false"`
AllowBackupDB bool `json:"allow_backup_database" gorm:"default:false"`
AllowUpgrade bool `json:"allow_upgrade" gorm:"default:true"`
PriceMonthly *float64 `json:"price_monthly" gorm:"type:decimal(10,2)"`
PriceYearly *float64 `json:"price_yearly" gorm:"type:decimal(10,2)"`
PriceLifetime *float64 `json:"price_lifetime" gorm:"type:decimal(10,2)"`
SortOrder int `json:"sort_order" gorm:"default:0"`
IsVisible bool `json:"is_visible" gorm:"default:true"`
Status string `json:"status" gorm:"size:20;default:active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Plan) TableName() string { return "plans" }
// RedeemCode 兑换码模型
type RedeemCode struct {
ID string `json:"id" gorm:"primaryKey"`
Code string `json:"code" gorm:"uniqueIndex;size:32;not null"`
PlanID string `json:"plan_id" gorm:"not null"`
BillingType string `json:"billing_type" gorm:"size:20;not null"` // monthly/yearly/lifetime
MaxUseCount int `json:"max_use_count" gorm:"default:1"`
UsedCount int `json:"used_count" gorm:"default:0"`
CreatedBy string `json:"created_by" gorm:"not null"`
ExpiresAt *time.Time `json:"expires_at"`
Status string `json:"status" gorm:"size:20;default:active"` // active/used_up/expired
CreatedAt time.Time `json:"created_at"`
}
func (RedeemCode) TableName() string { return "redeem_codes" }
// SystemSetting 系统设置
type SystemSetting struct {
ID string `json:"id" gorm:"primaryKey"`
Category string `json:"category" gorm:"size:30;not null"`
Key string `json:"key" gorm:"uniqueIndex;size:100;not null"`
Value string `json:"value" gorm:"type:text"`
ValueType string `json:"value_type" gorm:"size:20;default:string"`
Description string `json:"description" gorm:"size:255"`
IsEncrypted bool `json:"is_encrypted" gorm:"default:false"`
UpdatedBy *string `json:"updated_by"`
UpdatedAt time.Time `json:"updated_at"`
}
func (SystemSetting) TableName() string { return "system_settings" }
// ===== 请求/响应模型 =====
// ListUsersRequest 用户列表请求
type ListUsersRequest struct {
Page int `form:"page" binding:"min=1"`
PageSize int `form:"page_size" binding:"min=1,max=100"`
Keyword string `form:"keyword"`
Role string `form:"role"`
Status string `form:"status"`
}
// ListUsersResponse 用户列表响应
type ListUsersResponse struct {
List []User `json:"list"`
Total int64 `json:"total"`
}
// UpdateUserStatusRequest 更新用户状态
type UpdateUserStatusRequest struct {
Status string `json:"status" binding:"required,oneof=active disabled"`
}
// CreatePlanRequest 创建套餐请求
type CreatePlanRequest struct {
Name string `json:"name" binding:"required,max=100"`
Description string `json:"description"`
MaxClients int `json:"max_clients" binding:"min=1"`
MaxTCPingNodes int `json:"max_tcping_nodes" binding:"min=0"`
AllowCustomTheme bool `json:"allow_custom_theme"`
AllowTGNotify bool `json:"allow_tg_notify"`
AllowBackup bool `json:"allow_backup"`
MaxBackupCount int `json:"max_backup_count"`
MaxBackupRepos int `json:"max_backup_repos"`
AllowBackupDocker bool `json:"allow_backup_docker"`
AllowBackupDB bool `json:"allow_backup_database"`
AllowUpgrade bool `json:"allow_upgrade"`
PriceMonthly *float64 `json:"price_monthly"`
PriceYearly *float64 `json:"price_yearly"`
PriceLifetime *float64 `json:"price_lifetime"`
SortOrder int `json:"sort_order"`
IsVisible bool `json:"is_visible"`
}
// UpdatePlanRequest 更新套餐请求
type UpdatePlanRequest struct {
Name string `json:"name" binding:"omitempty,max=100"`
Description string `json:"description"`
MaxClients int `json:"max_clients" binding:"omitempty,min=1"`
MaxTCPingNodes int `json:"max_tcping_nodes" binding:"omitempty,min=0"`
AllowCustomTheme bool `json:"allow_custom_theme"`
AllowTGNotify bool `json:"allow_tg_notify"`
AllowBackup bool `json:"allow_backup"`
MaxBackupCount int `json:"max_backup_count"`
MaxBackupRepos int `json:"max_backup_repos"`
AllowBackupDocker bool `json:"allow_backup_docker"`
AllowBackupDB bool `json:"allow_backup_database"`
AllowUpgrade bool `json:"allow_upgrade"`
PriceMonthly *float64 `json:"price_monthly"`
PriceYearly *float64 `json:"price_yearly"`
PriceLifetime *float64 `json:"price_lifetime"`
SortOrder int `json:"sort_order"`
IsVisible bool `json:"is_visible"`
}
// CreateRedeemCodeRequest 创建兑换码请求
type CreateRedeemCodeRequest struct {
PlanID string `json:"plan_id" binding:"required"`
BillingType string `json:"billing_type" binding:"required,oneof=monthly yearly lifetime"`
MaxUseCount int `json:"max_use_count" binding:"min=1,max=1000"`
Count int `json:"count" binding:"min=1,max=100"` // 批量创建数量
ExpiresDays int `json:"expires_days"` // 过期天数,0=永不过期
}
// RedeemCodeListResponse 兑换码列表响应
type RedeemCodeListResponse struct {
List []RedeemCode `json:"list"`
Total int64 `json:"total"`
}
// UpdateSettingsRequest 更新系统设置
type UpdateSettingsRequest struct {
Settings map[string]interface{} `json:"settings"`
}
// ===== 工具函数 =====
// NewUUID 生成 UUID
func NewUUID() string {
return uuid.New().String()
}
+258
View File
@@ -0,0 +1,258 @@
// Package admin 数据访问层
package admin
import (
"context"
"time"
"gorm.io/gorm"
)
// Repository 管理员仓储接口
type Repository interface {
// 用户管理
ListUsers(ctx context.Context, keyword, role, status string, page, pageSize int) (*ListUsersResponse, error)
UpdateUserStatus(ctx context.Context, userID, status string) error
GetUserByID(ctx context.Context, userID string) (*User, error)
// 套餐管理
ListPlans(ctx context.Context) (*[]Plan, error)
GetPlanByID(ctx context.Context, planID string) (*Plan, error)
CreatePlan(ctx context.Context, plan *Plan) error
UpdatePlan(ctx context.Context, planID string, updates map[string]interface{}) error
DeletePlan(ctx context.Context, planID string) error
// 兑换码管理
ListRedeemCodes(ctx context.Context, page, pageSize int) (*RedeemCodeListResponse, error)
CreateRedeemCode(ctx context.Context, code *RedeemCode) error
GetRedeemCodeByCode(ctx context.Context, code string) (*RedeemCode, error)
UpdateRedeemCode(ctx context.Context, codeID string, updates map[string]interface{}) error
// 系统设置
GetSettings(ctx context.Context) (*map[string]string, error)
GetSetting(ctx context.Context, key string) (*SystemSetting, error)
UpdateSetting(ctx context.Context, key, value string, userID string) error
UpdateSettings(ctx context.Context, settings map[string]string, userID string) error
}
// repository 实现
type repository struct {
db *gorm.DB
}
// NewRepository 创建管理员仓储
func NewRepository(db *gorm.DB) Repository {
return &repository{db: db}
}
// ===== 用户管理 =====
func (r *repository) ListUsers(ctx context.Context, keyword, role, status string, page, pageSize int) (*ListUsersResponse, error) {
query := r.db.WithContext(ctx).Model(&User{})
if keyword != "" {
query = query.Where("username LIKE ? OR email LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
}
if role != "" {
query = query.Where("role = ?", role)
}
if status != "" {
query = query.Where("status = ?", status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
var users []User
offset := (page - 1) * pageSize
if err := query.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&users).Error; err != nil {
return nil, err
}
return &ListUsersResponse{
List: users,
Total: total,
}, nil
}
func (r *repository) UpdateUserStatus(ctx context.Context, userID, status string) error {
return r.db.WithContext(ctx).Model(&User{}).
Where("id = ?", userID).
Updates(map[string]interface{}{
"status": status,
"updated_at": time.Now(),
}).Error
}
func (r *repository) GetUserByID(ctx context.Context, userID string) (*User, error) {
var user User
err := r.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &user, nil
}
// ===== 套餐管理 =====
func (r *repository) ListPlans(ctx context.Context) (*[]Plan, error) {
var plans []Plan
err := r.db.WithContext(ctx).Order("sort_order ASC, created_at DESC").Find(&plans).Error
return &plans, err
}
func (r *repository) GetPlanByID(ctx context.Context, planID string) (*Plan, error) {
var plan Plan
err := r.db.WithContext(ctx).Where("id = ?", planID).First(&plan).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &plan, nil
}
func (r *repository) CreatePlan(ctx context.Context, plan *Plan) error {
if plan.ID == "" {
plan.ID = NewUUID()
}
plan.CreatedAt = time.Now()
plan.UpdatedAt = time.Now()
return r.db.WithContext(ctx).Create(plan).Error
}
func (r *repository) UpdatePlan(ctx context.Context, planID string, updates map[string]interface{}) error {
updates["updated_at"] = time.Now()
return r.db.WithContext(ctx).Model(&Plan{}).
Where("id = ?", planID).
Updates(updates).Error
}
func (r *repository) DeletePlan(ctx context.Context, planID string) error {
return r.db.WithContext(ctx).Delete(&Plan{}, "id = ?", planID).Error
}
// ===== 兑换码管理 =====
func (r *repository) ListRedeemCodes(ctx context.Context, page, pageSize int) (*RedeemCodeListResponse, error) {
var total int64
if err := r.db.WithContext(ctx).Model(&RedeemCode{}).Count(&total).Error; err != nil {
return nil, err
}
var codes []RedeemCode
offset := (page - 1) * pageSize
if err := r.db.WithContext(ctx).Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&codes).Error; err != nil {
return nil, err
}
return &RedeemCodeListResponse{
List: codes,
Total: total,
}, nil
}
func (r *repository) CreateRedeemCode(ctx context.Context, code *RedeemCode) error {
if code.ID == "" {
code.ID = NewUUID()
}
code.CreatedAt = time.Now()
return r.db.WithContext(ctx).Create(code).Error
}
func (r *repository) GetRedeemCodeByCode(ctx context.Context, code string) (*RedeemCode, error) {
var rc RedeemCode
err := r.db.WithContext(ctx).Where("code = ?", code).First(&rc).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &rc, nil
}
func (r *repository) UpdateRedeemCode(ctx context.Context, codeID string, updates map[string]interface{}) error {
return r.db.WithContext(ctx).Model(&RedeemCode{}).
Where("id = ?", codeID).
Updates(updates).Error
}
// ===== 系统设置 =====
func (r *repository) GetSettings(ctx context.Context) (*map[string]string, error) {
var settings []SystemSetting
if err := r.db.WithContext(ctx).Find(&settings).Error; err != nil {
return nil, err
}
result := make(map[string]string)
for _, s := range settings {
result[s.Key] = s.Value
}
return &result, nil
}
func (r *repository) GetSetting(ctx context.Context, key string) (*SystemSetting, error) {
var setting SystemSetting
err := r.db.WithContext(ctx).Where("key = ?", key).First(&setting).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &setting, nil
}
func (r *repository) UpdateSetting(ctx context.Context, key, value, userID string) error {
// 查找是否存在
var setting SystemSetting
err := r.db.WithContext(ctx).Where("key = ?", key).First(&setting).Error
if err == gorm.ErrRecordNotFound {
// 创建新设置
setting = SystemSetting{
ID: NewUUID(),
Key: key,
Value: value,
UpdatedAt: time.Now(),
}
if userID != "" {
setting.UpdatedBy = &userID
}
return r.db.WithContext(ctx).Create(&setting).Error
}
if err != nil {
return err
}
// 更新设置
updates := map[string]interface{}{
"value": value,
"updated_at": time.Now(),
}
if userID != "" {
updates["updated_by"] = userID
}
return r.db.WithContext(ctx).Model(&SystemSetting{}).
Where("key = ?", key).
Updates(updates).Error
}
func (r *repository) UpdateSettings(ctx context.Context, settings map[string]string, userID string) error {
for key, value := range settings {
if err := r.UpdateSetting(ctx, key, value, userID); err != nil {
return err
}
}
return nil
}
+33
View File
@@ -0,0 +1,33 @@
// Package admin 路由定义
package admin
import (
"github.com/gin-gonic/gin"
"github.com/nuyue/server/internal/middleware"
"github.com/nuyue/server/pkg/jwt"
)
// RegisterRoutes 注册管理员路由
func RegisterRoutes(r *gin.RouterGroup, handler *Handler, jwtSvc *jwt.JWTService) {
admin := r.Group("/admin")
admin.Use(middleware.AuthMiddleware(jwtSvc))
admin.Use(middleware.AdminMiddleware())
{
// 用户管理
admin.GET("/users", handler.ListUsers)
admin.PUT("/users/:id", handler.UpdateUser)
admin.DELETE("/users/:id", handler.DeleteUser)
// 系统统计
admin.GET("/stats", handler.GetSystemStats)
admin.GET("/stats/payment", handler.GetPaymentStats)
// 兑换码管理
admin.POST("/redeem-codes", handler.GenerateRedeemCodes)
admin.GET("/redeem-codes", handler.ListRedeemCodes)
admin.DELETE("/redeem-codes/:id", handler.DeleteRedeemCode)
// 订单管理
admin.GET("/orders", handler.ListOrders)
}
}
+440
View File
@@ -0,0 +1,440 @@
// Package admin 管理员服务
package admin
import (
"context"
"time"
"gorm.io/gorm"
)
// AdminService 管理员服务接口
type AdminService interface {
// 用户管理
ListUsers(ctx context.Context, req *UserListRequest) (*UserListResponse, error)
UpdateUser(ctx context.Context, userID string, req *UpdateUserRequest) error
DeleteUser(ctx context.Context, userID string) error
// 系统统计
GetSystemStats(ctx context.Context) (*SystemStatsResponse, error)
GetPaymentStats(ctx context.Context) (*PaymentStatsResponse, error)
// 兑换码管理
GenerateRedeemCodes(ctx context.Context, adminID string, req *RedeemCodeGenerateRequest) ([]string, error)
ListRedeemCodes(ctx context.Context, page, pageSize int) (*RedeemCodeListResponse, error)
DeleteRedeemCode(ctx context.Context, codeID string) error
// 订单管理
ListOrders(ctx context.Context, req *OrderListRequest) (*OrderListResponse, error)
}
// adminService 管理员服务实现
type adminService struct {
db *gorm.DB
}
// NewService 创建管理员服务
func NewService(db *gorm.DB) AdminService {
return &adminService{db: db}
}
// ListUsers 获取用户列表
func (s *adminService) ListUsers(ctx context.Context, req *UserListRequest) (*UserListResponse, error) {
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
var users []struct {
ID string
Username string
Email *string
Role string
Status string
CreatedAt time.Time
}
var total int64
query := s.db.WithContext(ctx).Model(&User{})
if req.Search != "" {
query = query.Where("username LIKE ? OR email LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%")
}
if req.Role != "" {
query = query.Where("role = ?", req.Role)
}
if req.Status != "" {
query = query.Where("status = ?", req.Status)
}
if err := query.Count(&total).Error; err != nil {
return nil, err
}
offset := (req.Page - 1) * req.PageSize
if err := query.Offset(offset).Limit(req.PageSize).Order("created_at DESC").Find(&users).Error; err != nil {
return nil, err
}
list := make([]UserItem, len(users))
for i, u := range users {
var serverCount int64
s.db.WithContext(ctx).Model(&Server{}).Where("user_id = ?", u.ID).Count(&serverCount)
list[i] = UserItem{
ID: u.ID,
Username: u.Username,
Email: u.Email,
Role: u.Role,
Status: u.Status,
ServerCount: serverCount,
CreatedAt: u.CreatedAt,
}
}
return &UserListResponse{
List: list,
Total: total,
Page: req.Page,
}, nil
}
// UpdateUser 更新用户
func (s *adminService) UpdateUser(ctx context.Context, userID string, req *UpdateUserRequest) error {
updates := map[string]interface{}{
"updated_at": time.Now(),
}
if req.Role != "" {
updates["role"] = req.Role
}
if req.Status != "" {
updates["status"] = req.Status
}
return s.db.WithContext(ctx).Model(&User{}).Where("id = ?", userID).Updates(updates).Error
}
// DeleteUser 删除用户
func (s *adminService) DeleteUser(ctx context.Context, userID string) error {
// 开启事务
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 删除用户的服务器
if err := tx.Where("user_id = ?", userID).Delete(&Server{}).Error; err != nil {
return err
}
// 删除用户
return tx.Delete(&User{}, "id = ?", userID).Error
})
}
// GetSystemStats 获取系统统计
func (s *adminService) GetSystemStats(ctx context.Context) (*SystemStatsResponse, error) {
stats := &SystemStatsResponse{}
// 用户统计
s.db.WithContext(ctx).Model(&User{}).Count(&stats.TotalUsers)
s.db.WithContext(ctx).Model(&User{}).Where("status = ?", "active").Count(&stats.ActiveUsers)
// 服务器统计
s.db.WithContext(ctx).Model(&Server{}).Count(&stats.TotalServers)
s.db.WithContext(ctx).Model(&Server{}).Where("status = ?", "online").Count(&stats.OnlineServers)
// 订单统计
s.db.WithContext(ctx).Model(&PaymentOrder{}).Count(&stats.TotalOrders)
// 收入统计
s.db.WithContext(ctx).Model(&PaymentOrder{}).
Where("status = ?", "paid").
Select("COALESCE(SUM(amount), 0)").
Scan(&stats.TotalRevenue)
// 活跃订阅
s.db.WithContext(ctx).Model(&Subscription{}).
Where("status = ?", "active").
Count(&stats.ActiveSubscriptions)
return stats, nil
}
// GetPaymentStats 获取支付统计
func (s *adminService) GetPaymentStats(ctx context.Context) (*PaymentStatsResponse, error) {
stats := &PaymentStatsResponse{
MethodStats: make(map[string]float64),
}
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
// 总收入
s.db.WithContext(ctx).Model(&PaymentOrder{}).
Where("status = ?", "paid").
Select("COALESCE(SUM(amount), 0)").
Scan(&stats.TotalRevenue)
// 今日收入
s.db.WithContext(ctx).Model(&PaymentOrder{}).
Where("status = ? AND paid_at >= ?", "paid", todayStart).
Select("COALESCE(SUM(amount), 0)").
Scan(&stats.TodayRevenue)
// 本月收入
s.db.WithContext(ctx).Model(&PaymentOrder{}).
Where("status = ? AND paid_at >= ?", "paid", monthStart).
Select("COALESCE(SUM(amount), 0)").
Scan(&stats.MonthRevenue)
// 订单统计
s.db.WithContext(ctx).Model(&PaymentOrder{}).Count(&stats.TotalOrders)
s.db.WithContext(ctx).Model(&PaymentOrder{}).Where("status = ?", "pending").Count(&stats.PendingOrders)
// 按支付方式统计
var methodStats []struct {
Method string
Amount float64
}
s.db.WithContext(ctx).Model(&PaymentOrder{}).
Select("payment_method as method, SUM(amount) as amount").
Where("status = ?", "paid").
Group("payment_method").
Scan(&methodStats)
for _, m := range methodStats {
stats.MethodStats[m.Method] = m.Amount
}
return stats, nil
}
// GenerateRedeemCodes 生成兑换码
func (s *adminService) GenerateRedeemCodes(ctx context.Context, adminID string, req *RedeemCodeGenerateRequest) ([]string, error) {
codes := make([]string, req.Count)
for i := 0; i < req.Count; i++ {
code := generateRedeemCode()
codes[i] = code
rc := &RedeemCode{
ID: generateUUID(),
Code: code,
PlanID: req.PlanID,
BillingType: req.BillingType,
MaxUseCount: req.MaxUseCount,
UsedCount: 0,
ExpiresAt: req.ExpiresAt,
CreatedBy: adminID,
Status: "active",
CreatedAt: time.Now(),
}
if err := s.db.WithContext(ctx).Create(rc).Error; err != nil {
return nil, err
}
}
return codes, nil
}
// ListRedeemCodes 获取兑换码列表
func (s *adminService) ListRedeemCodes(ctx context.Context, page, pageSize int) (*RedeemCodeListResponse, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
var codes []RedeemCode
var total int64
s.db.WithContext(ctx).Model(&RedeemCode{}).Count(&total)
offset := (page - 1) * pageSize
if err := s.db.WithContext(ctx).
Preload("Plan").
Offset(offset).
Limit(pageSize).
Order("created_at DESC").
Find(&codes).Error; err != nil {
return nil, err
}
list := make([]RedeemCodeItem, len(codes))
for i, c := range codes {
planName := ""
if c.Plan != nil {
planName = c.Plan.Name
}
list[i] = RedeemCodeItem{
ID: c.ID,
Code: c.Code,
PlanName: planName,
BillingType: c.BillingType,
MaxUseCount: c.MaxUseCount,
UsedCount: c.UsedCount,
ExpiresAt: c.ExpiresAt,
Status: c.Status,
CreatedAt: c.CreatedAt,
}
}
return &RedeemCodeListResponse{
List: list,
Total: total,
}, nil
}
// DeleteRedeemCode 删除兑换码
func (s *adminService) DeleteRedeemCode(ctx context.Context, codeID string) error {
return s.db.WithContext(ctx).Delete(&RedeemCode{}, "id = ?", codeID).Error
}
// ListOrders 获取订单列表
func (s *adminService) ListOrders(ctx context.Context, req *OrderListRequest) (*OrderListResponse, error) {
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
var orders []PaymentOrder
var total int64
query := s.db.WithContext(ctx).Model(&PaymentOrder{})
if req.Status != "" {
query = query.Where("status = ?", req.Status)
}
if req.Method != "" {
query = query.Where("payment_method = ?", req.Method)
}
query.Count(&total)
offset := (req.Page - 1) * req.PageSize
if err := query.Offset(offset).Limit(req.PageSize).
Preload("User").
Preload("Plan").
Order("created_at DESC").
Find(&orders).Error; err != nil {
return nil, err
}
list := make([]OrderItem, len(orders))
for i, o := range orders {
username := ""
if o.User != nil {
username = o.User.Username
}
planName := ""
if o.Plan != nil {
planName = o.Plan.Name
}
list[i] = OrderItem{
ID: o.ID,
UserUsername: username,
PlanName: planName,
Amount: o.Amount,
Currency: o.Currency,
Method: o.PaymentMethod,
Status: o.Status,
TradeNo: o.TradeNo,
CreatedAt: o.CreatedAt,
PaidAt: o.PaidAt,
}
}
return &OrderListResponse{
List: list,
Total: total,
}, nil
}
// 辅助函数
func generateRedeemCode() string {
// 生成格式: SP-XXXX-XXXX-XXXX
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
b := make([]byte, 14)
for i := range b {
b[i] = chars[randomInt(len(chars))]
}
return "SP-" + string(b[0:4]) + "-" + string(b[4:8]) + "-" + string(b[8:12])
}
func generateUUID() string {
return ""
}
func randomInt(n int) int {
return 0
}
// 模型定义
type User struct {
ID string `gorm:"primaryKey"`
Username string `gorm:"size:50"`
Email *string `gorm:"size:255"`
Role string `gorm:"size:20"`
Status string `gorm:"size:20"`
CreatedAt time.Time `gorm:"column:created_at"`
}
func (User) TableName() string { return "users" }
type Server struct {
ID string `gorm:"primaryKey"`
UserID string `gorm:"index"`
}
func (Server) TableName() string { return "servers" }
type PaymentOrder struct {
ID string `gorm:"primaryKey"`
UserID string `gorm:"index"`
PlanID string `gorm:"index"`
Amount float64
Currency string
PaymentMethod string
Status string
TradeNo string
CreatedAt time.Time
PaidAt *time.Time
User *User `gorm:"foreignKey:UserID"`
Plan *Plan `gorm:"foreignKey:PlanID"`
}
func (PaymentOrder) TableName() string { return "payment_orders" }
type Subscription struct {
ID string `gorm:"primaryKey"`
UserID string `gorm:"index"`
Status string `gorm:"size:20"`
}
func (Subscription) TableName() string { return "subscriptions" }
type RedeemCode struct {
ID string `gorm:"primaryKey"`
Code string `gorm:"uniqueIndex"`
PlanID string `gorm:"index"`
BillingType string
MaxUseCount int
UsedCount int
ExpiresAt *time.Time
CreatedBy string
Status string
CreatedAt time.Time
Plan *Plan `gorm:"foreignKey:PlanID"`
}
func (RedeemCode) TableName() string { return "redeem_codes" }
type Plan struct {
ID string `gorm:"primaryKey"`
Name string `gorm:"size:100"`
}
func (Plan) TableName() string { return "plans" }
+254
View File
@@ -0,0 +1,254 @@
// Package alert 告警评估引擎
package alert
import (
"context"
"log"
"math"
"sync"
"time"
)
// Evaluator 告警评估引擎
type Evaluator struct {
repo AlertRepository
metrics MetricsProvider
notify NotificationDispatcher
mu sync.RWMutex
alertState map[string]*AlertState // rule_id:server_id -> state
}
// AlertState 告警状态
type AlertState struct {
RuleID string
ServerID string
Status string // ok, firing
TriggerCount int // 连续触发次数
FiredAt time.Time
LastValue float64
}
// MetricsProvider 指标提供者接口
type MetricsProvider interface {
GetLatestValue(serverID string, metric string) (float64, error)
}
// NotificationDispatcher 通知分发器接口
type NotificationDispatcher interface {
DispatchAlert(userID string, event *AlertEvent) error
}
// NewEvaluator 创建告警评估引擎
func NewEvaluator(repo AlertRepository, metrics MetricsProvider, notify NotificationDispatcher) *Evaluator {
return &Evaluator{
repo: repo,
metrics: metrics,
notify: notify,
alertState: make(map[string]*AlertState),
}
}
// EvaluateRules 评估所有活跃的告警规则
func (e *Evaluator) EvaluateRules(ctx context.Context) error {
// 获取所有活跃规则
rules, err := e.repo.GetActiveRules(ctx)
if err != nil {
return err
}
for _, rule := range rules {
if err := e.evaluateRule(ctx, rule); err != nil {
log.Printf("评估规则 %s 失败: %v", rule.ID, err)
}
}
return nil
}
// evaluateRule 评估单个规则
func (e *Evaluator) evaluateRule(ctx context.Context, rule AlertRule) error {
// 获取需要检测的服务器
serverIDs, err := e.getTargetServerIDs(ctx, rule)
if err != nil {
return err
}
for _, serverID := range serverIDs {
if err := e.evaluateServerRule(ctx, rule, serverID); err != nil {
log.Printf("评估服务器 %s 规则 %s 失败: %v", serverID, rule.ID, err)
}
}
return nil
}
// evaluateServerRule 评估单个服务器的规则
func (e *Evaluator) evaluateServerRule(ctx context.Context, rule AlertRule, serverID string) error {
// 获取当前指标值
value, err := e.metrics.GetLatestValue(serverID, rule.Metric)
if err != nil {
return err
}
// 判断是否触发
triggered := e.compareValue(value, rule.Operator, rule.Threshold)
// 获取或创建状态
stateKey := rule.ID + ":" + serverID
e.mu.Lock()
state := e.alertState[stateKey]
if state == nil {
state = &AlertState{
RuleID: rule.ID,
ServerID: serverID,
Status: "ok",
}
e.alertState[stateKey] = state
}
e.mu.Unlock()
// 更新状态
state.LastValue = value
if triggered {
state.TriggerCount++
// 检查是否达到持续时间
durationCount := rule.DurationSeconds / 10 // 假设每 10 秒评估一次
if durationCount <= 0 {
durationCount = 1
}
if state.TriggerCount >= durationCount {
// 触发告警
if state.Status != "firing" {
state.Status = "firing"
state.FiredAt = time.Now()
// 创建告警事件
event := &AlertEvent{
RuleID: rule.ID,
ServerID: serverID,
Status: "firing",
Value: value,
Message: e.generateAlertMessage(rule, serverID, value),
FiredAt: &state.FiredAt,
}
if err := e.repo.CreateEvent(ctx, event); err != nil {
log.Printf("创建告警事件失败: %v", err)
}
// 发送通知
if e.notify != nil {
go func() {
if err := e.notify.DispatchAlert(rule.UserID, event); err != nil {
log.Printf("发送告警通知失败: %v", err)
}
}()
}
log.Printf("[告警] 规则 %s 触发,服务器 %s,当前值 %.2f", rule.Name, serverID, value)
}
}
} else {
// 未触发
if state.Status == "firing" {
// 恢复正常
state.Status = "ok"
state.TriggerCount = 0
// 更新告警事件
now := time.Now()
event := &AlertEvent{
RuleID: rule.ID,
ServerID: serverID,
Status: "resolved",
Value: value,
Message: e.generateResolveMessage(rule, serverID, value),
ResolvedAt: &now,
}
if err := e.repo.CreateEvent(ctx, event); err != nil {
log.Printf("创建恢复事件失败: %v", err)
}
// 发送恢复通知
if e.notify != nil {
go func() {
if err := e.notify.DispatchAlert(rule.UserID, event); err != nil {
log.Printf("发送恢复通知失败: %v", err)
}
}()
}
log.Printf("[恢复] 规则 %s 恢复,服务器 %s,当前值 %.2f", rule.Name, serverID, value)
} else {
state.TriggerCount = 0
}
}
return nil
}
// compareValue 比较值
func (e *Evaluator) compareValue(value float64, operator string, threshold float64) bool {
switch operator {
case ">":
return value > threshold
case "<":
return value < threshold
case ">=":
return value >= threshold
case "<=":
return value <= threshold
case "==":
return math.Abs(value-threshold) < 0.0001
case "!=":
return math.Abs(value-threshold) >= 0.0001
default:
return false
}
}
// getTargetServerIDs 获取目标服务器 ID 列表
func (e *Evaluator) getTargetServerIDs(ctx context.Context, rule AlertRule) ([]string, error) {
if rule.ServerID != "" {
// 特定服务器
return []string{rule.ServerID}, nil
}
// 全局规则,获取用户所有服务器
// TODO: 从 server 模块获取
return nil, nil
}
// generateAlertMessage 生成告警消息
func (e *Evaluator) generateAlertMessage(rule AlertRule, serverID string, value float64) string {
return ""
}
// generateResolveMessage 生成恢复消息
func (e *Evaluator) generateResolveMessage(rule AlertRule, serverID string, value float64) string {
return ""
}
// Start 启动评估引擎
func (e *Evaluator) Start(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
log.Printf("[告警引擎] 启动,评估间隔 %v", interval)
for {
select {
case <-ctx.Done():
log.Println("[告警引擎] 停止")
return
case <-ticker.C:
if err := e.EvaluateRules(ctx); err != nil {
log.Printf("[告警引擎] 评估失败: %v", err)
}
}
}
}
+8 -20
View File
@@ -75,26 +75,14 @@ func (h *Handler) ListRules(c *gin.Context) {
response.Success(c, resp)
}
func RegisterRoutes(r *gin.RouterGroup, h *Handler) {
alerts := r.Group("/alerts")
alerts.Use(authMiddleware())
{
alerts.GET("", h.ListRules)
alerts.POST("", h.CreateRule)
alerts.PUT("/:id", h.UpdateRule)
alerts.DELETE("/:id", h.DeleteRule)
}
}
func (h *Handler) ListEvents(c *gin.Context) {
userID := c.GetString("user_id")
func authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
c.JSON(401, gin.H{"code": 401, "message": "未登录"})
c.Abort()
return
}
c.Set("user_id", "test-user-id")
c.Next()
events, err := h.svc.ListEvents(c.Request.Context(), userID)
if err != nil {
response.InternalError(c, err.Error())
return
}
response.Success(c, events)
}
+138
View File
@@ -0,0 +1,138 @@
// Package alert 数据访问层
package alert
import (
"context"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// AlertRepository 告警仓储接口
type AlertRepository interface {
// 规则
CreateRule(ctx context.Context, rule *AlertRule) error
UpdateRule(ctx context.Context, rule *AlertRule) error
DeleteRule(ctx context.Context, id string) error
GetRuleByID(ctx context.Context, id string) (*AlertRule, error)
GetRulesByUserID(ctx context.Context, userID string) ([]AlertRule, error)
GetActiveRules(ctx context.Context) ([]AlertRule, error)
// 事件
CreateEvent(ctx context.Context, event *AlertEvent) error
UpdateEvent(ctx context.Context, event *AlertEvent) error
GetEventsByRuleID(ctx context.Context, ruleID string, limit int) ([]AlertEvent, error)
GetEventsByServerID(ctx context.Context, serverID string, limit int) ([]AlertEvent, error)
GetUnresolvedEvents(ctx context.Context, ruleID, serverID string) (*AlertEvent, error)
}
// alertRepository 告警仓储实现
type alertRepository struct {
db *gorm.DB
}
// NewRepository 创建告警仓储
func NewRepository(db *gorm.DB) AlertRepository {
return &alertRepository{db: db}
}
// CreateRule 创建规则
func (r *alertRepository) CreateRule(ctx context.Context, rule *AlertRule) error {
if rule.ID == "" {
rule.ID = uuid.New().String()
}
rule.CreatedAt = time.Now()
rule.UpdatedAt = time.Now()
return r.db.WithContext(ctx).Create(rule).Error
}
// UpdateRule 更新规则
func (r *alertRepository) UpdateRule(ctx context.Context, rule *AlertRule) error {
rule.UpdatedAt = time.Now()
return r.db.WithContext(ctx).Save(rule).Error
}
// DeleteRule 删除规则
func (r *alertRepository) DeleteRule(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&AlertRule{}, "id = ?", id).Error
}
// GetRuleByID 根据ID获取规则
func (r *alertRepository) GetRuleByID(ctx context.Context, id string) (*AlertRule, error) {
var rule AlertRule
err := r.db.WithContext(ctx).Where("id = ?", id).First(&rule).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &rule, nil
}
// GetRulesByUserID 根据用户ID获取规则
func (r *alertRepository) GetRulesByUserID(ctx context.Context, userID string) ([]AlertRule, error) {
var rules []AlertRule
err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&rules).Error
return rules, err
}
// GetActiveRules 获取所有活跃规则
func (r *alertRepository) GetActiveRules(ctx context.Context) ([]AlertRule, error) {
var rules []AlertRule
err := r.db.WithContext(ctx).Where("enabled = ?", true).Find(&rules).Error
return rules, err
}
// CreateEvent 创建事件
func (r *alertRepository) CreateEvent(ctx context.Context, event *AlertEvent) error {
if event.ID == "" {
event.ID = uuid.New().String()
}
event.CreatedAt = time.Now()
return r.db.WithContext(ctx).Create(event).Error
}
// UpdateEvent 更新事件
func (r *alertRepository) UpdateEvent(ctx context.Context, event *AlertEvent) error {
return r.db.WithContext(ctx).Save(event).Error
}
// GetEventsByRuleID 根据规则ID获取事件
func (r *alertRepository) GetEventsByRuleID(ctx context.Context, ruleID string, limit int) ([]AlertEvent, error) {
var events []AlertEvent
err := r.db.WithContext(ctx).
Where("rule_id = ?", ruleID).
Order("created_at DESC").
Limit(limit).
Find(&events).Error
return events, err
}
// GetEventsByServerID 根据服务器ID获取事件
func (r *alertRepository) GetEventsByServerID(ctx context.Context, serverID string, limit int) ([]AlertEvent, error) {
var events []AlertEvent
err := r.db.WithContext(ctx).
Where("server_id = ?", serverID).
Order("created_at DESC").
Limit(limit).
Find(&events).Error
return events, err
}
// GetUnresolvedEvents 获取未解决的事件
func (r *alertRepository) GetUnresolvedEvents(ctx context.Context, ruleID, serverID string) (*AlertEvent, error) {
var event AlertEvent
err := r.db.WithContext(ctx).
Where("rule_id = ? AND server_id = ? AND status = ?", ruleID, serverID, "firing").
Order("created_at DESC").
First(&event).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &event, nil
}
+24
View File
@@ -0,0 +1,24 @@
// Package alert 路由定义
package alert
import (
"github.com/gin-gonic/gin"
"github.com/nuyue/server/internal/middleware"
"github.com/nuyue/server/pkg/jwt"
)
// RegisterRoutes 注册告警路由
func RegisterRoutes(r *gin.RouterGroup, handler *Handler, jwtSvc *jwt.JWTService) {
alert := r.Group("/alert")
alert.Use(middleware.AuthMiddleware(jwtSvc))
{
// 告警规则
alert.GET("/rules", handler.ListRules)
alert.POST("/rules", handler.CreateRule)
alert.PUT("/rules/:id", handler.UpdateRule)
alert.DELETE("/rules/:id", handler.DeleteRule)
// 告警事件
alert.GET("/events", handler.ListEvents)
}
}
+15 -1
View File
@@ -14,6 +14,7 @@ type AlertService interface {
UpdateRule(ctx context.Context, userID, ruleID string, req *CreateAlertRuleRequest) (*AlertRule, error)
DeleteRule(ctx context.Context, userID, ruleID string) error
ListRules(ctx context.Context, userID string) (*AlertListResponse, error)
ListEvents(ctx context.Context, userID string) ([]AlertEvent, error)
// 告警检测
CheckMetrics(ctx context.Context, serverID string, metrics map[string]float64) []AlertEvent
@@ -94,10 +95,23 @@ func (s *alertService) ListRules(ctx context.Context, userID string) (*AlertList
return &AlertListResponse{
Rules: rules,
Events: events,
Total: len(rules),
Total: int64(len(rules)),
}, nil
}
func (s *alertService) ListEvents(ctx context.Context, userID string) ([]AlertEvent, error) {
var events []AlertEvent
if err := s.db.WithContext(ctx).
Joins("JOIN alert_rules ON alert_events.rule_id = alert_rules.id").
Where("alert_rules.user_id = ?", userID).
Order("alert_events.created_at DESC").
Limit(100).
Find(&events).Error; err != nil {
return nil, err
}
return events, nil
}
func (s *alertService) CheckMetrics(ctx context.Context, serverID string, metrics map[string]float64) []AlertEvent {
var rules []AlertRule
s.db.WithContext(ctx).Where("server_id = ? OR server_id = ''", serverID).Find(&rules)
-2
View File
@@ -2,8 +2,6 @@
package auth
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/nuyue/server/pkg/response"
)
+1 -1
View File
@@ -45,7 +45,7 @@ type RegisterRequest struct {
// LoginRequest 登录请求
type LoginRequest struct {
Username string `json:"username" binding:"required"`
PasswordHash string `json:"password_hash" binding:"required"`
Password string `json:"password" binding:"required"`
CaptchaToken string `json:"captcha_token"`
}
+21 -1
View File
@@ -3,6 +3,7 @@ package auth
import (
"context"
"log"
"time"
"github.com/nuyue/server/pkg/jwt"
@@ -89,17 +90,34 @@ func (s *authService) Register(ctx context.Context, req *RegisterRequest) (*User
// Login 用户登录
func (s *authService) Login(ctx context.Context, req *LoginRequest) (*LoginResponse, error) {
log.Printf("[Login] 尝试登录: username=%s, password=%s", req.Username, req.Password)
// 获取用户
user, err := s.repo.GetUserByUsername(ctx, req.Username)
if err != nil {
log.Printf("[Login] 获取用户失败: %v", err)
return nil, err
}
log.Printf("[Login] 找到用户: id=%s, username=%s, password_hash=%s", user.ID, user.Username, user.PasswordHash)
// 验证密码
if user.PasswordHash != req.PasswordHash {
// 目前安装时保存的是 "bcrypt:" + 明文密码 格式
storedPassword := user.PasswordHash
if len(storedPassword) > 7 && storedPassword[:7] == "bcrypt:" {
// 去掉 "bcrypt:" 前缀后比较
storedPassword = storedPassword[7:]
}
log.Printf("[Login] 密码比较: stored=%s, input=%s", storedPassword, req.Password)
if storedPassword != req.Password {
log.Printf("[Login] 密码不匹配")
return nil, ErrInvalidPassword
}
log.Printf("[Login] 密码匹配成功")
// 检查状态
if user.Status == "disabled" {
return nil, ErrUserDisabled
@@ -111,6 +129,8 @@ func (s *authService) Login(ctx context.Context, req *LoginRequest) (*LoginRespo
return nil, err
}
log.Printf("[Login] 登录成功: user_id=%s", user.ID)
return &LoginResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
+79
View File
@@ -0,0 +1,79 @@
// Package backup HTTP 处理层
package backup
import (
"github.com/gin-gonic/gin"
)
// Handler 备份处理器
type Handler struct {
repo BackupRepository
}
// NewHandler 创建处理器
func NewHandler(repo BackupRepository) *Handler {
return &Handler{repo: repo}
}
// ListBackups 获取备份列表
func (h *Handler) ListBackups(c *gin.Context) {
serverID := c.Param("server_id")
if serverID == "" {
c.JSON(400, gin.H{"code": 400, "message": "缺少服务器ID"})
return
}
records, err := h.repo.GetByServerID(c.Request.Context(), serverID, 50)
if err != nil {
c.JSON(500, gin.H{"code": 500, "message": err.Error()})
return
}
c.JSON(200, gin.H{"code": 0, "data": records})
}
// GetBackupStats 获取备份统计
func (h *Handler) GetBackupStats(c *gin.Context) {
serverID := c.Param("server_id")
if serverID == "" {
c.JSON(400, gin.H{"code": 400, "message": "缺少服务器ID"})
return
}
stats, err := h.repo.GetStats(c.Request.Context(), serverID)
if err != nil {
c.JSON(500, gin.H{"code": 500, "message": err.Error()})
return
}
c.JSON(200, gin.H{"code": 0, "data": stats})
}
// TriggerBackup 触发备份
func (h *Handler) TriggerBackup(c *gin.Context) {
serverID := c.Param("server_id")
if serverID == "" {
c.JSON(400, gin.H{"code": 400, "message": "缺少服务器ID"})
return
}
// TODO: 发送备份命令到 Agent
c.JSON(200, gin.H{"code": 0, "message": "备份任务已触发"})
}
// DeleteBackup 删除备份记录
func (h *Handler) DeleteBackup(c *gin.Context) {
backupID := c.Param("id")
if backupID == "" {
c.JSON(400, gin.H{"code": 400, "message": "缺少备份ID"})
return
}
if err := h.repo.Delete(c.Request.Context(), backupID); err != nil {
c.JSON(500, gin.H{"code": 500, "message": err.Error()})
return
}
c.JSON(200, gin.H{"code": 0, "message": "删除成功"})
}
+83
View File
@@ -0,0 +1,83 @@
// Package backup 备份管理模块
package backup
import (
"time"
)
// BackupRecord 备份记录
type BackupRecord struct {
ID string `json:"id" gorm:"primaryKey"`
ServerID string `json:"server_id" gorm:"index;not null"`
RepoName string `json:"repo_name" gorm:"size:100"`
TaskName string `json:"task_name" gorm:"size:100"`
TaskType string `json:"task_type" gorm:"size:20"` // folder, database, docker
SnapshotID string `json:"snapshot_id" gorm:"size:64"`
Success bool `json:"success"`
FilesNew int64 `json:"files_new"`
FilesChanged int64 `json:"files_changed"`
DataAdded int64 `json:"data_added_bytes"`
TotalSize int64 `json:"total_size_bytes"`
DurationMs int `json:"duration_ms"`
ErrorMessage string `json:"error_message" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
}
// TableName 表名
func (BackupRecord) TableName() string {
return "backup_records"
}
// BackupConfig 备份配置
type BackupConfig struct {
Enabled bool `json:"enabled"`
IntervalHours int `json:"interval_hours"`
MaxCount int `json:"max_count"`
Repos []BackupRepo `json:"repos"`
BackupTasks []BackupTask `json:"backup_tasks"`
}
// BackupRepo 备份仓库
type BackupRepo struct {
Name string `json:"name"`
Type string `json:"type"` // s3, sftp, ftp, webdav, local
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket"`
Prefix string `json:"prefix"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Region string `json:"region"`
Password string `json:"password"` // Restic 仓库密码
}
// BackupTask 备份任务
type BackupTask struct {
Name string `json:"name"`
Type string `json:"type"` // folder, database, docker
Paths []string `json:"paths"`
Exclude []string `json:"exclude"`
PreCommand string `json:"pre_command"`
PostCommand string `json:"post_command"`
DBType string `json:"db_type"` // mysql, postgres, mongodb
Containers []string `json:"containers"` // Docker 容器名
}
// TriggerBackupRequest 触发备份请求
type TriggerBackupRequest struct {
TaskName string `json:"task_name" binding:"required"`
}
// BackupListResponse 备份列表响应
type BackupListResponse struct {
List []BackupRecord `json:"list"`
Total int64 `json:"total"`
}
// BackupStatsResponse 备份统计响应
type BackupStatsResponse struct {
TotalBackups int64 `json:"total_backups"`
SuccessBackups int64 `json:"success_backups"`
FailedBackups int64 `json:"failed_backups"`
TotalSize int64 `json:"total_size"`
LastBackupAt *time.Time `json:"last_backup_at"`
}
+102
View File
@@ -0,0 +1,102 @@
// Package backup 数据访问层
package backup
import (
"context"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// BackupRepository 备份仓储接口
type BackupRepository interface {
Create(ctx context.Context, record *BackupRecord) error
GetByID(ctx context.Context, id string) (*BackupRecord, error)
GetByServerID(ctx context.Context, serverID string, limit int) ([]BackupRecord, error)
Delete(ctx context.Context, id string) error
GetStats(ctx context.Context, serverID string) (*BackupStatsResponse, error)
CleanupOldRecords(ctx context.Context, before time.Time) error
}
type backupRepository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) BackupRepository {
return &backupRepository{db: db}
}
func (r *backupRepository) Create(ctx context.Context, record *BackupRecord) error {
if record.ID == "" {
record.ID = uuid.New().String()
}
record.CreatedAt = time.Now()
return r.db.WithContext(ctx).Create(record).Error
}
func (r *backupRepository) GetByID(ctx context.Context, id string) (*BackupRecord, error) {
var record BackupRecord
err := r.db.WithContext(ctx).Where("id = ?", id).First(&record).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &record, nil
}
func (r *backupRepository) GetByServerID(ctx context.Context, serverID string, limit int) ([]BackupRecord, error) {
if limit <= 0 {
limit = 50
}
var records []BackupRecord
err := r.db.WithContext(ctx).
Where("server_id = ?", serverID).
Order("created_at DESC").
Limit(limit).
Find(&records).Error
return records, err
}
func (r *backupRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&BackupRecord{}, "id = ?", id).Error
}
func (r *backupRepository) GetStats(ctx context.Context, serverID string) (*BackupStatsResponse, error) {
stats := &BackupStatsResponse{}
r.db.WithContext(ctx).Model(&BackupRecord{}).
Where("server_id = ?", serverID).
Count(&stats.TotalBackups)
r.db.WithContext(ctx).Model(&BackupRecord{}).
Where("server_id = ? AND success = ?", serverID, true).
Count(&stats.SuccessBackups)
r.db.WithContext(ctx).Model(&BackupRecord{}).
Where("server_id = ? AND success = ?", serverID, false).
Count(&stats.FailedBackups)
r.db.WithContext(ctx).Model(&BackupRecord{}).
Where("server_id = ?", serverID).
Select("COALESCE(SUM(total_size_bytes), 0)").
Scan(&stats.TotalSize)
var lastBackup BackupRecord
if err := r.db.WithContext(ctx).
Where("server_id = ?", serverID).
Order("created_at DESC").
First(&lastBackup).Error; err == nil {
stats.LastBackupAt = &lastBackup.CreatedAt
}
return stats, nil
}
func (r *backupRepository) CleanupOldRecords(ctx context.Context, before time.Time) error {
return r.db.WithContext(ctx).
Where("created_at < ?", before).
Delete(&BackupRecord{}).Error
}
+20
View File
@@ -0,0 +1,20 @@
// Package backup 路由定义
package backup
import (
"github.com/gin-gonic/gin"
"github.com/nuyue/server/internal/middleware"
"github.com/nuyue/server/pkg/jwt"
)
// RegisterRoutes 注册备份路由
func RegisterRoutes(r *gin.RouterGroup, handler *Handler, jwtSvc *jwt.JWTService) {
backup := r.Group("/servers/:server_id/backups")
backup.Use(middleware.AuthMiddleware(jwtSvc))
{
backup.GET("", handler.ListBackups)
backup.GET("/stats", handler.GetBackupStats)
backup.POST("/trigger", handler.TriggerBackup)
backup.DELETE("/:id", handler.DeleteBackup)
}
}
+112 -10
View File
@@ -203,6 +203,9 @@ func (h *Handler) CreateAdmin(c *gin.Context) {
return
}
// 服务端哈希密码
req.PasswordHash = hashPassword(req.Password)
if err := h.svc.CreateAdmin(c.Request.Context(), &req); err != nil {
response.Error(c, 500, err.Error())
return
@@ -213,6 +216,13 @@ func (h *Handler) CreateAdmin(c *gin.Context) {
})
}
// hashPassword 哈希密码
func hashPassword(password string) string {
// 使用 bcrypt 哈希
// 这里简化处理,实际应该使用 bcrypt.GenerateFromPassword
return "bcrypt:" + password // TODO: 使用真正的 bcrypt
}
// Complete 完成安装
// @Summary 完成安装
// @Tags 安装
@@ -242,14 +252,106 @@ func (h *Handler) Complete(c *gin.Context) {
// @Success 200
// @Router /install [get]
func (h *Handler) InstallPage(c *gin.Context) {
// 检查是否已安装
if installed, _ := h.svc.IsInstalled(c.Request.Context()); installed {
c.Redirect(http.StatusFound, "/login")
return
}
// 返回安装页面(前端静态页面)
c.HTML(http.StatusOK, "install.html", gin.H{
"title": "怒月 - 安装向导",
})
// 返回安装页面(简单 HTML,后续替换为前端)
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>怒月 - 安装向导</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; background: #0a0a0a; color: #e0e0e0; }
h1 { color: #fff; }
.step { margin: 20px 0; padding: 20px; background: #1a1a1a; border-radius: 8px; }
input, select { width: 100%; padding: 10px; margin: 10px 0; background: #2a2a2a; border: 1px solid #3a3a3a; color: #fff; border-radius: 4px; box-sizing: border-box; }
button { padding: 12px 24px; background: #4a9eff; color: #fff; border: none; border-radius: 4px; cursor: pointer; margin-right: 10px; }
button:hover { background: #3a8eef; }
.success { color: #4ade80; }
.error { color: #f87171; }
#result { margin-top: 20px; padding: 15px; border-radius: 4px; }
</style>
</head>
<body>
<h1>🌙 怒月 - 安装向导</h1>
<div class="step">
<h3>步骤 1: 数据库配置</h3>
<label>数据库类型:</label>
<select id="dbType">
<option value="sqlite">SQLite (推荐)</option>
<option value="postgres">PostgreSQL</option>
</select>
<div id="sqliteConfig">
<label>数据库路径:</label>
<input type="text" id="dbPath" value="/data/nuyue.db">
</div>
<button onclick="testDB()">测试连接</button>
<span id="dbResult"></span>
</div>
<div class="step">
<h3>步骤 2: Redis 配置 (可选)</h3>
<label>启用 Redis:</label>
<select id="redisEnabled">
<option value="false">不启用</option>
<option value="true">启用</option>
</select>
<div id="redisConfig" style="display:none;">
<label>Redis 地址:</label>
<input type="text" id="redisHost" value="redis:6379">
</div>
<button onclick="testRedis()">测试连接</button>
<span id="redisResult"></span>
</div>
<div class="step">
<h3>步骤 3: 管理员账户</h3>
<label>用户名:</label>
<input type="text" id="adminUser" value="admin">
<label>密码:</label>
<input type="password" id="adminPass" value="">
<label>邮箱:</label>
<input type="email" id="adminEmail" value="">
</div>
<div class="step">
<button onclick="completeInstall()">完成安装</button>
</div>
<div id="result"></div>
<script>
document.getElementById('redisEnabled').onchange = function() {
document.getElementById('redisConfig').style.display = this.value === 'true' ? 'block' : 'none';
};
async function testDB() {
const res = await fetch('/api/v1/install/test-db', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({type: document.getElementById('dbType').value, path: document.getElementById('dbPath').value})
});
const data = await res.json();
document.getElementById('dbResult').textContent = data.code === 0 ? '✓ 连接成功' : '✗ ' + data.message;
document.getElementById('dbResult').className = data.code === 0 ? 'success' : 'error';
}
async function testRedis() {
const res = await fetch('/api/v1/install/test-redis', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled: document.getElementById('redisEnabled').value === 'true', host: document.getElementById('redisHost').value})
});
const data = await res.json();
document.getElementById('redisResult').textContent = data.code === 0 ? '✓ 连接成功' : '✗ ' + data.message;
document.getElementById('redisResult').className = data.code === 0 ? 'success' : 'error';
}
async function completeInstall() {
const res = await fetch('/api/v1/install/complete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
database: {type: document.getElementById('dbType').value, path: document.getElementById('dbPath').value},
redis: {enabled: document.getElementById('redisEnabled').value === 'true', host: document.getElementById('redisHost').value},
admin: {username: document.getElementById('adminUser').value, password: document.getElementById('adminPass').value, email: document.getElementById('adminEmail').value}
})
});
const data = await res.json();
document.getElementById('result').innerHTML = data.code === 0 ? '<span class="success">✓ 安装完成!<a href="/login">点击登录</a></span>' : '<span class="error">✗ ' + data.message + '</span>';
}
</script>
</body>
</html>`)
}
+4 -3
View File
@@ -33,10 +33,11 @@ type RedisConfigRequest struct {
// AdminConfigRequest 管理员配置请求
type AdminConfigRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
PasswordHash string `json:"password_hash" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
Email string `json:"email" binding:"omitempty,email"`
EncryptedConfigKey string `json:"encrypted_config_key" binding:"required"`
ConfigKeyNonce string `json:"config_key_nonce" binding:"required"`
PasswordHash string `json:"-"` // 服务端生成
EncryptedConfigKey string `json:"encrypted_config_key"`
ConfigKeyNonce string `json:"config_key_nonce"`
}
// DatabaseTestRequest 数据库连接测试请求
+230 -3
View File
@@ -4,6 +4,7 @@ package install
import (
"context"
"encoding/json"
"log"
"time"
"github.com/google/uuid"
@@ -42,8 +43,14 @@ func NewRepository(db *gorm.DB) InstallRepository {
func (r *installRepository) GetStatus(ctx context.Context) (*InstallStatus, error) {
var status InstallStatus
// 先确保表存在
if err := r.ensureTablesExist(ctx); err != nil {
log.Printf("GetStatus: ensureTablesExist error: %v", err)
return &status, err
}
// 从 system_settings 表读取
var dbType, redisEnabled string
var dbType, redisEnabled, installed string
if err := r.db.WithContext(ctx).
Table("system_settings").
@@ -61,11 +68,21 @@ func (r *installRepository) GetStatus(ctx context.Context) (*InstallStatus, erro
return nil, err
}
status.Installed = false
if err := r.db.WithContext(ctx).
Table("system_settings").
Select("value").
Where("key = ?", "installed").
Scan(&installed).Error; err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
status.Installed = installed == "true"
status.DatabaseType = dbType
status.RedisEnabled = redisEnabled == "true"
if status.DatabaseType != "" {
if status.Installed {
status.Step = "complete"
} else if status.DatabaseType != "" {
if status.RedisEnabled || redisEnabled == "false" {
status.Step = "admin"
} else {
@@ -144,6 +161,12 @@ func (r *installRepository) LockInstall(ctx context.Context) error {
// IsInstalled 检查是否已安装
func (r *installRepository) IsInstalled(ctx context.Context) (bool, error) {
// 先确保表存在
if err := r.ensureTablesExist(ctx); err != nil {
log.Printf("ensureTablesExist error: %v", err)
return false, err
}
var value string
err := r.db.WithContext(ctx).
Table("system_settings").
@@ -151,10 +174,13 @@ func (r *installRepository) IsInstalled(ctx context.Context) (bool, error) {
Where("key = ?", "installed").
Scan(&value).Error
log.Printf("IsInstalled query: value=%s, err=%v", value, err)
if err == gorm.ErrRecordNotFound {
return false, nil
}
if err != nil {
log.Printf("IsInstalled error: %v", err)
return false, err
}
@@ -163,6 +189,11 @@ func (r *installRepository) IsInstalled(ctx context.Context) (bool, error) {
// upsertSetting 插入或更新设置
func (r *installRepository) upsertSetting(ctx context.Context, key, value string) error {
// 先确保表存在
if err := r.ensureTablesExist(ctx); err != nil {
return err
}
now := time.Now()
result := r.db.WithContext(ctx).
@@ -188,6 +219,202 @@ func (r *installRepository) upsertSetting(ctx context.Context, key, value string
return nil
}
// ensureTablesExist 确保必要的表存在
func (r *installRepository) ensureTablesExist(ctx context.Context) error {
// 创建 system_settings 表
if err := r.db.WithContext(ctx).Exec(`
CREATE TABLE IF NOT EXISTS system_settings (
id TEXT PRIMARY KEY,
category TEXT,
key TEXT UNIQUE,
value TEXT,
created_at DATETIME,
updated_at DATETIME
)
`).Error; err != nil {
return err
}
// 创建 users 表
if err := r.db.WithContext(ctx).Exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE,
password_hash TEXT,
email TEXT UNIQUE,
email_verified BOOLEAN DEFAULT FALSE,
avatar_url TEXT,
role TEXT DEFAULT 'user',
status TEXT DEFAULT 'active',
encrypted_config_key TEXT,
config_key_nonce TEXT,
tg_chat_id INTEGER,
tg_username TEXT,
tg_bound_at DATETIME,
preference_show_remaining_value BOOLEAN DEFAULT TRUE,
preference_tg_notify_enabled BOOLEAN DEFAULT FALSE,
created_at DATETIME,
updated_at DATETIME
)
`).Error; err != nil {
return err
}
// 创建 servers 表
if err := r.db.WithContext(ctx).Exec(`
CREATE TABLE IF NOT EXISTS servers (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT UNIQUE NOT NULL,
display_name TEXT,
region TEXT,
tags TEXT,
agent_token TEXT UNIQUE,
encrypted_config TEXT,
config_nonce TEXT,
config_version TEXT,
status TEXT DEFAULT 'offline',
last_seen_at DATETIME,
created_at DATETIME,
updated_at DATETIME
)
`).Error; err != nil {
return err
}
// 创建 server_metrics 表
if err := r.db.WithContext(ctx).Exec(`
CREATE TABLE IF NOT EXISTS server_metrics (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL,
cpu_usage REAL,
memory_total INTEGER,
memory_used INTEGER,
disk_total INTEGER,
disk_used INTEGER,
network_rx INTEGER,
network_tx INTEGER,
load_1 REAL,
load_5 REAL,
load_15 REAL,
uptime INTEGER,
os_info TEXT,
process_count INTEGER,
cpu_temp REAL,
gpu_info TEXT,
timestamp DATETIME,
created_at DATETIME
)
`).Error; err != nil {
return err
}
// 创建 alert_rules 表
if err := r.db.WithContext(ctx).Exec(`
CREATE TABLE IF NOT EXISTS alert_rules (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
server_id TEXT,
name TEXT NOT NULL,
description TEXT,
metric_type TEXT NOT NULL,
operator TEXT NOT NULL,
threshold REAL,
duration_seconds INTEGER DEFAULT 0,
cooldown_seconds INTEGER DEFAULT 300,
is_enabled BOOLEAN DEFAULT TRUE,
created_at DATETIME,
updated_at DATETIME
)
`).Error; err != nil {
return err
}
// 创建 alert_events 表
if err := r.db.WithContext(ctx).Exec(`
CREATE TABLE IF NOT EXISTS alert_events (
id TEXT PRIMARY KEY,
rule_id TEXT NOT NULL,
server_id TEXT,
message TEXT,
severity TEXT,
status TEXT DEFAULT 'pending',
triggered_at DATETIME,
resolved_at DATETIME,
created_at DATETIME
)
`).Error; err != nil {
return err
}
// 创建 probe_pages 表
if err := r.db.WithContext(ctx).Exec(`
CREATE TABLE IF NOT EXISTS probe_pages (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL UNIQUE,
slug TEXT UNIQUE NOT NULL,
title TEXT,
sub_title TEXT,
logo_url TEXT,
description TEXT,
footer_text TEXT,
footer_link_url TEXT,
footer_link_text TEXT,
theme_id TEXT DEFAULT 'dark',
primary_color TEXT,
layout_columns INTEGER DEFAULT 0,
visible_components TEXT,
visible_server_ids TEXT,
custom_css TEXT,
custom_js TEXT,
is_published BOOLEAN DEFAULT FALSE,
is_public BOOLEAN DEFAULT TRUE,
created_at DATETIME,
updated_at DATETIME
)
`).Error; err != nil {
return err
}
// 创建 plans 表
if err := r.db.WithContext(ctx).Exec(`
CREATE TABLE IF NOT EXISTS plans (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
price_monthly REAL,
price_yearly REAL,
max_servers INTEGER DEFAULT 5,
max_alerts INTEGER DEFAULT 10,
features TEXT,
is_active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at DATETIME,
updated_at DATETIME
)
`).Error; err != nil {
return err
}
// 创建 email_verification_codes 表
if err := r.db.WithContext(ctx).Exec(`
CREATE TABLE IF NOT EXISTS email_verification_codes (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
code TEXT NOT NULL,
purpose TEXT NOT NULL,
expires_at DATETIME,
used BOOLEAN DEFAULT FALSE,
ip_address TEXT,
created_at DATETIME
)
`).Error; err != nil {
return err
}
return nil
}
func boolToStr(b bool) string {
if b {
return "true"
+10 -14
View File
@@ -5,20 +5,16 @@ import (
"github.com/gin-gonic/gin"
)
// RegisterRoutes 注册安装路由
func RegisterRoutes(r *gin.Engine, h *Handler) {
// 安装页面
r.GET("/install", h.InstallPage)
// 安装 API
installAPI := r.Group("/api/v1/install")
// RegisterAPIRoutes 注册安装 API 路由
func RegisterAPIRoutes(api *gin.RouterGroup, h *Handler) {
install := api.Group("/install")
{
installAPI.GET("/status", h.GetStatus)
installAPI.POST("/test-db", h.TestDatabase)
installAPI.POST("/test-redis", h.TestRedis)
installAPI.POST("/database", h.ConfigureDatabase)
installAPI.POST("/redis", h.ConfigureRedis)
installAPI.POST("/admin", h.CreateAdmin)
installAPI.POST("/complete", h.Complete)
install.GET("/status", h.GetStatus)
install.POST("/test-db", h.TestDatabase)
install.POST("/test-redis", h.TestRedis)
install.POST("/database", h.ConfigureDatabase)
install.POST("/redis", h.ConfigureRedis)
install.POST("/admin", h.CreateAdmin)
install.POST("/complete", h.Complete)
}
}
+150
View File
@@ -0,0 +1,150 @@
// Package monitor HTTP 处理层
package monitor
import (
"time"
"github.com/gin-gonic/gin"
"github.com/nuyue/server/pkg/response"
)
// Handler 监控处理器
type Handler struct {
svc MonitorService
}
// NewHandler 创建监控处理器
func NewHandler(svc MonitorService) *Handler {
return &Handler{svc: svc}
}
// GetDashboardStats 获取控制台统计
// @Summary 获取控制台统计
// @Tags 监控
// @Security BearerAuth
// @Success 200 {object} response.Response{data=DashboardStats}
// @Router /api/v1/monitor/dashboard [get]
func (h *Handler) GetDashboardStats(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未认证")
return
}
stats, err := h.svc.GetDashboardStats(c.Request.Context(), userID)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, stats)
}
// GetServerMetrics 获取服务器指标
// @Summary 获取服务器最新指标
// @Tags 监控
// @Security BearerAuth
// @Param server_id query string true "服务器ID"
// @Success 200 {object} response.Response{data=ServerStats}
// @Router /api/v1/monitor/metrics [get]
func (h *Handler) GetServerMetrics(c *gin.Context) {
serverID := c.Query("server_id")
if serverID == "" {
response.BadRequest(c, "缺少 server_id 参数")
return
}
stats, err := h.svc.GetLatestMetrics(c.Request.Context(), serverID)
if err != nil {
response.Error(c, 500, err.Error())
return
}
if stats == nil {
response.NotFound(c, "暂无指标数据")
return
}
response.Success(c, stats)
}
// GetMetricsHistory 获取指标历史
// @Summary 获取指标历史
// @Tags 监控
// @Security BearerAuth
// @Param server_id query string true "服务器ID"
// @Param start_time query string false "开始时间 (RFC3339)"
// @Param end_time query string false "结束时间 (RFC3339)"
// @Param limit query int false "数量限制" default(100)
// @Success 200 {object} response.Response{data=MetricsQueryResponse}
// @Router /api/v1/monitor/metrics/history [get]
func (h *Handler) GetMetricsHistory(c *gin.Context) {
var req MetricsQueryRequest
if err := c.ShouldBindQuery(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
// 解析时间
if startTime := c.Query("start_time"); startTime != "" {
t, err := time.Parse(time.RFC3339, startTime)
if err == nil {
req.StartTime = t
}
}
if endTime := c.Query("end_time"); endTime != "" {
t, err := time.Parse(time.RFC3339, endTime)
if err == nil {
req.EndTime = t
}
}
data, err := h.svc.GetMetricsHistory(c.Request.Context(), &req)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, data)
}
// GetTCPingResults 获取 TCPing 结果
// @Summary 获取 TCPing 结果
// @Tags 监控
// @Security BearerAuth
// @Param server_id query string true "服务器ID"
// @Param limit query int false "数量限制" default(50)
// @Success 200 {object} response.Response{data=[]TCPingResult}
// @Router /api/v1/monitor/tcping [get]
func (h *Handler) GetTCPingResults(c *gin.Context) {
serverID := c.Query("server_id")
if serverID == "" {
response.BadRequest(c, "缺少 server_id 参数")
return
}
limit := 50
if l := c.Query("limit"); l != "" {
if parsed, err := parseIntParam(l); err == nil {
limit = parsed
}
}
results, err := h.svc.GetLatestTCPing(c.Request.Context(), serverID, limit)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, results)
}
func parseIntParam(s string) (int, error) {
var result int
_, err := sscanf(s, "%d", &result)
return result, err
}
func sscanf(s string, format string, a ...interface{}) (int, error) {
return 0, nil // 简化实现
}
+89
View File
@@ -0,0 +1,89 @@
// Package monitor 监控模块
package monitor
import (
"time"
)
// MetricsQueryRequest 指标查询请求
type MetricsQueryRequest struct {
ServerID string `form:"server_id" binding:"required"`
StartTime time.Time `form:"start_time"`
EndTime time.Time `form:"end_time"`
Limit int `form:"limit" binding:"omitempty,min=1,max=1000"`
}
// MetricsQueryResponse 指标查询响应
type MetricsQueryResponse struct {
ServerID string `json:"server_id"`
Metrics []MetricPoint `json:"metrics"`
}
// MetricPoint 指标数据点
type MetricPoint struct {
Timestamp time.Time `json:"timestamp"`
CPUUsage float64 `json:"cpu_usage"`
MemoryUsed int64 `json:"memory_used"`
MemoryTotal int64 `json:"memory_total"`
DiskUsed int64 `json:"disk_used"`
DiskTotal int64 `json:"disk_total"`
NetworkRx int64 `json:"network_rx"`
NetworkTx int64 `json:"network_tx"`
Load1 float64 `json:"load_1"`
Load5 float64 `json:"load_5"`
Load15 float64 `json:"load_15"`
CPUTemp float64 `json:"cpu_temp"`
}
// ServerStats 服务器统计
type ServerStats struct {
ServerID string `json:"server_id"`
CPUUsage float64 `json:"cpu_usage"`
MemoryUsed int64 `json:"memory_used"`
MemoryTotal int64 `json:"memory_total"`
MemoryPercent float64 `json:"memory_percent"`
DiskUsed int64 `json:"disk_used"`
DiskTotal int64 `json:"disk_total"`
DiskPercent float64 `json:"disk_percent"`
NetworkRx int64 `json:"network_rx"`
NetworkTx int64 `json:"network_tx"`
Load1 float64 `json:"load_1"`
Uptime int64 `json:"uptime"`
ProcessCount int32 `json:"process_count"`
CPUTemp float64 `json:"cpu_temp"`
}
// DashboardStats 控制台统计
type DashboardStats struct {
TotalServers int64 `json:"total_servers"`
OnlineServers int64 `json:"online_servers"`
OfflineServers int64 `json:"offline_servers"`
AlertEvents int64 `json:"alert_events"`
}
// TCPingQueryRequest TCPing 查询请求
type TCPingQueryRequest struct {
ServerID string `form:"server_id" binding:"required"`
Limit int `form:"limit" binding:"omitempty,min=1,max=100"`
}
// TCPingResult TCPing 结果
type TCPingResult struct {
TargetHost string `json:"target_host"`
TargetPort int `json:"target_port"`
LatencyMs float64 `json:"latency_ms"`
Success bool `json:"success"`
Timestamp time.Time `json:"timestamp"`
}
// TCPingStats TCPing 统计
type TCPingStats struct {
TargetHost string `json:"target_host"`
TargetPort int `json:"target_port"`
AvgLatency float64 `json:"avg_latency"`
MinLatency float64 `json:"min_latency"`
MaxLatency float64 `json:"max_latency"`
LossRate float64 `json:"loss_rate"` // 丢包率 0-100
TotalCount int `json:"total_count"`
SuccessCount int `json:"success_count"`
}
+204
View File
@@ -0,0 +1,204 @@
// Package monitor 数据访问层
package monitor
import (
"context"
"time"
"gorm.io/gorm"
)
// MonitorRepository 监控仓储接口
type MonitorRepository interface {
// 指标
GetLatestMetrics(ctx context.Context, serverID string) (*ServerStats, error)
GetMetricsHistory(ctx context.Context, serverID string, start, end time.Time, limit int) ([]MetricPoint, error)
GetAllLatestMetrics(ctx context.Context, serverIDs []string) (map[string]*ServerStats, error)
// TCPing
GetLatestTCPing(ctx context.Context, serverID string, limit int) ([]TCPingResult, error)
GetTCPingStats(ctx context.Context, serverID string, host string, port int, duration time.Duration) (*TCPingStats, error)
// 清理
CleanupOldMetrics(ctx context.Context, before time.Time) error
}
// monitorRepository 监控仓储实现
type monitorRepository struct {
db *gorm.DB
}
// NewRepository 创建监控仓储
func NewRepository(db *gorm.DB) MonitorRepository {
return &monitorRepository{db: db}
}
// GetLatestMetrics 获取最新指标
func (r *monitorRepository) GetLatestMetrics(ctx context.Context, serverID string) (*ServerStats, error) {
var metrics struct {
CPUUsage float64
MemoryTotal int64
MemoryUsed int64
DiskTotal int64
DiskUsed int64
NetworkRx int64
NetworkTx int64
Load1 float64
Uptime int64
ProcessCount int32
CPUTemp float64
}
err := r.db.WithContext(ctx).
Table("server_metrics").
Select("cpu_usage, memory_total, memory_used, disk_total, disk_used, network_rx, network_tx, load_1, uptime, process_count, cpu_temp").
Where("server_id = ?", serverID).
Order("timestamp DESC").
First(&metrics).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
stats := &ServerStats{
ServerID: serverID,
CPUUsage: metrics.CPUUsage,
MemoryTotal: metrics.MemoryTotal,
MemoryUsed: metrics.MemoryUsed,
DiskTotal: metrics.DiskTotal,
DiskUsed: metrics.DiskUsed,
NetworkRx: metrics.NetworkRx,
NetworkTx: metrics.NetworkTx,
Load1: metrics.Load1,
Uptime: metrics.Uptime,
ProcessCount: metrics.ProcessCount,
CPUTemp: metrics.CPUTemp,
}
// 计算百分比
if stats.MemoryTotal > 0 {
stats.MemoryPercent = float64(stats.MemoryUsed) / float64(stats.MemoryTotal) * 100
}
if stats.DiskTotal > 0 {
stats.DiskPercent = float64(stats.DiskUsed) / float64(stats.DiskTotal) * 100
}
return stats, nil
}
// GetMetricsHistory 获取指标历史
func (r *monitorRepository) GetMetricsHistory(ctx context.Context, serverID string, start, end time.Time, limit int) ([]MetricPoint, error) {
if limit <= 0 {
limit = 100
}
var metrics []MetricPoint
err := r.db.WithContext(ctx).
Table("server_metrics").
Select("timestamp, cpu_usage, memory_total, memory_used, disk_total, disk_used, network_rx, network_tx, load_1, load_5, load_15, cpu_temp").
Where("server_id = ? AND timestamp >= ? AND timestamp <= ?", serverID, start, end).
Order("timestamp DESC").
Limit(limit).
Find(&metrics).Error
return metrics, err
}
// GetAllLatestMetrics 获取多个服务器的最新指标
func (r *monitorRepository) GetAllLatestMetrics(ctx context.Context, serverIDs []string) (map[string]*ServerStats, error) {
result := make(map[string]*ServerStats)
for _, id := range serverIDs {
stats, err := r.GetLatestMetrics(ctx, id)
if err != nil {
continue
}
if stats != nil {
result[id] = stats
}
}
return result, nil
}
// GetLatestTCPing 获取最新 TCPing 结果
func (r *monitorRepository) GetLatestTCPing(ctx context.Context, serverID string, limit int) ([]TCPingResult, error) {
if limit <= 0 {
limit = 50
}
var results []TCPingResult
err := r.db.WithContext(ctx).
Table("tcping_results").
Select("target_host, target_port, latency_ms, success, timestamp").
Where("server_id = ?", serverID).
Order("timestamp DESC").
Limit(limit).
Find(&results).Error
return results, err
}
// GetTCPingStats 获取 TCPing 统计
func (r *monitorRepository) GetTCPingStats(ctx context.Context, serverID string, host string, port int, duration time.Duration) (*TCPingStats, error) {
start := time.Now().Add(-duration)
var stats struct {
TotalCount int
SuccessCount int
AvgLatency float64
MinLatency float64
MaxLatency float64
}
err := r.db.WithContext(ctx).
Table("tcping_results").
Select(`
COUNT(*) as total_count,
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count,
AVG(CASE WHEN success = 1 THEN latency_ms ELSE NULL END) as avg_latency,
MIN(CASE WHEN success = 1 THEN latency_ms ELSE NULL END) as min_latency,
MAX(CASE WHEN success = 1 THEN latency_ms ELSE NULL END) as max_latency
`).
Where("server_id = ? AND target_host = ? AND target_port = ? AND timestamp >= ?",
serverID, host, port, start).
Scan(&stats).Error
if err != nil {
return nil, err
}
result := &TCPingStats{
TargetHost: host,
TargetPort: port,
TotalCount: stats.TotalCount,
SuccessCount: stats.SuccessCount,
AvgLatency: stats.AvgLatency,
MinLatency: stats.MinLatency,
MaxLatency: stats.MaxLatency,
}
if stats.TotalCount > 0 {
result.LossRate = float64(stats.TotalCount-stats.SuccessCount) / float64(stats.TotalCount) * 100
}
return result, nil
}
// CleanupOldMetrics 清理旧指标
func (r *monitorRepository) CleanupOldMetrics(ctx context.Context, before time.Time) error {
// 删除 server_metrics
if err := r.db.WithContext(ctx).
Where("timestamp < ?", before).
Delete(&struct{ TableName string }{TableName: "server_metrics"}).Error; err != nil {
return err
}
// 删除 tcping_results
return r.db.WithContext(ctx).
Where("timestamp < ?", before).
Delete(&struct{ TableName string }{TableName: "tcping_results"}).Error
}
+25
View File
@@ -0,0 +1,25 @@
// Package monitor 路由定义
package monitor
import (
"github.com/gin-gonic/gin"
"github.com/nuyue/server/internal/middleware"
"github.com/nuyue/server/pkg/jwt"
)
// RegisterRoutes 注册监控路由
func RegisterRoutes(r *gin.RouterGroup, handler *Handler, jwtSvc *jwt.JWTService) {
monitor := r.Group("/monitor")
monitor.Use(middleware.AuthMiddleware(jwtSvc))
{
// 控制台统计
monitor.GET("/dashboard", handler.GetDashboardStats)
// 指标
monitor.GET("/metrics", handler.GetServerMetrics)
monitor.GET("/metrics/history", handler.GetMetricsHistory)
// TCPing
monitor.GET("/tcping", handler.GetTCPingResults)
}
}
+98
View File
@@ -0,0 +1,98 @@
// Package monitor 业务逻辑层
package monitor
import (
"context"
"time"
"github.com/nuyue/server/internal/mod/server"
)
// MonitorService 监控服务接口
type MonitorService interface {
// 指标
GetLatestMetrics(ctx context.Context, serverID string) (*ServerStats, error)
GetMetricsHistory(ctx context.Context, req *MetricsQueryRequest) (*MetricsQueryResponse, error)
GetDashboardStats(ctx context.Context, userID string) (*DashboardStats, error)
// TCPing
GetLatestTCPing(ctx context.Context, serverID string, limit int) ([]TCPingResult, error)
}
// monitorService 监控服务实现
type monitorService struct {
repo MonitorRepository
serverRepo server.ServerRepository
}
// NewService 创建监控服务
func NewService(repo MonitorRepository, serverRepo server.ServerRepository) MonitorService {
return &monitorService{
repo: repo,
serverRepo: serverRepo,
}
}
// GetLatestMetrics 获取最新指标
func (s *monitorService) GetLatestMetrics(ctx context.Context, serverID string) (*ServerStats, error) {
return s.repo.GetLatestMetrics(ctx, serverID)
}
// GetMetricsHistory 获取指标历史
func (s *monitorService) GetMetricsHistory(ctx context.Context, req *MetricsQueryRequest) (*MetricsQueryResponse, error) {
// 设置默认时间范围
if req.StartTime.IsZero() {
req.StartTime = time.Now().Add(-1 * time.Hour)
}
if req.EndTime.IsZero() {
req.EndTime = time.Now()
}
if req.Limit <= 0 {
req.Limit = 100
}
metrics, err := s.repo.GetMetricsHistory(ctx, req.ServerID, req.StartTime, req.EndTime, req.Limit)
if err != nil {
return nil, err
}
return &MetricsQueryResponse{
ServerID: req.ServerID,
Metrics: metrics,
}, nil
}
// GetDashboardStats 获取控制台统计
func (s *monitorService) GetDashboardStats(ctx context.Context, userID string) (*DashboardStats, error) {
// 获取用户的服务器列表
servers, err := s.serverRepo.GetByUserID(ctx, userID)
if err != nil {
return nil, err
}
stats := &DashboardStats{
TotalServers: int64(len(servers)),
}
// 统计在线/离线
for _, srv := range servers {
if srv.Status == "online" {
stats.OnlineServers++
} else {
stats.OfflineServers++
}
}
// TODO: 获取告警事件数量
stats.AlertEvents = 0
return stats, nil
}
// GetLatestTCPing 获取最新 TCPing 结果
func (s *monitorService) GetLatestTCPing(ctx context.Context, serverID string, limit int) ([]TCPingResult, error) {
if limit <= 0 {
limit = 50
}
return s.repo.GetLatestTCPing(ctx, serverID, limit)
}
+59
View File
@@ -0,0 +1,59 @@
// Package notify 路由定义
package notify
import (
"github.com/gin-gonic/gin"
"github.com/nuyue/server/internal/middleware"
"github.com/nuyue/server/pkg/jwt"
)
// Handler 通知处理器
type Handler struct {
svc *NotifyService
}
// NewHandler 创建通知处理器
func NewHandler(svc *NotifyService) *Handler {
return &Handler{svc: svc}
}
// TestNotify 测试通知
func (h *Handler) TestNotify(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(401, gin.H{"code": 401, "message": "未认证"})
return
}
var req struct {
Channel string `json:"channel" binding:"required"`
Message string `json:"message" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"code": 400, "message": "参数错误"})
return
}
msg := &NotificationMessage{
Title: "测试通知",
Content: req.Message,
Level: "info",
}
if err := h.svc.Dispatch(c.Request.Context(), userID, NotifyChannel(req.Channel), msg); err != nil {
c.JSON(500, gin.H{"code": 500, "message": err.Error()})
return
}
c.JSON(200, gin.H{"code": 0, "message": "发送成功"})
}
// RegisterRoutes 注册通知路由
func RegisterRoutes(r *gin.RouterGroup, handler *Handler, jwtSvc *jwt.JWTService) {
notify := r.Group("/notify")
notify.Use(middleware.AuthMiddleware(jwtSvc))
{
notify.POST("/test", handler.TestNotify)
}
}
+156
View File
@@ -0,0 +1,156 @@
// Package notify 通知模块
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// NotifyChannel 通知渠道类型
type NotifyChannel string
const (
NotifyChannelTelegram NotifyChannel = "telegram"
NotifyChannelEmail NotifyChannel = "email"
)
// NotificationDispatcher 通知分发器接口
type NotificationDispatcher interface {
Dispatch(ctx context.Context, userID string, channel NotifyChannel, message *NotificationMessage) error
DispatchAlert(ctx context.Context, userID string, event *AlertEvent) error
}
// NotificationMessage 通知消息
type NotificationMessage struct {
Title string `json:"title"`
Content string `json:"content"`
Level string `json:"level"` // info, warning, error
}
// AlertEvent 告警事件
type AlertEvent struct {
ID string
RuleID string
ServerID string
Status string // firing, resolved
Value float64
Message string
FiredAt *time.Time
ResolvedAt *time.Time
}
// NotifyService 通知服务
type NotifyService struct {
tgBotToken string
httpClient *http.Client
}
// NewService 创建通知服务
func NewService(tgBotToken string) *NotifyService {
return &NotifyService{
tgBotToken: tgBotToken,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// Dispatch 发送通知
func (s *NotifyService) Dispatch(ctx context.Context, userID string, channel NotifyChannel, message *NotificationMessage) error {
switch channel {
case NotifyChannelTelegram:
return s.sendTelegram(ctx, userID, message)
case NotifyChannelEmail:
return s.sendEmail(ctx, userID, message)
default:
return fmt.Errorf("不支持的通知渠道: %s", channel)
}
}
// DispatchAlert 发送告警通知
func (s *NotifyService) DispatchAlert(ctx context.Context, userID string, event *AlertEvent) error {
// 构建告警消息
var emoji string
var title string
if event.Status == "firing" {
emoji = "🔴"
title = "告警触发"
} else {
emoji = "🟢"
title = "告警恢复"
}
message := &NotificationMessage{
Title: fmt.Sprintf("%s %s", emoji, title),
Content: event.Message,
Level: "warning",
}
return s.sendTelegram(ctx, userID, message)
}
// sendTelegram 发送 Telegram 消息
func (s *NotifyService) sendTelegram(ctx context.Context, chatID string, message *NotificationMessage) error {
if s.tgBotToken == "" {
return fmt.Errorf("Telegram Bot Token 未配置")
}
// 构建消息体
text := fmt.Sprintf("*%s*\n\n%s", message.Title, message.Content)
body := map[string]interface{}{
"chat_id": chatID,
"text": text,
"parse_mode": "Markdown",
}
jsonBody, err := json.Marshal(body)
if err != nil {
return err
}
// 发送请求
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", s.tgBotToken)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("Telegram API 错误: %s", string(respBody))
}
return nil
}
// sendEmail 发送邮件
func (s *NotifyService) sendEmail(ctx context.Context, email string, message *NotificationMessage) error {
// TODO: 实现邮件发送
return fmt.Errorf("邮件通知暂未实现")
}
// NotifyChannelModel 通知渠道模型
type NotifyChannelModel struct {
ID string `json:"id" gorm:"primaryKey"`
UserID string `json:"user_id" gorm:"index"`
Type NotifyChannel `json:"type" gorm:"size:20"`
Name string `json:"name" gorm:"size:100"`
Config string `json:"config" gorm:"type:text"` // JSON 配置
IsEnabled bool `json:"is_enabled" gorm:"default:true"`
CreatedAt time.Time `json:"created_at"`
}
// TableName 表名
func (NotifyChannelModel) TableName() string {
return "notify_channels"
}
+142
View File
@@ -0,0 +1,142 @@
// Package payment HTTP 处理层
package payment
import (
"github.com/gin-gonic/gin"
"github.com/nuyue/server/pkg/response"
)
// Handler 支付处理器
type Handler struct {
svc PaymentService
}
// NewHandler 创建处理器
func NewHandler(svc PaymentService) *Handler {
return &Handler{svc: svc}
}
// CreateOrder 创建订单
func (h *Handler) CreateOrder(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未认证")
return
}
var req CreateOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
resp, err := h.svc.CreateOrder(c.Request.Context(), userID, &req)
if err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, resp)
}
// GetOrder 获取订单详情
func (h *Handler) GetOrder(c *gin.Context) {
orderNo := c.Param("order_no")
if orderNo == "" {
response.BadRequest(c, "缺少订单号")
return
}
resp, err := h.svc.GetOrder(c.Request.Context(), orderNo)
if err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, resp)
}
// GetUserOrders 获取用户订单列表
func (h *Handler) GetUserOrders(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未认证")
return
}
orders, err := h.svc.GetUserOrders(c.Request.Context(), userID, 20)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, orders)
}
// AlipayCallback 支付宝回调
func (h *Handler) AlipayCallback(c *gin.Context) {
// TODO: 解析支付宝回调数据
req := &PaymentCallbackRequest{
Channel: "alipay",
}
if err := h.svc.HandleAlipayCallback(c.Request.Context(), req); err != nil {
c.String(500, "fail")
return
}
c.String(200, "success")
}
// WechatCallback 微信回调
func (h *Handler) WechatCallback(c *gin.Context) {
// TODO: 解析微信回调数据
req := &PaymentCallbackRequest{
Channel: "wechat",
}
if err := h.svc.HandleWechatCallback(c.Request.Context(), req); err != nil {
c.String(500, "fail")
return
}
c.String(200, "success")
}
// StripeCallback Stripe 回调
func (h *Handler) StripeCallback(c *gin.Context) {
// TODO: 解析 Stripe 回调数据
req := &PaymentCallbackRequest{
Channel: "stripe",
}
if err := h.svc.HandleStripeCallback(c.Request.Context(), req); err != nil {
c.String(500, "fail")
return
}
c.String(200, "success")
}
// Refund 退款(管理员)
func (h *Handler) Refund(c *gin.Context) {
orderNo := c.Param("order_no")
if orderNo == "" {
response.BadRequest(c, "缺少订单号")
return
}
var req RefundRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
resp, err := h.svc.Refund(c.Request.Context(), orderNo, &req)
if err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, resp)
}
+98
View File
@@ -0,0 +1,98 @@
// Package payment 支付模块
package payment
import (
"time"
)
// PaymentOrder 支付订单
type PaymentOrder struct {
ID string `json:"id" gorm:"primaryKey"`
UserID string `json:"user_id" gorm:"index;not null"`
PlanID string `json:"plan_id" gorm:"index;not null"`
UserPlanID *string `json:"user_plan_id"`
OrderNo string `json:"order_no" gorm:"uniqueIndex;size:32;not null"`
Channel string `json:"channel" gorm:"size:20;not null"` // alipay, wechat, stripe
Amount float64 `json:"amount" gorm:"type:decimal(10,2);not null"`
Currency string `json:"currency" gorm:"size:10;default:CNY"`
BillingType string `json:"billing_type" gorm:"size:20;not null"` // monthly, yearly, lifetime
Type string `json:"type" gorm:"size:20;default:purchase"` // purchase, upgrade
Status string `json:"status" gorm:"size:20;default:pending"` // pending, paid, failed, refunded, expired
ChannelTradeNo string `json:"channel_trade_no" gorm:"size:100"`
PaidAt *time.Time `json:"paid_at"`
ExpiresAt *time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 表名
func (PaymentOrder) TableName() string {
return "payment_orders"
}
// CreateOrderRequest 创建订单请求
type CreateOrderRequest struct {
PlanID string `json:"plan_id" binding:"required"`
BillingType string `json:"billing_type" binding:"required,oneof=monthly yearly lifetime"`
Channel string `json:"channel" binding:"required,oneof=alipay wechat stripe"`
}
// CreateOrderResponse 创建订单响应
type CreateOrderResponse struct {
OrderNo string `json:"order_no"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
PayURL string `json:"pay_url"`
QRCodeURL string `json:"qr_code_url,omitempty"`
}
// OrderDetailResponse 订单详情响应
type OrderDetailResponse struct {
PaymentOrder
PlanName string `json:"plan_name"`
}
// PaymentCallbackRequest 支付回调请求
type PaymentCallbackRequest struct {
Channel string
TradeNo string
OrderNo string
Amount float64
PaidAt time.Time
RawData []byte
}
// RefundRequest 退款请求
type RefundRequest struct {
Reason string `json:"reason" binding:"required"`
}
// RefundResponse 退款响应
type RefundResponse struct {
RefundNo string `json:"refund_no"`
Status string `json:"status"`
}
// PaymentConfig 支付配置
type PaymentConfig struct {
// 支付宝
AlipayEnabled bool `json:"alipay_enabled"`
AlipayAppID string `json:"alipay_app_id"`
AlipayPrivateKey string `json:"alipay_private_key"`
AlipayPublicKey string `json:"alipay_public_key"`
AlipayNotifyURL string `json:"alipay_notify_url"`
AlipayReturnURL string `json:"alipay_return_url"`
// 微信支付
WechatEnabled bool `json:"wechat_enabled"`
WechatMchID string `json:"wechat_mch_id"`
WechatAPIKey string `json:"wechat_api_key"`
WechatCertSN string `json:"wechat_cert_sn"`
WechatNotifyURL string `json:"wechat_notify_url"`
// Stripe
StripeEnabled bool `json:"stripe_enabled"`
StripePublishableKey string `json:"stripe_publishable_key"`
StripeSecretKey string `json:"stripe_secret_key"`
StripeWebhookSecret string `json:"stripe_webhook_secret"`
}
+120
View File
@@ -0,0 +1,120 @@
// Package payment 数据访问层
package payment
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
var (
ErrOrderNotFound = errors.New("订单不存在")
ErrOrderExpired = errors.New("订单已过期")
ErrOrderAlreadyPaid = errors.New("订单已支付")
)
// PaymentRepository 支付仓储接口
type PaymentRepository interface {
Create(ctx context.Context, order *PaymentOrder) error
Update(ctx context.Context, order *PaymentOrder) error
GetByID(ctx context.Context, id string) (*PaymentOrder, error)
GetByOrderNo(ctx context.Context, orderNo string) (*PaymentOrder, error)
GetByUserID(ctx context.Context, userID string, limit int) ([]PaymentOrder, error)
GetPendingOrders(ctx context.Context) ([]PaymentOrder, error)
MarkExpired(ctx context.Context, orderNo string) error
MarkPaid(ctx context.Context, orderNo string, tradeNo string, paidAt time.Time) error
}
type paymentRepository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) PaymentRepository {
return &paymentRepository{db: db}
}
func (r *paymentRepository) Create(ctx context.Context, order *PaymentOrder) error {
if order.ID == "" {
order.ID = uuid.New().String()
}
if order.OrderNo == "" {
order.OrderNo = generateOrderNo()
}
order.CreatedAt = time.Now()
order.UpdatedAt = time.Now()
return r.db.WithContext(ctx).Create(order).Error
}
func (r *paymentRepository) Update(ctx context.Context, order *PaymentOrder) error {
order.UpdatedAt = time.Now()
return r.db.WithContext(ctx).Save(order).Error
}
func (r *paymentRepository) GetByID(ctx context.Context, id string) (*PaymentOrder, error) {
var order PaymentOrder
err := r.db.WithContext(ctx).Where("id = ?", id).First(&order).Error
if err == gorm.ErrRecordNotFound {
return nil, ErrOrderNotFound
}
if err != nil {
return nil, err
}
return &order, nil
}
func (r *paymentRepository) GetByOrderNo(ctx context.Context, orderNo string) (*PaymentOrder, error) {
var order PaymentOrder
err := r.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&order).Error
if err == gorm.ErrRecordNotFound {
return nil, ErrOrderNotFound
}
if err != nil {
return nil, err
}
return &order, nil
}
func (r *paymentRepository) GetByUserID(ctx context.Context, userID string, limit int) ([]PaymentOrder, error) {
var orders []PaymentOrder
err := r.db.WithContext(ctx).
Where("user_id = ?", userID).
Order("created_at DESC").
Limit(limit).
Find(&orders).Error
return orders, err
}
func (r *paymentRepository) GetPendingOrders(ctx context.Context) ([]PaymentOrder, error) {
var orders []PaymentOrder
err := r.db.WithContext(ctx).
Where("status = ? AND expires_at > ?", "pending", time.Now()).
Find(&orders).Error
return orders, err
}
func (r *paymentRepository) MarkExpired(ctx context.Context, orderNo string) error {
return r.db.WithContext(ctx).Model(&PaymentOrder{}).
Where("order_no = ? AND status = ?", orderNo, "pending").
Updates(map[string]interface{}{
"status": "expired",
"updated_at": time.Now(),
}).Error
}
func (r *paymentRepository) MarkPaid(ctx context.Context, orderNo string, tradeNo string, paidAt time.Time) error {
return r.db.WithContext(ctx).Model(&PaymentOrder{}).
Where("order_no = ? AND status = ?", orderNo, "pending").
Updates(map[string]interface{}{
"status": "paid",
"channel_trade_no": tradeNo,
"paid_at": paidAt,
"updated_at": time.Now(),
}).Error
}
func generateOrderNo() string {
return "NY" + time.Now().Format("yyyyMMddHHmmss") + uuid.New().String()[:8]
}
+33
View File
@@ -0,0 +1,33 @@
// Package payment 路由定义
package payment
import (
"github.com/gin-gonic/gin"
"github.com/nuyue/server/internal/middleware"
"github.com/nuyue/server/pkg/jwt"
)
// RegisterRoutes 注册支付路由
func RegisterRoutes(r *gin.RouterGroup, handler *Handler, jwtSvc *jwt.JWTService) {
// 支付回调(无需认证)
r.POST("/payment/notify/alipay", handler.AlipayCallback)
r.POST("/payment/notify/wechat", handler.WechatCallback)
r.POST("/payment/notify/stripe", handler.StripeCallback)
// 用户支付接口
payment := r.Group("/payment")
payment.Use(middleware.AuthMiddleware(jwtSvc))
{
payment.POST("/orders", handler.CreateOrder)
payment.GET("/orders", handler.GetUserOrders)
payment.GET("/orders/:order_no", handler.GetOrder)
}
// 管理员接口
admin := r.Group("/admin/payment")
admin.Use(middleware.AuthMiddleware(jwtSvc))
admin.Use(middleware.AdminMiddleware())
{
admin.POST("/orders/:order_no/refund", handler.Refund)
}
}
+239
View File
@@ -0,0 +1,239 @@
// Package payment 业务逻辑层
package payment
import (
"context"
"errors"
"time"
)
// PaymentService 支付服务接口
type PaymentService interface {
// 订单管理
CreateOrder(ctx context.Context, userID string, req *CreateOrderRequest) (*CreateOrderResponse, error)
GetOrder(ctx context.Context, orderNo string) (*OrderDetailResponse, error)
GetUserOrders(ctx context.Context, userID string, limit int) ([]PaymentOrder, error)
// 支付回调
HandleAlipayCallback(ctx context.Context, req *PaymentCallbackRequest) error
HandleWechatCallback(ctx context.Context, req *PaymentCallbackRequest) error
HandleStripeCallback(ctx context.Context, req *PaymentCallbackRequest) error
// 退款
Refund(ctx context.Context, orderNo string, req *RefundRequest) (*RefundResponse, error)
}
type paymentService struct {
repo PaymentRepository
planRepo PlanRepository
subSvc SubscriptionService
config *PaymentConfig
}
// PlanRepository 套餐仓储接口
type PlanRepository interface {
GetByID(ctx context.Context, id string) (*Plan, error)
}
// Plan 套餐模型
type Plan struct {
ID string
Name string
PriceMonthly float64
PriceYearly float64
PriceLifetime float64
}
// SubscriptionService 订阅服务接口
type SubscriptionService interface {
HandlePaymentSuccess(ctx context.Context, orderID string) error
}
func NewService(repo PaymentRepository, planRepo PlanRepository, subSvc SubscriptionService, config *PaymentConfig) PaymentService {
return &paymentService{
repo: repo,
planRepo: planRepo,
subSvc: subSvc,
config: config,
}
}
// CreateOrder 创建订单
func (s *paymentService) CreateOrder(ctx context.Context, userID string, req *CreateOrderRequest) (*CreateOrderResponse, error) {
// 获取套餐
plan, err := s.planRepo.GetByID(ctx, req.PlanID)
if err != nil {
return nil, ErrPlanNotFound
}
// 计算金额
var amount float64
switch req.BillingType {
case "monthly":
amount = plan.PriceMonthly
case "yearly":
amount = plan.PriceYearly
case "lifetime":
amount = plan.PriceLifetime
}
// 创建订单
order := &PaymentOrder{
UserID: userID,
PlanID: req.PlanID,
Channel: req.Channel,
Amount: amount,
Currency: "CNY",
BillingType: req.BillingType,
Type: "purchase",
Status: "pending",
ExpiresAt: timePtr(time.Now().Add(30 * time.Minute)),
}
if err := s.repo.Create(ctx, order); err != nil {
return nil, err
}
// 生成支付链接
payURL, qrCodeURL := s.generatePayURL(order)
return &CreateOrderResponse{
OrderNo: order.OrderNo,
Amount: order.Amount,
Currency: order.Currency,
PayURL: payURL,
QRCodeURL: qrCodeURL,
}, nil
}
// GetOrder 获取订单详情
func (s *paymentService) GetOrder(ctx context.Context, orderNo string) (*OrderDetailResponse, error) {
order, err := s.repo.GetByOrderNo(ctx, orderNo)
if err != nil {
return nil, err
}
plan, _ := s.planRepo.GetByID(ctx, order.PlanID)
planName := ""
if plan != nil {
planName = plan.Name
}
return &OrderDetailResponse{
PaymentOrder: *order,
PlanName: planName,
}, nil
}
// GetUserOrders 获取用户订单列表
func (s *paymentService) GetUserOrders(ctx context.Context, userID string, limit int) ([]PaymentOrder, error) {
if limit <= 0 {
limit = 20
}
return s.repo.GetByUserID(ctx, userID, limit)
}
// HandleAlipayCallback 处理支付宝回调
func (s *paymentService) HandleAlipayCallback(ctx context.Context, req *PaymentCallbackRequest) error {
// TODO: 验证签名
return s.handlePaymentSuccess(ctx, req.OrderNo, req.TradeNo, req.PaidAt)
}
// HandleWechatCallback 处理微信回调
func (s *paymentService) HandleWechatCallback(ctx context.Context, req *PaymentCallbackRequest) error {
// TODO: 验证签名
return s.handlePaymentSuccess(ctx, req.OrderNo, req.TradeNo, req.PaidAt)
}
// HandleStripeCallback 处理 Stripe 回调
func (s *paymentService) HandleStripeCallback(ctx context.Context, req *PaymentCallbackRequest) error {
// TODO: 验证签名
return s.handlePaymentSuccess(ctx, req.OrderNo, req.TradeNo, req.PaidAt)
}
// handlePaymentSuccess 处理支付成功
func (s *paymentService) handlePaymentSuccess(ctx context.Context, orderNo string, tradeNo string, paidAt time.Time) error {
order, err := s.repo.GetByOrderNo(ctx, orderNo)
if err != nil {
return err
}
if order.Status != "pending" {
return ErrOrderAlreadyPaid
}
if order.ExpiresAt != nil && time.Now().After(*order.ExpiresAt) {
return ErrOrderExpired
}
// 更新订单状态
if err := s.repo.MarkPaid(ctx, orderNo, tradeNo, paidAt); err != nil {
return err
}
// 处理订阅
if err := s.subSvc.HandlePaymentSuccess(ctx, order.ID); err != nil {
return err
}
return nil
}
// Refund 退款
func (s *paymentService) Refund(ctx context.Context, orderNo string, req *RefundRequest) (*RefundResponse, error) {
order, err := s.repo.GetByOrderNo(ctx, orderNo)
if err != nil {
return nil, err
}
if order.Status != "paid" {
return nil, errors.New("订单状态不允许退款")
}
// TODO: 调用支付渠道退款接口
order.Status = "refunded"
order.UpdatedAt = time.Now()
if err := s.repo.Update(ctx, order); err != nil {
return nil, err
}
return &RefundResponse{
RefundNo: "RF" + time.Now().Format("yyyyMMddHHmmss"),
Status: "success",
}, nil
}
// generatePayURL 生成支付链接
func (s *paymentService) generatePayURL(order *PaymentOrder) (string, string) {
switch order.Channel {
case "alipay":
return s.generateAlipayURL(order)
case "wechat":
return s.generateWechatURL(order)
case "stripe":
return s.generateStripeURL(order)
}
return "", ""
}
func (s *paymentService) generateAlipayURL(order *PaymentOrder) (string, string) {
// TODO: 生成支付宝支付链接
return "", ""
}
func (s *paymentService) generateWechatURL(order *PaymentOrder) (string, string) {
// TODO: 生成微信支付链接
return "", ""
}
func (s *paymentService) generateStripeURL(order *PaymentOrder) (string, string) {
// TODO: 生成 Stripe 支付链接
return "", ""
}
func timePtr(t time.Time) *time.Time {
return &t
}
var ErrPlanNotFound = errors.New("套餐不存在")
+3 -14
View File
@@ -3,6 +3,7 @@ package plan
import (
"context"
"errors"
)
// PlanService 套餐服务接口
@@ -102,7 +103,7 @@ func (s *planService) Update(ctx context.Context, id string, req *UpdatePlanRequ
// Delete 删除套餐
func (s *planService) Delete(ctx context.Context, id string) error {
plan, err := s.repo.GetByID(ctx, id)
_, err := s.repo.GetByID(ctx, id)
if err != nil {
return err
}
@@ -145,7 +146,7 @@ func (s *planService) GetActive(ctx context.Context) (*PlanListResponse, error)
return &PlanListResponse{
List: plans,
Total: len(plans),
Total: int64(len(plans)),
}, nil
}
@@ -166,16 +167,4 @@ func (s *planService) GetPrice(ctx context.Context, id string, billingType Billi
default:
return plan.PriceMonthly, nil
}
}
var errors = struct {
New func(string) error
}{New: func(msg string) error { return &planError{msg: msg} }}
type planError struct {
msg string
}
func (e *planError) Error() string {
return e.msg
}
+7 -25
View File
@@ -60,31 +60,13 @@ func (h *Handler) PublicProbePage(c *gin.Context) {
response.Success(c, page)
}
// RegisterRoutes 注册探针页面路由
func RegisterRoutes(r *gin.RouterGroup, h *Handler) {
// 用户探针页面配置
probeAPI := r.Group("/probe-page")
probeAPI.Use(authMiddleware())
{
probeAPI.GET("", h.GetProbePage)
probeAPI.PUT("", h.UpdateProbePage)
}
}
func (h *Handler) PublishProbePage(c *gin.Context) {
userID := c.GetString("user_id")
// RegisterPublicRoute 注册公开路由
func RegisterPublicRoute(r *gin.Engine, h *Handler) {
r.GET("/probe/:slug", h.PublicProbePage)
}
func authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
c.JSON(401, gin.H{"code": 401, "message": "未登录"})
c.Abort()
return
}
c.Set("user_id", "test-user-id")
c.Next()
if err := h.svc.Publish(c.Request.Context(), userID); err != nil {
response.Error(c, 400, err.Error())
return
}
response.SuccessWithMessage(c, "发布成功", nil)
}
+4 -1
View File
@@ -13,15 +13,18 @@ type ProbePage struct {
Title string `json:"title" gorm:"size:100"`
SubTitle string `json:"sub_title" gorm:"size:200"`
LogoURL string `json:"logo_url" gorm:"size:500"`
Description string `json:"description" gorm:"type:text"`
FooterText string `json:"footer_text" gorm:"size:200"`
FooterLinkURL string `json:"footer_link_url" gorm:"size:500"`
FooterLinkText string `json:"footer_link_text" gorm:"size:50"`
ThemeID string `json:"theme_id" gorm:"size:30"` // default, dark, termius 等
PrimaryColor string `json:"primary_color" gorm:"size:7"` // 自定义主色
LayoutColumns int `json:"layout_columns"` // 卡片网格列数
VisibleComponents string `json:"visible_components" gorm:"type:json"` // 显示组件配置
VisibleComponents string `json:"visible_components" gorm:"type:text"` // 显示组件配置 JSON
VisibleServerIDs string `json:"visible_server_ids" gorm:"type:text"` // 可见服务器 ID 列表 JSON
CustomCSS string `json:"custom_css" gorm:"type:text"`
CustomJS string `json:"custom_js" gorm:"type:text"`
IsPublished bool `json:"is_published" gorm:"default:false"`
IsPublic bool `json:"is_public" gorm:"default:true"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+20
View File
@@ -0,0 +1,20 @@
// Package probepage 路由定义
package probepage
import (
"github.com/gin-gonic/gin"
"github.com/nuyue/server/internal/middleware"
"github.com/nuyue/server/pkg/jwt"
)
// RegisterRoutes 注册探针页面路由
func RegisterRoutes(r *gin.RouterGroup, handler *Handler, jwtSvc *jwt.JWTService) {
// 用户配置接口
probe := r.Group("/probe-page")
probe.Use(middleware.AuthMiddleware(jwtSvc))
{
probe.GET("", handler.GetProbePage)
probe.PUT("", handler.UpdateProbePage)
probe.POST("/publish", handler.PublishProbePage)
}
}
+11 -1
View File
@@ -5,7 +5,6 @@ import (
"context"
"errors"
"strings"
"time"
)
type ProbePageService interface {
@@ -13,6 +12,7 @@ type ProbePageService interface {
GetBySlug(ctx context.Context, slug string) (*ProbePageResponse, error)
Update(ctx context.Context, userID string, req *UpdateProbePageRequest) (*ProbePage, error)
CreateDefault(ctx context.Context, userID string) (*ProbePage, error)
Publish(ctx context.Context, userID string) error
}
type probePageService struct {
@@ -100,6 +100,16 @@ func (s *probePageService) CreateDefault(ctx context.Context, userID string) (*P
return page, nil
}
func (s *probePageService) Publish(ctx context.Context, userID string) error {
page, err := s.repo.GetByUserID(ctx, userID)
if err != nil {
return err
}
page.IsPublished = true
return s.repo.Update(ctx, page)
}
func generateSlug(userID string) string {
if len(userID) < 8 {
return strings.ToLower(userID)
+64 -26
View File
@@ -141,34 +141,72 @@ func (h *Handler) GetMetrics(c *gin.Context) {
response.Success(c, metrics)
}
// RegisterRoutes 注册服务器路由
func RegisterRoutes(r *gin.RouterGroup, h *Handler) {
servers := r.Group("/servers")
servers.Use(authMiddleware())
{
servers.GET("", h.ListServers)
servers.POST("", h.AddServer)
servers.GET("/:id", h.GetServer)
servers.PUT("/:id", h.UpdateServer)
servers.DELETE("/:id", h.DeleteServer)
servers.GET("/:id/metrics", h.GetMetrics)
servers.POST("/:id/install-command", h.GenerateInstallCommand)
// GetServerMetrics 获取服务器最新指标
func (h *Handler) GetServerMetrics(c *gin.Context) {
serverID := c.Param("id")
userID := c.GetString("user_id")
// 验证服务器所有权
_, err := h.svc.GetServer(c.Request.Context(), userID, serverID)
if err != nil {
response.NotFound(c, err.Error())
return
}
metrics, err := h.svc.GetLatestMetrics(c.Request.Context(), serverID)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, metrics)
}
// authMiddleware 认证中间件
func authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
c.JSON(401, gin.H{"code": 401, "message": "未登录"})
c.Abort()
return
}
// 简化:直接设置 user_id
// TODO: 实际验证 JWT
c.Set("user_id", "test-user-id")
c.Next()
// GetMetricsHistory 获取指标历史
func (h *Handler) GetMetricsHistory(c *gin.Context) {
serverID := c.Param("id")
userID := c.GetString("user_id")
// 验证服务器所有权
_, err := h.svc.GetServer(c.Request.Context(), userID, serverID)
if err != nil {
response.NotFound(c, err.Error())
return
}
// 解析时间参数
startStr := c.DefaultQuery("start", time.Now().Add(-24*time.Hour).Format(time.RFC3339))
endStr := c.DefaultQuery("end", time.Now().Format(time.RFC3339))
start, _ := time.Parse(time.RFC3339, startStr)
end, _ := time.Parse(time.RFC3339, endStr)
metrics, err := h.svc.GetMetrics(c.Request.Context(), serverID, start, end)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, metrics)
}
// GetTCPingResults 获取 TCPing 结果
func (h *Handler) GetTCPingResults(c *gin.Context) {
serverID := c.Param("id")
userID := c.GetString("user_id")
// 验证服务器所有权
_, err := h.svc.GetServer(c.Request.Context(), userID, serverID)
if err != nil {
response.NotFound(c, err.Error())
return
}
results, err := h.svc.GetTCPingResults(c.Request.Context(), serverID, 50)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, results)
}
+10 -3
View File
@@ -101,9 +101,16 @@ type ServerDetailResponse struct {
// TCPingResult TCPing 结果
type TCPingResult struct {
TargetHost string `json:"target_host"`
TargetPort int32 `json:"target_port"`
ID string `json:"id" gorm:"primaryKey"`
ServerID string `json:"server_id" gorm:"index;not null"`
TargetHost string `json:"target_host" gorm:"size:255;not null"`
TargetPort int `json:"target_port" gorm:"not null"`
LatencyMs float64 `json:"latency_ms"`
Success bool `json:"success"`
Timestamp time.Time `json:"timestamp"`
Timestamp time.Time `json:"timestamp" gorm:"index"`
}
// TableName 表名
func (TCPingResult) TableName() string {
return "tcping_results"
}
+14
View File
@@ -37,6 +37,9 @@ type ServerRepository interface {
SaveMetrics(ctx context.Context, metrics *ServerMetrics) error
GetLatestMetrics(ctx context.Context, serverID string) (*ServerMetrics, error)
GetMetricsHistory(ctx context.Context, serverID string, start, end time.Time, limit int) ([]ServerMetrics, error)
// TCPing
GetTCPingResults(ctx context.Context, serverID string, limit int) ([]TCPingResult, error)
}
// serverRepository 服务器仓储实现
@@ -173,6 +176,17 @@ func (r *serverRepository) GetMetricsHistory(ctx context.Context, serverID strin
return metrics, err
}
// GetTCPingResults 获取 TCPing 结果
func (r *serverRepository) GetTCPingResults(ctx context.Context, serverID string, limit int) ([]TCPingResult, error) {
var results []TCPingResult
err := r.db.WithContext(ctx).
Where("server_id = ?", serverID).
Order("timestamp DESC").
Limit(limit).
Find(&results).Error
return results, err
}
// generateToken 生成随机 Token
func generateToken(length int) string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+32
View File
@@ -0,0 +1,32 @@
// Package server 路由定义
package server
import (
"github.com/gin-gonic/gin"
"github.com/nuyue/server/internal/middleware"
"github.com/nuyue/server/pkg/jwt"
)
// RegisterRoutes 注册服务器路由
func RegisterRoutes(r *gin.RouterGroup, handler *Handler, jwtSvc *jwt.JWTService) {
servers := r.Group("/servers")
servers.Use(middleware.AuthMiddleware(jwtSvc))
{
// 服务器管理
servers.GET("", handler.ListServers)
servers.POST("", handler.AddServer)
servers.GET("/:id", handler.GetServer)
servers.PUT("/:id", handler.UpdateServer)
servers.DELETE("/:id", handler.DeleteServer)
// 安装命令
servers.POST("/:id/install-command", handler.GenerateInstallCommand)
// 指标查询
servers.GET("/:id/metrics", handler.GetServerMetrics)
servers.GET("/:id/metrics/history", handler.GetMetricsHistory)
// TCPing 查询
servers.GET("/:id/tcping", handler.GetTCPingResults)
}
}
+20 -1
View File
@@ -18,6 +18,10 @@ type ServerService interface {
// 指标
SaveMetrics(ctx context.Context, agentToken string, metrics *ServerMetrics) error
GetMetrics(ctx context.Context, serverID string, start, end time.Time) ([]ServerMetrics, error)
GetLatestMetrics(ctx context.Context, serverID string) (*ServerMetrics, error)
// TCPing
GetTCPingResults(ctx context.Context, serverID string, limit int) ([]TCPingResult, error)
// Agent 认证
AuthenticateAgent(ctx context.Context, agentToken string) (*Server, error)
@@ -25,7 +29,12 @@ type ServerService interface {
type serverService struct {
repo ServerRepository
planRepo PlanRepository
}
// Plan 套餐信息(用于限制检查)
type Plan struct {
ID string
MaxServers int
}
// PlanRepository 套餐仓储接口(用于检查限制)
@@ -193,6 +202,16 @@ func (s *serverService) AuthenticateAgent(ctx context.Context, agentToken string
return s.repo.GetByAgentToken(ctx, agentToken)
}
// GetLatestMetrics 获取最新指标
func (s *serverService) GetLatestMetrics(ctx context.Context, serverID string) (*ServerMetrics, error) {
return s.repo.GetLatestMetrics(ctx, serverID)
}
// GetTCPingResults 获取 TCPing 结果
func (s *serverService) GetTCPingResults(ctx context.Context, serverID string, limit int) ([]TCPingResult, error) {
return s.repo.GetTCPingResults(ctx, serverID, limit)
}
// stringifyTags 序列化标签
func stringifyTags(tags []string) string {
if len(tags) == 0 {
+1 -1
View File
@@ -66,7 +66,7 @@ func (s *settingService) UpdateByKey(ctx context.Context, key, value string) err
// TestSMTP 测试 SMTP
func (s *settingService) TestSMTP(ctx context.Context, req *TestSMTPRequest) (*TestSMTPResponse, error) {
// 获取 SMTP 配置
smtpConfig, err := s.GetSMTPConfig(ctx)
_, err := s.GetSMTPConfig(ctx)
if err != nil {
return &TestSMTPResponse{
Success: false,
+123
View File
@@ -0,0 +1,123 @@
// Package subscription HTTP 处理层
package subscription
import (
"github.com/gin-gonic/gin"
"github.com/nuyue/server/pkg/response"
)
// Handler 订阅处理器
type Handler struct {
svc SubscriptionService
}
// NewHandler 创建订阅处理器
func NewHandler(svc SubscriptionService) *Handler {
return &Handler{svc: svc}
}
// Purchase 购买套餐
func (h *Handler) Purchase(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未认证")
return
}
var req PurchaseRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
resp, err := h.svc.Purchase(c.Request.Context(), userID, &req)
if err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, resp)
}
// Upgrade 升级套餐
func (h *Handler) Upgrade(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未认证")
return
}
var req UpgradeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
resp, err := h.svc.Upgrade(c.Request.Context(), userID, &req)
if err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, resp)
}
// Redeem 兑换码兑换
func (h *Handler) Redeem(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未认证")
return
}
var req RedeemRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
resp, err := h.svc.Redeem(c.Request.Context(), userID, &req)
if err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, resp)
}
// GetCurrentSubscription 获取当前订阅
func (h *Handler) GetCurrentSubscription(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未认证")
return
}
resp, err := h.svc.GetCurrentSubscription(c.Request.Context(), userID)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, resp)
}
// GetHistory 获取订阅历史
func (h *Handler) GetHistory(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未认证")
return
}
page := 1
pageSize := 20
resp, err := h.svc.GetHistory(c.Request.Context(), userID, page, pageSize)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, resp)
}
+114
View File
@@ -0,0 +1,114 @@
// Package subscription 订阅模块
package subscription
import (
"time"
)
// Subscription 订阅模型
type Subscription struct {
ID string `json:"id" gorm:"primaryKey"`
UserID string `json:"user_id" gorm:"index;not null"`
PlanID string `json:"plan_id" gorm:"index;not null"`
BillingType string `json:"billing_type" gorm:"size:20"` // monthly, yearly, lifetime
Status string `json:"status" gorm:"size:20"` // active, expired, cancelled
StartedAt time.Time `json:"started_at"`
ExpiresAt *time.Time `json:"expires_at"` // NULL 表示永久
AutoRenew bool `json:"auto_renew" gorm:"default:false"`
UpgradedFrom *string `json:"upgraded_from"` // 升级来源订阅 ID
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 表名
func (Subscription) TableName() string {
return "subscriptions"
}
// PurchaseRequest 购买套餐请求
type PurchaseRequest struct {
PlanID string `json:"plan_id" binding:"required"`
BillingType string `json:"billing_type" binding:"required,oneof=monthly yearly lifetime"`
PaymentMethod string `json:"payment_method" binding:"required,oneof=alipay wechat stripe"`
}
// PurchaseResponse 购买响应
type PurchaseResponse struct {
OrderID string `json:"order_id"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
PayURL string `json:"pay_url"` // 支付链接
}
// UpgradeRequest 升级套餐请求
type UpgradeRequest struct {
NewPlanID string `json:"new_plan_id" binding:"required"`
BillingType string `json:"billing_type" binding:"required,oneof=monthly yearly lifetime"`
PaymentMethod string `json:"payment_method" binding:"required,oneof=alipay wechat stripe"`
}
// UpgradeResponse 升级响应
type UpgradeResponse struct {
OrderID string `json:"order_id"`
PriceDiff float64 `json:"price_diff"` // 差价
RemainingValue float64 `json:"remaining_value"` // 原套餐剩余价值
NewAmount float64 `json:"new_amount"` // 需支付的金额
PayURL string `json:"pay_url"`
}
// RedeemRequest 兑换码兑换请求
type RedeemRequest struct {
Code string `json:"code" binding:"required"`
}
// RedeemResponse 兑换响应
type RedeemResponse struct {
SubscriptionID string `json:"subscription_id"`
PlanName string `json:"plan_name"`
BillingType string `json:"billing_type"`
ExpiresAt *time.Time `json:"expires_at"`
}
// CurrentSubscriptionResponse 当前订阅响应
type CurrentSubscriptionResponse struct {
Subscription SubscriptionDetail `json:"subscription"`
Plan PlanInfo `json:"plan"`
RemainingDays int `json:"remaining_days"`
RemainingValue float64 `json:"remaining_value"`
CanUpgrade bool `json:"can_upgrade"`
}
// SubscriptionDetail 订阅详情
type SubscriptionDetail struct {
ID string `json:"id"`
BillingType string `json:"billing_type"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
ExpiresAt *time.Time `json:"expires_at"`
}
// PlanInfo 套餐信息
type PlanInfo struct {
ID string `json:"id"`
Name string `json:"name"`
MaxClients int `json:"max_clients"`
MaxTCPingNodes int `json:"max_tcping_nodes"`
MaxBackupRepos int `json:"max_backup_repos"`
}
// SubscriptionHistoryResponse 订阅历史响应
type SubscriptionHistoryResponse struct {
List []SubscriptionHistoryItem `json:"list"`
Total int64 `json:"total"`
}
// SubscriptionHistoryItem 订阅历史项
type SubscriptionHistoryItem struct {
ID string `json:"id"`
PlanName string `json:"plan_name"`
BillingType string `json:"billing_type"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
ExpiresAt *time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
@@ -0,0 +1,155 @@
// Package subscription 数据访问层
package subscription
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
var (
ErrSubscriptionNotFound = errors.New("订阅不存在")
ErrPlanNotFound = errors.New("套餐不存在")
ErrRedeemCodeInvalid = errors.New("兑换码无效或已过期")
ErrRedeemCodeUsedUp = errors.New("兑换码已用完")
ErrAlreadySubscribed = errors.New("已有有效订阅")
ErrCannotUpgrade = errors.New("无法升级")
)
// SubscriptionRepository 订阅仓储接口
type SubscriptionRepository interface {
// 订阅
Create(ctx context.Context, sub *Subscription) error
Update(ctx context.Context, sub *Subscription) error
GetByID(ctx context.Context, id string) (*Subscription, error)
GetByUserID(ctx context.Context, userID string) ([]Subscription, error)
GetActiveByUserID(ctx context.Context, userID string) (*Subscription, error)
// 兑换码
GetRedeemCodeByCode(ctx context.Context, code string) (*RedeemCode, error)
UseRedeemCode(ctx context.Context, codeID string) error
CreateRedeemRecord(ctx context.Context, record *RedeemRecord) error
// 统计
CountActiveByUserID(ctx context.Context, userID string) (int64, error)
}
type subscriptionRepository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) SubscriptionRepository {
return &subscriptionRepository{db: db}
}
func (r *subscriptionRepository) Create(ctx context.Context, sub *Subscription) error {
if sub.ID == "" {
sub.ID = uuid.New().String()
}
sub.CreatedAt = time.Now()
sub.UpdatedAt = time.Now()
return r.db.WithContext(ctx).Create(sub).Error
}
func (r *subscriptionRepository) Update(ctx context.Context, sub *Subscription) error {
sub.UpdatedAt = time.Now()
return r.db.WithContext(ctx).Save(sub).Error
}
func (r *subscriptionRepository) GetByID(ctx context.Context, id string) (*Subscription, error) {
var sub Subscription
err := r.db.WithContext(ctx).Where("id = ?", id).First(&sub).Error
if err == gorm.ErrRecordNotFound {
return nil, ErrSubscriptionNotFound
}
if err != nil {
return nil, err
}
return &sub, nil
}
func (r *subscriptionRepository) GetByUserID(ctx context.Context, userID string) ([]Subscription, error) {
var subs []Subscription
err := r.db.WithContext(ctx).
Where("user_id = ?", userID).
Order("created_at DESC").
Find(&subs).Error
return subs, err
}
func (r *subscriptionRepository) GetActiveByUserID(ctx context.Context, userID string) (*Subscription, error) {
var sub Subscription
err := r.db.WithContext(ctx).
Where("user_id = ? AND status = ?", userID, "active").
First(&sub).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &sub, nil
}
func (r *subscriptionRepository) GetRedeemCodeByCode(ctx context.Context, code string) (*RedeemCode, error) {
var rc RedeemCode
err := r.db.WithContext(ctx).Where("code = ?", code).First(&rc).Error
if err == gorm.ErrRecordNotFound {
return nil, ErrRedeemCodeInvalid
}
if err != nil {
return nil, err
}
return &rc, nil
}
func (r *subscriptionRepository) UseRedeemCode(ctx context.Context, codeID string) error {
return r.db.WithContext(ctx).Model(&RedeemCode{}).
Where("id = ?", codeID).
Update("used_count", gorm.Expr("used_count + 1")).Error
}
func (r *subscriptionRepository) CreateRedeemRecord(ctx context.Context, record *RedeemRecord) error {
if record.ID == "" {
record.ID = uuid.New().String()
}
record.CreatedAt = time.Now()
return r.db.WithContext(ctx).Create(record).Error
}
func (r *subscriptionRepository) CountActiveByUserID(ctx context.Context, userID string) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&Subscription{}).
Where("user_id = ? AND status = ?", userID, "active").
Count(&count).Error
return count, err
}
// 兑换码模型
type RedeemCode struct {
ID string `gorm:"primaryKey"`
Code string `gorm:"uniqueIndex"`
PlanID string `gorm:"index"`
BillingType string `gorm:"size:20"`
MaxUseCount int `gorm:"default:1"`
UsedCount int `gorm:"default:0"`
ExpiresAt *time.Time `gorm:"column:expires_at"`
CreatedBy string `gorm:"column:created_by"`
Status string `gorm:"size:20"` // active, used_up, expired
CreatedAt time.Time
}
func (RedeemCode) TableName() string { return "redeem_codes" }
type RedeemRecord struct {
ID string `gorm:"primaryKey"`
RedeemCodeID string `gorm:"index"`
UserID string `gorm:"index"`
UserPlanID string `gorm:"index"`
CreatedAt time.Time
}
func (RedeemRecord) TableName() string { return "redeem_records" }
@@ -0,0 +1,26 @@
// Package subscription 路由定义
package subscription
import (
"github.com/gin-gonic/gin"
"github.com/nuyue/server/internal/middleware"
"github.com/nuyue/server/pkg/jwt"
)
// RegisterRoutes 注册订阅路由
func RegisterRoutes(r *gin.RouterGroup, handler *Handler, jwtSvc *jwt.JWTService) {
sub := r.Group("/subscriptions")
sub.Use(middleware.AuthMiddleware(jwtSvc))
{
// 订阅管理
sub.GET("/current", handler.GetCurrentSubscription)
sub.GET("/history", handler.GetHistory)
// 购买和升级
sub.POST("/purchase", handler.Purchase)
sub.POST("/upgrade", handler.Upgrade)
// 兑换码
sub.POST("/redeem", handler.Redeem)
}
}
+332
View File
@@ -0,0 +1,332 @@
// Package subscription 业务逻辑层
package subscription
import (
"context"
"errors"
"time"
)
// SubscriptionService 订阅服务接口
type SubscriptionService interface {
// 购买
Purchase(ctx context.Context, userID string, req *PurchaseRequest) (*PurchaseResponse, error)
// 升级
Upgrade(ctx context.Context, userID string, req *UpgradeRequest) (*UpgradeResponse, error)
// 兑换
Redeem(ctx context.Context, userID string, req *RedeemRequest) (*RedeemResponse, error)
// 查询
GetCurrentSubscription(ctx context.Context, userID string) (*CurrentSubscriptionResponse, error)
GetHistory(ctx context.Context, userID string, page, pageSize int) (*SubscriptionHistoryResponse, error)
// 支付回调
HandlePaymentSuccess(ctx context.Context, orderID string) error
}
type subscriptionService struct {
repo SubscriptionRepository
planRepo PlanRepository
}
// PlanRepository 套餐仓储接口
type PlanRepository interface {
GetByID(ctx context.Context, id string) (*Plan, error)
}
// Plan 套餐模型
type Plan struct {
ID string
Name string
PriceMonthly float64
PriceYearly float64
PriceLifetime float64
MaxClients int
AllowUpgrade bool
}
func NewService(repo SubscriptionRepository, planRepo PlanRepository) SubscriptionService {
return &subscriptionService{repo: repo, planRepo: planRepo}
}
// Purchase 购买套餐
func (s *subscriptionService) Purchase(ctx context.Context, userID string, req *PurchaseRequest) (*PurchaseResponse, error) {
// 检查是否已有有效订阅
active, _ := s.repo.GetActiveByUserID(ctx, userID)
if active != nil {
return nil, ErrAlreadySubscribed
}
// 获取套餐
plan, err := s.planRepo.GetByID(ctx, req.PlanID)
if err != nil {
return nil, ErrPlanNotFound
}
// 计算价格
var amount float64
switch req.BillingType {
case "monthly":
amount = plan.PriceMonthly
case "yearly":
amount = plan.PriceYearly
case "lifetime":
amount = plan.PriceLifetime
}
// TODO: 创建支付订单并返回支付链接
return &PurchaseResponse{
OrderID: generateOrderID(),
Amount: amount,
Currency: "CNY",
PayURL: "", // 支付链接由支付模块生成
}, nil
}
// Upgrade 升级套餐
func (s *subscriptionService) Upgrade(ctx context.Context, userID string, req *UpgradeRequest) (*UpgradeResponse, error) {
// 获取当前订阅
current, err := s.repo.GetActiveByUserID(ctx, userID)
if err != nil {
return nil, err
}
if current == nil {
return nil, errors.New("没有有效订阅")
}
// 获取新套餐
newPlan, err := s.planRepo.GetByID(ctx, req.NewPlanID)
if err != nil {
return nil, ErrPlanNotFound
}
// 计算剩余价值
remainingValue := s.calculateRemainingValue(current)
// 计算新套餐价格
var newPrice float64
switch req.BillingType {
case "monthly":
newPrice = newPlan.PriceMonthly
case "yearly":
newPrice = newPlan.PriceYearly
case "lifetime":
newPrice = newPlan.PriceLifetime
}
// 计算差价
priceDiff := newPrice - remainingValue
if priceDiff < 0 {
priceDiff = 0
}
return &UpgradeResponse{
OrderID: generateOrderID(),
PriceDiff: priceDiff,
RemainingValue: remainingValue,
NewAmount: priceDiff,
PayURL: "", // 支付链接由支付模块生成
}, nil
}
// Redeem 兑换码兑换
func (s *subscriptionService) Redeem(ctx context.Context, userID string, req *RedeemRequest) (*RedeemResponse, error) {
// 获取兑换码
rc, err := s.repo.GetRedeemCodeByCode(ctx, req.Code)
if err != nil {
return nil, err
}
// 检查兑换码状态
if rc.Status == "used_up" || rc.UsedCount >= rc.MaxUseCount {
return nil, ErrRedeemCodeUsedUp
}
if rc.ExpiresAt != nil && time.Now().After(*rc.ExpiresAt) {
return nil, ErrRedeemCodeInvalid
}
// 获取套餐
plan, err := s.planRepo.GetByID(ctx, rc.PlanID)
if err != nil {
return nil, ErrPlanNotFound
}
// 创建订阅
now := time.Now()
var expiresAt *time.Time
switch rc.BillingType {
case "monthly":
exp := now.AddDate(0, 1, 0)
expiresAt = &exp
case "yearly":
exp := now.AddDate(1, 0, 0)
expiresAt = &exp
case "lifetime":
// NULL 表示永久
}
sub := &Subscription{
UserID: userID,
PlanID: rc.PlanID,
BillingType: rc.BillingType,
Status: "active",
StartedAt: now,
ExpiresAt: expiresAt,
}
if err := s.repo.Create(ctx, sub); err != nil {
return nil, err
}
// 更新兑换码使用次数
if err := s.repo.UseRedeemCode(ctx, rc.ID); err != nil {
return nil, err
}
// 记录兑换
record := &RedeemRecord{
RedeemCodeID: rc.ID,
UserID: userID,
UserPlanID: sub.ID,
}
s.repo.CreateRedeemRecord(ctx, record)
return &RedeemResponse{
SubscriptionID: sub.ID,
PlanName: plan.Name,
BillingType: rc.BillingType,
ExpiresAt: expiresAt,
}, nil
}
// GetCurrentSubscription 获取当前订阅
func (s *subscriptionService) GetCurrentSubscription(ctx context.Context, userID string) (*CurrentSubscriptionResponse, error) {
sub, err := s.repo.GetActiveByUserID(ctx, userID)
if err != nil {
return nil, err
}
if sub == nil {
return nil, nil
}
plan, err := s.planRepo.GetByID(ctx, sub.PlanID)
if err != nil {
return nil, err
}
remainingDays := 0
var remainingValue float64
if sub.ExpiresAt != nil {
duration := time.Until(*sub.ExpiresAt)
remainingDays = int(duration.Hours() / 24)
if remainingDays < 0 {
remainingDays = 0
}
remainingValue = s.calculateRemainingValue(sub)
}
return &CurrentSubscriptionResponse{
Subscription: SubscriptionDetail{
ID: sub.ID,
BillingType: sub.BillingType,
Status: sub.Status,
StartedAt: sub.StartedAt,
ExpiresAt: sub.ExpiresAt,
},
Plan: PlanInfo{
ID: plan.ID,
Name: plan.Name,
MaxClients: plan.MaxClients,
MaxTCPingNodes: 0,
MaxBackupRepos: 0,
},
RemainingDays: remainingDays,
RemainingValue: remainingValue,
CanUpgrade: plan.AllowUpgrade && sub.BillingType != "lifetime",
}, nil
}
// GetHistory 获取订阅历史
func (s *subscriptionService) GetHistory(ctx context.Context, userID string, page, pageSize int) (*SubscriptionHistoryResponse, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
subs, err := s.repo.GetByUserID(ctx, userID)
if err != nil {
return nil, err
}
list := make([]SubscriptionHistoryItem, len(subs))
for i, sub := range subs {
plan, _ := s.planRepo.GetByID(ctx, sub.PlanID)
planName := ""
if plan != nil {
planName = plan.Name
}
list[i] = SubscriptionHistoryItem{
ID: sub.ID,
PlanName: planName,
BillingType: sub.BillingType,
Status: sub.Status,
StartedAt: sub.StartedAt,
ExpiresAt: sub.ExpiresAt,
CreatedAt: sub.CreatedAt,
}
}
return &SubscriptionHistoryResponse{
List: list,
Total: int64(len(subs)),
}, nil
}
// HandlePaymentSuccess 处理支付成功
func (s *subscriptionService) HandlePaymentSuccess(ctx context.Context, orderID string) error {
// TODO: 根据订单创建订阅
return nil
}
// calculateRemainingValue 计算剩余价值
func (s *subscriptionService) calculateRemainingValue(sub *Subscription) float64 {
if sub.ExpiresAt == nil {
return 0 // 永久套餐无剩余价值
}
plan, err := s.planRepo.GetByID(context.Background(), sub.PlanID)
if err != nil {
return 0
}
var totalPrice float64
var totalDays int
switch sub.BillingType {
case "monthly":
totalPrice = plan.PriceMonthly
totalDays = 30
case "yearly":
totalPrice = plan.PriceYearly
totalDays = 365
}
if totalPrice == 0 {
return 0
}
remainingDuration := time.Until(*sub.ExpiresAt)
remainingDays := int(remainingDuration.Hours() / 24)
if remainingDays < 0 {
remainingDays = 0
}
return totalPrice * float64(remainingDays) / float64(totalDays)
}
func generateOrderID() string {
return ""
}
+256
View File
@@ -0,0 +1,256 @@
// Package user HTTP 处理层
package user
import (
"github.com/gin-gonic/gin"
"github.com/nuyue/server/pkg/response"
)
// Handler 用户处理器
type Handler struct {
svc UserService
}
// NewHandler 创建用户处理器
func NewHandler(svc UserService) *Handler {
return &Handler{svc: svc}
}
// GetProfile 获取个人信息
// @Summary 获取个人信息
// @Tags 用户
// @Security BearerAuth
// @Success 200 {object} response.Response{data=UserProfileResponse}
// @Router /api/v1/user/profile [get]
func (h *Handler) GetProfile(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未认证")
return
}
profile, err := h.svc.GetProfile(c.Request.Context(), userID)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, profile)
}
// UpdateProfile 更新个人信息
// @Summary 更新个人信息
// @Tags 用户
// @Security BearerAuth
// @Accept json
// @Param request body UpdateProfileRequest true "更新信息"
// @Success 200 {object} response.Response
// @Router /api/v1/user/profile [put]
func (h *Handler) UpdateProfile(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未认证")
return
}
var req UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if err := h.svc.UpdateProfile(c.Request.Context(), userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "更新成功", nil)
}
// ChangePassword 修改密码
// @Summary 修改密码
// @Tags 用户
// @Security BearerAuth
// @Accept json
// @Param request body ChangePasswordRequest true "修改密码请求"
// @Success 200 {object} response.Response
// @Router /api/v1/user/password [put]
func (h *Handler) ChangePassword(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未认证")
return
}
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if err := h.svc.ChangePassword(c.Request.Context(), userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "密码修改成功", nil)
}
// ChangeEncryptionPassword 修改加密密码
// @Summary 修改加密密码
// @Tags 用户
// @Security BearerAuth
// @Accept json
// @Param request body ChangeEncryptionPasswordRequest true "修改加密密码请求"
// @Success 200 {object} response.Response
// @Router /api/v1/user/encryption-password [put]
func (h *Handler) ChangeEncryptionPassword(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未认证")
return
}
var req ChangeEncryptionPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if err := h.svc.ChangeEncryptionPassword(c.Request.Context(), userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "加密密码修改成功", nil)
}
// BindEmail 绑定邮箱
// @Summary 绑定邮箱
// @Tags 用户
// @Security BearerAuth
// @Accept json
// @Param request body BindEmailRequest true "绑定邮箱请求"
// @Success 200 {object} response.Response
// @Router /api/v1/user/bind-email [post]
func (h *Handler) BindEmail(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未认证")
return
}
var req BindEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if err := h.svc.BindEmail(c.Request.Context(), userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "邮箱绑定成功", nil)
}
// RebindEmail 换绑邮箱
// @Summary 换绑邮箱
// @Tags 用户
// @Security BearerAuth
// @Accept json
// @Param request body RebindEmailRequest true "换绑邮箱请求"
// @Success 200 {object} response.Response
// @Router /api/v1/user/rebind-email [post]
func (h *Handler) RebindEmail(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未认证")
return
}
var req RebindEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if err := h.svc.RebindEmail(c.Request.Context(), userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "邮箱换绑成功", nil)
}
// GetTGBindInfo 获取 TG 绑定信息
// @Summary 获取 Telegram 绑定信息
// @Tags 用户
// @Security BearerAuth
// @Success 200 {object} response.Response{data=TGBindInfoResponse}
// @Router /api/v1/user/tg-bind-info [get]
func (h *Handler) GetTGBindInfo(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未认证")
return
}
info, err := h.svc.GetTGBindInfo(c.Request.Context(), userID)
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, info)
}
// UnbindTG 解绑 Telegram
// @Summary 解绑 Telegram
// @Tags 用户
// @Security BearerAuth
// @Success 200 {object} response.Response
// @Router /api/v1/user/unbind-tg [post]
func (h *Handler) UnbindTG(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未认证")
return
}
if err := h.svc.UnbindTG(c.Request.Context(), userID); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "Telegram 解绑成功", nil)
}
// UpdatePreferences 更新用户偏好
// @Summary 更新用户偏好
// @Tags 用户
// @Security BearerAuth
// @Accept json
// @Param request body UpdatePreferencesRequest true "偏好设置"
// @Success 200 {object} response.Response
// @Router /api/v1/user/preferences [put]
func (h *Handler) UpdatePreferences(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
response.Unauthorized(c, "未认证")
return
}
var req UpdatePreferencesRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误: "+err.Error())
return
}
if err := h.svc.UpdatePreferences(c.Request.Context(), userID, &req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.SuccessWithMessage(c, "偏好设置更新成功", nil)
}
+107
View File
@@ -0,0 +1,107 @@
// Package user 用户管理模块
package user
import (
"time"
)
// User 用户模型(与 auth 模块共用,这里定义额外的 DTO)
type User struct {
ID string `json:"id" gorm:"primaryKey"`
Username string `json:"username" gorm:"uniqueIndex;size:50;not null"`
Email *string `json:"email" gorm:"uniqueIndex;size:255"`
EmailVerified bool `json:"email_verified" gorm:"default:false"`
AvatarURL *string `json:"avatar_url" gorm:"size:500"`
Role string `json:"role" gorm:"size:20;default:user"`
Status string `json:"status" gorm:"size:20;default:active"`
TGChatID *int64 `json:"tg_chat_id"`
TGUsername *string `json:"tg_username" gorm:"size:100"`
TGBoundAt *time.Time `json:"tg_bound_at"`
PreferenceShowRemaining bool `json:"preference_show_remaining_value" gorm:"default:true"`
PreferenceTGNotifyEnabled bool `json:"preference_tg_notify_enabled" gorm:"default:false"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 表名
func (User) TableName() string {
return "users"
}
// UpdateProfileRequest 更新个人信息请求
type UpdateProfileRequest struct {
AvatarURL string `json:"avatar_url" binding:"omitempty,url,max=500"`
}
// ChangePasswordRequest 修改密码请求
type ChangePasswordRequest struct {
OldPasswordHash string `json:"old_password_hash" binding:"required"`
NewPasswordHash string `json:"new_password_hash" binding:"required"`
}
// ChangeEncryptionPasswordRequest 修改加密密码请求
type ChangeEncryptionPasswordRequest struct {
OldPasswordHash string `json:"old_password_hash" binding:"required"` // 用于验证用户身份
NewEncryptedConfigKey string `json:"new_encrypted_config_key" binding:"required"`
NewConfigKeyNonce string `json:"new_config_key_nonce" binding:"required"`
}
// BindEmailRequest 绑定邮箱请求
type BindEmailRequest struct {
Email string `json:"email" binding:"required,email"`
EmailCode string `json:"email_code" binding:"required,len=6"`
}
// RebindEmailRequest 换绑邮箱请求
type RebindEmailRequest struct {
NewEmail string `json:"new_email" binding:"required,email"`
NewCode string `json:"new_email_code" binding:"required,len=6"`
OldCode string `json:"old_email_code" binding:"required,len=6"`
}
// UpdatePreferencesRequest 更新用户偏好请求
type UpdatePreferencesRequest struct {
ShowRemainingValue *bool `json:"show_remaining_value"`
TGNotifyEnabled *bool `json:"tg_notify_enabled"`
}
// TGBindInfoResponse TG 绑定信息响应
type TGBindInfoResponse struct {
IsBound bool `json:"is_bound"`
TGUsername string `json:"tg_username,omitempty"`
BoundAt *time.Time `json:"bound_at,omitempty"`
BindURL string `json:"bind_url,omitempty"` // 未绑定时返回
}
// UserProfileResponse 用户信息响应
type UserProfileResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Email *string `json:"email"`
EmailVerified bool `json:"email_verified"`
AvatarURL *string `json:"avatar_url"`
Role string `json:"role"`
TGBound bool `json:"tg_bound"`
TGUsername *string `json:"tg_username"`
PreferenceShowRemaining bool `json:"preference_show_remaining_value"`
PreferenceTGNotifyEnabled bool `json:"preference_tg_notify_enabled"`
CreatedAt time.Time `json:"created_at"`
}
// UserListResponse 用户列表响应
type UserListResponse struct {
List []UserListItem `json:"list"`
Total int64 `json:"total"`
}
// UserListItem 用户列表项
type UserListItem struct {
ID string `json:"id"`
Username string `json:"username"`
Email *string `json:"email"`
Role string `json:"role"`
Status string `json:"status"`
ServerCount int64 `json:"server_count"`
CreatedAt time.Time `json:"created_at"`
LastSeenAt *time.Time `json:"last_seen_at"`
}
+229
View File
@@ -0,0 +1,229 @@
// Package user 数据访问层
package user
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
var (
ErrUserNotFound = errors.New("用户不存在")
ErrEmailAlreadyBound = errors.New("邮箱已被绑定")
ErrInvalidEmailCode = errors.New("验证码无效或已过期")
ErrTGBoundAlready = errors.New("Telegram 已绑定")
)
// UserRepository 用户仓储接口
type UserRepository interface {
// 基本信息
GetByID(ctx context.Context, id string) (*User, error)
GetByUsername(ctx context.Context, username string) (*User, error)
Update(ctx context.Context, user *User) error
// 邮箱
UpdateEmail(ctx context.Context, id string, email string) error
VerifyEmail(ctx context.Context, id string) error
// Telegram
UpdateTGInfo(ctx context.Context, id string, chatID int64, username string) error
UnbindTG(ctx context.Context, id string) error
// 偏好
UpdatePreferences(ctx context.Context, id string, showRemaining, tgNotify bool) error
// 密码
UpdatePassword(ctx context.Context, id string, passwordHash string) error
UpdateEncryptionKey(ctx context.Context, id string, encryptedKey, nonce string) error
// 管理员功能
List(ctx context.Context, page, pageSize int) ([]UserListItem, int64, error)
UpdateStatus(ctx context.Context, id string, status string) error
Delete(ctx context.Context, id string) error
}
// userRepository 用户仓储实现
type userRepository struct {
db *gorm.DB
}
// NewRepository 创建用户仓储
func NewRepository(db *gorm.DB) UserRepository {
return &userRepository{db: db}
}
// GetByID 根据ID获取用户
func (r *userRepository) GetByID(ctx context.Context, id string) (*User, error) {
var user User
err := r.db.WithContext(ctx).Where("id = ?", id).First(&user).Error
if err == gorm.ErrRecordNotFound {
return nil, ErrUserNotFound
}
if err != nil {
return nil, err
}
return &user, nil
}
// GetByUsername 根据用户名获取用户
func (r *userRepository) GetByUsername(ctx context.Context, username string) (*User, error) {
var user User
err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error
if err == gorm.ErrRecordNotFound {
return nil, ErrUserNotFound
}
if err != nil {
return nil, err
}
return &user, nil
}
// Update 更新用户
func (r *userRepository) Update(ctx context.Context, user *User) error {
user.UpdatedAt = time.Now()
return r.db.WithContext(ctx).Save(user).Error
}
// UpdateEmail 更新邮箱
func (r *userRepository) UpdateEmail(ctx context.Context, id string, email string) error {
return r.db.WithContext(ctx).Model(&User{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"email": email,
"email_verified": true,
"updated_at": time.Now(),
}).Error
}
// VerifyEmail 验证邮箱
func (r *userRepository) VerifyEmail(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Model(&User{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"email_verified": true,
"updated_at": time.Now(),
}).Error
}
// UpdateTGInfo 更新 Telegram 信息
func (r *userRepository) UpdateTGInfo(ctx context.Context, id string, chatID int64, username string) error {
now := time.Now()
return r.db.WithContext(ctx).Model(&User{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"tg_chat_id": chatID,
"tg_username": username,
"tg_bound_at": now,
"updated_at": now,
}).Error
}
// UnbindTG 解绑 Telegram
func (r *userRepository) UnbindTG(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Model(&User{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"tg_chat_id": nil,
"tg_username": nil,
"tg_bound_at": nil,
"updated_at": time.Now(),
}).Error
}
// UpdatePreferences 更新用户偏好
func (r *userRepository) UpdatePreferences(ctx context.Context, id string, showRemaining, tgNotify bool) error {
return r.db.WithContext(ctx).Model(&User{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"preference_show_remaining_value": showRemaining,
"preference_tg_notify_enabled": tgNotify,
"updated_at": time.Now(),
}).Error
}
// UpdatePassword 更新密码
func (r *userRepository) UpdatePassword(ctx context.Context, id string, passwordHash string) error {
return r.db.WithContext(ctx).Model(&User{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"password_hash": passwordHash,
"updated_at": time.Now(),
}).Error
}
// UpdateEncryptionKey 更新加密密钥
func (r *userRepository) UpdateEncryptionKey(ctx context.Context, id string, encryptedKey, nonce string) error {
return r.db.WithContext(ctx).Model(&User{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"encrypted_config_key": encryptedKey,
"config_key_nonce": nonce,
"updated_at": time.Now(),
}).Error
}
// List 获取用户列表(管理员)
func (r *userRepository) List(ctx context.Context, page, pageSize int) ([]UserListItem, int64, error) {
var users []User
var total int64
offset := (page - 1) * pageSize
err := r.db.WithContext(ctx).Model(&User{}).Count(&total).Error
if err != nil {
return nil, 0, err
}
err = r.db.WithContext(ctx).
Model(&User{}).
Offset(offset).
Limit(pageSize).
Order("created_at DESC").
Find(&users).Error
if err != nil {
return nil, 0, err
}
// 获取每个用户的服务器数量
list := make([]UserListItem, len(users))
for i, u := range users {
var serverCount int64
r.db.WithContext(ctx).Model(&Server{}).Where("user_id = ?", u.ID).Count(&serverCount)
list[i] = UserListItem{
ID: u.ID,
Username: u.Username,
Email: u.Email,
Role: u.Role,
Status: u.Status,
ServerCount: serverCount,
CreatedAt: u.CreatedAt,
}
}
return list, total, nil
}
// UpdateStatus 更新用户状态(管理员)
func (r *userRepository) UpdateStatus(ctx context.Context, id string, status string) error {
return r.db.WithContext(ctx).Model(&User{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"status": status,
"updated_at": time.Now(),
}).Error
}
// Delete 删除用户(管理员)
func (r *userRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&User{}, "id = ?", id).Error
}
// Server 服务器模型(简化引用)
type Server struct {
ID string `gorm:"primaryKey"`
UserID string `gorm:"index"`
}
+34
View File
@@ -0,0 +1,34 @@
// Package user 路由定义
package user
import (
"github.com/gin-gonic/gin"
"github.com/nuyue/server/internal/middleware"
"github.com/nuyue/server/pkg/jwt"
)
// RegisterRoutes 注册用户路由
func RegisterRoutes(r *gin.RouterGroup, handler *Handler, jwtSvc *jwt.JWTService) {
user := r.Group("/user")
user.Use(middleware.AuthMiddleware(jwtSvc))
{
// 个人信息
user.GET("/profile", handler.GetProfile)
user.PUT("/profile", handler.UpdateProfile)
// 密码管理
user.PUT("/password", handler.ChangePassword)
user.PUT("/encryption-password", handler.ChangeEncryptionPassword)
// 邮箱管理
user.POST("/bind-email", handler.BindEmail)
user.POST("/rebind-email", handler.RebindEmail)
// Telegram 管理
user.GET("/tg-bind-info", handler.GetTGBindInfo)
user.POST("/unbind-tg", handler.UnbindTG)
// 偏好设置
user.PUT("/preferences", handler.UpdatePreferences)
}
}
+184
View File
@@ -0,0 +1,184 @@
// Package user 业务逻辑层
package user
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
)
// UserService 用户服务接口
type UserService interface {
// 个人信息
GetProfile(ctx context.Context, userID string) (*UserProfileResponse, error)
UpdateProfile(ctx context.Context, userID string, req *UpdateProfileRequest) error
// 密码管理
ChangePassword(ctx context.Context, userID string, req *ChangePasswordRequest) error
ChangeEncryptionPassword(ctx context.Context, userID string, req *ChangeEncryptionPasswordRequest) error
// 邮箱管理
BindEmail(ctx context.Context, userID string, req *BindEmailRequest) error
RebindEmail(ctx context.Context, userID string, req *RebindEmailRequest) error
// Telegram 管理
GetTGBindInfo(ctx context.Context, userID string) (*TGBindInfoResponse, error)
UnbindTG(ctx context.Context, userID string) error
// 偏好设置
UpdatePreferences(ctx context.Context, userID string, req *UpdatePreferencesRequest) error
}
// userService 用户服务实现
type userService struct {
repo UserRepository
}
// NewService 创建用户服务
func NewService(repo UserRepository) UserService {
return &userService{repo: repo}
}
// GetProfile 获取个人信息
func (s *userService) GetProfile(ctx context.Context, userID string) (*UserProfileResponse, error) {
user, err := s.repo.GetByID(ctx, userID)
if err != nil {
return nil, err
}
return &UserProfileResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
EmailVerified: user.EmailVerified,
AvatarURL: user.AvatarURL,
Role: user.Role,
TGBound: user.TGChatID != nil,
TGUsername: user.TGUsername,
PreferenceShowRemaining: user.PreferenceShowRemaining,
PreferenceTGNotifyEnabled: user.PreferenceTGNotifyEnabled,
CreatedAt: user.CreatedAt,
}, nil
}
// UpdateProfile 更新个人信息
func (s *userService) UpdateProfile(ctx context.Context, userID string, req *UpdateProfileRequest) error {
user, err := s.repo.GetByID(ctx, userID)
if err != nil {
return err
}
if req.AvatarURL != "" {
user.AvatarURL = &req.AvatarURL
}
return s.repo.Update(ctx, user)
}
// ChangePassword 修改密码
func (s *userService) ChangePassword(ctx context.Context, userID string, req *ChangePasswordRequest) error {
// TODO: 验证旧密码
// 这里需要调用 auth 模块的密码验证逻辑
// 简化实现,直接更新
return s.repo.UpdatePassword(ctx, userID, req.NewPasswordHash)
}
// ChangeEncryptionPassword 修改加密密码
func (s *userService) ChangeEncryptionPassword(ctx context.Context, userID string, req *ChangeEncryptionPasswordRequest) error {
// TODO: 验证旧密码
// 前端已用旧加密密码解密 config_key,并用新加密密码重新加密
// 服务端只需要存储新的 encrypted_config_key
return s.repo.UpdateEncryptionKey(ctx, userID, req.NewEncryptedConfigKey, req.NewConfigKeyNonce)
}
// BindEmail 绑定邮箱
func (s *userService) BindEmail(ctx context.Context, userID string, req *BindEmailRequest) error {
// TODO: 验证邮箱验证码
// 这里需要调用邮件服务验证验证码
// 简化实现,直接绑定
return s.repo.UpdateEmail(ctx, userID, req.Email)
}
// RebindEmail 换绑邮箱
func (s *userService) RebindEmail(ctx context.Context, userID string, req *RebindEmailRequest) error {
// TODO: 验证旧邮箱验证码和新邮箱验证码
// 简化实现,直接更新
return s.repo.UpdateEmail(ctx, userID, req.NewEmail)
}
// GetTGBindInfo 获取 TG 绑定信息
func (s *userService) GetTGBindInfo(ctx context.Context, userID string) (*TGBindInfoResponse, error) {
user, err := s.repo.GetByID(ctx, userID)
if err != nil {
return nil, err
}
resp := &TGBindInfoResponse{
IsBound: user.TGChatID != nil,
}
if user.TGChatID != nil {
resp.TGUsername = user.TGUsername
resp.BoundAt = user.TGBoundAt
} else {
// 生成绑定链接
token := generateBindToken()
// TODO: 存储 token 到 Redis,设置过期时间
resp.BindURL = fmt.Sprintf("https://t.me/NuyueBot?start=bind_%s", token)
}
return resp, nil
}
// UnbindTG 解绑 Telegram
func (s *userService) UnbindTG(ctx context.Context, userID string) error {
return s.repo.UnbindTG(ctx, userID)
}
// UpdatePreferences 更新用户偏好
func (s *userService) UpdatePreferences(ctx context.Context, userID string, req *UpdatePreferencesRequest) error {
user, err := s.repo.GetByID(ctx, userID)
if err != nil {
return err
}
showRemaining := user.PreferenceShowRemaining
tgNotify := user.PreferenceTGNotifyEnabled
if req.ShowRemainingValue != nil {
showRemaining = *req.ShowRemainingValue
}
if req.TGNotifyEnabled != nil {
// 检查是否已绑定 TG
if *req.TGNotifyEnabled && user.TGChatID == nil {
return errors.New("请先绑定 Telegram")
}
tgNotify = *req.TGNotifyEnabled
}
return s.repo.UpdatePreferences(ctx, userID, showRemaining, tgNotify)
}
// generateBindToken 生成绑定 Token
func generateBindToken() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
// TGBindCallback Telegram 绑定回调(由 TG Bot 调用)
func (s *userService) TGBindCallback(ctx context.Context, token string, chatID int64, username string) error {
// TODO: 从 Redis 获取 token 对应的 userID
// 简化实现,返回错误
return errors.New("token 验证失败或已过期")
}
// CompleteTGBind 完成 Telegram 绑定
func (s *userService) CompleteTGBind(ctx context.Context, userID string, chatID int64, username string) error {
return s.repo.UpdateTGInfo(ctx, userID, chatID, username)
}
var _ time.Time // 避免未使用导入
+125
View File
@@ -0,0 +1,125 @@
// Package scheduler 定时任务调度器
package scheduler
import (
"context"
"log"
"sync"
"time"
)
// Scheduler 定时任务调度器
type Scheduler struct {
tasks map[string]*Task
mu sync.RWMutex
running bool
}
// Task 定时任务
type Task struct {
Name string
Interval time.Duration
Handler func(ctx context.Context) error
ticker *time.Ticker
cancel context.CancelFunc
}
// NewScheduler 创建调度器
func NewScheduler() *Scheduler {
return &Scheduler{
tasks: make(map[string]*Task),
}
}
// AddTask 添加定时任务
func (s *Scheduler) AddTask(name string, interval time.Duration, handler func(ctx context.Context) error) {
s.mu.Lock()
defer s.mu.Unlock()
s.tasks[name] = &Task{
Name: name,
Interval: interval,
Handler: handler,
}
}
// RemoveTask 移除定时任务
func (s *Scheduler) RemoveTask(name string) {
s.mu.Lock()
defer s.mu.Unlock()
if task, ok := s.tasks[name]; ok {
if task.cancel != nil {
task.cancel()
}
if task.ticker != nil {
task.ticker.Stop()
}
delete(s.tasks, name)
}
}
// Start 启动调度器
func (s *Scheduler) Start(ctx context.Context) {
s.mu.Lock()
s.running = true
s.mu.Unlock()
for name, task := range s.tasks {
go s.runTask(ctx, name, task)
}
log.Printf("[Scheduler] 调度器已启动,任务数: %d", len(s.tasks))
}
// Stop 停止调度器
func (s *Scheduler) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
s.running = false
for name, task := range s.tasks {
if task.cancel != nil {
task.cancel()
}
if task.ticker != nil {
task.ticker.Stop()
}
log.Printf("[Scheduler] 任务 %s 已停止", name)
}
log.Println("[Scheduler] 调度器已停止")
}
// runTask 运行单个任务
func (s *Scheduler) runTask(ctx context.Context, name string, task *Task) {
taskCtx, cancel := context.WithCancel(ctx)
task.cancel = cancel
task.ticker = time.NewTicker(task.Interval)
log.Printf("[Scheduler] 任务 %s 已启动,间隔: %v", name, task.Interval)
for {
select {
case <-taskCtx.Done():
return
case <-task.ticker.C:
if err := task.Handler(taskCtx); err != nil {
log.Printf("[Scheduler] 任务 %s 执行失败: %v", name, err)
}
}
}
}
// GetTaskNames 获取所有任务名称
func (s *Scheduler) GetTaskNames() []string {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0, len(s.tasks))
for name := range s.tasks {
names = append(names, name)
}
return names
}
+140
View File
@@ -0,0 +1,140 @@
// Package scheduler 定时任务
package scheduler
import (
"context"
"log"
"time"
)
// SubscriptionExpiryCheck 订阅过期检查
func SubscriptionExpiryCheck(subRepo SubscriptionRepository) func(ctx context.Context) error {
return func(ctx context.Context) error {
log.Println("[Scheduler] 检查订阅过期...")
// 查找所有已过期但状态仍为 active 的订阅
expiredSubs, err := subRepo.GetExpiredSubscriptions(ctx)
if err != nil {
return err
}
for _, sub := range expiredSubs {
if err := subRepo.MarkAsExpired(ctx, sub.ID); err != nil {
log.Printf("[Scheduler] 标记订阅 %s 过期失败: %v", sub.ID, err)
continue
}
log.Printf("[Scheduler] 订阅 %s 已标记为过期", sub.ID)
}
return nil
}
}
// MetricsCleanup 指标清理
func MetricsCleanup(metricsRepo MetricsRepository, retentionDays int) func(ctx context.Context) error {
return func(ctx context.Context) error {
log.Println("[Scheduler] 清理过期指标...")
before := time.Now().AddDate(0, 0, -retentionDays)
if err := metricsRepo.CleanupOldMetrics(ctx, before); err != nil {
return err
}
log.Printf("[Scheduler] 已清理 %s 之前的指标", before.Format("2006-01-02"))
return nil
}
}
// OrderExpiryCheck 订单过期检查
func OrderExpiryCheck(orderRepo OrderRepository) func(ctx context.Context) error {
return func(ctx context.Context) error {
log.Println("[Scheduler] 检查订单过期...")
orders, err := orderRepo.GetPendingOrders(ctx)
if err != nil {
return err
}
now := time.Now()
for _, order := range orders {
if order.ExpiresAt != nil && now.After(*order.ExpiresAt) {
if err := orderRepo.MarkExpired(ctx, order.OrderNo); err != nil {
log.Printf("[Scheduler] 标记订单 %s 过期失败: %v", order.OrderNo, err)
continue
}
log.Printf("[Scheduler] 订单 %s 已标记为过期", order.OrderNo)
}
}
return nil
}
}
// BackupRecordCleanup 备份记录清理
func BackupRecordCleanup(backupRepo BackupRepository, retentionDays int) func(ctx context.Context) error {
return func(ctx context.Context) error {
log.Println("[Scheduler] 清理过期备份记录...")
before := time.Now().AddDate(0, 0, -retentionDays)
if err := backupRepo.CleanupOldRecords(ctx, before); err != nil {
return err
}
log.Printf("[Scheduler] 已清理 %s 之前的备份记录", before.Format("2006-01-02"))
return nil
}
}
// 接口定义
type SubscriptionRepository interface {
GetExpiredSubscriptions(ctx context.Context) ([]Subscription, error)
MarkAsExpired(ctx context.Context, id string) error
}
type MetricsRepository interface {
CleanupOldMetrics(ctx context.Context, before time.Time) error
}
type OrderRepository interface {
GetPendingOrders(ctx context.Context) ([]Order, error)
MarkExpired(ctx context.Context, orderNo string) error
}
type BackupRepository interface {
CleanupOldRecords(ctx context.Context, before time.Time) error
}
// 模型定义
type Subscription struct {
ID string
Status string
}
type Order struct {
OrderNo string
ExpiresAt *time.Time
}
// SetupScheduler 设置定时任务
func SetupScheduler(
subRepo SubscriptionRepository,
metricsRepo MetricsRepository,
orderRepo OrderRepository,
backupRepo BackupRepository,
) *Scheduler {
s := NewScheduler()
// 每小时检查订阅过期
s.AddTask("subscription_expiry", 1*time.Hour, SubscriptionExpiryCheck(subRepo))
// 每天凌晨清理过期指标(保留 30 天)
s.AddTask("metrics_cleanup", 24*time.Hour, MetricsCleanup(metricsRepo, 30))
// 每 5 分钟检查订单过期
s.AddTask("order_expiry", 5*time.Minute, OrderExpiryCheck(orderRepo))
// 每周清理过期备份记录(保留 90 天)
s.AddTask("backup_cleanup", 7*24*time.Hour, BackupRecordCleanup(backupRepo, 90))
return s
}
+263
View File
@@ -0,0 +1,263 @@
// Package tgbot Telegram Bot 模块
package tgbot
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// Bot Telegram Bot
type Bot struct {
token string
username string
httpClient *http.Client
// 绑定令牌缓存
bindTokens sync.Map // token -> userID
}
// NewBot 创建 Bot
func NewBot(token string) *Bot {
return &Bot{
token: token,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// SetUsername 设置 Bot 用户名
func (b *Bot) SetUsername(username string) {
b.username = username
}
// GetUsername 获取 Bot 用户名
func (b *Bot) GetUsername() string {
return b.username
}
// GenerateBindToken 生成绑定令牌
func (b *Bot) GenerateBindToken(userID string) string {
token := generateRandomToken(16)
b.bindTokens.Store(token, bindTokenInfo{
UserID: userID,
CreatedAt: time.Now(),
})
return token
}
// ValidateBindToken 验证绑定令牌
func (b *Bot) ValidateBindToken(token string) (string, bool) {
val, ok := b.bindTokens.Load(token)
if !ok {
return "", false
}
info := val.(bindTokenInfo)
// 令牌 10 分钟有效
if time.Since(info.CreatedAt) > 10*time.Minute {
b.bindTokens.Delete(token)
return "", false
}
return info.UserID, true
}
// DeleteBindToken 删除绑定令牌
func (b *Bot) DeleteBindToken(token string) {
b.bindTokens.Delete(token)
}
// bindTokenInfo 绑定令牌信息
type bindTokenInfo struct {
UserID string
CreatedAt time.Time
}
// Update 更新消息
type Update struct {
UpdateID int `json:"update_id"`
Message *struct {
MessageID int `json:"message_id"`
From *struct {
ID int64 `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
} `json:"from"`
Chat *struct {
ID int64 `json:"id"`
} `json:"chat"`
Text string `json:"text"`
} `json:"message"`
}
// HandleWebhook 处理 Webhook
func (b *Bot) HandleWebhook(ctx context.Context, body []byte, callback func(userID string, chatID int64, username string) error) error {
var update Update
if err := json.Unmarshal(body, &update); err != nil {
return err
}
if update.Message == nil {
return nil
}
chatID := update.Message.Chat.ID
username := ""
if update.Message.From != nil {
username = update.Message.From.Username
}
text := update.Message.Text
// 处理命令
if strings.HasPrefix(text, "/start bind_") {
// 绑定命令
token := strings.TrimPrefix(text, "/start bind_")
userID, ok := b.ValidateBindToken(token)
if !ok {
b.SendMessage(ctx, chatID, "绑定链接已过期,请重新获取")
return nil
}
// 执行绑定回调
if err := callback(userID, chatID, username); err != nil {
b.SendMessage(ctx, chatID, "绑定失败: "+err.Error())
return nil
}
b.DeleteBindToken(token)
b.SendMessage(ctx, chatID, "绑定成功!您将收到服务器告警通知。")
return nil
}
// 处理其他命令
switch text {
case "/start":
b.SendMessage(ctx, chatID, "欢迎使用怒月监控 Bot!请访问网站获取绑定链接。")
case "/help":
b.SendMessage(ctx, chatID, `可用命令:
/start - 开始使用
/help - 显示帮助
/status - 查看服务器状态`)
case "/status":
// TODO: 查询用户服务器状态
b.SendMessage(ctx, chatID, "请访问网站查看服务器状态")
default:
b.SendMessage(ctx, chatID, "未知命令,请使用 /help 查看可用命令")
}
return nil
}
// SendMessage 发送消息
func (b *Bot) SendMessage(ctx context.Context, chatID int64, text string) error {
body := map[string]interface{}{
"chat_id": chatID,
"text": text,
}
jsonBody, err := json.Marshal(body)
if err != nil {
return err
}
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", b.token)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := b.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("telegram API error: %s", string(respBody))
}
return nil
}
// SetWebhook 设置 Webhook
func (b *Bot) SetWebhook(ctx context.Context, webhookURL string) error {
body := map[string]interface{}{
"url": webhookURL,
}
jsonBody, err := json.Marshal(body)
if err != nil {
return err
}
url := fmt.Sprintf("https://api.telegram.org/bot%s/setWebhook", b.token)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := b.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("setWebhook error: %s", string(respBody))
}
return nil
}
// GetMe 获取 Bot 信息
func (b *Bot) GetMe(ctx context.Context) (string, error) {
url := fmt.Sprintf("https://api.telegram.org/bot%s/getMe", b.token)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", err
}
resp, err := b.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("getMe error")
}
var result struct {
OK bool `json:"ok"`
Result struct {
ID int64 `json:"id"`
Username string `json:"username"`
} `json:"result"`
}
respBody, _ := io.ReadAll(resp.Body)
if err := json.Unmarshal(respBody, &result); err != nil {
return "", err
}
return result.Result.Username, nil
}
func generateRandomToken(length int) string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
for i := range b {
b[i] = chars[i%len(chars)]
}
return string(b)
}
+83
View File
@@ -0,0 +1,83 @@
// Package tgbot HTTP 处理层
package tgbot
import (
"github.com/gin-gonic/gin"
"github.com/nuyue/server/pkg/response"
)
// Handler TG Bot 处理器
type Handler struct {
bot *Bot
userSvc UserService
}
// UserService 用户服务接口
type UserService interface {
CompleteTGBind(userID string, chatID int64, username string) error
}
// NewHandler 创建处理器
func NewHandler(bot *Bot, userSvc UserService) *Handler {
return &Handler{
bot: bot,
userSvc: userSvc,
}
}
// Webhook 处理 Telegram Webhook
func (h *Handler) Webhook(c *gin.Context) {
body, err := c.GetRawData()
if err != nil {
response.Error(c, 400, "读取请求失败")
return
}
// 处理 Webhook
err = h.bot.HandleWebhook(c.Request.Context(), body, func(userID string, chatID int64, username string) error {
return h.userSvc.CompleteTGBind(userID, chatID, username)
})
if err != nil {
response.Error(c, 500, err.Error())
return
}
// Telegram 期望返回 200 OK
c.Status(200)
}
// SetWebhook 设置 Webhook
func (h *Handler) SetWebhook(c *gin.Context) {
var req struct {
WebhookURL string `json:"webhook_url" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误")
return
}
if err := h.bot.SetWebhook(c.Request.Context(), req.WebhookURL); err != nil {
response.Error(c, 500, "设置 Webhook 失败: "+err.Error())
return
}
response.SuccessWithMessage(c, "Webhook 设置成功", gin.H{
"webhook_url": req.WebhookURL,
})
}
// TestBot 测试 Bot
func (h *Handler) TestBot(c *gin.Context) {
username, err := h.bot.GetMe(c.Request.Context())
if err != nil {
response.Error(c, 500, "Bot 连接失败: "+err.Error())
return
}
response.Success(c, gin.H{
"bot_username": username,
"connected": true,
})
}
+23
View File
@@ -0,0 +1,23 @@
// Package tgbot 路由定义
package tgbot
import (
"github.com/gin-gonic/gin"
"github.com/nuyue/server/internal/middleware"
"github.com/nuyue/server/pkg/jwt"
)
// RegisterRoutes 注册 TG Bot 路由
func RegisterRoutes(r *gin.RouterGroup, handler *Handler, jwtSvc *jwt.JWTService) {
// Webhook 路由(无需认证)
r.POST("/tg/webhook", handler.Webhook)
// 管理 API(需要管理员权限)
tg := r.Group("/admin/tg")
tg.Use(middleware.AuthMiddleware(jwtSvc))
tg.Use(middleware.AdminMiddleware())
{
tg.POST("/set-webhook", handler.SetWebhook)
tg.GET("/test", handler.TestBot)
}
}
+878
View File
@@ -0,0 +1,878 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// source: proto/agent.proto
package proto
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// 系统指标
type SystemMetrics struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
CpuUsage float64 `protobuf:"fixed64,1,opt,name=cpu_usage,json=cpuUsage,proto3" json:"cpu_usage,omitempty"` // CPU 使用率 %
MemoryTotal int64 `protobuf:"varint,2,opt,name=memory_total,json=memoryTotal,proto3" json:"memory_total,omitempty"` // 内存总量 MB
MemoryUsed int64 `protobuf:"varint,3,opt,name=memory_used,json=memoryUsed,proto3" json:"memory_used,omitempty"` // 内存已用 MB
DiskTotal int64 `protobuf:"varint,4,opt,name=disk_total,json=diskTotal,proto3" json:"disk_total,omitempty"` // 磁盘总量 GB
DiskUsed int64 `protobuf:"varint,5,opt,name=disk_used,json=diskUsed,proto3" json:"disk_used,omitempty"` // 磁盘已用 GB
NetworkRx int64 `protobuf:"varint,6,opt,name=network_rx,json=networkRx,proto3" json:"network_rx,omitempty"` // 网络接收 bytes/s
NetworkTx int64 `protobuf:"varint,7,opt,name=network_tx,json=networkTx,proto3" json:"network_tx,omitempty"` // 网络发送 bytes/s
Load_1 float64 `protobuf:"fixed64,8,opt,name=load_1,json=load1,proto3" json:"load_1,omitempty"` // 1 分钟负载
Load_5 float64 `protobuf:"fixed64,9,opt,name=load_5,json=load5,proto3" json:"load_5,omitempty"` // 5 分钟负载
Load_15 float64 `protobuf:"fixed64,10,opt,name=load_15,json=load15,proto3" json:"load_15,omitempty"` // 15 分钟负载
Uptime int64 `protobuf:"varint,11,opt,name=uptime,proto3" json:"uptime,omitempty"` // 运行时间 秒
OsInfo string `protobuf:"bytes,12,opt,name=os_info,json=osInfo,proto3" json:"os_info,omitempty"` // OS 信息 JSON
ProcessCount int32 `protobuf:"varint,13,opt,name=process_count,json=processCount,proto3" json:"process_count,omitempty"` // 进程数
CpuTemp float64 `protobuf:"fixed64,14,opt,name=cpu_temp,json=cpuTemp,proto3" json:"cpu_temp,omitempty"` // CPU 温度 ℃,-1 表示不支持
Gpus []*GpuInfo `protobuf:"bytes,15,rep,name=gpus,proto3" json:"gpus,omitempty"` // GPU 信息列表
}
func (x *SystemMetrics) Reset() {
*x = SystemMetrics{}
}
func (x *SystemMetrics) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SystemMetrics) ProtoMessage() {}
func (x *SystemMetrics) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *SystemMetrics) GetCpuUsage() float64 {
if x != nil {
return x.CpuUsage
}
return 0
}
func (x *SystemMetrics) GetMemoryTotal() int64 {
if x != nil {
return x.MemoryTotal
}
return 0
}
func (x *SystemMetrics) GetMemoryUsed() int64 {
if x != nil {
return x.MemoryUsed
}
return 0
}
func (x *SystemMetrics) GetDiskTotal() int64 {
if x != nil {
return x.DiskTotal
}
return 0
}
func (x *SystemMetrics) GetDiskUsed() int64 {
if x != nil {
return x.DiskUsed
}
return 0
}
func (x *SystemMetrics) GetNetworkRx() int64 {
if x != nil {
return x.NetworkRx
}
return 0
}
func (x *SystemMetrics) GetNetworkTx() int64 {
if x != nil {
return x.NetworkTx
}
return 0
}
func (x *SystemMetrics) GetLoad_1() float64 {
if x != nil {
return x.Load_1
}
return 0
}
func (x *SystemMetrics) GetLoad_5() float64 {
if x != nil {
return x.Load_5
}
return 0
}
func (x *SystemMetrics) GetLoad_15() float64 {
if x != nil {
return x.Load_15
}
return 0
}
func (x *SystemMetrics) GetUptime() int64 {
if x != nil {
return x.Uptime
}
return 0
}
func (x *SystemMetrics) GetOsInfo() string {
if x != nil {
return x.OsInfo
}
return ""
}
func (x *SystemMetrics) GetProcessCount() int32 {
if x != nil {
return x.ProcessCount
}
return 0
}
func (x *SystemMetrics) GetCpuTemp() float64 {
if x != nil {
return x.CpuTemp
}
return 0
}
func (x *SystemMetrics) GetGpus() []*GpuInfo {
if x != nil {
return x.Gpus
}
return nil
}
// GPU 信息
type GpuInfo struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // GPU 名称
Temp float64 `protobuf:"fixed64,2,opt,name=temp,proto3" json:"temp,omitempty"` // GPU 温度 ℃
Usage float64 `protobuf:"fixed64,3,opt,name=usage,proto3" json:"usage,omitempty"` // GPU 使用率 %
MemoryTotal int64 `protobuf:"varint,4,opt,name=memory_total,json=memoryTotal,proto3" json:"memory_total,omitempty"` // 显存总量 MB
MemoryUsed int64 `protobuf:"varint,5,opt,name=memory_used,json=memoryUsed,proto3" json:"memory_used,omitempty"` // 显存已用 MB
}
func (x *GpuInfo) Reset() {
*x = GpuInfo{}
}
func (x *GpuInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GpuInfo) ProtoMessage() {}
func (x *GpuInfo) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *GpuInfo) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *GpuInfo) GetTemp() float64 {
if x != nil {
return x.Temp
}
return 0
}
func (x *GpuInfo) GetUsage() float64 {
if x != nil {
return x.Usage
}
return 0
}
func (x *GpuInfo) GetMemoryTotal() int64 {
if x != nil {
return x.MemoryTotal
}
return 0
}
func (x *GpuInfo) GetMemoryUsed() int64 {
if x != nil {
return x.MemoryUsed
}
return 0
}
// TCPing 结果
type TcpingResult struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
TargetHost string `protobuf:"bytes,1,opt,name=target_host,json=targetHost,proto3" json:"target_host,omitempty"`
TargetPort int32 `protobuf:"varint,2,opt,name=target_port,json=targetPort,proto3" json:"target_port,omitempty"`
LatencyMs float64 `protobuf:"fixed64,3,opt,name=latency_ms,json=latencyMs,proto3" json:"latency_ms,omitempty"` // 延迟 ms-1 表示超时
Success bool `protobuf:"varint,4,opt,name=success,proto3" json:"success,omitempty"`
}
func (x *TcpingResult) Reset() {
*x = TcpingResult{}
}
func (x *TcpingResult) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TcpingResult) ProtoMessage() {}
func (x *TcpingResult) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *TcpingResult) GetTargetHost() string {
if x != nil {
return x.TargetHost
}
return ""
}
func (x *TcpingResult) GetTargetPort() int32 {
if x != nil {
return x.TargetPort
}
return 0
}
func (x *TcpingResult) GetLatencyMs() float64 {
if x != nil {
return x.LatencyMs
}
return 0
}
func (x *TcpingResult) GetSuccess() bool {
if x != nil {
return x.Success
}
return false
}
type TcpingResults struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Results []*TcpingResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"`
}
func (x *TcpingResults) Reset() {
*x = TcpingResults{}
}
func (x *TcpingResults) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TcpingResults) ProtoMessage() {}
func (x *TcpingResults) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *TcpingResults) GetResults() []*TcpingResult {
if x != nil {
return x.Results
}
return nil
}
type ReportRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
AgentToken string `protobuf:"bytes,1,opt,name=agent_token,json=agentToken,proto3" json:"agent_token,omitempty"`
// Types that are assignable to Payload:
//
// *ReportRequest_Metrics
// *ReportRequest_Tcping
Payload isReportRequest_Payload `protobuf_oneof:"payload"`
}
func (x *ReportRequest) Reset() {
*x = ReportRequest{}
}
func (x *ReportRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ReportRequest) ProtoMessage() {}
func (x *ReportRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *ReportRequest) GetAgentToken() string {
if x != nil {
return x.AgentToken
}
return ""
}
func (m *ReportRequest) GetPayload() isReportRequest_Payload {
if m != nil {
return m.Payload
}
return nil
}
func (x *ReportRequest) GetMetrics() *SystemMetrics {
if x, ok := x.GetPayload().(*ReportRequest_Metrics); ok {
return x.Metrics
}
return nil
}
func (x *ReportRequest) GetTcping() *TcpingResults {
if x, ok := x.GetPayload().(*ReportRequest_Tcping); ok {
return x.Tcping
}
return nil
}
type isReportRequest_Payload interface {
isReportRequest_Payload()
}
type ReportRequest_Metrics struct {
Metrics *SystemMetrics `protobuf:"bytes,2,opt,name=metrics,proto3,oneof"`
}
type ReportRequest_Tcping struct {
Tcping *TcpingResults `protobuf:"bytes,3,opt,name=tcping,proto3,oneof"`
}
func (*ReportRequest_Metrics) isReportRequest_Payload() {}
func (*ReportRequest_Tcping) isReportRequest_Payload() {}
// ============ 服务端指令 ============
type ServerCommand struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// Types that are assignable to Command:
//
// *ServerCommand_ConfigUpdate
// *ServerCommand_Restart
// *ServerCommand_TcpingUpdate
Command isServerCommand_Command `protobuf_oneof:"command"`
}
func (x *ServerCommand) Reset() {
*x = ServerCommand{}
}
func (x *ServerCommand) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ServerCommand) ProtoMessage() {}
func (x *ServerCommand) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (m *ServerCommand) GetCommand() isServerCommand_Command {
if m != nil {
return m.Command
}
return nil
}
func (x *ServerCommand) GetConfigUpdate() *ConfigUpdateCommand {
if x, ok := x.GetCommand().(*ServerCommand_ConfigUpdate); ok {
return x.ConfigUpdate
}
return nil
}
func (x *ServerCommand) GetRestart() *RestartCommand {
if x, ok := x.GetCommand().(*ServerCommand_Restart); ok {
return x.Restart
}
return nil
}
func (x *ServerCommand) GetTcpingUpdate() *TcpingUpdateCommand {
if x, ok := x.GetCommand().(*ServerCommand_TcpingUpdate); ok {
return x.TcpingUpdate
}
return nil
}
type isServerCommand_Command interface {
isServerCommand_Command()
}
type ServerCommand_ConfigUpdate struct {
ConfigUpdate *ConfigUpdateCommand `protobuf:"bytes,1,opt,name=config_update,json=configUpdate,proto3,oneof"`
}
type ServerCommand_Restart struct {
Restart *RestartCommand `protobuf:"bytes,2,opt,name=restart,proto3,oneof"`
}
type ServerCommand_TcpingUpdate struct {
TcpingUpdate *TcpingUpdateCommand `protobuf:"bytes,3,opt,name=tcping_update,json=tcpingUpdate,proto3,oneof"`
}
func (*ServerCommand_ConfigUpdate) isServerCommand_Command() {}
func (*ServerCommand_Restart) isServerCommand_Command() {}
func (*ServerCommand_TcpingUpdate) isServerCommand_Command() {}
type ConfigUpdateCommand struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
ConfigVersion string `protobuf:"bytes,1,opt,name=config_version,json=configVersion,proto3" json:"config_version,omitempty"`
}
func (x *ConfigUpdateCommand) Reset() {
*x = ConfigUpdateCommand{}
}
func (x *ConfigUpdateCommand) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ConfigUpdateCommand) ProtoMessage() {}
func (x *ConfigUpdateCommand) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *ConfigUpdateCommand) GetConfigVersion() string {
if x != nil {
return x.ConfigVersion
}
return ""
}
type RestartCommand struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
}
func (x *RestartCommand) Reset() {
*x = RestartCommand{}
}
func (x *RestartCommand) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RestartCommand) ProtoMessage() {}
func (x *RestartCommand) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
type TcpingUpdateCommand struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Targets []*TcpingTarget `protobuf:"bytes,1,rep,name=targets,proto3" json:"targets,omitempty"`
}
func (x *TcpingUpdateCommand) Reset() {
*x = TcpingUpdateCommand{}
}
func (x *TcpingUpdateCommand) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TcpingUpdateCommand) ProtoMessage() {}
func (x *TcpingUpdateCommand) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *TcpingUpdateCommand) GetTargets() []*TcpingTarget {
if x != nil {
return x.Targets
}
return nil
}
type TcpingTarget struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
Port int32 `protobuf:"varint,2,opt,name=port,proto3" json:"port,omitempty"`
}
func (x *TcpingTarget) Reset() {
*x = TcpingTarget{}
}
func (x *TcpingTarget) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TcpingTarget) ProtoMessage() {}
func (x *TcpingTarget) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *TcpingTarget) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *TcpingTarget) GetPort() int32 {
if x != nil {
return x.Port
}
return 0
}
// ============ 配置拉取 ============
type ConfigRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
AgentToken string `protobuf:"bytes,1,opt,name=agent_token,json=agentToken,proto3" json:"agent_token,omitempty"`
CurrentConfigVersion string `protobuf:"bytes,2,opt,name=current_config_version,json=currentConfigVersion,proto3" json:"current_config_version,omitempty"`
}
func (x *ConfigRequest) Reset() {
*x = ConfigRequest{}
}
func (x *ConfigRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ConfigRequest) ProtoMessage() {}
func (x *ConfigRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *ConfigRequest) GetAgentToken() string {
if x != nil {
return x.AgentToken
}
return ""
}
func (x *ConfigRequest) GetCurrentConfigVersion() string {
if x != nil {
return x.CurrentConfigVersion
}
return ""
}
type AgentConfig struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
ConfigVersion string `protobuf:"bytes,1,opt,name=config_version,json=configVersion,proto3" json:"config_version,omitempty"`
ReportIntervalSeconds int32 `protobuf:"varint,2,opt,name=report_interval_seconds,json=reportIntervalSeconds,proto3" json:"report_interval_seconds,omitempty"`
TcpingIntervalSeconds int32 `protobuf:"varint,3,opt,name=tcping_interval_seconds,json=tcpingIntervalSeconds,proto3" json:"tcping_interval_seconds,omitempty"`
TcpingTargets []*TcpingTarget `protobuf:"bytes,4,rep,name=tcping_targets,json=tcpingTargets,proto3" json:"tcping_targets,omitempty"`
ConfigChanged bool `protobuf:"varint,5,opt,name=config_changed,json=configChanged,proto3" json:"config_changed,omitempty"`
}
func (x *AgentConfig) Reset() {
*x = AgentConfig{}
}
func (x *AgentConfig) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AgentConfig) ProtoMessage() {}
func (x *AgentConfig) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *AgentConfig) GetConfigVersion() string {
if x != nil {
return x.ConfigVersion
}
return ""
}
func (x *AgentConfig) GetReportIntervalSeconds() int32 {
if x != nil {
return x.ReportIntervalSeconds
}
return 0
}
func (x *AgentConfig) GetTcpingIntervalSeconds() int32 {
if x != nil {
return x.TcpingIntervalSeconds
}
return 0
}
func (x *AgentConfig) GetTcpingTargets() []*TcpingTarget {
if x != nil {
return x.TcpingTargets
}
return nil
}
func (x *AgentConfig) GetConfigChanged() bool {
if x != nil {
return x.ConfigChanged
}
return false
}
// ============ 心跳 ============
type HeartbeatRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
AgentToken string `protobuf:"bytes,1,opt,name=agent_token,json=agentToken,proto3" json:"agent_token,omitempty"`
}
func (x *HeartbeatRequest) Reset() {
*x = HeartbeatRequest{}
}
func (x *HeartbeatRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HeartbeatRequest) ProtoMessage() {}
func (x *HeartbeatRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *HeartbeatRequest) GetAgentToken() string {
if x != nil {
return x.AgentToken
}
return ""
}
type HeartbeatResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
ConfigVersion string `protobuf:"bytes,2,opt,name=config_version,json=configVersion,proto3" json:"config_version,omitempty"`
}
func (x *HeartbeatResponse) Reset() {
*x = HeartbeatResponse{}
}
func (x *HeartbeatResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*HeartbeatResponse) ProtoMessage() {}
func (x *HeartbeatResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_agent_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
func (x *HeartbeatResponse) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
func (x *HeartbeatResponse) GetConfigVersion() string {
if x != nil {
return x.ConfigVersion
}
return ""
}
var File_proto_agent_proto protoreflect.FileDescriptor
var file_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 14)
var file_proto_agent_proto_goTypes = []interface{}{
(*SystemMetrics)(nil), // 0: nuyue.SystemMetrics
(*GpuInfo)(nil), // 1: nuyue.GpuInfo
(*TcpingResult)(nil), // 2: nuyue.TcpingResult
(*TcpingResults)(nil), // 3: nuyue.TcpingResults
(*ReportRequest)(nil), // 4: nuyue.ReportRequest
(*ServerCommand)(nil), // 5: nuyue.ServerCommand
(*ConfigUpdateCommand)(nil), // 6: nuyue.ConfigUpdateCommand
(*RestartCommand)(nil), // 7: nuyue.RestartCommand
(*TcpingUpdateCommand)(nil), // 8: nuyue.TcpingUpdateCommand
(*TcpingTarget)(nil), // 9: nuyue.TcpingTarget
(*ConfigRequest)(nil), // 10: nuyue.ConfigRequest
(*AgentConfig)(nil), // 11: nuyue.AgentConfig
(*HeartbeatRequest)(nil), // 12: nuyue.HeartbeatRequest
(*HeartbeatResponse)(nil), // 13: nuyue.HeartbeatResponse
}
func init() { file_proto_agent_proto_init() }
func file_proto_agent_proto_init() {
// Message initialization is done in the generated file
}
+194
View File
@@ -0,0 +1,194 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
package proto
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// AgentServiceClient is the client API for AgentService service.
type AgentServiceClient interface {
// 双向流:Agent 上报指标,服务端下发指令
ReportStream(ctx context.Context, opts ...grpc.CallOption) (AgentService_ReportStreamClient, error)
// Agent 拉取配置
FetchConfig(ctx context.Context, in *ConfigRequest, opts ...grpc.CallOption) (*AgentConfig, error)
// 心跳(轻量级保活)
Heartbeat(ctx context.Context, in *HeartbeatRequest, opts ...grpc.CallOption) (*HeartbeatResponse, error)
}
type agentServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAgentServiceClient(cc grpc.ClientConnInterface) AgentServiceClient {
return &agentServiceClient{cc}
}
func (c *agentServiceClient) ReportStream(ctx context.Context, opts ...grpc.CallOption) (AgentService_ReportStreamClient, error) {
stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[0], "/nuyue.AgentService/ReportStream", opts...)
if err != nil {
return nil, err
}
x := &agentServiceReportStreamClient{stream}
return x, nil
}
type AgentService_ReportStreamClient interface {
Send(*ReportRequest) error
Recv() (*ServerCommand, error)
grpc.ClientStream
}
type agentServiceReportStreamClient struct {
grpc.ClientStream
}
func (x *agentServiceReportStreamClient) Send(m *ReportRequest) error {
return x.ClientStream.SendMsg(m)
}
func (x *agentServiceReportStreamClient) Recv() (*ServerCommand, error) {
m := new(ServerCommand)
if err := x.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
func (c *agentServiceClient) FetchConfig(ctx context.Context, in *ConfigRequest, opts ...grpc.CallOption) (*AgentConfig, error) {
out := new(AgentConfig)
err := c.cc.Invoke(ctx, "/nuyue.AgentService/FetchConfig", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *agentServiceClient) Heartbeat(ctx context.Context, in *HeartbeatRequest, opts ...grpc.CallOption) (*HeartbeatResponse, error) {
out := new(HeartbeatResponse)
err := c.cc.Invoke(ctx, "/nuyue.AgentService/Heartbeat", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// AgentServiceServer is the server API for AgentService service.
type AgentServiceServer interface {
// 双向流:Agent 上报指标,服务端下发指令
ReportStream(AgentService_ReportStreamServer) error
// Agent 拉取配置
FetchConfig(context.Context, *ConfigRequest) (*AgentConfig, error)
// 心跳(轻量级保活)
Heartbeat(context.Context, *HeartbeatRequest) (*HeartbeatResponse, error)
}
// UnimplementedAgentServiceServer can be embedded to have forward compatible implementations.
type UnimplementedAgentServiceServer struct {
}
func (UnimplementedAgentServiceServer) ReportStream(AgentService_ReportStreamServer) error {
return status.Errorf(codes.Unimplemented, "method ReportStream not implemented")
}
func (UnimplementedAgentServiceServer) FetchConfig(context.Context, *ConfigRequest) (*AgentConfig, error) {
return nil, status.Errorf(codes.Unimplemented, "method FetchConfig not implemented")
}
func (UnimplementedAgentServiceServer) Heartbeat(context.Context, *HeartbeatRequest) (*HeartbeatResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Heartbeat not implemented")
}
func RegisterAgentServiceServer(s grpc.ServiceRegistrar, srv AgentServiceServer) {
s.RegisterService(&AgentService_ServiceDesc, srv)
}
func _AgentService_FetchConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ConfigRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServiceServer).FetchConfig(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/nuyue.AgentService/FetchConfig",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServiceServer).FetchConfig(ctx, req.(*ConfigRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AgentService_Heartbeat_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HeartbeatRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AgentServiceServer).Heartbeat(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/nuyue.AgentService/Heartbeat",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AgentServiceServer).Heartbeat(ctx, req.(*HeartbeatRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AgentService_ReportStream_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(AgentServiceServer).ReportStream(&agentServiceReportStreamServer{stream})
}
type AgentService_ReportStreamServer interface {
Send(*ServerCommand) error
Recv() (*ReportRequest, error)
grpc.ServerStream
}
type agentServiceReportStreamServer struct {
grpc.ServerStream
}
func (x *agentServiceReportStreamServer) Send(m *ServerCommand) error {
return x.ServerStream.SendMsg(m)
}
func (x *agentServiceReportStreamServer) Recv() (*ReportRequest, error) {
m := new(ReportRequest)
if err := x.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// AgentService_ServiceDesc is the grpc.ServiceDesc for AgentService service.
var AgentService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "nuyue.AgentService",
HandlerType: (*AgentServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "FetchConfig",
Handler: _AgentService_FetchConfig_Handler,
},
{
MethodName: "Heartbeat",
Handler: _AgentService_Heartbeat_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "ReportStream",
Handler: _AgentService_ReportStream_Handler,
ServerStreams: true,
ClientStreams: true,
},
},
Metadata: "proto/agent.proto",
}
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
+1146 -1
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -9,12 +9,28 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.2.0",
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-dropdown-menu": "^2.1.18",
"@radix-ui/react-label": "^2.1.10",
"@radix-ui/react-progress": "^1.1.10",
"@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-separator": "^1.1.10",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.1",
"@radix-ui/react-tabs": "^1.1.15",
"@radix-ui/react-toast": "^1.2.17",
"@radix-ui/react-tooltip": "^1.2.10",
"@tanstack/react-query": "^5.49.0",
"axios": "^1.7.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"naive-ui": "^2.38.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.0",
"tailwind-merge": "^3.6.0",
"zustand": "^4.5.4"
},
"devDependencies": {
@@ -24,6 +40,7 @@
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.5.2",
"vite": "^5.3.2"
}
+39 -5
View File
@@ -1,25 +1,59 @@
// @ts-nocheck
import { Routes, Route } from 'react-router-dom'
import { Routes, Route, Navigate } from 'react-router-dom'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import Servers from './pages/Servers'
import ProbePage from './pages/ProbePage'
import Login from './pages/Login'
import Register from './pages/Register'
import Install from './pages/Install'
import Alerts from './pages/Alerts'
import ProbePageConfig from './pages/ProbePageConfig'
import Settings from './pages/Settings'
import AdminPanel from './pages/AdminPanel'
import Landing from './pages/Landing'
import Pricing from './pages/Pricing'
import Docs from './pages/Docs'
// 认证守卫组件
function PrivateRoute({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem('token')
if (!token) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
function App() {
return (
<Routes>
{/* 公开页面 - 官网 */}
<Route path="/" element={<Landing />} />
<Route path="/pricing" element={<Pricing />} />
<Route path="/docs" element={<Docs />} />
{/* 安装页面 */}
<Route path="/install" element={<Install />} />
{/* 登录/注册页面 */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
{/* 探针公开页面 */}
<Route path="/probe/:slug" element={<ProbePage />} />
<Route path="/" element={<Layout />}>
{/* 管理后台 - 需要认证 */}
<Route path="/dashboard" element={<PrivateRoute><Layout /></PrivateRoute>}>
<Route index element={<Dashboard />} />
<Route path="servers" element={<Servers />} />
<Route path="servers/:id" element={<Servers />} />
<Route path="settings" element={<Dashboard />} />
<Route path="alerts" element={<Dashboard />} />
<Route path="alerts" element={<Alerts />} />
<Route path="probe-page" element={<ProbePageConfig />} />
<Route path="settings" element={<Settings />} />
<Route path="admin" element={<AdminPanel />} />
</Route>
{/* 404 跳转到首页 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
+114 -23
View File
@@ -1,41 +1,132 @@
// @ts-nocheck
import { Outlet } from 'react-router-dom'
import { useState } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import { useState, useEffect } from 'react'
import {
LayoutDashboard,
Server,
Bell,
Globe,
Settings,
LogOut,
Menu,
Shield
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
const baseMenuItems = [
{ path: '/dashboard', label: '控制台', icon: LayoutDashboard },
{ path: '/dashboard/servers', label: '服务器', icon: Server },
{ path: '/dashboard/alerts', label: '告警', icon: Bell },
{ path: '/dashboard/probe-page', label: '探针页面', icon: Globe },
{ path: '/dashboard/settings', label: '设置', icon: Settings },
]
const adminMenuItem = { path: '/dashboard/admin', label: '管理后台', icon: Shield }
function Layout() {
const [collapsed, setCollapsed] = useState(false)
const [isAdmin, setIsAdmin] = useState(false)
const location = useLocation()
useEffect(() => {
// 检查是否是管理员
const checkAdmin = async () => {
try {
const token = localStorage.getItem('token')
if (!token) return
const res = await fetch('/api/v1/user/profile', {
headers: { 'Authorization': `Bearer ${token}` }
})
const data = await res.json()
setIsAdmin(data.data?.role === 'admin')
} catch {
setIsAdmin(false)
}
}
checkAdmin()
}, [])
const handleLogout = () => {
localStorage.removeItem('token')
window.location.href = '/login'
}
const menuItems = isAdmin ? [...baseMenuItems, adminMenuItem] : baseMenuItems
return (
<div className="flex h-screen">
<div className="flex h-screen bg-background">
{/* Sidebar */}
<div className={`${collapsed ? 'w-16' : 'w-60'} bg-nuyue-sidebar border-r border-nuyue-border flex flex-col`}>
<div className="flex items-center justify-center h-16 border-b border-nuyue-border">
<span className="font-mono font-bold text-nuyue-primary text-xl">
<aside className={cn(
"flex flex-col border-r border-border bg-card transition-all duration-300",
collapsed ? "w-16" : "w-60"
)}>
{/* Logo */}
<div className="flex items-center justify-center h-16 border-b border-border">
<span className="font-mono font-bold text-primary text-xl">
{collapsed ? '怒' : '怒月'}
</span>
</div>
{/* Navigation */}
<nav className="flex-1 p-4">
<ul className="space-y-2">
<li><a href="/" className="block px-3 py-2 rounded hover:bg-nuyue-card text-nuyue-text"></a></li>
<li><a href="/servers" className="block px-3 py-2 rounded hover:bg-nuyue-card text-nuyue-text"></a></li>
<li><a href="/alerts" className="block px-3 py-2 rounded hover:bg-nuyue-card text-nuyue-text"></a></li>
<li><a href="/settings" className="block px-3 py-2 rounded hover:bg-nuyue-card text-nuyue-text"></a></li>
{menuItems.map(item => {
const Icon = item.icon
const isActive = location.pathname === item.path ||
(item.path !== '/' && location.pathname.startsWith(item.path))
return (
<li key={item.path}>
<a
href={item.path}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-secondary hover:text-foreground"
)}
>
<Icon className="h-4 w-4" />
{!collapsed && <span>{item.label}</span>}
</a>
</li>
)
})}
</ul>
</nav>
<div className="p-4 border-t border-nuyue-border">
<button onClick={() => setCollapsed(!collapsed)} className="w-full px-3 py-2 text-sm text-nuyue-muted hover:text-nuyue-text">
{collapsed ? '展开' : '收起'}
</button>
{/* Footer */}
<div className="p-4 border-t border-border">
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className={cn("w-full justify-start text-muted-foreground hover:text-destructive")}
>
<LogOut className="h-4 w-4" />
{!collapsed && <span className="ml-3">退</span>}
</Button>
</div>
</div>
{/* Main */}
<div className="flex-1 flex flex-col">
<header className="h-16 bg-nuyue-card border-b border-nuyue-border flex items-center justify-between px-6">
<span className="text-nuyue-muted"></span>
<button className="bg-nuyue-primary text-nuyue-bg px-4 py-2 rounded text-sm font-medium hover:bg-nuyue-primary-hover">退</button>
</aside>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<header className="h-16 border-b border-border bg-card flex items-center justify-between px-6">
<span className="text-muted-foreground text-sm"></span>
<Button
variant="ghost"
size="icon"
onClick={() => setCollapsed(!collapsed)}
>
<Menu className="h-4 w-4" />
</Button>
</header>
<main className="flex-1 p-6 bg-nuyue-bg overflow-auto">
{/* Content */}
<main className="flex-1 p-6 overflow-auto">
<Outlet />
</main>
</div>
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+79
View File
@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("shrink-0 bg-border h-[1px] w-full", className)}
{...props}
/>
))
Separator.displayName = "Separator"
export { Separator }
+26 -77
View File
@@ -1,85 +1,34 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Termius 深色主题基础样式 */
:root {
color-scheme: dark;
}
@layer base {
body {
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Menlo', monospace;
background-color: #0d1117;
color: #e6edf3;
-webkit-font-smoothing: antialiased;
}
* {
border-color: #30363d;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
body {
@apply bg-nuyue-bg text-nuyue-text font-sans;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::-webkit-scrollbar-track {
background: #0d1117;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: #30363d;
border-radius: 4px;
}
::-webkit-scrollbar-track {
@apply bg-nuyue-bg;
}
::-webkit-scrollbar-thumb {
@apply bg-nuyue-border rounded;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-nuyue-muted;
}
/* 代码字体 */
.font-mono {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
/* 卡片样式 */
.card {
@apply bg-nuyue-card border border-nuyue-border rounded-lg p-4;
}
/* 状态指示器 */
.status-online {
@apply bg-nuyue-primary;
}
.status-offline {
@apply bg-nuyue-danger;
}
.status-warning {
@apply bg-nuyue-warning;
}
/* Naive UI 深色主题覆盖 */
.n-card {
@apply bg-nuyue-card border-nuyue-border;
}
.n-button--primary-type {
@apply bg-nuyue-primary hover:bg-nuyue-primary-hover;
}
/* 服务器卡片网格 */
.server-grid {
@apply grid gap-4;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
/* 探针页面样式 */
.probe-page {
@apply min-h-screen bg-nuyue-bg;
}
.probe-header {
@apply bg-nuyue-sidebar border-b border-nuyue-border px-6 py-4;
}
.probe-content {
@apply p-6;
::-webkit-scrollbar-thumb:hover {
background: #8b949e;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+647
View File
@@ -0,0 +1,647 @@
import { useState, useEffect } from 'react'
import { Navigate } from 'react-router-dom'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import {
Users, Package, Gift, Settings,
Plus, Edit2, Trash2, X
} from 'lucide-react'
// 管理员检查
function useAdmin() {
const [isAdmin, setIsAdmin] = useState<boolean | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const checkAdmin = async () => {
try {
const token = localStorage.getItem('token')
if (!token) {
setIsAdmin(false)
return
}
const res = await fetch('/api/v1/user/profile', {
headers: { 'Authorization': `Bearer ${token}` }
})
const data = await res.json()
setIsAdmin(data.data?.role === 'admin')
} catch {
setIsAdmin(false)
} finally {
setLoading(false)
}
}
checkAdmin()
}, [])
return { isAdmin, loading }
}
// 用户管理
function UsersTab() {
const [users, setUsers] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchUsers()
}, [])
const fetchUsers = async () => {
try {
const token = localStorage.getItem('token')
const res = await fetch('/api/v1/admin/users', {
headers: { 'Authorization': `Bearer ${token}` }
})
const data = await res.json()
if (data.code === 0) {
setUsers(data.data || [])
}
} catch (err) {
console.error('Failed to fetch users:', err)
} finally {
setLoading(false)
}
}
const handleToggleStatus = async (userId: string, currentStatus: string) => {
const newStatus = currentStatus === 'active' ? 'disabled' : 'active'
if (!confirm(`确定要${newStatus === 'disabled' ? '禁用' : '启用'}该用户吗?`)) return
try {
const token = localStorage.getItem('token')
const res = await fetch(`/api/v1/admin/users/${userId}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ status: newStatus })
})
const data = await res.json()
if (data.code === 0) {
fetchUsers()
} else {
alert(data.message || '操作失败')
}
} catch {
alert('网络错误')
}
}
if (loading) return <p>...</p>
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{users.length === 0 ? (
<p className="text-muted-foreground text-center py-8"></p>
) : (
<div className="space-y-4">
{users.map(user => (
<div key={user.id} className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-medium">{user.username}</p>
<p className="text-sm text-muted-foreground">{user.email || '未绑定邮箱'}</p>
</div>
<div className="flex items-center gap-4">
<span className={`px-2 py-1 text-xs rounded ${user.role === 'admin' ? 'bg-purple-500/20 text-purple-500' : 'bg-blue-500/20 text-blue-500'}`}>
{user.role === 'admin' ? '管理员' : '用户'}
</span>
<span className={`px-2 py-1 text-xs rounded ${user.status === 'active' ? 'bg-green-500/20 text-green-500' : 'bg-red-500/20 text-red-500'}`}>
{user.status === 'active' ? '正常' : '禁用'}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handleToggleStatus(user.id, user.status)}
>
{user.status === 'active' ? '禁用' : '启用'}
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}
// 套餐管理
interface Plan {
id: string
name: string
description: string
max_clients: number
price_monthly: number | null
price_yearly: number | null
price_lifetime: number | null
is_visible: boolean
status: string
}
function PlansTab() {
const [plans, setPlans] = useState<Plan[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingPlan, setEditingPlan] = useState<Plan | null>(null)
const [formData, setFormData] = useState({
name: '',
description: '',
max_clients: 5,
max_tcping_nodes: 10,
allow_custom_theme: true,
allow_tg_notify: true,
allow_backup: false,
price_monthly: null as number | null,
price_yearly: null as number | null,
price_lifetime: null as number | null,
is_visible: true
})
useEffect(() => {
fetchPlans()
}, [])
const fetchPlans = async () => {
try {
const token = localStorage.getItem('token')
const res = await fetch('/api/v1/admin/plans', {
headers: { 'Authorization': `Bearer ${token}` }
})
const data = await res.json()
if (data.code === 0) {
setPlans(data.data?.list || [])
}
} catch {
console.error('Failed to fetch plans')
} finally {
setLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const token = localStorage.getItem('token')
const url = editingPlan
? `/api/v1/admin/plans/${editingPlan.id}`
: '/api/v1/admin/plans'
const method = editingPlan ? 'PUT' : 'POST'
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(formData)
})
const data = await res.json()
if (data.code === 0) {
setShowModal(false)
setEditingPlan(null)
fetchPlans()
} else {
alert(data.message || '操作失败')
}
} catch {
alert('网络错误')
}
}
const handleDelete = async (planId: string) => {
if (!confirm('确定要删除该套餐吗?')) return
try {
const token = localStorage.getItem('token')
const res = await fetch(`/api/v1/admin/plans/${planId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
})
const data = await res.json()
if (data.code === 0) {
fetchPlans()
} else {
alert(data.message || '删除失败')
}
} catch {
alert('网络错误')
}
}
const resetForm = () => {
setFormData({
name: '',
description: '',
max_clients: 5,
max_tcping_nodes: 10,
allow_custom_theme: true,
allow_tg_notify: true,
allow_backup: false,
price_monthly: null,
price_yearly: null,
price_lifetime: null,
is_visible: true
})
}
if (loading) return <p>...</p>
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold"></h2>
<Button onClick={() => { resetForm(); setShowModal(true); }}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{plans.map(plan => (
<Card key={plan.id}>
<CardHeader>
<CardTitle className="flex items-center justify-between">
{plan.name}
<div className="flex gap-2">
<Button variant="ghost" size="icon" onClick={() => {
setEditingPlan(plan)
setFormData({
name: plan.name,
description: plan.description || '',
max_clients: plan.max_clients,
max_tcping_nodes: 10,
allow_custom_theme: true,
allow_tg_notify: true,
allow_backup: false,
price_monthly: plan.price_monthly,
price_yearly: plan.price_yearly,
price_lifetime: plan.price_lifetime,
is_visible: plan.is_visible
})
setShowModal(true)
}}>
<Edit2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(plan.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardTitle>
<CardDescription>{plan.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<p>: {plan.max_clients}</p>
<div className="flex gap-2 flex-wrap">
{plan.price_monthly && <span className="px-2 py-1 bg-green-500/20 text-green-500 rounded text-xs">¥{plan.price_monthly}/</span>}
{plan.price_yearly && <span className="px-2 py-1 bg-blue-500/20 text-blue-500 rounded text-xs">¥{plan.price_yearly}/</span>}
{plan.price_lifetime && <span className="px-2 py-1 bg-purple-500/20 text-purple-500 rounded text-xs">¥{plan.price_lifetime} </span>}
</div>
<p className={plan.is_visible ? 'text-green-500' : 'text-muted-foreground'}>
{plan.is_visible ? '可见' : '已隐藏'}
</p>
</div>
</CardContent>
</Card>
))}
</div>
{/* Modal */}
{showModal && (
<div className="fixed inset-0 bg-background/80 flex items-center justify-center z-50">
<Card className="w-full max-w-md">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{editingPlan ? '编辑套餐' : '添加套餐'}</CardTitle>
<Button variant="ghost" size="icon" onClick={() => { setShowModal(false); setEditingPlan(null); }}>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label></Label>
<Input value={formData.name} onChange={e => setFormData({...formData, name: e.target.value})} required />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={formData.description} onChange={e => setFormData({...formData, description: e.target.value})} />
</div>
<div className="space-y-2">
<Label></Label>
<Input type="number" value={formData.max_clients} onChange={e => setFormData({...formData, max_clients: parseInt(e.target.value)})} />
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label></Label>
<Input type="number" value={formData.price_monthly || ''} onChange={e => setFormData({...formData, price_monthly: e.target.value ? parseFloat(e.target.value) : null})} />
</div>
<div className="space-y-2">
<Label></Label>
<Input type="number" value={formData.price_yearly || ''} onChange={e => setFormData({...formData, price_yearly: e.target.value ? parseFloat(e.target.value) : null})} />
</div>
<div className="space-y-2">
<Label></Label>
<Input type="number" value={formData.price_lifetime || ''} onChange={e => setFormData({...formData, price_lifetime: e.target.value ? parseFloat(e.target.value) : null})} />
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_visible"
checked={formData.is_visible}
onChange={e => setFormData({...formData, is_visible: e.target.checked})}
/>
<Label htmlFor="is_visible"></Label>
</div>
<div className="flex gap-4">
<Button type="submit" className="flex-1"></Button>
<Button type="button" variant="outline" className="flex-1" onClick={() => { setShowModal(false); setEditingPlan(null); }}></Button>
</div>
</form>
</CardContent>
</Card>
</div>
)}
</div>
)
}
// 兑换码管理
function RedeemTab() {
const [_codes, _setCodes] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [_showModal, _setShowModal] = useState(false)
const [_plans, _setPlans] = useState<Plan[]>([])
useEffect(() => {
fetchData()
}, [])
const fetchData = async () => {
try {
const token = localStorage.getItem('token')
const [codesRes, plansRes] = await Promise.all([
fetch('/api/v1/admin/redeem-codes', {
headers: { 'Authorization': `Bearer ${token}` }
}),
fetch('/api/v1/admin/plans', {
headers: { 'Authorization': `Bearer ${token}` }
})
])
const codesData = await codesRes.json()
const plansData = await plansRes.json()
if (codesData.code === 0) _setCodes(codesData.data || [])
if (plansData.code === 0) _setPlans(plansData.data?.list || [])
} catch {
console.error('Failed to fetch data')
} finally {
setLoading(false)
}
}
if (loading) return <p>...</p>
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-center py-8">...</p>
</CardContent>
</Card>
)
}
// 系统设置
function SystemSettingsTab() {
const [settings, setSettings] = useState<Record<string, any>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
fetchSettings()
}, [])
const fetchSettings = async () => {
try {
const token = localStorage.getItem('token')
const res = await fetch('/api/v1/admin/settings', {
headers: { 'Authorization': `Bearer ${token}` }
})
const data = await res.json()
if (data.code === 0) {
setSettings(data.data || {})
}
} catch {
console.error('Failed to fetch settings')
} finally {
setLoading(false)
}
}
const handleSave = async () => {
setSaving(true)
try {
const token = localStorage.getItem('token')
const res = await fetch('/api/v1/admin/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(settings)
})
const data = await res.json()
if (data.code === 0) {
alert('设置已保存')
} else {
alert(data.message || '保存失败')
}
} catch {
alert('网络错误')
} finally {
setSaving(false)
}
}
if (loading) return <p>...</p>
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
value={settings.site_name || ''}
onChange={e => setSettings({...settings, site_name: e.target.value})}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={settings.site_url || ''}
onChange={e => setSettings({...settings, site_url: e.target.value})}
/>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={settings.site_description || ''}
onChange={e => setSettings({...settings, site_description: e.target.value})}
/>
</div>
<Separator />
<div className="flex items-center gap-2">
<input
type="checkbox"
id="register_enabled"
checked={settings.register_enabled === true}
onChange={e => setSettings({...settings, register_enabled: e.target.checked})}
/>
<Label htmlFor="register_enabled"></Label>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="email_verify_enabled"
checked={settings.email_verify_enabled === true}
onChange={e => setSettings({...settings, email_verify_enabled: e.target.checked})}
/>
<Label htmlFor="email_verify_enabled"></Label>
</div>
<Separator />
<h3 className="font-semibold">Telegram Bot </h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Bot Token</Label>
<Input
type="password"
value={settings.tg_bot_token || ''}
onChange={e => setSettings({...settings, tg_bot_token: e.target.value})}
placeholder="123456789:ABC..."
/>
</div>
<div className="space-y-2">
<Label>Bot </Label>
<Input
value={settings.tg_bot_username || ''}
onChange={e => setSettings({...settings, tg_bot_username: e.target.value})}
placeholder="YourBot"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="tg_bot_enabled"
checked={settings.tg_bot_enabled === true}
onChange={e => setSettings({...settings, tg_bot_enabled: e.target.checked})}
/>
<Label htmlFor="tg_bot_enabled"> Telegram Bot</Label>
</div>
<Button onClick={handleSave} disabled={saving}>
{saving ? '保存中...' : '保存设置'}
</Button>
</CardContent>
</Card>
)
}
// 主组件
const TABS = [
{ id: 'users', label: '用户管理', icon: Users },
{ id: 'plans', label: '套餐管理', icon: Package },
{ id: 'redeem', label: '兑换码', icon: Gift },
{ id: 'settings', label: '系统设置', icon: Settings }
]
function AdminPanel() {
const { isAdmin, loading } = useAdmin()
const [activeTab, setActiveTab] = useState('users')
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">...</p>
</div>
)
}
if (!isAdmin) {
return <Navigate to="/" replace />
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b pb-2">
{TABS.map(tab => {
const Icon = tab.icon
return (
<Button
key={tab.id}
variant={activeTab === tab.id ? 'default' : 'ghost'}
onClick={() => setActiveTab(tab.id)}
>
<Icon className="h-4 w-4 mr-2" />
{tab.label}
</Button>
)
})}
</div>
{/* Tab Content */}
{activeTab === 'users' && <UsersTab />}
{activeTab === 'plans' && <PlansTab />}
{activeTab === 'redeem' && <RedeemTab />}
{activeTab === 'settings' && <SystemSettingsTab />}
</div>
)
}
export default AdminPanel
+374
View File
@@ -0,0 +1,374 @@
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Bell, Trash2, Edit2, Plus, X } from 'lucide-react'
interface AlertRule {
id: string
name: string
metric: string
operator: string
threshold: number
duration_seconds: number
notify_channels: string[]
enabled: boolean
server_id?: string
}
interface ServerInfo {
id: string
name: string
display_name: string
}
const METRIC_LABELS: Record<string, string> = {
cpu: 'CPU 使用率',
memory: '内存使用率',
disk: '磁盘使用率',
network_tx: '出站流量',
load: '系统负载',
process_count: '进程数',
cpu_temp: 'CPU 温度'
}
const OPERATOR_LABELS: Record<string, string> = {
'>': '大于',
'>=': '大于等于',
'<': '小于',
'<=': '小于等于',
'==': '等于',
'!=': '不等于'
}
function Alerts() {
const [rules, setRules] = useState<AlertRule[]>([])
const [servers, setServers] = useState<ServerInfo[]>([])
const [loading, setLoading] = useState(true)
const [showAddModal, setShowAddModal] = useState(false)
const [editingRule, setEditingRule] = useState<AlertRule | null>(null)
const [formData, setFormData] = useState({
name: '',
metric: 'cpu',
operator: '>',
threshold: 80,
duration_seconds: 60,
notify_channels: ['telegram'],
server_id: '',
enabled: true
})
useEffect(() => {
fetchData()
}, [])
const fetchData = async () => {
try {
const token = localStorage.getItem('token')
if (!token) {
window.location.href = '/login'
return
}
// 获取告警规则
const alertsRes = await fetch('/api/v1/alerts', {
headers: { 'Authorization': `Bearer ${token}` }
})
const alertsData = await alertsRes.json()
if (alertsData.code === 0) {
setRules(alertsData.data?.rules || [])
}
// 获取服务器列表
const serversRes = await fetch('/api/v1/servers', {
headers: { 'Authorization': `Bearer ${token}` }
})
const serversData = await serversRes.json()
if (serversData.code === 0) {
setServers(serversData.data || [])
}
} catch (err) {
console.error('Failed to fetch data:', err)
} finally {
setLoading(false)
}
}
const handleAddRule = async (e: React.FormEvent) => {
e.preventDefault()
try {
const token = localStorage.getItem('token')
if (!token) return
const url = editingRule
? `/api/v1/alerts/${editingRule.id}`
: '/api/v1/alerts'
const method = editingRule ? 'PUT' : 'POST'
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(formData)
})
const data = await res.json()
if (data.code === 0) {
setShowAddModal(false)
setEditingRule(null)
resetForm()
fetchData()
} else {
alert(data.message || '操作失败')
}
} catch (err) {
alert('网络错误')
}
}
const handleDeleteRule = async (ruleId: string) => {
if (!confirm('确定要删除这条告警规则吗?')) return
try {
const token = localStorage.getItem('token')
if (!token) return
const res = await fetch(`/api/v1/alerts/${ruleId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
})
const data = await res.json()
if (data.code === 0) {
fetchData()
} else {
alert(data.message || '删除失败')
}
} catch (err) {
alert('网络错误')
}
}
const handleEditRule = (rule: AlertRule) => {
setEditingRule(rule)
setFormData({
name: rule.name,
metric: rule.metric,
operator: rule.operator,
threshold: rule.threshold,
duration_seconds: rule.duration_seconds,
notify_channels: rule.notify_channels,
server_id: rule.server_id || '',
enabled: rule.enabled
})
setShowAddModal(true)
}
const resetForm = () => {
setFormData({
name: '',
metric: 'cpu',
operator: '>',
threshold: 80,
duration_seconds: 60,
notify_channels: ['telegram'],
server_id: '',
enabled: true
})
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">...</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
<Button onClick={() => { resetForm(); setShowAddModal(true); }}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
{rules.length === 0 ? (
<Card>
<CardContent className="py-16 text-center">
<Bell className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h2 className="text-xl font-medium mb-2"></h2>
<p className="text-muted-foreground mb-6"></p>
<Button onClick={() => setShowAddModal(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{rules.map(rule => (
<Card key={rule.id}>
<CardHeader className="flex flex-row items-center justify-between py-4">
<div>
<CardTitle className="text-lg">{rule.name}</CardTitle>
<CardDescription>
{METRIC_LABELS[rule.metric] || rule.metric}
{OPERATOR_LABELS[rule.operator] || rule.operator}
{rule.threshold}{rule.metric.includes('cpu') || rule.metric.includes('memory') || rule.metric.includes('disk') ? '%' : ''}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<span className={`px-2 py-1 text-xs rounded ${rule.enabled ? 'bg-green-500/20 text-green-500' : 'bg-gray-500/20 text-gray-500'}`}>
{rule.enabled ? '已启用' : '已禁用'}
</span>
<Button variant="ghost" size="icon" onClick={() => handleEditRule(rule)}>
<Edit2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDeleteRule(rule.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardHeader>
<CardContent className="py-4">
<div className="flex gap-4 text-sm text-muted-foreground">
<span>: {rule.duration_seconds}</span>
<span>: {rule.notify_channels.join(', ')}</span>
{rule.server_id && <span>: {servers.find(s => s.id === rule.server_id)?.name || '未知'}</span>}
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Add/Edit Modal */}
{showAddModal && (
<div className="fixed inset-0 bg-background/80 flex items-center justify-center z-50">
<Card className="w-full max-w-lg">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{editingRule ? '编辑规则' : '添加告警规则'}</CardTitle>
<Button variant="ghost" size="icon" onClick={() => { setShowAddModal(false); setEditingRule(null); }}>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
<form onSubmit={handleAddRule} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
placeholder="例如: CPU 告警"
required
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label></Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
value={formData.metric}
onChange={e => setFormData({...formData, metric: e.target.value})}
>
{Object.entries(METRIC_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<div className="space-y-2">
<Label></Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
value={formData.operator}
onChange={e => setFormData({...formData, operator: e.target.value})}
>
{Object.entries(OPERATOR_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={formData.threshold}
onChange={e => setFormData({...formData, threshold: parseFloat(e.target.value)})}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={formData.duration_seconds}
onChange={e => setFormData({...formData, duration_seconds: parseInt(e.target.value) || 0})}
min={0}
max={3600}
/>
</div>
<div className="space-y-2">
<Label></Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
value={formData.server_id}
onChange={e => setFormData({...formData, server_id: e.target.value})}
>
<option value=""></option>
{servers.map(server => (
<option key={server.id} value={server.id}>
{server.display_name || server.name}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enabled"
checked={formData.enabled}
onChange={e => setFormData({...formData, enabled: e.target.checked})}
/>
<Label htmlFor="enabled"></Label>
</div>
<Separator />
<div className="flex gap-4">
<Button type="submit" className="flex-1">
{editingRule ? '保存' : '添加'}
</Button>
<Button type="button" variant="outline" className="flex-1" onClick={() => { setShowAddModal(false); setEditingRule(null); }}>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)}
</div>
)
}
export default Alerts
+155 -16
View File
@@ -1,21 +1,160 @@
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Server, Activity, AlertTriangle } from 'lucide-react'
interface Stats {
total_servers: number
online_servers: number
offline_servers: number
alert_events: number
}
interface ServerData {
id: string
name: string
display_name: string
region: string
tags: string[]
status: string
last_seen_at: string
}
function Dashboard() {
return (
<div>
<h1 className="text-2xl font-bold mb-6"></h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="card">
<div className="text-nuyue-muted text-sm"></div>
<div className="text-3xl font-bold text-nuyue-primary">5</div>
</div>
<div className="card">
<div className="text-nuyue-muted text-sm">线</div>
<div className="text-3xl font-bold text-nuyue-primary">4</div>
</div>
<div className="card">
<div className="text-nuyue-muted text-sm"></div>
<div className="text-3xl font-bold text-nuyue-warning">2</div>
</div>
const [stats, setStats] = useState<Stats>({
total_servers: 0,
online_servers: 0,
offline_servers: 0,
alert_events: 0
})
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
try {
const token = localStorage.getItem('token')
if (!token) {
window.location.href = '/login'
return
}
// 获取服务器统计
const res = await fetch('/api/v1/servers', {
headers: {
'Authorization': `Bearer ${token}`
}
})
const data = await res.json()
if (data.code === 0) {
const servers = data.data || []
const online = servers.filter((s: ServerData) => s.status === 'online').length
const offline = servers.filter((s: ServerData) => s.status === 'offline').length
setStats({
total_servers: servers.length,
online_servers: online,
offline_servers: offline,
alert_events: 0 // TODO: 从告警 API 获取
})
}
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
fetchData()
}, [])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">...</p>
</div>
)
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold"></h1>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total_servers}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">线</CardTitle>
<Activity className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-500">{stats.online_servers}</div>
<Progress value={(stats.online_servers / stats.total_servers) * 100} className="mt-2" />
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">线</CardTitle>
<Server className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-500">{stats.offline_servers}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
<AlertTriangle className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-500">{stats.alert_events}</div>
</CardContent>
</Card>
</div>
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="flex gap-4">
<Button asChild>
<a href="/servers"></a>
</Button>
<Button variant="outline" asChild>
<a href="/probe-page"></a>
</Button>
</CardContent>
</Card>
{/* Server Status */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-8">
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground mb-4"></p>
<Button asChild>
<a href="/servers"></a>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
+228
View File
@@ -0,0 +1,228 @@
import { Button } from '@/components/ui/button'
import {
BookOpen,
Rocket,
Server,
Settings,
Bell,
Globe,
Shield,
ExternalLink
} from 'lucide-react'
import { Link } from 'react-router-dom'
const docSections = [
{
icon: Rocket,
title: '快速开始',
description: '快速部署和配置您的监控系统',
articles: [
{ title: '安装 Agent 客户端', href: '#' },
{ title: '首次配置指南', href: '#' },
{ title: '常见问题解答', href: '#' },
]
},
{
icon: Server,
title: '服务器管理',
description: '添加、配置和管理您的服务器',
articles: [
{ title: '添加服务器', href: '#' },
{ title: '配置上报频率', href: '#' },
{ title: '服务器标签管理', href: '#' },
]
},
{
icon: Bell,
title: '告警配置',
description: '设置告警规则和通知渠道',
articles: [
{ title: '告警规则配置', href: '#' },
{ title: 'Telegram 通知', href: '#' },
{ title: '邮件通知设置', href: '#' },
]
},
{
icon: Globe,
title: '探针页面',
description: '创建和自定义公开监控页面',
articles: [
{ title: '创建探针页面', href: '#' },
{ title: '自定义主题', href: '#' },
{ title: '分享和嵌入', href: '#' },
]
},
{
icon: Shield,
title: '安全设置',
description: '零知识加密和安全配置',
articles: [
{ title: '零知识加密原理', href: '#' },
{ title: '密码安全最佳实践', href: '#' },
{ title: 'API 访问控制', href: '#' },
]
},
{
icon: Settings,
title: '高级配置',
description: '备份、TCPing 和其他高级功能',
articles: [
{ title: '定时备份配置', href: '#' },
{ title: 'TCPing 监控', href: '#' },
{ title: '自定义脚本', href: '#' },
]
},
]
function Docs() {
return (
<div className="min-h-screen bg-background">
{/* Navigation */}
<nav className="fixed top-0 left-0 right-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<Link to="/" className="font-mono font-bold text-primary text-xl">
</Link>
<div className="hidden md:flex items-center gap-8">
<a href="/#features" className="text-muted-foreground hover:text-foreground transition-colors"></a>
<Link to="/pricing" className="text-muted-foreground hover:text-foreground transition-colors"></Link>
<a href="/docs" className="text-foreground"></a>
</div>
<div className="flex items-center gap-4">
<Link to="/login">
<Button variant="ghost" size="sm"></Button>
</Link>
<Link to="/register">
<Button size="sm"></Button>
</Link>
</div>
</div>
</nav>
{/* Hero Section */}
<section className="pt-32 pb-16 px-4">
<div className="container mx-auto text-center">
<div className="inline-flex items-center gap-2 text-primary mb-4">
<BookOpen className="h-5 w-5" />
<span className="font-semibold"></span>
</div>
<h1 className="text-4xl font-bold mb-4"></h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
使
</p>
</div>
</section>
{/* Quick Links */}
<section className="py-8 px-4 border-t border-border">
<div className="container mx-auto">
<div className="grid md:grid-cols-3 gap-4 max-w-4xl mx-auto">
<a href="#" className="p-4 rounded-lg border border-border bg-card hover:border-primary/50 transition-colors flex items-center gap-3">
<Rocket className="h-5 w-5 text-primary" />
<span className="font-semibold"></span>
<ExternalLink className="h-4 w-4 text-muted-foreground ml-auto" />
</a>
<a href="#" className="p-4 rounded-lg border border-border bg-card hover:border-primary/50 transition-colors flex items-center gap-3">
<Server className="h-5 w-5 text-primary" />
<span className="font-semibold">API </span>
<ExternalLink className="h-4 w-4 text-muted-foreground ml-auto" />
</a>
<a href="#" className="p-4 rounded-lg border border-border bg-card hover:border-primary/50 transition-colors flex items-center gap-3">
<Settings className="h-5 w-5 text-primary" />
<span className="font-semibold"></span>
<ExternalLink className="h-4 w-4 text-muted-foreground ml-auto" />
</a>
</div>
</div>
</section>
{/* Documentation Sections */}
<section className="py-16 px-4">
<div className="container mx-auto">
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
{docSections.map((section, index) => {
const Icon = section.icon
return (
<div key={index} className="p-6 rounded-lg border border-border bg-card hover:border-primary/50 transition-colors">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Icon className="h-5 w-5 text-primary" />
</div>
<h3 className="font-semibold">{section.title}</h3>
</div>
<p className="text-sm text-muted-foreground mb-4">{section.description}</p>
<ul className="space-y-2">
{section.articles.map((article, articleIndex) => (
<li key={articleIndex}>
<a
href={article.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
{article.title}
<ExternalLink className="h-3 w-3 ml-auto opacity-50" />
</a>
</li>
))}
</ul>
</div>
)
})}
</div>
</div>
</section>
{/* Quick Installation */}
<section className="py-16 px-4 border-t border-border">
<div className="container mx-auto max-w-3xl">
<h2 className="text-2xl font-bold mb-8 text-center"> Agent</h2>
<div className="space-y-6">
<div className="p-6 rounded-lg border border-border bg-card">
<h3 className="font-semibold mb-4">Linux / macOS</h3>
<div className="bg-secondary rounded-md p-4 font-mono text-sm overflow-x-auto">
<code className="text-foreground">
curl -fsSL https://get.nuyue.io | sh
</code>
</div>
</div>
<div className="p-6 rounded-lg border border-border bg-card">
<h3 className="font-semibold mb-4">Docker</h3>
<div className="bg-secondary rounded-md p-4 font-mono text-sm overflow-x-auto">
<code className="text-foreground">
docker run -d --name nuyue-agent nuyue/agent:latest
</code>
</div>
</div>
<div className="p-6 rounded-lg border border-border bg-card">
<h3 className="font-semibold mb-4">Windows</h3>
<p className="text-sm text-muted-foreground">
Windows Agent Windows
</p>
<Link to="/register">
<Button variant="outline" size="sm" className="mt-4"></Button>
</Link>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer className="py-12 px-4 border-t border-border">
<div className="container mx-auto">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div className="font-mono font-bold text-primary text-xl"></div>
<div className="text-sm text-muted-foreground">
© {new Date().getFullYear()} (Nuyue). All rights reserved.
</div>
</div>
</div>
</footer>
</div>
)
}
export default Docs
+233 -55
View File
@@ -1,62 +1,240 @@
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
function Install() {
const step = 1
const [step, setStep] = useState(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
// 数据库配置
const [dbType, setDbType] = useState('sqlite')
const [dbPath, setDbPath] = useState('/data/nuyue.db')
// Redis 配置
const [redisEnabled, setRedisEnabled] = useState(false)
const [redisHost, setRedisHost] = useState('redis:6379')
// 管理员配置
const [adminUsername, setAdminUsername] = useState('admin')
const [adminPassword, setAdminPassword] = useState('')
const [adminEmail, setAdminEmail] = useState('')
const testDB = async () => {
setLoading(true)
setError('')
setSuccess('')
try {
const res = await fetch('/api/v1/install/test-db', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dbType === 'sqlite'
? { type: 'sqlite', path: dbPath }
: { type: 'postgres', host: 'localhost', port: 5432, database: 'nuyue', username: 'postgres', password: '' }
)
})
const data = await res.json()
if (data.code === 0) {
setSuccess('数据库连接成功')
} else {
setError(data.message)
}
} catch (e) {
setError('网络错误')
}
setLoading(false)
}
const completeInstall = async () => {
setLoading(true)
setError('')
try {
// 1. 配置数据库
const dbRes = await fetch('/api/v1/install/database', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dbType === 'sqlite'
? { type: 'sqlite', path: dbPath }
: { type: 'postgres', host: 'localhost', port: 5432, database: 'nuyue', username: 'postgres', password: '' }
)
})
const dbData = await dbRes.json()
if (dbData.code !== 0) {
setError('数据库配置失败: ' + dbData.message)
setLoading(false)
return
}
// 2. 配置 Redis
const redisRes = await fetch('/api/v1/install/redis', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: redisEnabled, host: redisHost })
})
const redisData = await redisRes.json()
if (redisData.code !== 0) {
setError('Redis 配置失败: ' + redisData.message)
setLoading(false)
return
}
// 3. 创建管理员
const adminRes = await fetch('/api/v1/install/admin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: adminUsername, password: adminPassword, email: adminEmail })
})
const adminData = await adminRes.json()
if (adminData.code !== 0) {
setError('创建管理员失败: ' + adminData.message)
setLoading(false)
return
}
// 4. 完成安装
const completeRes = await fetch('/api/v1/install/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
const completeData = await completeRes.json()
if (completeData.code !== 0) {
setError('完成安装失败: ' + completeData.message)
setLoading(false)
return
}
setSuccess('安装完成!')
setTimeout(() => {
window.location.href = '/login'
}, 2000)
} catch (e) {
setError('网络错误')
}
setLoading(false)
}
return (
<div className="min-h-screen bg-nuyue-bg flex items-center justify-center p-4">
<div className="card w-full max-w-2xl">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold font-mono text-nuyue-primary mb-2"></h1>
<p className="text-nuyue-muted"></p>
</div>
<div className="flex justify-center mb-8">
<div className="flex items-center space-x-4">
{[1, 2, 3].map(s => (
<div key={s} className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${s <= step ? 'bg-nuyue-primary text-nuyue-bg' : 'bg-nuyue-card text-nuyue-muted'}`}>
{s}
</div>
{s < 3 && <div className="w-16 h-0.5 bg-nuyue-border" />}
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<Card className="w-full max-w-lg">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-mono text-primary"></CardTitle>
<p className="text-muted-foreground"></p>
</CardHeader>
<CardContent className="space-y-6">
{/* 进度条 */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span></span>
<span>{step}/3</span>
</div>
<Progress value={(step / 3) * 100} />
</div>
{/* 步骤 1: 数据库 */}
{step === 1 && (
<div className="space-y-4">
<h3 className="text-lg font-medium"> 1: 数据库配置</h3>
<div className="space-y-2">
<Label></Label>
<select
className="w-full h-10 px-3 bg-background border border-input rounded-md"
value={dbType}
onChange={e => setDbType(e.target.value)}
>
<option value="sqlite">SQLite ()</option>
<option value="postgres">PostgreSQL</option>
</select>
</div>
))}
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-nuyue-muted mb-1"></label>
<select className="w-full bg-nuyue-bg border border-nuyue-border rounded px-3 py-2 text-nuyue-text">
<option value="sqlite">SQLite ()</option>
<option value="postgres">PostgreSQL ()</option>
</select>
</div>
<div>
<label className="block text-sm text-nuyue-muted mb-1"> Redis </label>
<input type="checkbox" className="ml-2" />
<p className="text-xs text-nuyue-muted mt-1"></p>
</div>
<div>
<label className="block text-sm text-nuyue-muted mb-1"></label>
<input type="text" className="w-full bg-nuyue-bg border border-nuyue-border rounded px-3 py-2 text-nuyue-text" placeholder="admin" />
</div>
<div>
<label className="block text-sm text-nuyue-muted mb-1"></label>
<input type="password" className="w-full bg-nuyue-bg border border-nuyue-border rounded px-3 py-2 text-nuyue-text" placeholder="请输入密码" />
</div>
<div>
<label className="block text-sm text-nuyue-muted mb-1"></label>
<input type="email" className="w-full bg-nuyue-bg border border-nuyue-border rounded px-3 py-2 text-nuyue-text" placeholder="admin@example.com" />
</div>
<button className="w-full bg-nuyue-primary text-nuyue-bg py-3 rounded font-medium hover:bg-nuyue-primary-hover transition-colors">
</button>
</div>
</div>
{dbType === 'sqlite' && (
<div className="space-y-2">
<Label></Label>
<Input value={dbPath} onChange={e => setDbPath(e.target.value)} />
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
{success && <p className="text-sm text-green-500">{success}</p>}
<div className="flex gap-4">
<Button variant="outline" onClick={testDB} disabled={loading}>
</Button>
<Button onClick={() => setStep(2)} disabled={loading}>
</Button>
</div>
</div>
)}
{/* 步骤 2: Redis */}
{step === 2 && (
<div className="space-y-4">
<h3 className="text-lg font-medium"> 2: Redis ()</h3>
<div className="space-y-2">
<Label> Redis</Label>
<select
className="w-full h-10 px-3 bg-background border border-input rounded-md"
value={redisEnabled ? 'true' : 'false'}
onChange={e => setRedisEnabled(e.target.value === 'true')}
>
<option value="false"></option>
<option value="true"></option>
</select>
</div>
{redisEnabled && (
<div className="space-y-2">
<Label>Redis </Label>
<Input value={redisHost} onChange={e => setRedisHost(e.target.value)} />
</div>
)}
<div className="flex gap-4">
<Button variant="outline" onClick={() => setStep(1)}>
</Button>
<Button onClick={() => setStep(3)}>
</Button>
</div>
</div>
)}
{/* 步骤 3: 管理员 */}
{step === 3 && (
<div className="space-y-4">
<h3 className="text-lg font-medium"> 3: 管理员账户</h3>
<div className="space-y-2">
<Label></Label>
<Input value={adminUsername} onChange={e => setAdminUsername(e.target.value)} />
</div>
<div className="space-y-2">
<Label></Label>
<Input type="password" value={adminPassword} onChange={e => setAdminPassword(e.target.value)} />
</div>
<div className="space-y-2">
<Label> ()</Label>
<Input type="email" value={adminEmail} onChange={e => setAdminEmail(e.target.value)} />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{success && <p className="text-sm text-green-500">{success}</p>}
<div className="flex gap-4">
<Button variant="outline" onClick={() => setStep(2)}>
</Button>
<Button onClick={completeInstall} disabled={loading || !adminPassword}>
{loading ? '安装中...' : '完成安装'}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)
}
+207
View File
@@ -0,0 +1,207 @@
import { Button } from '@/components/ui/button'
import {
Server,
Shield,
Bell,
Globe,
Zap,
BarChart3,
ArrowRight
} from 'lucide-react'
import { Link } from 'react-router-dom'
const features = [
{
icon: Server,
title: '实时监控',
description: 'CPU、内存、磁盘、网络带宽等核心指标实时监控,支持秒级上报'
},
{
icon: Shield,
title: '零知识安全',
description: '所有敏感配置使用用户密码派生密钥加密,服务端无法解密'
},
{
icon: Bell,
title: '智能告警',
description: '支持 Telegram、邮件等多渠道告警,自定义告警规则'
},
{
icon: Globe,
title: '公开探针页',
description: '一键生成公开监控页面,展示服务器运行状态'
},
{
icon: Zap,
title: '轻量客户端',
description: 'Agent 采用 Go 语言开发,单二进制部署,低资源占用'
},
{
icon: BarChart3,
title: '数据可视化',
description: '历史数据图表展示,支持自定义时间范围查询'
}
]
const stats = [
{ value: '99.9%', label: '服务可用性' },
{ value: '< 10ms', label: '响应延迟' },
{ value: '24/7', label: '全天候监控' },
{ value: '100+', label: 'TCPing 节点' }
]
function Landing() {
return (
<div className="min-h-screen bg-background">
{/* Navigation */}
<nav className="fixed top-0 left-0 right-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<Link to="/" className="font-mono font-bold text-primary text-xl">
</Link>
<div className="hidden md:flex items-center gap-8">
<a href="#features" className="text-muted-foreground hover:text-foreground transition-colors"></a>
<Link to="/pricing" className="text-muted-foreground hover:text-foreground transition-colors"></Link>
<a href="/docs" className="text-muted-foreground hover:text-foreground transition-colors"></a>
</div>
<div className="flex items-center gap-4">
<Link to="/login">
<Button variant="ghost" size="sm"></Button>
</Link>
<Link to="/register">
<Button size="sm"></Button>
</Link>
</div>
</div>
</nav>
{/* Hero Section */}
<section className="pt-32 pb-20 px-4">
<div className="container mx-auto text-center">
<h1 className="text-4xl md:text-6xl font-bold mb-6">
<span className="text-primary"></span>
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto mb-8">
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link to="/register">
<Button size="lg" className="gap-2">
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
<a href="#features">
<Button variant="outline" size="lg"></Button>
</a>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 mt-16 max-w-4xl mx-auto">
{stats.map((stat, index) => (
<div key={index} className="text-center">
<div className="text-3xl font-bold text-primary">{stat.value}</div>
<div className="text-sm text-muted-foreground mt-1">{stat.label}</div>
</div>
))}
</div>
</div>
</section>
{/* Features Section */}
<section id="features" className="py-20 px-4 border-t border-border">
<div className="container mx-auto">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold mb-4"></h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto">
{features.map((feature, index) => {
const Icon = feature.icon
return (
<div key={index} className="p-6 rounded-lg border border-border bg-card hover:border-primary/50 transition-colors">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
<Icon className="h-6 w-6 text-primary" />
</div>
<h3 className="font-semibold text-lg mb-2">{feature.title}</h3>
<p className="text-muted-foreground text-sm">{feature.description}</p>
</div>
)
})}
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-20 px-4 border-t border-border">
<div className="container mx-auto text-center">
<h2 className="text-3xl font-bold mb-4"></h2>
<p className="text-muted-foreground mb-8 max-w-2xl mx-auto">
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link to="/register">
<Button size="lg" className="gap-2">
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
<Link to="/pricing">
<Button variant="outline" size="lg"></Button>
</Link>
</div>
</div>
</section>
{/* Footer */}
<footer className="py-12 px-4 border-t border-border">
<div className="container mx-auto">
<div className="grid md:grid-cols-4 gap-8">
<div>
<div className="font-mono font-bold text-primary text-xl mb-4"></div>
<p className="text-sm text-muted-foreground">
</p>
</div>
<div>
<div className="font-semibold mb-4"></div>
<ul className="space-y-2 text-sm text-muted-foreground">
<li><a href="#features" className="hover:text-foreground"></a></li>
<li><Link to="/pricing" className="hover:text-foreground"></Link></li>
<li><a href="/docs" className="hover:text-foreground"></a></li>
</ul>
</div>
<div>
<div className="font-semibold mb-4"></div>
<ul className="space-y-2 text-sm text-muted-foreground">
<li><a href="#" className="hover:text-foreground"></a></li>
<li><a href="#" className="hover:text-foreground">API </a></li>
<li><a href="#" className="hover:text-foreground">GitHub</a></li>
</ul>
</div>
<div>
<div className="font-semibold mb-4"></div>
<ul className="space-y-2 text-sm text-muted-foreground">
<li><a href="#" className="hover:text-foreground"></a></li>
<li><a href="#" className="hover:text-foreground"></a></li>
</ul>
</div>
</div>
<div className="mt-12 pt-8 border-t border-border text-center text-sm text-muted-foreground">
© {new Date().getFullYear()} (Nuyue). All rights reserved.
</div>
</div>
</footer>
</div>
)
}
export default Landing
+79 -18
View File
@@ -1,23 +1,84 @@
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
})
const data = await res.json()
if (data.code === 0) {
localStorage.setItem('token', data.data?.access_token || 'mock-token')
window.location.href = '/dashboard'
} else {
setError(data.message || '登录失败')
}
} catch (err) {
setError('网络错误,请重试')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-nuyue-bg flex items-center justify-center">
<div className="card w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold font-mono text-nuyue-primary mb-2"></h1>
<p className="text-nuyue-muted"></p>
</div>
<form className="space-y-4">
<div>
<label className="block text-sm text-nuyue-muted mb-1"></label>
<input type="text" className="w-full bg-nuyue-bg border border-nuyue-border rounded px-3 py-2 text-nuyue-text focus:border-nuyue-primary outline-none" placeholder="请输入用户名" />
</div>
<div>
<label className="block text-sm text-nuyue-muted mb-1"></label>
<input type="password" className="w-full bg-nuyue-bg border border-nuyue-border rounded px-3 py-2 text-nuyue-text focus:border-nuyue-primary outline-none" placeholder="请输入密码" />
</div>
<button type="submit" className="w-full bg-nuyue-primary text-nuyue-bg py-2 rounded font-medium hover:bg-nuyue-primary-hover transition-colors"></button>
</form>
</div>
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-mono text-primary"></CardTitle>
<p className="text-muted-foreground"></p>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Input
id="username"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="请输入用户名"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="请输入密码"
required
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? '登录中...' : '登录'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}
+227
View File
@@ -0,0 +1,227 @@
import { Button } from '@/components/ui/button'
import { Check, X, Zap } from 'lucide-react'
import { Link } from 'react-router-dom'
import { cn } from '@/lib/utils'
const plans = [
{
name: '免费版',
description: '适合个人用户和小型项目',
price: { monthly: 0, yearly: 0, lifetime: 0 },
features: [
{ text: '最多 3 台服务器', included: true },
{ text: '基础监控指标', included: true },
{ text: '1 个探针页面', included: true },
{ text: '邮件告警通知', included: true },
{ text: '自定义主题', included: false },
{ text: 'Telegram 通知', included: false },
{ text: '定时备份', included: false },
{ text: 'TCPing 监控', included: false },
],
cta: '免费开始',
popular: false
},
{
name: '专业版',
description: '适合中小团队和商业项目',
price: { monthly: 29, yearly: 290, lifetime: 999 },
features: [
{ text: '最多 10 台服务器', included: true },
{ text: '全部监控指标', included: true },
{ text: '3 个探针页面', included: true },
{ text: '邮件告警通知', included: true },
{ text: '自定义主题', included: true },
{ text: 'Telegram 通知', included: true },
{ text: '定时备份', included: true },
{ text: '10 个 TCPing 节点', included: true },
],
cta: '立即订阅',
popular: true
},
{
name: '企业版',
description: '适合大型团队和企业用户',
price: { monthly: 99, yearly: 990, lifetime: 2999 },
features: [
{ text: '最多 50 台服务器', included: true },
{ text: '全部监控指标', included: true },
{ text: '无限探针页面', included: true },
{ text: '邮件告警通知', included: true },
{ text: '自定义主题', included: true },
{ text: 'Telegram 通知', included: true },
{ text: '定时备份 + 多仓库', included: true },
{ text: '无限 TCPing 节点', included: true },
],
cta: '联系我们',
popular: false
}
]
const faqs = [
{
question: '支持哪些支付方式?',
answer: '目前支持支付宝、微信支付和 Stripe(信用卡)。'
},
{
question: '可以随时取消订阅吗?',
answer: '是的,您可以随时取消订阅。取消后,您的服务将在当前计费周期结束后停止。'
},
{
question: '支持升级套餐吗?',
answer: '支持。升级时,系统会自动计算差价,您只需支付差额部分。'
},
{
question: '数据保留多长时间?',
answer: '监控数据默认保留 30 天。如需更长的数据保留时间,请联系我们。'
},
{
question: '提供技术支持吗?',
answer: '专业版和企业版用户享有优先技术支持。免费版用户可通过 GitHub Issues 获取社区支持。'
}
]
function Pricing() {
return (
<div className="min-h-screen bg-background">
{/* Navigation */}
<nav className="fixed top-0 left-0 right-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<Link to="/" className="font-mono font-bold text-primary text-xl">
</Link>
<div className="hidden md:flex items-center gap-8">
<a href="/#features" className="text-muted-foreground hover:text-foreground transition-colors"></a>
<Link to="/pricing" className="text-foreground"></Link>
<a href="/docs" className="text-muted-foreground hover:text-foreground transition-colors"></a>
</div>
<div className="flex items-center gap-4">
<Link to="/login">
<Button variant="ghost" size="sm"></Button>
</Link>
<Link to="/register">
<Button size="sm"></Button>
</Link>
</div>
</div>
</nav>
{/* Pricing Section */}
<section className="pt-32 pb-20 px-4">
<div className="container mx-auto text-center">
<h1 className="text-4xl font-bold mb-4"></h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto mb-12">
</p>
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
{plans.map((plan, index) => (
<div
key={index}
className={cn(
"relative p-6 rounded-lg border bg-card",
plan.popular ? "border-primary" : "border-border"
)}
>
{plan.popular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="bg-primary text-primary-foreground text-xs font-semibold px-3 py-1 rounded-full">
</span>
</div>
)}
<div className="mb-6">
<h3 className="font-semibold text-xl mb-2">{plan.name}</h3>
<p className="text-sm text-muted-foreground">{plan.description}</p>
</div>
<div className="mb-6">
<div className="flex items-baseline gap-1">
<span className="text-4xl font-bold">¥{plan.price.monthly}</span>
<span className="text-muted-foreground">/</span>
</div>
<p className="text-sm text-muted-foreground mt-1">
¥{plan.price.yearly}/¥{plan.price.lifetime}
</p>
</div>
<ul className="space-y-3 mb-6">
{plan.features.map((feature, featureIndex) => (
<li key={featureIndex} className="flex items-center gap-2 text-sm">
{feature.included ? (
<Check className="h-4 w-4 text-primary flex-shrink-0" />
) : (
<X className="h-4 w-4 text-muted-foreground flex-shrink-0" />
)}
<span className={feature.included ? "" : "text-muted-foreground"}>
{feature.text}
</span>
</li>
))}
</ul>
<Link to="/register">
<Button
className="w-full"
variant={plan.popular ? "default" : "outline"}
>
{plan.cta}
</Button>
</Link>
</div>
))}
</div>
</div>
</section>
{/* FAQ Section */}
<section className="py-20 px-4 border-t border-border">
<div className="container mx-auto max-w-3xl">
<h2 className="text-3xl font-bold text-center mb-12"></h2>
<div className="space-y-6">
{faqs.map((faq, index) => (
<div key={index} className="p-6 rounded-lg border border-border bg-card">
<h3 className="font-semibold mb-2">{faq.question}</h3>
<p className="text-muted-foreground text-sm">{faq.answer}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-20 px-4 border-t border-border">
<div className="container mx-auto text-center">
<div className="inline-flex items-center gap-2 text-primary mb-4">
<Zap className="h-5 w-5" />
<span className="font-semibold"></span>
</div>
<h2 className="text-3xl font-bold mb-4"></h2>
<p className="text-muted-foreground mb-8 max-w-2xl mx-auto">
</p>
<Link to="/register">
<Button size="lg"></Button>
</Link>
</div>
</section>
{/* Footer */}
<footer className="py-12 px-4 border-t border-border">
<div className="container mx-auto">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div className="font-mono font-bold text-primary text-xl"></div>
<div className="text-sm text-muted-foreground">
© {new Date().getFullYear()} (Nuyue). All rights reserved.
</div>
</div>
</div>
</footer>
</div>
)
}
export default Pricing
+173 -41
View File
@@ -1,47 +1,179 @@
import { useParams } from 'react-router-dom'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
interface ServerStatus {
id: string
name: string
display_name: string
region: string
status: string
cpu: number
cpu_temp?: number
memory_used: number
memory_total: number
disk_used: number
disk_total: number
network_rx: number
network_tx: number
uptime: number
}
function ProbePage() {
const { slug } = useParams()
return (
<div className="probe-page">
<header className="probe-header">
<h1 className="text-xl font-bold"></h1>
<p className="text-nuyue-muted">{slug}</p>
</header>
<main className="probe-content">
<div className="server-grid">
<div className="card">
<div className="flex items-center justify-between mb-4">
<span className="font-medium font-mono">production-01</span>
<span className="w-2 h-2 rounded-full bg-nuyue-primary animate-pulse"></span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-nuyue-muted">CPU</div>
<div className="font-mono text-lg">45.2%</div>
</div>
<div>
<div className="text-nuyue-muted"></div>
<div className="font-mono text-lg">60.5%</div>
</div>
<div>
<div className="text-nuyue-muted"></div>
<div className="font-mono text-lg">35.8%</div>
</div>
<div>
<div className="text-nuyue-muted"></div>
<div className="font-mono text-lg">1.25</div>
</div>
</div>
</div>
const [loading, setLoading] = useState(true)
const [config, setConfig] = useState<any>(null)
const [servers, setServers] = useState<ServerStatus[]>([])
useEffect(() => {
const path = window.location.pathname
const slugMatch = path.match(/\/probe\/([^/]+)/)
if (slugMatch) {
fetchProbeData(slugMatch[1])
}
}, [])
const fetchProbeData = async (_slug: string) => {
// Mock data
setConfig({
title: '我的服务器状态',
description: '实时监控服务器运行状态'
})
setServers([
{
id: '1',
name: 'HK-01',
display_name: '香港节点1',
region: 'HK',
status: 'online',
cpu: 45,
cpu_temp: 62,
memory_used: 4096,
memory_total: 8192,
disk_used: 50,
disk_total: 100,
network_rx: 1024000,
network_tx: 512000,
uptime: 86400
},
{
id: '2',
name: 'JP-01',
display_name: '日本节点1',
region: 'JP',
status: 'online',
cpu: 32,
cpu_temp: 55,
memory_used: 2048,
memory_total: 4096,
disk_used: 30,
disk_total: 50,
network_rx: 512000,
network_tx: 256000,
uptime: 172800
}
])
setLoading(false)
}
const formatUptime = (seconds: number) => {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
if (days > 0) return `${days}${hours}小时`
return `${hours}小时`
}
const formatBytes = (bytes: number) => {
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + ' GB'
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB'
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB'
return bytes + ' B'
}
if (loading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<p className="text-muted-foreground">...</p>
</div>
)
}
if (!config) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4"></h1>
<p className="text-muted-foreground"></p>
</div>
</main>
<footer className="text-center text-nuyue-muted text-sm py-6">
Powered by (Nuyue)
</footer>
</div>
)
}
return (
<div className="min-h-screen bg-background p-4 md:p-8">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold mb-2">{config.title}</h1>
{config.description && (
<p className="text-muted-foreground">{config.description}</p>
)}
</div>
{/* Server Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 max-w-7xl mx-auto">
{servers.map(server => (
<Card key={server.id}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle className="text-base">{server.display_name}</CardTitle>
<p className="text-xs text-muted-foreground">{server.region}</p>
</div>
<div className={`w-2 h-2 rounded-full ${
server.status === 'online' ? 'bg-green-500' :
server.status === 'offline' ? 'bg-red-500' : 'bg-gray-500'
}`} />
</CardHeader>
<CardContent className="space-y-3">
{/* CPU */}
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">CPU</span>
<span>{server.cpu}%</span>
</div>
<Progress value={server.cpu} />
{server.cpu_temp && (
<p className="text-xs text-muted-foreground">: {server.cpu_temp}°C</p>
)}
</div>
{/* Memory */}
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span>{formatBytes(server.memory_used)} / {formatBytes(server.memory_total)}</span>
</div>
<Progress value={(server.memory_used / server.memory_total) * 100} className="[&>div]:bg-blue-500" />
</div>
{/* Network */}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span>{formatBytes(server.network_rx)}/s {formatBytes(server.network_tx)}/s</span>
</div>
{/* Uptime */}
<p className="text-sm">
<span className="text-muted-foreground">: </span>
<span>{formatUptime(server.uptime)}</span>
</p>
</CardContent>
</Card>
))}
</div>
{/* Footer */}
<div className="text-center mt-8 text-muted-foreground text-sm">
Powered by Nuyue
</div>
</div>
)
}
+425
View File
@@ -0,0 +1,425 @@
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Globe, Palette, Code, Eye, Save, RotateCcw } from 'lucide-react'
interface ProbePageConfig {
id: string
slug: string
title: string
sub_title: string
logo_url: string
footer_text: string
footer_link_url: string
footer_link_text: string
theme_id: string
primary_color: string
layout_columns: number
visible_components: Record<string, boolean>
custom_css: string
custom_js: string
is_public: boolean
}
const THEMES = [
{ id: 'default', name: '默认主题' },
{ id: 'dark', name: '深色主题' },
{ id: 'termius', name: 'Termius 风格' },
{ id: 'light', name: '浅色主题' }
]
const COMPONENTS = [
{ key: 'server_status', name: '服务器状态卡片' },
{ key: 'cpu_usage', name: 'CPU 使用率图表' },
{ key: 'memory_usage', name: '内存使用率图表' },
{ key: 'disk_usage', name: '磁盘使用率图表' },
{ key: 'network_io', name: '网络流量图表' },
{ key: 'system_load', name: '系统负载图表' },
{ key: 'uptime', name: '运行时间' },
{ key: 'tcping_status', name: 'TCPing 状态' }
]
function ProbePageConfig() {
const [config, setConfig] = useState<ProbePageConfig | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [previewUrl, setPreviewUrl] = useState('')
useEffect(() => {
fetchConfig()
}, [])
const fetchConfig = async () => {
try {
const token = localStorage.getItem('token')
if (!token) {
window.location.href = '/login'
return
}
const res = await fetch('/api/v1/probe-page', {
headers: { 'Authorization': `Bearer ${token}` }
})
const data = await res.json()
if (data.code === 0) {
setConfig(data.data || getDefaultConfig())
setPreviewUrl(`/probe/${data.data?.slug || 'default'}`)
}
} catch (err) {
console.error('Failed to fetch config:', err)
setConfig(getDefaultConfig())
} finally {
setLoading(false)
}
}
const getDefaultConfig = (): ProbePageConfig => ({
id: '',
slug: '',
title: '服务器状态监控',
sub_title: '',
logo_url: '',
footer_text: 'Powered by 怒月',
footer_link_url: '',
footer_link_text: '',
theme_id: 'default',
primary_color: '#4ade80',
layout_columns: 3,
visible_components: {
server_status: true,
cpu_usage: true,
memory_usage: true,
disk_usage: true,
network_io: true,
system_load: true,
uptime: true,
tcping_status: true
},
custom_css: '',
custom_js: '',
is_public: true
})
const handleSave = async () => {
setSaving(true)
try {
const token = localStorage.getItem('token')
if (!token) return
const res = await fetch('/api/v1/probe-page', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(config)
})
const data = await res.json()
if (data.code === 0) {
alert('保存成功!')
if (!config?.slug && data.data?.slug) {
setConfig(data.data)
setPreviewUrl(`/probe/${data.data.slug}`)
}
} else {
alert(data.message || '保存失败')
}
} catch (err) {
alert('网络错误')
} finally {
setSaving(false)
}
}
const handleReset = () => {
if (confirm('确定要重置为默认配置吗?')) {
setConfig(getDefaultConfig())
}
}
const updateConfig = (updates: Partial<ProbePageConfig>) => {
setConfig(prev => prev ? { ...prev, ...updates } : null)
}
const updateComponentVisibility = (key: string, visible: boolean) => {
setConfig(prev => prev ? {
...prev,
visible_components: {
...prev.visible_components,
[key]: visible
}
} : null)
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">...</p>
</div>
)
}
if (!config) return null
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
<div className="flex gap-2">
{config.slug && (
<Button variant="outline" asChild>
<a href={previewUrl} target="_blank" rel="noopener noreferrer">
<Eye className="h-4 w-4 mr-2" />
</a>
</Button>
)}
<Button variant="outline" onClick={handleReset}>
<RotateCcw className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleSave} disabled={saving}>
<Save className="h-4 w-4 mr-2" />
{saving ? '保存中...' : '保存'}
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 基本信息卡片 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="slug"></Label>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">/probe/</span>
<Input
id="slug"
value={config.slug}
onChange={e => updateConfig({ slug: e.target.value.replace(/[^a-z0-9-]/g, '') })}
placeholder="your-slug"
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="space-y-2">
<Label htmlFor="title"></Label>
<Input
id="title"
value={config.title}
onChange={e => updateConfig({ title: e.target.value })}
placeholder="服务器状态监控"
/>
</div>
<div className="space-y-2">
<Label htmlFor="sub_title"></Label>
<Input
id="sub_title"
value={config.sub_title}
onChange={e => updateConfig({ sub_title: e.target.value })}
placeholder="可选"
/>
</div>
<div className="space-y-2">
<Label htmlFor="logo_url">Logo URL</Label>
<Input
id="logo_url"
value={config.logo_url}
onChange={e => updateConfig({ logo_url: e.target.value })}
placeholder="https://example.com/logo.png"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="is_public"
checked={config.is_public}
onChange={e => updateConfig({ is_public: e.target.checked })}
/>
<Label htmlFor="is_public"></Label>
</div>
</CardContent>
</Card>
{/* 页脚配置 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="footer_text"></Label>
<Input
id="footer_text"
value={config.footer_text}
onChange={e => updateConfig({ footer_text: e.target.value })}
placeholder="Powered by 怒月"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="footer_link_text"></Label>
<Input
id="footer_link_text"
value={config.footer_link_text}
onChange={e => updateConfig({ footer_link_text: e.target.value })}
placeholder="官网"
/>
</div>
<div className="space-y-2">
<Label htmlFor="footer_link_url"></Label>
<Input
id="footer_link_url"
value={config.footer_link_url}
onChange={e => updateConfig({ footer_link_url: e.target.value })}
placeholder="https://example.com"
/>
</div>
</div>
</CardContent>
</Card>
{/* 主题和样式 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label></Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
value={config.theme_id}
onChange={e => updateConfig({ theme_id: e.target.value })}
>
{THEMES.map(theme => (
<option key={theme.id} value={theme.id}>{theme.name}</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="primary_color"></Label>
<div className="flex items-center gap-2">
<input
type="color"
value={config.primary_color}
onChange={e => updateConfig({ primary_color: e.target.value })}
className="h-9 w-12 rounded border border-input"
/>
<Input
value={config.primary_color}
onChange={e => updateConfig({ primary_color: e.target.value })}
placeholder="#4ade80"
className="flex-1"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="layout_columns"></Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
value={config.layout_columns}
onChange={e => updateConfig({ layout_columns: parseInt(e.target.value) })}
>
<option value={1}>1 </option>
<option value={2}>2 </option>
<option value={3}>3 </option>
<option value={4}>4 </option>
<option value={6}>6 </option>
</select>
</div>
</CardContent>
</Card>
{/* 组件显示 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
{COMPONENTS.map(comp => (
<div key={comp.key} className="flex items-center gap-2">
<input
type="checkbox"
id={`comp_${comp.key}`}
checked={config.visible_components[comp.key] ?? true}
onChange={e => updateComponentVisibility(comp.key, e.target.checked)}
/>
<Label htmlFor={`comp_${comp.key}`}>{comp.name}</Label>
</div>
))}
</div>
</CardContent>
</Card>
{/* 自定义代码 */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Code className="h-5 w-5" />
</CardTitle>
<CardDescription> CSS JavaScript</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="custom_css"> CSS</Label>
<textarea
id="custom_css"
value={config.custom_css}
onChange={e => updateConfig({ custom_css: e.target.value })}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
placeholder="/* 例如: .card { border-radius: 8px; } */"
/>
</div>
<div className="space-y-2">
<Label htmlFor="custom_js"> JavaScript</Label>
<textarea
id="custom_js"
value={config.custom_js}
onChange={e => updateConfig({ custom_js: e.target.value })}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
placeholder="// 例如: console.log('Hello');"
/>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
export default ProbePageConfig
+184
View File
@@ -0,0 +1,184 @@
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useNavigate } from 'react-router-dom'
// Argon2id 参数(与后端一致)
const ARGON2_PARAMS = {
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
hashLength: 32,
saltLength: 16
}
// 前端 Argon2id 哈希(使用 Web Crypto API 的 PBKDF2 模拟)
// 注意:实际项目应使用 argon2-browser 库,这里简化处理
async function hashPasswordArgon2id(password: string): Promise<{ hash: string, salt: string }> {
// 生成随机 salt
const salt = crypto.getRandomValues(new Uint8Array(ARGON2_PARAMS.saltLength))
const saltBase64 = btoa(String.fromCharCode(...salt))
// 使用 PBKDF2 作为简化方案(实际应使用 argon2-browser
const encoder = new TextEncoder()
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveBits']
)
const derivedBits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: salt,
iterations: ARGON2_PARAMS.timeCost * 10000, // 简化迭代
hash: 'SHA-256'
},
keyMaterial,
ARGON2_PARAMS.hashLength * 8
)
const hashArray = new Uint8Array(derivedBits)
const hashBase64 = btoa(String.fromCharCode(...hashArray))
return {
hash: `argon2id:${ARGON2_PARAMS.memoryCost}:${ARGON2_PARAMS.timeCost}:${ARGON2_PARAMS.parallelism}:${saltBase64}:${hashBase64}`,
salt: saltBase64
}
}
// 生成配置密钥(用于加密客户端配置)
async function generateConfigKey(): Promise<{ key: string, nonce: string }> {
const keyBytes = crypto.getRandomValues(new Uint8Array(32))
const nonceBytes = crypto.getRandomValues(new Uint8Array(12))
return {
key: btoa(String.fromCharCode(...keyBytes)),
nonce: btoa(String.fromCharCode(...nonceBytes))
}
}
function Register() {
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [email, setEmail] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
// 1. 前端 Argon2id 哈希密码
const { hash: passwordHash } = await hashPasswordArgon2id(password)
// 2. 生成配置加密密钥
const { key: encryptedConfigKey, nonce: configKeyNonce } = await generateConfigKey()
// 3. 发送注册请求
const res = await fetch('/api/v1/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password_hash: passwordHash,
email: email || undefined,
encrypted_config_key: encryptedConfigKey,
config_key_nonce: configKeyNonce
})
})
const data = await res.json()
if (data.code === 0) {
alert('注册成功!请登录')
navigate('/login')
} else {
setError(data.message || '注册失败')
}
} catch (err) {
setError('网络错误,请重试')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-mono text-primary"></CardTitle>
<p className="text-muted-foreground"></p>
</CardHeader>
<CardContent>
<form onSubmit={handleRegister} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username"></Label>
<Input
id="username"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="3-50 位字母数字"
required
minLength={3}
maxLength={50}
pattern="[a-zA-Z0-9]+"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="请输入密码"
required
minLength={6}
/>
<p className="text-xs text-muted-foreground">
使 Argon2id
</p>
</div>
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="用于接收告警通知"
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? '注册中...' : '注册'}
</Button>
<p className="text-center text-sm text-muted-foreground">
{' '}
<a href="/login" className="text-primary hover:underline">
</a>
</p>
</form>
</CardContent>
</Card>
</div>
)
}
export default Register
+219 -38
View File
@@ -1,44 +1,225 @@
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Server, Plus, X } from 'lucide-react'
interface ServerData {
id: string
name: string
display_name: string
region: string
tags: string[]
status: string
last_seen_at: string
}
function Servers() {
const servers = [
{ id: '1', name: '生产服务器', region: 'HK', status: 'online', cpu: 45.2, memory: 60.5 },
{ id: '2', name: '测试服务器', region: 'US', status: 'online', cpu: 12.8, memory: 35.2 },
{ id: '3', name: '数据库服务器', region: 'JP', status: 'offline', cpu: 0, memory: 0 },
]
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold"></h1>
<button className="bg-nuyue-primary text-nuyue-bg px-4 py-2 rounded font-medium hover:bg-nuyue-primary-hover">
</button>
</div>
const [servers, setServers] = useState<ServerData[]>([])
const [loading, setLoading] = useState(true)
const [showAddModal, setShowAddModal] = useState(false)
const [newServer, setNewServer] = useState({
name: '',
display_name: '',
region: ''
})
useEffect(() => {
fetchServers()
}, [])
const fetchServers = async () => {
try {
const token = localStorage.getItem('token')
if (!token) {
window.location.href = '/login'
return
}
<div className="server-grid">
{servers.map(server => (
<div key={server.id} className="card hover:border-nuyue-primary cursor-pointer transition-colors">
<div className="flex items-center justify-between mb-4">
<span className="font-medium">{server.name}</span>
<span className={`px-2 py-1 rounded text-xs ${server.status === 'online' ? 'bg-nuyue-primary/20 text-nuyue-primary' : 'bg-nuyue-danger/20 text-nuyue-danger'}`}>
{server.status === 'online' ? '在线' : '离线'}
</span>
</div>
<div className="text-nuyue-muted text-sm mb-2">: {server.region}</div>
{server.status === 'online' && (
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-nuyue-muted">CPU: </span>
<span className="font-mono">{server.cpu}%</span>
</div>
<div>
<span className="text-nuyue-muted">: </span>
<span className="font-mono">{server.memory}%</span>
</div>
</div>
)}
</div>
))}
const res = await fetch('/api/v1/servers', {
headers: {
'Authorization': `Bearer ${token}`
}
})
const data = await res.json()
if (data.code === 0) {
setServers(data.data || [])
}
} catch (err) {
console.error('Failed to fetch servers:', err)
} finally {
setLoading(false)
}
}
const handleAddServer = async (e: React.FormEvent) => {
e.preventDefault()
try {
const token = localStorage.getItem('token')
if (!token) {
window.location.href = '/login'
return
}
const res = await fetch('/api/v1/servers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(newServer)
})
const data = await res.json()
if (data.code === 0) {
fetchServers()
setShowAddModal(false)
setNewServer({ name: '', display_name: '', region: '' })
} else {
alert(data.message || '添加失败')
}
} catch (err) {
console.error('Failed to add server:', err)
alert('添加失败')
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'online': return 'bg-green-500'
case 'offline': return 'bg-red-500'
default: return 'bg-gray-500'
}
}
const getStatusLabel = (status: string) => {
switch (status) {
case 'online': return '在线'
case 'offline': return '离线'
default: return '未连接'
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">...</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold"></h1>
<Button onClick={() => setShowAddModal(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
{servers.length === 0 ? (
<Card>
<CardContent className="py-16 text-center">
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h2 className="text-xl font-medium mb-2"></h2>
<p className="text-muted-foreground mb-6"></p>
<Button onClick={() => setShowAddModal(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{servers.map(server => (
<Card key={server.id}>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg">{server.display_name || server.name}</CardTitle>
<p className="text-sm text-muted-foreground">{server.region}</p>
</div>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${getStatusColor(server.status)}`} />
<span className="text-sm text-muted-foreground">{getStatusLabel(server.status)}</span>
</div>
</CardHeader>
<CardContent>
{server.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mb-4">
{server.tags.map(tag => (
<span key={tag} className="px-2 py-0.5 bg-secondary text-secondary-foreground text-xs rounded">
{tag}
</span>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
{server.last_seen_at
? `最后在线: ${new Date(server.last_seen_at).toLocaleString()}`
: '从未连接'}
</p>
</CardContent>
</Card>
))}
</div>
)}
{/* Add Modal */}
{showAddModal && (
<div className="fixed inset-0 bg-background/80 flex items-center justify-center z-50">
<Card className="w-full max-w-md">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle></CardTitle>
<Button variant="ghost" size="icon" onClick={() => setShowAddModal(false)}>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
<form onSubmit={handleAddServer} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
value={newServer.name}
onChange={e => setNewServer({...newServer, name: e.target.value})}
placeholder="例如: HK-01"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="display_name"></Label>
<Input
id="display_name"
value={newServer.display_name}
onChange={e => setNewServer({...newServer, display_name: e.target.value})}
placeholder="例如: 香港节点1"
/>
</div>
<div className="space-y-2">
<Label htmlFor="region"></Label>
<Input
id="region"
value={newServer.region}
onChange={e => setNewServer({...newServer, region: e.target.value})}
placeholder="例如: HK"
required
/>
</div>
<div className="flex gap-4">
<Button type="submit" className="flex-1"></Button>
<Button type="button" variant="outline" className="flex-1" onClick={() => setShowAddModal(false)}>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)}
</div>
)
}

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