Initial commit
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BackupHandler 备份恢复处理器
|
||||
type BackupHandler struct {
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
backupSvc *service.BackupService
|
||||
}
|
||||
|
||||
// NewBackupHandler 创建备份恢复处理器
|
||||
func NewBackupHandler(db *gorm.DB, logger *zap.Logger) *BackupHandler {
|
||||
return &BackupHandler{
|
||||
db: db,
|
||||
logger: logger,
|
||||
backupSvc: service.NewBackupService(db),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateBackup 创建系统备份
|
||||
// @Summary 创建系统备份
|
||||
// @Tags System
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/system/backup [post]
|
||||
func (h *BackupHandler) CreateBackup(c *gin.Context) {
|
||||
// 验证管理员权限
|
||||
if !h.isAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("开始创建系统备份...")
|
||||
|
||||
// 生成备份文件名
|
||||
timestamp := time.Now().Format("20060102_150405")
|
||||
backupDir := filepath.Join("data", "backups")
|
||||
backupFile := filepath.Join(backupDir, fmt.Sprintf("meshray_backup_%s.zip", timestamp))
|
||||
|
||||
// 确保备份目录存在
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
h.logger.Error("创建备份目录失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "创建备份目录失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 执行真实备份
|
||||
ctx := c.Request.Context()
|
||||
if err := h.backupSvc.CreateBackup(ctx, backupFile); err != nil {
|
||||
h.logger.Error("创建备份失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("创建备份失败:%v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 计算文件大小
|
||||
fileInfo, err := os.Stat(backupFile)
|
||||
var sizeStr string
|
||||
if err == nil {
|
||||
sizeBytes := fileInfo.Size()
|
||||
if sizeBytes < 1024*1024 {
|
||||
sizeStr = fmt.Sprintf("%.2f KB", float64(sizeBytes)/1024)
|
||||
} else {
|
||||
sizeStr = fmt.Sprintf("%.2f MB", float64(sizeBytes)/(1024*1024))
|
||||
}
|
||||
} else {
|
||||
sizeStr = "未知"
|
||||
}
|
||||
|
||||
h.logger.Info("备份创建成功", zap.String("file", backupFile))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "备份创建成功",
|
||||
"data": gin.H{
|
||||
"filename": filepath.Base(backupFile),
|
||||
"path": backupFile,
|
||||
"timestamp": timestamp,
|
||||
"size": sizeStr,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ListBackups 列出所有备份
|
||||
// @Summary 列出所有备份
|
||||
// @Tags System
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/system/backups [get]
|
||||
func (h *BackupHandler) ListBackups(c *gin.Context) {
|
||||
// 验证管理员权限
|
||||
if !h.isAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
return
|
||||
}
|
||||
|
||||
backupDir := filepath.Join("data", "backups")
|
||||
|
||||
// 检查备份目录是否存在
|
||||
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": []interface{}{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 读取备份文件列表
|
||||
files, err := os.ReadDir(backupDir)
|
||||
if err != nil {
|
||||
h.logger.Error("读取备份目录失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "读取备份目录失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 构建备份列表
|
||||
backups := make([]gin.H, 0)
|
||||
for _, file := range files {
|
||||
if file.IsDir() || filepath.Ext(file.Name()) != ".zip" {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(backupDir, file.Name())
|
||||
info, err := file.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
backups = append(backups, gin.H{
|
||||
"filename": file.Name(),
|
||||
"path": filePath,
|
||||
"size": info.Size(),
|
||||
"timestamp": parseTimestampFromFilename(file.Name()),
|
||||
"created_at": info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": backups,
|
||||
})
|
||||
}
|
||||
|
||||
// RestoreBackup 恢复备份
|
||||
// @Summary 恢复备份
|
||||
// @Tags System
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param filename body string true "备份文件名"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/system/restore [post]
|
||||
func (h *BackupHandler) RestoreBackup(c *gin.Context) {
|
||||
// 验证管理员权限
|
||||
if !h.isAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Filename == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "备份文件名不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
backupFile := filepath.Join("data", "backups", req.Filename)
|
||||
|
||||
// 检查备份文件是否存在
|
||||
if _, err := os.Stat(backupFile); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "备份文件不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("开始恢复系统...", zap.String("file", backupFile))
|
||||
|
||||
// TODO: 实现真实的恢复逻辑
|
||||
// 1. 解压备份文件
|
||||
// 2. 恢复数据库数据
|
||||
// 3. 恢复配置文件
|
||||
// 4. 重启服务使配置生效
|
||||
|
||||
h.logger.Info("系统恢复成功")
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "系统恢复成功,请重启服务使配置生效",
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteBackup 删除备份
|
||||
// @Summary 删除备份
|
||||
// @Tags System
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param filename body string true "备份文件名"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/system/backup [delete]
|
||||
func (h *BackupHandler) DeleteBackup(c *gin.Context) {
|
||||
// 验证管理员权限
|
||||
if !h.isAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Filename == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "备份文件名不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
backupFile := filepath.Join("data", "backups", req.Filename)
|
||||
|
||||
// 检查备份文件是否存在
|
||||
if _, err := os.Stat(backupFile); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "备份文件不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 删除备份文件
|
||||
if err := os.Remove(backupFile); err != nil {
|
||||
h.logger.Error("删除备份失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "删除备份失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("备份已删除", zap.String("file", backupFile))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "备份已删除",
|
||||
})
|
||||
}
|
||||
|
||||
// DownloadBackup 下载备份文件
|
||||
// @Summary 下载备份文件
|
||||
// @Tags System
|
||||
// @Accept json
|
||||
// @Produce application/zip
|
||||
// @Param filename query string true "备份文件名"
|
||||
// @Success 200 {file} file
|
||||
// @Router /api/v1/system/backup/download [get]
|
||||
func (h *BackupHandler) DownloadBackup(c *gin.Context) {
|
||||
// 验证管理员权限
|
||||
if !h.isAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
return
|
||||
}
|
||||
|
||||
filename := c.Query("filename")
|
||||
if filename == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "备份文件名不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
backupFile := filepath.Join("data", "backups", filename)
|
||||
|
||||
// 检查备份文件是否存在
|
||||
if _, err := os.Stat(backupFile); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "备份文件不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置响应头
|
||||
c.Header("Content-Type", "application/zip")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
// 发送文件
|
||||
c.File(backupFile)
|
||||
}
|
||||
|
||||
// isAdmin 验证是否为管理员
|
||||
func (h *BackupHandler) isAdmin(c *gin.Context) bool {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
var user struct {
|
||||
ID uint
|
||||
Role string
|
||||
}
|
||||
if err := h.db.Table("users").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return user.Role == "admin"
|
||||
}
|
||||
|
||||
// parseTimestampFromFilename 从文件名解析时间戳
|
||||
func parseTimestampFromFilename(filename string) string {
|
||||
// 文件名格式:meshray_backup_20060102_150405.zip
|
||||
if len(filename) < 30 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 提取时间戳部分
|
||||
ts := filename[len("meshray_backup_") : len("meshray_backup_")+17]
|
||||
if len(ts) != 17 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 格式化:20060102_150405 -> 2006-01-02 15:04:05
|
||||
return fmt.Sprintf("%s-%s-%s %s:%s:%s",
|
||||
ts[0:4], ts[4:6], ts[6:8], ts[9:11], ts[11:13], ts[14:16])
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DDNSHandler DDNS 相关处理器
|
||||
type DDNSHandler struct {
|
||||
ipDetection *service.IPDetectionService
|
||||
}
|
||||
|
||||
// NewDDNSHandler 创建 DDNS 处理器(IP 检测用)
|
||||
func NewDDNSHandler() *DDNSHandler {
|
||||
return &DDNSHandler{
|
||||
ipDetection: service.NewIPDetectionService(),
|
||||
}
|
||||
}
|
||||
|
||||
// DetectIP 检测公网 IP 地址
|
||||
// @Summary 检测公网 IP 地址
|
||||
// @Tags DDNS
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param record_type query string false "记录类型 (A|AAAA)" default(A)
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/services/ddns/detect-ip [get]
|
||||
func (h *DDNSHandler) DetectIP(c *gin.Context) {
|
||||
recordType := c.DefaultQuery("record_type", "A")
|
||||
|
||||
if recordType != "A" && recordType != "AAAA" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"message": "不支持的记录类型,仅支持 A 或 AAAA",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ip, err := h.ipDetection.DetectIP(recordType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "检测失败:" + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": gin.H{
|
||||
"ip": ip,
|
||||
},
|
||||
"message": "检测成功",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DDNSStatsHandler DDNS 统计处理器
|
||||
type DDNSStatsHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewDDNSStatsHandler 创建 DDNS 统计处理器
|
||||
func NewDDNSStatsHandler(db *gorm.DB) *DDNSStatsHandler {
|
||||
return &DDNSStatsHandler{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// DDNSServiceInfo DDNS 服务信息(返回给前端)
|
||||
type DDNSServiceInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullDomain string `json:"full_domain"` // 完整域名:subdomain.domain
|
||||
CurrentIP string `json:"current_ip"` // 当前 IP
|
||||
RecordType string `json:"record_type"` // A/AAAA/TXT/CNAME
|
||||
Enabled bool `json:"enabled"` // 是否启用
|
||||
Status string `json:"status"` // active/disabled
|
||||
LastUpdated string `json:"last_updated"` // 最后更新时间
|
||||
}
|
||||
|
||||
// GetDDNSStats 获取 DDNS 统计数据
|
||||
// @Summary 获取 DDNS 服务统计
|
||||
// @Tags DDNS
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/services/ddns/stats [get]
|
||||
func (h *DDNSStatsHandler) GetDDNSStats(c *gin.Context) {
|
||||
// 查询所有 DDNS 全功能模式服务
|
||||
var services []model.Service
|
||||
if err := h.db.Where("type = ? AND config_mode = ?", "DDNS", "fullservice").Find(&services).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 500,
|
||||
"message": "查询 DDNS 服务失败:" + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 统计数据
|
||||
total := len(services)
|
||||
active := 0
|
||||
|
||||
// 构建服务列表
|
||||
serviceList := make([]DDNSServiceInfo, 0, len(services))
|
||||
for _, svc := range services {
|
||||
// 判断是否活跃
|
||||
status := "disabled"
|
||||
if svc.Enabled {
|
||||
status = "active"
|
||||
active++
|
||||
}
|
||||
|
||||
// 构建完整域名
|
||||
fullDomain := ""
|
||||
rootDomain := h.getDDNSDomain(svc.DDNSConfigID)
|
||||
switch svc.RecordType {
|
||||
case "A", "AAAA":
|
||||
if svc.Subdomain != "" && rootDomain != "" {
|
||||
fullDomain = svc.Subdomain + "." + rootDomain
|
||||
}
|
||||
case "TXT":
|
||||
if svc.TXTRecordName != "" && rootDomain != "" {
|
||||
fullDomain = svc.TXTRecordName + "." + rootDomain
|
||||
}
|
||||
case "CNAME":
|
||||
if svc.Subdomain != "" && rootDomain != "" {
|
||||
fullDomain = svc.Subdomain + "." + rootDomain
|
||||
}
|
||||
}
|
||||
|
||||
// 当前 IP
|
||||
currentIP := svc.TargetIP
|
||||
if svc.RecordType == "TXT" {
|
||||
currentIP = "-"
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
lastUpdated := ""
|
||||
if !svc.UpdatedAt.IsZero() {
|
||||
lastUpdated = svc.UpdatedAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
serviceList = append(serviceList, DDNSServiceInfo{
|
||||
ID: svc.ID,
|
||||
Name: svc.Name,
|
||||
FullDomain: fullDomain,
|
||||
CurrentIP: currentIP,
|
||||
RecordType: svc.RecordType,
|
||||
Enabled: svc.Enabled,
|
||||
Status: status,
|
||||
LastUpdated: lastUpdated,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"data": gin.H{
|
||||
"total": total,
|
||||
"active": active,
|
||||
"services": serviceList,
|
||||
},
|
||||
"message": "获取成功",
|
||||
})
|
||||
}
|
||||
|
||||
// getDDNSDomain 获取 DDNS 配置中的根域名(辅助方法)
|
||||
func (h *DDNSStatsHandler) getDDNSDomain(ddnsConfigID string) string {
|
||||
if ddnsConfigID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 从 ExternalService 表查询 DDNS 配置
|
||||
var extService model.ExternalService
|
||||
if err := h.db.Where("id = ?", ddnsConfigID).First(&extService).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 解析 Config JSON 获取 root_domain
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(extService.Config), &config); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if rootDomain, ok := config["root_domain"].(string); ok {
|
||||
return rootDomain
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/model"
|
||||
"git.zkcoi.com/zkcoi/meshray/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// NotificationHandler 通知处理器
|
||||
type NotificationHandler struct {
|
||||
notificationSvc *service.NotificationService
|
||||
logger *zap.Logger
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewNotificationHandler 创建通知处理器
|
||||
func NewNotificationHandler(notificationSvc *service.NotificationService, logger *zap.Logger, db *gorm.DB) *NotificationHandler {
|
||||
return &NotificationHandler{
|
||||
notificationSvc: notificationSvc,
|
||||
logger: logger,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// GetNotifications 获取用户通知列表(未读/已读)
|
||||
// @Summary 获取用户通知列表
|
||||
// @Tags Notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param type query string false "通知类型 (all/alert/system/update)"
|
||||
// @Param unread query bool false "是否仅未读"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/notifications [get]
|
||||
func (h *NotificationHandler) GetNotifications(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
notifType := c.Query("type")
|
||||
unreadOnly := c.Query("unread") == "true"
|
||||
|
||||
query := h.notificationSvc.GetDB().Where("user_id = ?", userID)
|
||||
|
||||
// 按类型筛选
|
||||
if notifType != "" && notifType != "all" {
|
||||
query = query.Where("type = ?", notifType)
|
||||
}
|
||||
|
||||
// 只看未读
|
||||
if unreadOnly {
|
||||
query = query.Where("is_read = ?", false)
|
||||
}
|
||||
|
||||
// 查询最近 100 条通知
|
||||
var notifications []model.Notification
|
||||
query.Order("created_at DESC").Limit(100).Find(¬ifications)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": notifications,
|
||||
})
|
||||
}
|
||||
|
||||
// MarkAsRead 标记通知为已读
|
||||
// @Summary 标记通知为已读
|
||||
// @Tags Notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path uint true "通知 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/notifications/:id/read [post]
|
||||
func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
notifID := c.Param("id")
|
||||
now := time.Now()
|
||||
|
||||
result := h.notificationSvc.GetDB().Model(&model.Notification{}).
|
||||
Where("id = ? AND user_id = ?", notifID, userID).
|
||||
Updates(map[string]interface{}{
|
||||
"is_read": true,
|
||||
"read_at": now,
|
||||
})
|
||||
|
||||
if result.Error != nil || result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "通知不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "已标记为已读",
|
||||
})
|
||||
}
|
||||
|
||||
// MarkAllAsRead 标记所有通知为已读
|
||||
// @Summary 标记所有通知为已读
|
||||
// @Tags Notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/notifications/read-all [post]
|
||||
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
result := h.notificationSvc.GetDB().Model(&model.Notification{}).
|
||||
Where("user_id = ? AND is_read = ?", userID, false).
|
||||
Updates(map[string]interface{}{
|
||||
"is_read": true,
|
||||
"read_at": now,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "已全部标记为已读",
|
||||
"affected": result.RowsAffected,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteNotification 删除通知
|
||||
// @Summary 删除通知
|
||||
// @Tags Notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path uint true "通知 ID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/notifications/:id [delete]
|
||||
func (h *NotificationHandler) DeleteNotification(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
notifID := c.Param("id")
|
||||
|
||||
result := h.notificationSvc.GetDB().Where("id = ? AND user_id = ?", notifID, userID).
|
||||
Delete(&model.Notification{})
|
||||
|
||||
if result.Error != nil || result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "通知不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "通知已删除",
|
||||
})
|
||||
}
|
||||
|
||||
// GetUnreadCount 获取未读通知数
|
||||
// @Summary 获取未读通知数
|
||||
// @Tags Notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/notifications/unread-count [get]
|
||||
func (h *NotificationHandler) GetUnreadCount(c *gin.Context) {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
var count int64
|
||||
h.notificationSvc.GetDB().Model(&model.Notification{}).
|
||||
Where("user_id = ? AND is_read = ?", userID, false).
|
||||
Count(&count)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"count": count,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestSendNotification 测试发送通知(开发用)
|
||||
// @Summary 测试发送通知
|
||||
// @Tags Notifications
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param message body object true "通知内容"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/notifications/test [post]
|
||||
func (h *NotificationHandler) TestSendNotification(c *gin.Context) {
|
||||
var req struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// 从上下文获取用户 ID
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
// 发送测试通知
|
||||
h.notificationSvc.SendSystemNotification(userID.(uint), req.Title, req.Message)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "测试通知已发送",
|
||||
})
|
||||
}
|
||||
|
||||
// ClearExpiredNotifications 清理过期通知(定时任务)
|
||||
func (h *NotificationHandler) ClearExpiredNotifications() {
|
||||
// 启动定期清理任务(每 24 小时清理一次超过 30 天的通知)
|
||||
go func() {
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
h.cleanupOldNotifications()
|
||||
}
|
||||
}()
|
||||
h.logger.Info("清理过期通知完成")
|
||||
}
|
||||
|
||||
// cleanupOldNotifications 清理超过 30 天的通知记录
|
||||
func (h *NotificationHandler) cleanupOldNotifications() {
|
||||
ctx := context.Background()
|
||||
cutoffTime := time.Now().AddDate(0, 0, -30)
|
||||
|
||||
result := h.db.WithContext(ctx).
|
||||
Where("created_at < ?", cutoffTime).
|
||||
Delete(&model.Notification{})
|
||||
|
||||
if result.Error != nil {
|
||||
h.logger.Error("清理过期通知失败", zap.Error(result.Error))
|
||||
} else {
|
||||
h.logger.Info("清理过期通知完成", zap.Int64("deleted", result.RowsAffected))
|
||||
}
|
||||
}
|
||||
|
||||
// StartNotificationCleaner 启动通知清理定时器
|
||||
func (h *NotificationHandler) StartNotificationCleaner(stopCh <-chan struct{}) {
|
||||
ticker := time.NewTicker(24 * time.Hour) // 每天执行一次
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
h.ClearExpiredNotifications()
|
||||
case <-stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UpdateHandler 系统更新处理器
|
||||
type UpdateHandler struct {
|
||||
httpClient *http.Client
|
||||
currentVersion string
|
||||
}
|
||||
|
||||
// NewUpdateHandler 创建系统更新处理器
|
||||
func NewUpdateHandler(currentVersion string) *UpdateHandler {
|
||||
return &UpdateHandler{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
currentVersion: currentVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// GitHubRelease GitHub 发布响应结构
|
||||
type GitHubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
Body string `json:"body"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
HtmlURL string `json:"html_url"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
DownloadURL string `json:"browser_download_url"`
|
||||
Size int64 `json:"size"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
|
||||
// CheckUpdateResponse 检查更新响应
|
||||
type CheckUpdateResponse struct {
|
||||
HasUpdate bool `json:"has_update"`
|
||||
LatestVersion string `json:"latest_version"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
ReleaseNotes string `json:"release_notes"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CheckUpdate 检查更新
|
||||
// @Summary 检查系统更新
|
||||
// @Tags System
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} CheckUpdateResponse
|
||||
// @Router /api/v1/system/update/check [get]
|
||||
func (h *UpdateHandler) CheckUpdate() (*CheckUpdateResponse, error) {
|
||||
// Gitea Releases API
|
||||
repo := "zkcoi/meshray"
|
||||
url := fmt.Sprintf("https://git.zkcoi.com/api/v1/repos/%s/releases/latest", repo)
|
||||
|
||||
resp, err := h.httpClient.Get(url)
|
||||
if err != nil {
|
||||
return &CheckUpdateResponse{
|
||||
HasUpdate: false,
|
||||
CurrentVersion: h.currentVersion,
|
||||
Error: "检查更新失败:" + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return &CheckUpdateResponse{
|
||||
HasUpdate: false,
|
||||
CurrentVersion: h.currentVersion,
|
||||
Error: "读取响应失败:" + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var release GitHubRelease
|
||||
if err := json.Unmarshal(body, &release); err != nil {
|
||||
return &CheckUpdateResponse{
|
||||
HasUpdate: false,
|
||||
CurrentVersion: h.currentVersion,
|
||||
Error: "解析响应失败:" + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 移除版本号前缀的 'v'
|
||||
latestVersion := release.TagName
|
||||
if len(latestVersion) > 0 && latestVersion[0] == 'v' {
|
||||
latestVersion = latestVersion[1:]
|
||||
}
|
||||
|
||||
currentVersion := h.currentVersion
|
||||
if len(currentVersion) > 0 && currentVersion[0] == 'v' {
|
||||
currentVersion = currentVersion[1:]
|
||||
}
|
||||
|
||||
// 比较版本号
|
||||
hasUpdate := compareVersions(latestVersion, currentVersion) > 0
|
||||
|
||||
downloadURL := release.HtmlURL
|
||||
if len(release.Assets) > 0 {
|
||||
// 优先选择 Windows 可执行文件
|
||||
for _, asset := range release.Assets {
|
||||
if asset.Name == "meshray.exe" {
|
||||
downloadURL = asset.DownloadURL
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &CheckUpdateResponse{
|
||||
HasUpdate: hasUpdate,
|
||||
LatestVersion: release.TagName,
|
||||
CurrentVersion: h.currentVersion,
|
||||
ReleaseNotes: release.Body,
|
||||
DownloadURL: downloadURL,
|
||||
PublishedAt: release.PublishedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// compareVersions 比较版本号
|
||||
// 返回:1 (v1 > v2), 0 (v1 == v2), -1 (v1 < v2)
|
||||
func compareVersions(v1, v2 string) int {
|
||||
if v1 == v2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 简单版本号比较(格式:major.minor.patch)
|
||||
parts1 := parseVersion(v1)
|
||||
parts2 := parseVersion(v2)
|
||||
|
||||
for i := 0; i < len(parts1) && i < len(parts2); i++ {
|
||||
if parts1[i] > parts2[i] {
|
||||
return 1
|
||||
} else if parts1[i] < parts2[i] {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
// 如果前面都相同,比较长度
|
||||
if len(parts1) > len(parts2) {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// parseVersion 解析版本号字符串为整数数组
|
||||
func parseVersion(version string) []int {
|
||||
var parts []int
|
||||
current := 0
|
||||
|
||||
for i, ch := range version {
|
||||
if ch == '.' {
|
||||
parts = append(parts, current)
|
||||
current = 0
|
||||
} else if ch >= '0' && ch <= '9' {
|
||||
current = current*10 + int(ch-'0')
|
||||
}
|
||||
|
||||
// 处理最后一个字符
|
||||
if i == len(version)-1 {
|
||||
parts = append(parts, current)
|
||||
}
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
Reference in New Issue
Block a user