From 8aae2b291cc3e6438b1729db332499b1ebfd181b Mon Sep 17 00:00:00 2001 From: murphyyi Date: Mon, 22 Sep 2025 17:33:27 +0800 Subject: [PATCH 1/6] feat(admin): add transfer log tab and polish storage ui --- config.yaml | 6 +- docs/config.new.yaml | 2 + internal/config/transfer_config.go | 24 + internal/database/database.go | 1 + internal/handlers/admin.go | 65 +- internal/handlers/share.go | 15 +- internal/handlers/storage.go | 12 +- internal/models/db/transfer_log.go | 30 + internal/models/models.go | 2 + internal/models/web/admin.go | 20 + internal/repository/manager.go | 2 + internal/repository/transfer_log.go | 77 +++ internal/routes/admin.go | 3 + internal/services/admin/audit.go | 21 + internal/services/admin/config.go | 2 + internal/services/share/file.go | 2 + internal/services/share/logging.go | 78 +++ themes/2025/admin/css/admin-modern.css | 61 ++ themes/2025/admin/css/storage.css | 847 +++++++++++++------------ themes/2025/admin/index.html | 112 +++- themes/2025/admin/js/config-simple.js | 8 +- themes/2025/admin/js/main.js | 25 +- themes/2025/admin/js/storage-simple.js | 78 ++- themes/2025/admin/js/transfer-logs.js | 190 ++++++ 24 files changed, 1226 insertions(+), 457 deletions(-) create mode 100644 internal/models/db/transfer_log.go create mode 100644 internal/repository/transfer_log.go create mode 100644 internal/services/admin/audit.go create mode 100644 internal/services/share/logging.go create mode 100644 themes/2025/admin/js/transfer-logs.js diff --git a/config.yaml b/config.yaml index 508eae0..7806734 100644 --- a/config.yaml +++ b/config.yaml @@ -1,5 +1,5 @@ base: - name: FileCodeBox + name: 站点 description: 开箱即用的文件快传系统 keywords: FileCodeBox, 文件快递柜, 口令传送箱, 匿名口令分享文本, 文件 port: 12345 @@ -18,13 +18,15 @@ transfer: upload: openupload: 1 uploadsize: 10485760 - enablechunk: 0 + enablechunk: 1 chunksize: 2097152 maxsaveseconds: 0 + requirelogin: 1 download: enableconcurrentdownload: 1 maxconcurrentdownloads: 10 downloadtimeout: 300 + requirelogin: 0 storage: type: "" storagepath: "" diff --git a/docs/config.new.yaml b/docs/config.new.yaml index 1ea6c99..4a6e634 100644 --- a/docs/config.new.yaml +++ b/docs/config.new.yaml @@ -17,10 +17,12 @@ transfer: enable_chunk: 1 chunk_size: 2097152 max_save_seconds: 0 + require_login: 0 download: enable_concurrent_download: 1 max_concurrent_downloads: 10 download_timeout: 300 + require_login: 0 user: allow_user_registration: 1 diff --git a/internal/config/transfer_config.go b/internal/config/transfer_config.go index ffdad60..690a4a2 100644 --- a/internal/config/transfer_config.go +++ b/internal/config/transfer_config.go @@ -13,6 +13,7 @@ type UploadConfig struct { EnableChunk int `json:"enable_chunk"` // 是否启用分片上传 0-禁用 1-启用 ChunkSize int64 `json:"chunk_size"` // 分片大小(字节) MaxSaveSeconds int `json:"max_save_seconds"` // 最大保存时间(秒) + RequireLogin int `json:"require_login"` // 上传是否强制登录 0-否 1-是 } // DownloadConfig 下载配置 @@ -20,6 +21,7 @@ type DownloadConfig struct { EnableConcurrentDownload int `json:"enable_concurrent_download"` // 是否启用并发下载 MaxConcurrentDownloads int `json:"max_concurrent_downloads"` // 最大并发下载数 DownloadTimeout int `json:"download_timeout"` // 下载超时时间(秒) + RequireLogin int `json:"require_login"` // 下载是否强制登录 0-否 1-是 } // TransferConfig 文件传输配置(包含上传和下载) @@ -36,6 +38,7 @@ func NewUploadConfig() *UploadConfig { EnableChunk: 0, ChunkSize: 2 * 1024 * 1024, // 2MB MaxSaveSeconds: 0, // 0表示不限制 + RequireLogin: 0, } } @@ -45,6 +48,7 @@ func NewDownloadConfig() *DownloadConfig { EnableConcurrentDownload: 1, // 默认启用 MaxConcurrentDownloads: 10, // 最大10个并发 DownloadTimeout: 300, // 5分钟超时 + RequireLogin: 0, } } @@ -86,6 +90,10 @@ func (uc *UploadConfig) Validate() error { errors = append(errors, "最大保存时间不能为负数") } + if uc.RequireLogin != 0 && uc.RequireLogin != 1 { + errors = append(errors, "上传登录开关只能是0或1") + } + if len(errors) > 0 { return fmt.Errorf("上传配置验证失败: %s", strings.Join(errors, "; ")) } @@ -113,6 +121,10 @@ func (dc *DownloadConfig) Validate() error { errors = append(errors, "下载超时时间不能超过1小时") } + if dc.RequireLogin != 0 && dc.RequireLogin != 1 { + errors = append(errors, "下载登录开关只能是0或1") + } + if len(errors) > 0 { return fmt.Errorf("下载配置验证失败: %s", strings.Join(errors, "; ")) } @@ -138,6 +150,11 @@ func (uc *UploadConfig) IsChunkEnabled() bool { return uc.EnableChunk == 1 } +// IsLoginRequired 判断是否需要登录才能上传 +func (uc *UploadConfig) IsLoginRequired() bool { + return uc.RequireLogin == 1 +} + // GetUploadSizeMB 获取上传大小限制(MB) func (uc *UploadConfig) GetUploadSizeMB() float64 { return float64(uc.UploadSize) / (1024 * 1024) @@ -161,6 +178,11 @@ func (dc *DownloadConfig) IsDownloadConcurrentEnabled() bool { return dc.EnableConcurrentDownload == 1 } +// IsLoginRequired 判断是否需要登录才能下载 +func (dc *DownloadConfig) IsLoginRequired() bool { + return dc.RequireLogin == 1 +} + // GetDownloadTimeoutMinutes 获取下载超时时间(分钟) func (dc *DownloadConfig) GetDownloadTimeoutMinutes() float64 { return float64(dc.DownloadTimeout) / 60 @@ -174,6 +196,7 @@ func (uc *UploadConfig) Clone() *UploadConfig { EnableChunk: uc.EnableChunk, ChunkSize: uc.ChunkSize, MaxSaveSeconds: uc.MaxSaveSeconds, + RequireLogin: uc.RequireLogin, } } @@ -183,6 +206,7 @@ func (dc *DownloadConfig) Clone() *DownloadConfig { EnableConcurrentDownload: dc.EnableConcurrentDownload, MaxConcurrentDownloads: dc.MaxConcurrentDownloads, DownloadTimeout: dc.DownloadTimeout, + RequireLogin: dc.RequireLogin, } } diff --git a/internal/database/database.go b/internal/database/database.go index b7f95e4..3dc7c8e 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -45,6 +45,7 @@ func InitWithManager(manager *config.ConfigManager) (*gorm.DB, error) { &models.UploadChunk{}, &models.User{}, &models.UserSession{}, + &models.TransferLog{}, ) if err != nil { return nil, fmt.Errorf("数据库自动迁移失败: %w", err) diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go index 21ecb21..9a94809 100644 --- a/internal/handlers/admin.go +++ b/internal/handlers/admin.go @@ -5,6 +5,7 @@ import ( "log" "net" "strconv" + "strings" "time" "github.com/zy84338719/filecodebox/internal/common" @@ -1164,7 +1165,69 @@ func tcpProbe(address string, timeout time.Duration) error { return nil } -// GetSystemLogs 获取系统日志 +// GetTransferLogs 获取上传/下载审计日志 +func (h *AdminHandler) GetTransferLogs(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } else if pageSize > 200 { + pageSize = 200 + } + + operation := strings.TrimSpace(c.DefaultQuery("operation", "")) + search := strings.TrimSpace(c.DefaultQuery("search", "")) + + logs, total, err := h.service.GetTransferLogs(page, pageSize, operation, search) + if err != nil { + common.InternalServerErrorResponse(c, "获取传输日志失败: "+err.Error()) + return + } + + items := make([]web.TransferLogItem, 0, len(logs)) + for _, record := range logs { + item := web.TransferLogItem{ + ID: record.ID, + Operation: record.Operation, + FileCode: record.FileCode, + FileName: record.FileName, + FileSize: record.FileSize, + Username: record.Username, + IP: record.IP, + DurationMs: record.DurationMs, + CreatedAt: record.CreatedAt.Format(time.RFC3339), + } + if record.UserID != nil { + id := *record.UserID + item.UserID = &id + } + items = append(items, item) + } + + pages := int64(0) + if pageSize > 0 { + pages = (total + int64(pageSize) - 1) / int64(pageSize) + } + if pages == 0 { + pages = 1 + } + + response := web.TransferLogListResponse{ + Logs: items, + Pagination: web.PaginationResponse{ + Page: page, + PageSize: pageSize, + Total: total, + Pages: pages, + }, + } + + common.SuccessResponse(c, response) +} + func (h *AdminHandler) GetSystemLogs(c *gin.Context) { level := c.DefaultQuery("level", "") limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100")) diff --git a/internal/handlers/share.go b/internal/handlers/share.go index 47302ba..2c33583 100644 --- a/internal/handlers/share.go +++ b/internal/handlers/share.go @@ -1,6 +1,8 @@ package handlers import ( + "time" + "github.com/zy84338719/filecodebox/internal/common" "github.com/zy84338719/filecodebox/internal/models" "github.com/zy84338719/filecodebox/internal/models/web" @@ -107,15 +109,18 @@ func (h *ShareHandler) ShareFile(c *gin.Context) { return } + userID := utils.GetUserIDFromContext(c) + if h.service.IsUploadLoginRequired() && userID == nil { + common.UnauthorizedResponse(c, "当前配置要求登录后才能上传文件") + return + } + // 解析文件 file, success := utils.ParseFileFromForm(c, "file") if !success { return } - // 检查是否为认证用户上传 - userID := utils.GetUserIDFromContext(c) - // 构建服务层请求(这里需要适配服务层的接口) serviceReq := models.ShareFileRequest{ File: file, @@ -255,6 +260,7 @@ func (h *ShareHandler) DownloadFile(c *gin.Context) { if fileCode.Text != "" { common.SuccessResponse(c, fileCode.Text) + h.service.RecordDownloadLog(fileCode, userID, c.ClientIP(), 0) return } @@ -266,10 +272,13 @@ func (h *ShareHandler) DownloadFile(c *gin.Context) { return } + start := time.Now() if err := storageService.GetFileResponse(c, fileCode); err != nil { common.NotFoundResponse(c, "文件下载失败: "+err.Error()) return } + + h.service.RecordDownloadLog(fileCode, userID, c.ClientIP(), time.Since(start)) } // getDisplayFileName 获取用于显示的文件名 diff --git a/internal/handlers/storage.go b/internal/handlers/storage.go index c3b79d9..8026242 100644 --- a/internal/handlers/storage.go +++ b/internal/handlers/storage.go @@ -49,12 +49,16 @@ func (sh *StorageHandler) GetStorageInfo(c *gin.Context) { // 尝试附加路径与使用率信息 switch storageType { case "local": - // 本地存储使用配置中的 StoragePath - detail.StoragePath = sh.storageConfig.StoragePath + // 本地存储使用配置中的 StoragePath,如果未配置则回退到数据目录 + path := sh.storageConfig.StoragePath + if path == "" { + path = sh.configManager.Base.DataPath + } + detail.StoragePath = path // 尝试读取磁盘使用率(若可用) - if sh.storageConfig.StoragePath != "" { - if usagePercent, err := utils.GetUsagePercent(sh.storageConfig.StoragePath); err == nil { + if path != "" { + if usagePercent, err := utils.GetUsagePercent(path); err == nil { val := int(usagePercent) detail.UsagePercent = &val } diff --git a/internal/models/db/transfer_log.go b/internal/models/db/transfer_log.go new file mode 100644 index 0000000..901bb26 --- /dev/null +++ b/internal/models/db/transfer_log.go @@ -0,0 +1,30 @@ +package db + +import "gorm.io/gorm" + +// TransferLog 记录上传/下载操作日志 +// 当系统要求登录上传/下载时,用于追踪用户行为 +// Operation: upload 或 download +// DurationMs: 针对下载记录耗时,上传默认为0 +// Username保留冗余信息,便于查询,即使用户被删除仍保留原始名字 +type TransferLog struct { + gorm.Model + Operation string `gorm:"size:20;index" json:"operation"` + FileCodeID uint `gorm:"index" json:"file_code_id"` + FileCode string `gorm:"size:255" json:"file_code"` + FileName string `gorm:"size:255" json:"file_name"` + FileSize int64 `json:"file_size"` + UserID *uint `gorm:"index" json:"user_id"` + Username string `gorm:"size:100" json:"username"` + IP string `gorm:"size:45" json:"ip"` + DurationMs int64 `json:"duration_ms"` +} + +// TransferLogQuery 查询条件 +type TransferLogQuery struct { + Operation string `json:"operation"` + UserID *uint `json:"user_id"` + Search string `json:"search"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} diff --git a/internal/models/models.go b/internal/models/models.go index c215d12..527cd70 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -14,6 +14,8 @@ type ( UploadChunk = db.UploadChunk User = db.User UserSession = db.UserSession + TransferLog = db.TransferLog + TransferLogQuery = db.TransferLogQuery // 服务模型别名 BuildInfo = service.BuildInfo diff --git a/internal/models/web/admin.go b/internal/models/web/admin.go index 76a7f7d..1c2492b 100644 --- a/internal/models/web/admin.go +++ b/internal/models/web/admin.go @@ -251,6 +251,26 @@ type AdminFileDetail struct { UploadType string `json:"upload_type"` } +// TransferLogItem 审计日志单条记录 +type TransferLogItem struct { + ID uint `json:"id"` + Operation string `json:"operation"` + FileCode string `json:"file_code"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + UserID *uint `json:"user_id,omitempty"` + Username string `json:"username"` + IP string `json:"ip"` + DurationMs int64 `json:"duration_ms"` + CreatedAt string `json:"created_at"` +} + +// TransferLogListResponse 审计日志列表响应 +type TransferLogListResponse struct { + Logs []TransferLogItem `json:"logs"` + Pagination PaginationResponse `json:"pagination"` +} + // MCPStatusResponse MCP状态响应 type MCPStatusResponse struct { Status string `json:"status"` diff --git a/internal/repository/manager.go b/internal/repository/manager.go index 33180d0..d8a1e7f 100644 --- a/internal/repository/manager.go +++ b/internal/repository/manager.go @@ -12,6 +12,7 @@ type RepositoryManager struct { Chunk *ChunkDAO UserSession *UserSessionDAO Upload *ChunkDAO + TransferLog *TransferLogDAO } // NewRepositoryManager 创建新的数据访问管理器 @@ -23,6 +24,7 @@ func NewRepositoryManager(db *gorm.DB) *RepositoryManager { Chunk: NewChunkDAO(db), UserSession: NewUserSessionDAO(db), Upload: NewChunkDAO(db), // 别名 + TransferLog: NewTransferLogDAO(db), } } diff --git a/internal/repository/transfer_log.go b/internal/repository/transfer_log.go new file mode 100644 index 0000000..78317d4 --- /dev/null +++ b/internal/repository/transfer_log.go @@ -0,0 +1,77 @@ +package repository + +import ( + "github.com/zy84338719/filecodebox/internal/models" + "github.com/zy84338719/filecodebox/internal/models/db" + "gorm.io/gorm" +) + +// TransferLogDAO 负责上传/下载日志的持久化 +// 目前仅提供简单的写入接口,便于后续扩展查询统计等功能 + +type TransferLogDAO struct { + db *gorm.DB +} + +func NewTransferLogDAO(db *gorm.DB) *TransferLogDAO { + return &TransferLogDAO{db: db} +} + +func (dao *TransferLogDAO) Create(log *models.TransferLog) error { + return dao.db.Create(log).Error +} + +func (dao *TransferLogDAO) WithDB(db *gorm.DB) *TransferLogDAO { + return &TransferLogDAO{db: db} +} + +// List 返回传输日志,支持基本筛选和分页 +func (dao *TransferLogDAO) List(query db.TransferLogQuery) ([]models.TransferLog, int64, error) { + if dao.db == nil { + return nil, 0, gorm.ErrInvalidDB + } + + page := query.Page + if page < 1 { + page = 1 + } + + pageSize := query.PageSize + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 200 { + pageSize = 200 + } + + dbQuery := dao.db.Model(&models.TransferLog{}) + + if query.Operation != "" { + dbQuery = dbQuery.Where("operation = ?", query.Operation) + } + + if query.UserID != nil { + dbQuery = dbQuery.Where("user_id = ?", *query.UserID) + } + + if query.Search != "" { + like := "%" + query.Search + "%" + dbQuery = dbQuery.Where( + "file_code LIKE ? OR file_name LIKE ? OR username LIKE ? OR ip LIKE ?", + like, like, like, like, + ) + } + + var total int64 + if err := dbQuery.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + var logs []models.TransferLog + if err := dbQuery.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil { + return nil, 0, err + } + + return logs, total, nil +} diff --git a/internal/routes/admin.go b/internal/routes/admin.go index 9bf90ee..a50401b 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -118,6 +118,9 @@ func SetupAdminRoutes( // 系统维护 setupMaintenanceRoutes(authGroup, adminHandler) + // 传输日志 + authGroup.GET("/logs/transfer", adminHandler.GetTransferLogs) + // 用户管理 setupUserRoutes(authGroup, adminHandler) diff --git a/internal/services/admin/audit.go b/internal/services/admin/audit.go new file mode 100644 index 0000000..5f538c1 --- /dev/null +++ b/internal/services/admin/audit.go @@ -0,0 +1,21 @@ +package admin + +import ( + "errors" + + "github.com/zy84338719/filecodebox/internal/models" +) + +// GetTransferLogs 返回传输日志列表 +func (s *Service) GetTransferLogs(page, pageSize int, operation, search string) ([]models.TransferLog, int64, error) { + if s.repositoryManager == nil || s.repositoryManager.TransferLog == nil { + return nil, 0, errors.New("传输日志存储未初始化") + } + query := models.TransferLogQuery{ + Page: page, + PageSize: pageSize, + Operation: operation, + Search: search, + } + return s.repositoryManager.TransferLog.List(query) +} diff --git a/internal/services/admin/config.go b/internal/services/admin/config.go index a424c98..4576e38 100644 --- a/internal/services/admin/config.go +++ b/internal/services/admin/config.go @@ -89,6 +89,7 @@ func (s *Service) UpdateConfigFromRequest(configRequest *web.AdminConfigRequest) s.manager.Transfer.Upload.EnableChunk = uploadConfig.EnableChunk s.manager.Transfer.Upload.ChunkSize = uploadConfig.ChunkSize s.manager.Transfer.Upload.MaxSaveSeconds = uploadConfig.MaxSaveSeconds + s.manager.Transfer.Upload.RequireLogin = uploadConfig.RequireLogin } if configRequest.Transfer.Download != nil { @@ -96,6 +97,7 @@ func (s *Service) UpdateConfigFromRequest(configRequest *web.AdminConfigRequest) s.manager.Transfer.Download.EnableConcurrentDownload = downloadConfig.EnableConcurrentDownload s.manager.Transfer.Download.MaxConcurrentDownloads = downloadConfig.MaxConcurrentDownloads s.manager.Transfer.Download.DownloadTimeout = downloadConfig.DownloadTimeout + s.manager.Transfer.Download.RequireLogin = downloadConfig.RequireLogin } } diff --git a/internal/services/share/file.go b/internal/services/share/file.go index a1d34b3..53ce636 100644 --- a/internal/services/share/file.go +++ b/internal/services/share/file.go @@ -408,6 +408,8 @@ func (s *Service) ShareFileWithAuth(req models.ShareFileRequest) (*models.ShareF return nil, fmt.Errorf("failed to create file share: %w", err) } + s.RecordUploadLog(fileCode, req.UserID, req.ClientIP) + return &models.ShareFileResult{ Code: fileCode.Code, ShareURL: fmt.Sprintf("/s/%s", fileCode.Code), diff --git a/internal/services/share/logging.go b/internal/services/share/logging.go new file mode 100644 index 0000000..660161c --- /dev/null +++ b/internal/services/share/logging.go @@ -0,0 +1,78 @@ +package share + +import ( + "time" + + "github.com/sirupsen/logrus" + "github.com/zy84338719/filecodebox/internal/models" +) + +func (s *Service) IsUploadLoginRequired() bool { + if s == nil || s.manager == nil || s.manager.Transfer == nil || s.manager.Transfer.Upload == nil { + return false + } + return s.manager.Transfer.Upload.IsLoginRequired() +} + +func (s *Service) IsDownloadLoginRequired() bool { + if s == nil || s.manager == nil || s.manager.Transfer == nil || s.manager.Transfer.Download == nil { + return false + } + return s.manager.Transfer.Download.IsLoginRequired() +} + +func (s *Service) recordTransferLog(operation string, fileCode *models.FileCode, userID *uint, ip string, duration time.Duration) { + if s == nil || s.repositoryManager == nil || s.repositoryManager.TransferLog == nil || fileCode == nil { + return + } + + logEntry := &models.TransferLog{ + Operation: operation, + FileCodeID: fileCode.ID, + FileCode: fileCode.Code, + FileName: displayFileName(fileCode), + FileSize: fileCode.Size, + IP: ip, + DurationMs: duration.Milliseconds(), + } + + if userID != nil { + idCopy := *userID + logEntry.UserID = &idCopy + if s.repositoryManager.User != nil { + if user, err := s.repositoryManager.User.GetByID(*userID); err == nil { + logEntry.Username = user.Username + } else if err != nil { + logrus.WithError(err).Warn("recordTransferLog: fetch user failed") + } + } + } + + if err := s.repositoryManager.TransferLog.Create(logEntry); err != nil { + logrus.WithError(err).Warn("recordTransferLog: create log failed") + } +} + +func displayFileName(fileCode *models.FileCode) string { + if fileCode == nil { + return "" + } + if fileCode.UUIDFileName != "" { + return fileCode.UUIDFileName + } + return fileCode.Prefix + fileCode.Suffix +} + +func (s *Service) RecordDownloadLog(fileCode *models.FileCode, userID *uint, ip string, duration time.Duration) { + if !s.IsDownloadLoginRequired() { + return + } + s.recordTransferLog("download", fileCode, userID, ip, duration) +} + +func (s *Service) RecordUploadLog(fileCode *models.FileCode, userID *uint, ip string) { + if !s.IsUploadLoginRequired() { + return + } + s.recordTransferLog("upload", fileCode, userID, ip, 0) +} diff --git a/themes/2025/admin/css/admin-modern.css b/themes/2025/admin/css/admin-modern.css index a17e8c4..2e66ebe 100644 --- a/themes/2025/admin/css/admin-modern.css +++ b/themes/2025/admin/css/admin-modern.css @@ -1186,6 +1186,67 @@ body.admin-modern-body { width: 100%; } +.transfer-log-panel { + display: none; +} + +.transfer-log-container { + display: flex; + flex-direction: column; + gap: 24px; +} + +.transfer-log-controls { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + margin-bottom: 16px; +} + +.transfer-log-controls .form-control { + min-width: 180px; +} + +.transfer-log-control-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.transfer-log-table-wrapper { + overflow-x: auto; + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.12); +} + +.transfer-log-table thead { + background: var(--admin-interactive-bg); +} + +.transfer-log-table tbody tr:hover { + background: rgba(99, 102, 241, 0.08); +} + +.transfer-log-table .empty-cell { + text-align: center; + padding: 24px 16px; + color: var(--admin-muted); +} + +.transfer-log-pagination { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.transfer-log-pagination-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + .admin-modern-body .quick-action-buttons { display: flex; justify-content: center; diff --git a/themes/2025/admin/css/storage.css b/themes/2025/admin/css/storage.css index 94821ac..0933206 100644 --- a/themes/2025/admin/css/storage.css +++ b/themes/2025/admin/css/storage.css @@ -1,489 +1,599 @@ -/* 存储管理页面专用样式 */ +/* Storage Management - refreshed visuals aligned with admin theme */ -/* 存储容器 */ .storage-container { - max-width: 1200px; - margin: 0 auto; + max-width: 1180px; + margin: 0 auto 32px; + display: flex; + flex-direction: column; + gap: 28px; +} + +/* Current storage summary ------------------------------------------------- */ + +.current-storage-card { + position: relative; + padding: 28px; + border-radius: 24px; + background: linear-gradient(180deg, rgba(99, 102, 241, 0.09) 0%, rgba(15, 23, 42, 0.02) 100%); + border: 1px solid var(--admin-panel-border); + box-shadow: var(--admin-panel-shadow); + overflow: hidden; +} + +.current-storage-card::before { + content: ''; + position: absolute; + inset: -50% -35% 40% 55%; + background: radial-gradient(circle at top right, rgba(129, 140, 248, 0.25) 0%, transparent 74%); + opacity: 0.65; + pointer-events: none; +} + +.current-storage-overview { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 20px; +} + +.current-storage-label { + display: flex; + align-items: center; + gap: 16px; +} + +.current-storage-icon { + width: 52px; + height: 52px; + border-radius: 16px; + background: rgba(99, 102, 241, 0.22); + color: var(--admin-accent-strong); + display: grid; + place-items: center; + font-size: 20px; + box-shadow: 0 12px 28px rgba(99, 102, 241, 0.18); +} + +.current-storage-title h4 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--admin-heading); +} + +.current-storage-title p { + margin: 6px 0 0; + color: var(--admin-muted); + font-size: 13px; +} + +.current-storage-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-radius: 999px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + background: var(--admin-ghost-bg); + color: var(--admin-heading); +} + +.current-storage-chip i { + font-size: 13px; +} + +.current-storage-chip.chip-success { + background: rgba(74, 222, 128, 0.14); + color: #4ade80; +} + +.current-storage-chip.chip-error { + background: rgba(248, 113, 113, 0.18); + color: #f87171; +} + +.current-storage-grid { + position: relative; + z-index: 1; + display: grid; + gap: 14px; +} + +.current-storage-item { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 12px 16px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.4); + border: 1px solid rgba(148, 163, 184, 0.22); + backdrop-filter: blur(8px); + font-size: 14px; +} + +.current-storage-item .item-label { + color: var(--admin-muted); +} + +.current-storage-item .item-value { + color: var(--admin-heading); + font-weight: 500; + text-align: right; +} + +.current-storage-item .item-value.item-path { + max-width: 65%; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.current-storage-item .item-value.status-ok { + color: #22c55e; + font-weight: 600; +} + +.current-storage-item .item-value.status-error { + color: #f87171; + font-weight: 600; +} + +.current-storage-item .item-value.status-info { + color: var(--admin-accent); +} + +.current-storage-item.current-storage-alert { + border-color: rgba(248, 113, 113, 0.28); + background: rgba(248, 113, 113, 0.12); + color: #f87171; + font-weight: 500; +} + +.current-storage-item.current-storage-alert .item-label { + color: currentColor; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 8px; } -/* 存储卡片网格 */ +/* Storage cards ----------------------------------------------------------- */ + .storage-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 20px; - margin-top: 20px; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 22px; } -/* 存储卡片基础样式 */ .storage-card { - background: white; - border: 2px solid #e9ecef; - border-radius: 12px; - padding: 20px; - cursor: pointer; - transition: all 0.3s ease; position: relative; + padding: 24px; + border-radius: 20px; + background: var(--admin-panel-bg); + border: 1px solid var(--admin-panel-border); + box-shadow: var(--admin-shadow-card); + transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease; + cursor: pointer; overflow: hidden; + min-height: 180px; +} + +.storage-card::before { + content: ''; + position: absolute; + inset: -45% -20% 45% 40%; + background: radial-gradient(circle at top right, var(--admin-panel-highlight) 0%, transparent 70%); + opacity: 0.35; + pointer-events: none; + transition: opacity 0.25s ease; } .storage-card:hover { - transform: translateY(-2px); - box-shadow: 0 8px 25px rgba(0,0,0,0.15); - border-color: #007bff; + transform: translateY(-4px); + border-color: var(--admin-accent-soft); + box-shadow: 0 18px 36px rgba(79, 70, 229, 0.22); } -.storage-card.selected { - border-color: #007bff; - background: linear-gradient(135deg, #f8f9ff, #ffffff); - box-shadow: 0 4px 15px rgba(0, 123, 255, 0.1); +.storage-card:hover::before { + opacity: 0.7; } -.storage-card.selected::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 3px; - background: linear-gradient(90deg, #007bff, #0056b3); +.storage-card.selected, +.storage-card.current-storage { + border-color: var(--admin-accent-strong); + box-shadow: 0 20px 42px rgba(99, 102, 241, 0.28); } -/* 存储卡片头部 */ -.storage-card-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 15px; - margin-bottom: 15px; +.storage-card.selected::before, +.storage-card.current-storage::before { + opacity: 0.85; } -/* 存储图标 */ -.storage-icon { - width: 50px; - height: 50px; - border-radius: 12px; - display: flex; - align-items: center; - justify-content: center; - color: white; - font-size: 20px; - flex-shrink: 0; +.storage-card.storage-unavailable { + border-color: rgba(248, 113, 113, 0.25); } -.local-icon { - background: linear-gradient(135deg, #28a745, #20c997); +.storage-card.storage-unavailable .storage-status-badge { + background: rgba(248, 113, 113, 0.18); + color: #f87171; } -.webdav-icon { - background: linear-gradient(135deg, #007bff, #0056b3); +.storage-card.storage-available:not(.current-storage)::after { + content: '可用'; + position: absolute; + top: 18px; + right: 20px; + padding: 4px 10px; + border-radius: 999px; + background: rgba(74, 222, 128, 0.14); + color: #4ade80; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 600; } -.nfs-icon { - background: linear-gradient(135deg, #6f42c1, #563d7c); +.storage-card-header { + display: flex; + gap: 16px; + align-items: flex-start; + position: relative; + z-index: 1; } -.s3-icon { - background: linear-gradient(135deg, #fd7e14, #e85d04); +.storage-icon { + width: 48px; + height: 48px; + border-radius: 16px; + display: grid; + place-items: center; + font-size: 20px; + color: var(--admin-inverse-text); + background: rgba(99, 102, 241, 0.35); + box-shadow: 0 12px 22px rgba(99, 102, 241, 0.25); } -/* 存储信息 */ +.local-icon { background: linear-gradient(135deg, #4ade80, #22c55e); } +.webdav-icon { background: linear-gradient(135deg, #60a5fa, #2563eb); } +.nfs-icon { background: linear-gradient(135deg, #a78bfa, #7c3aed); } +.s3-icon { background: linear-gradient(135deg, #f97316, #ea580c); } + .storage-info { flex: 1; + display: grid; + gap: 6px; } .storage-info h4 { - margin: 0 0 8px 0; + margin: 0; font-size: 16px; font-weight: 600; - color: #333; + color: var(--admin-heading); } .storage-info p { margin: 0; - font-size: 14px; - color: #6c757d; - line-height: 1.4; + color: var(--admin-muted); + font-size: 13px; + line-height: 1.5; } -/* 存储状态 */ -.storage-status { - position: absolute; - top: 15px; - right: 15px; - padding: 4px 8px; - border-radius: 12px; - font-size: 11px; +.storage-status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + font-size: 12px; font-weight: 600; + letter-spacing: 0.08em; text-transform: uppercase; + background: var(--admin-ghost-bg); + color: var(--admin-heading); } -.storage-status.active { - background: #d4edda; - color: #155724; -} - -.storage-status.inactive { - background: #f8d7da; - color: #721c24; +.storage-status-badge i { + font-size: 12px; } -/* 存储配置表单 */ -.storage-config { - margin-top: 20px; - padding: 20px; - background: #f8f9fa; - border-radius: 8px; - border: 1px solid #e9ecef; - display: none; +.storage-status-badge.status-success { + background: rgba(74, 222, 128, 0.12); + color: #4ade80; } -.storage-config.active { - display: block; - animation: fadeIn 0.3s ease; +.storage-status-badge.status-error { + background: rgba(248, 113, 113, 0.18); + color: #f87171; } -.storage-config h5 { - margin: 0 0 15px 0; - color: #495057; - font-size: 14px; - font-weight: 600; +.storage-status-badge.status-current { + background: rgba(250, 204, 21, 0.18); + color: #facc15; } -.config-group { - margin-bottom: 15px; +.storage-card-header .storage-status-badge { + margin-left: auto; } -.config-group:last-child { - margin-bottom: 0; +.storage-error-display { + margin-top: 16px; + padding: 12px 14px; + border-radius: 14px; + background: rgba(248, 113, 113, 0.14); + color: #f87171; + font-size: 13px; + display: none; } -.config-group label { - display: block; - margin-bottom: 5px; +.storage-meta { + margin-top: 18px; + display: grid; + gap: 8px; font-size: 13px; - font-weight: 500; - color: #495057; + color: var(--admin-subtle); } -.config-group input, -.config-group select { - width: 100%; - padding: 8px 12px; - border: 1px solid #ced4da; - border-radius: 4px; - font-size: 13px; - transition: border-color 0.2s ease; +.storage-meta strong { + color: var(--admin-heading); } -.config-group input:focus, -.config-group select:focus { - outline: none; - border-color: #007bff; - box-shadow: 0 0 0 0.2rem rgba(0,123,255,0.25); +/* Configuration panels ---------------------------------------------------- */ + +.storage-config-panel { + display: none; } -.config-help { - font-size: 11px; - color: #6c757d; - margin-top: 4px; - line-height: 1.3; +.storage-config-panel.active, +.storage-config-panel[style*='block'] { + display: block; } -/* 存储操作按钮 */ -.storage-actions { - text-align: center; - margin: 30px 0; - padding: 20px; - background: white; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); +.config-form { + position: relative; + padding: 26px 28px; + border-radius: 20px; + background: var(--admin-surface); + border: 1px solid var(--admin-border); + box-shadow: var(--admin-shadow-card); + display: grid; + gap: 18px; } -.storage-actions .btn { - margin: 0 5px; - min-width: 120px; +.config-form h4 { + margin: 0; + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 16px; + color: var(--admin-heading); } -/* 当前存储信息 */ -.current-storage-info { - background: linear-gradient(135deg, #007bff 0%, #0056b3 100%); - color: white; - padding: 20px; - border-radius: 10px; - text-align: center; - margin-bottom: 20px; - box-shadow: 0 4px 12px rgba(0,123,255,0.3); +.config-form h4 i { + color: var(--admin-accent); } -.current-storage-info h3 { - margin: 0 0 8px 0; - font-size: 18px; +.form-group label { font-weight: 600; + color: var(--admin-heading); + margin-bottom: 6px; + display: block; } -.current-storage-info p { - margin: 0; - opacity: 0.9; - font-size: 14px; +.form-group input, +.form-group select { + width: 100%; + padding: 10px 14px; + border-radius: 12px; + border: 1px solid var(--admin-border); + background: var(--admin-surface-subtle); + color: var(--admin-text); + transition: border-color 0.2s ease, box-shadow 0.2s ease; } -.current-storage-icon { - width: 60px; - height: 60px; - border-radius: 50%; - background: rgba(255,255,255,0.2); +.form-group input:focus, +.form-group select:focus { + border-color: var(--admin-accent); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.18); + outline: none; +} + +.form-text { + color: var(--admin-muted); + font-size: 12px; + line-height: 1.5; +} + +.form-actions { display: flex; - align-items: center; - justify-content: center; - margin: 0 auto 15px; - font-size: 24px; + justify-content: flex-end; + gap: 12px; } -/* 存储统计 */ -.storage-stats { +/* Type selector + helper blocks ------------------------------------------ */ + +.storage-type-selector, +.storage-actions, +.migration-container, +.config-wizard { + background: var(--admin-surface); + border: 1px solid var(--admin-border); + border-radius: 20px; + box-shadow: var(--admin-shadow-card); +} + +.storage-type-selector { display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 15px; - margin-bottom: 20px; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 16px; + padding: 20px; } -.storage-stat-card { - background: white; - border-radius: 8px; +.storage-type-option { + border: 1px solid var(--admin-border); + border-radius: 16px; padding: 16px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); text-align: center; + cursor: pointer; + transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; + background: var(--admin-surface-subtle); } -.storage-stat-value { - font-size: 20px; - font-weight: 700; - color: #333; - margin-bottom: 4px; +.storage-type-option:hover { + transform: translateY(-2px); + border-color: var(--admin-accent); + box-shadow: 0 12px 28px rgba(99, 102, 241, 0.18); } -.storage-stat-label { - font-size: 12px; - color: #6c757d; - text-transform: uppercase; - font-weight: 500; +.storage-type-option.selected { + border-color: var(--admin-accent); + background: rgba(99, 102, 241, 0.14); + box-shadow: 0 16px 36px rgba(99, 102, 241, 0.22); +} + +.storage-actions { + padding: 24px; + display: grid; + gap: 16px; + text-align: center; +} + +.storage-actions .btn { + min-width: 140px; } -/* 存储测试结果 */ .storage-test-result { - margin-top: 15px; - padding: 12px; - border-radius: 6px; + padding: 14px; + border-radius: 14px; font-size: 13px; - line-height: 1.4; + line-height: 1.5; } .storage-test-result.success { - background: #d4edda; - color: #155724; - border: 1px solid #c3e6cb; + background: rgba(74, 222, 128, 0.12); + color: #4ade80; + border: 1px solid rgba(74, 222, 128, 0.35); } .storage-test-result.error { - background: #f8d7da; - color: #721c24; - border: 1px solid #f5c6cb; + background: rgba(248, 113, 113, 0.14); + color: #f87171; + border: 1px solid rgba(248, 113, 113, 0.28); } .storage-test-result.loading { - background: #d1ecf1; - color: #0c5460; - border: 1px solid #bee5eb; + background: rgba(56, 189, 248, 0.18); + color: #38bdf8; + border: 1px solid rgba(14, 165, 233, 0.28); } -/* 存储迁移界面 */ +/* Migration / wizard legacy sections -------------------------------------- */ + .migration-container { - background: white; - border-radius: 8px; - padding: 20px; - margin-top: 20px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + padding: 26px; } .migration-header { text-align: center; - margin-bottom: 20px; - padding-bottom: 15px; - border-bottom: 1px solid #e9ecef; + margin-bottom: 18px; + padding-bottom: 14px; + border-bottom: 1px solid var(--admin-border); + color: var(--admin-heading); } .migration-steps { display: flex; justify-content: space-between; - align-items: center; - margin-bottom: 20px; position: relative; + margin-bottom: 24px; } .migration-steps::before { content: ''; position: absolute; - top: 15px; + top: 18px; left: 30px; right: 30px; height: 2px; - background: #e9ecef; - z-index: 1; + background: var(--admin-border); } .migration-step { - background: white; - border: 2px solid #e9ecef; + width: 36px; + height: 36px; border-radius: 50%; - width: 30px; - height: 30px; - display: flex; - align-items: center; - justify-content: center; + border: 2px solid var(--admin-border); + background: var(--admin-surface); + display: grid; + place-items: center; font-size: 12px; font-weight: 600; + color: var(--admin-heading); position: relative; - z-index: 2; + z-index: 1; } .migration-step.active { - background: #007bff; - border-color: #007bff; - color: white; + border-color: var(--admin-accent); + background: rgba(99, 102, 241, 0.18); + color: var(--admin-accent); } .migration-step.completed { - background: #28a745; - border-color: #28a745; - color: white; + border-color: #4ade80; + background: rgba(74, 222, 128, 0.18); + color: #22c55e; } -.migration-progress { - background: #f8f9fa; - border-radius: 8px; - padding: 15px; - margin-bottom: 15px; +.migration-progress, +.migration-log { + background: var(--admin-surface-subtle); + border: 1px solid var(--admin-border); + border-radius: 14px; + padding: 16px; + color: var(--admin-muted); } .migration-log { - background: #f8f9fa; - border: 1px solid #e9ecef; - border-radius: 4px; - padding: 10px; - max-height: 200px; + max-height: 240px; overflow-y: auto; - font-family: monospace; + font-family: 'Fira Code', 'Courier New', monospace; font-size: 12px; - line-height: 1.4; - color: #495057; } -/* 存储配置向导 */ +/* Wizard */ .config-wizard { - background: white; - border-radius: 8px; overflow: hidden; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .wizard-header { - background: linear-gradient(135deg, #007bff, #0056b3); - color: white; - padding: 20px; + background: linear-gradient(135deg, var(--admin-accent) 0%, var(--admin-accent-strong) 100%); + color: var(--admin-inverse-text); + padding: 22px; text-align: center; } .wizard-body { - padding: 30px; + padding: 26px; + background: var(--admin-surface); } .wizard-step { display: none; - -/* Current storage card - new styles for improved display */ -.current-storage-card { - background: #ffffff; - border: 1px solid #e9ecef; - border-radius: 8px; - padding: 16px; -} - -.current-storage-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; -} - -.storage-type-badge { - display: inline-block; - background: #f1f3f5; - color: #333; - padding: 6px 10px; - border-radius: 12px; - font-size: 12px; - margin-right: 8px; -} - -.storage-status-badge { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - border-radius: 12px; - font-size: 12px; - font-weight: 600; -} - -.storage-status-badge.status-success { - background: #d4edda; - color: #155724; -} - -.storage-status-badge.status-error { - background: #f8d7da; - color: #721c24; -} - -.current-storage-body { - padding-top: 8px; -} - -.storage-path, .storage-usage { - font-size: 13px; - color: #495057; - margin-top: 6px; -} - -.storage-error { - margin-top: 10px; - color: #721c24; - font-weight: 600; -} - -/* storage card meta info */ -.storage-meta { - margin-top: 12px; - font-size: 13px; - color: #6c757d; - display: flex; - justify-content: space-between; - gap: 10px; -} - -.storage-meta .meta-path strong { - color: #333; -} } .wizard-step.active { display: block; - animation: fadeIn 0.3s ease; } .wizard-footer { - padding: 20px 30px; - border-top: 1px solid #e9ecef; + padding: 20px 26px; + border-top: 1px solid var(--admin-border); text-align: right; } @@ -491,104 +601,19 @@ margin-left: 10px; } -/* 存储类型选择器 */ -.storage-type-selector { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 15px; - margin-bottom: 20px; -} - -.storage-type-option { - border: 2px solid #e9ecef; - border-radius: 8px; - padding: 15px; - text-align: center; - cursor: pointer; - transition: all 0.2s ease; -} - -.storage-type-option:hover { - border-color: #007bff; - background: #f8f9ff; -} - -.storage-type-option.selected { - border-color: #007bff; - background: #e6f3ff; -} - -.storage-type-option .icon { - font-size: 24px; - margin-bottom: 8px; -} - -.storage-type-option .name { - font-size: 14px; - font-weight: 600; - color: #333; -} - -/* 响应式设计 */ -@media (max-width: 768px) { - .storage-grid { - grid-template-columns: 1fr; - gap: 15px; - } - - .storage-card { - padding: 15px; - } - - .storage-card-header { +/* Responsive tweaks */ +@media (max-width: 720px) { + .current-storage-header { flex-direction: column; align-items: flex-start; - gap: 10px; - } - - .storage-icon { - width: 40px; - height: 40px; - font-size: 16px; - } - - .storage-stats { - grid-template-columns: repeat(2, 1fr); - gap: 10px; - } - - .migration-steps { - flex-direction: column; - gap: 10px; + gap: 12px; } - - .migration-steps::before { - display: none; - } - - .storage-type-selector { - grid-template-columns: repeat(2, 1fr); - gap: 10px; - } - - .storage-actions { - padding: 15px; - } - - .storage-actions .btn { - display: block; - width: 100%; - margin: 5px 0; - min-width: auto; - } -} -@media (max-width: 480px) { - .storage-stats { + .storage-grid { grid-template-columns: 1fr; } - - .storage-type-selector { - grid-template-columns: 1fr; + + .storage-container { + padding: 0 4px; } -} \ No newline at end of file +} diff --git a/themes/2025/admin/index.html b/themes/2025/admin/index.html index 603bf36..feceba2 100644 --- a/themes/2025/admin/index.html +++ b/themes/2025/admin/index.html @@ -57,6 +57,10 @@ 存储管理 + + + + + +
+ + + + + + + + + + + + + + + + + + +
操作文件代码文件名大小用户IP耗时时间
加载数据中...
+
+ + + + + +
@@ -1075,6 +1128,18 @@

上传限制设置

+ +
+
+
+ + 开启后必须登录才能上传,并记录上传行为 +
+
+
@@ -1116,6 +1181,18 @@

性能设置

+ +
+
+
+ + 开启后只能登录用户下载,并记录下载行为 +
+
+
@@ -1336,6 +1413,22 @@

安全管理

+ +
+
+
+ +
+

传输审计

+
+

审查上传与下载行为,追踪登录记录

+
+ +
+
+
@@ -1452,6 +1545,7 @@ + diff --git a/themes/2025/admin/js/config-simple.js b/themes/2025/admin/js/config-simple.js index 5280260..df00788 100644 --- a/themes/2025/admin/js/config-simple.js +++ b/themes/2025/admin/js/config-simple.js @@ -131,11 +131,13 @@ function fillConfigForm(config) { setFieldValue('max_save_seconds', config.transfer?.upload?.max_save_seconds); setCheckboxValue('open_upload', config.transfer?.upload?.open_upload); setCheckboxValue('enable_chunk', config.transfer?.upload?.enable_chunk); + setCheckboxValue('require_login_upload', config.transfer?.upload?.require_login); // 性能设置 setCheckboxValue('enable_concurrent_download', config.transfer?.download?.enable_concurrent_download); setFieldValue('max_concurrent_downloads', config.transfer?.download?.max_concurrent_downloads); setFieldValue('download_timeout', config.transfer?.download?.download_timeout); + setCheckboxValue('require_login_download', config.transfer?.download?.require_login); if (resolvedOpacity !== undefined && resolvedOpacity !== null) { setFieldValue('opacity', resolvedOpacity); } else { @@ -226,12 +228,14 @@ async function handleConfigSubmit(e) { upload_size: mbToBytes(getFieldValue('upload_size_mb', 'number')), enable_chunk: getCheckboxValue('enable_chunk') ? 1 : 0, chunk_size: mbToBytes(getFieldValue('chunk_size_mb', 'number')), - max_save_seconds: getFieldValue('max_save_seconds', 'number') + max_save_seconds: getFieldValue('max_save_seconds', 'number'), + require_login: getCheckboxValue('require_login_upload') ? 1 : 0 }, download: { enable_concurrent_download: getCheckboxValue('enable_concurrent_download') ? 1 : 0, max_concurrent_downloads: getFieldValue('max_concurrent_downloads', 'number'), - download_timeout: getFieldValue('download_timeout', 'number') + download_timeout: getFieldValue('download_timeout', 'number'), + require_login: getCheckboxValue('require_login_download') ? 1 : 0 } }, user: { diff --git a/themes/2025/admin/js/main.js b/themes/2025/admin/js/main.js index cf0955a..d773214 100644 --- a/themes/2025/admin/js/main.js +++ b/themes/2025/admin/js/main.js @@ -556,14 +556,28 @@ async function apiRequest(url, options = {}) { const response = await fetch(url, finalOptions); console.log('📡 API响应状态:', response.status, response.statusText); - + if (response.status === 401) { console.log('🚫 收到401未授权响应,执行自动登出'); logout(); throw new Error('认证失败'); } - - return response.json(); + + const contentType = response.headers.get('content-type') || ''; + const rawText = await response.text(); + + if (contentType.includes('application/json')) { + try { + return JSON.parse(rawText || '{}'); + } catch (error) { + console.error('JSON解析失败,原始响应:', rawText); + throw new Error('解析服务器响应失败: ' + error.message); + } + } + + // 非JSON响应,抛出更直观的错误 + const message = rawText || `HTTP ${response.status}`; + throw new Error(message); } // ========== 统计数据 ========== @@ -707,6 +721,11 @@ function loadTabData(tabName) { loadStorageInfo(); } break; + case 'transferlogs': + if (typeof initTransferLogsTab === 'function') { + initTransferLogsTab(); + } + break; case 'mcp': // 由 mcp-simple.js 处理 if (typeof loadMCPConfig === 'function') { diff --git a/themes/2025/admin/js/storage-simple.js b/themes/2025/admin/js/storage-simple.js index 1291ae2..7e44779 100644 --- a/themes/2025/admin/js/storage-simple.js +++ b/themes/2025/admin/js/storage-simple.js @@ -91,25 +91,42 @@ function updateCurrentStorageDisplay(data) { const html = `
-
-

当前存储

-
- ${typeNames[currentType] || (currentType || '未配置')} - - - ${available ? '正常' : '异常'} - +
+
+
+
+

当前存储

+

${typeNames[currentType] || (currentType || '未配置')}

+
+ + + ${available ? '正常运行' : '当前异常'} +
-
-
存储路径: ${storagePath || '未配置'}
- ${usage !== null ? `
使用率: ${usage}%
` : ''} +
+
+ 存储类型 + ${typeNames[currentType] || (currentType || '未配置')} +
+
+ 运行状态 + ${available ? '正常' : '异常'} +
+
+ 存储路径 + ${storagePath || '未配置'} +
+ ${usage !== null ? ` +
+ 使用率 + ${usage}% +
` : ''} ${!available && (detail.error || '') ? ` -
- - ${detail.error || '存储连接异常'} -
- ` : ''} +
+ 异常信息 + ${detail.error || '存储连接异常'} +
` : ''}
`; @@ -135,7 +152,8 @@ function updateStorageCards(data) { card.classList.remove('current-storage', 'storage-available', 'storage-unavailable'); // 添加当前状态类 - if (type === currentType) { + const isCurrent = type === currentType; + if (isCurrent) { card.classList.add('current-storage'); } @@ -149,11 +167,27 @@ function updateStorageCards(data) { const statusBadge = card.querySelector('.storage-status-badge'); if (statusBadge) { const available = Boolean(detail.available); - statusBadge.className = `storage-status-badge ${available ? 'status-success' : 'status-error'}`; - statusBadge.innerHTML = ` - - ${available ? '可用' : '不可用'} - `; + if (isCurrent) { + if (available) { + statusBadge.className = 'storage-status-badge status-current'; + statusBadge.innerHTML = ` + + 当前使用 + `; + } else { + statusBadge.className = 'storage-status-badge status-error'; + statusBadge.innerHTML = ` + + 当前异常 + `; + } + } else { + statusBadge.className = `storage-status-badge ${available ? 'status-success' : 'status-error'}`; + statusBadge.innerHTML = ` + + ${available ? '可用' : '不可用'} + `; + } } // 更新错误信息与显示路径/usage diff --git a/themes/2025/admin/js/transfer-logs.js b/themes/2025/admin/js/transfer-logs.js new file mode 100644 index 0000000..c5c356b --- /dev/null +++ b/themes/2025/admin/js/transfer-logs.js @@ -0,0 +1,190 @@ +// 传输日志独立管理页面逻辑 + +(function () { + const state = { + page: 1, + pageSize: 20, + totalPages: 1, + operation: '', + search: '', + initialized: false + }; + + function formatDuration(ms) { + if (ms === undefined || ms === null || ms < 0) { + return '—'; + } + if (ms < 1000) { + return `${ms} ms`; + } + return `${(ms / 1000).toFixed(2)} s`; + } + + function formatDateTime(value) { + if (!value) { + return '—'; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString('zh-CN'); + } + + function ensureBindings() { + if (state.initialized) { + return; + } + const searchInput = document.getElementById('transfer-log-search'); + if (searchInput) { + searchInput.addEventListener('keypress', event => { + if (event.key === 'Enter') { + applyFilters(); + } + }); + } + const operationSelect = document.getElementById('transfer-log-operation'); + if (operationSelect) { + operationSelect.addEventListener('change', () => applyFilters()); + } + state.initialized = true; + } + + async function load(page = 1) { + ensureBindings(); + + state.page = page; + const params = new URLSearchParams(); + params.set('page', page); + params.set('page_size', state.pageSize); + + if (state.operation) { + params.set('operation', state.operation); + } + if (state.search) { + params.set('search', state.search); + } + + try { + const result = await apiRequest(`/admin/logs/transfer?${params.toString()}`); + if (result.code === 200 && result.data) { + render(result.data); + } else { + throw new Error(result.message || '获取传输日志失败'); + } + } catch (error) { + console.error('加载传输日志失败:', error); + const message = (error && error.message) ? error.message.substring(0, 200) : '未知错误'; + showAlert('加载传输日志失败: ' + message, 'error'); + } + } + + function render(data) { + const tbody = document.getElementById('transfer-log-table-body'); + const pagination = document.getElementById('transfer-log-pagination'); + + if (!tbody) { + return; + } + + const logs = Array.isArray(data.logs) ? data.logs : []; + tbody.innerHTML = ''; + + if (!logs.length) { + tbody.innerHTML = '暂无记录'; + } else { + logs.forEach(log => { + const tr = document.createElement('tr'); + const operationLabel = log.operation === 'upload' ? '上传' : (log.operation === 'download' ? '下载' : (log.operation || '—')); + const userLabel = log.username ? log.username : (log.user_id ? `用户 #${log.user_id}` : '匿名'); + const sizeLabel = log.file_size ? formatFileSize(log.file_size) : '—'; + + tr.innerHTML = ` + ${operationLabel} + ${log.file_code || '—'} + ${log.file_name || '—'} + ${sizeLabel} + ${userLabel} + ${log.ip || '—'} + ${formatDuration(log.duration_ms)} + ${formatDateTime(log.created_at)} + `; + tbody.appendChild(tr); + }); + } + + if (!pagination || !data.pagination) { + return; + } + + const { page, pages, total, page_size: pageSize } = data.pagination; + state.page = page || 1; + state.totalPages = pages || 1; + if (pageSize) { + state.pageSize = pageSize; + } + + if (!total) { + pagination.style.display = 'none'; + pagination.innerHTML = ''; + return; + } + + pagination.innerHTML = ''; + pagination.style.display = 'flex'; + + const info = document.createElement('div'); + info.className = 'pagination-info'; + info.textContent = `第 ${state.page} / ${Math.max(state.totalPages, 1)} 页 · 共 ${total} 条记录`; + + const actions = document.createElement('div'); + actions.className = 'transfer-log-pagination-actions'; + + const prevBtn = document.createElement('button'); + prevBtn.className = 'btn btn-secondary'; + prevBtn.innerHTML = ' 上一页'; + prevBtn.disabled = state.page <= 1; + prevBtn.onclick = () => changePage(-1); + + const nextBtn = document.createElement('button'); + nextBtn.className = 'btn btn-secondary'; + nextBtn.innerHTML = '下一页 '; + nextBtn.disabled = state.page >= state.totalPages; + nextBtn.onclick = () => changePage(1); + + actions.appendChild(prevBtn); + actions.appendChild(nextBtn); + + pagination.appendChild(info); + pagination.appendChild(actions); + } + + function applyFilters() { + const operationSelect = document.getElementById('transfer-log-operation'); + const searchInput = document.getElementById('transfer-log-search'); + state.operation = operationSelect ? operationSelect.value : ''; + state.search = searchInput ? searchInput.value.trim() : ''; + load(1); + } + + function refresh() { + load(state.page || 1); + } + + function changePage(delta) { + const next = (state.page || 1) + delta; + if (next < 1 || next > (state.totalPages || 1)) { + return; + } + load(next); + } + + window.initTransferLogsTab = function () { + ensureBindings(); + load(1); + }; + + window.applyTransferLogFilters = applyFilters; + window.refreshTransferLogs = refresh; + window.changeTransferLogPage = changePage; +})(); From 6d6c931007a595b83e4abd41b3aa0c35dd7444f4 Mon Sep 17 00:00:00 2001 From: murphyyi Date: Wed, 24 Sep 2025 08:25:53 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handlers/admin.go | 48 +- internal/handlers/setup.go | 49 +- internal/handlers/storage.go | 22 +- internal/mcp/manager.go | 2 +- internal/mcp/server.go | 3 +- .../services/admin/admin_test_helpers_test.go | 43 ++ internal/services/admin/files.go | 11 +- internal/services/admin/maintenance.go | 568 +++++++++++++++--- internal/services/admin/maintenance_test.go | 107 ++++ internal/services/admin/users.go | 108 +++- internal/services/admin/users_test.go | 104 ++++ internal/services/chunk/upload.go | 6 +- internal/services/services.go | 2 + internal/services/share/file.go | 16 +- internal/services/user/stats_fix.go | 6 +- internal/storage/concrete_service.go | 22 + internal/storage/local_strategy.go | 6 +- internal/storage/nfs_strategy.go | 23 +- internal/storage/s3_strategy.go | 6 +- internal/storage/storage.go | 4 +- internal/storage/webdav_strategy.go | 8 +- internal/tasks/tasks.go | 29 +- internal/utils/disk_unix.go | 13 + internal/utils/disk_windows.go | 46 +- main.go | 2 +- 25 files changed, 1061 insertions(+), 193 deletions(-) create mode 100644 internal/services/admin/admin_test_helpers_test.go create mode 100644 internal/services/admin/maintenance_test.go create mode 100644 internal/services/admin/users_test.go diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go index 9a94809..066d993 100644 --- a/internal/handlers/admin.go +++ b/internal/handlers/admin.go @@ -2,7 +2,6 @@ package handlers import ( "fmt" - "log" "net" "strconv" "strings" @@ -10,12 +9,12 @@ import ( "github.com/zy84338719/filecodebox/internal/common" "github.com/zy84338719/filecodebox/internal/config" - "github.com/zy84338719/filecodebox/internal/models" "github.com/zy84338719/filecodebox/internal/models/web" "github.com/zy84338719/filecodebox/internal/services" "github.com/zy84338719/filecodebox/internal/utils" "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" ) // AdminHandler 管理处理器 @@ -649,43 +648,26 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) { } var userData struct { - Email string `json:"email" binding:"omitempty,email"` - Password string `json:"password"` - Nickname string `json:"nickname"` - IsAdmin bool `json:"is_admin"` - IsActive bool `json:"is_active"` + Email *string `json:"email" binding:"omitempty,email"` + Password *string `json:"password"` + Nickname *string `json:"nickname"` + IsAdmin *bool `json:"is_admin"` + IsActive *bool `json:"is_active"` } if !utils.BindJSONWithValidation(c, &userData) { return } - // 准备参数 - role := "user" - if userData.IsAdmin { - role = "admin" - } - - status := "active" - if !userData.IsActive { - status = "inactive" - } - - // 更新用户:构建 models.User 并调用服务方法 - // 构建更新用的 models.User:只填充仓库 UpdateUserFields 所需字段 - user := models.User{} - // ID will be used by UpdateUserFields via repository; ensure repository method uses provided ID - // NOTE: models.User uses gorm.Model embed; set via zero-value and pass id to repository - user.Email = userData.Email - if userData.Password != "" { - // Hashing handled inside service layer; here we pass raw password in a convention used elsewhere - user.PasswordHash = userData.Password + params := services.AdminUserUpdateParams{ + Email: userData.Email, + Password: userData.Password, + Nickname: userData.Nickname, + IsAdmin: userData.IsAdmin, + IsActive: userData.IsActive, } - user.Nickname = userData.Nickname - user.Role = role - user.Status = status - err := h.service.UpdateUser(user) + err := h.service.UpdateUserWithParams(userID, params) if err != nil { common.InternalServerErrorResponse(c, "更新用户失败: "+err.Error()) return @@ -965,7 +947,7 @@ func (h *AdminHandler) UpdateMCPConfig(c *gin.Context) { } // 保存配置 - err := h.config.Save() + err := h.config.PersistYAML() if err != nil { common.InternalServerErrorResponse(c, "保存MCP配置失败: "+err.Error()) return @@ -1159,7 +1141,7 @@ func tcpProbe(address string, timeout time.Duration) error { } defer func() { if err := conn.Close(); err != nil { - log.Printf("关闭连接失败: %v", err) + logrus.WithError(err).Warn("关闭 TCP 连接失败") } }() return nil diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go index 0ac34c3..ac98d9a 100644 --- a/internal/handlers/setup.go +++ b/internal/handlers/setup.go @@ -3,7 +3,6 @@ package handlers import ( "errors" "fmt" - "log" "os" "path/filepath" "sync/atomic" @@ -17,6 +16,8 @@ import ( "github.com/zy84338719/filecodebox/internal/services/auth" "github.com/zy84338719/filecodebox/internal/utils" "gorm.io/gorm" + + "github.com/sirupsen/logrus" ) type SetupHandler struct { @@ -85,8 +86,8 @@ func (h *SetupHandler) updateDatabaseConfig(db DatabaseConfig) error { h.manager.Database.Name = db.Database } - // 保存配置到文件 - return h.manager.Save() + // 持久化配置到 YAML + return h.manager.PersistYAML() } // createAdminUser 创建管理员用户 @@ -98,7 +99,8 @@ func (h *SetupHandler) createAdminUser(admin AdminConfig) error { // 检查用户名 if _, err := h.daoManager.User.GetByUsername(admin.Username); err == nil { - log.Printf("[createAdminUser] 管理员已存在(用户名):%s,跳过创建", admin.Username) + logrus.WithField("username", admin.Username). + Info("[createAdminUser] 管理员已存在,跳过创建") return nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("查询用户失败: %w", err) @@ -106,7 +108,8 @@ func (h *SetupHandler) createAdminUser(admin AdminConfig) error { // 检查邮箱 if _, err := h.daoManager.User.GetByEmail(admin.Email); err == nil { - log.Printf("[createAdminUser] 管理员已存在(邮箱):%s,跳过创建", admin.Email) + logrus.WithField("email", admin.Email). + Info("[createAdminUser] 管理员已存在,跳过创建") return nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("查询用户失败: %w", err) @@ -142,7 +145,7 @@ func (h *SetupHandler) createAdminUser(admin AdminConfig) error { if err != nil { // 如果是唯一约束冲突(用户已存在),视为成功(幂等行为) if contains(err.Error(), "UNIQUE constraint failed") || contains(err.Error(), "duplicate key value") { - log.Printf("[createAdminUser] 用户已存在,忽略错误: %v", err) + logrus.WithError(err).Warn("[createAdminUser] 用户已存在,忽略错误") return nil } return err @@ -161,8 +164,8 @@ func (h *SetupHandler) enableUserSystem(adminConfig AdminConfig) error { h.manager.User.AllowUserRegistration = 0 } - // 保存配置 - return h.manager.Save() + // 保存配置到 YAML + return h.manager.PersistYAML() } // contains 检查字符串是否包含子字符串 @@ -231,7 +234,7 @@ func InitializeNoDB(manager *config.ConfigManager) gin.HandlerFunc { return } else { if err := f.Close(); err != nil { - log.Printf("warning: failed to close perm check file: %v", err) + logrus.WithError(err).Warn("failed to close permission check file") } _ = os.Remove(testFile) } @@ -291,8 +294,7 @@ func InitializeNoDB(manager *config.ConfigManager) gin.HandlerFunc { manager.Database.Pass = req.Database.Password manager.Database.Name = req.Database.Database } - // 注意:此处不能在数据库未初始化前调用 manager.Save(),因为 Save 仅保存到数据库。 - // 后续将在数据库初始化并注入 manager 后再次保存配置。 + // 配置将在数据库初始化并注入 manager 后持久化到 YAML。 // 初始化数据库连接并执行自动迁移 // Ensure Base config exists @@ -334,10 +336,13 @@ func InitializeNoDB(manager *config.ConfigManager) gin.HandlerFunc { } } - log.Printf("[InitializeNoDB] 开始调用 database.InitWithManager, dbType=%s, dataPath=%s", manager.Database.Type, manager.Base.DataPath) + logrus.WithFields(logrus.Fields{ + "db_type": manager.Database.Type, + "data_path": manager.Base.DataPath, + }).Info("[InitializeNoDB] 开始调用 database.InitWithManager") db, err := database.InitWithManager(manager) if err != nil { - log.Printf("[InitializeNoDB] InitWithManager 失败: %v", err) + logrus.WithError(err).Error("[InitializeNoDB] InitWithManager 失败") common.InternalServerErrorResponse(c, "初始化数据库失败: "+err.Error()) return } @@ -348,7 +353,7 @@ func InitializeNoDB(manager *config.ConfigManager) gin.HandlerFunc { // 诊断检查:确认 manager 内部已设置 db if manager.GetDB() == nil { - log.Printf("[InitializeNoDB] 警告: manager.GetDB() 返回 nil(注入失败)") + logrus.Warn("[InitializeNoDB] 警告: manager.GetDB() 返回 nil(注入失败)") common.InternalServerErrorResponse(c, "初始化失败:配置管理器未能获取数据库连接") return } @@ -359,8 +364,8 @@ func InitializeNoDB(manager *config.ConfigManager) gin.HandlerFunc { // 如果之前捕获了 desiredStoragePath,则此时 manager 已注入 DB,可以持久化 storage_path if desiredStoragePath != "" { manager.Storage.StoragePath = desiredStoragePath - if err := manager.Save(); err != nil { - log.Printf("[InitializeNoDB] 保存 storage_path 失败: %v", err) + if err := manager.PersistYAML(); err != nil { + logrus.WithError(err).Warn("[InitializeNoDB] 持久化 storage_path 失败") // 记录但不阻塞初始化流程 if manager.Base != nil && manager.Base.DataPath != "" { _ = os.WriteFile(manager.Base.DataPath+"/init_save_storage_err.log", []byte(err.Error()), 0644) @@ -373,7 +378,7 @@ func InitializeNoDB(manager *config.ConfigManager) gin.HandlerFunc { // 创建管理员用户(使用 SetupHandler.createAdminUser,包含幂等性处理) setupHandler := NewSetupHandler(daoManager, manager) if err := setupHandler.createAdminUser(req.Admin); err != nil { - log.Printf("[InitializeNoDB] 创建管理员用户失败: %v", err) + logrus.WithError(err).Error("[InitializeNoDB] 创建管理员用户失败") common.InternalServerErrorResponse(c, "创建管理员用户失败: "+err.Error()) return } @@ -384,9 +389,9 @@ func InitializeNoDB(manager *config.ConfigManager) gin.HandlerFunc { } else { manager.User.AllowUserRegistration = 0 } - if err := manager.Save(); err != nil { + if err := manager.PersistYAML(); err != nil { // 不阻塞初始化成功路径,但记录错误 - log.Printf("[InitializeNoDB] manager.Save() 返回错误(但不阻塞初始化): %v", err) + logrus.WithError(err).Warn("[InitializeNoDB] manager.PersistYAML() 返回错误(但不阻塞初始化)") // 将错误写入数据目录下的日志文件以便排查 if manager.Base != nil && manager.Base.DataPath != "" { _ = os.WriteFile(manager.Base.DataPath+"/init_save_err.log", []byte(err.Error()), 0644) @@ -401,7 +406,7 @@ func InitializeNoDB(manager *config.ConfigManager) gin.HandlerFunc { if atomic.CompareAndSwapInt32(&onDBInitCalled, 0, 1) { OnDatabaseInitialized(daoManager) } else { - log.Printf("[InitializeNoDB] OnDatabaseInitialized 已调用,跳过重复挂载") + logrus.Warn("[InitializeNoDB] OnDatabaseInitialized 已调用,跳过重复挂载") } } @@ -455,7 +460,7 @@ func (h *SetupHandler) Initialize(c *gin.Context) { return } - // 更新数据库配置并保存(manager.Save 会将配置写入数据库,因为 manager 已注入 DB) + // 更新数据库配置并保存到 YAML(manager 已注入 DB,但配置以 YAML 为主存储) if err := h.updateDatabaseConfig(req.Database); err != nil { common.InternalServerErrorResponse(c, "保存数据库配置失败: "+err.Error()) return @@ -470,7 +475,7 @@ func (h *SetupHandler) Initialize(c *gin.Context) { // 启用用户系统设置 if err := h.enableUserSystem(req.Admin); err != nil { // 记录但不阻塞主要流程 - log.Printf("enableUserSystem 返回错误: %v", err) + logrus.WithError(err).Warn("enableUserSystem 返回错误") } common.SuccessWithMessage(c, "系统初始化成功", map[string]interface{}{ diff --git a/internal/handlers/storage.go b/internal/handlers/storage.go index 8026242..3e87a6f 100644 --- a/internal/handlers/storage.go +++ b/internal/handlers/storage.go @@ -107,12 +107,12 @@ func (sh *StorageHandler) SwitchStorage(c *gin.Context) { return } - // 更新配置 - sh.storageConfig.Type = req.Type - if err := sh.configManager.Save(); err != nil { - common.InternalServerErrorResponse(c, "保存配置失败: "+err.Error()) - return - } + // 更新配置 + sh.storageConfig.Type = req.Type + if err := sh.configManager.PersistYAML(); err != nil { + common.InternalServerErrorResponse(c, "保存配置失败: "+err.Error()) + return + } common.SuccessResponse(c, web.StorageSwitchResponse{ Success: true, @@ -267,11 +267,11 @@ func (sh *StorageHandler) UpdateStorageConfig(c *gin.Context) { return } - // 保存配置(会同时保存到文件和数据库) - if err := sh.configManager.Save(); err != nil { - common.InternalServerErrorResponse(c, "保存配置失败: "+err.Error()) - return - } + // 持久化最新配置 + if err := sh.configManager.PersistYAML(); err != nil { + common.InternalServerErrorResponse(c, "保存配置失败: "+err.Error()) + return + } common.SuccessWithMessage(c, "存储配置更新成功", nil) } diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index c40ef37..5978d63 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -217,7 +217,7 @@ func (m *MCPManager) testMCPConnection(port string) bool { defer func() { if err := conn.Close(); err != nil { // 在这种测试场景下,关闭连接的错误不是关键问题,只记录一下 - fmt.Printf("Warning: failed to close connection: %v\n", err) + logrus.WithError(err).Warn("failed to close MCP test connection") } }() diff --git a/internal/mcp/server.go b/internal/mcp/server.go index e0dc278..c031b12 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "log" "net" "os" "strings" @@ -429,7 +428,7 @@ func (s *Server) LogMessage(level LogLevel, data interface{}, logger string) { for _, conn := range s.connections { if err := s.sendNotification(conn, "notifications/message", notification); err != nil { - log.Printf("Failed to send log notification: %v", err) + logrus.WithError(err).Warn("failed to send MCP log notification") } } } diff --git a/internal/services/admin/admin_test_helpers_test.go b/internal/services/admin/admin_test_helpers_test.go new file mode 100644 index 0000000..edb8249 --- /dev/null +++ b/internal/services/admin/admin_test_helpers_test.go @@ -0,0 +1,43 @@ +package admin_test + +import ( + "path/filepath" + "testing" + + "github.com/zy84338719/filecodebox/internal/config" + "github.com/zy84338719/filecodebox/internal/models" + "github.com/zy84338719/filecodebox/internal/repository" + admin "github.com/zy84338719/filecodebox/internal/services/admin" + "github.com/zy84338719/filecodebox/internal/storage" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupAdminTestService(t *testing.T) (*admin.Service, *repository.RepositoryManager, *config.ConfigManager) { + t.Helper() + + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "test.db") + + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open test database: %v", err) + } + + if err := db.AutoMigrate(&models.User{}, &models.FileCode{}, &models.UploadChunk{}, &models.TransferLog{}); err != nil { + t.Fatalf("failed to auto-migrate test database: %v", err) + } + + repo := repository.NewRepositoryManager(db) + + manager := config.NewConfigManager() + manager.Base.DataPath = tempDir + manager.Storage.Type = "local" + manager.Storage.StoragePath = tempDir + manager.SetDB(db) + + storageService := storage.NewConcreteStorageService(manager) + + svc := admin.NewService(repo, manager, storageService) + return svc, repo, manager +} diff --git a/internal/services/admin/files.go b/internal/services/admin/files.go index 90e0cc5..d359658 100644 --- a/internal/services/admin/files.go +++ b/internal/services/admin/files.go @@ -1,11 +1,12 @@ package admin import ( - "fmt" "time" "github.com/gin-gonic/gin" "github.com/zy84338719/filecodebox/internal/models" + + "github.com/sirupsen/logrus" ) // GetFiles 获取文件列表 @@ -34,7 +35,9 @@ func (s *Service) DeleteFile(id uint) error { result := s.storageService.DeleteFileWithResult(fileCode) if !result.Success { // 记录错误,但不阻止数据库删除 - fmt.Printf("Warning: Failed to delete physical file: %v\n", result.Error) + logrus.WithError(result.Error). + WithField("code", fileCode.Code). + Warn("failed to delete physical file while removing file record") } return s.repositoryManager.FileCode.DeleteByFileCode(fileCode) @@ -51,7 +54,9 @@ func (s *Service) DeleteFileByCode(code string) error { result := s.storageService.DeleteFileWithResult(fileCode) if !result.Success { // 记录错误,但不阻止数据库删除 - fmt.Printf("Warning: Failed to delete physical file: %v\n", result.Error) + logrus.WithError(result.Error). + WithField("code", fileCode.Code). + Warn("failed to delete physical file while removing file record") } return s.repositoryManager.FileCode.DeleteByFileCode(fileCode) diff --git a/internal/services/admin/maintenance.go b/internal/services/admin/maintenance.go index 6af652d..255488f 100644 --- a/internal/services/admin/maintenance.go +++ b/internal/services/admin/maintenance.go @@ -1,13 +1,23 @@ package admin import ( + "bufio" + "errors" "fmt" + "io" "os" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" "time" "github.com/zy84338719/filecodebox/internal/models" "github.com/zy84338719/filecodebox/internal/models/service" "github.com/zy84338719/filecodebox/internal/utils" + + "github.com/sirupsen/logrus" ) // CleanupExpiredFiles 清理过期文件 @@ -20,15 +30,20 @@ func (s *Service) CleanupExpiredFiles() (int, error) { count := 0 for _, file := range expiredFiles { + file := file // 删除实际文件 result := s.storageService.DeleteFileWithResult(&file) if !result.Success { - fmt.Printf("Warning: Failed to delete physical file: %v\n", result.Error) + logrus.WithError(result.Error). + WithField("code", file.Code). + Warn("failed to delete expired file from storage") } // 删除数据库记录 if err := s.repositoryManager.FileCode.DeleteByFileCode(&file); err != nil { - fmt.Printf("Warning: Failed to delete file record: %v\n", err) + logrus.WithError(err). + WithField("code", file.Code). + Warn("failed to delete expired file record") } else { count++ } @@ -39,38 +54,58 @@ func (s *Service) CleanupExpiredFiles() (int, error) { // CleanupInvalidFiles 清理无效文件(数据库有记录但文件不存在) func (s *Service) CleanupInvalidFiles() (int, error) { - // 清理没有对应物理文件的数据库记录 - count := 0 - - // 获取所有文件记录(分页获取以避免内存问题) + cleaned := 0 page := 1 - pageSize := 100 + const pageSize = 200 for { files, total, err := s.repositoryManager.FileCode.List(page, pageSize, "") if err != nil { - return count, err + return cleaned, err + } + + if len(files) == 0 { + break } - // 处理当前页的文件 - for _, file := range files { - // 对于非文本文件,简单检查文件路径是否为空 - if file.Text == "" && file.GetFilePath() == "" { - // 删除无效记录 - if err := s.repositoryManager.FileCode.DeleteByFileCode(&file); err == nil { - count++ + for idx := range files { + file := files[idx] + if file.Text != "" { + continue + } + + filePath := file.GetFilePath() + if strings.TrimSpace(filePath) == "" { + if err := s.repositoryManager.FileCode.DeleteByFileCode(&file); err != nil { + logrus.WithError(err). + WithField("code", file.Code). + Warn("failed to delete file record with empty path") + continue } + cleaned++ + continue + } + + if s.storageService.FileExists(&file) { + continue + } + + if err := s.repositoryManager.FileCode.DeleteByFileCode(&file); err != nil { + logrus.WithError(err). + WithField("code", file.Code). + Warn("failed to delete orphan file record") + continue } + cleaned++ } - // 检查是否还有更多页 if int64(page*pageSize) >= total { break } page++ } - return count, nil + return cleaned, nil } // CleanupOrphanedFiles 清理孤儿文件(文件存在但数据库无记录) @@ -82,17 +117,80 @@ func (s *Service) CleanupOrphanedFiles() (int, error) { // CleanTempFiles 清理临时文件 func (s *Service) CleanTempFiles() (int, error) { - // 这里可以实现清理临时文件的逻辑 - // 比如清理上传过程中产生的临时文件 - count := 0 - // TODO: 实现具体的临时文件清理逻辑 - return count, nil + cutoff := time.Now().Add(-24 * time.Hour) + oldChunks, err := s.repositoryManager.Chunk.GetOldChunks(cutoff) + if err != nil { + return 0, err + } + + if len(oldChunks) == 0 { + return 0, nil + } + + uploadIDSet := make(map[string]struct{}) + for _, chunk := range oldChunks { + uploadID := chunk.UploadID + if strings.TrimSpace(uploadID) == "" { + continue + } + if _, exists := uploadIDSet[uploadID]; exists { + continue + } + uploadIDSet[uploadID] = struct{}{} + + result := s.storageService.CleanChunksWithResult(uploadID) + if !result.Success { + logrus.WithError(result.Error). + WithField("upload_id", uploadID). + Warn("failed to clean temporary upload chunks") + } + } + + uploadIDs := make([]string, 0, len(uploadIDSet)) + for uploadID := range uploadIDSet { + uploadIDs = append(uploadIDs, uploadID) + } + sort.Strings(uploadIDs) + + cleaned, err := s.repositoryManager.Chunk.DeleteChunksByUploadIDs(uploadIDs) + if err != nil { + return cleaned, err + } + + return cleaned, nil } // OptimizeDatabase 优化数据库 func (s *Service) OptimizeDatabase() error { - // 简单的数据库优化操作 - 可以扩展更复杂的逻辑 - // 比如重建索引、清理碎片等 + db := s.repositoryManager.DB() + if db == nil { + return errors.New("数据库连接不可用") + } + + switch strings.ToLower(s.manager.Database.Type) { + case "sqlite": + if err := db.Exec("VACUUM").Error; err != nil { + return fmt.Errorf("执行 VACUUM 失败: %w", err) + } + if err := db.Exec("ANALYZE").Error; err != nil { + return fmt.Errorf("执行 ANALYZE 失败: %w", err) + } + case "mysql": + tables := []string{"file_codes", "upload_chunks", "users", "user_sessions", "transfer_logs"} + for _, table := range tables { + stmt := fmt.Sprintf("ANALYZE TABLE %s", table) + if err := db.Exec(stmt).Error; err != nil { + return fmt.Errorf("分析表 %s 失败: %w", table, err) + } + } + case "postgres", "postgresql": + if err := db.Exec("VACUUM ANALYZE").Error; err != nil { + return fmt.Errorf("执行 VACUUM ANALYZE 失败: %w", err) + } + default: + return fmt.Errorf("不支持的数据库类型: %s", s.manager.Database.Type) + } + return nil } @@ -119,30 +217,50 @@ func (s *Service) AnalyzeDatabase() (*models.DatabaseStats, error) { TotalFiles: totalFiles, TotalUsers: totalUsers, TotalSize: totalSize, - DatabaseSize: "N/A", // 可以在RepositoryManager中实现具体获取方法 + DatabaseSize: s.getDatabaseSizeHumanReadable(), }, nil } // GetSystemLogs 获取系统日志 func (s *Service) GetSystemLogs(lines int) ([]string, error) { - // 这里应该读取日志文件 - // 简单实现,实际应该根据配置的日志文件路径读取 - return []string{"System log functionality not implemented yet"}, nil + if lines <= 0 { + lines = 200 + } + + logPath, err := s.resolveLogPath("system") + if err != nil { + return nil, err + } + + return tailFile(logPath, lines) } // BackupDatabase 备份数据库 func (s *Service) BackupDatabase() (string, error) { - timestamp := time.Now().Format("20060102150405") - backupPath := fmt.Sprintf("backup/filecodebox_%s.db", timestamp) + if strings.ToLower(s.manager.Database.Type) != "sqlite" { + return "", errors.New("当前仅支持 SQLite 数据库备份,请使用外部工具备份其他数据库") + } - // 创建备份目录 - if err := os.MkdirAll("backup", 0755); err != nil { + sourcePath, err := s.resolveSQLitePath() + if err != nil { return "", err } + if _, err := os.Stat(sourcePath); err != nil { + return "", fmt.Errorf("数据库文件不存在: %w", err) + } + + backupDir := filepath.Join(s.ensureDataPath(), "backup") + if err := os.MkdirAll(backupDir, 0755); err != nil { + return "", fmt.Errorf("创建备份目录失败: %w", err) + } + + backupPath := filepath.Join(backupDir, fmt.Sprintf("filecodebox_%s.db", time.Now().Format("20060102150405"))) + + if err := copyFile(sourcePath, backupPath); err != nil { + return "", fmt.Errorf("备份数据库失败: %w", err) + } - // 简单的文件复制备份 - // 实际生产环境应该使用更专业的数据库备份方法 - return backupPath, fmt.Errorf("database backup not implemented yet") + return backupPath, nil } // GetStorageStatus 获取存储状态 @@ -161,32 +279,59 @@ func (s *Service) GetStorageStatus() (*models.StorageStatus, error) { Type: storageType, } - // 根据当前配置尝试附加使用率信息 - switch storageType { - case "local": - if s.manager.Storage.StoragePath != "" { - if usage, err := utils.GetUsagePercent(s.manager.Storage.StoragePath); err == nil { - details.UsagePercent = usage + available := true + + if storageType == "local" { + basePath := s.storageRoot() + if usage, err := utils.GetUsagePercent(basePath); err == nil { + details.UsagePercent = usage + } + if total, free, usable, err := utils.GetDiskUsageStats(basePath); err == nil { + details.TotalSpace = int64(total) + details.AvailableSpace = int64(usable) + if total > 0 { + details.UsagePercent = (float64(total-free) / float64(total)) * 100 } + } else { + available = false + logrus.WithError(err). + WithField("path", basePath). + Warn("failed to collect disk usage for storage path") } } return &models.StorageStatus{ Type: storageType, Status: "active", - Available: true, + Available: available, Details: details, }, nil } // GetDiskUsage 获取磁盘使用情况 func (s *Service) GetDiskUsage() (*models.DiskUsage, error) { - // 这里应该实现真实的磁盘使用情况获取 + basePath := s.storageRoot() + total, free, available, err := utils.GetDiskUsageStats(basePath) + if err != nil { + errMsg := err.Error() + return &models.DiskUsage{ + StorageType: s.manager.Storage.Type, + Success: false, + Error: &errMsg, + }, err + } + + used := total - free + usagePercent := 0.0 + if total > 0 { + usagePercent = (float64(used) / float64(total)) * 100 + } + return &models.DiskUsage{ - TotalSpace: int64(100 * 1024 * 1024 * 1024), // 100GB - UsedSpace: int64(50 * 1024 * 1024 * 1024), // 50GB - AvailableSpace: int64(50 * 1024 * 1024 * 1024), // 50GB - UsagePercent: 50.0, + TotalSpace: int64(total), + UsedSpace: int64(used), + AvailableSpace: int64(available), + UsagePercent: usagePercent, StorageType: s.manager.Storage.Type, Success: true, Error: nil, @@ -210,44 +355,81 @@ func (s *Service) GetStorageUsage() (int64, error) { // GetPerformanceMetrics 获取性能指标 func (s *Service) GetPerformanceMetrics() (*models.PerformanceMetrics, error) { - // 获取基本性能指标 + var mem runtime.MemStats + runtime.ReadMemStats(&mem) + + memoryUsage := fmt.Sprintf("%.2f MB", float64(mem.Alloc)/1024.0/1024.0) + cpuUsage := fmt.Sprintf("goroutines: %d", runtime.NumGoroutine()) + responseTime := "-" + + dbStats := "-" + if db := s.repositoryManager.DB(); db != nil { + if sqlDB, err := db.DB(); err == nil { + stats := sqlDB.Stats() + dbStats = fmt.Sprintf("open=%d idle=%d inUse=%d waitCount=%d", stats.OpenConnections, stats.Idle, stats.InUse, stats.WaitCount) + } + } + return &models.PerformanceMetrics{ - CPUUsage: "0%", // 这里可以实现真实的CPU使用率获取 - MemoryUsage: "0%", // 这里可以实现真实的内存使用率获取 - ResponseTime: "100ms", + MemoryUsage: memoryUsage, + CPUUsage: cpuUsage, + ResponseTime: responseTime, LastUpdated: time.Now(), - DatabaseStats: "active", + DatabaseStats: dbStats, }, nil } // ClearSystemCache 清理系统缓存 func (s *Service) ClearSystemCache() error { - // 这里可以实现清理系统缓存的逻辑 - // 比如清理内存缓存、文件缓存等 + cacheDir := filepath.Join(s.ensureDataPath(), "cache") + if _, err := os.Stat(cacheDir); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + if err := os.RemoveAll(cacheDir); err != nil { + return fmt.Errorf("清理系统缓存失败: %w", err) + } return nil } // ClearUploadCache 清理上传缓存 func (s *Service) ClearUploadCache() error { - // 实现清理上传缓存的逻辑 - return nil + chunkDir := filepath.Join(s.storageRoot(), "chunks") + if err := os.RemoveAll(chunkDir); err != nil { + return fmt.Errorf("清理上传缓存失败: %w", err) + } + return os.MkdirAll(chunkDir, 0755) } // ClearDownloadCache 清理下载缓存 func (s *Service) ClearDownloadCache() error { - // 实现清理下载缓存的逻辑 - return nil + downloadDir := filepath.Join(s.storageRoot(), "downloads") + if err := os.RemoveAll(downloadDir); err != nil { + return fmt.Errorf("清理下载缓存失败: %w", err) + } + return os.MkdirAll(downloadDir, 0755) } // GetSystemInfo 获取系统信息 func (s *Service) GetSystemInfo() (*models.SystemInfo, error) { - now := time.Now() + start := time.Now() + if strings.TrimSpace(s.SysStart) != "" { + if ms, err := strconv.ParseInt(s.SysStart, 10, 64); err == nil { + start = time.UnixMilli(ms) + } + } + + uptime := time.Since(start).Truncate(time.Second) + return &models.SystemInfo{ - OS: "linux", // 可以通过runtime.GOOS获取 - Architecture: "amd64", // 可以通过runtime.GOARCH获取 - GoVersion: "go version", // 可以通过runtime.Version()获取 - StartTime: now.Add(-time.Hour), // 假设一小时前启动 - Uptime: "1h0m0s", + OS: runtime.GOOS, + Architecture: runtime.GOARCH, + GoVersion: runtime.Version(), + StartTime: start, + Uptime: uptime.String(), }, nil } @@ -294,33 +476,93 @@ func (s *Service) CheckIntegrity() (*models.IntegrityCheckResult, error) { // ClearSystemLogs 清理系统日志 (占位符实现) func (s *Service) ClearSystemLogs() (int, error) { - return 0, nil + return s.truncateLogFile("system") } // ClearAccessLogs 清理访问日志 (占位符实现) func (s *Service) ClearAccessLogs() (int, error) { - return 0, nil + return s.truncateLogFile("access") } // ClearErrorLogs 清理错误日志 (占位符实现) func (s *Service) ClearErrorLogs() (int, error) { - return 0, nil + return s.truncateLogFile("error") } // ExportLogs 导出日志 (占位符实现) func (s *Service) ExportLogs(logType string) (string, error) { - return "", fmt.Errorf("log export not implemented yet") + logPath, err := s.resolveLogPath(logType) + if err != nil { + return "", err + } + + exportDir := filepath.Join(s.ensureDataPath(), "logs", "exports") + if err := os.MkdirAll(exportDir, 0755); err != nil { + return "", fmt.Errorf("创建日志导出目录失败: %w", err) + } + + fileName := fmt.Sprintf("%s_%s.log", logType, time.Now().Format("20060102150405")) + destination := filepath.Join(exportDir, fileName) + + if err := copyFile(logPath, destination); err != nil { + return "", fmt.Errorf("导出日志失败: %w", err) + } + + return destination, nil } // GetLogStats 获取日志统计 (占位符实现) func (s *Service) GetLogStats() (*models.LogStats, error) { + logPath, err := s.resolveLogPath("system") + if err != nil { + return nil, err + } + + file, err := os.Open(logPath) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + + var total, errorCount, warnCount, infoCount int + var lastLine string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + total++ + upper := strings.ToUpper(line) + switch { + case strings.Contains(upper, "ERROR"): + errorCount++ + case strings.Contains(upper, "WARN"): + warnCount++ + case strings.Contains(upper, "INFO"): + infoCount++ + } + lastLine = line + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + fileInfo, err := file.Stat() + if err != nil { + return nil, err + } + + lastLogTime := "" + if ts := extractTimestamp(lastLine); ts != "" { + lastLogTime = ts + } + return &models.LogStats{ - TotalLogs: 0, - ErrorLogs: 0, - WarningLogs: 0, - InfoLogs: 0, - LastLogTime: time.Now().Format("2006-01-02 15:04:05"), - LogSize: "0KB", + TotalLogs: total, + ErrorLogs: errorCount, + WarningLogs: warnCount, + InfoLogs: infoCount, + LastLogTime: lastLogTime, + LogSize: formatBytes(uint64(fileInfo.Size())), }, nil } @@ -343,14 +585,14 @@ func (s *Service) GetRunningTasks() ([]*models.RunningTask, error) { // CancelTask 取消任务 func (s *Service) CancelTask(taskID string) error { // 这里应该实现真实的任务取消逻辑 - fmt.Printf("Cancelling task: %s\n", taskID) + logrus.WithField("task_id", taskID).Info("Cancelling task") return nil } // RetryTask 重试任务 func (s *Service) RetryTask(taskID string) error { // 这里应该实现真实的任务重试逻辑 - fmt.Printf("Retrying task: %s\n", taskID) + logrus.WithField("task_id", taskID).Info("Retrying task") return nil } @@ -358,6 +600,178 @@ func (s *Service) RetryTask(taskID string) error { func (s *Service) RestartSystem() error { // 这里应该实现真实的系统重启逻辑 // 注意:这是一个危险操作,需要仔细考虑 - fmt.Println("System restart request received") + logrus.Info("System restart request received") return nil } + +func (s *Service) storageRoot() string { + if path := strings.TrimSpace(s.manager.Storage.StoragePath); path != "" { + if filepath.IsAbs(path) { + return path + } + return filepath.Join(s.ensureDataPath(), path) + } + return s.ensureDataPath() +} + +func (s *Service) ensureDataPath() string { + base := strings.TrimSpace(s.manager.Base.DataPath) + if base == "" { + base = "./data" + } + if abs, err := filepath.Abs(base); err == nil { + return abs + } + return base +} + +func (s *Service) resolveSQLitePath() (string, error) { + path := strings.TrimSpace(s.manager.Database.Name) + if path == "" { + path = filepath.Join(s.ensureDataPath(), "filecodebox.db") + } else if !filepath.IsAbs(path) { + path = filepath.Join(s.ensureDataPath(), path) + } + return filepath.Abs(path) +} + +func (s *Service) resolveLogPath(logType string) (string, error) { + nameCandidates := map[string][]string{ + "system": {"system.log", "server.log", "filecodebox.log"}, + "access": {"access.log", "server.log"}, + "error": {"error.log", "server.log"}, + } + + names, ok := nameCandidates[logType] + if !ok { + names = []string{"server.log"} + } + + searchRoots := []string{ + s.ensureDataPath(), + filepath.Join(s.ensureDataPath(), "logs"), + "./", + "./logs", + } + + for _, root := range searchRoots { + for _, name := range names { + candidate := filepath.Join(root, name) + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + } + } + + return "", fmt.Errorf("未找到 %s 日志文件", logType) +} + +func tailFile(path string, limit int) ([]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + + if limit <= 0 { + limit = 200 + } + + buffer := make([]string, 0, limit) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + buffer = append(buffer, scanner.Text()) + if len(buffer) > limit { + buffer = buffer[1:] + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return buffer, nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer func() { _ = in.Close() }() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer func() { _ = out.Close() }() + + if _, err = io.Copy(out, in); err != nil { + return err + } + + return out.Sync() +} + +func (s *Service) truncateLogFile(logType string) (int, error) { + logPath, err := s.resolveLogPath(logType) + if err != nil { + return 0, err + } + + info, err := os.Stat(logPath) + if err != nil { + return 0, err + } + + if err := os.Truncate(logPath, 0); err != nil { + return 0, err + } + + return int(info.Size()), nil +} + +func (s *Service) getDatabaseSizeHumanReadable() string { + if strings.ToLower(s.manager.Database.Type) != "sqlite" { + return "-" + } + path, err := s.resolveSQLitePath() + if err != nil { + return "-" + } + info, err := os.Stat(path) + if err != nil { + return "-" + } + return formatBytes(uint64(info.Size())) +} + +func formatBytes(b uint64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := uint64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + exp++ + div *= unit + } + return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) +} + +func extractTimestamp(line string) string { + if line == "" { + return "" + } + if idx := strings.Index(line, "time=\""); idx >= 0 { + rest := line[idx+len("time=\""):] + if end := strings.Index(rest, "\""); end > 0 { + return rest[:end] + } + } + fields := strings.Fields(line) + if len(fields) > 0 { + return fields[0] + } + return "" +} diff --git a/internal/services/admin/maintenance_test.go b/internal/services/admin/maintenance_test.go new file mode 100644 index 0000000..c9a66ee --- /dev/null +++ b/internal/services/admin/maintenance_test.go @@ -0,0 +1,107 @@ +package admin_test + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/zy84338719/filecodebox/internal/models" + dbmodels "github.com/zy84338719/filecodebox/internal/models/db" + "gorm.io/gorm" +) + +func TestCleanupInvalidFilesRemovesMissingRecords(t *testing.T) { + svc, repo, manager := setupAdminTestService(t) + + // create valid file on disk + validDir := filepath.Join("files", "2025") + validName := "file.bin" + validFullPath := filepath.Join(manager.Storage.StoragePath, validDir, validName) + if err := os.MkdirAll(filepath.Dir(validFullPath), 0755); err != nil { + t.Fatalf("failed to create storage dir: %v", err) + } + if err := os.WriteFile(validFullPath, []byte("data"), 0644); err != nil { + t.Fatalf("failed to write valid file: %v", err) + } + + valid := &models.FileCode{ + Code: "valid", + FilePath: validDir, + UUIDFileName: validName, + Size: int64(len("data")), + } + if err := repo.FileCode.Create(valid); err != nil { + t.Fatalf("failed to create valid record: %v", err) + } + + missing := &models.FileCode{ + Code: "missing", + FilePath: filepath.Join("files", "missing"), + UUIDFileName: "ghost.bin", + Size: 10, + } + if err := repo.FileCode.Create(missing); err != nil { + t.Fatalf("failed to create missing record: %v", err) + } + + cleaned, err := svc.CleanupInvalidFiles() + if err != nil { + t.Fatalf("CleanupInvalidFiles returned error: %v", err) + } + if cleaned != 1 { + t.Fatalf("expected 1 record cleaned, got %d", cleaned) + } + + if _, err := repo.FileCode.GetByCode("missing"); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("expected missing record to be removed, err=%v", err) + } + + if _, err := repo.FileCode.GetByCode("valid"); err != nil { + t.Fatalf("expected valid record to remain: %v", err) + } +} + +func TestCleanTempFilesRemovesOldChunks(t *testing.T) { + svc, repo, manager := setupAdminTestService(t) + + uploadID := "session-123" + chunk := &dbmodels.UploadChunk{ + UploadID: uploadID, + ChunkIndex: -1, + Status: "pending", + } + if err := repo.Chunk.Create(chunk); err != nil { + t.Fatalf("failed to create chunk: %v", err) + } + + // backdate the chunk so it qualifies as old + oldTime := time.Now().Add(-48 * time.Hour) + if err := repo.DB().Model(&dbmodels.UploadChunk{}). + Where("upload_id = ? AND chunk_index = -1", uploadID). + Update("created_at", oldTime).Error; err != nil { + t.Fatalf("failed to backdate chunk: %v", err) + } + + chunkDir := filepath.Join(manager.Storage.StoragePath, "chunks", uploadID) + if err := os.MkdirAll(chunkDir, 0755); err != nil { + t.Fatalf("failed to create chunk dir: %v", err) + } + + cleaned, err := svc.CleanTempFiles() + if err != nil { + t.Fatalf("CleanTempFiles returned error: %v", err) + } + if cleaned != 1 { + t.Fatalf("expected 1 upload cleaned, got %d", cleaned) + } + + if _, err := repo.Chunk.GetByUploadID(uploadID); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("expected chunk record to be removed, err=%v", err) + } + + if _, err := os.Stat(chunkDir); !os.IsNotExist(err) { + t.Fatalf("expected chunk directory to be removed, stat err=%v", err) + } +} diff --git a/internal/services/admin/users.go b/internal/services/admin/users.go index d12db96..9674042 100644 --- a/internal/services/admin/users.go +++ b/internal/services/admin/users.go @@ -3,11 +3,21 @@ package admin import ( "errors" "fmt" + "strings" "github.com/zy84338719/filecodebox/internal/models" "gorm.io/gorm" ) +// UserUpdateParams 管理端用户更新参数 +type UserUpdateParams struct { + Email *string + Password *string + Nickname *string + IsAdmin *bool + IsActive *bool +} + // GetUsers 获取用户列表 func (s *Service) GetUsers(page, pageSize int, search string) ([]models.User, int64, error) { return s.repositoryManager.User.List(page, pageSize, search) @@ -65,8 +75,104 @@ func (s *Service) CreateUser(username, email, password, nickname, role, status s // UpdateUser 更新用户 - 使用结构化更新 func (s *Service) UpdateUser(user models.User) error { + if user.ID == 0 { + return errors.New("用户ID不能为空") + } + + params := UserUpdateParams{} + if user.Email != "" { + params.Email = &user.Email + } + if user.PasswordHash != "" { + params.Password = &user.PasswordHash + } + if user.Nickname != "" { + params.Nickname = &user.Nickname + } + if user.Role != "" { + isAdmin := user.Role == "admin" + params.IsAdmin = &isAdmin + } + if user.Status != "" { + isActive := user.Status == "active" + params.IsActive = &isActive + } + + return s.UpdateUserWithParams(user.ID, params) +} + +// UpdateUserWithParams 使用结构化参数更新用户 +func (s *Service) UpdateUserWithParams(userID uint, params UserUpdateParams) error { + if userID == 0 { + return errors.New("用户ID不能为空") + } + + existingUser, err := s.repositoryManager.User.GetByID(userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("用户不存在") + } + return fmt.Errorf("获取用户失败: %w", err) + } + + updates := make(map[string]interface{}) + + if params.Email != nil { + email := strings.TrimSpace(*params.Email) + if email == "" { + return errors.New("邮箱不能为空") + } + if !strings.EqualFold(email, existingUser.Email) { + if _, err := s.repositoryManager.User.CheckEmailExists(email, userID); err == nil { + return errors.New("该邮箱已被使用") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("检查邮箱唯一性失败: %w", err) + } + } + updates["email"] = email + } + + if params.Nickname != nil { + updates["nickname"] = strings.TrimSpace(*params.Nickname) + } + + if params.IsAdmin != nil { + role := "user" + if *params.IsAdmin { + role = "admin" + } + updates["role"] = role + } + + if params.IsActive != nil { + status := "inactive" + if *params.IsActive { + status = "active" + } + updates["status"] = status + } + + if params.Password != nil { + password := strings.TrimSpace(*params.Password) + if len(password) < 6 { + return errors.New("密码长度至少6个字符") + } + hashedPassword, err := s.authService.HashPassword(password) + if err != nil { + return fmt.Errorf("哈希密码失败: %w", err) + } + updates["password_hash"] = hashedPassword + } + + if len(updates) == 0 { + return nil + } + + if err := s.repositoryManager.User.UpdateColumns(userID, updates); err != nil { + return fmt.Errorf("更新用户失败: %w", err) + } - return s.repositoryManager.User.UpdateUserFields(user.ID, user) + return nil } // DeleteUser 删除用户 diff --git a/internal/services/admin/users_test.go b/internal/services/admin/users_test.go new file mode 100644 index 0000000..03c9087 --- /dev/null +++ b/internal/services/admin/users_test.go @@ -0,0 +1,104 @@ +package admin_test + +import ( + "strings" + "testing" + + "github.com/zy84338719/filecodebox/internal/models" + admin "github.com/zy84338719/filecodebox/internal/services/admin" +) + +func TestUpdateUserWithParamsHashesPasswordAndUpdatesRole(t *testing.T) { + svc, repo, _ := setupAdminTestService(t) + + user := &models.User{ + Username: "alice", + Email: "alice@example.com", + PasswordHash: "legacy", + Role: "user", + Status: "active", + } + if err := repo.User.Create(user); err != nil { + t.Fatalf("failed to create user: %v", err) + } + + newEmail := "alice-updated@example.com" + newPassword := "StrongPass123" + isAdmin := true + isActive := false + + params := admin.UserUpdateParams{ + Email: &newEmail, + Password: &newPassword, + IsAdmin: &isAdmin, + IsActive: &isActive, + } + + if err := svc.UpdateUserWithParams(user.ID, params); err != nil { + t.Fatalf("UpdateUserWithParams returned error: %v", err) + } + + updated, err := repo.User.GetByID(user.ID) + if err != nil { + t.Fatalf("failed to reload user: %v", err) + } + + if updated.Email != newEmail { + t.Fatalf("expected email to be updated, got %s", updated.Email) + } + if updated.Role != "admin" { + t.Fatalf("expected role to be admin, got %s", updated.Role) + } + if updated.Status != "inactive" { + t.Fatalf("expected status to be inactive, got %s", updated.Status) + } + if updated.PasswordHash == newPassword || updated.PasswordHash == "" { + t.Fatalf("expected password to be hashed, got %s", updated.PasswordHash) + } +} + +func TestUpdateUserWithParamsDuplicateEmail(t *testing.T) { + svc, repo, _ := setupAdminTestService(t) + + user1 := &models.User{Username: "bob", Email: "bob@example.com", PasswordHash: "hash"} + if err := repo.User.Create(user1); err != nil { + t.Fatalf("failed to create user1: %v", err) + } + + user2 := &models.User{Username: "carol", Email: "carol@example.com", PasswordHash: "hash"} + if err := repo.User.Create(user2); err != nil { + t.Fatalf("failed to create user2: %v", err) + } + + duplicateEmail := "carol@example.com" + params := admin.UserUpdateParams{Email: &duplicateEmail} + + err := svc.UpdateUserWithParams(user1.ID, params) + if err == nil { + t.Fatal("expected duplicate email error, got nil") + } + if !strings.Contains(err.Error(), "该邮箱已被使用") { + t.Fatalf("unexpected error message: %v", err) + } + + // ensure original email unchanged + reloaded, err := repo.User.GetByID(user1.ID) + if err != nil { + t.Fatalf("failed to reload user: %v", err) + } + if reloaded.Email != "bob@example.com" { + t.Fatalf("expected email to remain unchanged, got %s", reloaded.Email) + } +} + +func TestUpdateUserWithParamsMissingUser(t *testing.T) { + svc, _, _ := setupAdminTestService(t) + + err := svc.UpdateUserWithParams(9999, admin.UserUpdateParams{}) + if err == nil { + t.Fatal("expected error for missing user, got nil") + } + if !strings.Contains(err.Error(), "用户不存在") { + t.Fatalf("unexpected error message: %v", err) + } +} diff --git a/internal/services/chunk/upload.go b/internal/services/chunk/upload.go index 736ab29..e8e20d3 100644 --- a/internal/services/chunk/upload.go +++ b/internal/services/chunk/upload.go @@ -5,6 +5,8 @@ import ( "fmt" "github.com/zy84338719/filecodebox/internal/models" + + "github.com/sirupsen/logrus" ) // InitiateUpload 初始化分片上传 @@ -160,7 +162,9 @@ func (s *Service) CleanupExpiredUploads() (int, error) { for _, upload := range expiredUploads { err := s.repositoryManager.Chunk.DeleteByUploadID(upload.UploadID) if err != nil { - fmt.Printf("Warning: Failed to delete expired upload %s: %v\n", upload.UploadID, err) + logrus.WithError(err). + WithField("upload_id", upload.UploadID). + Warn("failed to delete expired upload metadata") } else { deletedCount++ } diff --git a/internal/services/services.go b/internal/services/services.go index 1cc4975..2f1bbb3 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -20,6 +20,8 @@ type ChunkService = chunk.Service type ShareService = share.Service type UserService = user.Service +type AdminUserUpdateParams = admin.UserUpdateParams + // 导出auth包中的类型 type AuthClaims = auth.AuthClaims diff --git a/internal/services/share/file.go b/internal/services/share/file.go index 53ce636..a58224f 100644 --- a/internal/services/share/file.go +++ b/internal/services/share/file.go @@ -9,6 +9,8 @@ import ( "github.com/gin-gonic/gin" "github.com/zy84338719/filecodebox/internal/models" "github.com/zy84338719/filecodebox/internal/models/service" + + "github.com/sirupsen/logrus" ) // GetFileByCode 通过代码获取文件 @@ -108,7 +110,9 @@ func (s *Service) CreateFileShare( err = s.userService.UpdateUserStats(*userID, "upload", fileSize) if err != nil { // 记录警告但不中断流程 - fmt.Printf("Warning: Failed to update user stats: %v\n", err) + logrus.WithError(err). + WithField("user_id", *userID). + Warn("failed to update user stats after upload") } } } @@ -154,7 +158,9 @@ func (s *Service) DeleteFileShare(code string) error { // 删除实际文件 result := s.storageService.DeleteFileWithResult(fileCode) if !result.Success { - fmt.Printf("Warning: Failed to delete physical file: %v\n", result.Error) + logrus.WithError(result.Error). + WithField("code", fileCode.Code). + Warn("failed to delete physical file during share removal") } // 如果是已认证用户的文件,更新用户统计信息 @@ -163,7 +169,9 @@ func (s *Service) DeleteFileShare(code string) error { err = s.userService.UpdateUserStats(*fileCode.UserID, "delete", -fileCode.Size) if err != nil { // 记录警告但不中断流程 - fmt.Printf("Warning: Failed to update user stats for deletion: %v\n", err) + logrus.WithError(err). + WithField("user_id", *fileCode.UserID). + Warn("failed to update user stats after deletion") } } @@ -331,7 +339,7 @@ func (s *Service) ShareFileWithAuth(req models.ShareFileRequest) (*models.ShareF } defer func() { if err := file.Close(); err != nil { - fmt.Printf("关闭文件失败: %v\n", err) + logrus.WithError(err).Warn("关闭文件失败") } }() diff --git a/internal/services/user/stats_fix.go b/internal/services/user/stats_fix.go index 51d40c3..e4f8cbe 100644 --- a/internal/services/user/stats_fix.go +++ b/internal/services/user/stats_fix.go @@ -1,7 +1,7 @@ package user import ( - "fmt" + "github.com/sirupsen/logrus" ) // RecalculateUserStats 重新计算用户统计数据 @@ -44,7 +44,9 @@ func (s *Service) RecalculateAllUsersStats() error { for _, user := range users { err := s.RecalculateUserStats(user.ID) if err != nil { - fmt.Printf("Warning: Failed to recalculate stats for user %d: %v\n", user.ID, err) + logrus.WithError(err). + WithField("user_id", user.ID). + Warn("failed to recalculate user stats") } } diff --git a/internal/storage/concrete_service.go b/internal/storage/concrete_service.go index df0efd2..ea5877a 100644 --- a/internal/storage/concrete_service.go +++ b/internal/storage/concrete_service.go @@ -185,6 +185,28 @@ func (css *ConcreteStorageService) DeleteFileWithResult(fileCode *models.FileCod return result } +// FileExists 检查文件是否存在 +func (css *ConcreteStorageService) FileExists(fileCode *models.FileCode) bool { + if fileCode == nil { + return false + } + if fileCode.Text != "" { + return true + } + + filePath := fileCode.GetFilePath() + if filePath == "" { + return false + } + + strategy := css.getCurrentStrategy() + fullPath := css.pathManager.GetFullPath(filePath) + if strategy.FileExists(fullPath) { + return true + } + return strategy.FileExists(filePath) +} + // GetFileDownloadInfo 获取文件下载信息 func (css *ConcreteStorageService) GetFileDownloadInfo(fileCode *models.FileCode) (*FileDownloadInfo, error) { strategy := css.getCurrentStrategy() diff --git a/internal/storage/local_strategy.go b/internal/storage/local_strategy.go index 6790c91..4894791 100644 --- a/internal/storage/local_strategy.go +++ b/internal/storage/local_strategy.go @@ -3,12 +3,12 @@ package storage import ( "fmt" "io" - "log" "mime/multipart" "os" "path/filepath" "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" ) const ( @@ -80,7 +80,7 @@ func (ls *LocalStorageStrategy) SaveUploadFile(file *multipart.FileHeader, saveP } defer func() { if cerr := src.Close(); cerr != nil { - log.Printf("Error closing source file: %v", cerr) + logrus.WithError(cerr).Warn("local storage: failed to close source file") } }() @@ -91,7 +91,7 @@ func (ls *LocalStorageStrategy) SaveUploadFile(file *multipart.FileHeader, saveP } defer func() { if cerr := dst.Close(); cerr != nil { - log.Printf("Error closing destination file: %v", cerr) + logrus.WithError(cerr).Warn("local storage: failed to close destination file") } }() diff --git a/internal/storage/nfs_strategy.go b/internal/storage/nfs_strategy.go index 6cf0028..6bbd9c7 100644 --- a/internal/storage/nfs_strategy.go +++ b/internal/storage/nfs_strategy.go @@ -3,7 +3,6 @@ package storage import ( "fmt" "io" - "log" "mime/multipart" "os" "os/exec" @@ -12,6 +11,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" ) // NFSStorageStrategy NFS存储策略实现 @@ -107,7 +107,7 @@ func (nfs *NFSStorageStrategy) checkMountStatus() { cmd := exec.Command("mount") output, err := cmd.Output() if err != nil { - log.Printf("检查挂载状态失败: %v", err) + logrus.WithError(err).Warn("NFS: 检查挂载状态失败") nfs.isMounted = false return } @@ -143,11 +143,16 @@ func (nfs *NFSStorageStrategy) mount() error { cmd := exec.Command("mount", args...) if err = cmd.Run(); err == nil { nfs.isMounted = true - log.Printf("NFS挂载成功: %s -> %s", nfsTarget, nfs.mountPoint) + logrus.WithFields(logrus.Fields{ + "target": nfsTarget, + "mount_point": nfs.mountPoint, + }).Info("NFS挂载成功") return nil } - log.Printf("NFS挂载尝试 %d/%d 失败: %v", i+1, nfs.retryCount, err) + logrus.WithError(err). + WithFields(logrus.Fields{"attempt": i + 1, "max_attempts": nfs.retryCount}). + Warn("NFS挂载失败") if i < nfs.retryCount-1 { time.Sleep(time.Duration(2*(i+1)) * time.Second) // 递增等待时间 } @@ -172,7 +177,7 @@ func (nfs *NFSStorageStrategy) unmount() error { } nfs.isMounted = false - log.Printf("NFS卸载成功: %s", nfs.mountPoint) + logrus.WithField("mount_point", nfs.mountPoint).Info("NFS卸载成功") return nil } @@ -230,7 +235,7 @@ func (nfs *NFSStorageStrategy) DeleteFile(path string) error { // FileExists 检查文件是否存在 func (nfs *NFSStorageStrategy) FileExists(path string) bool { if err := nfs.ensureMounted(); err != nil { - log.Printf("检查文件存在性时NFS挂载失败: %v", err) + logrus.WithError(err).Warn("NFS: 检查文件存在性时挂载失败") return false } @@ -257,7 +262,7 @@ func (nfs *NFSStorageStrategy) SaveUploadFile(file *multipart.FileHeader, savePa } defer func() { if cerr := src.Close(); cerr != nil { - log.Printf("Error closing source file: %v", cerr) + logrus.WithError(cerr).Warn("NFS: failed to close source file") } }() @@ -268,7 +273,7 @@ func (nfs *NFSStorageStrategy) SaveUploadFile(file *multipart.FileHeader, savePa } defer func() { if cerr := dst.Close(); cerr != nil { - log.Printf("Error closing destination file: %v", cerr) + logrus.WithError(cerr).Warn("NFS: failed to close destination file") } }() @@ -356,7 +361,7 @@ func (nfs *NFSStorageStrategy) GetMountInfo() map[string]interface{} { func (nfs *NFSStorageStrategy) Remount() error { // 先卸载 if err := nfs.unmount(); err != nil { - log.Printf("卸载NFS时出错(继续挂载): %v", err) + logrus.WithError(err).Warn("NFS: 卸载失败,将继续执行重新挂载") } // 等待一段时间 diff --git a/internal/storage/s3_strategy.go b/internal/storage/s3_strategy.go index 13781e1..07fe2cd 100644 --- a/internal/storage/s3_strategy.go +++ b/internal/storage/s3_strategy.go @@ -5,7 +5,6 @@ import ( "context" "fmt" "io" - "log" "mime/multipart" "net/http" "strings" @@ -16,6 +15,7 @@ import ( "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" ) // S3StorageStrategy S3 存储策略实现 @@ -116,7 +116,7 @@ func (ss *S3StorageStrategy) ReadFile(path string) ([]byte, error) { } defer func() { if cerr := result.Body.Close(); cerr != nil { - log.Printf("Error closing response body: %v", cerr) + logrus.WithError(cerr).Warn("S3: failed to close response body") } }() @@ -192,7 +192,7 @@ func (ss *S3StorageStrategy) SaveUploadFile(file *multipart.FileHeader, savePath } defer func() { if cerr := src.Close(); cerr != nil { - log.Printf("Error closing source file: %v", cerr) + logrus.WithError(cerr).Warn("S3: failed to close uploaded source file") } }() diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 9857716..dc6a6da 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -4,7 +4,6 @@ import ( "crypto/sha256" "fmt" "io" - "log" "mime/multipart" "path/filepath" "time" @@ -13,6 +12,7 @@ import ( "github.com/zy84338719/filecodebox/internal/models" "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" ) // StorageInterface 存储接口 @@ -253,7 +253,7 @@ func CalculateFileHash(file *multipart.FileHeader) (string, error) { } defer func() { if cerr := src.Close(); cerr != nil { - log.Printf("Error closing source file: %v", cerr) + logrus.WithError(cerr).Warn("storage: failed to close source file during hash calculation") } }() diff --git a/internal/storage/webdav_strategy.go b/internal/storage/webdav_strategy.go index 9d5c173..4b0cadb 100644 --- a/internal/storage/webdav_strategy.go +++ b/internal/storage/webdav_strategy.go @@ -3,13 +3,13 @@ package storage import ( "fmt" "io" - "log" "mime/multipart" "net/http" "path/filepath" "strings" "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" "github.com/studio-b12/gowebdav" ) @@ -180,7 +180,7 @@ func (ws *WebDAVStorageStrategy) SaveUploadFile(file *multipart.FileHeader, save } defer func() { if cerr := src.Close(); cerr != nil { - log.Printf("Error closing source file: %v", cerr) + logrus.WithError(cerr).Warn("WebDAV: failed to close source file") } }() @@ -271,7 +271,9 @@ func (ws *WebDAVStorageStrategy) TestConnection() error { // 清理测试文件 if err := client.Remove(testPath); err != nil { // 删除失败不是致命错误,只记录警告 - log.Printf("Warning: 无法删除测试文件 %s: %v", testPath, err) + logrus.WithError(err). + WithField("path", testPath). + Warn("WebDAV: 无法删除测试文件") } return nil diff --git a/internal/tasks/tasks.go b/internal/tasks/tasks.go index 4f97eac..065854c 100644 --- a/internal/tasks/tasks.go +++ b/internal/tasks/tasks.go @@ -16,9 +16,15 @@ type TaskManager struct { storage *storage.StorageManager cron *cron.Cron pathManager *storage.PathManager + maintenance MaintenanceExecutor } -func NewTaskManager(daoManager *repository.RepositoryManager, storageManager *storage.StorageManager, dataPath string) *TaskManager { +type MaintenanceExecutor interface { + CleanupExpiredFiles() (int, error) + CleanTempFiles() (int, error) +} + +func NewTaskManager(daoManager *repository.RepositoryManager, storageManager *storage.StorageManager, maintenance MaintenanceExecutor, dataPath string) *TaskManager { // 创建路径管理器 pathManager := storage.NewPathManager(dataPath) @@ -27,6 +33,7 @@ func NewTaskManager(daoManager *repository.RepositoryManager, storageManager *st storage: storageManager, cron: cron.New(), pathManager: pathManager, + maintenance: maintenance, } } @@ -56,6 +63,16 @@ func (tm *TaskManager) Stop() { func (tm *TaskManager) cleanExpiredFiles() { logrus.Info("开始清理过期文件") + if tm.maintenance != nil { + count, err := tm.maintenance.CleanupExpiredFiles() + if err != nil { + logrus.WithError(err).Error("手动清理过期文件失败") + return + } + logrus.Infof("清理过期文件完成,共清理 %d 个文件", count) + return + } + // 使用 DAO 获取过期文件 expiredFiles, err := tm.daoManager.FileCode.GetExpiredFiles() if err != nil { @@ -91,6 +108,16 @@ func (tm *TaskManager) cleanExpiredFiles() { func (tm *TaskManager) cleanTempFiles() { logrus.Info("开始清理临时文件") + if tm.maintenance != nil { + count, err := tm.maintenance.CleanTempFiles() + if err != nil { + logrus.WithError(err).Error("手动清理临时文件失败") + return + } + logrus.Infof("清理临时文件完成,共清理 %d 个上传会话", count) + return + } + // 清理超过24小时的未完成上传 cutoff := time.Now().Add(-24 * time.Hour) diff --git a/internal/utils/disk_unix.go b/internal/utils/disk_unix.go index ce2ceaa..4301670 100644 --- a/internal/utils/disk_unix.go +++ b/internal/utils/disk_unix.go @@ -25,3 +25,16 @@ func GetUsagePercent(path string) (float64, error) { usage := (used / total) * 100.0 return usage, nil } + +// GetDiskUsageStats returns total, free, and available bytes for the filesystem containing path. +func GetDiskUsageStats(path string) (total uint64, free uint64, available uint64, err error) { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return 0, 0, 0, err + } + + total = uint64(stat.Blocks) * uint64(stat.Bsize) + free = uint64(stat.Bfree) * uint64(stat.Bsize) + available = uint64(stat.Bavail) * uint64(stat.Bsize) + return total, free, available, nil +} diff --git a/internal/utils/disk_windows.go b/internal/utils/disk_windows.go index 85c766a..487845d 100644 --- a/internal/utils/disk_windows.go +++ b/internal/utils/disk_windows.go @@ -12,30 +12,29 @@ import ( // GetUsagePercent returns disk usage percentage for the drive containing the given path on Windows. func GetUsagePercent(path string) (float64, error) { - volume, err := resolveVolume(path) + volume, totalBytes, _, freeBytes, err := getDiskFreeSpace(path) if err != nil { return 0, err } - var ( - freeBytesAvailable uint64 - totalNumberOfBytes uint64 - totalNumberOfFree uint64 - ) - - if err := windows.GetDiskFreeSpaceEx(windows.StringToUTF16Ptr(volume), &freeBytesAvailable, &totalNumberOfBytes, &totalNumberOfFree); err != nil { - return 0, err - } - - if totalNumberOfBytes == 0 { + if totalBytes == 0 { return 0, fmt.Errorf("unable to compute total disk size for %s", volume) } - used := totalNumberOfBytes - totalNumberOfFree - usage := (float64(used) / float64(totalNumberOfBytes)) * 100.0 + used := totalBytes - freeBytes + usage := (float64(used) / float64(totalBytes)) * 100.0 return usage, nil } +// GetDiskUsageStats returns total, free, and available bytes for the volume containing path. +func GetDiskUsageStats(path string) (total uint64, free uint64, available uint64, err error) { + _, totalBytes, availableBytes, freeBytes, err := getDiskFreeSpace(path) + if err != nil { + return 0, 0, 0, err + } + return totalBytes, freeBytes, availableBytes, nil +} + func resolveVolume(path string) (string, error) { absPath, err := filepath.Abs(path) if err != nil { @@ -54,3 +53,22 @@ func resolveVolume(path string) (string, error) { return volume, nil } + +func getDiskFreeSpace(path string) (volume string, total uint64, available uint64, free uint64, err error) { + volume, err = resolveVolume(path) + if err != nil { + return "", 0, 0, 0, err + } + + var ( + freeBytesAvailable uint64 + totalNumberOfBytes uint64 + totalNumberOfFree uint64 + ) + + if err = windows.GetDiskFreeSpaceEx(windows.StringToUTF16Ptr(volume), &freeBytesAvailable, &totalNumberOfBytes, &totalNumberOfFree); err != nil { + return "", 0, 0, 0, err + } + + return volume, totalNumberOfBytes, freeBytesAvailable, totalNumberOfFree, nil +} diff --git a/main.go b/main.go index b0db491..7c9ca31 100644 --- a/main.go +++ b/main.go @@ -97,7 +97,7 @@ func main() { adminService := services.NewAdminService(dmgr, manager, storageService) // 启动任务管理器 - taskManager := tasks.NewTaskManager(dmgr, storageManager, manager.Base.DataPath) + taskManager := tasks.NewTaskManager(dmgr, storageManager, adminService, manager.Base.DataPath) taskManager.Start() // 注意:taskManager 的停止将在主结束时处理(可以扩展保存引用以便停止) From de0bf670a44d44002e4dac2d336f4a34f18c0bca Mon Sep 17 00:00:00 2001 From: murphyyi Date: Wed, 24 Sep 2025 14:59:47 +0800 Subject: [PATCH 3/6] release: v1.9.1 --- .github/copilot-instructions.md | 52 + VERSION | 2 +- internal/config/manager.go | 161 +- internal/config/manager_test.go | 64 + internal/database/database.go | 1 + internal/handlers/admin.go | 1306 ----------------- internal/handlers/admin_audit.go | 130 ++ internal/handlers/admin_auth.go | 53 + internal/handlers/admin_base.go | 19 + internal/handlers/admin_config_handler.go | 45 + internal/handlers/admin_files.go | 128 ++ internal/handlers/admin_maintenance.go | 365 +++++ internal/handlers/admin_mcp.go | 234 +++ internal/handlers/admin_transfer.go | 75 + internal/handlers/admin_users.go | 426 ++++++ internal/handlers/api.go | 2 +- internal/handlers/setup.go | 86 +- internal/handlers/storage.go | 241 +-- internal/models/db/admin_operation_log.go | 32 + internal/models/models.go | 14 +- internal/models/service/system.go | 2 +- internal/models/web/admin.go | 42 +- internal/repository/admin_operation_log.go | 70 + internal/repository/manager.go | 2 + internal/routes/admin.go | 1 + .../services/admin/admin_test_helpers_test.go | 2 +- internal/services/admin/audit.go | 26 + internal/services/admin/config.go | 317 ++-- internal/services/admin/maintenance_test.go | 36 + main.go | 2 +- 30 files changed, 2267 insertions(+), 1669 deletions(-) delete mode 100644 internal/handlers/admin.go create mode 100644 internal/handlers/admin_audit.go create mode 100644 internal/handlers/admin_auth.go create mode 100644 internal/handlers/admin_base.go create mode 100644 internal/handlers/admin_config_handler.go create mode 100644 internal/handlers/admin_files.go create mode 100644 internal/handlers/admin_maintenance.go create mode 100644 internal/handlers/admin_mcp.go create mode 100644 internal/handlers/admin_transfer.go create mode 100644 internal/handlers/admin_users.go create mode 100644 internal/models/db/admin_operation_log.go create mode 100644 internal/repository/admin_operation_log.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ae02b42..464f4b1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,3 +1,55 @@ +# Copilot / AI Agent Instructions for FileCodeBox + +Short, actionable guidance so an AI agent can be productive immediately in this repo. + +1) Big picture +- Layered architecture: `routes -> handlers -> services -> repository -> database/storage`. +- Key directories: `internal/routes/`, `internal/handlers/`, `internal/services/`, `internal/repository/`, `internal/storage/`, `internal/config/`, `internal/mcp/`, `docs/`. +- Main entry: `main.go` initializes `ConfigManager`, `StorageManager`, and starts the HTTP server via `routes.CreateAndStartServer()`; DB initialization is deferred and can be triggered via `/setup/initialize`. + +2) Typical data & control flow +- HTTP requests handled by `routes` -> call `handlers` -> call `services` -> use `repository` -> talk to DB/storage. +- Storage is abstracted by `storage.NewStorageManager(manager)` and concrete implementations under `internal/storage/` (local, s3, webdav, onedrive). +- MCP subsystem lives under `internal/mcp/` and is conditionally started when `manager.MCP.EnableMCPServer == 1`. + +3) Configuration & runtime +- Config is centralized in `internal/config/manager.go` (use `config.InitManager()` to obtain `ConfigManager`). +- Environment variables override config; `manager.SetDB(db)` is used to inject DB connection after initialization. +- Common env vars: `PORT`, `DATA_PATH`, `ENABLE_MCP_SERVER`. + +4) Versioning & release patterns +- Project version stored in the top-level `VERSION` file and echoed in `internal/models/service/system.go` (`Version` variable) and swagger docs under `docs/`. +- Releases are created by updating those files and tagging the commit (e.g., `v1.8.2`). Avoid editing `go.sum` manually. + +5) Build, test, and release commands +- Build: `make build` or `go build ./...`. +- Tests: `make test` or `go test ./...` (many packages have no tests; run targeted packages if needed). +- Docker: `./scripts/build-docker.sh` and `docker-compose.yml` present. + +6) Project-specific conventions +- Handler constructors follow: `NewXxxHandler(service *services.XxxService, config *config.ConfigManager) *XxxHandler`. +- Services are created with dependency injection in `routes` during server start: e.g., `services.NewUserService(daoManager, manager)`. +- Repositories live under `internal/repository/` and expose `RepositoryManager` for DAOs (use `dmgr *repository.RepositoryManager`). +- Error responses use helper functions in `internal/common/` (`common.SuccessResponse`, `common.ErrorResponse`, `common.BadRequestResponse`). + +7) Integration points to inspect when editing code +- `internal/config/manager.go` — config lifecycle, hot reload hooks. +- `internal/routes/setup.go` — server and route wiring, DI order. +- `internal/mcp/manager.go` — MCP lifecycle and tools registration. +- `internal/storage/*` — adding new storage backends: implement `storage.StorageInterface` and register in `storage.NewStorageManager`. + +8) Useful file examples to copy patterns from +- `internal/services/*` shows DI, error wrapping and transaction handling (e.g., `internal/services/admin/*`). +- `internal/routes/*` shows route grouping and middleware usage, look at `internal/routes/admin.go` for admin auth patterns. + +9) CSS / Frontend notes +- Frontend themes under `themes/2025/`. Admin UI assets are served via protected routes — modifying admin CSS may require rebuilding or restarting server to pick static changes when not using hot reload. + +10) Safety & version control +- Do not alter `go.sum` manually. +- When creating tags, prefer not to overwrite remote tags; create a new semver or `-local.` tag if necessary. + +If any section is unclear or you want examples added (e.g., sample PR description, changelog generation command), tell me which part to expand. After your feedback I'll iterate. # FileCodeBox AI Coding Agent Instructions ## Project Overview diff --git a/VERSION b/VERSION index 53adb84..9ab8337 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.8.2 +1.9.1 diff --git a/internal/config/manager.go b/internal/config/manager.go index 79b043a..949cadf 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -2,6 +2,7 @@ package config import ( "errors" + "fmt" "os" "strings" @@ -171,22 +172,7 @@ func (cm *ConfigManager) ReloadConfig() error { // PersistYAML 将当前配置保存到 YAML 文件 func (cm *ConfigManager) PersistYAML() error { - configPath := os.Getenv("CONFIG_PATH") - if configPath == "" { - configPath = "./config.yaml" - } - - // 确保 UI 配置存在 - if cm.UI == nil { - cm.UI = &UIConfig{} - } - - data, err := yaml.Marshal(cm) - if err != nil { - return err - } - - return os.WriteFile(configPath, data, 0644) + return writeConfigToPath(cm.configFilePath(), cm) } // applyEnvironmentOverrides 应用环境变量覆盖配置 @@ -200,6 +186,54 @@ func (cm *ConfigManager) Save() error { return errors.New("数据库配置保存已不支持,请使用 config.yaml 和环境变量") } +// UpdateTransaction 在配置上执行事务式更新:先克隆、应用变更、落盘、重载;若任一步失败则回滚到原状态并恢复文件 +func (cm *ConfigManager) UpdateTransaction(apply func(draft *ConfigManager) error) error { + if apply == nil { + return errors.New("更新操作不能为空") + } + + original := cm.Clone() + draft := cm.Clone() + + if err := apply(draft); err != nil { + return err + } + + if err := draft.Validate(); err != nil { + return err + } + + cm.applyFrom(draft) + path := cm.configFilePath() + + if err := writeConfigToPath(path, cm); err != nil { + cm.applyFrom(original) + _ = writeConfigToPath(path, original) + return err + } + + reloaded := NewConfigManager() + if err := reloaded.LoadFromYAML(path); err != nil { + cm.applyFrom(original) + _ = writeConfigToPath(path, original) + return err + } + reloaded.applyEnvironmentOverrides() + reloaded.db = cm.db + + cm.applyFrom(reloaded) + + if err := cm.ReloadConfig(); err != nil { + cm.applyFrom(original) + if rollbackErr := writeConfigToPath(path, original); rollbackErr != nil { + return errors.Join(err, fmt.Errorf("rollback failed: %w", rollbackErr)) + } + return err + } + + return nil +} + // === 配置访问助手方法 === func (cm *ConfigManager) GetAddress() string { return cm.Base.GetAddress() } @@ -223,6 +257,13 @@ func (cm *ConfigManager) Clone() *ConfigManager { newManager.NotifyTitle = cm.NotifyTitle newManager.NotifyContent = cm.NotifyContent newManager.SysStart = cm.SysStart + newManager.UploadMinute = cm.UploadMinute + newManager.UploadCount = cm.UploadCount + newManager.ErrorMinute = cm.ErrorMinute + newManager.ErrorCount = cm.ErrorCount + if len(cm.ExpireStyle) > 0 { + newManager.ExpireStyle = append([]string(nil), cm.ExpireStyle...) + } // 克隆 UI 配置 if cm.UI != nil { @@ -233,6 +274,94 @@ func (cm *ConfigManager) Clone() *ConfigManager { return newManager } +// applyFrom 用于将另外一个配置实例的内容拷贝到当前实例,保留数据库连接句柄 +func (cm *ConfigManager) applyFrom(src *ConfigManager) { + if src == nil { + return + } + + db := cm.db + + if src.Base != nil { + cm.Base = src.Base.Clone() + } else { + cm.Base = nil + } + + if src.Database != nil { + cm.Database = src.Database.Clone() + } else { + cm.Database = nil + } + + if src.Transfer != nil { + cm.Transfer = src.Transfer.Clone() + } else { + cm.Transfer = nil + } + + if src.Storage != nil { + cm.Storage = src.Storage.Clone() + } else { + cm.Storage = nil + } + + if src.User != nil { + cm.User = src.User.Clone() + } else { + cm.User = nil + } + + if src.MCP != nil { + cm.MCP = src.MCP.Clone() + } else { + cm.MCP = nil + } + + if src.UI != nil { + ui := *src.UI + cm.UI = &ui + } else { + cm.UI = nil + } + + cm.NotifyTitle = src.NotifyTitle + cm.NotifyContent = src.NotifyContent + cm.SysStart = src.SysStart + cm.UploadMinute = src.UploadMinute + cm.UploadCount = src.UploadCount + cm.ErrorMinute = src.ErrorMinute + cm.ErrorCount = src.ErrorCount + cm.ExpireStyle = append([]string(nil), src.ExpireStyle...) + + cm.db = db +} + +func (cm *ConfigManager) configFilePath() string { + if path := os.Getenv("CONFIG_PATH"); path != "" { + return path + } + return "./config.yaml" +} + +func writeConfigToPath(path string, cfg *ConfigManager) error { + if cfg == nil { + return errors.New("配置不能为空") + } + + clone := cfg.Clone() + if clone.UI == nil { + clone.UI = &UIConfig{} + } + + data, err := yaml.Marshal(clone) + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} + // Validate 验证所有配置模块 func (cm *ConfigManager) Validate() error { // 检查配置模块是否初始化 diff --git a/internal/config/manager_test.go b/internal/config/manager_test.go index 5109ffc..1be0b17 100644 --- a/internal/config/manager_test.go +++ b/internal/config/manager_test.go @@ -2,6 +2,7 @@ package config import ( "os" + "path/filepath" "testing" "gopkg.in/yaml.v3" @@ -81,3 +82,66 @@ func TestApplySourcesAggregatesErrors(t *testing.T) { t.Fatalf("expected aggregated error when environment value invalid") } } + +func TestUpdateTransactionPersistsAndReloads(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.yaml") + if err := os.Setenv("CONFIG_PATH", configPath); err != nil { + t.Fatalf("setenv failed: %v", err) + } + defer os.Unsetenv("CONFIG_PATH") + + cm := NewConfigManager() + if err := cm.UpdateTransaction(func(draft *ConfigManager) error { + draft.Base.Name = "Transactional" + draft.NotifyTitle = "updated" + return nil + }); err != nil { + t.Fatalf("UpdateTransaction returned error: %v", err) + } + + if cm.Base.Name != "Transactional" { + t.Fatalf("expected in-memory base name updated, got %s", cm.Base.Name) + } + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read persisted config: %v", err) + } + + var persisted ConfigManager + if err := yaml.Unmarshal(data, &persisted); err != nil { + t.Fatalf("failed to unmarshal persisted config: %v", err) + } + if persisted.Base == nil || persisted.Base.Name != "Transactional" { + t.Fatalf("expected persisted base name, got %#v", persisted.Base) + } +} + +func TestUpdateTransactionRollbackOnPersistFailure(t *testing.T) { + tempDir := t.TempDir() + badPath := filepath.Join(tempDir, "missing", "config.yaml") + if err := os.Setenv("CONFIG_PATH", badPath); err != nil { + t.Fatalf("setenv failed: %v", err) + } + defer os.Unsetenv("CONFIG_PATH") + + cm := NewConfigManager() + originalName := cm.Base.Name + + err := cm.UpdateTransaction(func(draft *ConfigManager) error { + draft.Base.Name = "ShouldRollback" + return nil + }) + if err == nil { + t.Fatalf("expected error when persisting to missing directory") + } + + if cm.Base.Name != originalName { + t.Fatalf("expected rollback to restore base name, got %s", cm.Base.Name) + } + + if _, err := os.Stat(badPath); !os.IsNotExist(err) { + t.Fatalf("expected no config file created, stat err=%v", err) + } +} diff --git a/internal/database/database.go b/internal/database/database.go index 3dc7c8e..e1981ff 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -46,6 +46,7 @@ func InitWithManager(manager *config.ConfigManager) (*gorm.DB, error) { &models.User{}, &models.UserSession{}, &models.TransferLog{}, + &models.AdminOperationLog{}, ) if err != nil { return nil, fmt.Errorf("数据库自动迁移失败: %w", err) diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go deleted file mode 100644 index 066d993..0000000 --- a/internal/handlers/admin.go +++ /dev/null @@ -1,1306 +0,0 @@ -package handlers - -import ( - "fmt" - "net" - "strconv" - "strings" - "time" - - "github.com/zy84338719/filecodebox/internal/common" - "github.com/zy84338719/filecodebox/internal/config" - "github.com/zy84338719/filecodebox/internal/models/web" - "github.com/zy84338719/filecodebox/internal/services" - "github.com/zy84338719/filecodebox/internal/utils" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" -) - -// AdminHandler 管理处理器 -type AdminHandler struct { - service *services.AdminService - config *config.ConfigManager -} - -func NewAdminHandler(service *services.AdminService, config *config.ConfigManager) *AdminHandler { - return &AdminHandler{ - service: service, - config: config, - } -} - -// Login 管理员登录 -func (h *AdminHandler) Login(c *gin.Context) { - var req web.AdminLoginRequest - if !utils.BindJSONWithValidation(c, &req) { - return - } - - // 使用 AdminService 进行管理员凭据验证并生成 token - tokenString, err := h.service.GenerateTokenForAdmin(req.Username, req.Password) - if err != nil { - common.UnauthorizedResponse(c, "认证失败: "+err.Error()) - return - } - - response := web.AdminLoginResponse{ - Token: tokenString, - TokenType: "Bearer", - ExpiresIn: 24 * 60 * 60, // 24小时,单位秒 - } - - common.SuccessWithMessage(c, "登录成功", response) -} - -// Dashboard 仪表盘 -func (h *AdminHandler) Dashboard(c *gin.Context) { - stats, err := h.service.GetStats() - if err != nil { - common.InternalServerErrorResponse(c, "获取统计信息失败: "+err.Error()) - return - } - - common.SuccessResponse(c, stats) -} - -// GetStats 获取统计信息 -func (h *AdminHandler) GetStats(c *gin.Context) { - stats, err := h.service.GetStats() - if err != nil { - common.InternalServerErrorResponse(c, "获取统计信息失败: "+err.Error()) - return - } - - common.SuccessResponse(c, stats) -} - -// GetFiles 获取文件列表 -func (h *AdminHandler) GetFiles(c *gin.Context) { - pagination := utils.ParsePaginationParams(c) - - files, total, err := h.service.GetFiles(pagination.Page, pagination.PageSize, pagination.Search) - if err != nil { - common.InternalServerErrorResponse(c, "获取文件列表失败: "+err.Error()) - return - } - - common.SuccessWithPagination(c, files, int(total), pagination.Page, pagination.PageSize) -} - -// DeleteFile 删除文件 -func (h *AdminHandler) DeleteFile(c *gin.Context) { - code := c.Param("code") - if code == "" { - common.BadRequestResponse(c, "文件代码不能为空") - return - } - - err := h.service.DeleteFileByCode(code) - if err != nil { - common.InternalServerErrorResponse(c, "删除失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "删除成功", nil) -} - -// GetFile 获取单个文件信息 -func (h *AdminHandler) GetFile(c *gin.Context) { - code := c.Param("code") - if code == "" { - common.BadRequestResponse(c, "文件代码不能为空") - return - } - - fileCode, err := h.service.GetFileByCode(code) - if err != nil { - common.NotFoundResponse(c, "文件不存在") - return - } - - common.SuccessResponse(c, fileCode) -} - -// GetConfig 获取配置 -func (h *AdminHandler) GetConfig(c *gin.Context) { - cfg := h.service.GetFullConfig() - resp := web.AdminConfigResponse{ - AdminConfigRequest: web.AdminConfigRequest{ - Base: cfg.Base, - Database: cfg.Database, - Transfer: cfg.Transfer, - Storage: cfg.Storage, - User: cfg.User, - MCP: cfg.MCP, - UI: cfg.UI, - SysStart: &cfg.SysStart, - }, - } - - resp.NotifyTitle = &cfg.NotifyTitle - resp.NotifyContent = &cfg.NotifyContent - common.SuccessResponse(c, resp) -} - -// UpdateConfig 更新配置 -func (h *AdminHandler) UpdateConfig(c *gin.Context) { - - // 绑定为 AdminConfigRequest 并使用服务层处理(服务会构建 map 并持久化) - var req web.AdminConfigRequest - if err := c.ShouldBind(&req); err != nil { - common.BadRequestResponse(c, "请求参数错误: "+err.Error()) - return - } - - if err := h.service.UpdateConfigFromRequest(&req); err != nil { - common.InternalServerErrorResponse(c, "更新配置失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "更新成功", nil) -} - -// CleanExpiredFiles 清理过期文件 -func (h *AdminHandler) CleanExpiredFiles(c *gin.Context) { - // TODO: 修复服务方法调用 - //count, err := h.service.CleanupExpiredFiles() - //if err != nil { - // common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) - // return - //} - - // 临时返回 - common.SuccessWithMessage(c, "清理完成", web.CleanedCountResponse{CleanedCount: 0}) -} - -// CleanTempFiles 清理临时文件 -func (h *AdminHandler) CleanTempFiles(c *gin.Context) { - count, err := h.service.CleanTempFiles() - if err != nil { - common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, fmt.Sprintf("清理了 %d 个临时文件", count), web.CountResponse{Count: count}) -} - -// CleanInvalidRecords 清理无效记录 -func (h *AdminHandler) CleanInvalidRecords(c *gin.Context) { - count, err := h.service.CleanInvalidRecords() - if err != nil { - common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, fmt.Sprintf("清理了 %d 个无效记录", count), web.CountResponse{Count: count}) -} - -// OptimizeDatabase 优化数据库 -func (h *AdminHandler) OptimizeDatabase(c *gin.Context) { - err := h.service.OptimizeDatabase() - if err != nil { - common.InternalServerErrorResponse(c, "优化失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "数据库优化完成", nil) -} - -// AnalyzeDatabase 分析数据库 -func (h *AdminHandler) AnalyzeDatabase(c *gin.Context) { - stats, err := h.service.AnalyzeDatabase() - if err != nil { - common.InternalServerErrorResponse(c, "分析失败: "+err.Error()) - return - } - - common.SuccessResponse(c, stats) -} - -// BackupDatabase 备份数据库 -func (h *AdminHandler) BackupDatabase(c *gin.Context) { - backupPath, err := h.service.BackupDatabase() - if err != nil { - common.InternalServerErrorResponse(c, "备份失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "数据库备份完成", web.BackupPathResponse{BackupPath: backupPath}) -} - -// ClearSystemCache 清理系统缓存 -func (h *AdminHandler) ClearSystemCache(c *gin.Context) { - err := h.service.ClearSystemCache() - if err != nil { - common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "系统缓存清理完成", nil) -} - -// ClearUploadCache 清理上传缓存 -func (h *AdminHandler) ClearUploadCache(c *gin.Context) { - err := h.service.ClearUploadCache() - if err != nil { - common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "上传缓存清理完成", nil) -} - -// ClearDownloadCache 清理下载缓存 -func (h *AdminHandler) ClearDownloadCache(c *gin.Context) { - err := h.service.ClearDownloadCache() - if err != nil { - common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "下载缓存清理完成", nil) -} - -// GetSystemInfo 获取系统信息 -func (h *AdminHandler) GetSystemInfo(c *gin.Context) { - info, err := h.service.GetSystemInfo() - if err != nil { - common.InternalServerErrorResponse(c, "获取系统信息失败: "+err.Error()) - return - } - - common.SuccessResponse(c, info) -} - -// GetStorageStatus 获取存储状态 -func (h *AdminHandler) GetStorageStatus(c *gin.Context) { - status, err := h.service.GetStorageStatus() - if err != nil { - common.InternalServerErrorResponse(c, "获取存储状态失败: "+err.Error()) - return - } - - common.SuccessResponse(c, status) -} - -// GetPerformanceMetrics 获取性能指标 -func (h *AdminHandler) GetPerformanceMetrics(c *gin.Context) { - metrics, err := h.service.GetPerformanceMetrics() - if err != nil { - common.InternalServerErrorResponse(c, "获取性能指标失败: "+err.Error()) - return - } - - common.SuccessResponse(c, metrics) -} - -// ScanSecurity 安全扫描 -func (h *AdminHandler) ScanSecurity(c *gin.Context) { - result, err := h.service.ScanSecurity() - if err != nil { - common.InternalServerErrorResponse(c, "安全扫描失败: "+err.Error()) - return - } - - common.SuccessResponse(c, result) -} - -// CheckPermissions 检查权限 -func (h *AdminHandler) CheckPermissions(c *gin.Context) { - result, err := h.service.CheckPermissions() - if err != nil { - common.InternalServerErrorResponse(c, "权限检查失败: "+err.Error()) - return - } - - common.SuccessResponse(c, result) -} - -// CheckIntegrity 检查完整性 -func (h *AdminHandler) CheckIntegrity(c *gin.Context) { - result, err := h.service.CheckIntegrity() - if err != nil { - common.InternalServerErrorResponse(c, "完整性检查失败: "+err.Error()) - return - } - - common.SuccessResponse(c, result) -} - -// ClearSystemLogs 清理系统日志 -func (h *AdminHandler) ClearSystemLogs(c *gin.Context) { - count, err := h.service.ClearSystemLogs() - if err != nil { - common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, fmt.Sprintf("清理了 %d 条系统日志", count), web.CountResponse{Count: count}) -} - -// ClearAccessLogs 清理访问日志 -func (h *AdminHandler) ClearAccessLogs(c *gin.Context) { - count, err := h.service.ClearAccessLogs() - if err != nil { - common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, fmt.Sprintf("清理了 %d 条访问日志", count), web.CountResponse{Count: count}) -} - -// ClearErrorLogs 清理错误日志 -func (h *AdminHandler) ClearErrorLogs(c *gin.Context) { - count, err := h.service.ClearErrorLogs() - if err != nil { - common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, fmt.Sprintf("清理了 %d 条错误日志", count), web.CountResponse{Count: count}) -} - -// ExportLogs 导出日志 -func (h *AdminHandler) ExportLogs(c *gin.Context) { - logType := c.DefaultQuery("type", "system") - - logPath, err := h.service.ExportLogs(logType) - if err != nil { - common.InternalServerErrorResponse(c, "导出失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "日志导出完成", web.LogPathResponse{LogPath: logPath}) -} - -// GetLogStats 获取日志统计 -func (h *AdminHandler) GetLogStats(c *gin.Context) { - stats, err := h.service.GetLogStats() - if err != nil { - common.InternalServerErrorResponse(c, "获取日志统计失败: "+err.Error()) - return - } - - common.SuccessResponse(c, stats) -} - -// UpdateFile 更新文件信息 -func (h *AdminHandler) UpdateFile(c *gin.Context) { - // 从URL参数获取code - code := c.Param("code") - if code == "" { - common.BadRequestResponse(c, "文件代码不能为空") - return - } - - var updateData struct { - Code string `json:"code"` - Text string `json:"text"` - ExpiredAt *time.Time `json:"expired_at"` - ExpiredCount *int `json:"expired_count"` - } - - if err := c.ShouldBindJSON(&updateData); err != nil { - common.BadRequestResponse(c, "参数错误: "+err.Error()) - return - } - - // 获取现有文件信息 - _, err := h.service.GetFileByCode(code) - if err != nil { - common.NotFoundResponse(c, "文件不存在") - return - } - - // 更新字段 - var expiredAt *time.Time - if updateData.ExpiredAt != nil { - expiredAt = updateData.ExpiredAt - } - - // 保存更新 - 使用UpdateFileByCode方法 - var expTime time.Time - if expiredAt != nil { - expTime = *expiredAt - } - err = h.service.UpdateFileByCode(code, updateData.Code, "", expTime) - if err != nil { - common.InternalServerErrorResponse(c, "更新失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "更新成功", nil) -} - -// DownloadFile 下载文件(管理员) -func (h *AdminHandler) DownloadFile(c *gin.Context) { - code := c.Query("code") - if code == "" { - common.BadRequestResponse(c, "文件代码不能为空") - return - } - - fileCode, err := h.service.GetFileByCode(code) - if err != nil { - common.NotFoundResponse(c, "文件不存在") - return - } - - if fileCode.Text != "" { - // 文本内容 - fileName := fileCode.Prefix + ".txt" - c.Header("Content-Disposition", "attachment; filename=\""+fileName+"\"") - c.Header("Content-Type", "text/plain") - c.String(200, fileCode.Text) - return - } - - // 文件下载 - 通过存储管理器 - filePath := fileCode.GetFilePath() - if filePath == "" { - common.NotFoundResponse(c, "文件路径为空") - return - } - - err = h.service.ServeFile(c, fileCode) - if err != nil { - common.InternalServerErrorResponse(c, "文件下载失败: "+err.Error()) - return - } -} - -// ========== 用户管理相关方法 ========== - -// GetUsers 获取用户列表 -func (h *AdminHandler) GetUsers(c *gin.Context) { - // 解析分页参数 - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) - search := c.Query("search") - - if page < 1 { - page = 1 - } - if pageSize < 1 || pageSize > 100 { - pageSize = 20 - } - - // 从数据库获取用户列表 - users, total, err := h.getUsersFromDB(page, pageSize, search) - if err != nil { - common.InternalServerErrorResponse(c, "获取用户列表失败: "+err.Error()) - return - } - - // 计算统计信息 - stats, err := h.getUserStats() - if err != nil { - // 如果统计失败,使用默认值但不阻止接口 - stats = &web.AdminUserStatsResponse{ - TotalUsers: total, - ActiveUsers: total, - TodayRegistrations: 0, - TodayUploads: 0, - } - } - - pagination := web.PaginationResponse{ - Page: page, - PageSize: pageSize, - Total: total, - Pages: (total + int64(pageSize) - 1) / int64(pageSize), - } - - common.SuccessResponse(c, web.AdminUsersListResponse{ - Users: users, - Stats: *stats, - Pagination: pagination, - }) -} - -// getUsersFromDB 从数据库获取用户列表 -func (h *AdminHandler) getUsersFromDB(page, pageSize int, search string) ([]web.AdminUserDetail, int64, error) { - users, total, err := h.service.GetUsers(page, pageSize, search) - if err != nil { - return nil, 0, err - } - - // 转换为返回格式 - result := make([]web.AdminUserDetail, len(users)) - for i, user := range users { - lastLoginAt := "" - if user.LastLoginAt != nil { - lastLoginAt = user.LastLoginAt.Format("2006-01-02 15:04:05") - } - - result[i] = web.AdminUserDetail{ - ID: user.ID, - Username: user.Username, - Email: user.Email, - Nickname: user.Nickname, - Role: user.Role, - IsAdmin: user.Role == "admin", - IsActive: user.Status == "active", - Status: user.Status, - EmailVerified: user.EmailVerified, - CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"), - LastLoginAt: lastLoginAt, - LastLoginIP: user.LastLoginIP, - TotalUploads: user.TotalUploads, - TotalDownloads: user.TotalDownloads, - TotalStorage: user.TotalStorage, - } - } - - return result, total, nil -} - -// getUserStats 获取用户统计信息 -func (h *AdminHandler) getUserStats() (*web.AdminUserStatsResponse, error) { - stats, err := h.service.GetStats() - if err != nil { - return nil, err - } - - return &web.AdminUserStatsResponse{ - TotalUsers: stats.TotalUsers, - ActiveUsers: stats.ActiveUsers, - TodayRegistrations: stats.TodayRegistrations, - TodayUploads: stats.TodayUploads, - }, nil -} - -// GetUser 获取单个用户 -func (h *AdminHandler) GetUser(c *gin.Context) { - userID, ok := utils.ParseUserIDFromParam(c, "id") - if !ok { - return - } - - user, err := h.service.GetUserByID(userID) - if err != nil { - common.NotFoundResponse(c, "用户不存在") - return - } - - lastLoginAt := "" - if user.LastLoginAt != nil { - lastLoginAt = user.LastLoginAt.Format("2006-01-02 15:04:05") - } - - userDetail := web.AdminUserDetail{ - ID: user.ID, - Username: user.Username, - Email: user.Email, - Nickname: user.Nickname, - Role: user.Role, - IsAdmin: user.Role == "admin", - IsActive: user.Status == "active", - Status: user.Status, - EmailVerified: user.EmailVerified, - CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"), - LastLoginAt: lastLoginAt, - LastLoginIP: user.LastLoginIP, - TotalUploads: user.TotalUploads, - TotalDownloads: user.TotalDownloads, - TotalStorage: user.TotalStorage, - } - - common.SuccessResponse(c, userDetail) -} - -// CreateUser 创建用户 -func (h *AdminHandler) CreateUser(c *gin.Context) { - var userData web.UserDataRequest - if !utils.BindJSONWithValidation(c, &userData) { - return - } - - // 准备参数 - role := "user" - if userData.IsAdmin { - role = "admin" - } - - status := "active" - if !userData.IsActive { - status = "inactive" - } - - // 创建用户 - user, err := h.service.CreateUser(userData.Username, userData.Email, userData.Password, userData.Nickname, role, status) - if err != nil { - common.InternalServerErrorResponse(c, "创建用户失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "用户创建成功", web.IDResponse{ - ID: user.ID, - }) -} - -// UpdateUser 更新用户 -func (h *AdminHandler) UpdateUser(c *gin.Context) { - userID, ok := utils.ParseUserIDFromParam(c, "id") - if !ok { - return - } - - var userData struct { - Email *string `json:"email" binding:"omitempty,email"` - Password *string `json:"password"` - Nickname *string `json:"nickname"` - IsAdmin *bool `json:"is_admin"` - IsActive *bool `json:"is_active"` - } - - if !utils.BindJSONWithValidation(c, &userData) { - return - } - - params := services.AdminUserUpdateParams{ - Email: userData.Email, - Password: userData.Password, - Nickname: userData.Nickname, - IsAdmin: userData.IsAdmin, - IsActive: userData.IsActive, - } - - err := h.service.UpdateUserWithParams(userID, params) - if err != nil { - common.InternalServerErrorResponse(c, "更新用户失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "用户更新成功", web.IDResponse{ - ID: userID, - }) -} - -// DeleteUser 删除用户 -func (h *AdminHandler) DeleteUser(c *gin.Context) { - userIDStr := c.Param("id") - userID64, err := strconv.ParseUint(userIDStr, 10, 32) - if err != nil { - common.BadRequestResponse(c, "用户ID错误") - return - } - - userID := uint(userID64) - - // 删除用户 - err = h.service.DeleteUser(userID) - if err != nil { - common.InternalServerErrorResponse(c, "删除用户失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "用户删除成功", web.IDResponse{ - ID: userID, - }) -} - -// UpdateUserStatus 更新用户状态 -func (h *AdminHandler) UpdateUserStatus(c *gin.Context) { - userIDStr := c.Param("id") - userID64, err := strconv.ParseUint(userIDStr, 10, 32) - if err != nil { - common.BadRequestResponse(c, "用户ID错误") - return - } - - var statusData struct { - IsActive bool `json:"is_active"` - } - - if err := c.ShouldBindJSON(&statusData); err != nil { - common.BadRequestResponse(c, "参数错误: "+err.Error()) - return - } - - userID := uint(userID64) - - // 更新用户状态 - err = h.service.UpdateUserStatus(userID, statusData.IsActive) - if err != nil { - common.InternalServerErrorResponse(c, "更新用户状态失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "用户状态更新成功", web.IDResponse{ - ID: userID, - }) -} - -// GetUserFiles 获取用户文件 -func (h *AdminHandler) GetUserFiles(c *gin.Context) { - userIDStr := c.Param("id") - userID64, err := strconv.ParseUint(userIDStr, 10, 32) - if err != nil { - common.BadRequestResponse(c, "用户ID错误") - return - } - - userID := uint(userID64) - - // 获取用户信息 - user, err := h.service.GetUserByID(userID) - if err != nil { - common.NotFoundResponse(c, "用户不存在") - return - } - - // 获取用户的文件列表 - page := 1 // 默认第一页 - limit := 20 // 默认每页20条 - - files, total, err := h.service.GetUserFiles(userID, page, limit) - if err != nil { - common.InternalServerErrorResponse(c, "获取用户文件失败: "+err.Error()) - return - } - - // 转换为返回格式 - fileList := make([]web.AdminFileDetail, len(files)) - for i, file := range files { - expiredAt := "" - if file.ExpiredAt != nil { - expiredAt = file.ExpiredAt.Format("2006-01-02 15:04:05") - } - - fileType := "文件" - if file.Text != "" { - fileType = "文本" - } - - fileList[i] = web.AdminFileDetail{ - ID: file.ID, - Code: file.Code, - Prefix: file.Prefix, - Suffix: file.Suffix, - Size: file.Size, - Type: fileType, - ExpiredAt: expiredAt, - ExpiredCount: file.ExpiredCount, - UsedCount: file.UsedCount, - CreatedAt: file.CreatedAt.Format("2006-01-02 15:04:05"), - RequireAuth: file.RequireAuth, - UploadType: file.UploadType, - } - } - - common.SuccessResponse(c, web.AdminUserFilesResponse{ - Files: fileList, - Username: user.Username, - Total: total, - }) -} - -// ExportUsers 导出用户列表为CSV -func (h *AdminHandler) ExportUsers(c *gin.Context) { - // 获取所有用户数据 - users, _, err := h.getUsersFromDB(1, 10000, "") // 获取大量用户数据用于导出 - if err != nil { - common.InternalServerErrorResponse(c, "获取用户数据失败: "+err.Error()) - return - } - - // 生成CSV内容 - csvContent := "用户名,邮箱,昵称,状态,注册时间,最后登录,上传次数,下载次数,存储大小(MB)\n" - for _, user := range users { - status := "正常" - if user.Status == "disabled" || user.Status == "inactive" { - status = "禁用" - } - - lastLoginTime := "从未登录" - if user.LastLoginAt != "" { - lastLoginTime = user.LastLoginAt - } - - csvContent += fmt.Sprintf("%s,%s,%s,%s,%s,%s,%d,%d,%.2f\n", - user.Username, - user.Email, - user.Nickname, - status, - user.CreatedAt, - lastLoginTime, - user.TotalUploads, - user.TotalDownloads, - float64(user.TotalStorage)/(1024*1024), // 转换为MB - ) - } - - // 添加UTF-8 BOM以确保Excel正确显示中文 - bomContent := "\xEF\xBB\xBF" + csvContent - - // 设置响应头(Content-Length 使用实际发送的字节长度) - c.Header("Content-Type", "text/csv; charset=utf-8") - c.Header("Content-Disposition", "attachment; filename=users_export.csv") - c.Header("Content-Length", strconv.Itoa(len([]byte(bomContent)))) - - // 使用 Write 写入原始字节,避免框架对长度的二次处理 - c.Writer.WriteHeader(200) - _, _ = c.Writer.Write([]byte(bomContent)) -} - -// BatchEnableUsers 批量启用用户 -func (h *AdminHandler) BatchEnableUsers(c *gin.Context) { - var req struct { - UserIDs []uint `json:"user_ids" binding:"required"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - common.BadRequestResponse(c, "参数错误: "+err.Error()) - return - } - - if len(req.UserIDs) == 0 { - common.BadRequestResponse(c, "user_ids 不能为空") - return - } - - if err := h.service.BatchUpdateUserStatus(req.UserIDs, true); err != nil { - common.InternalServerErrorResponse(c, "批量启用用户失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "批量启用成功", nil) -} - -// BatchDisableUsers 批量禁用用户 -func (h *AdminHandler) BatchDisableUsers(c *gin.Context) { - var req struct { - UserIDs []uint `json:"user_ids" binding:"required"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - common.BadRequestResponse(c, "参数错误: "+err.Error()) - return - } - - if len(req.UserIDs) == 0 { - common.BadRequestResponse(c, "user_ids 不能为空") - return - } - - if err := h.service.BatchUpdateUserStatus(req.UserIDs, false); err != nil { - common.InternalServerErrorResponse(c, "批量禁用用户失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "批量禁用成功", nil) -} - -// BatchDeleteUsers 批量删除用户 -func (h *AdminHandler) BatchDeleteUsers(c *gin.Context) { - var req struct { - UserIDs []uint `json:"user_ids" binding:"required"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - common.BadRequestResponse(c, "参数错误: "+err.Error()) - return - } - - if len(req.UserIDs) == 0 { - common.BadRequestResponse(c, "user_ids 不能为空") - return - } - - if err := h.service.BatchDeleteUsers(req.UserIDs); err != nil { - common.InternalServerErrorResponse(c, "批量删除用户失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "批量删除成功", nil) -} - -// GetMCPConfig 获取 MCP 配置 -func (h *AdminHandler) GetMCPConfig(c *gin.Context) { - common.SuccessResponse(c, h.config.MCP) -} - -// UpdateMCPConfig 更新 MCP 配置 -func (h *AdminHandler) UpdateMCPConfig(c *gin.Context) { - var mcpConfig struct { - EnableMCPServer *int `json:"enable_mcp_server"` - MCPPort *string `json:"mcp_port"` - MCPHost *string `json:"mcp_host"` - } - - if err := c.ShouldBindJSON(&mcpConfig); err != nil { - common.BadRequestResponse(c, "MCP配置参数错误: "+err.Error()) - return - } - - // 直接更新配置结构 - if mcpConfig.EnableMCPServer != nil { - h.config.MCP.EnableMCPServer = *mcpConfig.EnableMCPServer - } - if mcpConfig.MCPPort != nil { - h.config.MCP.MCPPort = *mcpConfig.MCPPort - } - if mcpConfig.MCPHost != nil { - h.config.MCP.MCPHost = *mcpConfig.MCPHost - } - - // 保存配置 - err := h.config.PersistYAML() - if err != nil { - common.InternalServerErrorResponse(c, "保存MCP配置失败: "+err.Error()) - return - } - - // 重新加载配置(从 config.yaml 与环境变量) - err = h.config.ReloadConfig() - if err != nil { - common.InternalServerErrorResponse(c, "重新加载配置失败: "+err.Error()) - return - } - - // 应用MCP配置更改 - mcpManager := GetMCPManager() - if mcpManager != nil { - enableMCP := h.config.MCP.EnableMCPServer == 1 - port := h.config.MCP.MCPPort - - err = mcpManager.ApplyConfig(enableMCP, port) - if err != nil { - common.InternalServerErrorResponse(c, "应用MCP配置失败: "+err.Error()) - return - } - } - - common.SuccessWithMessage(c, "MCP配置更新成功", nil) -} - -// GetMCPStatus 获取 MCP 服务器状态 -func (h *AdminHandler) GetMCPStatus(c *gin.Context) { - mcpManager := GetMCPManager() - if mcpManager == nil { - common.InternalServerErrorResponse(c, "MCP管理器未初始化") - return - } - - status := mcpManager.GetStatus() - - statusText := "inactive" - if status.Running { - statusText = "active" - } - - response := web.MCPStatusResponse{ - Status: statusText, - Config: h.config.MCP, - } - - common.SuccessResponse(c, response) -} - -// RestartMCPServer 重启 MCP 服务器 -func (h *AdminHandler) RestartMCPServer(c *gin.Context) { - mcpManager := GetMCPManager() - if mcpManager == nil { - common.InternalServerErrorResponse(c, "MCP管理器未初始化") - return - } - - // 检查是否启用了MCP服务器 - if h.config.MCP.EnableMCPServer != 1 { - common.BadRequestResponse(c, "MCP服务器未启用") - return - } - - err := mcpManager.RestartMCPServer(h.config.MCP.MCPPort) - if err != nil { - common.InternalServerErrorResponse(c, "重启MCP服务器失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "MCP服务器重启成功", nil) -} - -// ControlMCPServer 控制 MCP 服务器(启动/停止) -func (h *AdminHandler) ControlMCPServer(c *gin.Context) { - var controlData struct { - Action string `json:"action" binding:"required"` // "start" 或 "stop" - } - - if err := c.ShouldBindJSON(&controlData); err != nil { - common.BadRequestResponse(c, "参数错误: "+err.Error()) - return - } - - mcpManager := GetMCPManager() - if mcpManager == nil { - common.InternalServerErrorResponse(c, "MCP管理器未初始化") - return - } - - switch controlData.Action { - case "start": - if h.config.MCP.EnableMCPServer != 1 { - common.BadRequestResponse(c, "MCP服务器未启用,请先在配置中启用") - return - } - err := mcpManager.StartMCPServer(h.config.MCP.MCPPort) - if err != nil { - common.InternalServerErrorResponse(c, "启动MCP服务器失败: "+err.Error()) - return - } - common.SuccessWithMessage(c, "MCP服务器启动成功", nil) - - case "stop": - err := mcpManager.StopMCPServer() - if err != nil { - common.InternalServerErrorResponse(c, "停止MCP服务器失败: "+err.Error()) - return - } - common.SuccessWithMessage(c, "MCP服务器停止成功", nil) - - default: - common.BadRequestResponse(c, "无效的操作,只支持 start 或 stop") - } -} - -// TestMCPConnection 测试 MCP 连接 -func (h *AdminHandler) TestMCPConnection(c *gin.Context) { - var testData struct { - Port string `json:"port"` - Host string `json:"host"` - } - - if err := c.ShouldBindJSON(&testData); err != nil { - common.BadRequestResponse(c, "参数错误: "+err.Error()) - return - } - - address, _, _, err := normalizeMCPAddress(testData.Host, testData.Port, h.config) - if err != nil { - common.BadRequestResponse(c, "参数错误: "+err.Error()) - return - } - - if err := tcpProbe(address, 3*time.Second); err != nil { - common.ErrorResponse(c, 400, fmt.Sprintf("连接测试失败: %s,端口可能未开放或MCP服务器未启动", err.Error())) - return - } - - response := web.MCPTestResponse{ - MCPStatusResponse: web.MCPStatusResponse{ - Status: "连接正常", - Config: h.config.MCP, - }, - } - - common.SuccessWithMessage(c, "MCP连接测试成功", response) -} - -// normalizeMCPAddress 解析并验证 host/port,返回用于 TCP 测试的 address、实际 host(用于响应)和 port。 -func normalizeMCPAddress(reqHost, reqPort string, cfg *config.ConfigManager) (address, host, port string, err error) { - // port 优先级:请求参数 -> 配置 -> 默认 8081 - port = reqPort - if port == "" { - port = cfg.MCP.MCPPort - } - if port == "" { - port = "8081" - } - - // 验证端口号 - pnum, perr := strconv.Atoi(port) - if perr != nil || pnum < 1 || pnum > 65535 { - err = fmt.Errorf("无效端口号: %s", port) - return - } - - // host 优先级:请求参数 -> 配置 -> 默认 0.0.0.0 - host = reqHost - if host == "" { - host = cfg.MCP.MCPHost - } - if host == "" { - host = "0.0.0.0" - } - - address = host + ":" + port - if host == "0.0.0.0" { - // 绑定到 0.0.0.0 时,测试本机回环地址 - address = "127.0.0.1:" + port - } - return -} - -// tcpProbe 尝试在给定超时内建立 TCP 连接以检测端口连通性 -func tcpProbe(address string, timeout time.Duration) error { - conn, err := net.DialTimeout("tcp", address, timeout) - if err != nil { - return err - } - defer func() { - if err := conn.Close(); err != nil { - logrus.WithError(err).Warn("关闭 TCP 连接失败") - } - }() - return nil -} - -// GetTransferLogs 获取上传/下载审计日志 -func (h *AdminHandler) GetTransferLogs(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) - if page < 1 { - page = 1 - } - if pageSize <= 0 { - pageSize = 20 - } else if pageSize > 200 { - pageSize = 200 - } - - operation := strings.TrimSpace(c.DefaultQuery("operation", "")) - search := strings.TrimSpace(c.DefaultQuery("search", "")) - - logs, total, err := h.service.GetTransferLogs(page, pageSize, operation, search) - if err != nil { - common.InternalServerErrorResponse(c, "获取传输日志失败: "+err.Error()) - return - } - - items := make([]web.TransferLogItem, 0, len(logs)) - for _, record := range logs { - item := web.TransferLogItem{ - ID: record.ID, - Operation: record.Operation, - FileCode: record.FileCode, - FileName: record.FileName, - FileSize: record.FileSize, - Username: record.Username, - IP: record.IP, - DurationMs: record.DurationMs, - CreatedAt: record.CreatedAt.Format(time.RFC3339), - } - if record.UserID != nil { - id := *record.UserID - item.UserID = &id - } - items = append(items, item) - } - - pages := int64(0) - if pageSize > 0 { - pages = (total + int64(pageSize) - 1) / int64(pageSize) - } - if pages == 0 { - pages = 1 - } - - response := web.TransferLogListResponse{ - Logs: items, - Pagination: web.PaginationResponse{ - Page: page, - PageSize: pageSize, - Total: total, - Pages: pages, - }, - } - - common.SuccessResponse(c, response) -} - -func (h *AdminHandler) GetSystemLogs(c *gin.Context) { - level := c.DefaultQuery("level", "") - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100")) - - if limit <= 0 || limit > 1000 { - limit = 100 - } - - logs, err := h.service.GetSystemLogs(limit) - if err != nil { - common.InternalServerErrorResponse(c, "获取日志失败: "+err.Error()) - return - } - - // 如果指定了日志级别,则过滤日志 - if level != "" { - filteredLogs := make([]string, 0) - for _, log := range logs { - // 简单的级别过滤,实际应该解析日志格式 - if len(log) > 0 && (level == "" || len(log) > 10) { - filteredLogs = append(filteredLogs, log) - } - } - logs = filteredLogs - } - - response := web.LogsResponse{ - Logs: logs, - Total: len(logs), - } - - common.SuccessResponse(c, response) -} - -// GetRunningTasks 获取运行中的任务 -func (h *AdminHandler) GetRunningTasks(c *gin.Context) { - tasks, err := h.service.GetRunningTasks() - if err != nil { - common.InternalServerErrorResponse(c, "获取运行任务失败: "+err.Error()) - return - } - - response := web.TasksResponse{ - Tasks: tasks, - Total: len(tasks), - } - - common.SuccessResponse(c, response) -} - -// CancelTask 取消任务 -func (h *AdminHandler) CancelTask(c *gin.Context) { - taskID := c.Param("id") - if taskID == "" { - common.BadRequestResponse(c, "任务ID不能为空") - return - } - - err := h.service.CancelTask(taskID) - if err != nil { - common.InternalServerErrorResponse(c, "取消任务失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "任务已取消", nil) -} - -// RetryTask 重试任务 -func (h *AdminHandler) RetryTask(c *gin.Context) { - taskID := c.Param("id") - if taskID == "" { - common.BadRequestResponse(c, "任务ID不能为空") - return - } - - err := h.service.RetryTask(taskID) - if err != nil { - common.InternalServerErrorResponse(c, "重试任务失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "任务已重新启动", nil) -} - -// RestartSystem 重启系统 -func (h *AdminHandler) RestartSystem(c *gin.Context) { - err := h.service.RestartSystem() - if err != nil { - common.InternalServerErrorResponse(c, "重启系统失败: "+err.Error()) - return - } - - common.SuccessWithMessage(c, "系统重启指令已发送", nil) -} diff --git a/internal/handlers/admin_audit.go b/internal/handlers/admin_audit.go new file mode 100644 index 0000000..f382655 --- /dev/null +++ b/internal/handlers/admin_audit.go @@ -0,0 +1,130 @@ +package handlers + +import ( + "fmt" + "strconv" + "time" + + "github.com/zy84338719/filecodebox/internal/common" + "github.com/zy84338719/filecodebox/internal/models" + "github.com/zy84338719/filecodebox/internal/models/web" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +func (h *AdminHandler) GetOperationLogs(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + action := c.Query("action") + actor := c.Query("actor") + successParam := c.Query("success") + + var success *bool + if successParam != "" { + switch successParam { + case "true", "1": + v := true + success = &v + case "false", "0": + v := false + success = &v + } + } + + logs, total, err := h.service.GetOperationLogs(page, pageSize, action, actor, success) + if err != nil { + common.InternalServerErrorResponse(c, "获取运维日志失败: "+err.Error()) + return + } + + items := make([]web.AdminOperationLogItem, 0, len(logs)) + for _, logEntry := range logs { + item := web.AdminOperationLogItem{ + ID: logEntry.ID, + Action: logEntry.Action, + Target: logEntry.Target, + Success: logEntry.Success, + Message: logEntry.Message, + ActorName: logEntry.ActorName, + IP: logEntry.IP, + LatencyMs: logEntry.LatencyMs, + CreatedAt: logEntry.CreatedAt.Format(time.RFC3339), + } + if logEntry.ActorID != nil { + id := *logEntry.ActorID + item.ActorID = &id + } + items = append(items, item) + } + + pages := int64(0) + if pageSize > 0 { + pages = (total + int64(pageSize) - 1) / int64(pageSize) + } + if pages == 0 { + pages = 1 + } + + response := web.AdminOperationLogListResponse{ + Logs: items, + Pagination: web.PaginationResponse{ + Page: page, + PageSize: pageSize, + Total: total, + Pages: pages, + }, + } + + common.SuccessResponse(c, response) +} + +func (h *AdminHandler) recordOperationLog(c *gin.Context, action, target string, success bool, msg string, started time.Time) { + if h == nil || h.service == nil { + return + } + + var actorID *uint + if rawID, exists := c.Get("user_id"); exists { + switch v := rawID.(type) { + case uint: + id := v + actorID = &id + case uint64: + id := uint(v) + actorID = &id + case int: + if v >= 0 { + id := uint(v) + actorID = &id + } + } + } + + actorName := "" + if v, exists := c.Get("username"); exists { + actorName = fmt.Sprint(v) + } + if actorName == "" { + actorName = "" + } + + entry := &models.AdminOperationLog{ + Action: action, + Target: target, + Success: success, + Message: msg, + ActorName: actorName, + IP: c.ClientIP(), + } + if actorID != nil { + entry.ActorID = actorID + } + if !started.IsZero() { + entry.LatencyMs = time.Since(started).Milliseconds() + } + + if err := h.service.RecordOperationLog(entry); err != nil { + logrus.WithError(err).Warn("record operation log failed") + } +} diff --git a/internal/handlers/admin_auth.go b/internal/handlers/admin_auth.go new file mode 100644 index 0000000..8969bd3 --- /dev/null +++ b/internal/handlers/admin_auth.go @@ -0,0 +1,53 @@ +package handlers + +import ( + "github.com/zy84338719/filecodebox/internal/common" + "github.com/zy84338719/filecodebox/internal/models/web" + "github.com/zy84338719/filecodebox/internal/utils" + + "github.com/gin-gonic/gin" +) + +// Login 管理员登录 +func (h *AdminHandler) Login(c *gin.Context) { + var req web.AdminLoginRequest + if !utils.BindJSONWithValidation(c, &req) { + return + } + + tokenString, err := h.service.GenerateTokenForAdmin(req.Username, req.Password) + if err != nil { + common.UnauthorizedResponse(c, "认证失败: "+err.Error()) + return + } + + response := web.AdminLoginResponse{ + Token: tokenString, + TokenType: "Bearer", + ExpiresIn: 24 * 60 * 60, + } + + common.SuccessWithMessage(c, "登录成功", response) +} + +// Dashboard 仪表盘 +func (h *AdminHandler) Dashboard(c *gin.Context) { + stats, err := h.service.GetStats() + if err != nil { + common.InternalServerErrorResponse(c, "获取统计信息失败: "+err.Error()) + return + } + + common.SuccessResponse(c, stats) +} + +// GetStats 获取统计信息 +func (h *AdminHandler) GetStats(c *gin.Context) { + stats, err := h.service.GetStats() + if err != nil { + common.InternalServerErrorResponse(c, "获取统计信息失败: "+err.Error()) + return + } + + common.SuccessResponse(c, stats) +} diff --git a/internal/handlers/admin_base.go b/internal/handlers/admin_base.go new file mode 100644 index 0000000..1b12b22 --- /dev/null +++ b/internal/handlers/admin_base.go @@ -0,0 +1,19 @@ +package handlers + +import ( + "github.com/zy84338719/filecodebox/internal/config" + "github.com/zy84338719/filecodebox/internal/services" +) + +// AdminHandler 管理处理器 +type AdminHandler struct { + service *services.AdminService + config *config.ConfigManager +} + +func NewAdminHandler(service *services.AdminService, config *config.ConfigManager) *AdminHandler { + return &AdminHandler{ + service: service, + config: config, + } +} diff --git a/internal/handlers/admin_config_handler.go b/internal/handlers/admin_config_handler.go new file mode 100644 index 0000000..2bd2b65 --- /dev/null +++ b/internal/handlers/admin_config_handler.go @@ -0,0 +1,45 @@ +package handlers + +import ( + "github.com/zy84338719/filecodebox/internal/common" + "github.com/zy84338719/filecodebox/internal/models/web" + + "github.com/gin-gonic/gin" +) + +// GetConfig 获取配置 +func (h *AdminHandler) GetConfig(c *gin.Context) { + cfg := h.service.GetFullConfig() + resp := web.AdminConfigResponse{ + AdminConfigRequest: web.AdminConfigRequest{ + Base: cfg.Base, + Database: cfg.Database, + Transfer: cfg.Transfer, + Storage: cfg.Storage, + User: cfg.User, + MCP: cfg.MCP, + UI: cfg.UI, + SysStart: &cfg.SysStart, + }, + } + + resp.NotifyTitle = &cfg.NotifyTitle + resp.NotifyContent = &cfg.NotifyContent + common.SuccessResponse(c, resp) +} + +// UpdateConfig 更新配置 +func (h *AdminHandler) UpdateConfig(c *gin.Context) { + var req web.AdminConfigRequest + if err := c.ShouldBind(&req); err != nil { + common.BadRequestResponse(c, "请求参数错误: "+err.Error()) + return + } + + if err := h.service.UpdateConfigFromRequest(&req); err != nil { + common.InternalServerErrorResponse(c, "更新配置失败: "+err.Error()) + return + } + + common.SuccessWithMessage(c, "更新成功", nil) +} diff --git a/internal/handlers/admin_files.go b/internal/handlers/admin_files.go new file mode 100644 index 0000000..89013b1 --- /dev/null +++ b/internal/handlers/admin_files.go @@ -0,0 +1,128 @@ +package handlers + +import ( + "time" + + "github.com/zy84338719/filecodebox/internal/common" + "github.com/zy84338719/filecodebox/internal/utils" + + "github.com/gin-gonic/gin" +) + +// GetFiles 获取文件列表 +func (h *AdminHandler) GetFiles(c *gin.Context) { + pagination := utils.ParsePaginationParams(c) + + files, total, err := h.service.GetFiles(pagination.Page, pagination.PageSize, pagination.Search) + if err != nil { + common.InternalServerErrorResponse(c, "获取文件列表失败: "+err.Error()) + return + } + + common.SuccessWithPagination(c, files, int(total), pagination.Page, pagination.PageSize) +} + +// DeleteFile 删除文件 +func (h *AdminHandler) DeleteFile(c *gin.Context) { + code := c.Param("code") + if code == "" { + common.BadRequestResponse(c, "文件代码不能为空") + return + } + + if err := h.service.DeleteFileByCode(code); err != nil { + common.InternalServerErrorResponse(c, "删除失败: "+err.Error()) + return + } + + common.SuccessWithMessage(c, "删除成功", nil) +} + +// GetFile 获取单个文件信息 +func (h *AdminHandler) GetFile(c *gin.Context) { + code := c.Param("code") + if code == "" { + common.BadRequestResponse(c, "文件代码不能为空") + return + } + + fileCode, err := h.service.GetFileByCode(code) + if err != nil { + common.NotFoundResponse(c, "文件不存在") + return + } + + common.SuccessResponse(c, fileCode) +} + +// UpdateFile 更新文件信息 +func (h *AdminHandler) UpdateFile(c *gin.Context) { + code := c.Param("code") + if code == "" { + common.BadRequestResponse(c, "文件代码不能为空") + return + } + + var updateData struct { + Code string `json:"code"` + Text string `json:"text"` + ExpiredAt *time.Time `json:"expired_at"` + ExpiredCount *int `json:"expired_count"` + } + + if err := c.ShouldBindJSON(&updateData); err != nil { + common.BadRequestResponse(c, "参数错误: "+err.Error()) + return + } + + if _, err := h.service.GetFileByCode(code); err != nil { + common.NotFoundResponse(c, "文件不存在") + return + } + + var expTime time.Time + if updateData.ExpiredAt != nil { + expTime = *updateData.ExpiredAt + } + + if err := h.service.UpdateFileByCode(code, updateData.Code, "", expTime); err != nil { + common.InternalServerErrorResponse(c, "更新失败: "+err.Error()) + return + } + + common.SuccessWithMessage(c, "更新成功", nil) +} + +// DownloadFile 下载文件(管理员) +func (h *AdminHandler) DownloadFile(c *gin.Context) { + code := c.Query("code") + if code == "" { + common.BadRequestResponse(c, "文件代码不能为空") + return + } + + fileCode, err := h.service.GetFileByCode(code) + if err != nil { + common.NotFoundResponse(c, "文件不存在") + return + } + + if fileCode.Text != "" { + fileName := fileCode.Prefix + ".txt" + c.Header("Content-Disposition", "attachment; filename=\""+fileName+"\"") + c.Header("Content-Type", "text/plain") + c.String(200, fileCode.Text) + return + } + + filePath := fileCode.GetFilePath() + if filePath == "" { + common.NotFoundResponse(c, "文件路径为空") + return + } + + if err := h.service.ServeFile(c, fileCode); err != nil { + common.InternalServerErrorResponse(c, "文件下载失败: "+err.Error()) + return + } +} diff --git a/internal/handlers/admin_maintenance.go b/internal/handlers/admin_maintenance.go new file mode 100644 index 0000000..4b6e1f8 --- /dev/null +++ b/internal/handlers/admin_maintenance.go @@ -0,0 +1,365 @@ +package handlers + +import ( + "fmt" + "strconv" + "time" + + "github.com/zy84338719/filecodebox/internal/common" + "github.com/zy84338719/filecodebox/internal/models/web" + + "github.com/gin-gonic/gin" +) + +// CleanExpiredFiles 清理过期文件 +func (h *AdminHandler) CleanExpiredFiles(c *gin.Context) { + start := time.Now() + // TODO: 修复服务方法调用 + // count, err := h.service.CleanupExpiredFiles() + // if err != nil { + // h.recordOperationLog(c, "maintenance.clean_expired", "files", false, err.Error(), start) + // common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) + // return + // } + msg := "清理完成" + h.recordOperationLog(c, "maintenance.clean_expired", "files", true, msg, start) + common.SuccessWithMessage(c, msg, web.CleanedCountResponse{CleanedCount: 0}) +} + +// CleanTempFiles 清理临时文件 +func (h *AdminHandler) CleanTempFiles(c *gin.Context) { + start := time.Now() + count, err := h.service.CleanTempFiles() + if err != nil { + h.recordOperationLog(c, "maintenance.clean_temp", "chunks", false, err.Error(), start) + common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) + return + } + msg := fmt.Sprintf("清理了 %d 个临时文件", count) + h.recordOperationLog(c, "maintenance.clean_temp", "chunks", true, msg, start) + common.SuccessWithMessage(c, msg, web.CountResponse{Count: count}) +} + +// CleanInvalidRecords 清理无效记录 +func (h *AdminHandler) CleanInvalidRecords(c *gin.Context) { + start := time.Now() + count, err := h.service.CleanInvalidRecords() + if err != nil { + h.recordOperationLog(c, "maintenance.clean_invalid", "records", false, err.Error(), start) + common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) + return + } + msg := fmt.Sprintf("清理了 %d 个无效记录", count) + h.recordOperationLog(c, "maintenance.clean_invalid", "records", true, msg, start) + common.SuccessWithMessage(c, msg, web.CountResponse{Count: count}) +} + +// OptimizeDatabase 优化数据库 +func (h *AdminHandler) OptimizeDatabase(c *gin.Context) { + start := time.Now() + if err := h.service.OptimizeDatabase(); err != nil { + h.recordOperationLog(c, "maintenance.db_optimize", "database", false, err.Error(), start) + common.InternalServerErrorResponse(c, "优化失败: "+err.Error()) + return + } + h.recordOperationLog(c, "maintenance.db_optimize", "database", true, "数据库优化完成", start) + common.SuccessWithMessage(c, "数据库优化完成", nil) +} + +// AnalyzeDatabase 分析数据库 +func (h *AdminHandler) AnalyzeDatabase(c *gin.Context) { + start := time.Now() + stats, err := h.service.AnalyzeDatabase() + if err != nil { + h.recordOperationLog(c, "maintenance.db_analyze", "database", false, err.Error(), start) + common.InternalServerErrorResponse(c, "分析失败: "+err.Error()) + return + } + h.recordOperationLog(c, "maintenance.db_analyze", "database", true, "数据库分析完成", start) + common.SuccessResponse(c, stats) +} + +// BackupDatabase 备份数据库 +func (h *AdminHandler) BackupDatabase(c *gin.Context) { + start := time.Now() + backupPath, err := h.service.BackupDatabase() + if err != nil { + h.recordOperationLog(c, "maintenance.db_backup", "database", false, err.Error(), start) + common.InternalServerErrorResponse(c, "备份失败: "+err.Error()) + return + } + msg := "数据库备份完成" + h.recordOperationLog(c, "maintenance.db_backup", backupPath, true, msg, start) + common.SuccessWithMessage(c, "数据库备份完成", web.BackupPathResponse{BackupPath: backupPath}) +} + +// ClearSystemCache 清理系统缓存 +func (h *AdminHandler) ClearSystemCache(c *gin.Context) { + start := time.Now() + if err := h.service.ClearSystemCache(); err != nil { + h.recordOperationLog(c, "maintenance.cache_clear_system", "system-cache", false, err.Error(), start) + common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) + return + } + h.recordOperationLog(c, "maintenance.cache_clear_system", "system-cache", true, "系统缓存清理完成", start) + common.SuccessWithMessage(c, "系统缓存清理完成", nil) +} + +// ClearUploadCache 清理上传缓存 +func (h *AdminHandler) ClearUploadCache(c *gin.Context) { + start := time.Now() + if err := h.service.ClearUploadCache(); err != nil { + h.recordOperationLog(c, "maintenance.cache_clear_upload", "upload-cache", false, err.Error(), start) + common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) + return + } + h.recordOperationLog(c, "maintenance.cache_clear_upload", "upload-cache", true, "上传缓存清理完成", start) + common.SuccessWithMessage(c, "上传缓存清理完成", nil) +} + +// ClearDownloadCache 清理下载缓存 +func (h *AdminHandler) ClearDownloadCache(c *gin.Context) { + start := time.Now() + if err := h.service.ClearDownloadCache(); err != nil { + h.recordOperationLog(c, "maintenance.cache_clear_download", "download-cache", false, err.Error(), start) + common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) + return + } + h.recordOperationLog(c, "maintenance.cache_clear_download", "download-cache", true, "下载缓存清理完成", start) + common.SuccessWithMessage(c, "下载缓存清理完成", nil) +} + +// GetSystemInfo 获取系统信息 +func (h *AdminHandler) GetSystemInfo(c *gin.Context) { + info, err := h.service.GetSystemInfo() + if err != nil { + common.InternalServerErrorResponse(c, "获取系统信息失败: "+err.Error()) + return + } + + common.SuccessResponse(c, info) +} + +// GetStorageStatus 获取存储状态 +func (h *AdminHandler) GetStorageStatus(c *gin.Context) { + status, err := h.service.GetStorageStatus() + if err != nil { + common.InternalServerErrorResponse(c, "获取存储状态失败: "+err.Error()) + return + } + + common.SuccessResponse(c, status) +} + +// GetPerformanceMetrics 获取性能指标 +func (h *AdminHandler) GetPerformanceMetrics(c *gin.Context) { + metrics, err := h.service.GetPerformanceMetrics() + if err != nil { + common.InternalServerErrorResponse(c, "获取性能指标失败: "+err.Error()) + return + } + + common.SuccessResponse(c, metrics) +} + +// ScanSecurity 安全扫描 +func (h *AdminHandler) ScanSecurity(c *gin.Context) { + result, err := h.service.ScanSecurity() + if err != nil { + common.InternalServerErrorResponse(c, "安全扫描失败: "+err.Error()) + return + } + + common.SuccessResponse(c, result) +} + +// CheckPermissions 检查权限 +func (h *AdminHandler) CheckPermissions(c *gin.Context) { + result, err := h.service.CheckPermissions() + if err != nil { + common.InternalServerErrorResponse(c, "权限检查失败: "+err.Error()) + return + } + + common.SuccessResponse(c, result) +} + +// CheckIntegrity 检查完整性 +func (h *AdminHandler) CheckIntegrity(c *gin.Context) { + result, err := h.service.CheckIntegrity() + if err != nil { + common.InternalServerErrorResponse(c, "完整性检查失败: "+err.Error()) + return + } + + common.SuccessResponse(c, result) +} + +// ClearSystemLogs 清理系统日志 +func (h *AdminHandler) ClearSystemLogs(c *gin.Context) { + start := time.Now() + count, err := h.service.ClearSystemLogs() + if err != nil { + h.recordOperationLog(c, "maintenance.logs_clear_system", "system-logs", false, err.Error(), start) + common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) + return + } + msg := fmt.Sprintf("清理了 %d 条系统日志", count) + h.recordOperationLog(c, "maintenance.logs_clear_system", "system-logs", true, msg, start) + common.SuccessWithMessage(c, msg, web.CountResponse{Count: count}) +} + +// ClearAccessLogs 清理访问日志 +func (h *AdminHandler) ClearAccessLogs(c *gin.Context) { + start := time.Now() + count, err := h.service.ClearAccessLogs() + if err != nil { + h.recordOperationLog(c, "maintenance.logs_clear_access", "access-logs", false, err.Error(), start) + common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) + return + } + msg := fmt.Sprintf("清理了 %d 条访问日志", count) + h.recordOperationLog(c, "maintenance.logs_clear_access", "access-logs", true, msg, start) + common.SuccessWithMessage(c, msg, web.CountResponse{Count: count}) +} + +// ClearErrorLogs 清理错误日志 +func (h *AdminHandler) ClearErrorLogs(c *gin.Context) { + start := time.Now() + count, err := h.service.ClearErrorLogs() + if err != nil { + h.recordOperationLog(c, "maintenance.logs_clear_error", "error-logs", false, err.Error(), start) + common.InternalServerErrorResponse(c, "清理失败: "+err.Error()) + return + } + msg := fmt.Sprintf("清理了 %d 条错误日志", count) + h.recordOperationLog(c, "maintenance.logs_clear_error", "error-logs", true, msg, start) + common.SuccessWithMessage(c, msg, web.CountResponse{Count: count}) +} + +// ExportLogs 导出日志 +func (h *AdminHandler) ExportLogs(c *gin.Context) { + start := time.Now() + logType := c.DefaultQuery("type", "system") + + logPath, err := h.service.ExportLogs(logType) + if err != nil { + h.recordOperationLog(c, "maintenance.logs_export", logType, false, err.Error(), start) + common.InternalServerErrorResponse(c, "导出失败: "+err.Error()) + return + } + msg := "日志导出完成" + h.recordOperationLog(c, "maintenance.logs_export", logType, true, msg, start) + common.SuccessWithMessage(c, msg, web.LogPathResponse{LogPath: logPath}) +} + +// GetLogStats 获取日志统计 +func (h *AdminHandler) GetLogStats(c *gin.Context) { + stats, err := h.service.GetLogStats() + if err != nil { + common.InternalServerErrorResponse(c, "获取日志统计失败: "+err.Error()) + return + } + + common.SuccessResponse(c, stats) +} + +// GetSystemLogs 获取系统日志 +func (h *AdminHandler) GetSystemLogs(c *gin.Context) { + level := c.DefaultQuery("level", "") + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100")) + + if limit <= 0 || limit > 1000 { + limit = 100 + } + + logs, err := h.service.GetSystemLogs(limit) + if err != nil { + common.InternalServerErrorResponse(c, "获取日志失败: "+err.Error()) + return + } + + if level != "" { + filteredLogs := make([]string, 0) + for _, log := range logs { + if len(log) > 0 && (level == "" || len(log) > 10) { + filteredLogs = append(filteredLogs, log) + } + } + logs = filteredLogs + } + + response := web.LogsResponse{ + Logs: logs, + Total: len(logs), + } + + common.SuccessResponse(c, response) +} + +// GetRunningTasks 获取运行中的任务 +func (h *AdminHandler) GetRunningTasks(c *gin.Context) { + tasks, err := h.service.GetRunningTasks() + if err != nil { + common.InternalServerErrorResponse(c, "获取运行任务失败: "+err.Error()) + return + } + + response := web.TasksResponse{ + Tasks: tasks, + Total: len(tasks), + } + + common.SuccessResponse(c, response) +} + +// CancelTask 取消任务 +func (h *AdminHandler) CancelTask(c *gin.Context) { + taskID := c.Param("id") + if taskID == "" { + common.BadRequestResponse(c, "任务ID不能为空") + return + } + + start := time.Now() + if err := h.service.CancelTask(taskID); err != nil { + h.recordOperationLog(c, "maintenance.task_cancel", taskID, false, err.Error(), start) + common.InternalServerErrorResponse(c, "取消任务失败: "+err.Error()) + return + } + + h.recordOperationLog(c, "maintenance.task_cancel", taskID, true, "任务已取消", start) + common.SuccessWithMessage(c, "任务已取消", nil) +} + +// RetryTask 重试任务 +func (h *AdminHandler) RetryTask(c *gin.Context) { + taskID := c.Param("id") + if taskID == "" { + common.BadRequestResponse(c, "任务ID不能为空") + return + } + + start := time.Now() + if err := h.service.RetryTask(taskID); err != nil { + h.recordOperationLog(c, "maintenance.task_retry", taskID, false, err.Error(), start) + common.InternalServerErrorResponse(c, "重试任务失败: "+err.Error()) + return + } + + h.recordOperationLog(c, "maintenance.task_retry", taskID, true, "任务已重新启动", start) + common.SuccessWithMessage(c, "任务已重新启动", nil) +} + +// RestartSystem 重启系统 +func (h *AdminHandler) RestartSystem(c *gin.Context) { + start := time.Now() + if err := h.service.RestartSystem(); err != nil { + h.recordOperationLog(c, "maintenance.system_restart", "system", false, err.Error(), start) + common.InternalServerErrorResponse(c, "重启系统失败: "+err.Error()) + return + } + + h.recordOperationLog(c, "maintenance.system_restart", "system", true, "系统重启指令已发送", start) + common.SuccessWithMessage(c, "系统重启指令已发送", nil) +} diff --git a/internal/handlers/admin_mcp.go b/internal/handlers/admin_mcp.go new file mode 100644 index 0000000..6a6d84d --- /dev/null +++ b/internal/handlers/admin_mcp.go @@ -0,0 +1,234 @@ +package handlers + +import ( + "fmt" + "net" + "strconv" + "time" + + "github.com/zy84338719/filecodebox/internal/common" + "github.com/zy84338719/filecodebox/internal/config" + "github.com/zy84338719/filecodebox/internal/models/web" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +// GetMCPConfig 获取 MCP 配置 +func (h *AdminHandler) GetMCPConfig(c *gin.Context) { + common.SuccessResponse(c, h.config.MCP) +} + +// UpdateMCPConfig 更新 MCP 配置 +func (h *AdminHandler) UpdateMCPConfig(c *gin.Context) { + var mcpConfig struct { + EnableMCPServer *int `json:"enable_mcp_server"` + MCPPort *string `json:"mcp_port"` + MCPHost *string `json:"mcp_host"` + } + + if err := c.ShouldBindJSON(&mcpConfig); err != nil { + common.BadRequestResponse(c, "MCP配置参数错误: "+err.Error()) + return + } + + start := time.Now() + if err := h.config.UpdateTransaction(func(draft *config.ConfigManager) error { + if mcpConfig.EnableMCPServer != nil { + draft.MCP.EnableMCPServer = *mcpConfig.EnableMCPServer + } + if mcpConfig.MCPPort != nil { + draft.MCP.MCPPort = *mcpConfig.MCPPort + } + if mcpConfig.MCPHost != nil { + draft.MCP.MCPHost = *mcpConfig.MCPHost + } + return nil + }); err != nil { + h.recordOperationLog(c, "mcp.update_config", "config", false, err.Error(), start) + common.InternalServerErrorResponse(c, "保存MCP配置失败: "+err.Error()) + return + } + + mcpManager := GetMCPManager() + if mcpManager != nil { + enableMCP := h.config.MCP.EnableMCPServer == 1 + port := h.config.MCP.MCPPort + + if err := mcpManager.ApplyConfig(enableMCP, port); err != nil { + h.recordOperationLog(c, "mcp.update_config", "config", false, err.Error(), start) + common.InternalServerErrorResponse(c, "应用MCP配置失败: "+err.Error()) + return + } + } + + h.recordOperationLog(c, "mcp.update_config", "config", true, "MCP配置更新成功", start) + common.SuccessWithMessage(c, "MCP配置更新成功", nil) +} + +// GetMCPStatus 获取 MCP 服务器状态 +func (h *AdminHandler) GetMCPStatus(c *gin.Context) { + mcpManager := GetMCPManager() + if mcpManager == nil { + common.InternalServerErrorResponse(c, "MCP管理器未初始化") + return + } + + status := mcpManager.GetStatus() + + statusText := "inactive" + if status.Running { + statusText = "active" + } + + response := web.MCPStatusResponse{ + Status: statusText, + Config: h.config.MCP, + } + + common.SuccessResponse(c, response) +} + +// RestartMCPServer 重启 MCP 服务 +func (h *AdminHandler) RestartMCPServer(c *gin.Context) { + mcpManager := GetMCPManager() + if mcpManager == nil { + common.InternalServerErrorResponse(c, "MCP管理器未初始化") + return + } + + if h.config.MCP.EnableMCPServer != 1 { + common.BadRequestResponse(c, "MCP服务器未启用") + return + } + + start := time.Now() + if err := mcpManager.RestartMCPServer(h.config.MCP.MCPPort); err != nil { + h.recordOperationLog(c, "mcp.restart", "mcp", false, err.Error(), start) + common.InternalServerErrorResponse(c, "重启MCP服务器失败: "+err.Error()) + return + } + + h.recordOperationLog(c, "mcp.restart", "mcp", true, "MCP服务器重启成功", start) + common.SuccessWithMessage(c, "MCP服务器重启成功", nil) +} + +// ControlMCPServer 控制 MCP 服务的启停 +func (h *AdminHandler) ControlMCPServer(c *gin.Context) { + var controlData struct { + Action string `json:"action" binding:"required"` + } + + if err := c.ShouldBindJSON(&controlData); err != nil { + common.BadRequestResponse(c, "参数错误: "+err.Error()) + return + } + + mcpManager := GetMCPManager() + if mcpManager == nil { + common.InternalServerErrorResponse(c, "MCP管理器未初始化") + return + } + + start := time.Now() + switch controlData.Action { + case "start": + if h.config.MCP.EnableMCPServer != 1 { + common.BadRequestResponse(c, "MCP服务器未启用,请先在配置中启用") + return + } + if err := mcpManager.StartMCPServer(h.config.MCP.MCPPort); err != nil { + h.recordOperationLog(c, "mcp.start", "mcp", false, err.Error(), start) + common.InternalServerErrorResponse(c, "启动MCP服务器失败: "+err.Error()) + return + } + h.recordOperationLog(c, "mcp.start", "mcp", true, "MCP服务器启动成功", start) + common.SuccessWithMessage(c, "MCP服务器启动成功", nil) + case "stop": + if err := mcpManager.StopMCPServer(); err != nil { + h.recordOperationLog(c, "mcp.stop", "mcp", false, err.Error(), start) + common.InternalServerErrorResponse(c, "停止MCP服务器失败: "+err.Error()) + return + } + h.recordOperationLog(c, "mcp.stop", "mcp", true, "MCP服务器停止成功", start) + common.SuccessWithMessage(c, "MCP服务器停止成功", nil) + default: + common.BadRequestResponse(c, "无效的操作,只支持 start 或 stop") + } +} + +// TestMCPConnection 测试 MCP 服务连接 +func (h *AdminHandler) TestMCPConnection(c *gin.Context) { + var testData struct { + Port string `json:"port"` + Host string `json:"host"` + } + + if err := c.ShouldBindJSON(&testData); err != nil { + common.BadRequestResponse(c, "参数错误: "+err.Error()) + return + } + + address, _, _, err := normalizeMCPAddress(testData.Host, testData.Port, h.config) + if err != nil { + common.BadRequestResponse(c, "参数错误: "+err.Error()) + return + } + + if err := tcpProbe(address, 3*time.Second); err != nil { + common.ErrorResponse(c, 400, fmt.Sprintf("连接测试失败: %s,端口可能未开放或MCP服务器未启动", err.Error())) + return + } + + response := web.MCPTestResponse{ + MCPStatusResponse: web.MCPStatusResponse{ + Status: "连接正常", + Config: h.config.MCP, + }, + } + + common.SuccessWithMessage(c, "MCP连接测试成功", response) +} + +func normalizeMCPAddress(reqHost, reqPort string, cfg *config.ConfigManager) (address, host, port string, err error) { + port = reqPort + if port == "" { + port = cfg.MCP.MCPPort + } + if port == "" { + port = "8081" + } + + pnum, perr := strconv.Atoi(port) + if perr != nil || pnum < 1 || pnum > 65535 { + err = fmt.Errorf("无效端口号: %s", port) + return + } + + host = reqHost + if host == "" { + host = cfg.MCP.MCPHost + } + if host == "" { + host = "0.0.0.0" + } + + address = host + ":" + port + if host == "0.0.0.0" { + address = "127.0.0.1:" + port + } + return +} + +func tcpProbe(address string, timeout time.Duration) error { + conn, err := net.DialTimeout("tcp", address, timeout) + if err != nil { + return err + } + defer func() { + if err := conn.Close(); err != nil { + logrus.WithError(err).Warn("关闭 TCP 连接失败") + } + }() + return nil +} diff --git a/internal/handlers/admin_transfer.go b/internal/handlers/admin_transfer.go new file mode 100644 index 0000000..5f5aebc --- /dev/null +++ b/internal/handlers/admin_transfer.go @@ -0,0 +1,75 @@ +package handlers + +import ( + "strconv" + "strings" + "time" + + "github.com/zy84338719/filecodebox/internal/common" + "github.com/zy84338719/filecodebox/internal/models/web" + + "github.com/gin-gonic/gin" +) + +// GetTransferLogs 获取上传/下载审计日志 +func (h *AdminHandler) GetTransferLogs(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } else if pageSize > 200 { + pageSize = 200 + } + + operation := strings.TrimSpace(c.DefaultQuery("operation", "")) + search := strings.TrimSpace(c.DefaultQuery("search", "")) + + logs, total, err := h.service.GetTransferLogs(page, pageSize, operation, search) + if err != nil { + common.InternalServerErrorResponse(c, "获取传输日志失败: "+err.Error()) + return + } + + items := make([]web.TransferLogItem, 0, len(logs)) + for _, record := range logs { + item := web.TransferLogItem{ + ID: record.ID, + Operation: record.Operation, + FileCode: record.FileCode, + FileName: record.FileName, + FileSize: record.FileSize, + Username: record.Username, + IP: record.IP, + DurationMs: record.DurationMs, + CreatedAt: record.CreatedAt.Format(time.RFC3339), + } + if record.UserID != nil { + id := *record.UserID + item.UserID = &id + } + items = append(items, item) + } + + pages := int64(0) + if pageSize > 0 { + pages = (total + int64(pageSize) - 1) / int64(pageSize) + } + if pages == 0 { + pages = 1 + } + + response := web.TransferLogListResponse{ + Logs: items, + Pagination: web.PaginationResponse{ + Page: page, + PageSize: pageSize, + Total: total, + Pages: pages, + }, + } + + common.SuccessResponse(c, response) +} diff --git a/internal/handlers/admin_users.go b/internal/handlers/admin_users.go new file mode 100644 index 0000000..59e6b10 --- /dev/null +++ b/internal/handlers/admin_users.go @@ -0,0 +1,426 @@ +package handlers + +import ( + "fmt" + "strconv" + + "github.com/zy84338719/filecodebox/internal/common" + "github.com/zy84338719/filecodebox/internal/models/web" + "github.com/zy84338719/filecodebox/internal/services" + "github.com/zy84338719/filecodebox/internal/utils" + + "github.com/gin-gonic/gin" +) + +// GetUsers 获取用户列表 +func (h *AdminHandler) GetUsers(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + search := c.Query("search") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + users, total, err := h.getUsersFromDB(page, pageSize, search) + if err != nil { + common.InternalServerErrorResponse(c, "获取用户列表失败: "+err.Error()) + return + } + + stats, err := h.getUserStats() + if err != nil { + stats = &web.AdminUserStatsResponse{ + TotalUsers: total, + ActiveUsers: total, + TodayRegistrations: 0, + TodayUploads: 0, + } + } + + pagination := web.PaginationResponse{ + Page: page, + PageSize: pageSize, + Total: total, + Pages: (total + int64(pageSize) - 1) / int64(pageSize), + } + + common.SuccessResponse(c, web.AdminUsersListResponse{ + Users: users, + Stats: *stats, + Pagination: pagination, + }) +} + +func (h *AdminHandler) getUsersFromDB(page, pageSize int, search string) ([]web.AdminUserDetail, int64, error) { + users, total, err := h.service.GetUsers(page, pageSize, search) + if err != nil { + return nil, 0, err + } + + result := make([]web.AdminUserDetail, len(users)) + for i, user := range users { + lastLoginAt := "" + if user.LastLoginAt != nil { + lastLoginAt = user.LastLoginAt.Format("2006-01-02 15:04:05") + } + + result[i] = web.AdminUserDetail{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + Nickname: user.Nickname, + Role: user.Role, + IsAdmin: user.Role == "admin", + IsActive: user.Status == "active", + Status: user.Status, + EmailVerified: user.EmailVerified, + CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"), + LastLoginAt: lastLoginAt, + LastLoginIP: user.LastLoginIP, + TotalUploads: user.TotalUploads, + TotalDownloads: user.TotalDownloads, + TotalStorage: user.TotalStorage, + } + } + + return result, total, nil +} + +func (h *AdminHandler) getUserStats() (*web.AdminUserStatsResponse, error) { + stats, err := h.service.GetStats() + if err != nil { + return nil, err + } + + return &web.AdminUserStatsResponse{ + TotalUsers: stats.TotalUsers, + ActiveUsers: stats.ActiveUsers, + TodayRegistrations: stats.TodayRegistrations, + TodayUploads: stats.TodayUploads, + }, nil +} + +// GetUser 获取单个用户 +func (h *AdminHandler) GetUser(c *gin.Context) { + userID, ok := utils.ParseUserIDFromParam(c, "id") + if !ok { + return + } + + user, err := h.service.GetUserByID(userID) + if err != nil { + common.NotFoundResponse(c, "用户不存在") + return + } + + lastLoginAt := "" + if user.LastLoginAt != nil { + lastLoginAt = user.LastLoginAt.Format("2006-01-02 15:04:05") + } + + userDetail := web.AdminUserDetail{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + Nickname: user.Nickname, + Role: user.Role, + IsAdmin: user.Role == "admin", + IsActive: user.Status == "active", + Status: user.Status, + EmailVerified: user.EmailVerified, + CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"), + LastLoginAt: lastLoginAt, + LastLoginIP: user.LastLoginIP, + TotalUploads: user.TotalUploads, + TotalDownloads: user.TotalDownloads, + TotalStorage: user.TotalStorage, + } + + common.SuccessResponse(c, userDetail) +} + +// CreateUser 创建用户 +func (h *AdminHandler) CreateUser(c *gin.Context) { + var userData web.UserDataRequest + if !utils.BindJSONWithValidation(c, &userData) { + return + } + + role := "user" + if userData.IsAdmin { + role = "admin" + } + + status := "active" + if !userData.IsActive { + status = "inactive" + } + + user, err := h.service.CreateUser(userData.Username, userData.Email, userData.Password, userData.Nickname, role, status) + if err != nil { + common.InternalServerErrorResponse(c, "创建用户失败: "+err.Error()) + return + } + + common.SuccessWithMessage(c, "用户创建成功", web.IDResponse{ID: user.ID}) +} + +// UpdateUser 更新用户 +func (h *AdminHandler) UpdateUser(c *gin.Context) { + userID, ok := utils.ParseUserIDFromParam(c, "id") + if !ok { + return + } + + var userData struct { + Email string `json:"email" binding:"omitempty,email"` + Password string `json:"password"` + Nickname string `json:"nickname"` + IsAdmin bool `json:"is_admin"` + IsActive bool `json:"is_active"` + } + + if !utils.BindJSONWithValidation(c, &userData) { + return + } + + params := services.AdminUserUpdateParams{} + if userData.Email != "" { + email := userData.Email + params.Email = &email + } + if userData.Password != "" { + password := userData.Password + params.Password = &password + } + if userData.Nickname != "" { + nickname := userData.Nickname + params.Nickname = &nickname + } + isAdmin := userData.IsAdmin + params.IsAdmin = &isAdmin + isActive := userData.IsActive + params.IsActive = &isActive + + if err := h.service.UpdateUserWithParams(userID, params); err != nil { + common.InternalServerErrorResponse(c, "更新用户失败: "+err.Error()) + return + } + + common.SuccessWithMessage(c, "用户更新成功", web.IDResponse{ID: userID}) +} + +// DeleteUser 删除用户 +func (h *AdminHandler) DeleteUser(c *gin.Context) { + userID, ok := utils.ParseUserIDFromParam(c, "id") + if !ok { + return + } + + if err := h.service.DeleteUser(userID); err != nil { + common.InternalServerErrorResponse(c, "删除用户失败: "+err.Error()) + return + } + + common.SuccessWithMessage(c, "用户删除成功", web.IDResponse{ID: userID}) +} + +// UpdateUserStatus 更新用户状态 +func (h *AdminHandler) UpdateUserStatus(c *gin.Context) { + userID, ok := utils.ParseUserIDFromParam(c, "id") + if !ok { + return + } + + var statusData struct { + IsActive bool `json:"is_active"` + } + + if err := c.ShouldBindJSON(&statusData); err != nil { + common.BadRequestResponse(c, "参数错误: "+err.Error()) + return + } + + if err := h.service.UpdateUserStatus(userID, statusData.IsActive); err != nil { + common.InternalServerErrorResponse(c, "更新用户状态失败: "+err.Error()) + return + } + + common.SuccessWithMessage(c, "用户状态更新成功", web.IDResponse{ID: userID}) +} + +// GetUserFiles 获取用户文件 +func (h *AdminHandler) GetUserFiles(c *gin.Context) { + userID, ok := utils.ParseUserIDFromParam(c, "id") + if !ok { + return + } + + user, err := h.service.GetUserByID(userID) + if err != nil { + common.NotFoundResponse(c, "用户不存在") + return + } + + page := 1 + limit := 20 + + files, total, err := h.service.GetUserFiles(userID, page, limit) + if err != nil { + common.InternalServerErrorResponse(c, "获取用户文件失败: "+err.Error()) + return + } + + fileList := make([]web.AdminFileDetail, len(files)) + for i, file := range files { + expiredAt := "" + if file.ExpiredAt != nil { + expiredAt = file.ExpiredAt.Format("2006-01-02 15:04:05") + } + + fileType := "文件" + if file.Text != "" { + fileType = "文本" + } + + fileList[i] = web.AdminFileDetail{ + ID: file.ID, + Code: file.Code, + Prefix: file.Prefix, + Suffix: file.Suffix, + Size: file.Size, + Type: fileType, + ExpiredAt: expiredAt, + ExpiredCount: file.ExpiredCount, + UsedCount: file.UsedCount, + CreatedAt: file.CreatedAt.Format("2006-01-02 15:04:05"), + RequireAuth: file.RequireAuth, + UploadType: file.UploadType, + } + } + + common.SuccessResponse(c, web.AdminUserFilesResponse{ + Files: fileList, + Username: user.Username, + Total: total, + }) +} + +// ExportUsers 导出用户列表为CSV +func (h *AdminHandler) ExportUsers(c *gin.Context) { + users, _, err := h.getUsersFromDB(1, 10000, "") + if err != nil { + common.InternalServerErrorResponse(c, "获取用户数据失败: "+err.Error()) + return + } + + csvContent := "用户名,邮箱,昵称,状态,注册时间,最后登录,上传次数,下载次数,存储大小(MB)\n" + for _, user := range users { + status := "正常" + if user.Status == "disabled" || user.Status == "inactive" { + status = "禁用" + } + + lastLoginTime := "从未登录" + if user.LastLoginAt != "" { + lastLoginTime = user.LastLoginAt + } + + csvContent += fmt.Sprintf("%s,%s,%s,%s,%s,%s,%d,%d,%.2f\n", + user.Username, + user.Email, + user.Nickname, + status, + user.CreatedAt, + lastLoginTime, + user.TotalUploads, + user.TotalDownloads, + float64(user.TotalStorage)/(1024*1024), + ) + } + + bomContent := "\xEF\xBB\xBF" + csvContent + + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", "attachment; filename=users_export.csv") + c.Header("Content-Length", strconv.Itoa(len([]byte(bomContent)))) + + c.Writer.WriteHeader(200) + _, _ = c.Writer.Write([]byte(bomContent)) +} + +// BatchEnableUsers 批量启用用户 +func (h *AdminHandler) BatchEnableUsers(c *gin.Context) { + var req struct { + UserIDs []uint `json:"user_ids" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + common.BadRequestResponse(c, "参数错误: "+err.Error()) + return + } + + if len(req.UserIDs) == 0 { + common.BadRequestResponse(c, "user_ids 不能为空") + return + } + + if err := h.service.BatchUpdateUserStatus(req.UserIDs, true); err != nil { + common.InternalServerErrorResponse(c, "批量启用用户失败: "+err.Error()) + return + } + + common.SuccessWithMessage(c, "批量启用成功", nil) +} + +// BatchDisableUsers 批量禁用用户 +func (h *AdminHandler) BatchDisableUsers(c *gin.Context) { + var req struct { + UserIDs []uint `json:"user_ids" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + common.BadRequestResponse(c, "参数错误: "+err.Error()) + return + } + + if len(req.UserIDs) == 0 { + common.BadRequestResponse(c, "user_ids 不能为空") + return + } + + if err := h.service.BatchUpdateUserStatus(req.UserIDs, false); err != nil { + common.InternalServerErrorResponse(c, "批量禁用用户失败: "+err.Error()) + return + } + + common.SuccessWithMessage(c, "批量禁用成功", nil) +} + +// BatchDeleteUsers 批量删除用户 +func (h *AdminHandler) BatchDeleteUsers(c *gin.Context) { + var req struct { + UserIDs []uint `json:"user_ids" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + common.BadRequestResponse(c, "参数错误: "+err.Error()) + return + } + + if len(req.UserIDs) == 0 { + common.BadRequestResponse(c, "user_ids 不能为空") + return + } + + if err := h.service.BatchDeleteUsers(req.UserIDs); err != nil { + common.InternalServerErrorResponse(c, "批量删除用户失败: "+err.Error()) + return + } + + common.SuccessWithMessage(c, "批量删除成功", nil) +} diff --git a/internal/handlers/api.go b/internal/handlers/api.go index c504726..3549923 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -29,7 +29,7 @@ func NewAPIHandler(manager *config.ConfigManager) *APIHandler { type HealthResponse struct { Status string `json:"status" example:"ok"` Timestamp string `json:"timestamp" example:"2025-09-11T10:00:00Z"` - Version string `json:"version" example:"1.8.2"` + Version string `json:"version" example:"1.9.1"` Uptime string `json:"uptime" example:"2h30m15s"` } diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go index ac98d9a..c4b6c40 100644 --- a/internal/handlers/setup.go +++ b/internal/handlers/setup.go @@ -62,32 +62,33 @@ type AdminConfig struct { // updateDatabaseConfig 更新数据库配置 func (h *SetupHandler) updateDatabaseConfig(db DatabaseConfig) error { - // 更新配置管理器中的数据库配置 - h.manager.Database.Type = db.Type - - switch db.Type { - case "sqlite": - h.manager.Database.Host = "" - h.manager.Database.Port = 0 - h.manager.Database.User = "" - h.manager.Database.Pass = "" - h.manager.Database.Name = db.File - case "mysql": - h.manager.Database.Host = db.Host - h.manager.Database.Port = db.Port - h.manager.Database.User = db.User - h.manager.Database.Pass = db.Password - h.manager.Database.Name = db.Database - case "postgres": - h.manager.Database.Host = db.Host - h.manager.Database.Port = db.Port - h.manager.Database.User = db.User - h.manager.Database.Pass = db.Password - h.manager.Database.Name = db.Database - } + return h.manager.UpdateTransaction(func(draft *config.ConfigManager) error { + // 更新配置管理器中的数据库配置 + draft.Database.Type = db.Type + + switch db.Type { + case "sqlite": + draft.Database.Host = "" + draft.Database.Port = 0 + draft.Database.User = "" + draft.Database.Pass = "" + draft.Database.Name = db.File + case "mysql": + draft.Database.Host = db.Host + draft.Database.Port = db.Port + draft.Database.User = db.User + draft.Database.Pass = db.Password + draft.Database.Name = db.Database + case "postgres": + draft.Database.Host = db.Host + draft.Database.Port = db.Port + draft.Database.User = db.User + draft.Database.Pass = db.Password + draft.Database.Name = db.Database + } - // 持久化配置到 YAML - return h.manager.PersistYAML() + return nil + }) } // createAdminUser 创建管理员用户 @@ -155,17 +156,14 @@ func (h *SetupHandler) createAdminUser(admin AdminConfig) error { // enableUserSystem 启用用户系统 func (h *SetupHandler) enableUserSystem(adminConfig AdminConfig) error { - // 用户系统始终启用,无需设置 - - // 根据管理员选择设置用户注册权限 - if adminConfig.AllowUserRegistration { - h.manager.User.AllowUserRegistration = 1 - } else { - h.manager.User.AllowUserRegistration = 0 - } - - // 保存配置到 YAML - return h.manager.PersistYAML() + return h.manager.UpdateTransaction(func(draft *config.ConfigManager) error { + if adminConfig.AllowUserRegistration { + draft.User.AllowUserRegistration = 1 + } else { + draft.User.AllowUserRegistration = 0 + } + return nil + }) } // contains 检查字符串是否包含子字符串 @@ -363,8 +361,10 @@ func InitializeNoDB(manager *config.ConfigManager) gin.HandlerFunc { // 如果之前捕获了 desiredStoragePath,则此时 manager 已注入 DB,可以持久化 storage_path if desiredStoragePath != "" { - manager.Storage.StoragePath = desiredStoragePath - if err := manager.PersistYAML(); err != nil { + if err := manager.UpdateTransaction(func(draft *config.ConfigManager) error { + draft.Storage.StoragePath = desiredStoragePath + return nil + }); err != nil { logrus.WithError(err).Warn("[InitializeNoDB] 持久化 storage_path 失败") // 记录但不阻塞初始化流程 if manager.Base != nil && manager.Base.DataPath != "" { @@ -384,15 +384,9 @@ func InitializeNoDB(manager *config.ConfigManager) gin.HandlerFunc { } // 启用用户系统配置 - if req.Admin.AllowUserRegistration { - manager.User.AllowUserRegistration = 1 - } else { - manager.User.AllowUserRegistration = 0 - } - if err := manager.PersistYAML(); err != nil { + if err := setupHandler.enableUserSystem(req.Admin); err != nil { // 不阻塞初始化成功路径,但记录错误 - logrus.WithError(err).Warn("[InitializeNoDB] manager.PersistYAML() 返回错误(但不阻塞初始化)") - // 将错误写入数据目录下的日志文件以便排查 + logrus.WithError(err).Warn("[InitializeNoDB] enableUserSystem 返回错误(但不阻塞初始化)") if manager.Base != nil && manager.Base.DataPath != "" { _ = os.WriteFile(manager.Base.DataPath+"/init_save_err.log", []byte(err.Error()), 0644) } else { diff --git a/internal/handlers/storage.go b/internal/handlers/storage.go index 3e87a6f..32c343f 100644 --- a/internal/handlers/storage.go +++ b/internal/handlers/storage.go @@ -1,6 +1,8 @@ package handlers import ( + "fmt" + "github.com/zy84338719/filecodebox/internal/common" "github.com/zy84338719/filecodebox/internal/config" "github.com/zy84338719/filecodebox/internal/models/web" @@ -107,12 +109,15 @@ func (sh *StorageHandler) SwitchStorage(c *gin.Context) { return } - // 更新配置 - sh.storageConfig.Type = req.Type - if err := sh.configManager.PersistYAML(); err != nil { - common.InternalServerErrorResponse(c, "保存配置失败: "+err.Error()) - return - } + // 更新配置 + if err := sh.configManager.UpdateTransaction(func(draft *config.ConfigManager) error { + draft.Storage.Type = req.Type + return nil + }); err != nil { + common.InternalServerErrorResponse(c, "保存配置失败: "+err.Error()) + return + } + sh.storageConfig = sh.configManager.Storage common.SuccessResponse(c, web.StorageSwitchResponse{ Success: true, @@ -148,130 +153,136 @@ func (sh *StorageHandler) UpdateStorageConfig(c *gin.Context) { return } - // 根据存储类型更新配置 - switch req.Type { - case "local": - if req.Config != nil && req.Config.StoragePath != "" { - sh.storageConfig.StoragePath = req.Config.StoragePath - } + var reconfigure func() error - case "webdav": - if req.Config != nil && req.Config.WebDAV != nil { - if sh.storageConfig.WebDAV == nil { - sh.storageConfig.WebDAV = &config.WebDAVConfig{} - } - if req.Config.WebDAV.Hostname != "" { - sh.storageConfig.WebDAV.Hostname = req.Config.WebDAV.Hostname - } - if req.Config.WebDAV.Username != "" { - sh.storageConfig.WebDAV.Username = req.Config.WebDAV.Username - } - if req.Config.WebDAV.Password != "" { - sh.storageConfig.WebDAV.Password = req.Config.WebDAV.Password + if err := sh.configManager.UpdateTransaction(func(draft *config.ConfigManager) error { + switch req.Type { + case "local": + if req.Config != nil && req.Config.StoragePath != "" { + draft.Storage.StoragePath = req.Config.StoragePath } - if req.Config.WebDAV.RootPath != "" { - sh.storageConfig.WebDAV.RootPath = req.Config.WebDAV.RootPath + case "webdav": + if req.Config != nil && req.Config.WebDAV != nil { + if draft.Storage.WebDAV == nil { + draft.Storage.WebDAV = &config.WebDAVConfig{} + } + if req.Config.WebDAV.Hostname != "" { + draft.Storage.WebDAV.Hostname = req.Config.WebDAV.Hostname + } + if req.Config.WebDAV.Username != "" { + draft.Storage.WebDAV.Username = req.Config.WebDAV.Username + } + if req.Config.WebDAV.Password != "" { + draft.Storage.WebDAV.Password = req.Config.WebDAV.Password + } + if req.Config.WebDAV.RootPath != "" { + draft.Storage.WebDAV.RootPath = req.Config.WebDAV.RootPath + } + if req.Config.WebDAV.URL != "" { + draft.Storage.WebDAV.URL = req.Config.WebDAV.URL + } } - if req.Config.WebDAV.URL != "" { - sh.storageConfig.WebDAV.URL = req.Config.WebDAV.URL + case "s3": + if req.Config != nil && req.Config.S3 != nil { + if draft.Storage.S3 == nil { + draft.Storage.S3 = &config.S3Config{} + } + if req.Config.S3.AccessKeyID != "" { + draft.Storage.S3.AccessKeyID = req.Config.S3.AccessKeyID + } + if req.Config.S3.SecretAccessKey != "" { + draft.Storage.S3.SecretAccessKey = req.Config.S3.SecretAccessKey + } + if req.Config.S3.BucketName != "" { + draft.Storage.S3.BucketName = req.Config.S3.BucketName + } + if req.Config.S3.EndpointURL != "" { + draft.Storage.S3.EndpointURL = req.Config.S3.EndpointURL + } + if req.Config.S3.RegionName != "" { + draft.Storage.S3.RegionName = req.Config.S3.RegionName + } + if req.Config.S3.Hostname != "" { + draft.Storage.S3.Hostname = req.Config.S3.Hostname + } + draft.Storage.S3.Proxy = req.Config.S3.Proxy } - - // 重新创建 WebDAV 存储以应用新配置 - if err := sh.storageManager.ReconfigureWebDAV( - sh.storageConfig.WebDAV.Hostname, - sh.storageConfig.WebDAV.Username, - sh.storageConfig.WebDAV.Password, - sh.storageConfig.WebDAV.RootPath, - ); err != nil { - common.InternalServerErrorResponse(c, "重新配置WebDAV存储失败: "+err.Error()) - return + case "nfs": + if req.Config != nil && req.Config.NFS != nil { + if draft.Storage.NFS == nil { + draft.Storage.NFS = &config.NFSConfig{} + } + if req.Config.NFS.Server != "" { + draft.Storage.NFS.Server = req.Config.NFS.Server + } + if req.Config.NFS.Path != "" { + draft.Storage.NFS.Path = req.Config.NFS.Path + } + if req.Config.NFS.MountPoint != "" { + draft.Storage.NFS.MountPoint = req.Config.NFS.MountPoint + } + if req.Config.NFS.Version != "" { + draft.Storage.NFS.Version = req.Config.NFS.Version + } + if req.Config.NFS.Options != "" { + draft.Storage.NFS.Options = req.Config.NFS.Options + } + if req.Config.NFS.Timeout > 0 { + draft.Storage.NFS.Timeout = req.Config.NFS.Timeout + } + if req.Config.NFS.SubPath != "" { + draft.Storage.NFS.SubPath = req.Config.NFS.SubPath + } + draft.Storage.NFS.AutoMount = req.Config.NFS.AutoMount + draft.Storage.NFS.RetryCount = req.Config.NFS.RetryCount } + default: + return fmt.Errorf("不支持的存储类型: %s", req.Type) } + return nil + }); err != nil { + common.InternalServerErrorResponse(c, "保存配置失败: "+err.Error()) + return + } - case "s3": - if req.Config != nil && req.Config.S3 != nil { - if sh.storageConfig.S3 == nil { - sh.storageConfig.S3 = &config.S3Config{} - } - if req.Config.S3.AccessKeyID != "" { - sh.storageConfig.S3.AccessKeyID = req.Config.S3.AccessKeyID - } - if req.Config.S3.SecretAccessKey != "" { - sh.storageConfig.S3.SecretAccessKey = req.Config.S3.SecretAccessKey - } - if req.Config.S3.BucketName != "" { - sh.storageConfig.S3.BucketName = req.Config.S3.BucketName - } - if req.Config.S3.EndpointURL != "" { - sh.storageConfig.S3.EndpointURL = req.Config.S3.EndpointURL - } - if req.Config.S3.RegionName != "" { - sh.storageConfig.S3.RegionName = req.Config.S3.RegionName - } - if req.Config.S3.Hostname != "" { - sh.storageConfig.S3.Hostname = req.Config.S3.Hostname + sh.storageConfig = sh.configManager.Storage + + switch req.Type { + case "webdav": + if req.Config != nil && req.Config.WebDAV != nil { + reconfigure = func() error { + return sh.storageManager.ReconfigureWebDAV( + sh.storageConfig.WebDAV.Hostname, + sh.storageConfig.WebDAV.Username, + sh.storageConfig.WebDAV.Password, + sh.storageConfig.WebDAV.RootPath, + ) } - // Proxy 字段直接赋值 - sh.storageConfig.S3.Proxy = req.Config.S3.Proxy } - case "nfs": if req.Config != nil && req.Config.NFS != nil { - if sh.storageConfig.NFS == nil { - sh.storageConfig.NFS = &config.NFSConfig{} - } - if req.Config.NFS.Server != "" { - sh.storageConfig.NFS.Server = req.Config.NFS.Server - } - if req.Config.NFS.Path != "" { - sh.storageConfig.NFS.Path = req.Config.NFS.Path - } - if req.Config.NFS.MountPoint != "" { - sh.storageConfig.NFS.MountPoint = req.Config.NFS.MountPoint - } - if req.Config.NFS.Version != "" { - sh.storageConfig.NFS.Version = req.Config.NFS.Version - } - if req.Config.NFS.Options != "" { - sh.storageConfig.NFS.Options = req.Config.NFS.Options - } - if req.Config.NFS.Timeout > 0 { - sh.storageConfig.NFS.Timeout = req.Config.NFS.Timeout - } - if req.Config.NFS.SubPath != "" { - sh.storageConfig.NFS.SubPath = req.Config.NFS.SubPath - } - // 直接赋值的字段 - sh.storageConfig.NFS.AutoMount = req.Config.NFS.AutoMount - sh.storageConfig.NFS.RetryCount = req.Config.NFS.RetryCount - - // 重新创建 NFS 存储以应用新配置 - if err := sh.storageManager.ReconfigureNFS( - sh.storageConfig.NFS.Server, - sh.storageConfig.NFS.Path, - sh.storageConfig.NFS.MountPoint, - sh.storageConfig.NFS.Version, - sh.storageConfig.NFS.Options, - sh.storageConfig.NFS.Timeout, - sh.storageConfig.NFS.AutoMount == 1, - sh.storageConfig.NFS.RetryCount, - sh.storageConfig.NFS.SubPath, - ); err != nil { - common.InternalServerErrorResponse(c, "重新配置NFS存储失败: "+err.Error()) - return + reconfigure = func() error { + return sh.storageManager.ReconfigureNFS( + sh.storageConfig.NFS.Server, + sh.storageConfig.NFS.Path, + sh.storageConfig.NFS.MountPoint, + sh.storageConfig.NFS.Version, + sh.storageConfig.NFS.Options, + sh.storageConfig.NFS.Timeout, + sh.storageConfig.NFS.AutoMount == 1, + sh.storageConfig.NFS.RetryCount, + sh.storageConfig.NFS.SubPath, + ) } } - - default: - common.BadRequestResponse(c, "不支持的存储类型: "+req.Type) - return } - // 持久化最新配置 - if err := sh.configManager.PersistYAML(); err != nil { - common.InternalServerErrorResponse(c, "保存配置失败: "+err.Error()) - return - } + if reconfigure != nil { + if err := reconfigure(); err != nil { + common.InternalServerErrorResponse(c, "重新配置存储失败: "+err.Error()) + return + } + } common.SuccessWithMessage(c, "存储配置更新成功", nil) } diff --git a/internal/models/db/admin_operation_log.go b/internal/models/db/admin_operation_log.go new file mode 100644 index 0000000..43cef7d --- /dev/null +++ b/internal/models/db/admin_operation_log.go @@ -0,0 +1,32 @@ +package db + +import "gorm.io/gorm" + +// AdminOperationLog 记录后台运维操作 +// Action: 具体操作标识,例如 maintenance.clean_temp +// Target: 操作影响的目标对象描述 +// Success: 操作是否成功 +// Message: 可读的描述信息 +// Actor: 操作者信息 +// LatencyMs: 操作耗时(毫秒,可选) +type AdminOperationLog struct { + gorm.Model + Action string `gorm:"size:100;index" json:"action"` + Target string `gorm:"size:255" json:"target"` + Success bool `json:"success"` + Message string `gorm:"type:text" json:"message"` + ActorID *uint `gorm:"index" json:"actor_id"` + ActorName string `gorm:"size:100" json:"actor_name"` + IP string `gorm:"size:45" json:"ip"` + LatencyMs int64 `json:"latency_ms"` +} + +// AdminOperationLogQuery 查询后台操作日志 +// Success: nil 表示忽略; true/false 表示按结果过滤 +type AdminOperationLogQuery struct { + Action string + Actor string + Success *bool + Page int + PageSize int +} diff --git a/internal/models/models.go b/internal/models/models.go index 527cd70..63b08bd 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -10,12 +10,14 @@ import ( // 类型别名,用于向后兼容 type ( // 数据库模型别名 - FileCode = db.FileCode - UploadChunk = db.UploadChunk - User = db.User - UserSession = db.UserSession - TransferLog = db.TransferLog - TransferLogQuery = db.TransferLogQuery + FileCode = db.FileCode + UploadChunk = db.UploadChunk + User = db.User + UserSession = db.UserSession + TransferLog = db.TransferLog + TransferLogQuery = db.TransferLogQuery + AdminOperationLog = db.AdminOperationLog + AdminOperationLogQuery = db.AdminOperationLogQuery // 服务模型别名 BuildInfo = service.BuildInfo diff --git a/internal/models/service/system.go b/internal/models/service/system.go index c9155ba..d99036e 100644 --- a/internal/models/service/system.go +++ b/internal/models/service/system.go @@ -19,7 +19,7 @@ var ( GitBranch = "unknown" // Version 应用版本号 - Version = "1.8.2" + Version = "1.9.1" ) // BuildInfo 构建信息结构体 diff --git a/internal/models/web/admin.go b/internal/models/web/admin.go index 1c2492b..299aa1a 100644 --- a/internal/models/web/admin.go +++ b/internal/models/web/admin.go @@ -253,21 +253,21 @@ type AdminFileDetail struct { // TransferLogItem 审计日志单条记录 type TransferLogItem struct { - ID uint `json:"id"` - Operation string `json:"operation"` - FileCode string `json:"file_code"` - FileName string `json:"file_name"` - FileSize int64 `json:"file_size"` - UserID *uint `json:"user_id,omitempty"` - Username string `json:"username"` - IP string `json:"ip"` - DurationMs int64 `json:"duration_ms"` - CreatedAt string `json:"created_at"` + ID uint `json:"id"` + Operation string `json:"operation"` + FileCode string `json:"file_code"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + UserID *uint `json:"user_id,omitempty"` + Username string `json:"username"` + IP string `json:"ip"` + DurationMs int64 `json:"duration_ms"` + CreatedAt string `json:"created_at"` } // TransferLogListResponse 审计日志列表响应 type TransferLogListResponse struct { - Logs []TransferLogItem `json:"logs"` + Logs []TransferLogItem `json:"logs"` Pagination PaginationResponse `json:"pagination"` } @@ -293,3 +293,23 @@ type TasksResponse struct { Tasks interface{} `json:"tasks"` // 使用 interface{} 以兼容现有类型 Total int `json:"total"` } + +// AdminOperationLogItem 管理员操作审计记录项 +type AdminOperationLogItem struct { + ID uint `json:"id"` + Action string `json:"action"` + Target string `json:"target"` + Success bool `json:"success"` + Message string `json:"message"` + ActorID *uint `json:"actor_id,omitempty"` + ActorName string `json:"actor_name"` + IP string `json:"ip"` + LatencyMs int64 `json:"latency_ms"` + CreatedAt string `json:"created_at"` +} + +// AdminOperationLogListResponse 管理员操作审计列表响应 +type AdminOperationLogListResponse struct { + Logs []AdminOperationLogItem `json:"logs"` + Pagination PaginationResponse `json:"pagination"` +} diff --git a/internal/repository/admin_operation_log.go b/internal/repository/admin_operation_log.go new file mode 100644 index 0000000..e83aaeb --- /dev/null +++ b/internal/repository/admin_operation_log.go @@ -0,0 +1,70 @@ +package repository + +import ( + "github.com/zy84338719/filecodebox/internal/models" + "github.com/zy84338719/filecodebox/internal/models/db" + "gorm.io/gorm" +) + +// AdminOperationLogDAO 管理后台运维日志 +type AdminOperationLogDAO struct { + db *gorm.DB +} + +func NewAdminOperationLogDAO(db *gorm.DB) *AdminOperationLogDAO { + return &AdminOperationLogDAO{db: db} +} + +func (dao *AdminOperationLogDAO) Create(log *models.AdminOperationLog) error { + if dao.db == nil { + return gorm.ErrInvalidDB + } + return dao.db.Create(log).Error +} + +func (dao *AdminOperationLogDAO) List(query db.AdminOperationLogQuery) ([]models.AdminOperationLog, int64, error) { + if dao.db == nil { + return nil, 0, gorm.ErrInvalidDB + } + + page := query.Page + if page < 1 { + page = 1 + } + + pageSize := query.PageSize + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 200 { + pageSize = 200 + } + + dbQuery := dao.db.Model(&models.AdminOperationLog{}) + + if query.Action != "" { + dbQuery = dbQuery.Where("action = ?", query.Action) + } + + if query.Actor != "" { + like := "%" + query.Actor + "%" + dbQuery = dbQuery.Where("actor_name LIKE ?", like) + } + + if query.Success != nil { + dbQuery = dbQuery.Where("success = ?", *query.Success) + } + + var total int64 + if err := dbQuery.Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + var logs []models.AdminOperationLog + if err := dbQuery.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil { + return nil, 0, err + } + + return logs, total, nil +} diff --git a/internal/repository/manager.go b/internal/repository/manager.go index d8a1e7f..f3483d1 100644 --- a/internal/repository/manager.go +++ b/internal/repository/manager.go @@ -13,6 +13,7 @@ type RepositoryManager struct { UserSession *UserSessionDAO Upload *ChunkDAO TransferLog *TransferLogDAO + AdminOpLog *AdminOperationLogDAO } // NewRepositoryManager 创建新的数据访问管理器 @@ -25,6 +26,7 @@ func NewRepositoryManager(db *gorm.DB) *RepositoryManager { UserSession: NewUserSessionDAO(db), Upload: NewChunkDAO(db), // 别名 TransferLog: NewTransferLogDAO(db), + AdminOpLog: NewAdminOperationLogDAO(db), } } diff --git a/internal/routes/admin.go b/internal/routes/admin.go index a50401b..f30b181 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -168,6 +168,7 @@ func setupMaintenanceRoutes(authGroup *gin.RouterGroup, adminHandler *handlers.A authGroup.GET("/maintenance/logs/export", adminHandler.ExportLogs) authGroup.GET("/maintenance/logs/stats", adminHandler.GetLogStats) authGroup.GET("/maintenance/logs", adminHandler.GetSystemLogs) + authGroup.GET("/maintenance/audit", adminHandler.GetOperationLogs) // 任务管理 authGroup.GET("/maintenance/tasks", adminHandler.GetRunningTasks) diff --git a/internal/services/admin/admin_test_helpers_test.go b/internal/services/admin/admin_test_helpers_test.go index edb8249..1e99314 100644 --- a/internal/services/admin/admin_test_helpers_test.go +++ b/internal/services/admin/admin_test_helpers_test.go @@ -24,7 +24,7 @@ func setupAdminTestService(t *testing.T) (*admin.Service, *repository.Repository t.Fatalf("failed to open test database: %v", err) } - if err := db.AutoMigrate(&models.User{}, &models.FileCode{}, &models.UploadChunk{}, &models.TransferLog{}); err != nil { + if err := db.AutoMigrate(&models.User{}, &models.FileCode{}, &models.UploadChunk{}, &models.TransferLog{}, &models.AdminOperationLog{}); err != nil { t.Fatalf("failed to auto-migrate test database: %v", err) } diff --git a/internal/services/admin/audit.go b/internal/services/admin/audit.go index 5f538c1..777c73a 100644 --- a/internal/services/admin/audit.go +++ b/internal/services/admin/audit.go @@ -19,3 +19,29 @@ func (s *Service) GetTransferLogs(page, pageSize int, operation, search string) } return s.repositoryManager.TransferLog.List(query) } + +// RecordOperationLog 记录后台运维操作 +func (s *Service) RecordOperationLog(log *models.AdminOperationLog) error { + if s.repositoryManager == nil || s.repositoryManager.AdminOpLog == nil { + return errors.New("运维审计存储未初始化") + } + if log == nil { + return errors.New("操作日志为空") + } + return s.repositoryManager.AdminOpLog.Create(log) +} + +// GetOperationLogs 获取运维审计日志 +func (s *Service) GetOperationLogs(page, pageSize int, action, actor string, success *bool) ([]models.AdminOperationLog, int64, error) { + if s.repositoryManager == nil || s.repositoryManager.AdminOpLog == nil { + return nil, 0, errors.New("运维审计存储未初始化") + } + query := models.AdminOperationLogQuery{ + Action: action, + Actor: actor, + Success: success, + Page: page, + PageSize: pageSize, + } + return s.repositoryManager.AdminOpLog.List(query) +} diff --git a/internal/services/admin/config.go b/internal/services/admin/config.go index 4576e38..84c1efd 100644 --- a/internal/services/admin/config.go +++ b/internal/services/admin/config.go @@ -22,170 +22,159 @@ func (s *Service) UpdateConfig(configData map[string]interface{}) error { // UpdateConfigFromRequest 从结构化请求更新配置 func (s *Service) UpdateConfigFromRequest(configRequest *web.AdminConfigRequest) error { - // 直接更新配置管理器的各个模块,不使用 map 转换 - ensureUI := func() *config.UIConfig { - if s.manager.UI == nil { - s.manager.UI = &config.UIConfig{} - } - return s.manager.UI - } - - // 处理基础配置 - if configRequest.Base != nil { - baseConfig := configRequest.Base - if baseConfig.Name != "" { - s.manager.Base.Name = baseConfig.Name - } - if baseConfig.Description != "" { - s.manager.Base.Description = baseConfig.Description - } - if baseConfig.Keywords != "" { - s.manager.Base.Keywords = baseConfig.Keywords - } - if baseConfig.Port != 0 { - s.manager.Base.Port = baseConfig.Port - } - if baseConfig.Host != "" { - s.manager.Base.Host = baseConfig.Host - } - if baseConfig.DataPath != "" { - s.manager.Base.DataPath = baseConfig.DataPath - } - s.manager.Base.Production = baseConfig.Production - } - - // 处理数据库配置 - if configRequest.Database != nil { - dbConfig := configRequest.Database - if dbConfig.Type != "" { - s.manager.Database.Type = dbConfig.Type - } - if dbConfig.Host != "" { - s.manager.Database.Host = dbConfig.Host - } - if dbConfig.Port != 0 { - s.manager.Database.Port = dbConfig.Port - } - if dbConfig.Name != "" { - s.manager.Database.Name = dbConfig.Name - } - if dbConfig.User != "" { - s.manager.Database.User = dbConfig.User - } - if dbConfig.Pass != "" { - s.manager.Database.Pass = dbConfig.Pass - } - if dbConfig.SSL != "" { - s.manager.Database.SSL = dbConfig.SSL - } - } - - // 处理传输配置 - if configRequest.Transfer != nil { - if configRequest.Transfer.Upload != nil { - uploadConfig := configRequest.Transfer.Upload - s.manager.Transfer.Upload.OpenUpload = uploadConfig.OpenUpload - s.manager.Transfer.Upload.UploadSize = uploadConfig.UploadSize - s.manager.Transfer.Upload.EnableChunk = uploadConfig.EnableChunk - s.manager.Transfer.Upload.ChunkSize = uploadConfig.ChunkSize - s.manager.Transfer.Upload.MaxSaveSeconds = uploadConfig.MaxSaveSeconds - s.manager.Transfer.Upload.RequireLogin = uploadConfig.RequireLogin - } - - if configRequest.Transfer.Download != nil { - downloadConfig := configRequest.Transfer.Download - s.manager.Transfer.Download.EnableConcurrentDownload = downloadConfig.EnableConcurrentDownload - s.manager.Transfer.Download.MaxConcurrentDownloads = downloadConfig.MaxConcurrentDownloads - s.manager.Transfer.Download.DownloadTimeout = downloadConfig.DownloadTimeout - s.manager.Transfer.Download.RequireLogin = downloadConfig.RequireLogin - } - } - - // 处理存储配置 - if configRequest.Storage != nil { - storageConfig := configRequest.Storage - if storageConfig.Type != "" { - s.manager.Storage.Type = storageConfig.Type - } - if storageConfig.StoragePath != "" { - s.manager.Storage.StoragePath = storageConfig.StoragePath - } - if storageConfig.S3 != nil { - s.manager.Storage.S3 = storageConfig.S3 - } - if storageConfig.WebDAV != nil { - s.manager.Storage.WebDAV = storageConfig.WebDAV - } - if storageConfig.OneDrive != nil { - s.manager.Storage.OneDrive = storageConfig.OneDrive - } - if storageConfig.NFS != nil { - s.manager.Storage.NFS = storageConfig.NFS - } - } - - // 处理用户系统配置 - if configRequest.User != nil { - userConfig := configRequest.User - s.manager.User.AllowUserRegistration = userConfig.AllowUserRegistration - s.manager.User.RequireEmailVerify = userConfig.RequireEmailVerify - if userConfig.UserStorageQuota != 0 { - s.manager.User.UserStorageQuota = userConfig.UserStorageQuota - } - if userConfig.UserUploadSize != 0 { - s.manager.User.UserUploadSize = userConfig.UserUploadSize - } - if userConfig.SessionExpiryHours != 0 { - s.manager.User.SessionExpiryHours = userConfig.SessionExpiryHours - } - if userConfig.MaxSessionsPerUser != 0 { - s.manager.User.MaxSessionsPerUser = userConfig.MaxSessionsPerUser - } - if userConfig.JWTSecret != "" { - s.manager.User.JWTSecret = userConfig.JWTSecret - } - } - - // 处理 MCP 配置 - if configRequest.MCP != nil { - mcpConfig := configRequest.MCP - s.manager.MCP.EnableMCPServer = mcpConfig.EnableMCPServer - if mcpConfig.MCPPort != "" { - s.manager.MCP.MCPPort = mcpConfig.MCPPort - } - if mcpConfig.MCPHost != "" { - s.manager.MCP.MCPHost = mcpConfig.MCPHost - } - } - - // 处理 UI 配置 - if configRequest.UI != nil { - uiConfig := configRequest.UI - ui := ensureUI() - if strings.TrimSpace(uiConfig.ThemesSelect) != "" { - ui.ThemesSelect = uiConfig.ThemesSelect - } - ui.PageExplain = uiConfig.PageExplain - ui.Opacity = uiConfig.Opacity - } - - // 顶层通知字段 - if configRequest.NotifyTitle != nil { - s.manager.NotifyTitle = *configRequest.NotifyTitle - } - if configRequest.NotifyContent != nil { - s.manager.NotifyContent = *configRequest.NotifyContent - } - - // 处理系统运行时字段 - if configRequest.SysStart != nil { - s.manager.SysStart = *configRequest.SysStart - } - - if err := s.manager.PersistYAML(); err != nil { - return fmt.Errorf("persist config: %w", err) - } - return nil + return s.manager.UpdateTransaction(func(draft *config.ConfigManager) error { + ensureUI := func(cfg *config.ConfigManager) *config.UIConfig { + if cfg.UI == nil { + cfg.UI = &config.UIConfig{} + } + return cfg.UI + } + + if configRequest.Base != nil { + baseConfig := configRequest.Base + if baseConfig.Name != "" { + draft.Base.Name = baseConfig.Name + } + if baseConfig.Description != "" { + draft.Base.Description = baseConfig.Description + } + if baseConfig.Keywords != "" { + draft.Base.Keywords = baseConfig.Keywords + } + if baseConfig.Port != 0 { + draft.Base.Port = baseConfig.Port + } + if baseConfig.Host != "" { + draft.Base.Host = baseConfig.Host + } + if baseConfig.DataPath != "" { + draft.Base.DataPath = baseConfig.DataPath + } + draft.Base.Production = baseConfig.Production + } + + if configRequest.Database != nil { + dbConfig := configRequest.Database + if dbConfig.Type != "" { + draft.Database.Type = dbConfig.Type + } + if dbConfig.Host != "" { + draft.Database.Host = dbConfig.Host + } + if dbConfig.Port != 0 { + draft.Database.Port = dbConfig.Port + } + if dbConfig.Name != "" { + draft.Database.Name = dbConfig.Name + } + if dbConfig.User != "" { + draft.Database.User = dbConfig.User + } + if dbConfig.Pass != "" { + draft.Database.Pass = dbConfig.Pass + } + if dbConfig.SSL != "" { + draft.Database.SSL = dbConfig.SSL + } + } + + if configRequest.Transfer != nil { + if configRequest.Transfer.Upload != nil { + uploadConfig := configRequest.Transfer.Upload + draft.Transfer.Upload.OpenUpload = uploadConfig.OpenUpload + draft.Transfer.Upload.UploadSize = uploadConfig.UploadSize + draft.Transfer.Upload.EnableChunk = uploadConfig.EnableChunk + draft.Transfer.Upload.ChunkSize = uploadConfig.ChunkSize + draft.Transfer.Upload.MaxSaveSeconds = uploadConfig.MaxSaveSeconds + draft.Transfer.Upload.RequireLogin = uploadConfig.RequireLogin + } + + if configRequest.Transfer.Download != nil { + downloadConfig := configRequest.Transfer.Download + draft.Transfer.Download.EnableConcurrentDownload = downloadConfig.EnableConcurrentDownload + draft.Transfer.Download.MaxConcurrentDownloads = downloadConfig.MaxConcurrentDownloads + draft.Transfer.Download.DownloadTimeout = downloadConfig.DownloadTimeout + draft.Transfer.Download.RequireLogin = downloadConfig.RequireLogin + } + } + + if configRequest.Storage != nil { + storageConfig := configRequest.Storage + if storageConfig.Type != "" { + draft.Storage.Type = storageConfig.Type + } + if storageConfig.StoragePath != "" { + draft.Storage.StoragePath = storageConfig.StoragePath + } + if storageConfig.S3 != nil { + draft.Storage.S3 = storageConfig.S3 + } + if storageConfig.WebDAV != nil { + draft.Storage.WebDAV = storageConfig.WebDAV + } + if storageConfig.OneDrive != nil { + draft.Storage.OneDrive = storageConfig.OneDrive + } + if storageConfig.NFS != nil { + draft.Storage.NFS = storageConfig.NFS + } + } + + if configRequest.User != nil { + userConfig := configRequest.User + draft.User.AllowUserRegistration = userConfig.AllowUserRegistration + draft.User.RequireEmailVerify = userConfig.RequireEmailVerify + if userConfig.UserStorageQuota != 0 { + draft.User.UserStorageQuota = userConfig.UserStorageQuota + } + if userConfig.UserUploadSize != 0 { + draft.User.UserUploadSize = userConfig.UserUploadSize + } + if userConfig.SessionExpiryHours != 0 { + draft.User.SessionExpiryHours = userConfig.SessionExpiryHours + } + if userConfig.MaxSessionsPerUser != 0 { + draft.User.MaxSessionsPerUser = userConfig.MaxSessionsPerUser + } + if userConfig.JWTSecret != "" { + draft.User.JWTSecret = userConfig.JWTSecret + } + } + + if configRequest.MCP != nil { + mcpConfig := configRequest.MCP + draft.MCP.EnableMCPServer = mcpConfig.EnableMCPServer + if mcpConfig.MCPPort != "" { + draft.MCP.MCPPort = mcpConfig.MCPPort + } + if mcpConfig.MCPHost != "" { + draft.MCP.MCPHost = mcpConfig.MCPHost + } + } + + if configRequest.UI != nil { + uiConfig := configRequest.UI + ui := ensureUI(draft) + if strings.TrimSpace(uiConfig.ThemesSelect) != "" { + ui.ThemesSelect = uiConfig.ThemesSelect + } + ui.PageExplain = uiConfig.PageExplain + ui.Opacity = uiConfig.Opacity + } + + if configRequest.NotifyTitle != nil { + draft.NotifyTitle = *configRequest.NotifyTitle + } + if configRequest.NotifyContent != nil { + draft.NotifyContent = *configRequest.NotifyContent + } + + if configRequest.SysStart != nil { + draft.SysStart = *configRequest.SysStart + } + + return nil + }) } // GetFullConfig 获取完整配置 - 返回配置管理器结构体 diff --git a/internal/services/admin/maintenance_test.go b/internal/services/admin/maintenance_test.go index c9a66ee..a263d0d 100644 --- a/internal/services/admin/maintenance_test.go +++ b/internal/services/admin/maintenance_test.go @@ -105,3 +105,39 @@ func TestCleanTempFilesRemovesOldChunks(t *testing.T) { t.Fatalf("expected chunk directory to be removed, stat err=%v", err) } } + +func TestRecordOperationLogAndList(t *testing.T) { + svc, repo, _ := setupAdminTestService(t) + + logEntry := &models.AdminOperationLog{ + Action: "maintenance.test", + Target: "unit", + Success: true, + Message: "ok", + ActorName: "tester", + IP: "127.0.0.1", + LatencyMs: 123, + } + + if err := svc.RecordOperationLog(logEntry); err != nil { + t.Fatalf("RecordOperationLog failed: %v", err) + } + + logs, total, err := svc.GetOperationLogs(1, 10, "maintenance.test", "tester", nil) + if err != nil { + t.Fatalf("GetOperationLogs returned error: %v", err) + } + if total != 1 || len(logs) != 1 { + t.Fatalf("expected one log, got total=%d len=%d", total, len(logs)) + } + + stored := logs[0] + if stored.Message != "ok" || stored.ActorName != "tester" || stored.IP != "127.0.0.1" { + t.Fatalf("unexpected log entry: %#v", stored) + } + + // ensure DAO was wired correctly + if repo.AdminOpLog == nil { + t.Fatalf("expected AdminOpLog DAO to be initialized") + } +} diff --git a/main.go b/main.go index 7c9ca31..b8b10ba 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,7 @@ package main // @title FileCodeBox API -// @version 1.8.2 +// @version 1.9.1 // @description FileCodeBox 是一个用于文件分享和代码片段管理的 Web 应用程序 // @termsOfService http://swagger.io/terms/ From db3f230a55fc2409688473b41234deb4ec0cea71 Mon Sep 17 00:00:00 2001 From: murphyyi Date: Wed, 24 Sep 2025 15:08:27 +0800 Subject: [PATCH 4/6] release: v1.9.1 docs & remove generated embed --- docs/swagger-enhanced.yaml | 4 ++-- docs/swagger.json | 4 ++-- docs/swagger.yaml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/swagger-enhanced.yaml b/docs/swagger-enhanced.yaml index d4d8c13..e71f264 100644 --- a/docs/swagger-enhanced.yaml +++ b/docs/swagger-enhanced.yaml @@ -2,7 +2,7 @@ swagger: "2.0" info: title: "FileCodeBox API" description: "FileCodeBox 是一个用于文件分享和代码片段管理的 Web 应用程序" - version: "1.8.2" + version: "1.9.1" termsOfService: "http://swagger.io/terms/" contact: name: "API Support" @@ -69,7 +69,7 @@ paths: example: "2025-09-11T10:00:00Z" version: type: "string" - example: "1.8.2" + example: "1.9.1" uptime: type: "string" example: "2h30m15s" diff --git a/docs/swagger.json b/docs/swagger.json index 0a5f130..4363e1e 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -13,7 +13,7 @@ "name": "MIT", "url": "https://github.com/zy84338719/filecodebox/blob/main/LICENSE" }, - "version": "1.8.2" + "version": "1.9.1" }, "host": "localhost:12345", "basePath": "/", @@ -553,7 +553,7 @@ }, "version": { "type": "string", - "example": "1.8.2" + "example": "1.9.1" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 76a5289..b4f156d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -12,7 +12,7 @@ definitions: example: 2h30m15s type: string version: - example: 1.8.2 + example: 1.9.1 type: string type: object handlers.SystemConfig: @@ -57,7 +57,7 @@ info: url: https://github.com/zy84338719/filecodebox/blob/main/LICENSE termsOfService: http://swagger.io/terms/ title: FileCodeBox API - version: "1.8.2" + version: "1.9.1" paths: /api/config: get: From 16df4a713b4b2c7396b93a8cc5761a2d293935fc Mon Sep 17 00:00:00 2001 From: murphyyi Date: Wed, 24 Sep 2025 16:37:48 +0800 Subject: [PATCH 5/6] ci: gate heavy workflows to tags and explicit flags/labels --- .github/workflows/build.yml | 6 +++--- .github/workflows/ci.yml | 19 +++++++++++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d9503d9..193722f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,11 +2,11 @@ name: Build and Release on: push: + # Only trigger the heavy multi-platform build on version tag pushes (e.g. v1.9.1) tags: - 'v*' - branches: [ main ] - pull_request: - branches: [ main ] + # Allow manual dispatch for ad-hoc builds from the UI + workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49b89e9..d70957e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,8 @@ on: branches: [ main, develop ] pull_request: branches: [ main, develop ] + # Allow manual runs + workflow_dispatch: jobs: test: @@ -102,7 +104,14 @@ jobs: name: Docker Test runs-on: ubuntu-latest needs: test - if: github.event_name == 'push' + # Run Docker Test only when explicitly requested via commit message flag + # (e.g., include [docker-test] in the commit message) or when running on tags/branches on CI + # Run when explicitly requested by commit message, PR label, tag push, or manual dispatch + if: | + contains(github.event.head_commit.message, '[docker-test]') || + startsWith(github.ref, 'refs/tags/') || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && contains(join(github.event.pull_request.labels.*.name, ','), 'run-full-ci')) steps: - name: Checkout code @@ -148,7 +157,13 @@ jobs: security: name: Security Scan runs-on: ubuntu-latest - if: github.event_name == 'push' + # Run Security Scan only when explicitly requested via commit message flag + # (e.g., include [security-scan] in the commit message) or when running manually + # Run when explicitly requested by commit message, PR label, or manual dispatch + if: | + contains(github.event.head_commit.message, '[security-scan]') || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && contains(join(github.event.pull_request.labels.*.name, ','), 'run-security')) steps: - name: Checkout code From 68407df31f63fd445fbe93e512bc652df4060600 Mon Sep 17 00:00:00 2001 From: murphyyi Date: Wed, 24 Sep 2025 22:29:36 +0800 Subject: [PATCH 6/6] docs(CONTRIBUTING): explain CI gating and how to trigger heavy workflows --- CONTRIBUTING.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..24e964a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,69 @@ +## Contributing and CI guidelines + +This document explains how our GitHub Actions are gated and how to trigger heavier CI jobs (cross-platform builds, Docker tests, security scans) intentionally. + +### CI design principles +- Lightweight checks (unit tests, linters) run on `push` / `pull_request` for `main` and `develop` branches. +- Heavy tasks (cross-platform builds, Docker image builds & push, release packaging) only run on tag pushes (e.g. `v1.9.1`) or when explicitly requested. + +This reduces wasted CI minutes and avoids building/publishing artifacts for every code merge. + +### How to trigger heavy CI jobs +There are multiple intentional ways to trigger full/heavy workflows: + +- Push a semantic version tag (recommended for releases): + ```bash + git tag v1.9.1 + git push origin v1.9.1 + ``` + Tag pushes trigger the `build.yml` / `release.yml` flows which do cross-platform builds and create the Release. + +- Use commit message flags for ad-hoc full CI when pushing branches: + - `[docker-test]` — triggers Docker Test job + - `[security-scan]` — triggers Security Scan job + Example: + ```bash + git commit -m "feat: add X [docker-test]" + git push origin feature/xxx + ``` + +- Use PR labels to request full CI for a pull request (team members with label permissions can add labels): + - `run-full-ci` — triggers Docker Test + - `run-security` — triggers Security Scan + +- Manual dispatch from GitHub Actions UI (`workflow_dispatch`) — allowed for `build.yml` and can be used to run ad-hoc full builds. + +### Release flow (recommended) +1. Finish work on a branch, open PR and get reviews. +2. Merge to `main` when ready (this runs lightweight CI only). +3. Create a version tag on the merge commit and push the tag: + ```bash + git tag vX.Y.Z + git push origin vX.Y.Z + ``` +4. The tag push will run the release pipeline (build artifacts, create release, push Docker images). + +### Generated files and embeds +- Some files under `uploads/` or other `generated/` directories may be produced by code generation tools. Do not commit generated artifacts unless they are intentionally tracked. +- If a file uses `//go:embed` to include generated assets, ensure the assets exist in the repository or exclude the embed file from normal builds (recommended: keep generated assets out of VCS and generate them in CI when needed). + +Recommended `.gitignore` additions for generated assets (add if appropriate): +``` +# generated embed or build artifacts +uploads/ +dist/ +build/ +``` + +### Debugging CI triggers +- To see why a job ran, open the Actions page, find the workflow run and view the `Event` and `Jobs` details. The `Event` will indicate whether it was a `push`, `pull_request`, `workflow_dispatch`, or `tag` event. +- If you expected a heavy job but it didn't run, verify: + - You pushed a tag (tags trigger build/release flows). + - The commit message includes the required token (e.g., `[docker-test]`). + - A PR contains the appropriate label (`run-full-ci` or `run-security`). + +### Contact / ownership +- CI workflow files are located under `.github/workflows/`. If you want to change gating logic, please open a PR and tag the maintainers. + +--- +Thank you for contributing — keeping heavy CI runs intentional saves time and cost for the whole team.