Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec996c233a | |||
| 16b3b048bf | |||
| f4a0745032 | |||
| 0442e38a6b | |||
| bf0edfd500 | |||
| 7f728a7d1e | |||
| 2bcaaa1634 | |||
| 75097f4351 | |||
| 0ccd7efdad | |||
| 4a8c9050f6 | |||
| a00d172c21 | |||
| 4abbf05d22 | |||
| 553f36eb72 | |||
| a1a0c743a5 | |||
| 3898abcd4d | |||
| a1aa9f4581 | |||
| ce5f5a2696 | |||
| f84657c398 | |||
| cc336576c3 | |||
| b264356e73 | |||
| b770e963c1 | |||
| 09702ceaec | |||
| 2a28b1ce57 | |||
| 316e8d9906 | |||
| 3410ae9c8f | |||
| 6a6a7a19ad | |||
| 62fab1b97a | |||
| 22f21ea4d1 | |||
| d3f74112e6 | |||
| 0b02917a05 | |||
| 8c6e9a5e42 | |||
| 67744085f2 | |||
| 86cdbffb22 | |||
| 65118e62b2 | |||
| 6eb9e27855 | |||
| ca053bd2f3 | |||
| 92dc194212 | |||
| b03cf49623 | |||
| 592e0d14f8 | |||
| 9c9b7f7e58 | |||
| 8fcc8e5f53 | |||
| 61a287cec3 | |||
| 036e48b1b3 | |||
| 70837c9108 | |||
| 50faa12991 | |||
| 8c89b8468e | |||
| 64fc59e49f | |||
| 72e366f39d | |||
| 92730a7f45 | |||
| d4d2d74811 | |||
| 530cfed686 | |||
| c7c10c7c17 | |||
| ee54689fe9 | |||
| f1b01ec18f | |||
| d271a786d2 |
@@ -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
|
||||
'
|
||||
@@ -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"
|
||||
+27
-10
@@ -1,22 +1,39 @@
|
||||
# Build stage
|
||||
FROM golang:1.25-alpine AS builder
|
||||
# Build frontend
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
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 ./
|
||||
COPY server/go.mod server/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY server/ ./
|
||||
COPY agent/ ../agent/
|
||||
|
||||
# Build server
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /nuyue-server ./cmd/server
|
||||
# Copy frontend dist from previous stage
|
||||
COPY --from=frontend-builder /app/web/dist ./embed/dist
|
||||
|
||||
# Build agent
|
||||
# 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
|
||||
@@ -30,11 +47,11 @@ WORKDIR /app
|
||||
COPY --from=builder /nuyue-server /app/
|
||||
COPY --from=builder /nuyue-agent /app/
|
||||
|
||||
# Copy frontend (built by CI)
|
||||
COPY server/embed/dist /app/embed/dist
|
||||
# Copy frontend
|
||||
COPY --from=builder /app/embed/dist /app/embed/dist
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 8080 9090
|
||||
|
||||
# Run server
|
||||
CMD ["/app/nuyue-server"]
|
||||
CMD ["/app/nuyue-server"]
|
||||
@@ -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
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Package embed 静态文件嵌入
|
||||
package embed
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed all:dist
|
||||
var DistFS embed.FS
|
||||
+286
-38
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,8 +2,6 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/nuyue/server/pkg/response"
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,14 @@ func (s *authService) Login(ctx context.Context, req *LoginRequest) (*LoginRespo
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if user.PasswordHash != req.PasswordHash {
|
||||
// 目前安装时保存的是 "bcrypt:" + 明文密码 格式
|
||||
storedPassword := user.PasswordHash
|
||||
if len(storedPassword) > 7 && storedPassword[:7] == "bcrypt:" {
|
||||
// 去掉 "bcrypt:" 前缀后比较
|
||||
storedPassword = storedPassword[7:]
|
||||
}
|
||||
|
||||
if storedPassword != req.Password {
|
||||
return nil, ErrInvalidPassword
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "删除成功"})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>`)
|
||||
}
|
||||
|
||||
@@ -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 数据库连接测试请求
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 // 简化实现
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 // 避免未使用导入
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1146
-1
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Server,
|
||||
Shield,
|
||||
Bell,
|
||||
Globe,
|
||||
Zap,
|
||||
BarChart3,
|
||||
ArrowRight,
|
||||
Check
|
||||
} 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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user