chore: sync local changes (2026-03-12)

This commit is contained in:
2026-03-12 18:58:53 +08:00
parent 74f15c282e
commit d861a9937b
38 changed files with 3570 additions and 2926 deletions

View File

@@ -1,47 +1,47 @@
# Git 相关
.git
.gitignore
.gitattributes
# 编辑器和 IDE
.vscode
.idea
*.swp
*.swo
*~
# 操作系统文件
.DS_Store
Thumbs.db
# 数据文件(运行时生成)
data/*.json
# 日志文件
*.log
# 临时文件
tmp/
temp/
# 文档
README.md
LICENSE
*.md
# Docker 相关
Dockerfile
.dockerignore
docker-compose.yml
# 测试文件
*_test.go
test/
tests/
# 构建产物
*.exe
*.exe~
*.dll
*.so
*.dylib
# Git 相关
.git
.gitignore
.gitattributes
# 编辑器和 IDE
.vscode
.idea
*.swp
*.swo
*~
# 操作系统文件
.DS_Store
Thumbs.db
# 数据文件(运行时生成)
data/*.json
# 日志文件
*.log
# 临时文件
tmp/
temp/
# 文档
README.md
LICENSE
*.md
# Docker 相关
Dockerfile
.dockerignore
docker-compose.yml
# 测试文件
*_test.go
test/
tests/
# 构建产物
*.exe
*.exe~
*.dll
*.so
*.dylib

View File

@@ -1,56 +1,56 @@
# 多阶段构建 - 使用官方 Golang 镜像作为构建环境
FROM golang:1.25-alpine AS builder
# 设置工作目录
WORKDIR /app
# 安装必要的构建工具
RUN apk add --no-cache git ca-certificates tzdata
# 复制 go.mod 和 go.sum 文件
COPY go.mod go.sum ./
# 下载依赖
RUN go mod download
# 复制源代码
COPY . .
# 构建应用程序
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o mengyaping-backend .
# 使用轻量级的 alpine 镜像作为运行环境
FROM alpine:latest
# 安装必要的运行时依赖
RUN apk --no-cache add ca-certificates tzdata
# 设置时区为上海
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone
# 创建非 root 用户
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser
# 设置工作目录
WORKDIR /app
# 从构建阶段复制编译好的二进制文件
COPY --from=builder /app/mengyaping-backend .
# 创建数据目录
RUN mkdir -p /app/data && chown -R appuser:appuser /app
# 切换到非 root 用户
USER appuser
# 暴露端口
EXPOSE 8080
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1
# 运行应用程序
CMD ["./mengyaping-backend"]
# 多阶段构建 - 使用官方 Golang 镜像作为构建环境
FROM golang:1.25-alpine AS builder
# 设置工作目录
WORKDIR /app
# 安装必要的构建工具
RUN apk add --no-cache git ca-certificates tzdata
# 复制 go.mod 和 go.sum 文件
COPY go.mod go.sum ./
# 下载依赖
RUN go mod download
# 复制源代码
COPY . .
# 构建应用程序
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o mengyaping-backend .
# 使用轻量级的 alpine 镜像作为运行环境
FROM alpine:latest
# 安装必要的运行时依赖
RUN apk --no-cache add ca-certificates tzdata
# 设置时区为上海
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone
# 创建非 root 用户
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser
# 设置工作目录
WORKDIR /app
# 从构建阶段复制编译好的二进制文件
COPY --from=builder /app/mengyaping-backend .
# 创建数据目录
RUN mkdir -p /app/data && chown -R appuser:appuser /app
# 切换到非 root 用户
USER appuser
# 暴露端口
EXPOSE 8080
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1
# 运行应用程序
CMD ["./mengyaping-backend"]

View File

@@ -1,176 +1,176 @@
package config
import (
"encoding/json"
"fmt"
"os"
"sync"
"time"
)
// Config 应用配置
type Config struct {
Server ServerConfig `json:"server"`
Monitor MonitorConfig `json:"monitor"`
DataPath string `json:"data_path"`
}
// ServerConfig 服务器配置
type ServerConfig struct {
Port string `json:"port"`
Host string `json:"host"`
}
// MonitorConfig 监控配置
type MonitorConfig struct {
Interval time.Duration `json:"interval"` // 检测间隔
Timeout time.Duration `json:"timeout"` // 请求超时时间
RetryCount int `json:"retry_count"` // 重试次数
HistoryDays int `json:"history_days"` // 保留历史天数
}
var (
cfg *Config
once sync.Once
)
// GetConfig 获取配置单例
func GetConfig() *Config {
once.Do(func() {
cfg = &Config{
Server: ServerConfig{
Port: getEnv("SERVER_PORT", "8080"),
Host: getEnv("SERVER_HOST", "0.0.0.0"),
},
Monitor: MonitorConfig{
Interval: parseDuration(getEnv("MONITOR_INTERVAL", "5m"), 5*time.Minute),
Timeout: parseDuration(getEnv("MONITOR_TIMEOUT", "10s"), 10*time.Second),
RetryCount: parseInt(getEnv("MONITOR_RETRY_COUNT", "3"), 3),
HistoryDays: parseInt(getEnv("MONITOR_HISTORY_DAYS", "7"), 7),
},
DataPath: getEnv("DATA_PATH", "./data"),
}
// 尝试从配置文件加载(会覆盖环境变量配置)
loadConfigFromFile()
})
return cfg
}
// getEnv 获取环境变量,如果不存在则返回默认值
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// parseInt 解析整数环境变量
func parseInt(value string, defaultValue int) int {
if value == "" {
return defaultValue
}
var result int
if _, err := fmt.Sscanf(value, "%d", &result); err != nil {
return defaultValue
}
return result
}
// parseDuration 解析时间间隔环境变量
func parseDuration(value string, defaultValue time.Duration) time.Duration {
if value == "" {
return defaultValue
}
if duration, err := time.ParseDuration(value); err == nil {
return duration
}
return defaultValue
}
// loadConfigFromFile 从文件加载配置
func loadConfigFromFile() {
configFile := "./data/config.json"
if _, err := os.Stat(configFile); os.IsNotExist(err) {
return
}
data, err := os.ReadFile(configFile)
if err != nil {
return
}
var fileCfg struct {
Server ServerConfig `json:"server"`
Monitor struct {
IntervalMinutes int `json:"interval_minutes"`
TimeoutSeconds int `json:"timeout_seconds"`
RetryCount int `json:"retry_count"`
HistoryDays int `json:"history_days"`
} `json:"monitor"`
DataPath string `json:"data_path"`
}
if err := json.Unmarshal(data, &fileCfg); err != nil {
return
}
if fileCfg.Server.Port != "" {
cfg.Server.Port = fileCfg.Server.Port
}
if fileCfg.Server.Host != "" {
cfg.Server.Host = fileCfg.Server.Host
}
if fileCfg.Monitor.IntervalMinutes > 0 {
cfg.Monitor.Interval = time.Duration(fileCfg.Monitor.IntervalMinutes) * time.Minute
}
if fileCfg.Monitor.TimeoutSeconds > 0 {
cfg.Monitor.Timeout = time.Duration(fileCfg.Monitor.TimeoutSeconds) * time.Second
}
if fileCfg.Monitor.RetryCount > 0 {
cfg.Monitor.RetryCount = fileCfg.Monitor.RetryCount
}
if fileCfg.Monitor.HistoryDays > 0 {
cfg.Monitor.HistoryDays = fileCfg.Monitor.HistoryDays
}
if fileCfg.DataPath != "" {
cfg.DataPath = fileCfg.DataPath
}
}
// SaveConfig 保存配置到文件
func SaveConfig() error {
configFile := cfg.DataPath + "/config.json"
fileCfg := struct {
Server ServerConfig `json:"server"`
Monitor struct {
IntervalMinutes int `json:"interval_minutes"`
TimeoutSeconds int `json:"timeout_seconds"`
RetryCount int `json:"retry_count"`
HistoryDays int `json:"history_days"`
} `json:"monitor"`
DataPath string `json:"data_path"`
}{
Server: cfg.Server,
Monitor: struct {
IntervalMinutes int `json:"interval_minutes"`
TimeoutSeconds int `json:"timeout_seconds"`
RetryCount int `json:"retry_count"`
HistoryDays int `json:"history_days"`
}{
IntervalMinutes: int(cfg.Monitor.Interval.Minutes()),
TimeoutSeconds: int(cfg.Monitor.Timeout.Seconds()),
RetryCount: cfg.Monitor.RetryCount,
HistoryDays: cfg.Monitor.HistoryDays,
},
DataPath: cfg.DataPath,
}
data, err := json.MarshalIndent(fileCfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(configFile, data, 0644)
}
package config
import (
"encoding/json"
"fmt"
"os"
"sync"
"time"
)
// Config 应用配置
type Config struct {
Server ServerConfig `json:"server"`
Monitor MonitorConfig `json:"monitor"`
DataPath string `json:"data_path"`
}
// ServerConfig 服务器配置
type ServerConfig struct {
Port string `json:"port"`
Host string `json:"host"`
}
// MonitorConfig 监控配置
type MonitorConfig struct {
Interval time.Duration `json:"interval"` // 检测间隔
Timeout time.Duration `json:"timeout"` // 请求超时时间
RetryCount int `json:"retry_count"` // 重试次数
HistoryDays int `json:"history_days"` // 保留历史天数
}
var (
cfg *Config
once sync.Once
)
// GetConfig 获取配置单例
func GetConfig() *Config {
once.Do(func() {
cfg = &Config{
Server: ServerConfig{
Port: getEnv("SERVER_PORT", "8080"),
Host: getEnv("SERVER_HOST", "0.0.0.0"),
},
Monitor: MonitorConfig{
Interval: parseDuration(getEnv("MONITOR_INTERVAL", "1h"), 1*time.Hour),
Timeout: parseDuration(getEnv("MONITOR_TIMEOUT", "10s"), 10*time.Second),
RetryCount: parseInt(getEnv("MONITOR_RETRY_COUNT", "3"), 3),
HistoryDays: parseInt(getEnv("MONITOR_HISTORY_DAYS", "90"), 90),
},
DataPath: getEnv("DATA_PATH", "./data"),
}
// 尝试从配置文件加载(会覆盖环境变量配置)
loadConfigFromFile()
})
return cfg
}
// getEnv 获取环境变量,如果不存在则返回默认值
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// parseInt 解析整数环境变量
func parseInt(value string, defaultValue int) int {
if value == "" {
return defaultValue
}
var result int
if _, err := fmt.Sscanf(value, "%d", &result); err != nil {
return defaultValue
}
return result
}
// parseDuration 解析时间间隔环境变量
func parseDuration(value string, defaultValue time.Duration) time.Duration {
if value == "" {
return defaultValue
}
if duration, err := time.ParseDuration(value); err == nil {
return duration
}
return defaultValue
}
// loadConfigFromFile 从文件加载配置
func loadConfigFromFile() {
configFile := "./data/config.json"
if _, err := os.Stat(configFile); os.IsNotExist(err) {
return
}
data, err := os.ReadFile(configFile)
if err != nil {
return
}
var fileCfg struct {
Server ServerConfig `json:"server"`
Monitor struct {
IntervalMinutes int `json:"interval_minutes"`
TimeoutSeconds int `json:"timeout_seconds"`
RetryCount int `json:"retry_count"`
HistoryDays int `json:"history_days"`
} `json:"monitor"`
DataPath string `json:"data_path"`
}
if err := json.Unmarshal(data, &fileCfg); err != nil {
return
}
if fileCfg.Server.Port != "" {
cfg.Server.Port = fileCfg.Server.Port
}
if fileCfg.Server.Host != "" {
cfg.Server.Host = fileCfg.Server.Host
}
if fileCfg.Monitor.IntervalMinutes > 0 {
cfg.Monitor.Interval = time.Duration(fileCfg.Monitor.IntervalMinutes) * time.Minute
}
if fileCfg.Monitor.TimeoutSeconds > 0 {
cfg.Monitor.Timeout = time.Duration(fileCfg.Monitor.TimeoutSeconds) * time.Second
}
if fileCfg.Monitor.RetryCount > 0 {
cfg.Monitor.RetryCount = fileCfg.Monitor.RetryCount
}
if fileCfg.Monitor.HistoryDays > 0 {
cfg.Monitor.HistoryDays = fileCfg.Monitor.HistoryDays
}
if fileCfg.DataPath != "" {
cfg.DataPath = fileCfg.DataPath
}
}
// SaveConfig 保存配置到文件
func SaveConfig() error {
configFile := cfg.DataPath + "/config.json"
fileCfg := struct {
Server ServerConfig `json:"server"`
Monitor struct {
IntervalMinutes int `json:"interval_minutes"`
TimeoutSeconds int `json:"timeout_seconds"`
RetryCount int `json:"retry_count"`
HistoryDays int `json:"history_days"`
} `json:"monitor"`
DataPath string `json:"data_path"`
}{
Server: cfg.Server,
Monitor: struct {
IntervalMinutes int `json:"interval_minutes"`
TimeoutSeconds int `json:"timeout_seconds"`
RetryCount int `json:"retry_count"`
HistoryDays int `json:"history_days"`
}{
IntervalMinutes: int(cfg.Monitor.Interval.Minutes()),
TimeoutSeconds: int(cfg.Monitor.Timeout.Seconds()),
RetryCount: cfg.Monitor.RetryCount,
HistoryDays: cfg.Monitor.HistoryDays,
},
DataPath: cfg.DataPath,
}
data, err := json.MarshalIndent(fileCfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(configFile, data, 0644)
}

View File

@@ -1,40 +1,40 @@
version: '3.8'
services:
mengyaping-backend:
build:
context: .
dockerfile: Dockerfile
container_name: mengyaping-backend
restart: unless-stopped
ports:
- "6161:8080"
volumes:
# 持久化数据目录
- /shumengya/docker/mengyaping-backend/data/:/app/data
environment:
# 服务器配置
- SERVER_PORT=8080
- SERVER_HOST=0.0.0.0
# 监控配置
- MONITOR_INTERVAL=5m
- MONITOR_TIMEOUT=10s
- MONITOR_RETRY_COUNT=3
- MONITOR_HISTORY_DAYS=7
networks:
- mengyaping-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
mengyaping-network:
driver: bridge
version: '3.8'
services:
mengyaping-backend:
build:
context: .
dockerfile: Dockerfile
container_name: mengyaping-backend
restart: unless-stopped
ports:
- "6161:8080"
volumes:
# 持久化数据目录
- /shumengya/docker/mengyaping-backend/data/:/app/data
environment:
# 服务器配置
- SERVER_PORT=8080
- SERVER_HOST=0.0.0.0
# 监控配置
- MONITOR_INTERVAL=5m
- MONITOR_TIMEOUT=10s
- MONITOR_RETRY_COUNT=3
- MONITOR_HISTORY_DAYS=7
networks:
- mengyaping-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
mengyaping-network:
driver: bridge

View File

@@ -1,199 +1,199 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"mengyaping-backend/models"
"mengyaping-backend/services"
)
// WebsiteHandler 网站处理器
type WebsiteHandler struct {
websiteService *services.WebsiteService
monitorService *services.MonitorService
}
// NewWebsiteHandler 创建网站处理器
func NewWebsiteHandler() *WebsiteHandler {
return &WebsiteHandler{
websiteService: services.NewWebsiteService(),
monitorService: services.GetMonitorService(),
}
}
// GetWebsites 获取所有网站状态
func (h *WebsiteHandler) GetWebsites(c *gin.Context) {
statuses := h.monitorService.GetAllWebsiteStatuses()
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "success",
Data: statuses,
})
}
// GetWebsite 获取单个网站状态
func (h *WebsiteHandler) GetWebsite(c *gin.Context) {
id := c.Param("id")
status := h.monitorService.GetWebsiteStatus(id)
if status == nil {
c.JSON(http.StatusNotFound, models.APIResponse{
Code: 404,
Message: "网站不存在",
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "success",
Data: status,
})
}
// CreateWebsite 创建网站
func (h *WebsiteHandler) CreateWebsite(c *gin.Context) {
var req models.CreateWebsiteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.APIResponse{
Code: 400,
Message: "参数错误: " + err.Error(),
})
return
}
website, err := h.websiteService.CreateWebsite(req)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIResponse{
Code: 500,
Message: "创建失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "创建成功",
Data: website,
})
}
// UpdateWebsite 更新网站
func (h *WebsiteHandler) UpdateWebsite(c *gin.Context) {
id := c.Param("id")
var req models.UpdateWebsiteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.APIResponse{
Code: 400,
Message: "参数错误: " + err.Error(),
})
return
}
website, err := h.websiteService.UpdateWebsite(id, req)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIResponse{
Code: 500,
Message: "更新失败: " + err.Error(),
})
return
}
if website == nil {
c.JSON(http.StatusNotFound, models.APIResponse{
Code: 404,
Message: "网站不存在",
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "更新成功",
Data: website,
})
}
// DeleteWebsite 删除网站
func (h *WebsiteHandler) DeleteWebsite(c *gin.Context) {
id := c.Param("id")
if err := h.websiteService.DeleteWebsite(id); err != nil {
c.JSON(http.StatusInternalServerError, models.APIResponse{
Code: 500,
Message: "删除失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "删除成功",
})
}
// CheckWebsiteNow 立即检测网站
func (h *WebsiteHandler) CheckWebsiteNow(c *gin.Context) {
id := c.Param("id")
website := h.websiteService.GetWebsite(id)
if website == nil {
c.JSON(http.StatusNotFound, models.APIResponse{
Code: 404,
Message: "网站不存在",
})
return
}
h.monitorService.CheckWebsiteNow(id)
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "检测任务已提交",
})
}
// GetGroups 获取所有分组
func (h *WebsiteHandler) GetGroups(c *gin.Context) {
groups := h.websiteService.GetGroups()
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "success",
Data: groups,
})
}
// AddGroup 添加分组
func (h *WebsiteHandler) AddGroup(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.APIResponse{
Code: 400,
Message: "参数错误: " + err.Error(),
})
return
}
group, err := h.websiteService.AddGroup(req.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIResponse{
Code: 500,
Message: "添加失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "添加成功",
Data: group,
})
}
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"mengyaping-backend/models"
"mengyaping-backend/services"
)
// WebsiteHandler 网站处理器
type WebsiteHandler struct {
websiteService *services.WebsiteService
monitorService *services.MonitorService
}
// NewWebsiteHandler 创建网站处理器
func NewWebsiteHandler() *WebsiteHandler {
return &WebsiteHandler{
websiteService: services.NewWebsiteService(),
monitorService: services.GetMonitorService(),
}
}
// GetWebsites 获取所有网站状态
func (h *WebsiteHandler) GetWebsites(c *gin.Context) {
statuses := h.monitorService.GetAllWebsiteStatuses()
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "success",
Data: statuses,
})
}
// GetWebsite 获取单个网站状态
func (h *WebsiteHandler) GetWebsite(c *gin.Context) {
id := c.Param("id")
status := h.monitorService.GetWebsiteStatus(id)
if status == nil {
c.JSON(http.StatusNotFound, models.APIResponse{
Code: 404,
Message: "网站不存在",
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "success",
Data: status,
})
}
// CreateWebsite 创建网站
func (h *WebsiteHandler) CreateWebsite(c *gin.Context) {
var req models.CreateWebsiteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.APIResponse{
Code: 400,
Message: "参数错误: " + err.Error(),
})
return
}
website, err := h.websiteService.CreateWebsite(req)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIResponse{
Code: 500,
Message: "创建失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "创建成功",
Data: website,
})
}
// UpdateWebsite 更新网站
func (h *WebsiteHandler) UpdateWebsite(c *gin.Context) {
id := c.Param("id")
var req models.UpdateWebsiteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.APIResponse{
Code: 400,
Message: "参数错误: " + err.Error(),
})
return
}
website, err := h.websiteService.UpdateWebsite(id, req)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIResponse{
Code: 500,
Message: "更新失败: " + err.Error(),
})
return
}
if website == nil {
c.JSON(http.StatusNotFound, models.APIResponse{
Code: 404,
Message: "网站不存在",
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "更新成功",
Data: website,
})
}
// DeleteWebsite 删除网站
func (h *WebsiteHandler) DeleteWebsite(c *gin.Context) {
id := c.Param("id")
if err := h.websiteService.DeleteWebsite(id); err != nil {
c.JSON(http.StatusInternalServerError, models.APIResponse{
Code: 500,
Message: "删除失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "删除成功",
})
}
// CheckWebsiteNow 立即检测网站
func (h *WebsiteHandler) CheckWebsiteNow(c *gin.Context) {
id := c.Param("id")
website := h.websiteService.GetWebsite(id)
if website == nil {
c.JSON(http.StatusNotFound, models.APIResponse{
Code: 404,
Message: "网站不存在",
})
return
}
h.monitorService.CheckWebsiteNow(id)
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "检测任务已提交",
})
}
// GetGroups 获取所有分组
func (h *WebsiteHandler) GetGroups(c *gin.Context) {
groups := h.websiteService.GetGroups()
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "success",
Data: groups,
})
}
// AddGroup 添加分组
func (h *WebsiteHandler) AddGroup(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.APIResponse{
Code: 400,
Message: "参数错误: " + err.Error(),
})
return
}
group, err := h.websiteService.AddGroup(req.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIResponse{
Code: 500,
Message: "添加失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: 0,
Message: "添加成功",
Data: group,
})
}

View File

@@ -1,47 +1,47 @@
package main
import (
"fmt"
"log"
"os"
"os/signal"
"syscall"
"mengyaping-backend/config"
"mengyaping-backend/router"
"mengyaping-backend/services"
)
func main() {
// 获取配置
cfg := config.GetConfig()
// 确保数据目录存在
os.MkdirAll(cfg.DataPath, 0755)
// 启动监控服务
monitorService := services.GetMonitorService()
go monitorService.Start()
// 设置路由
r := router.SetupRouter()
// 优雅关闭
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("正在关闭服务...")
monitorService.Stop()
os.Exit(0)
}()
// 启动服务器
addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
log.Printf("🌱 萌芽Ping 监控服务已启动,监听地址: %s\n", addr)
if err := r.Run(addr); err != nil {
log.Fatalf("服务器启动失败: %v", err)
}
}
package main
import (
"fmt"
"log"
"os"
"os/signal"
"syscall"
"mengyaping-backend/config"
"mengyaping-backend/router"
"mengyaping-backend/services"
)
func main() {
// 获取配置
cfg := config.GetConfig()
// 确保数据目录存在
os.MkdirAll(cfg.DataPath, 0755)
// 启动监控服务
monitorService := services.GetMonitorService()
go monitorService.Start()
// 设置路由
r := router.SetupRouter()
// 优雅关闭
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("正在关闭服务...")
monitorService.Stop()
os.Exit(0)
}()
// 启动服务器
addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
log.Printf("🌱 萌芽Ping 监控服务已启动,监听地址: %s\n", addr)
if err := r.Run(addr); err != nil {
log.Fatalf("服务器启动失败: %v", err)
}
}

View File

@@ -1,98 +1,113 @@
package models
import (
"time"
)
// Website 网站信息
type Website struct {
ID string `json:"id"`
Name string `json:"name"` // 网站名称
Group string `json:"group"` // 所属分组
URLs []URLInfo `json:"urls"` // 网站访问地址列表
Favicon string `json:"favicon"` // 网站图标URL
Title string `json:"title"` // 网站标题
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedAt time.Time `json:"updated_at"` // 更新时间
}
// URLInfo 单个URL的信息
type URLInfo struct {
ID string `json:"id"`
URL string `json:"url"` // 访问地址
Remark string `json:"remark"` // 备注说明
}
// MonitorRecord 监控记录
type MonitorRecord struct {
WebsiteID string `json:"website_id"`
URLID string `json:"url_id"`
URL string `json:"url"`
StatusCode int `json:"status_code"` // HTTP状态码
Latency int64 `json:"latency"` // 延迟(毫秒)
IsUp bool `json:"is_up"` // 是否可访问
Error string `json:"error"` // 错误信息
CheckedAt time.Time `json:"checked_at"` // 检测时间
}
// WebsiteStatus 网站状态(用于前端展示)
type WebsiteStatus struct {
Website Website `json:"website"`
URLStatuses []URLStatus `json:"url_statuses"`
Uptime24h float64 `json:"uptime_24h"` // 24小时可用率
Uptime7d float64 `json:"uptime_7d"` // 7天可用率
LastChecked time.Time `json:"last_checked"` // 最后检测时间
}
// URLStatus 单个URL的状态
type URLStatus struct {
URLInfo URLInfo `json:"url_info"`
CurrentState MonitorRecord `json:"current_state"` // 当前状态
History24h []MonitorRecord `json:"history_24h"` // 24小时历史
History7d []HourlyStats `json:"history_7d"` // 7天按小时统计
Uptime24h float64 `json:"uptime_24h"` // 24小时可用率
Uptime7d float64 `json:"uptime_7d"` // 7天可用率
AvgLatency int64 `json:"avg_latency"` // 平均延迟
}
// HourlyStats 每小时统计
type HourlyStats struct {
Hour time.Time `json:"hour"`
TotalCount int `json:"total_count"`
UpCount int `json:"up_count"`
AvgLatency int64 `json:"avg_latency"`
Uptime float64 `json:"uptime"`
}
// Group 分组
type Group struct {
ID string `json:"id"`
Name string `json:"name"`
}
// DefaultGroups 默认分组
var DefaultGroups = []Group{
{ID: "normal", Name: "普通网站"},
{ID: "admin", Name: "管理员网站"},
}
// CreateWebsiteRequest 创建网站请求
type CreateWebsiteRequest struct {
Name string `json:"name" binding:"required"`
Group string `json:"group" binding:"required"`
URLs []string `json:"urls" binding:"required,min=1"`
}
// UpdateWebsiteRequest 更新网站请求
type UpdateWebsiteRequest struct {
Name string `json:"name"`
Group string `json:"group"`
URLs []string `json:"urls"`
}
// APIResponse API响应
type APIResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
package models
import (
"time"
)
// Website 网站信息
type Website struct {
ID string `json:"id"`
Name string `json:"name"` // 网站名称
Groups []string `json:"groups"` // 所属分组列表(支持多分组)
Group string `json:"group,omitempty"` // 已废弃,仅用于旧数据兼容
URLs []URLInfo `json:"urls"` // 网站访问地址列表
IPAddresses []string `json:"ip_addresses,omitempty"` // 域名解析的IP地址
Favicon string `json:"favicon"` // 网站图标URL
Title string `json:"title"` // 网站标题
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedAt time.Time `json:"updated_at"` // 更新时间
}
// URLInfo 单个URL的信息
type URLInfo struct {
ID string `json:"id"`
URL string `json:"url"` // 访问地址
Remark string `json:"remark"` // 备注说明
}
// MonitorRecord 监控记录
type MonitorRecord struct {
WebsiteID string `json:"website_id"`
URLID string `json:"url_id"`
URL string `json:"url"`
StatusCode int `json:"status_code"` // HTTP状态码
Latency int64 `json:"latency"` // 延迟(毫秒)
IsUp bool `json:"is_up"` // 是否可访问
Error string `json:"error"` // 错误信息
CheckedAt time.Time `json:"checked_at"` // 检测时间
}
// WebsiteStatus 网站状态(用于前端展示)
type WebsiteStatus struct {
Website Website `json:"website"`
URLStatuses []URLStatus `json:"url_statuses"`
DailyHistory []DailyStats `json:"daily_history"` // 90天逐日统计
Uptime24h float64 `json:"uptime_24h"` // 24小时可用率
Uptime7d float64 `json:"uptime_7d"` // 7天可用率
Uptime90d float64 `json:"uptime_90d"` // 90天可用率
LastChecked time.Time `json:"last_checked"` // 最后检测时间
}
// URLStatus 单个URL的状态
type URLStatus struct {
URLInfo URLInfo `json:"url_info"`
CurrentState MonitorRecord `json:"current_state"` // 当前状态
History24h []MonitorRecord `json:"history_24h"` // 24小时历史
History7d []HourlyStats `json:"history_7d"` // 7天按小时统计
Uptime24h float64 `json:"uptime_24h"` // 24小时可用率
Uptime7d float64 `json:"uptime_7d"` // 7天可用率
AvgLatency int64 `json:"avg_latency"` // 平均延迟
}
// HourlyStats 每小时统计
type HourlyStats struct {
Hour time.Time `json:"hour"`
TotalCount int `json:"total_count"`
UpCount int `json:"up_count"`
AvgLatency int64 `json:"avg_latency"`
Uptime float64 `json:"uptime"`
}
// DailyStats 每日统计
type DailyStats struct {
Date time.Time `json:"date"`
TotalCount int `json:"total_count"`
UpCount int `json:"up_count"`
AvgLatency int64 `json:"avg_latency"`
Uptime float64 `json:"uptime"`
}
// Group 分组
type Group struct {
ID string `json:"id"`
Name string `json:"name"`
}
// DefaultGroups 默认分组
var DefaultGroups = []Group{
{ID: "normal", Name: "普通网站"},
{ID: "admin", Name: "管理员网站"},
}
// CreateWebsiteRequest 创建网站请求
type CreateWebsiteRequest struct {
Name string `json:"name" binding:"required"`
Groups []string `json:"groups" binding:"required,min=1"`
Group string `json:"group"`
URLs []string `json:"urls" binding:"required,min=1"`
}
// UpdateWebsiteRequest 更新网站请求
type UpdateWebsiteRequest struct {
Name string `json:"name"`
Groups []string `json:"groups"`
Group string `json:"group"`
URLs []string `json:"urls"`
}
// APIResponse API响应
type APIResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}

View File

@@ -1,51 +1,51 @@
package router
import (
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"mengyaping-backend/handlers"
)
// SetupRouter 设置路由
func SetupRouter() *gin.Engine {
r := gin.Default()
// CORS配置
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
}))
// 创建处理器
websiteHandler := handlers.NewWebsiteHandler()
// API路由组
api := r.Group("/api")
{
// 健康检查
api.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"message": "服务运行正常",
})
})
// 网站相关
api.GET("/websites", websiteHandler.GetWebsites)
api.GET("/websites/:id", websiteHandler.GetWebsite)
api.POST("/websites", websiteHandler.CreateWebsite)
api.PUT("/websites/:id", websiteHandler.UpdateWebsite)
api.DELETE("/websites/:id", websiteHandler.DeleteWebsite)
api.POST("/websites/:id/check", websiteHandler.CheckWebsiteNow)
// 分组相关
api.GET("/groups", websiteHandler.GetGroups)
api.POST("/groups", websiteHandler.AddGroup)
}
return r
}
package router
import (
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"mengyaping-backend/handlers"
)
// SetupRouter 设置路由
func SetupRouter() *gin.Engine {
r := gin.Default()
// CORS配置
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
}))
// 创建处理器
websiteHandler := handlers.NewWebsiteHandler()
// API路由组
api := r.Group("/api")
{
// 健康检查
api.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"message": "服务运行正常",
})
})
// 网站相关
api.GET("/websites", websiteHandler.GetWebsites)
api.GET("/websites/:id", websiteHandler.GetWebsite)
api.POST("/websites", websiteHandler.CreateWebsite)
api.PUT("/websites/:id", websiteHandler.UpdateWebsite)
api.DELETE("/websites/:id", websiteHandler.DeleteWebsite)
api.POST("/websites/:id/check", websiteHandler.CheckWebsiteNow)
// 分组相关
api.GET("/groups", websiteHandler.GetGroups)
api.POST("/groups", websiteHandler.AddGroup)
}
return r
}

View File

@@ -1,302 +1,418 @@
package services
import (
"log"
"sync"
"time"
"mengyaping-backend/config"
"mengyaping-backend/models"
"mengyaping-backend/storage"
"mengyaping-backend/utils"
)
// MonitorService 监控服务
type MonitorService struct {
httpClient *utils.HTTPClient
storage *storage.Storage
stopCh chan struct{}
running bool
mu sync.Mutex
}
var (
monitorService *MonitorService
monitorOnce sync.Once
)
// GetMonitorService 获取监控服务单例
func GetMonitorService() *MonitorService {
monitorOnce.Do(func() {
cfg := config.GetConfig()
monitorService = &MonitorService{
httpClient: utils.NewHTTPClient(cfg.Monitor.Timeout),
storage: storage.GetStorage(),
stopCh: make(chan struct{}),
}
})
return monitorService
}
// Start 启动监控服务
func (s *MonitorService) Start() {
s.mu.Lock()
if s.running {
s.mu.Unlock()
return
}
s.running = true
s.mu.Unlock()
log.Println("监控服务已启动")
// 立即执行一次检测
go s.checkAll()
// 定时检测
cfg := config.GetConfig()
ticker := time.NewTicker(cfg.Monitor.Interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
go s.checkAll()
case <-s.stopCh:
log.Println("监控服务已停止")
return
}
}
}
// Stop 停止监控服务
func (s *MonitorService) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
close(s.stopCh)
s.running = false
}
}
// checkAll 检查所有网站
func (s *MonitorService) checkAll() {
websites := s.storage.GetWebsites()
var wg sync.WaitGroup
semaphore := make(chan struct{}, 10) // 限制并发数
for _, website := range websites {
for _, urlInfo := range website.URLs {
wg.Add(1)
go func(w models.Website, u models.URLInfo) {
defer wg.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
s.checkURL(w, u)
}(website, urlInfo)
}
}
wg.Wait()
// 保存记录
s.storage.SaveAll()
}
// checkURL 检查单个URL
func (s *MonitorService) checkURL(website models.Website, urlInfo models.URLInfo) {
result := s.httpClient.CheckWebsite(urlInfo.URL)
record := models.MonitorRecord{
WebsiteID: website.ID,
URLID: urlInfo.ID,
URL: urlInfo.URL,
StatusCode: result.StatusCode,
Latency: result.Latency.Milliseconds(),
IsUp: result.Error == nil && utils.IsSuccessStatus(result.StatusCode),
CheckedAt: time.Now(),
}
if result.Error != nil {
record.Error = result.Error.Error()
}
s.storage.AddRecord(record)
// 更新网站信息标题和Favicon
if result.Title != "" || result.Favicon != "" {
w := s.storage.GetWebsite(website.ID)
if w != nil {
needUpdate := false
if result.Title != "" && w.Title != result.Title {
w.Title = result.Title
needUpdate = true
}
if result.Favicon != "" && w.Favicon != result.Favicon {
w.Favicon = result.Favicon
needUpdate = true
}
if needUpdate {
w.UpdatedAt = time.Now()
s.storage.UpdateWebsite(*w)
}
}
}
log.Printf("检测 [%s] %s - 状态码: %d, 延迟: %dms, 可用: %v",
website.Name, urlInfo.URL, result.StatusCode, result.Latency.Milliseconds(), record.IsUp)
}
// CheckWebsiteNow 立即检查指定网站
func (s *MonitorService) CheckWebsiteNow(websiteID string) {
website := s.storage.GetWebsite(websiteID)
if website == nil {
return
}
for _, urlInfo := range website.URLs {
go s.checkURL(*website, urlInfo)
}
}
// GetWebsiteStatus 获取网站状态
func (s *MonitorService) GetWebsiteStatus(websiteID string) *models.WebsiteStatus {
website := s.storage.GetWebsite(websiteID)
if website == nil {
return nil
}
status := &models.WebsiteStatus{
Website: *website,
URLStatuses: []models.URLStatus{},
}
now := time.Now()
since24h := now.Add(-24 * time.Hour)
since7d := now.Add(-7 * 24 * time.Hour)
var totalUptime24h, totalUptime7d float64
var urlCount int
for _, urlInfo := range website.URLs {
urlStatus := s.getURLStatus(website.ID, urlInfo, since24h, since7d)
status.URLStatuses = append(status.URLStatuses, urlStatus)
totalUptime24h += urlStatus.Uptime24h
totalUptime7d += urlStatus.Uptime7d
urlCount++
}
if urlCount > 0 {
status.Uptime24h = totalUptime24h / float64(urlCount)
status.Uptime7d = totalUptime7d / float64(urlCount)
}
// 获取最后检测时间
for _, urlStatus := range status.URLStatuses {
if urlStatus.CurrentState.CheckedAt.After(status.LastChecked) {
status.LastChecked = urlStatus.CurrentState.CheckedAt
}
}
return status
}
// getURLStatus 获取URL状态
func (s *MonitorService) getURLStatus(websiteID string, urlInfo models.URLInfo, since24h, since7d time.Time) models.URLStatus {
urlStatus := models.URLStatus{
URLInfo: urlInfo,
}
// 获取最新记录
latest := s.storage.GetLatestRecord(websiteID, urlInfo.ID)
if latest != nil {
urlStatus.CurrentState = *latest
}
// 获取24小时记录
records24h := s.storage.GetRecords(websiteID, urlInfo.ID, since24h)
urlStatus.History24h = records24h
// 计算24小时可用率
if len(records24h) > 0 {
upCount := 0
var totalLatency int64
for _, r := range records24h {
if r.IsUp {
upCount++
}
totalLatency += r.Latency
}
urlStatus.Uptime24h = float64(upCount) / float64(len(records24h)) * 100
urlStatus.AvgLatency = totalLatency / int64(len(records24h))
}
// 获取7天记录并按小时统计
records7d := s.storage.GetRecords(websiteID, urlInfo.ID, since7d)
urlStatus.History7d = s.aggregateByHour(records7d)
// 计算7天可用率
if len(records7d) > 0 {
upCount := 0
for _, r := range records7d {
if r.IsUp {
upCount++
}
}
urlStatus.Uptime7d = float64(upCount) / float64(len(records7d)) * 100
}
return urlStatus
}
// aggregateByHour 按小时聚合记录
func (s *MonitorService) aggregateByHour(records []models.MonitorRecord) []models.HourlyStats {
hourlyMap := make(map[string]*models.HourlyStats)
for _, r := range records {
hourKey := r.CheckedAt.Truncate(time.Hour).Format(time.RFC3339)
if _, exists := hourlyMap[hourKey]; !exists {
hourlyMap[hourKey] = &models.HourlyStats{
Hour: r.CheckedAt.Truncate(time.Hour),
}
}
stats := hourlyMap[hourKey]
stats.TotalCount++
if r.IsUp {
stats.UpCount++
}
stats.AvgLatency += r.Latency
}
var result []models.HourlyStats
for _, stats := range hourlyMap {
if stats.TotalCount > 0 {
stats.AvgLatency /= int64(stats.TotalCount)
stats.Uptime = float64(stats.UpCount) / float64(stats.TotalCount) * 100
}
result = append(result, *stats)
}
return result
}
// GetAllWebsiteStatuses 获取所有网站状态
func (s *MonitorService) GetAllWebsiteStatuses() []models.WebsiteStatus {
websites := s.storage.GetWebsites()
var statuses []models.WebsiteStatus
for _, website := range websites {
status := s.GetWebsiteStatus(website.ID)
if status != nil {
statuses = append(statuses, *status)
}
}
return statuses
}
package services
import (
"log"
"sort"
"sync"
"time"
"mengyaping-backend/config"
"mengyaping-backend/models"
"mengyaping-backend/storage"
"mengyaping-backend/utils"
)
// MonitorService 监控服务
type MonitorService struct {
httpClient *utils.HTTPClient
storage *storage.Storage
stopCh chan struct{}
running bool
mu sync.Mutex
}
var (
monitorService *MonitorService
monitorOnce sync.Once
)
// GetMonitorService 获取监控服务单例
func GetMonitorService() *MonitorService {
monitorOnce.Do(func() {
cfg := config.GetConfig()
monitorService = &MonitorService{
httpClient: utils.NewHTTPClient(cfg.Monitor.Timeout),
storage: storage.GetStorage(),
stopCh: make(chan struct{}),
}
})
return monitorService
}
// Start 启动监控服务
func (s *MonitorService) Start() {
s.mu.Lock()
if s.running {
s.mu.Unlock()
return
}
s.running = true
s.mu.Unlock()
log.Println("监控服务已启动")
// 立即执行一次检测
go s.checkAll()
// 定时检测
cfg := config.GetConfig()
ticker := time.NewTicker(cfg.Monitor.Interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
go s.checkAll()
case <-s.stopCh:
log.Println("监控服务已停止")
return
}
}
}
// Stop 停止监控服务
func (s *MonitorService) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
close(s.stopCh)
s.running = false
}
}
// checkAll 检查所有网站(错峰执行,避免并发暴涨)
func (s *MonitorService) checkAll() {
websites := s.storage.GetWebsites()
semaphore := make(chan struct{}, 3) // 最多 3 个并发检测
for i, website := range websites {
// 每个网站之间间隔 1 秒,把检测分散开
if i > 0 {
time.Sleep(1 * time.Second)
}
var wg sync.WaitGroup
for _, urlInfo := range website.URLs {
wg.Add(1)
go func(w models.Website, u models.URLInfo) {
defer wg.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
s.checkURL(w, u)
}(website, urlInfo)
}
wg.Wait()
}
// 检测完毕后,逐个解析 DNS
s.resolveAllWebsiteIPs(websites)
// 保存记录
s.storage.SaveAll()
log.Printf("本轮检测完成,共 %d 个网站", len(websites))
}
// resolveAllWebsiteIPs 逐个解析所有网站域名 IP每次都刷新
func (s *MonitorService) resolveAllWebsiteIPs(websites []models.Website) {
for i, website := range websites {
if len(website.URLs) == 0 {
continue
}
if i > 0 {
time.Sleep(500 * time.Millisecond)
}
s.resolveWebsiteIP(website)
}
}
// resolveWebsiteIP 解析单个网站的域名 IP
func (s *MonitorService) resolveWebsiteIP(website models.Website) {
if len(website.URLs) == 0 {
return
}
ips, err := utils.ResolveDomainIPs(website.URLs[0].URL)
if err != nil {
log.Printf("DNS解析失败 [%s]: %v", website.Name, err)
return
}
if len(ips) == 0 {
return
}
w := s.storage.GetWebsite(website.ID)
if w == nil {
return
}
w.IPAddresses = ips
w.UpdatedAt = time.Now()
s.storage.UpdateWebsite(*w)
log.Printf("DNS解析 [%s] → %v", website.Name, ips)
}
// checkURL 检查单个URL带重试
func (s *MonitorService) checkURL(website models.Website, urlInfo models.URLInfo) {
cfg := config.GetConfig()
maxRetries := cfg.Monitor.RetryCount
var result utils.CheckResult
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt) * 2 * time.Second)
log.Printf("重试 [%s] %s - 第 %d 次重试", website.Name, urlInfo.URL, attempt)
}
result = s.httpClient.CheckWebsiteStatus(urlInfo.URL)
if result.Error == nil && utils.IsSuccessStatus(result.StatusCode) {
break
}
}
record := models.MonitorRecord{
WebsiteID: website.ID,
URLID: urlInfo.ID,
URL: urlInfo.URL,
StatusCode: result.StatusCode,
Latency: result.Latency.Milliseconds(),
IsUp: result.Error == nil && utils.IsSuccessStatus(result.StatusCode),
CheckedAt: time.Now(),
}
if result.Error != nil {
record.Error = result.Error.Error()
}
s.storage.AddRecord(record)
// 仅当网站无标题时才做完整检测来获取元数据
if record.IsUp && website.Title == "" {
fullResult := s.httpClient.CheckWebsite(urlInfo.URL)
if fullResult.Title != "" {
w := s.storage.GetWebsite(website.ID)
if w != nil {
w.Title = fullResult.Title
w.UpdatedAt = time.Now()
s.storage.UpdateWebsite(*w)
}
}
}
log.Printf("检测 [%s] %s - 状态码: %d, 延迟: %dms, 可用: %v",
website.Name, urlInfo.URL, result.StatusCode, result.Latency.Milliseconds(), record.IsUp)
}
// CheckWebsiteNow 立即检查指定网站(状态 + DNS等待完成后保存
func (s *MonitorService) CheckWebsiteNow(websiteID string) {
website := s.storage.GetWebsite(websiteID)
if website == nil {
return
}
// 逐个检测该网站的所有 URL
for _, urlInfo := range website.URLs {
s.checkURL(*website, urlInfo)
}
// 刷新 DNS
s.resolveWebsiteIP(*website)
s.storage.SaveAll()
}
// GetWebsiteStatus 获取网站状态
func (s *MonitorService) GetWebsiteStatus(websiteID string) *models.WebsiteStatus {
website := s.storage.GetWebsite(websiteID)
if website == nil {
return nil
}
status := &models.WebsiteStatus{
Website: *website,
URLStatuses: []models.URLStatus{},
}
now := time.Now()
since24h := now.Add(-24 * time.Hour)
since7d := now.Add(-7 * 24 * time.Hour)
since90d := now.Add(-90 * 24 * time.Hour)
var totalUptime24h, totalUptime7d float64
var urlCount int
var allRecords90d []models.MonitorRecord
for _, urlInfo := range website.URLs {
urlStatus := s.getURLStatus(website.ID, urlInfo, since24h, since7d)
status.URLStatuses = append(status.URLStatuses, urlStatus)
totalUptime24h += urlStatus.Uptime24h
totalUptime7d += urlStatus.Uptime7d
urlCount++
records90d := s.storage.GetRecords(website.ID, urlInfo.ID, since90d)
allRecords90d = append(allRecords90d, records90d...)
}
if urlCount > 0 {
status.Uptime24h = totalUptime24h / float64(urlCount)
status.Uptime7d = totalUptime7d / float64(urlCount)
}
// 90 天逐日统计
status.DailyHistory = s.aggregateByDay(allRecords90d)
if len(allRecords90d) > 0 {
upCount := 0
for _, r := range allRecords90d {
if r.IsUp {
upCount++
}
}
status.Uptime90d = float64(upCount) / float64(len(allRecords90d)) * 100
}
// 获取最后检测时间
for _, urlStatus := range status.URLStatuses {
if urlStatus.CurrentState.CheckedAt.After(status.LastChecked) {
status.LastChecked = urlStatus.CurrentState.CheckedAt
}
}
return status
}
// getURLStatus 获取URL状态
func (s *MonitorService) getURLStatus(websiteID string, urlInfo models.URLInfo, since24h, since7d time.Time) models.URLStatus {
urlStatus := models.URLStatus{
URLInfo: urlInfo,
}
// 获取最新记录
latest := s.storage.GetLatestRecord(websiteID, urlInfo.ID)
if latest != nil {
urlStatus.CurrentState = *latest
}
// 获取24小时记录
records24h := s.storage.GetRecords(websiteID, urlInfo.ID, since24h)
urlStatus.History24h = records24h
// 计算24小时可用率
if len(records24h) > 0 {
upCount := 0
var totalLatency int64
for _, r := range records24h {
if r.IsUp {
upCount++
}
totalLatency += r.Latency
}
urlStatus.Uptime24h = float64(upCount) / float64(len(records24h)) * 100
urlStatus.AvgLatency = totalLatency / int64(len(records24h))
}
// 获取7天记录并按小时统计
records7d := s.storage.GetRecords(websiteID, urlInfo.ID, since7d)
urlStatus.History7d = s.aggregateByHour(records7d)
// 计算7天可用率
if len(records7d) > 0 {
upCount := 0
for _, r := range records7d {
if r.IsUp {
upCount++
}
}
urlStatus.Uptime7d = float64(upCount) / float64(len(records7d)) * 100
}
return urlStatus
}
// aggregateByHour 按小时聚合记录
func (s *MonitorService) aggregateByHour(records []models.MonitorRecord) []models.HourlyStats {
hourlyMap := make(map[string]*models.HourlyStats)
for _, r := range records {
hourKey := r.CheckedAt.Truncate(time.Hour).Format(time.RFC3339)
if _, exists := hourlyMap[hourKey]; !exists {
hourlyMap[hourKey] = &models.HourlyStats{
Hour: r.CheckedAt.Truncate(time.Hour),
}
}
stats := hourlyMap[hourKey]
stats.TotalCount++
if r.IsUp {
stats.UpCount++
}
stats.AvgLatency += r.Latency
}
var result []models.HourlyStats
for _, stats := range hourlyMap {
if stats.TotalCount > 0 {
stats.AvgLatency /= int64(stats.TotalCount)
stats.Uptime = float64(stats.UpCount) / float64(stats.TotalCount) * 100
}
result = append(result, *stats)
}
return result
}
// aggregateByDay 按天聚合记录
func (s *MonitorService) aggregateByDay(records []models.MonitorRecord) []models.DailyStats {
dayMap := make(map[string]*models.DailyStats)
for _, r := range records {
dayTime := time.Date(r.CheckedAt.Year(), r.CheckedAt.Month(), r.CheckedAt.Day(), 0, 0, 0, 0, r.CheckedAt.Location())
dayKey := dayTime.Format("2006-01-02")
if _, exists := dayMap[dayKey]; !exists {
dayMap[dayKey] = &models.DailyStats{
Date: dayTime,
}
}
stats := dayMap[dayKey]
stats.TotalCount++
if r.IsUp {
stats.UpCount++
}
stats.AvgLatency += r.Latency
}
result := make([]models.DailyStats, 0, len(dayMap))
for _, stats := range dayMap {
if stats.TotalCount > 0 {
stats.AvgLatency /= int64(stats.TotalCount)
stats.Uptime = float64(stats.UpCount) / float64(stats.TotalCount) * 100
}
result = append(result, *stats)
}
sort.Slice(result, func(i, j int) bool {
return result[i].Date.Before(result[j].Date)
})
return result
}
// GetAllWebsiteStatuses 获取所有网站状态
func (s *MonitorService) GetAllWebsiteStatuses() []models.WebsiteStatus {
websites := s.storage.GetWebsites()
var statuses []models.WebsiteStatus
for _, website := range websites {
status := s.GetWebsiteStatus(website.ID)
if status != nil {
statuses = append(statuses, *status)
}
}
return statuses
}

View File

@@ -1,127 +1,142 @@
package services
import (
"time"
"mengyaping-backend/models"
"mengyaping-backend/storage"
"mengyaping-backend/utils"
)
// WebsiteService 网站服务
type WebsiteService struct {
storage *storage.Storage
}
// NewWebsiteService 创建网站服务
func NewWebsiteService() *WebsiteService {
return &WebsiteService{
storage: storage.GetStorage(),
}
}
// CreateWebsite 创建网站
func (s *WebsiteService) CreateWebsite(req models.CreateWebsiteRequest) (*models.Website, error) {
website := models.Website{
ID: utils.GenerateID(),
Name: req.Name,
Group: req.Group,
URLs: make([]models.URLInfo, 0),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
for _, url := range req.URLs {
urlInfo := models.URLInfo{
ID: utils.GenerateShortID(),
URL: url,
}
website.URLs = append(website.URLs, urlInfo)
}
if err := s.storage.AddWebsite(website); err != nil {
return nil, err
}
// 立即检测该网站
go GetMonitorService().CheckWebsiteNow(website.ID)
return &website, nil
}
// GetWebsite 获取网站
func (s *WebsiteService) GetWebsite(id string) *models.Website {
return s.storage.GetWebsite(id)
}
// GetAllWebsites 获取所有网站
func (s *WebsiteService) GetAllWebsites() []models.Website {
return s.storage.GetWebsites()
}
// UpdateWebsite 更新网站
func (s *WebsiteService) UpdateWebsite(id string, req models.UpdateWebsiteRequest) (*models.Website, error) {
website := s.storage.GetWebsite(id)
if website == nil {
return nil, nil
}
if req.Name != "" {
website.Name = req.Name
}
if req.Group != "" {
website.Group = req.Group
}
if len(req.URLs) > 0 {
// 保留已有URL的ID添加新URL
existingURLs := make(map[string]models.URLInfo)
for _, u := range website.URLs {
existingURLs[u.URL] = u
}
newURLs := make([]models.URLInfo, 0)
for _, url := range req.URLs {
if existing, ok := existingURLs[url]; ok {
newURLs = append(newURLs, existing)
} else {
newURLs = append(newURLs, models.URLInfo{
ID: utils.GenerateShortID(),
URL: url,
})
}
}
website.URLs = newURLs
}
website.UpdatedAt = time.Now()
if err := s.storage.UpdateWebsite(*website); err != nil {
return nil, err
}
return website, nil
}
// DeleteWebsite 删除网站
func (s *WebsiteService) DeleteWebsite(id string) error {
return s.storage.DeleteWebsite(id)
}
// GetGroups 获取所有分组
func (s *WebsiteService) GetGroups() []models.Group {
return s.storage.GetGroups()
}
// AddGroup 添加分组
func (s *WebsiteService) AddGroup(name string) (*models.Group, error) {
group := models.Group{
ID: utils.GenerateShortID(),
Name: name,
}
if err := s.storage.AddGroup(group); err != nil {
return nil, err
}
return &group, nil
}
package services
import (
"time"
"mengyaping-backend/models"
"mengyaping-backend/storage"
"mengyaping-backend/utils"
)
// WebsiteService 网站服务
type WebsiteService struct {
storage *storage.Storage
}
// NewWebsiteService 创建网站服务
func NewWebsiteService() *WebsiteService {
return &WebsiteService{
storage: storage.GetStorage(),
}
}
// CreateWebsite 创建网站
func (s *WebsiteService) CreateWebsite(req models.CreateWebsiteRequest) (*models.Website, error) {
groups := req.Groups
if len(groups) == 0 && req.Group != "" {
groups = []string{req.Group}
}
website := models.Website{
ID: utils.GenerateID(),
Name: req.Name,
Groups: groups,
URLs: make([]models.URLInfo, 0),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
for _, url := range req.URLs {
urlInfo := models.URLInfo{
ID: utils.GenerateShortID(),
URL: url,
}
website.URLs = append(website.URLs, urlInfo)
}
// 创建前先解析域名 IP
if len(req.URLs) > 0 {
if ips, err := utils.ResolveDomainIPs(req.URLs[0]); err == nil {
website.IPAddresses = ips
}
}
if err := s.storage.AddWebsite(website); err != nil {
return nil, err
}
// 立即检测该网站
go GetMonitorService().CheckWebsiteNow(website.ID)
return &website, nil
}
// GetWebsite 获取网站
func (s *WebsiteService) GetWebsite(id string) *models.Website {
return s.storage.GetWebsite(id)
}
// GetAllWebsites 获取所有网站
func (s *WebsiteService) GetAllWebsites() []models.Website {
return s.storage.GetWebsites()
}
// UpdateWebsite 更新网站
func (s *WebsiteService) UpdateWebsite(id string, req models.UpdateWebsiteRequest) (*models.Website, error) {
website := s.storage.GetWebsite(id)
if website == nil {
return nil, nil
}
if req.Name != "" {
website.Name = req.Name
}
if len(req.Groups) > 0 {
website.Groups = req.Groups
} else if req.Group != "" {
website.Groups = []string{req.Group}
}
if len(req.URLs) > 0 {
// 保留已有URL的ID添加新URL
existingURLs := make(map[string]models.URLInfo)
for _, u := range website.URLs {
existingURLs[u.URL] = u
}
newURLs := make([]models.URLInfo, 0)
for _, url := range req.URLs {
if existing, ok := existingURLs[url]; ok {
newURLs = append(newURLs, existing)
} else {
newURLs = append(newURLs, models.URLInfo{
ID: utils.GenerateShortID(),
URL: url,
})
}
}
website.URLs = newURLs
website.IPAddresses = nil // URL 变更后清空 IP等下次检测重新解析
}
website.UpdatedAt = time.Now()
if err := s.storage.UpdateWebsite(*website); err != nil {
return nil, err
}
return website, nil
}
// DeleteWebsite 删除网站
func (s *WebsiteService) DeleteWebsite(id string) error {
return s.storage.DeleteWebsite(id)
}
// GetGroups 获取所有分组
func (s *WebsiteService) GetGroups() []models.Group {
return s.storage.GetGroups()
}
// AddGroup 添加分组
func (s *WebsiteService) AddGroup(name string) (*models.Group, error) {
group := models.Group{
ID: utils.GenerateShortID(),
Name: name,
}
if err := s.storage.AddGroup(group); err != nil {
return nil, err
}
return &group, nil
}

View File

@@ -1,285 +1,302 @@
package storage
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"mengyaping-backend/config"
"mengyaping-backend/models"
)
// Storage 数据存储
type Storage struct {
dataPath string
mu sync.RWMutex
websites []models.Website
records map[string][]models.MonitorRecord // key: websiteID_urlID
groups []models.Group
}
var (
store *Storage
once sync.Once
)
// GetStorage 获取存储单例
func GetStorage() *Storage {
once.Do(func() {
cfg := config.GetConfig()
store = &Storage{
dataPath: cfg.DataPath,
websites: []models.Website{},
records: make(map[string][]models.MonitorRecord),
groups: models.DefaultGroups,
}
store.ensureDataDir()
store.load()
})
return store
}
// ensureDataDir 确保数据目录存在
func (s *Storage) ensureDataDir() {
os.MkdirAll(s.dataPath, 0755)
}
// load 加载数据
func (s *Storage) load() {
s.loadWebsites()
s.loadRecords()
s.loadGroups()
}
// loadWebsites 加载网站数据
func (s *Storage) loadWebsites() {
filePath := filepath.Join(s.dataPath, "websites.json")
data, err := os.ReadFile(filePath)
if err != nil {
return
}
json.Unmarshal(data, &s.websites)
}
// loadRecords 加载监控记录
func (s *Storage) loadRecords() {
filePath := filepath.Join(s.dataPath, "records.json")
data, err := os.ReadFile(filePath)
if err != nil {
return
}
json.Unmarshal(data, &s.records)
// 清理过期记录
s.cleanOldRecords()
}
// loadGroups 加载分组
func (s *Storage) loadGroups() {
filePath := filepath.Join(s.dataPath, "groups.json")
data, err := os.ReadFile(filePath)
if err != nil {
// 使用默认分组
s.groups = models.DefaultGroups
s.saveGroups()
return
}
json.Unmarshal(data, &s.groups)
}
// saveWebsites 保存网站数据
func (s *Storage) saveWebsites() error {
filePath := filepath.Join(s.dataPath, "websites.json")
data, err := json.MarshalIndent(s.websites, "", " ")
if err != nil {
return err
}
return os.WriteFile(filePath, data, 0644)
}
// saveRecords 保存监控记录
func (s *Storage) saveRecords() error {
filePath := filepath.Join(s.dataPath, "records.json")
data, err := json.MarshalIndent(s.records, "", " ")
if err != nil {
return err
}
return os.WriteFile(filePath, data, 0644)
}
// saveGroups 保存分组
func (s *Storage) saveGroups() error {
filePath := filepath.Join(s.dataPath, "groups.json")
data, err := json.MarshalIndent(s.groups, "", " ")
if err != nil {
return err
}
return os.WriteFile(filePath, data, 0644)
}
// cleanOldRecords 清理过期记录
func (s *Storage) cleanOldRecords() {
cfg := config.GetConfig()
cutoff := time.Now().AddDate(0, 0, -cfg.Monitor.HistoryDays)
for key, records := range s.records {
var newRecords []models.MonitorRecord
for _, r := range records {
if r.CheckedAt.After(cutoff) {
newRecords = append(newRecords, r)
}
}
s.records[key] = newRecords
}
}
// GetWebsites 获取所有网站
func (s *Storage) GetWebsites() []models.Website {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]models.Website, len(s.websites))
copy(result, s.websites)
return result
}
// GetWebsite 获取单个网站
func (s *Storage) GetWebsite(id string) *models.Website {
s.mu.RLock()
defer s.mu.RUnlock()
for _, w := range s.websites {
if w.ID == id {
website := w
return &website
}
}
return nil
}
// AddWebsite 添加网站
func (s *Storage) AddWebsite(website models.Website) error {
s.mu.Lock()
defer s.mu.Unlock()
s.websites = append(s.websites, website)
return s.saveWebsites()
}
// UpdateWebsite 更新网站
func (s *Storage) UpdateWebsite(website models.Website) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, w := range s.websites {
if w.ID == website.ID {
s.websites[i] = website
return s.saveWebsites()
}
}
return nil
}
// DeleteWebsite 删除网站
func (s *Storage) DeleteWebsite(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, w := range s.websites {
if w.ID == id {
s.websites = append(s.websites[:i], s.websites[i+1:]...)
// 删除相关记录
for key := range s.records {
if len(key) > len(id) && key[:len(id)] == id {
delete(s.records, key)
}
}
s.saveRecords()
return s.saveWebsites()
}
}
return nil
}
// AddRecord 添加监控记录
func (s *Storage) AddRecord(record models.MonitorRecord) error {
s.mu.Lock()
defer s.mu.Unlock()
key := record.WebsiteID + "_" + record.URLID
s.records[key] = append(s.records[key], record)
// 每100条记录保存一次
if len(s.records[key])%100 == 0 {
return s.saveRecords()
}
return nil
}
// GetRecords 获取监控记录
func (s *Storage) GetRecords(websiteID, urlID string, since time.Time) []models.MonitorRecord {
s.mu.RLock()
defer s.mu.RUnlock()
key := websiteID + "_" + urlID
records := s.records[key]
var result []models.MonitorRecord
for _, r := range records {
if r.CheckedAt.After(since) {
result = append(result, r)
}
}
return result
}
// GetLatestRecord 获取最新记录
func (s *Storage) GetLatestRecord(websiteID, urlID string) *models.MonitorRecord {
s.mu.RLock()
defer s.mu.RUnlock()
key := websiteID + "_" + urlID
records := s.records[key]
if len(records) == 0 {
return nil
}
latest := records[len(records)-1]
return &latest
}
// GetGroups 获取所有分组
func (s *Storage) GetGroups() []models.Group {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]models.Group, len(s.groups))
copy(result, s.groups)
return result
}
// AddGroup 添加分组
func (s *Storage) AddGroup(group models.Group) error {
s.mu.Lock()
defer s.mu.Unlock()
s.groups = append(s.groups, group)
return s.saveGroups()
}
// SaveAll 保存所有数据
func (s *Storage) SaveAll() error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.saveWebsites(); err != nil {
return err
}
if err := s.saveRecords(); err != nil {
return err
}
return s.saveGroups()
}
package storage
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"mengyaping-backend/config"
"mengyaping-backend/models"
)
// Storage 数据存储
type Storage struct {
dataPath string
mu sync.RWMutex
websites []models.Website
records map[string][]models.MonitorRecord // key: websiteID_urlID
groups []models.Group
}
var (
store *Storage
once sync.Once
)
// GetStorage 获取存储单例
func GetStorage() *Storage {
once.Do(func() {
cfg := config.GetConfig()
store = &Storage{
dataPath: cfg.DataPath,
websites: []models.Website{},
records: make(map[string][]models.MonitorRecord),
groups: models.DefaultGroups,
}
store.ensureDataDir()
store.load()
})
return store
}
// ensureDataDir 确保数据目录存在
func (s *Storage) ensureDataDir() {
os.MkdirAll(s.dataPath, 0755)
}
// load 加载数据
func (s *Storage) load() {
s.loadWebsites()
s.loadRecords()
s.loadGroups()
}
// loadWebsites 加载网站数据
func (s *Storage) loadWebsites() {
filePath := filepath.Join(s.dataPath, "websites.json")
data, err := os.ReadFile(filePath)
if err != nil {
return
}
json.Unmarshal(data, &s.websites)
s.migrateWebsiteGroups()
}
// migrateWebsiteGroups 将旧的单分组字段迁移到多分组数组
func (s *Storage) migrateWebsiteGroups() {
migrated := false
for i := range s.websites {
w := &s.websites[i]
if len(w.Groups) == 0 && w.Group != "" {
w.Groups = []string{w.Group}
w.Group = ""
migrated = true
}
}
if migrated {
s.saveWebsites()
}
}
// loadRecords 加载监控记录
func (s *Storage) loadRecords() {
filePath := filepath.Join(s.dataPath, "records.json")
data, err := os.ReadFile(filePath)
if err != nil {
return
}
json.Unmarshal(data, &s.records)
// 清理过期记录
s.cleanOldRecords()
}
// loadGroups 加载分组
func (s *Storage) loadGroups() {
filePath := filepath.Join(s.dataPath, "groups.json")
data, err := os.ReadFile(filePath)
if err != nil {
// 使用默认分组
s.groups = models.DefaultGroups
s.saveGroups()
return
}
json.Unmarshal(data, &s.groups)
}
// saveWebsites 保存网站数据
func (s *Storage) saveWebsites() error {
filePath := filepath.Join(s.dataPath, "websites.json")
data, err := json.MarshalIndent(s.websites, "", " ")
if err != nil {
return err
}
return os.WriteFile(filePath, data, 0644)
}
// saveRecords 保存监控记录
func (s *Storage) saveRecords() error {
filePath := filepath.Join(s.dataPath, "records.json")
data, err := json.MarshalIndent(s.records, "", " ")
if err != nil {
return err
}
return os.WriteFile(filePath, data, 0644)
}
// saveGroups 保存分组
func (s *Storage) saveGroups() error {
filePath := filepath.Join(s.dataPath, "groups.json")
data, err := json.MarshalIndent(s.groups, "", " ")
if err != nil {
return err
}
return os.WriteFile(filePath, data, 0644)
}
// cleanOldRecords 清理过期记录
func (s *Storage) cleanOldRecords() {
cfg := config.GetConfig()
cutoff := time.Now().AddDate(0, 0, -cfg.Monitor.HistoryDays)
for key, records := range s.records {
var newRecords []models.MonitorRecord
for _, r := range records {
if r.CheckedAt.After(cutoff) {
newRecords = append(newRecords, r)
}
}
s.records[key] = newRecords
}
}
// GetWebsites 获取所有网站
func (s *Storage) GetWebsites() []models.Website {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]models.Website, len(s.websites))
copy(result, s.websites)
return result
}
// GetWebsite 获取单个网站
func (s *Storage) GetWebsite(id string) *models.Website {
s.mu.RLock()
defer s.mu.RUnlock()
for _, w := range s.websites {
if w.ID == id {
website := w
return &website
}
}
return nil
}
// AddWebsite 添加网站
func (s *Storage) AddWebsite(website models.Website) error {
s.mu.Lock()
defer s.mu.Unlock()
s.websites = append(s.websites, website)
return s.saveWebsites()
}
// UpdateWebsite 更新网站
func (s *Storage) UpdateWebsite(website models.Website) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, w := range s.websites {
if w.ID == website.ID {
s.websites[i] = website
return s.saveWebsites()
}
}
return nil
}
// DeleteWebsite 删除网站
func (s *Storage) DeleteWebsite(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, w := range s.websites {
if w.ID == id {
s.websites = append(s.websites[:i], s.websites[i+1:]...)
// 删除相关记录
for key := range s.records {
if len(key) > len(id) && key[:len(id)] == id {
delete(s.records, key)
}
}
s.saveRecords()
return s.saveWebsites()
}
}
return nil
}
// AddRecord 添加监控记录
func (s *Storage) AddRecord(record models.MonitorRecord) error {
s.mu.Lock()
defer s.mu.Unlock()
key := record.WebsiteID + "_" + record.URLID
s.records[key] = append(s.records[key], record)
// 每100条记录保存一次
if len(s.records[key])%100 == 0 {
return s.saveRecords()
}
return nil
}
// GetRecords 获取监控记录
func (s *Storage) GetRecords(websiteID, urlID string, since time.Time) []models.MonitorRecord {
s.mu.RLock()
defer s.mu.RUnlock()
key := websiteID + "_" + urlID
records := s.records[key]
var result []models.MonitorRecord
for _, r := range records {
if r.CheckedAt.After(since) {
result = append(result, r)
}
}
return result
}
// GetLatestRecord 获取最新记录
func (s *Storage) GetLatestRecord(websiteID, urlID string) *models.MonitorRecord {
s.mu.RLock()
defer s.mu.RUnlock()
key := websiteID + "_" + urlID
records := s.records[key]
if len(records) == 0 {
return nil
}
latest := records[len(records)-1]
return &latest
}
// GetGroups 获取所有分组
func (s *Storage) GetGroups() []models.Group {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]models.Group, len(s.groups))
copy(result, s.groups)
return result
}
// AddGroup 添加分组
func (s *Storage) AddGroup(group models.Group) error {
s.mu.Lock()
defer s.mu.Unlock()
s.groups = append(s.groups, group)
return s.saveGroups()
}
// SaveAll 保存所有数据
func (s *Storage) SaveAll() error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.saveWebsites(); err != nil {
return err
}
if err := s.saveRecords(); err != nil {
return err
}
return s.saveGroups()
}

View File

@@ -0,0 +1,60 @@
package utils
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"time"
)
const dnsAPIBase = "https://cf-dns.smyhub.com/api/dns?domain="
type dnsResponse struct {
Status string `json:"status"`
IPv4 []string `json:"ipv4"`
IPv6 []string `json:"ipv6"`
}
// ResolveDomainIPs 通过 DNS API 解析域名的 IPv4 + IPv6 地址
func ResolveDomainIPs(rawURL string) ([]string, error) {
parsed, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
hostname := parsed.Hostname()
if hostname == "" {
return nil, fmt.Errorf("no hostname in URL")
}
if net.ParseIP(hostname) != nil {
return []string{hostname}, nil
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(dnsAPIBase + hostname)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*10))
if err != nil {
return nil, err
}
var dnsResp dnsResponse
if err := json.Unmarshal(body, &dnsResp); err != nil {
return nil, err
}
if dnsResp.Status != "success" {
return nil, fmt.Errorf("DNS lookup failed for %s", hostname)
}
ips := append(dnsResp.IPv4, dnsResp.IPv6...)
return ips, nil
}

View File

@@ -1,131 +1,173 @@
package utils
import (
"crypto/tls"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
// HTTPClient HTTP客户端工具
type HTTPClient struct {
client *http.Client
}
// NewHTTPClient 创建HTTP客户端
func NewHTTPClient(timeout time.Duration) *HTTPClient {
return &HTTPClient{
client: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
return nil
},
},
}
}
// CheckResult 检查结果
type CheckResult struct {
StatusCode int
Latency time.Duration
Title string
Favicon string
Error error
}
// CheckWebsite 检查网站
func (c *HTTPClient) CheckWebsite(targetURL string) CheckResult {
result := CheckResult{}
start := time.Now()
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
result.Error = err
return result
}
req.Header.Set("User-Agent", "MengYaPing/1.0 (Website Monitor)")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
resp, err := c.client.Do(req)
if err != nil {
result.Error = err
result.Latency = time.Since(start)
return result
}
defer resp.Body.Close()
result.Latency = time.Since(start)
result.StatusCode = resp.StatusCode
// 读取响应体获取标题
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*100)) // 限制100KB
if err == nil {
result.Title = extractTitle(string(body))
result.Favicon = extractFavicon(string(body), targetURL)
}
return result
}
// extractTitle 提取网页标题
func extractTitle(html string) string {
re := regexp.MustCompile(`(?i)<title[^>]*>([^<]+)</title>`)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
return ""
}
// extractFavicon 提取Favicon
func extractFavicon(html string, baseURL string) string {
parsedURL, err := url.Parse(baseURL)
if err != nil {
return ""
}
// 尝试从HTML中提取favicon链接
patterns := []string{
`(?i)<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']+)["']`,
`(?i)<link[^>]*href=["']([^"']+)["'][^>]*rel=["'](?:shortcut )?icon["']`,
`(?i)<link[^>]*rel=["']apple-touch-icon["'][^>]*href=["']([^"']+)["']`,
}
for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
faviconURL := matches[1]
return resolveURL(parsedURL, faviconURL)
}
}
// 默认返回 /favicon.ico
return fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host)
}
// resolveURL 解析相对URL
func resolveURL(base *url.URL, ref string) string {
refURL, err := url.Parse(ref)
if err != nil {
return ref
}
return base.ResolveReference(refURL).String()
}
// IsSuccessStatus 判断是否为成功状态码
func IsSuccessStatus(statusCode int) bool {
return statusCode >= 200 && statusCode < 400
}
package utils
import (
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
// HTTPClient HTTP客户端工具
type HTTPClient struct {
client *http.Client
}
// NewHTTPClient 创建HTTP客户端
func NewHTTPClient(timeout time.Duration) *HTTPClient {
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: timeout,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
}
return &HTTPClient{
client: &http.Client{
Timeout: timeout,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
return nil
},
},
}
}
// CheckResult 检查结果
type CheckResult struct {
StatusCode int
Latency time.Duration
Title string
Favicon string
Error error
}
// CheckWebsiteStatus 轻量级状态检测(不读取页面内容)
func (c *HTTPClient) CheckWebsiteStatus(targetURL string) CheckResult {
result := CheckResult{}
start := time.Now()
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
result.Error = err
return result
}
req.Header.Set("User-Agent", "MengYaPing/1.0 (Website Monitor)")
req.Header.Set("Accept", "*/*")
req.Header.Set("Connection", "close")
resp, err := c.client.Do(req)
if err != nil {
result.Error = err
result.Latency = time.Since(start)
return result
}
defer resp.Body.Close()
result.Latency = time.Since(start)
result.StatusCode = resp.StatusCode
// 丢弃少量 body 以便连接正确释放
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
return result
}
// CheckWebsite 完整检查(读取页面提取标题等元数据)
func (c *HTTPClient) CheckWebsite(targetURL string) CheckResult {
result := CheckResult{}
start := time.Now()
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
result.Error = err
return result
}
req.Header.Set("User-Agent", "MengYaPing/1.0 (Website Monitor)")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
resp, err := c.client.Do(req)
if err != nil {
result.Error = err
result.Latency = time.Since(start)
return result
}
defer resp.Body.Close()
result.Latency = time.Since(start)
result.StatusCode = resp.StatusCode
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*100))
if err == nil {
result.Title = extractTitle(string(body))
result.Favicon = extractFavicon(string(body), targetURL)
}
return result
}
// extractTitle 提取网页标题
func extractTitle(html string) string {
re := regexp.MustCompile(`(?i)<title[^>]*>([^<]+)</title>`)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
return ""
}
// extractFavicon 提取Favicon
func extractFavicon(html string, baseURL string) string {
parsedURL, err := url.Parse(baseURL)
if err != nil {
return ""
}
patterns := []string{
`(?i)<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']+)["']`,
`(?i)<link[^>]*href=["']([^"']+)["'][^>]*rel=["'](?:shortcut )?icon["']`,
`(?i)<link[^>]*rel=["']apple-touch-icon["'][^>]*href=["']([^"']+)["']`,
}
for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(html)
if len(matches) > 1 {
faviconURL := matches[1]
return resolveURL(parsedURL, faviconURL)
}
}
return fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host)
}
// resolveURL 解析相对URL
func resolveURL(base *url.URL, ref string) string {
refURL, err := url.Parse(ref)
if err != nil {
return ref
}
return base.ResolveReference(refURL).String()
}
// IsSuccessStatus 判断是否为成功状态码
func IsSuccessStatus(statusCode int) bool {
return statusCode >= 200 && statusCode < 400
}

View File

@@ -1,31 +1,31 @@
package utils
import (
"crypto/rand"
"encoding/hex"
"time"
)
// GenerateID 生成唯一ID
func GenerateID() string {
timestamp := time.Now().UnixNano()
randomBytes := make([]byte, 4)
rand.Read(randomBytes)
return hex.EncodeToString([]byte{
byte(timestamp >> 56),
byte(timestamp >> 48),
byte(timestamp >> 40),
byte(timestamp >> 32),
byte(timestamp >> 24),
byte(timestamp >> 16),
byte(timestamp >> 8),
byte(timestamp),
}) + hex.EncodeToString(randomBytes)
}
// GenerateShortID 生成短ID
func GenerateShortID() string {
randomBytes := make([]byte, 6)
rand.Read(randomBytes)
return hex.EncodeToString(randomBytes)
}
package utils
import (
"crypto/rand"
"encoding/hex"
"time"
)
// GenerateID 生成唯一ID
func GenerateID() string {
timestamp := time.Now().UnixNano()
randomBytes := make([]byte, 4)
rand.Read(randomBytes)
return hex.EncodeToString([]byte{
byte(timestamp >> 56),
byte(timestamp >> 48),
byte(timestamp >> 40),
byte(timestamp >> 32),
byte(timestamp >> 24),
byte(timestamp >> 16),
byte(timestamp >> 8),
byte(timestamp),
}) + hex.EncodeToString(randomBytes)
}
// GenerateShortID 生成短ID
func GenerateShortID() string {
randomBytes := make([]byte, 6)
rand.Read(randomBytes)
return hex.EncodeToString(randomBytes)
}