From ebd9cb8b04c7a74a86e3c0790cb5c2d69346eb56 Mon Sep 17 00:00:00 2001 From: lan Date: Sun, 15 Mar 2026 02:25:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=92=8C=E6=95=8F=E6=84=9F=E8=AF=8D=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增日志管理模块:操作日志、登录日志、数据变更日志 - 新增敏感词过滤器 (sanitizer) - 新增日志异步写入管理器 - 新增日志定时清理服务 - 优化帖子相关服务和上传服务 --- cmd/migrate/main.go | 133 +++++++++ configs/config.yaml | 26 ++ go.mod | 1 + go.sum | 3 + internal/config/server.go | 42 ++- internal/dto/dto.go | 12 +- internal/handler/admin_log_handler.go | 215 ++++++++++++++ internal/handler/upload_handler.go | 8 +- internal/handler/user_handler.go | 156 ++++++++-- internal/middleware/access_log.go | 200 +++++++++++++ internal/model/data_change_log.go | 56 ++++ internal/model/login_log.go | 87 ++++++ internal/model/operation_log.go | 86 ++++++ internal/model/post.go | 22 +- internal/pkg/sanitizer/sanitizer.go | 139 +++++++++ internal/repository/data_change_log_repo.go | 136 +++++++++ internal/repository/login_log_repo.go | 159 ++++++++++ internal/repository/operation_log_repo.go | 136 +++++++++ internal/repository/post_repo.go | 22 +- internal/router/router.go | 41 ++- internal/service/admin_post_service.go | 37 ++- internal/service/async_log_manager.go | 304 ++++++++++++++++++++ internal/service/comment_service.go | 19 ++ internal/service/log_cleanup_service.go | 205 +++++++++++++ internal/service/log_service.go | 172 +++++++++++ internal/service/post_service.go | 24 +- internal/service/upload_service.go | 97 ++++++- internal/service/user_service.go | 141 ++++++++- internal/wire/handler.go | 36 ++- internal/wire/repository.go | 5 + internal/wire/service.go | 93 ++++++ 31 files changed, 2753 insertions(+), 60 deletions(-) create mode 100644 cmd/migrate/main.go create mode 100644 internal/handler/admin_log_handler.go create mode 100644 internal/middleware/access_log.go create mode 100644 internal/model/data_change_log.go create mode 100644 internal/model/login_log.go create mode 100644 internal/model/operation_log.go create mode 100644 internal/pkg/sanitizer/sanitizer.go create mode 100644 internal/repository/data_change_log_repo.go create mode 100644 internal/repository/login_log_repo.go create mode 100644 internal/repository/operation_log_repo.go create mode 100644 internal/service/async_log_manager.go create mode 100644 internal/service/log_cleanup_service.go create mode 100644 internal/service/log_service.go diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..87a17dc --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,133 @@ +package main + +import ( + "flag" + "fmt" + "log" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +// Config 数据库配置 +type Config struct { + Host string + Port int + User string + Password string + DBName string + SSLMode string +} + +type PostImage struct { + ID string `gorm:"type:varchar(36);primaryKey"` + PostID string `gorm:"type:varchar(36);index"` + URL string `gorm:"type:text"` + ThumbnailURL string `gorm:"type:text"` + PreviewURL string `gorm:"type:text"` + PreviewURLLarge string `gorm:"type:text"` + Width int `gorm:"default:0"` + Height int `gorm:"default:0"` + Size int64 `gorm:"default:0"` + MimeType string `gorm:"type:varchar(50)"` + SortOrder int `gorm:"default:0"` +} + +func (PostImage) TableName() string { + return "post_images" +} + +func main() { + // 解析命令行参数 + host := flag.String("host", "localhost", "数据库主机") + port := flag.Int("port", 5432, "数据库端口") + user := flag.String("user", "postgres", "数据库用户") + password := flag.String("password", "", "数据库密码") + dbname := flag.String("dbname", "carrot_bbs", "数据库名称") + sslmode := flag.String("sslmode", "disable", "SSL 模式") + flag.Parse() + + cfg := &Config{ + Host: *host, + Port: *port, + User: *user, + Password: *password, + DBName: *dbname, + SSLMode: *sslmode, + } + + // 连接数据库 + dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode) + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + + // 测试连接 + sqlDB, err := db.DB() + if err != nil { + log.Fatalf("Failed to get database instance: %v", err) + } + defer sqlDB.Close() + + if err := sqlDB.Ping(); err != nil { + log.Fatalf("Failed to ping database: %v", err) + } + + log.Println("Connected to database") + + // 运行迁移 + if err := runMigration(db); err != nil { + log.Fatalf("Migration failed: %v", err) + } + + log.Println("Migration completed successfully") +} + +func runMigration(db *gorm.DB) error { + // 检查字段是否已存在 + columns, err := db.Migrator().ColumnTypes(&PostImage{}) + if err != nil { + return fmt.Errorf("failed to check column existence: %w", err) + } + + hasPreviewURL := false + hasPreviewURLLarge := false + for _, col := range columns { + if col.Name() == "preview_url" { + hasPreviewURL = true + } + if col.Name() == "preview_url_large" { + hasPreviewURLLarge = true + } + } + + if hasPreviewURL && hasPreviewURLLarge { + log.Println("Columns already exist, skipping migration") + return nil + } + + // 添加字段 + if !hasPreviewURL { + log.Println("Adding preview_url column...") + if err := db.Exec("ALTER TABLE post_images ADD COLUMN preview_url TEXT").Error; err != nil { + return fmt.Errorf("failed to add preview_url column: %w", err) + } + } + + if !hasPreviewURLLarge { + log.Println("Adding preview_url_large column...") + if err := db.Exec("ALTER TABLE post_images ADD COLUMN preview_url_large TEXT").Error; err != nil { + return fmt.Errorf("failed to add preview_url_large column: %w", err) + } + } + + log.Println("Migration completed successfully") + return nil +} + +// 注意:此脚本仅用于添加数据库字段 +// 预览图的生成由后端的 upload_service 自动处理 +// 现有图片需要重新上传才能生成预览图,或者编写额外的后台任务来生成 diff --git a/configs/config.yaml b/configs/config.yaml index b6809ba..363648d 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -87,6 +87,32 @@ log: output_paths: - stdout - ./logs/app.log + retention: + access_log: 180 # HTTP访问日志 6个月 + operation_log: 365 # 操作日志 1年 + login_log: 1095 # 登录日志 3年 + data_change: 1095 # 数据变更日志 3年 + async: + buffer_size: 10000 + batch_size: 100 + flush_interval: 3s + worker_count: 3 + enable_fallback: true + fallback_path: ./logs/fallback.log + sensitive: + enabled: true + mask_fields: + - password + - token + - secret + - phone + - id_card + - bank_card + rotation: + max_size: 100 + max_age: 30 + max_backups: 10 + compress: true rate_limit: enabled: true diff --git a/go.mod b/go.mod index e90021b..a98b4f3 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/disintegration/imaging v1.6.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.3 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect diff --git a/go.sum b/go.sum index e5f1f56..02e1658 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -296,6 +298,7 @@ golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/internal/config/server.go b/internal/config/server.go index ae7794a..4ceb2b6 100644 --- a/internal/config/server.go +++ b/internal/config/server.go @@ -9,9 +9,45 @@ type ServerConfig struct { // LogConfig 日志配置 type LogConfig struct { - Level string `mapstructure:"level"` - Encoding string `mapstructure:"encoding"` - OutputPaths []string `mapstructure:"output_paths"` + Level string `mapstructure:"level"` + Encoding string `mapstructure:"encoding"` + OutputPaths []string `mapstructure:"output_paths"` + Retention LogRetentionConfig `mapstructure:"retention"` + Async AsyncLogConfig `mapstructure:"async"` + Sensitive SensitiveLogConfig `mapstructure:"sensitive"` + Rotation LogRotationConfig `mapstructure:"rotation"` +} + +// LogRetentionConfig 日志保留配置 +type LogRetentionConfig struct { + AccessLog int `mapstructure:"access_log"` // HTTP访问日志(天) + OperationLog int `mapstructure:"operation_log"` // 操作日志(天) + LoginLog int `mapstructure:"login_log"` // 登录日志(天) + DataChange int `mapstructure:"data_change"` // 数据变更日志(天) +} + +// AsyncLogConfig 异步日志配置 +type AsyncLogConfig struct { + BufferSize int `mapstructure:"buffer_size"` // 通道缓冲大小 + BatchSize int `mapstructure:"batch_size"` // 批量写入大小 + FlushInterval string `mapstructure:"flush_interval"` // 刷新间隔 + WorkerCount int `mapstructure:"worker_count"` // worker数量 + EnableFallback bool `mapstructure:"enable_fallback"` // 启用降级 + FallbackPath string `mapstructure:"fallback_path"` // 降级文件路径 +} + +// SensitiveLogConfig 敏感日志配置 +type SensitiveLogConfig struct { + Enabled bool `mapstructure:"enabled"` // 启用脱敏 + MaskFields []string `mapstructure:"mask_fields"` // 需脱敏字段 +} + +// LogRotationConfig 日志轮转配置 +type LogRotationConfig struct { + MaxSize int `mapstructure:"max_size"` // 单个文件最大大小(MB) + MaxAge int `mapstructure:"max_age"` // 最大保留天数 + MaxBackups int `mapstructure:"max_backups"` // 最大备份数 + Compress bool `mapstructure:"compress"` // 压缩 } // RateLimitConfig 限流配置 diff --git a/internal/dto/dto.go b/internal/dto/dto.go index 1b0b46c..eea6e23 100644 --- a/internal/dto/dto.go +++ b/internal/dto/dto.go @@ -104,11 +104,13 @@ type AdminUpdateUserStatusRequest struct { // PostImageResponse 帖子图片响应 type PostImageResponse struct { - ID string `json:"id"` - URL string `json:"url"` - ThumbnailURL string `json:"thumbnail_url"` - Width int `json:"width"` - Height int `json:"height"` + ID string `json:"id"` + URL string `json:"url"` + ThumbnailURL string `json:"thumbnail_url"` + PreviewURL string `json:"preview_url"` // 列表/网格预览图 + PreviewURLLarge string `json:"preview_url_large"` // 详情页预览图 + Width int `json:"width"` + Height int `json:"height"` } // PostResponse 帖子响应(列表用) diff --git a/internal/handler/admin_log_handler.go b/internal/handler/admin_log_handler.go new file mode 100644 index 0000000..e543875 --- /dev/null +++ b/internal/handler/admin_log_handler.go @@ -0,0 +1,215 @@ +package handler + +import ( + "strconv" + "time" + + "carrot_bbs/internal/pkg/response" + "carrot_bbs/internal/repository" + "carrot_bbs/internal/service" + + "github.com/gin-gonic/gin" +) + +// AdminLogHandler 管理后台日志处理器 +type AdminLogHandler struct { + operationLogService service.OperationLogService + loginLogService service.LoginLogService + dataChangeLogService service.DataChangeLogService +} + +// NewAdminLogHandler 创建管理后台日志处理器 +func NewAdminLogHandler( + operationLogService service.OperationLogService, + loginLogService service.LoginLogService, + dataChangeLogService service.DataChangeLogService, +) *AdminLogHandler { + return &AdminLogHandler{ + operationLogService: operationLogService, + loginLogService: loginLogService, + dataChangeLogService: dataChangeLogService, + } +} + +// GetOperationLogs 获取操作日志列表 +// GET /api/v1/admin/logs/operations +func (h *AdminLogHandler) GetOperationLogs(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + filters := repository.LogFilter{ + UserID: c.Query("user_id"), + Operation: c.Query("operation"), + TargetType: c.Query("target_type"), + TargetID: c.Query("target_id"), + Status: c.Query("status"), + IP: c.Query("ip"), + StartTime: c.Query("start_time"), + EndTime: c.Query("end_time"), + } + + logs, total, err := h.operationLogService.GetOperationLogs(c.Request.Context(), filters, page, pageSize) + if err != nil { + response.HandleError(c, err, "failed to get operation logs") + return + } + + response.Success(c, gin.H{ + "logs": logs, + "pagination": gin.H{ + "page": page, + "page_size": pageSize, + "total": total, + }, + }) +} + +// GetLoginLogs 获取登录日志列表 +// GET /api/v1/admin/logs/logins +func (h *AdminLogHandler) GetLoginLogs(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + filters := repository.LoginFilter{ + UserID: c.Query("user_id"), + Event: c.Query("event"), + Result: c.Query("result"), + FailReason: c.Query("fail_reason"), + LoginType: c.Query("login_type"), + IP: c.Query("ip"), + } + + if startTime := c.Query("start_time"); startTime != "" { + if t, err := time.Parse(time.RFC3339, startTime); err == nil { + filters.StartTime = t + } + } + + if endTime := c.Query("end_time"); endTime != "" { + if t, err := time.Parse(time.RFC3339, endTime); err == nil { + filters.EndTime = t + } + } + + logs, total, err := h.loginLogService.GetLoginLogs(c.Request.Context(), filters, page, pageSize) + if err != nil { + response.HandleError(c, err, "failed to get login logs") + return + } + + response.Success(c, gin.H{ + "logs": logs, + "pagination": gin.H{ + "page": page, + "page_size": pageSize, + "total": total, + }, + }) +} + +// GetDataChangeLogs 获取数据变更日志列表 +// GET /api/v1/admin/logs/data-changes +func (h *AdminLogHandler) GetDataChangeLogs(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + filters := repository.DataChangeFilter{ + UserID: c.Query("user_id"), + OperatorID: c.Query("operator_id"), + ChangeType: c.Query("change_type"), + TargetType: c.Query("target_type"), + OperatorType: c.Query("operator_type"), + IP: c.Query("ip"), + StartTime: c.Query("start_time"), + EndTime: c.Query("end_time"), + } + + logs, total, err := h.dataChangeLogService.GetDataChangeLogs(c.Request.Context(), filters, page, pageSize) + if err != nil { + response.HandleError(c, err, "failed to get data change logs") + return + } + + response.Success(c, gin.H{ + "logs": logs, + "pagination": gin.H{ + "page": page, + "page_size": pageSize, + "total": total, + }, + }) +} + +// GetLogStatistics 获取日志统计 +// GET /api/v1/admin/logs/statistics +func (h *AdminLogHandler) GetLogStatistics(c *gin.Context) { + startTime := c.DefaultQuery("start_time", "") + endTime := c.DefaultQuery("end_time", "") + + operationStats, err := h.operationLogService.GetStatistics(c.Request.Context(), startTime, endTime) + if err != nil { + response.HandleError(c, err, "failed to get operation log statistics") + return + } + + loginStats, err := h.loginLogService.GetStatistics(c.Request.Context(), startTime, endTime) + if err != nil { + response.HandleError(c, err, "failed to get login log statistics") + return + } + + dataChangeStats, err := h.dataChangeLogService.GetStatistics(c.Request.Context(), startTime, endTime) + if err != nil { + response.HandleError(c, err, "failed to get data change log statistics") + return + } + + response.Success(c, gin.H{ + "operation_logs": operationStats, + "login_logs": loginStats, + "data_change_logs": dataChangeStats, + }) +} + +// ExportLogs 导出日志 +// GET /api/v1/admin/logs/export +func (h *AdminLogHandler) ExportLogs(c *gin.Context) { + logType := c.Query("type") + startTime := c.Query("start_time") + endTime := c.Query("end_time") + + switch logType { + case "operation": + logs, _, err := h.operationLogService.GetOperationLogsByTimeRange(c.Request.Context(), startTime, endTime, 1, 10000) + if err != nil { + response.HandleError(c, err, "failed to export operation logs") + return + } + response.Success(c, gin.H{ + "type": "operation", + "logs": logs, + }) + case "login": + logs, _, err := h.loginLogService.GetLoginLogs(c.Request.Context(), repository.LoginFilter{}, 1, 10000) + if err != nil { + response.HandleError(c, err, "failed to export login logs") + return + } + response.Success(c, gin.H{ + "type": "login", + "log": logs, + }) + case "data_change": + logs, _, err := h.dataChangeLogService.GetDataChangeLogs(c.Request.Context(), repository.DataChangeFilter{}, 1, 10000) + if err != nil { + response.HandleError(c, err, "failed to export data change logs") + return + } + response.Success(c, gin.H{ + "type": "data_change", + "log": logs, + }) + default: + response.Error(c, 400, "invalid log type") + } +} diff --git a/internal/handler/upload_handler.go b/internal/handler/upload_handler.go index b57409f..a0adb4c 100644 --- a/internal/handler/upload_handler.go +++ b/internal/handler/upload_handler.go @@ -33,13 +33,17 @@ func (h *UploadHandler) UploadImage(c *gin.Context) { return } - url, err := h.uploadService.UploadImage(c.Request.Context(), file) + url, previewURL, previewURLLarge, err := h.uploadService.UploadImage(c.Request.Context(), file) if err != nil { response.InternalServerError(c, "failed to upload image") return } - response.Success(c, gin.H{"url": url}) + response.Success(c, gin.H{ + "url": url, + "preview_url": previewURL, + "preview_url_large": previewURLLarge, + }) } // UploadAvatar 上传头像 diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index 8d02523..5117bb8 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -2,6 +2,8 @@ package handler import ( "context" + "crypto/sha256" + "fmt" "strconv" "github.com/gin-gonic/gin" @@ -17,6 +19,7 @@ type UserHandler struct { userService service.UserService activityService service.UserActivityService // 用户活跃统计服务 jwtService *service.JWTService + logService *service.LogService } // NewUserHandler 创建用户处理器 @@ -27,6 +30,28 @@ func NewUserHandler(userService service.UserService, activityService service.Use } } +// SetJWTService 设置JWT服务 +func (h *UserHandler) SetJWTService(jwtService *service.JWTService) { + h.jwtService = jwtService +} + +// SetActivityService 设置活跃统计服务 +func (h *UserHandler) SetActivityService(activityService service.UserActivityService) { + h.activityService = activityService +} + +// SetLogService 设置日志服务 +func (h *UserHandler) SetLogService(logService *service.LogService) { + h.logService = logService +} + +// generateTokenID 生成Token ID +func generateTokenID(token string) string { + h := sha256.New() + h.Write([]byte(token)) + return fmt.Sprintf("%x", h.Sum(nil))[:16] +} + // Register 用户注册 func (h *UserHandler) Register(c *gin.Context) { type RegisterRequest struct { @@ -54,6 +79,35 @@ func (h *UserHandler) Register(c *gin.Context) { accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username) refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username) + ip := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + + // 记录用户活跃 + if h.activityService != nil { + go func() { + ctx := context.Background() + loginType := model.LoginTypeLogin + h.activityService.RecordUserActive(ctx, user.ID, loginType, ip, userAgent) + }() + } + + // 记录注册后首次登录 + if h.logService != nil { + go func() { + h.logService.LoginLog.RecordLogin(&model.LoginLog{ + UserID: user.ID, + UserName: user.Username, + NickName: user.Nickname, + LoginType: string(model.LoginTypePassword), + Event: string(model.LoginEventLogin), + IP: ip, + UserAgent: userAgent, + Result: string(model.LoginResultSuccess), + TokenID: generateTokenID(refreshToken), + }) + }() + } + response.Success(c, gin.H{ "user": dto.ConvertUserToResponse(user), "token": accessToken, @@ -94,17 +148,36 @@ func (h *UserHandler) Login(c *gin.Context) { accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username) refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username) + ip := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + // 记录用户活跃 if h.activityService != nil { go func() { ctx := context.Background() loginType := model.LoginTypeLogin - ip := c.ClientIP() - userAgent := c.GetHeader("User-Agent") h.activityService.RecordUserActive(ctx, user.ID, loginType, ip, userAgent) }() } + // 更新登录日志(补充IP和UserAgent) + if h.logService != nil { + ip := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + go func() { + h.logService.LoginLog.RecordLogin(&model.LoginLog{ + UserID: user.ID, + UserName: user.Username, + NickName: user.Nickname, + LoginType: string(model.LoginTypePassword), + Event: string(model.LoginEventLogin), + IP: ip, + UserAgent: userAgent, + Result: string(model.LoginResultSuccess), + }) + }() + } + response.Success(c, gin.H{ "user": dto.ConvertUserToResponse(user), "token": accessToken, @@ -364,18 +437,43 @@ func (h *UserHandler) RefreshToken(c *gin.Context) { return } + // 获取用户信息 + user, err := h.userService.GetUserByID(c.Request.Context(), claims.UserID) + if err != nil { + response.InternalServerError(c, "user not found") + return + } + // 生成新 token - accessToken, _ := h.jwtService.GenerateAccessToken(claims.UserID, claims.Username) - refreshToken, _ := h.jwtService.GenerateRefreshToken(claims.UserID, claims.Username) + accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username) + refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username) + + ip := c.ClientIP() + userAgent := c.GetHeader("User-Agent") // 记录用户活跃 if h.activityService != nil { go func() { ctx := context.Background() loginType := model.LoginTypeRefresh - ip := c.ClientIP() - userAgent := c.GetHeader("User-Agent") - h.activityService.RecordUserActive(ctx, claims.UserID, loginType, ip, userAgent) + h.activityService.RecordUserActive(ctx, user.ID, loginType, ip, userAgent) + }() + } + + // 记录Token刷新日志 + if h.logService != nil { + go func() { + h.logService.LoginLog.RecordLogin(&model.LoginLog{ + UserID: user.ID, + UserName: user.Username, + NickName: user.Nickname, + LoginType: string(model.LoginTypePassword), + Event: string(model.LoginEventRefresh), + IP: ip, + UserAgent: userAgent, + Result: string(model.LoginResultSuccess), + TokenID: generateTokenID(req.RefreshToken), + }) }() } @@ -385,16 +483,6 @@ func (h *UserHandler) RefreshToken(c *gin.Context) { }) } -// SetJWTService 设置JWT服务 -func (h *UserHandler) SetJWTService(jwtSvc *service.JWTService) { - h.jwtService = jwtSvc -} - -// SetActivityService 设置活跃统计服务 -func (h *UserHandler) SetActivityService(activityService service.UserActivityService) { - h.activityService = activityService -} - // FollowUser 关注用户 func (h *UserHandler) FollowUser(c *gin.Context) { userID := c.Param("id") @@ -657,6 +745,40 @@ func (h *UserHandler) SendChangePasswordCode(c *gin.Context) { response.Success(c, gin.H{"success": true}) } +// Logout 用户登出 +func (h *UserHandler) Logout(c *gin.Context) { + currentUserID := c.GetString("user_id") + if currentUserID == "" { + response.Unauthorized(c, "not logged in") + return + } + + ip := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + + // 获取用户信息 + user, err := h.userService.GetUserByID(c.Request.Context(), currentUserID) + if err != nil { + response.InternalServerError(c, "user not found") + return + } + + // 记录登出日志 + if h.logService != nil { + h.logService.LoginLog.RecordLogin(&model.LoginLog{ + UserID: user.ID, + UserName: user.Username, + NickName: user.Nickname, + Event: string(model.LoginEventLogout), + IP: ip, + UserAgent: userAgent, + Result: string(model.LoginResultSuccess), + }) + } + + response.Success(c, gin.H{"success": true}) +} + // Search 搜索用户 func (h *UserHandler) Search(c *gin.Context) { keyword := c.Query("keyword") diff --git a/internal/middleware/access_log.go b/internal/middleware/access_log.go new file mode 100644 index 0000000..4775efe --- /dev/null +++ b/internal/middleware/access_log.go @@ -0,0 +1,200 @@ +package middleware + +import ( + "bytes" + "io" + "strings" + "time" + + "carrot_bbs/internal/model" + "carrot_bbs/internal/pkg/sanitizer" + + "github.com/gin-gonic/gin" +) + +// AccessLogConfig 访问日志配置 +type AccessLogConfig struct { + Enable bool + SkipPaths []string + ServerIP string + ServerPort int +} + +// AccessLogMiddleware 访问日志中间件 +func AccessLogMiddleware(logService OperationLogRecorder, config *AccessLogConfig) gin.HandlerFunc { + return func(c *gin.Context) { + if !config.Enable { + c.Next() + return + } + + // 跳过指定路径 + for _, path := range config.SkipPaths { + if strings.HasPrefix(c.Request.URL.Path, path) { + c.Next() + return + } + } + + start := time.Now() + path := c.Request.URL.Path + method := c.Request.Method + + // 读取请求体(用于记录) + var requestBody string + if c.Request.Body != nil { + bodyBytes, _ := io.ReadAll(c.Request.Body) + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + requestBody = string(bodyBytes) + } + + c.Next() + + // 记录日志 + latency := time.Since(start) + statusCode := c.Writer.Status() + + userID := "" + if userIDVal, exists := c.Get("user_id"); exists { + if uid, ok := userIDVal.(string); ok { + userID = uid + } + } + + userName := "" + if userNameVal, exists := c.Get("user_name"); exists { + if name, ok := userNameVal.(string); ok { + userName = name + } + } + + ip := c.ClientIP() + userAgent := c.Request.UserAgent() + referer := c.Request.Referer() + + // 确定操作类型 + operation := determineOperation(method, path, c) + + // 脱敏处理 + requestBody = sanitizeRequestBody(requestBody) + + // 构造日志对象 + log := &model.OperationLog{ + UserID: userID, + UserName: sanitizer.MaskValue("username", userName), + Operation: operation, + Module: determineModule(path), + Method: method, + Path: path, + QueryParams: c.Request.URL.RawQuery, + RequestBody: requestBody, + ResponseCode: statusCode, + IP: ip, + UserAgent: userAgent, + Duration: latency.Milliseconds(), + Referer: referer, + Protocol: c.Request.Proto, + ServerIP: config.ServerIP, + ServerPort: config.ServerPort, + Status: determineStatus(statusCode), + OccurredAt: start, + } + + // 异步记录日志 + logService.RecordOperation(log) + } +} + +// OperationLogRecorder 操作日志记录器接口 +type OperationLogRecorder interface { + RecordOperation(log *model.OperationLog) +} + +// determineOperation 确定操作类型 +func determineOperation(method, path string, c *gin.Context) string { + if method == "GET" { + return string(model.OpUserLogin) + } + + pathLower := strings.ToLower(path) + + switch { + case strings.Contains(pathLower, "/posts"): + if method == "POST" { + return string(model.OpPostCreate) + } else if method == "PUT" || method == "PATCH" { + return string(model.OpPostUpdate) + } else if method == "DELETE" { + return string(model.OpPostDelete) + } + case strings.Contains(pathLower, "/comments"): + if method == "POST" { + return string(model.OpCommentCreate) + } else if method == "DELETE" { + return string(model.OpCommentDelete) + } + case strings.Contains(pathLower, "/users/me") || strings.Contains(pathLower, "/users/"): + if strings.Contains(pathLower, "/avatar") { + return string(model.OpAvatarUpload) + } + return string(model.OpProfileUpdate) + case strings.Contains(pathLower, "/auth/register"): + return string(model.OpUserRegister) + case strings.Contains(pathLower, "/auth/login"): + return string(model.OpUserLogin) + case strings.Contains(pathLower, "/auth/logout"): + return string(model.OpUserLogout) + } + + return method + ":" + path +} + +// determineModule 确定业务模块 +func determineModule(path string) string { + pathLower := strings.ToLower(path) + + switch { + case strings.Contains(pathLower, "/posts"): + return "post" + case strings.Contains(pathLower, "/comments"): + return "comment" + case strings.Contains(pathLower, "/users"): + return "user" + case strings.Contains(pathLower, "/auth"): + return "auth" + case strings.Contains(pathLower, "/groups"): + return "group" + case strings.Contains(pathLower, "/admin"): + return "admin" + default: + return "other" + } +} + +// determineStatus 确定状态 +func determineStatus(statusCode int) string { + if statusCode >= 400 { + return "fail" + } + return "success" +} + +// sanitizeRequestBody 脱敏请求体 +func sanitizeRequestBody(body string) string { + lines := strings.Split(body, "\n") + sanitizedLines := make([]string, 0, len(lines)) + + for _, line := range lines { + sanitizedLine := line + parts := strings.Split(line, ":") + if len(parts) >= 2 { + field := strings.TrimSpace(strings.Trim(parts[0], `"'`)) + if sanitizer.IsSensitiveField(field) { + sanitizedLine = parts[0] + ": ***" + } + } + sanitizedLines = append(sanitizedLines, sanitizedLine) + } + + return strings.Join(sanitizedLines, "\n") +} diff --git a/internal/model/data_change_log.go b/internal/model/data_change_log.go new file mode 100644 index 0000000..0942f0f --- /dev/null +++ b/internal/model/data_change_log.go @@ -0,0 +1,56 @@ +package model + +import ( + "time" +) + +// ChangeType 变更类型 +type ChangeType string + +const ( + ChangePassword ChangeType = "password" + ChangePhone ChangeType = "phone" + ChangeEmail ChangeType = "email" + ChangeNickName ChangeType = "nickname" + ChangeAvatar ChangeType = "avatar" + ChangeProfile ChangeType = "profile" + ChangePrivacy ChangeType = "privacy" + ChangeDelete ChangeType = "delete" + ChangeExport ChangeType = "export" + ChangeWithdraw ChangeType = "withdraw" +) + +// OperatorType 操作人类型 +type OperatorType string + +const ( + OperatorTypeSelf OperatorType = "self" + OperatorTypeAdmin OperatorType = "admin" + OperatorTypeSystem OperatorType = "system" +) + +// DataChangeLog 数据变更日志实体 +type DataChangeLog struct { + ID uint `json:"id" gorm:"primaryKey;autoIncrement"` + UserID string `json:"user_id" gorm:"type:varchar(36);index:idx_user"` + OperatorID string `json:"operator_id" gorm:"type:varchar(36);index:idx_operator"` + OperatorName string `json:"operator_name" gorm:"type:varchar(100)"` + ChangeType string `json:"change_type" gorm:"type:varchar(50);index:idx_change_type"` + TargetType string `json:"target_type" gorm:"type:varchar(50)"` + TargetID string `json:"target_id" gorm:"type:varchar(255)"` + FieldName string `json:"field_name" gorm:"type:varchar(50)"` + OldValue string `json:"old_value" gorm:"type:text"` + NewValue string `json:"new_value" gorm:"type:text"` + IP string `json:"ip" gorm:"type:varchar(45);index:idx_ip"` + IPLocation string `json:"ip_location" gorm:"type:varchar(100)"` + UserAgent string `json:"user_agent" gorm:"type:varchar(500)"` + Reason string `json:"reason" gorm:"type:varchar(500)"` + OperatorType string `json:"operator_type" gorm:"type:varchar(20);index:idx_operator_type"` + DeviceID string `json:"device_id" gorm:"type:varchar(100)"` + ServerIP string `json:"server_ip" gorm:"type:varchar(45)"` + CreatedAt time.Time `json:"created_at" gorm:"index:idx_created"` +} + +func (DataChangeLog) TableName() string { + return "data_change_logs" +} diff --git a/internal/model/login_log.go b/internal/model/login_log.go new file mode 100644 index 0000000..63af3f2 --- /dev/null +++ b/internal/model/login_log.go @@ -0,0 +1,87 @@ +package model + +import ( + "time" +) + +// LoginEvent 登录事件 +type LoginEvent string + +const ( + LoginEventLogin LoginEvent = "login" + LoginEventLogout LoginEvent = "logout" + LoginEventRefresh LoginEvent = "refresh" + LoginEventReset LoginEvent = "reset" + LoginEventFail LoginEvent = "fail" +) + +// LoginResult 登录结果 +type LoginResult string + +const ( + LoginResultSuccess LoginResult = "success" + LoginResultFail LoginResult = "fail" +) + +// FailReason 失败原因 +type FailReason string + +const ( + FailReasonUserNotFound FailReason = "user_not_found" + FailReasonWrongPassword FailReason = "wrong_password" + FailReasonAccountLocked FailReason = "account_locked" + FailReasonAccountBanned FailReason = "account_banned" + FailReasonRateLimit FailReason = "rate_limit" + FailReasonInvalidToken FailReason = "invalid_token" + FailReasonTokenExpired FailReason = "token_expired" + FailReasonInvalidCode FailReason = "invalid_code" + FailReasonCodeExpired FailReason = "code_expired" + FailReasonUnknown FailReason = "unknown" +) + +// LoginType 登录类型 +type LoginType string + +const ( + LoginTypePassword LoginType = "password" + LoginTypeSMS LoginType = "sms" + LoginTypeEmail LoginType = "email" + LoginTypeOAuth LoginType = "oauth" +) + +// LoginMethod 登录方式 +type LoginMethod string + +const ( + LoginMethodUsername LoginMethod = "username" + LoginMethodMobile LoginMethod = "mobile" + LoginMethodEmail LoginMethod = "email" +) + +// LoginLog 登录日志实体 +type LoginLog struct { + ID uint `json:"id" gorm:"primaryKey;autoIncrement"` + UserID string `json:"user_id" gorm:"type:varchar(36);index:idx_user"` + UserName string `json:"user_name" gorm:"type:varchar(100)"` + NickName string `json:"nick_name" gorm:"type:varchar(100)"` + LoginType string `json:"login_type" gorm:"type:varchar(20);index:idx_login_type"` + LoginMethod string `json:"login_method" gorm:"type:varchar(20)"` + Event string `json:"event" gorm:"type:varchar(20);index:idx_event"` + IP string `json:"ip" gorm:"type:varchar(45);index:idx_ip"` + IPLocation string `json:"ip_location" gorm:"type:varchar(100)"` + UserAgent string `json:"user_agent" gorm:"type:varchar(500)"` + DeviceType string `json:"device_type" gorm:"type:varchar(20)"` + DeviceID string `json:"device_id" gorm:"type:varchar(100)"` + AppVersion string `json:"app_version" gorm:"type:varchar(50)"` + Result string `json:"result" gorm:"type:varchar(20);index:idx_result"` + FailReason string `json:"fail_reason" gorm:"type:varchar(200)"` + TokenID string `json:"token_id" gorm:"type:varchar(64)"` + ExpiredAt *time.Time `json:"expired_at"` + ServerIP string `json:"server_ip" gorm:"type:varchar(45)"` + ServerPort int `json:"server_port" gorm:"type:int"` + CreatedAt time.Time `json:"created_at" gorm:"index:idx_created"` +} + +func (LoginLog) TableName() string { + return "login_logs" +} diff --git a/internal/model/operation_log.go b/internal/model/operation_log.go new file mode 100644 index 0000000..6528393 --- /dev/null +++ b/internal/model/operation_log.go @@ -0,0 +1,86 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// OperationType 操作类型 +type OperationType string + +const ( + OpPostCreate OperationType = "post:create" + OpPostUpdate OperationType = "post:update" + OpPostDelete OperationType = "post:delete" + OpCommentCreate OperationType = "comment:create" + OpCommentDelete OperationType = "comment:delete" + OpUserRegister OperationType = "user:register" + OpUserLogin OperationType = "user:login" + OpUserLogout OperationType = "user:logout" + OpPasswordChange OperationType = "password:change" + OpPasswordReset OperationType = "password:reset" + OpProfileUpdate OperationType = "profile:update" + OpAvatarUpload OperationType = "avatar:upload" + OpEmailBind OperationType = "email:bind" + OpPhoneBind OperationType = "phone:bind" + OpAccountDelete OperationType = "account:delete" + OpDataExport OperationType = "data:export" + OpDataWithdraw OperationType = "data:withdraw" + OpAdminBanUser OperationType = "admin:ban_user" + OpAdminUnbanUser OperationType = "admin:unban_user" + OpAdminDeletePost OperationType = "admin:delete_post" + OpAdminModifyRole OperationType = "admin:modify_role" + OpAdminMuteUser OperationType = "admin:mute_user" + OpAdminUnmuteUser OperationType = "admin:unmute_user" +) + +// OperationLog 操作日志实体 +type OperationLog struct { + ID uint `json:"id" gorm:"primaryKey;autoIncrement"` + TraceID string `json:"trace_id" gorm:"type:varchar(64);index:idx_trace"` + UserID string `json:"user_id" gorm:"type:varchar(36);index:idx_user"` + UserName string `json:"user_name" gorm:"type:varchar(100)"` + NickName string `json:"nick_name" gorm:"type:varchar(100)"` + Operation string `json:"operation" gorm:"type:varchar(50);index:idx_operation"` + Module string `json:"module" gorm:"type:varchar(50)"` + TargetType string `json:"target_type" gorm:"type:varchar(50)"` + TargetID string `json:"target_id" gorm:"type:varchar(255);index:idx_target"` + Method string `json:"method" gorm:"type:varchar(10)"` + Path string `json:"path" gorm:"type:varchar(500)"` + QueryParams string `json:"query_params" gorm:"type:text"` + RequestBody string `json:"request_body" gorm:"type:text"` + ResponseCode int `json:"response_code" gorm:"index:idx_status"` + ResponseMsg string `json:"response_msg" gorm:"type:varchar(500)"` + IP string `json:"ip" gorm:"type:varchar(45);index:idx_ip"` + IPLocation string `json:"ip_location" gorm:"type:varchar(100)"` + UserAgent string `json:"user_agent" gorm:"type:varchar(500)"` + DeviceID string `json:"device_id" gorm:"type:varchar(100)"` + DeviceType string `json:"device_type" gorm:"type:varchar(20)"` + Duration int64 `json:"duration" gorm:"type:bigint"` + Referer string `json:"referer" gorm:"type:varchar(500)"` + Protocol string `json:"protocol" gorm:"type:varchar(10)"` + ServerIP string `json:"server_ip" gorm:"type:varchar(45)"` + ServerPort int `json:"server_port" gorm:"type:int"` + ClientPort int `json:"client_port" gorm:"type:int"` + OccurredAt time.Time `json:"occurred_at" gorm:"index:idx_created"` + Status string `json:"status" gorm:"type:varchar(20);index:idx_status"` + ErrorMsg string `json:"error_msg" gorm:"type:text"` + CreatedAt time.Time `json:"created_at" gorm:"index:idx_created"` +} + +// BeforeCreate 创建前生成TraceID +func (ol *OperationLog) BeforeCreate(tx *gorm.DB) error { + if ol.TraceID == "" { + ol.TraceID = uuid.New().String() + } + if ol.OccurredAt.IsZero() { + ol.OccurredAt = time.Now() + } + return nil +} + +func (OperationLog) TableName() string { + return "operation_logs" +} diff --git a/internal/model/post.go b/internal/model/post.go index 3b4931f..25cf1b3 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -76,16 +76,18 @@ func (Post) TableName() string { // PostImage 帖子图片 type PostImage struct { - ID string `json:"id" gorm:"type:varchar(36);primaryKey"` - PostID string `json:"post_id" gorm:"type:varchar(36);index;not null"` - URL string `json:"url" gorm:"type:text;not null"` - ThumbnailURL string `json:"thumbnail_url" gorm:"type:text"` - Width int `json:"width" gorm:"default:0"` - Height int `json:"height" gorm:"default:0"` - Size int64 `json:"size" gorm:"default:0"` // 文件大小(字节) - MimeType string `json:"mime_type" gorm:"type:varchar(50)"` - SortOrder int `json:"sort_order" gorm:"default:0"` - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + ID string `json:"id" gorm:"type:varchar(36);primaryKey"` + PostID string `json:"post_id" gorm:"type:varchar(36);index;not null"` + URL string `json:"url" gorm:"type:text;not null"` + ThumbnailURL string `json:"thumbnail_url" gorm:"type:text"` + PreviewURL string `json:"preview_url" gorm:"type:text"` // 列表/网格预览图(最大300px) + PreviewURLLarge string `json:"preview_url_large" gorm:"type:text"` // 详情页预览图(最大800px) + Width int `json:"width" gorm:"default:0"` + Height int `json:"height" gorm:"default:0"` + Size int64 `json:"size" gorm:"default:0"` // 文件大小(字节) + MimeType string `json:"mime_type" gorm:"type:varchar(50)"` + SortOrder int `json:"sort_order" gorm:"default:0"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` } // BeforeCreate 创建前生成UUID diff --git a/internal/pkg/sanitizer/sanitizer.go b/internal/pkg/sanitizer/sanitizer.go new file mode 100644 index 0000000..d42b948 --- /dev/null +++ b/internal/pkg/sanitizer/sanitizer.go @@ -0,0 +1,139 @@ +package sanitizer + +import ( + "strings" +) + +// SensitiveFields 敏感字段列表 +var SensitiveFields = []string{ + "password", " Password", "pwd", + "token", "access_token", "refresh_token", "Token", + "secret", "Secret", "api_key", "apiKey", "secret_key", + "id_card", "idCard", "身份证", + "phone", "mobile", "Phone", "Mobile", "手机号", + "email", "Email", "邮箱", + "bank_card", "bankCard", "银行卡", + "credit_card", "信用卡", + "address", "Address", "详细地址", + "real_name", "realName", "真实姓名", +} + +// SkipMaskFields 不需要脱敏的字段 +var SkipMaskFields = []string{ + "avatar", "nickname", "username", "content", "title", "description", +} + +// MaskValue 根据字段名脱敏处理 +func MaskValue(field, value string) string { + if value == "" { + return "" + } + + fieldLower := strings.ToLower(field) + + // 检查是否跳过脱敏 + for _, f := range SkipMaskFields { + if fieldLower == f || strings.HasSuffix(fieldLower, f) { + return value + } + } + + // 根据字段类型脱敏 + switch { + case strings.Contains(fieldLower, "phone"): + return maskPhone(value) + case strings.Contains(fieldLower, "email"): + return maskEmail(value) + case strings.Contains(fieldLower, "id_card"): + return maskIDCard(value) + case strings.Contains(fieldLower, "bank_card") || strings.Contains(fieldLower, "credit_card"): + return maskBankCard(value) + case strings.Contains(fieldLower, "token") || strings.Contains(fieldLower, "secret") || strings.Contains(fieldLower, "api_key"): + return "***" + case fieldLower == "password" || fieldLower == "pwd": + return "***" + case strings.Contains(fieldLower, "address"): + return maskAddress(value) + case strings.Contains(fieldLower, "real_name"): + return maskName(value) + default: + return value + } +} + +// maskPhone 手机号脱敏(保留前3后4) +func maskPhone(s string) string { + if len(s) == 11 { + return s[:3] + "****" + s[7:] + } + return "***" +} + +// maskEmail 邮箱脱敏(保留前2) +func maskEmail(s string) string { + parts := strings.Split(s, "@") + if len(parts) == 2 && len(parts[0]) > 2 { + return parts[0][:2] + "***@" + parts[1] + } + return "***" +} + +// maskIDCard 身份证脱敏(保留前6后4) +func maskIDCard(s string) string { + if len(s) == 18 { + return s[:6] + "********" + s[14:] + } + if len(s) == 15 { + return s[:6] + "*****" + s[11:] + } + return "***" +} + +// maskBankCard 银行卡号脱敏(保留前4后4) +func maskBankCard(s string) string { + length := len(s) + if length >= 8 { + return s[:4] + "****" + s[length-4:] + } + return "***" +} + +// maskAddress 地址脱敏(只显示省市) +func maskAddress(s string) string { + parts := strings.Split(s, "省") + if len(parts) > 1 { + cityParts := strings.Split(parts[1], "市") + if len(cityParts) > 1 { + return parts[0] + "省" + cityParts[0] + "市****" + } + return parts[0] + "省****" + } + return "***" +} + +// maskName 姓名脱敏(保留姓氏) +func maskName(s string) string { + runes := []rune(s) + length := len(runes) + if length == 0 { + return "" + } + if length == 2 { + return string(runes[0]) + "*" + } + if length >= 3 { + return string(runes[0]) + "*" + string(runes[length-1:]) + } + return "*" +} + +// IsSensitiveField 判断字段是否为敏感字段 +func IsSensitiveField(field string) bool { + fieldLower := strings.ToLower(field) + for _, f := range SensitiveFields { + if strings.Contains(fieldLower, f) { + return true + } + } + return false +} diff --git a/internal/repository/data_change_log_repo.go b/internal/repository/data_change_log_repo.go new file mode 100644 index 0000000..1d12e63 --- /dev/null +++ b/internal/repository/data_change_log_repo.go @@ -0,0 +1,136 @@ +package repository + +import ( + "context" + + "carrot_bbs/internal/model" + + "gorm.io/gorm" +) + +// DataChangeLogRepository 数据变更日志仓储 +type DataChangeLogRepository struct { + db *gorm.DB +} + +// NewDataChangeLogRepository 创建数据变更日志仓储 +func NewDataChangeLogRepository(db *gorm.DB) *DataChangeLogRepository { + return &DataChangeLogRepository{db: db} +} + +// CreateDataChangeLog 创建单条数据变更日志 +func (r *DataChangeLogRepository) CreateDataChangeLog(ctx context.Context, log *model.DataChangeLog) error { + return r.db.WithContext(ctx).Create(log).Error +} + +// BatchCreateDataChangeLogs 批量创建数据变更日志 +func (r *DataChangeLogRepository) BatchCreateDataChangeLogs(ctx context.Context, logs []*model.DataChangeLog) error { + if len(logs) == 0 { + return nil + } + return r.db.WithContext(ctx).Create(logs).Error +} + +// GetDataChangeLogs 获取数据变更日志列表(分页) +func (r *DataChangeLogRepository) GetDataChangeLogs(ctx context.Context, filters DataChangeFilter, page, pageSize int) ([]*model.DataChangeLog, int64, error) { + query := r.db.WithContext(ctx).Model(&model.DataChangeLog{}) + + if filters.UserID != "" { + query = query.Where("user_id = ?", filters.UserID) + } + if filters.OperatorID != "" { + query = query.Where("operator_id = ?", filters.OperatorID) + } + if filters.ChangeType != "" { + query = query.Where("change_type = ?", filters.ChangeType) + } + if filters.TargetType != "" { + query = query.Where("target_type = ?", filters.TargetType) + } + if filters.OperatorType != "" { + query = query.Where("operator_type = ?", filters.OperatorType) + } + if filters.StartTime != "" { + query = query.Where("created_at >= ?", filters.StartTime) + } + if filters.EndTime != "" { + query = query.Where("created_at <= ?", filters.EndTime) + } + if filters.IP != "" { + query = query.Where("ip = ?", filters.IP) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var logs []*model.DataChangeLog + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil { + return nil, 0, err + } + + return logs, total, nil +} + +// GetDataChangeLogsByUser 获取指定用户的数据变更日志 +func (r *DataChangeLogRepository) GetDataChangeLogsByUser(ctx context.Context, userID string, page, pageSize int) ([]*model.DataChangeLog, int64, error) { + return r.GetDataChangeLogs(ctx, DataChangeFilter{UserID: userID}, page, pageSize) +} + +// GetDataChangeLogsByOperator 获取指定操作人的日志 +func (r *DataChangeLogRepository) GetDataChangeLogsByOperator(ctx context.Context, operatorID string, page, pageSize int) ([]*model.DataChangeLog, int64, error) { + return r.GetDataChangeLogs(ctx, DataChangeFilter{OperatorID: operatorID}, page, pageSize) +} + +// DeleteOldLogs 删除旧的数据变更日志(用于定时清理) +func (r *DataChangeLogRepository) DeleteOldLogs(ctx context.Context, beforeDate string) error { + return r.db.WithContext(ctx).Where("created_at < ?", beforeDate).Delete(&model.DataChangeLog{}).Error +} + +// GetStatistics 获取数据变更日志统计 +func (r *DataChangeLogRepository) GetStatistics(ctx context.Context, startTime, endTime string) (*DataChangeLogStatistics, error) { + stats := &DataChangeLogStatistics{} + + query := r.db.WithContext(ctx).Model(&model.DataChangeLog{}) + if startTime != "" { + query = query.Where("created_at >= ?", startTime) + } + if endTime != "" { + query = query.Where("created_at <= ?", endTime) + } + + if err := query.Count(&stats.TotalCount).Error; err != nil { + return nil, err + } + + if err := query.Where("operator_type = ?", string(model.OperatorTypeAdmin)).Count(&stats.AdminCount).Error; err != nil { + return nil, err + } + + if err := query.Where("operator_type = ?", string(model.OperatorTypeSelf)).Count(&stats.UserCount).Error; err != nil { + return nil, err + } + + return stats, nil +} + +// DataChangeFilter 数据变更日志过滤条件 +type DataChangeFilter struct { + UserID string + OperatorID string + ChangeType string + TargetType string + OperatorType string + IP string + StartTime string + EndTime string +} + +// DataChangeLogStatistics 数据变更日志统计 +type DataChangeLogStatistics struct { + TotalCount int64 + AdminCount int64 + UserCount int64 +} diff --git a/internal/repository/login_log_repo.go b/internal/repository/login_log_repo.go new file mode 100644 index 0000000..241228c --- /dev/null +++ b/internal/repository/login_log_repo.go @@ -0,0 +1,159 @@ +package repository + +import ( + "context" + "time" + + "carrot_bbs/internal/model" + + "gorm.io/gorm" +) + +// LoginLogRepository 登录日志仓储 +type LoginLogRepository struct { + db *gorm.DB +} + +// NewLoginLogRepository 创建登录日志仓储 +func NewLoginLogRepository(db *gorm.DB) *LoginLogRepository { + return &LoginLogRepository{db: db} +} + +// CreateLoginLog 创建单条登录日志 +func (r *LoginLogRepository) CreateLoginLog(ctx context.Context, log *model.LoginLog) error { + return r.db.WithContext(ctx).Create(log).Error +} + +// BatchCreateLoginLogs 批量创建登录日志 +func (r *LoginLogRepository) BatchCreateLoginLogs(ctx context.Context, logs []*model.LoginLog) error { + if len(logs) == 0 { + return nil + } + return r.db.WithContext(ctx).Create(logs).Error +} + +// GetLoginLogs 获取登录日志列表(分页) +func (r *LoginLogRepository) GetLoginLogs(ctx context.Context, filters LoginFilter, page, pageSize int) ([]*model.LoginLog, int64, error) { + query := r.db.WithContext(ctx).Model(&model.LoginLog{}) + + if filters.UserID != "" { + query = query.Where("user_id = ?", filters.UserID) + } + if filters.Event != "" { + query = query.Where("event = ?", filters.Event) + } + if filters.Result != "" { + query = query.Where("result = ?", filters.Result) + } + if filters.FailReason != "" { + query = query.Where("fail_reason = ?", filters.FailReason) + } + if filters.LoginType != "" { + query = query.Where("login_type = ?", filters.LoginType) + } + if !filters.StartTime.IsZero() { + query = query.Where("created_at >= ?", filters.StartTime) + } + if !filters.EndTime.IsZero() { + query = query.Where("created_at <= ?", filters.EndTime) + } + if filters.IP != "" { + query = query.Where("ip = ?", filters.IP) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var logs []*model.LoginLog + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil { + return nil, 0, err + } + + return logs, total, nil +} + +// GetLoginLogsByUser 获取指定用户的登录日志 +func (r *LoginLogRepository) GetLoginLogsByUser(ctx context.Context, userID string, page, pageSize int) ([]*model.LoginLog, int64, error) { + return r.GetLoginLogs(ctx, LoginFilter{UserID: userID}, page, pageSize) +} + +// GetFailedLoginsByIP 获取指定IP的失败登录记录(用于风控) +func (r *LoginLogRepository) GetFailedLoginsByIP(ctx context.Context, ip string, timeWindow time.Duration) (int64, error) { + startTime := time.Now().Add(-timeWindow) + var count int64 + err := r.db.WithContext(ctx). + Model(&model.LoginLog{}). + Where("ip = ? AND result = ? AND created_at >= ?", ip, string(model.LoginResultFail), startTime). + Count(&count).Error + return count, err +} + +// GetRecentSuccessLogins 获取最近成功的登录记录 +func (r *LoginLogRepository) GetRecentSuccessLogins(ctx context.Context, userID string, limit int) ([]*model.LoginLog, error) { + var logs []*model.LoginLog + err := r.db.WithContext(ctx). + Where("user_id = ? AND result = ? AND event = ?", userID, string(model.LoginResultSuccess), string(model.LoginEventLogin)). + Order("created_at DESC"). + Limit(limit). + Find(&logs).Error + return logs, err +} + +// DeleteOldLogs 删除旧的登录日志(用于定时清理) +func (r *LoginLogRepository) DeleteOldLogs(ctx context.Context, beforeDate string) error { + return r.db.WithContext(ctx).Where("created_at < ?", beforeDate).Delete(&model.LoginLog{}).Error +} + +// GetStatistics 获取登录日志统计 +func (r *LoginLogRepository) GetStatistics(ctx context.Context, startTime, endTime string) (*LoginLogStatistics, error) { + stats := &LoginLogStatistics{} + + query := r.db.WithContext(ctx).Model(&model.LoginLog{}) + if startTime != "" { + query = query.Where("created_at >= ?", startTime) + } + if endTime != "" { + query = query.Where("created_at <= ?", endTime) + } + + if err := query.Count(&stats.TotalCount).Error; err != nil { + return nil, err + } + + if err := query.Where("result = ?", string(model.LoginResultSuccess)).Count(&stats.SuccessCount).Error; err != nil { + return nil, err + } + + if err := query.Where("result = ?", string(model.LoginResultFail)).Count(&stats.FailCount).Error; err != nil { + return nil, err + } + + if err := query.Where("event = ?", string(model.LoginEventLogin)).Count(&stats.LoginCount).Error; err != nil { + return nil, err + } + + return stats, nil +} + +// LoginFilter 登录日志过滤条件 +type LoginFilter struct { + UserID string + Event string + Result string + FailReason string + LoginType string + IP string + StartTime time.Time + EndTime time.Time +} + +// LoginLogStatistics 登录日志统计 +type LoginLogStatistics struct { + TotalCount int64 + SuccessCount int64 + FailCount int64 + LoginCount int64 +} diff --git a/internal/repository/operation_log_repo.go b/internal/repository/operation_log_repo.go new file mode 100644 index 0000000..93818a3 --- /dev/null +++ b/internal/repository/operation_log_repo.go @@ -0,0 +1,136 @@ +package repository + +import ( + "context" + + "carrot_bbs/internal/model" + + "gorm.io/gorm" +) + +// OperationLogRepository 操作日志仓储 +type OperationLogRepository struct { + db *gorm.DB +} + +// NewOperationLogRepository 创建操作日志仓储 +func NewOperationLogRepository(db *gorm.DB) *OperationLogRepository { + return &OperationLogRepository{db: db} +} + +// CreateOperationLog 创建单条操作日志 +func (r *OperationLogRepository) CreateOperationLog(ctx context.Context, log *model.OperationLog) error { + return r.db.WithContext(ctx).Create(log).Error +} + +// BatchCreateOperationLogs 批量创建操作日志 +func (r *OperationLogRepository) BatchCreateOperationLogs(ctx context.Context, logs []*model.OperationLog) error { + if len(logs) == 0 { + return nil + } + return r.db.WithContext(ctx).Create(logs).Error +} + +// GetOperationLogs 获取操作日志列表(分页) +func (r *OperationLogRepository) GetOperationLogs(ctx context.Context, filters LogFilter, page, pageSize int) ([]*model.OperationLog, int64, error) { + query := r.db.WithContext(ctx).Model(&model.OperationLog{}) + + if filters.UserID != "" { + query = query.Where("user_id = ?", filters.UserID) + } + if filters.Operation != "" { + query = query.Where("operation = ?", filters.Operation) + } + if filters.TargetType != "" { + query = query.Where("target_type = ?", filters.TargetType) + } + if filters.TargetID != "" { + query = query.Where("target_id = ?", filters.TargetID) + } + if filters.Status != "" { + query = query.Where("status = ?", filters.Status) + } + if filters.StartTime != "" { + query = query.Where("occurred_at >= ?", filters.StartTime) + } + if filters.EndTime != "" { + query = query.Where("occurred_at <= ?", filters.EndTime) + } + if filters.IP != "" { + query = query.Where("ip = ?", filters.IP) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var logs []*model.OperationLog + offset := (page - 1) * pageSize + if err := query.Order("occurred_at DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil { + return nil, 0, err + } + + return logs, total, nil +} + +// GetOperationLogsByUser 获取指定用户的操作日志 +func (r *OperationLogRepository) GetOperationLogsByUser(ctx context.Context, userID string, page, pageSize int) ([]*model.OperationLog, int64, error) { + return r.GetOperationLogs(ctx, LogFilter{UserID: userID}, page, pageSize) +} + +// GetOperationLogsByTimeRange 按时间范围获取操作日志 +func (r *OperationLogRepository) GetOperationLogsByTimeRange(ctx context.Context, startTime, endTime string, page, pageSize int) ([]*model.OperationLog, int64, error) { + return r.GetOperationLogs(ctx, LogFilter{StartTime: startTime, EndTime: endTime}, page, pageSize) +} + +// DeleteOldLogs 删除旧的操作日志(用于定时清理) +func (r *OperationLogRepository) DeleteOldLogs(ctx context.Context, beforeDate string) error { + return r.db.WithContext(ctx).Where("created_at < ?", beforeDate).Delete(&model.OperationLog{}).Error +} + +// GetStatistics 获取操作日志统计 +func (r *OperationLogRepository) GetStatistics(ctx context.Context, startTime, endTime string) (*OperationLogStatistics, error) { + stats := &OperationLogStatistics{} + + query := r.db.WithContext(ctx).Model(&model.OperationLog{}) + if startTime != "" { + query = query.Where("created_at >= ?", startTime) + } + if endTime != "" { + query = query.Where("created_at <= ?", endTime) + } + + if err := query.Count(&stats.TotalCount).Error; err != nil { + return nil, err + } + + if err := query.Where("status = ?", "success").Count(&stats.SuccessCount).Error; err != nil { + return nil, err + } + + if err := query.Where("status = ?", "fail").Count(&stats.FailCount).Error; err != nil { + return nil, err + } + + return stats, nil +} + +// LogFilter 日志过滤条件 +type LogFilter struct { + UserID string + Operation string + TargetType string + TargetID string + Status string + IP string + StartTime string + EndTime string +} + +// OperationLogStatistics 操作日志统计 +type OperationLogStatistics struct { + TotalCount int64 + SuccessCount int64 + FailCount int64 +} diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index 380264e..3e09a58 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -3,6 +3,7 @@ package repository import ( "carrot_bbs/internal/model" "context" + "strings" "time" "gorm.io/gorm" @@ -35,11 +36,24 @@ func (r *PostRepository) Create(post *model.Post, images []string) error { } // 创建图片记录 - for i, url := range images { + // 支持格式:["url", "url|preview_url|preview_url_large", ...] + for i, imageData := range images { + parts := strings.Split(imageData, "|") + url := parts[0] + var previewURL, previewURLLarge string + if len(parts) >= 2 && parts[1] != "" { + previewURL = parts[1] + } + if len(parts) >= 3 && parts[2] != "" { + previewURLLarge = parts[2] + } + image := &model.PostImage{ - PostID: post.ID, - URL: url, - SortOrder: i, + PostID: post.ID, + URL: url, + PreviewURL: previewURL, + PreviewURLLarge: previewURLLarge, + SortOrder: i, } if err := tx.Create(image).Error; err != nil { return err diff --git a/internal/router/router.go b/internal/router/router.go index f076e8d..269fd9e 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -31,6 +31,8 @@ type Router struct { adminCommentHandler *handler.AdminCommentHandler adminGroupHandler *handler.AdminGroupHandler adminDashboardHandler *handler.AdminDashboardHandler + adminLogHandler *handler.AdminLogHandler + logService *service.LogService jwtService *service.JWTService casbinService service.CasbinService } @@ -57,6 +59,8 @@ func New( adminCommentHandler *handler.AdminCommentHandler, adminGroupHandler *handler.AdminGroupHandler, adminDashboardHandler *handler.AdminDashboardHandler, + adminLogHandler *handler.AdminLogHandler, + logService *service.LogService, activityService service.UserActivityService, casbinService service.CasbinService, ) *Router { @@ -86,6 +90,8 @@ func New( adminCommentHandler: adminCommentHandler, adminGroupHandler: adminGroupHandler, adminDashboardHandler: adminDashboardHandler, + adminLogHandler: adminLogHandler, + logService: logService, jwtService: jwtService, casbinService: casbinService, } @@ -104,6 +110,7 @@ func (r *Router) setupRoutes() { c.JSON(200, gin.H{"status": "ok"}) }) + // API v1 v1 := r.engine.Group("/api/v1") { @@ -115,11 +122,32 @@ func (r *Router) setupRoutes() { auth.POST("/login", r.userHandler.Login) auth.POST("/password/send-code", r.userHandler.SendPasswordResetCode) auth.POST("/password/reset", r.userHandler.ResetPassword) + auth.POST("/logout", r.userHandler.Logout) auth.POST("/refresh", r.userHandler.RefreshToken) } // 需要认证的路由 authMiddleware := middleware.Auth(r.jwtService) + + // === 访问日志中间件 === + if r.adminLogHandler != nil && r.logService != nil { + accessLogConfig := &middleware.AccessLogConfig{ + Enable: true, + SkipPaths: []string{"/health", "/api/v1/health"}, + ServerIP: "0.0.0.0", + ServerPort: 8080, + } + r.engine.Use(middleware.AccessLogMiddleware( + r.logService.OperationLog, + accessLogConfig, + )) + } + r.engine.Use(middleware.AccessLogMiddleware( + r.logService, + accessLogConfig, + )) + } + // Casbin 权限中间件(暂不全局启用,可根据需要在特定路由组上使用) // casbinMiddleware := middleware.CasbinAuth(r.casbinService) @@ -435,10 +463,21 @@ func (r *Router) setupRoutes() { admin.GET("/dashboard/pending-content", r.adminDashboardHandler.GetPendingContent) admin.GET("/dashboard/online-users", r.adminDashboardHandler.GetOnlineUsers) } + + // 日志管理 + if r.adminLogHandler != nil { + logs := admin.Group("/logs") + { + logs.GET("/operations", r.adminLogHandler.GetOperationLogs) + logs.GET("/logins", r.adminLogHandler.GetLoginLogs) + logs.GET("/data-changes", r.adminLogHandler.GetDataChangeLogs) + logs.GET("/statistics", r.adminLogHandler.GetLogStatistics) + logs.GET("/export", r.adminLogHandler.ExportLogs) + } + } } } } -} // Engine 获取引擎 func (r *Router) Engine() *gin.Engine { diff --git a/internal/service/admin_post_service.go b/internal/service/admin_post_service.go index 4ad7d37..4b5b03b 100644 --- a/internal/service/admin_post_service.go +++ b/internal/service/admin_post_service.go @@ -27,22 +27,32 @@ type AdminPostService interface { SetPostPin(ctx context.Context, postID string, isPinned bool) (*dto.AdminPostDetailResponse, error) // SetPostFeature 加精/取消加精帖子 SetPostFeature(ctx context.Context, postID string, isFeatured bool) (*dto.AdminPostDetailResponse, error) + + // 日志服务设置 + SetLogService(logService *LogService) } // adminPostServiceImpl 管理端帖子服务实现 type adminPostServiceImpl struct { - postRepo *repository.PostRepository - cache cache.Cache + postRepo *repository.PostRepository + cache cache.Cache + logService *LogService } // NewAdminPostService 创建管理端帖子服务 func NewAdminPostService(postRepo *repository.PostRepository, cacheBackend cache.Cache) AdminPostService { return &adminPostServiceImpl{ - postRepo: postRepo, - cache: cacheBackend, + postRepo: postRepo, + cache: cacheBackend, + logService: nil, } } +// SetLogService 设置日志服务 +func (s *adminPostServiceImpl) SetLogService(logService *LogService) { + s.logService = logService +} + // GetPostList 获取帖子列表 func (s *adminPostServiceImpl) GetPostList(ctx context.Context, page, pageSize int, keyword, status, authorID, startDate, endDate string) ([]dto.AdminPostListResponse, int64, error) { query := repository.AdminPostListQuery{ @@ -80,7 +90,12 @@ func (s *adminPostServiceImpl) GetPostDetail(ctx context.Context, postID string) // DeletePost 删除帖子 func (s *adminPostServiceImpl) DeletePost(ctx context.Context, postID string) error { - err := s.postRepo.Delete(postID) + post, err := s.postRepo.GetByID(postID) + if err != nil { + return err + } + + err = s.postRepo.Delete(postID) if err != nil { return err } @@ -89,6 +104,18 @@ func (s *adminPostServiceImpl) DeletePost(ctx context.Context, postID string) er cache.InvalidatePostDetail(s.cache, postID) cache.InvalidatePostList(s.cache) + // 记录操作日志 + if s.logService != nil { + s.logService.OperationLog.RecordOperation(&model.OperationLog{ + UserID: post.UserID, + Operation: string(model.OpAdminDeletePost), + Module: "admin", + TargetType: "post", + TargetID: postID, + Status: "success", + }) + } + return nil } diff --git a/internal/service/async_log_manager.go b/internal/service/async_log_manager.go new file mode 100644 index 0000000..3bacb10 --- /dev/null +++ b/internal/service/async_log_manager.go @@ -0,0 +1,304 @@ +package service + +import ( + "context" + "os" + "sync" + "time" + + "carrot_bbs/internal/model" + "carrot_bbs/internal/repository" + + "go.uber.org/zap" + "gorm.io/gorm" +) + +// AsyncLogConfig 异步日志配置 +type AsyncLogConfig struct { + BufferSize int + BatchSize int + FlushInterval time.Duration + WorkerCount int + EnableFallback bool + FallbackPath string +} + +// AsyncLogManager 异步日志管理器 +type AsyncLogManager struct { + operationChan chan *model.OperationLog + loginChan chan *model.LoginLog + dataChangeChan chan *model.DataChangeLog + + cfg *AsyncLogConfig + db *gorm.DB + wg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc + logger *zap.Logger + + fallbackWriter *os.File +} + +// NewAsyncLogManager 创建异步日志管理器 +func NewAsyncLogManager( + db *gorm.DB, + operationRepo *repository.OperationLogRepository, + loginRepo *repository.LoginLogRepository, + dataChangeRepo *repository.DataChangeLogRepository, + logger *zap.Logger, + cfg *AsyncLogConfig, +) *AsyncLogManager { + ctx, cancel := context.WithCancel(context.Background()) + + m := &AsyncLogManager{ + operationChan: make(chan *model.OperationLog, cfg.BufferSize), + loginChan: make(chan *model.LoginLog, cfg.BufferSize), + dataChangeChan: make(chan *model.DataChangeLog, cfg.BufferSize), + cfg: cfg, + db: db, + ctx: ctx, + cancel: cancel, + logger: logger, + } + + if cfg.EnableFallback { + if err := m.initFallbackWriter(); err != nil { + logger.Warn("failed to init fallback writer", zap.Error(err)) + } + } + + for i := 0; i < cfg.WorkerCount; i++ { + m.wg.Add(1) + go m.worker(i, operationRepo, loginRepo, dataChangeRepo) + } + + go m.flushScheduler() + + logger.Info("async log manager started", + zap.Int("buffer_size", cfg.BufferSize), + zap.Int("batch_size", cfg.BatchSize), + zap.Duration("flush_interval", cfg.FlushInterval), + zap.Int("worker_count", cfg.WorkerCount), + ) + + return m +} + +// worker 处理协程 +func (m *AsyncLogManager) worker( + id int, + operationRepo *repository.OperationLogRepository, + loginRepo *repository.LoginLogRepository, + dataChangeRepo *repository.DataChangeLogRepository, +) { + defer m.wg.Done() + + opBatch := make([]*model.OperationLog, 0, m.cfg.BatchSize) + loginBatch := make([]*model.LoginLog, 0, m.cfg.BatchSize) + changeBatch := make([]*model.DataChangeLog, 0, m.cfg.BatchSize) + + ticker := time.NewTicker(m.cfg.FlushInterval) + defer ticker.Stop() + + for { + select { + case <-m.ctx.Done(): + m.flushBatches(opBatch, loginBatch, changeBatch, operationRepo, loginRepo, dataChangeRepo) + return + + case op := <-m.operationChan: + opBatch = append(opBatch, op) + if len(opBatch) >= m.cfg.BatchSize { + if err := m.flushOperationLogs(opBatch, operationRepo); err != nil { + m.handleError("operation_logs", opBatch, err) + } + opBatch = opBatch[:0] + } + + case login := <-m.loginChan: + loginBatch = append(loginBatch, login) + if len(loginBatch) >= m.cfg.BatchSize { + if err := m.flushLoginLogs(loginBatch, loginRepo); err != nil { + m.handleError("login_logs", loginBatch, err) + } + loginBatch = loginBatch[:0] + } + + case change := <-m.dataChangeChan: + changeBatch = append(changeBatch, change) + if len(changeBatch) >= m.cfg.BatchSize { + if err := m.flushDataChangeLogs(changeBatch, dataChangeRepo); err != nil { + m.handleError("data_change_logs", changeBatch, err) + } + changeBatch = changeBatch[:0] + } + + case <-ticker.C: + if len(opBatch) > 0 || len(loginBatch) > 0 || len(changeBatch) > 0 { + m.flushBatches(opBatch, loginBatch, changeBatch, operationRepo, loginRepo, dataChangeRepo) + opBatch = opBatch[:0] + loginBatch = loginBatch[:0] + changeBatch = changeBatch[:0] + } + } + } +} + +// flushScheduler 定时刷新 +func (m *AsyncLogManager) flushScheduler() { + ticker := time.NewTicker(m.cfg.FlushInterval) + defer ticker.Stop() + + for { + select { + case <-m.ctx.Done(): + return + case <-ticker.C: + } + } +} + +// flushBatches 批量写入 +func (m *AsyncLogManager) flushBatches( + ops []*model.OperationLog, + logins []*model.LoginLog, + changes []*model.DataChangeLog, + operationRepo *repository.OperationLogRepository, + loginRepo *repository.LoginLogRepository, + dataChangeRepo *repository.DataChangeLogRepository, +) { + if len(ops) == 0 && len(logins) == 0 && len(changes) == 0 { + return + } + + ctx := context.Background() + + if len(ops) > 0 { + if err := operationRepo.BatchCreateOperationLogs(ctx, ops); err != nil { + m.handleError("operation_logs", ops, err) + } + } + + if len(logins) > 0 { + if err := loginRepo.BatchCreateLoginLogs(ctx, logins); err != nil { + m.handleError("login_logs", logins, err) + } + } + + if len(changes) > 0 { + if err := dataChangeRepo.BatchCreateDataChangeLogs(ctx, changes); err != nil { + m.handleError("data_change_logs", changes, err) + } + } +} + +// flushOperationLogs 刷新操作日志 +func (m *AsyncLogManager) flushOperationLogs(logs []*model.OperationLog, repo *repository.OperationLogRepository) error { + return repo.BatchCreateOperationLogs(context.Background(), logs) +} + +// flushLoginLogs 刷新登录日志 +func (m *AsyncLogManager) flushLoginLogs(logs []*model.LoginLog, repo *repository.LoginLogRepository) error { + return repo.BatchCreateLoginLogs(context.Background(), logs) +} + +// flushDataChangeLogs 刷新数据变更日志 +func (m *AsyncLogManager) flushDataChangeLogs(logs []*model.DataChangeLog, repo *repository.DataChangeLogRepository) error { + return repo.BatchCreateDataChangeLogs(context.Background(), logs) +} + +// handleError 处理写入错误 +func (m *AsyncLogManager) handleError(logType string, logs interface{}, err error) { + m.logger.Error("failed to batch write logs", + zap.String("log_type", logType), + zap.Error(err), + ) + + if m.fallbackWriter != nil { + if err := m.writeToFallback(logType, logs); err != nil { + m.logger.Error("failed to write to fallback", zap.Error(err)) + } + } +} + +// writeToFallback 写入降级文件 +func (m *AsyncLogManager) writeToFallback(logType string, logs interface{}) error { + return nil +} + +// initFallbackWriter 初始化降级写入器 +func (m *AsyncLogManager) initFallbackWriter() error { + if m.cfg.FallbackPath == "" { + m.cfg.FallbackPath = "./logs/fallback.log" + } + + dir := "./logs" + if _, err := os.Stat(dir); os.IsNotExist(err) { + os.MkdirAll(dir, 0755) + } + + file, err := os.OpenFile(m.cfg.FallbackPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + m.fallbackWriter = file + return nil +} + +// RecordOperation 记录操作日志(非阻塞) +func (m *AsyncLogManager) RecordOperation(log *model.OperationLog) { + select { + case m.operationChan <- log: + default: + m.logger.Warn("operation log channel full, dropping log", + zap.String("user_id", log.UserID), + zap.String("operation", log.Operation), + ) + } +} + +// RecordLogin 记录登录日志(非阻塞) +func (m *AsyncLogManager) RecordLogin(log *model.LoginLog) { + select { + case m.loginChan <- log: + default: + m.logger.Warn("login log channel full, dropping log", + zap.String("user_id", log.UserID), + zap.String("event", log.Event), + ) + } +} + +// RecordDataChange 记录数据变更日志(非阻塞) +func (m *AsyncLogManager) RecordDataChange(log *model.DataChangeLog) { + select { + case m.dataChangeChan <- log: + default: + m.logger.Warn("data change log channel full, dropping log", + zap.String("user_id", log.UserID), + zap.String("change_type", log.ChangeType), + ) + } +} + +// Stop 停止异步日志管理器 +func (m *AsyncLogManager) Stop() { + m.cancel() + + done := make(chan struct{}) + go func() { + m.wg.Wait() + close(done) + }() + + select { + case <-done: + m.logger.Info("async log manager stopped gracefully") + case <-time.After(10 * time.Second): + m.logger.Warn("async log manager stop timeout, force closing") + } + + if m.fallbackWriter != nil { + m.fallbackWriter.Close() + } +} diff --git a/internal/service/comment_service.go b/internal/service/comment_service.go index 272e020..b697f97 100644 --- a/internal/service/comment_service.go +++ b/internal/service/comment_service.go @@ -21,6 +21,7 @@ type CommentService struct { cache cache.Cache gorseClient gorse.Client postAIService *PostAIService + logService *LogService } // NewCommentService 创建评论服务 @@ -32,9 +33,15 @@ func NewCommentService(commentRepo *repository.CommentRepository, postRepo *repo cache: cacheBackend, gorseClient: gorseClient, postAIService: postAIService, + logService: nil, } } +// SetLogService 设置日志服务 +func (s *CommentService) SetLogService(logService *LogService) { + s.logService = logService +} + // Create 创建评论 func (s *CommentService) Create(ctx context.Context, postID, userID, content string, parentID *string, images string, imageURLs []string) (*model.Comment, error) { if s.postAIService != nil { @@ -75,6 +82,18 @@ func (s *CommentService) Create(ctx context.Context, postID, userID, content str return nil, err } + // 记录操作日志 + if s.logService != nil { + s.logService.OperationLog.RecordOperation(&model.OperationLog{ + UserID: userID, + Operation: string(model.OpCommentCreate), + Module: "comment", + TargetType: "comment", + TargetID: comment.ID, + Status: "success", + }) + } + // 重新查询以获取关联的 User comment, err = s.commentRepo.GetByID(comment.ID) if err != nil { diff --git a/internal/service/log_cleanup_service.go b/internal/service/log_cleanup_service.go new file mode 100644 index 0000000..7ecf9de --- /dev/null +++ b/internal/service/log_cleanup_service.go @@ -0,0 +1,205 @@ +package service + +import ( + "context" + "time" + + "carrot_bbs/internal/repository" + + "go.uber.org/zap" +) + +// LogCleanupConfig 日志清理配置 +type LogCleanupConfig struct { + Enabled bool + OperationLog int // 保留天数 + LoginLog int + DataChangeLog int + CleanupTime string // 清理时间,如 "02:00" +} + +// LogCleanupService 日志清理服务接口 +type LogCleanupService interface { + Start() + Stop() + CleanupOldLogs(ctx context.Context) error + CleanupOperationLogs(ctx context.Context, beforeDate string) error + CleanupLoginLogs(ctx context.Context, beforeDate string) error + CleanupDataChangeLogs(ctx context.Context, beforeDate string) error +} + +// logCleanupServiceImpl 日志清理服务实现 +type logCleanupServiceImpl struct { + operationRepo *repository.OperationLogRepository + loginRepo *repository.LoginLogRepository + dataChangeRepo *repository.DataChangeLogRepository + cfg *LogCleanupConfig + logger *zap.Logger + ctx context.Context + cancel context.CancelFunc +} + +// NewLogCleanupService 创建日志清理服务 +func NewLogCleanupService( + operationRepo *repository.OperationLogRepository, + loginRepo *repository.LoginLogRepository, + dataChangeRepo *repository.DataChangeLogRepository, + cfg *LogCleanupConfig, + logger *zap.Logger, +) LogCleanupService { + return &logCleanupServiceImpl{ + operationRepo: operationRepo, + loginRepo: loginRepo, + dataChangeRepo: dataChangeRepo, + cfg: cfg, + logger: logger, + } +} + +// Start 启动日志清理服务 +func (s *logCleanupServiceImpl) Start() { + if !s.cfg.Enabled { + s.logger.Info("log cleanup service disabled") + return + } + + s.ctx, s.cancel = context.WithCancel(context.Background()) + + go s.scheduleCleanup() + + s.logger.Info("log cleanup service started", + zap.Int("operation_log_days", s.cfg.OperationLog), + zap.Int("login_log_days", s.cfg.LoginLog), + zap.Int("data_change_log_days", s.cfg.DataChangeLog), + ) +} + +// Stop 停止日志清理服务 +func (s *logCleanupServiceImpl) Stop() { + if s.cancel != nil { + s.cancel() + s.logger.Info("log cleanup service stopped") + } +} + +// scheduleCleanup 定时调度清理任务 +func (s *logCleanupServiceImpl) scheduleCleanup() { + for { + select { + case <-s.ctx.Done(): + return + default: + now := time.Now() + nextCleanup := s.getNextCleanupTime(now) + duration := nextCleanup.Sub(now) + + s.logger.Info("next log cleanup scheduled", + zap.Time("time", nextCleanup), + zap.Duration("in", duration), + ) + + timer := time.NewTimer(duration) + select { + case <-s.ctx.Done(): + timer.Stop() + return + case <-timer.C: + if err := s.CleanupOldLogs(s.ctx); err != nil { + s.logger.Error("failed to cleanup old logs", zap.Error(err)) + } + } + } + } +} + +// getNextCleanupTime 计算下次清理时间 +func (s *logCleanupServiceImpl) getNextCleanupTime(now time.Time) time.Time { + cleanupTime, err := time.Parse("15:04", s.cfg.CleanupTime) + if err != nil { + cleanupTime = time.Date(now.Year(), now.Month(), now.Day(), 2, 0, 0, 0, now.Location()) + } + + nextCleanup := time.Date(now.Year(), now.Month(), now.Day(), cleanupTime.Hour(), cleanupTime.Minute(), 0, 0, now.Location()) + + if nextCleanup.Before(now) { + nextCleanup = nextCleanup.Add(24 * time.Hour) + } + + return nextCleanup +} + +// CleanupOldLogs 清理旧日志 +func (s *logCleanupServiceImpl) CleanupOldLogs(ctx context.Context) error { + s.logger.Info("starting log cleanup") + + // 清理操作日志 + if err := s.CleanupOperationLogs(ctx, s.getBeforeDate(s.cfg.OperationLog)); err != nil { + s.logger.Error("failed to cleanup operation logs", zap.Error(err)) + } + + // 清理登录日志 + if err := s.CleanupLoginLogs(ctx, s.getBeforeDate(s.cfg.LoginLog)); err != nil { + s.logger.Error("failed to cleanup login logs", zap.Error(err)) + } + + // 清理数据变更日志 + if err := s.CleanupDataChangeLogs(ctx, s.getBeforeDate(s.cfg.DataChangeLog)); err != nil { + s.logger.Error("failed to cleanup data change logs", zap.Error(err)) + } + + s.logger.Info("log cleanup completed") + return nil +} + +// CleanupOperationLogs 清理操作日志 +func (s *logCleanupServiceImpl) CleanupOperationLogs(ctx context.Context, beforeDate string) error { + result := s.operationRepo.DeleteOldLogs(ctx, beforeDate) + s.logger.Info("cleanup operation logs", zap.String("before_date", beforeDate)) + return result +} + +// CleanupLoginLogs 清理登录日志 +func (s *logCleanupServiceImpl) CleanupLoginLogs(ctx context.Context, beforeDate string) error { + result := s.loginRepo.DeleteOldLogs(ctx, beforeDate) + s.logger.Info("cleanup login logs", zap.String("before_date", beforeDate)) + return result +} + +// CleanupDataChangeLogs 清理数据变更日志 +func (s *logCleanupServiceImpl) CleanupDataChangeLogs(ctx context.Context, beforeDate string) error { + result := s.dataChangeRepo.DeleteOldLogs(ctx, beforeDate) + s.logger.Info("cleanup data change logs", zap.String("before_date", beforeDate)) + return result +} + +// getBeforeDate 计算截止日期 +func (s *logCleanupServiceImpl) getBeforeDate(days int) string { + return time.Now().AddDate(0, 0, -days).Format("2006-01-02") +} + +// LogService 日志服务组合 +type LogService struct { + OperationLog OperationLogService + LoginLog LoginLogService + DataChangeLog DataChangeLogService +} + +// NewLogService 创建日志服务组合 +func NewLogService( + operationLogService OperationLogService, + loginLogService LoginLogService, + dataChangeLogService DataChangeLogService, +) *LogService { + return &LogService{ + OperationLog: operationLogService, + LoginLog: loginLogService, + DataChangeLog: dataChangeLogService, + } +} + +// GetLogCleanupServiceFromApp 从应用中获取日志清理服务 +func GetLogCleanupServiceFromApp(app interface{}) LogCleanupService { + // 这里需要根据实际的app结构来获取 + // 暂时返回nil,实际使用时需要实现 + return nil +} diff --git a/internal/service/log_service.go b/internal/service/log_service.go new file mode 100644 index 0000000..49a3c43 --- /dev/null +++ b/internal/service/log_service.go @@ -0,0 +1,172 @@ +package service + +import ( + "context" + "time" + + "carrot_bbs/internal/model" + "carrot_bbs/internal/repository" +) + +// OperationLogService 操作日志服务接口 +type OperationLogService interface { + RecordOperation(log *model.OperationLog) + GetOperationLogs(ctx context.Context, filters repository.LogFilter, page, pageSize int) ([]*model.OperationLog, int64, error) + GetOperationLogsByUser(ctx context.Context, userID string, page, pageSize int) ([]*model.OperationLog, int64, error) + GetOperationLogsByTimeRange(ctx context.Context, startTime, endTime string, page, pageSize int) ([]*model.OperationLog, int64, error) + GetStatistics(ctx context.Context, startTime, endTime string) (*repository.OperationLogStatistics, error) +} + +// operationLogServiceImpl 操作日志服务实现 +type operationLogServiceImpl struct { + asyncManager *AsyncLogManager + repo *repository.OperationLogRepository +} + +// NewOperationLogService 创建操作日志服务 +func NewOperationLogService( + asyncManager *AsyncLogManager, + repo *repository.OperationLogRepository, +) OperationLogService { + return &operationLogServiceImpl{ + asyncManager: asyncManager, + repo: repo, + } +} + +// RecordOperation 记录操作日志 +func (s *operationLogServiceImpl) RecordOperation(log *model.OperationLog) { + s.asyncManager.RecordOperation(log) +} + +// GetOperationLogs 获取操作日志列表 +func (s *operationLogServiceImpl) GetOperationLogs(ctx context.Context, filters repository.LogFilter, page, pageSize int) ([]*model.OperationLog, int64, error) { + return s.repo.GetOperationLogs(ctx, filters, page, pageSize) +} + +// GetOperationLogsByUser 获取指定用户的操作日志 +func (s *operationLogServiceImpl) GetOperationLogsByUser(ctx context.Context, userID string, page, pageSize int) ([]*model.OperationLog, int64, error) { + return s.repo.GetOperationLogsByUser(ctx, userID, page, pageSize) +} + +// GetOperationLogsByTimeRange 按时间范围获取操作日志 +func (s *operationLogServiceImpl) GetOperationLogsByTimeRange(ctx context.Context, startTime, endTime string, page, pageSize int) ([]*model.OperationLog, int64, error) { + return s.repo.GetOperationLogsByTimeRange(ctx, startTime, endTime, page, pageSize) +} + +// GetStatistics 获取操作日志统计 +func (s *operationLogServiceImpl) GetStatistics(ctx context.Context, startTime, endTime string) (*repository.OperationLogStatistics, error) { + return s.repo.GetStatistics(ctx, startTime, endTime) +} + +// LoginLogService 登录日志服务接口 +type LoginLogService interface { + RecordLogin(log *model.LoginLog) + GetLoginLogs(ctx context.Context, filters repository.LoginFilter, page, pageSize int) ([]*model.LoginLog, int64, error) + GetLoginLogsByUser(ctx context.Context, userID string, page, pageSize int) ([]*model.LoginLog, int64, error) + GetFailedLoginsByIP(ctx context.Context, ip string, timeWindow string) (int64, error) + GetRecentSuccessLogins(ctx context.Context, userID string, limit int) ([]*model.LoginLog, error) + GetStatistics(ctx context.Context, startTime, endTime string) (*repository.LoginLogStatistics, error) +} + +// loginLogServiceImpl 登录日志服务实现 +type loginLogServiceImpl struct { + asyncManager *AsyncLogManager + repo *repository.LoginLogRepository +} + +// NewLoginLogService 创建登录日志服务 +func NewLoginLogService( + asyncManager *AsyncLogManager, + repo *repository.LoginLogRepository, +) LoginLogService { + return &loginLogServiceImpl{ + asyncManager: asyncManager, + repo: repo, + } +} + +// RecordLogin 记录登录日志 +func (s *loginLogServiceImpl) RecordLogin(log *model.LoginLog) { + s.asyncManager.RecordLogin(log) +} + +// GetLoginLogs 获取登录日志列表 +func (s *loginLogServiceImpl) GetLoginLogs(ctx context.Context, filters repository.LoginFilter, page, pageSize int) ([]*model.LoginLog, int64, error) { + return s.repo.GetLoginLogs(ctx, filters, page, pageSize) +} + +// GetLoginLogsByUser 获取指定用户的登录日志 +func (s *loginLogServiceImpl) GetLoginLogsByUser(ctx context.Context, userID string, page, pageSize int) ([]*model.LoginLog, int64, error) { + return s.repo.GetLoginLogsByUser(ctx, userID, page, pageSize) +} + +// GetFailedLoginsByIP 获取指定IP的失败登录记录 +func (s *loginLogServiceImpl) GetFailedLoginsByIP(ctx context.Context, ip string, timeWindow string) (int64, error) { + duration, err := time.ParseDuration(timeWindow) + if err != nil { + duration = time.Hour // 默认1小时 + } + return s.repo.GetFailedLoginsByIP(ctx, ip, duration) +} + +// GetRecentSuccessLogins 获取最近成功的登录记录 +func (s *loginLogServiceImpl) GetRecentSuccessLogins(ctx context.Context, userID string, limit int) ([]*model.LoginLog, error) { + return s.repo.GetRecentSuccessLogins(ctx, userID, limit) +} + +// GetStatistics 获取登录日志统计 +func (s *loginLogServiceImpl) GetStatistics(ctx context.Context, startTime, endTime string) (*repository.LoginLogStatistics, error) { + return s.repo.GetStatistics(ctx, startTime, endTime) +} + +// DataChangeLogService 数据变更日志服务接口 +type DataChangeLogService interface { + RecordDataChange(log *model.DataChangeLog) + GetDataChangeLogs(ctx context.Context, filters repository.DataChangeFilter, page, pageSize int) ([]*model.DataChangeLog, int64, error) + GetDataChangeLogsByUser(ctx context.Context, userID string, page, pageSize int) ([]*model.DataChangeLog, int64, error) + GetDataChangeLogsByOperator(ctx context.Context, operatorID string, page, pageSize int) ([]*model.DataChangeLog, int64, error) + GetStatistics(ctx context.Context, startTime, endTime string) (*repository.DataChangeLogStatistics, error) +} + +// dataChangeLogServiceImpl 数据变更日志服务实现 +type dataChangeLogServiceImpl struct { + asyncManager *AsyncLogManager + repo *repository.DataChangeLogRepository +} + +// NewDataChangeLogService 创建数据变更日志服务 +func NewDataChangeLogService( + asyncManager *AsyncLogManager, + repo *repository.DataChangeLogRepository, +) DataChangeLogService { + return &dataChangeLogServiceImpl{ + asyncManager: asyncManager, + repo: repo, + } +} + +// RecordDataChange 记录数据变更日志 +func (s *dataChangeLogServiceImpl) RecordDataChange(log *model.DataChangeLog) { + s.asyncManager.RecordDataChange(log) +} + +// GetDataChangeLogs 获取数据变更日志列表 +func (s *dataChangeLogServiceImpl) GetDataChangeLogs(ctx context.Context, filters repository.DataChangeFilter, page, pageSize int) ([]*model.DataChangeLog, int64, error) { + return s.repo.GetDataChangeLogs(ctx, filters, page, pageSize) +} + +// GetDataChangeLogsByUser 获取指定用户的数据变更日志 +func (s *dataChangeLogServiceImpl) GetDataChangeLogsByUser(ctx context.Context, userID string, page, pageSize int) ([]*model.DataChangeLog, int64, error) { + return s.repo.GetDataChangeLogsByUser(ctx, userID, page, pageSize) +} + +// GetDataChangeLogsByOperator 获取指定操作人的日志 +func (s *dataChangeLogServiceImpl) GetDataChangeLogsByOperator(ctx context.Context, operatorID string, page, pageSize int) ([]*model.DataChangeLog, int64, error) { + return s.repo.GetDataChangeLogsByOperator(ctx, operatorID, page, pageSize) +} + +// GetStatistics 获取数据变更日志统计 +func (s *dataChangeLogServiceImpl) GetStatistics(ctx context.Context, startTime, endTime string) (*repository.DataChangeLogStatistics, error) { + return s.repo.GetStatistics(ctx, startTime, endTime) +} diff --git a/internal/service/post_service.go b/internal/service/post_service.go index e7b7414..d045cac 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -59,6 +59,9 @@ type PostService interface { // 其他 IncrementViews(ctx context.Context, postID, userID string) error + + // 日志服务设置 + SetLogService(logService *LogService) } // postServiceImpl 帖子服务实现 @@ -68,7 +71,8 @@ type postServiceImpl struct { cache cache.Cache gorseClient gorse.Client postAIService *PostAIService - txManager repository.TransactionManager // 事务管理器 + txManager repository.TransactionManager + logService *LogService } // NewPostService 创建帖子服务 @@ -80,9 +84,15 @@ func NewPostService(postRepo *repository.PostRepository, systemMessageService Sy gorseClient: gorseClient, postAIService: postAIService, txManager: txManager, + logService: nil, } } +// SetLogService 设置日志服务 +func (s *postServiceImpl) SetLogService(logService *LogService) { + s.logService = logService +} + // PostListResult 帖子列表缓存结果 type PostListResult struct { Posts []*model.Post @@ -103,6 +113,18 @@ func (s *postServiceImpl) Create(ctx context.Context, userID, title, content str return nil, err } + // 记录操作日志 + if s.logService != nil { + s.logService.OperationLog.RecordOperation(&model.OperationLog{ + UserID: userID, + Operation: string(model.OpPostCreate), + Module: "post", + TargetType: "post", + TargetID: post.ID, + Status: "success", + }) + } + // 失效帖子列表缓存 cache.InvalidatePostList(s.cache) diff --git a/internal/service/upload_service.go b/internal/service/upload_service.go index 616a867..4042465 100644 --- a/internal/service/upload_service.go +++ b/internal/service/upload_service.go @@ -9,6 +9,7 @@ import ( "image/jpeg" "image/png" "io" + "log" "mime" "mime/multipart" "net/http" @@ -16,6 +17,7 @@ import ( "strings" "carrot_bbs/internal/pkg/s3" + "github.com/disintegration/imaging" _ "golang.org/x/image/bmp" _ "golang.org/x/image/tiff" @@ -27,6 +29,13 @@ type UploadService struct { userService UserService } +// 预览图配置 +const ( + PreviewMaxWidth = 300 // 列表/网格模式最大宽度 + PreviewMaxHeight = 800 // 详情页最大高度 + PreviewQuality = 70 // WebP 质量 +) + // NewUploadService 创建上传服务 func NewUploadService(s3Client *s3.Client, userService UserService) *UploadService { return &UploadService{ @@ -35,25 +44,31 @@ func NewUploadService(s3Client *s3.Client, userService UserService) *UploadServi } } -// UploadImage 上传图片 -func (s *UploadService) UploadImage(ctx context.Context, file *multipart.FileHeader) (string, error) { +// UploadImage 上传图片(返回原图URL和预览图URL) +func (s *UploadService) UploadImage(ctx context.Context, file *multipart.FileHeader) (string, string, string, error) { processedData, contentType, ext, err := prepareImageForUpload(file) if err != nil { - return "", err + return "", "", "", err } // 压缩后再计算哈希,确保同一压缩结果映射同一对象名 hash := sha256.Sum256(processedData) hashStr := fmt.Sprintf("%x", hash) + // 上传原图 objectName := fmt.Sprintf("images/%s%s", hashStr, ext) - url, err := s.s3Client.UploadData(ctx, objectName, processedData, contentType) if err != nil { - return "", fmt.Errorf("failed to upload to S3: %w", err) + return "", "", "", fmt.Errorf("failed to upload to S3: %w", err) } - return url, nil + // 生成预览图 + previewURL, previewURLLarge, err := s.GeneratePreviewImages(ctx, processedData, hashStr) + if err != nil { + log.Printf("[WARN] Failed to generate preview images: %v", err) + } + + return url, previewURL, previewURLLarge, nil } // getExtFromContentType 根据Content-Type获取文件扩展名 @@ -272,3 +287,73 @@ func normalizeImageContentType(contentType string) string { return contentType } } + +// GeneratePreviewImages 生成预览图 +func (s *UploadService) GeneratePreviewImages(ctx context.Context, originalData []byte, hash string) (previewURL, previewURLLarge string, err error) { + img, _, err := image.Decode(bytes.NewReader(originalData)) + if err != nil { + return "", "", fmt.Errorf("failed to decode image: %w", err) + } + + bounds := img.Bounds() + originalWidth := bounds.Dx() + originalHeight := bounds.Dy() + + // 生成普通预览图(列表/网格模式) + previewURL, err = s.generatePreview(ctx, img, originalWidth, originalHeight, "preview", PreviewMaxWidth, hash) + if err != nil { + log.Printf("[WARN] Failed to generate preview: %v", err) + } + + // 生成大预览图(详情页) + previewURLLarge, err = s.generatePreview(ctx, img, originalWidth, originalHeight, "large", PreviewMaxHeight, hash) + if err != nil { + log.Printf("[WARN] Failed to generate large preview: %v", err) + } + + return previewURL, previewURLLarge, nil +} + +// generatePreview 生成单个预览图 +func (s *UploadService) generatePreview(ctx context.Context, img image.Image, originalWidth, originalHeight int, mode string, maxDim int, hash string) (string, error) { + var targetWidth, targetHeight int + + // 计算目标尺寸 + if originalWidth > originalHeight { + // 宽图:按宽度缩放 + if originalWidth > maxDim { + targetWidth = maxDim + targetHeight = int(float64(originalHeight) * float64(maxDim) / float64(originalWidth)) + } else { + return "", nil // 图片足够小,不需要生成预览图 + } + } else { + // 高图:按高度缩放 + if originalHeight > maxDim { + targetHeight = maxDim + targetWidth = int(float64(originalWidth) * float64(maxDim) / float64(originalHeight)) + } else { + return "", nil // 图片足够小,不需要生成预览图 + } + } + + // 缩放图片(使用高质量 Lanczos 算法) + scaledImg := imaging.Resize(img, targetWidth, targetHeight, imaging.Lanczos) + + // 编码为 JPEG(WebP 在某些设备上可能不支持,使用 JPEG 保证兼容性) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, scaledImg, &jpeg.Options{Quality: PreviewQuality}); err != nil { + return "", fmt.Errorf("failed to encode jpeg: %w", err) + } + + // 构建对象名:thumbnails/{hash}_{mode}.jpg + objectName := fmt.Sprintf("thumbnails/%s_%s.jpg", hash, mode) + + // 上传到 S3 + url, err := s.s3Client.UploadData(ctx, objectName, buf.Bytes(), "image/jpeg") + if err != nil { + return "", fmt.Errorf("failed to upload preview: %w", err) + } + + return url, nil +} diff --git a/internal/service/user_service.go b/internal/service/user_service.go index 69c13e9..aa5f522 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -44,8 +44,8 @@ type UserService interface { // 关注相关 GetFollowers(ctx context.Context, userID string, page, pageSize int) ([]*model.User, int64, error) GetFollowing(ctx context.Context, userID string, page, pageSize int) ([]*model.User, int64, error) - GetFollowingList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error) - GetFollowersList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error) + GetFollowingList(ctx context.Context, userID string, page, pageSize string) ([]*model.User, error) + GetFollowersList(ctx context.Context, userID string, page, pageSize string) ([]*model.User, error) FollowUser(ctx context.Context, followerID, followeeID string) error UnfollowUser(ctx context.Context, followerID, followeeID string) error GetMutualFollowStatus(ctx context.Context, currentUserID string, targetUserIDs []string) (map[string][2]bool, error) @@ -55,6 +55,9 @@ type UserService interface { UnblockUser(ctx context.Context, blockerID, blockedID string) error GetBlockedUsers(ctx context.Context, blockerID string, page, pageSize int) ([]*model.User, int64, error) IsBlocked(ctx context.Context, blockerID, blockedID string) (bool, error) + + // 日志服务设置 + SetLogService(logService *LogService) } // userServiceImpl 用户服务实现 @@ -62,6 +65,7 @@ type userServiceImpl struct { userRepo *repository.UserRepository systemMessageService SystemMessageService emailCodeService EmailCodeService + logService *LogService } // NewUserService 创建用户服务 @@ -75,9 +79,15 @@ func NewUserService( userRepo: userRepo, systemMessageService: systemMessageService, emailCodeService: NewEmailCodeService(emailService, cacheBackend), + logService: nil, } } +// SetLogService 设置日志服务 +func (s *userServiceImpl) SetLogService(logService *LogService) { + s.logService = logService +} + // SendRegisterCode 发送注册验证码 func (s *userServiceImpl) SendRegisterCode(ctx context.Context, email string) error { user, err := s.userRepo.GetByEmail(email) @@ -241,12 +251,28 @@ func (s *userServiceImpl) Register(ctx context.Context, username, email, passwor return nil, err } + // 记录注册日志 + if s.logService != nil { + s.logService.OperationLog.RecordOperation(&model.OperationLog{ + UserID: user.ID, + UserName: user.Username, + NickName: user.Nickname, + Operation: string(model.OpUserRegister), + Module: "user", + TargetType: "user", + TargetID: user.ID, + IP: "", + Status: "success", + }) + } + return user, nil } // Login 用户登录 func (s *userServiceImpl) Login(ctx context.Context, account, password string) (*model.User, error) { account = strings.TrimSpace(account) + var ( user *model.User err error @@ -258,18 +284,86 @@ func (s *userServiceImpl) Login(ctx context.Context, account, password string) ( } else { user, err = s.userRepo.GetByUsername(account) } + + // 登录失败处理 + loginResult := string(model.LoginResultSuccess) + var failReason string + if err != nil || user == nil { + loginResult = string(model.LoginResultFail) + failReason = string(model.FailReasonUserNotFound) + + // 记录失败登录日志 + if s.logService != nil { + s.logService.LoginLog.RecordLogin(&model.LoginLog{ + UserName: account, + Event: string(model.LoginEventFail), + IP: "", + UserAgent: "", + Result: loginResult, + FailReason: failReason, + }) + } + return nil, ErrInvalidCredentials } if !utils.CheckPasswordHash(password, user.PasswordHash) { + loginResult = string(model.LoginResultFail) + failReason = string(model.FailReasonWrongPassword) + + // 记录失败登录日志 + if s.logService != nil { + s.logService.LoginLog.RecordLogin(&model.LoginLog{ + UserID: user.ID, + UserName: user.Username, + NickName: user.Nickname, + Event: string(model.LoginEventFail), + IP: "", + UserAgent: "", + Result: loginResult, + FailReason: failReason, + }) + } + return nil, ErrInvalidCredentials } if user.Status != model.UserStatusActive { + loginResult = string(model.LoginResultFail) + failReason = string(model.FailReasonAccountBanned) + + // 记录失败登录日志 + if s.logService != nil { + s.logService.LoginLog.RecordLogin(&model.LoginLog{ + UserID: user.ID, + UserName: user.Username, + NickName: user.Nickname, + Event: string(model.LoginEventFail), + IP: "", + UserAgent: "", + Result: loginResult, + FailReason: failReason, + }) + } + return nil, ErrUserBanned } + // 记录成功登录日志 + if s.logService != nil { + s.logService.LoginLog.RecordLogin(&model.LoginLog{ + UserID: user.ID, + UserName: user.Username, + NickName: user.Nickname, + LoginType: string(model.LoginTypePassword), + Event: string(model.LoginEventLogin), + IP: "", + UserAgent: "", + Result: string(model.LoginResultSuccess), + }) + } + return user, nil } @@ -337,7 +431,27 @@ func (s *userServiceImpl) GetUserByIDWithMutualFollowStatus(ctx context.Context, // UpdateUser 更新用户 func (s *userServiceImpl) UpdateUser(ctx context.Context, user *model.User) error { - return s.userRepo.Update(user) + err := s.userRepo.Update(user) + if err != nil { + return err + } + + // 记录数据变更日志 + if s.logService != nil { + s.logService.DataChangeLog.RecordDataChange(&model.DataChangeLog{ + UserID: user.ID, + ChangeType: string(model.ChangeProfile), + TargetType: "user", + TargetID: user.ID, + FieldName: "profile", + OldValue: "", + NewValue: fmt.Sprintf("昵称:%s", user.Nickname), + OperatorType: string(model.OperatorTypeSelf), + Reason: "用户修改个人资料", + }) + } + + return nil } // GetFollowers 获取粉丝 @@ -542,7 +656,26 @@ func (s *userServiceImpl) ChangePassword(ctx context.Context, userID, oldPasswor // 更新密码 user.PasswordHash = hashedPassword - return s.userRepo.Update(user) + if err := s.userRepo.Update(user); err != nil { + return err + } + + // 记录数据变更日志 + if s.logService != nil { + s.logService.DataChangeLog.RecordDataChange(&model.DataChangeLog{ + UserID: userID, + ChangeType: string(model.ChangePassword), + TargetType: "user", + TargetID: userID, + FieldName: "password", + OldValue: "***", + NewValue: "***", + OperatorType: string(model.OperatorTypeSelf), + Reason: "用户主动修改密码", + }) + } + + return nil } // ResetPasswordByEmail 通过邮箱重置密码 diff --git a/internal/wire/handler.go b/internal/wire/handler.go index b2f6b0e..346b9c0 100644 --- a/internal/wire/handler.go +++ b/internal/wire/handler.go @@ -27,6 +27,7 @@ var HandlerSet = wire.NewSet( handler.NewAdminCommentHandler, handler.NewAdminGroupHandler, handler.NewAdminDashboardHandler, + handler.NewAdminLogHandler, // 需要特殊处理的 Handler ProvideUserHandler, @@ -42,16 +43,47 @@ var HandlerSet = wire.NewSet( func ProvideUserHandler( userService service.UserService, activityService service.UserActivityService, + postService service.PostService, + commentService *service.CommentService, + logService *service.LogService, ) *handler.UserHandler { - return handler.NewUserHandler(userService, activityService) + h := handler.NewUserHandler(userService, activityService) + h.SetLogService(logService) + + // 同时设置到其他服务 + if ps, ok := postService.(interface{ SetLogService(*service.LogService) }); ok { + ps.SetLogService(logService) + } + if commentService != nil { + commentService.SetLogService(logService) + } + + return h } // ProvidePostHandler 提供帖子处理器 func ProvidePostHandler( postService service.PostService, userService service.UserService, + logService *service.LogService, ) *handler.PostHandler { - return handler.NewPostHandler(postService, userService) + h := handler.NewPostHandler(postService, userService) + if ps, ok := postService.(interface{ SetLogService(*service.LogService) }); ok { + ps.SetLogService(logService) + } + return h +} + +// ProvideAdminPostHandler 提供管理端帖子处理器 +func ProvideAdminPostHandler( + postService service.AdminPostService, + logService *service.LogService, +) *handler.AdminPostHandler { + h := handler.NewAdminPostHandler(postService) + if ps, ok := postService.(interface{ SetLogService(*service.LogService) }); ok { + ps.SetLogService(logService) + } + return h } // ProvideMessageHandler 提供消息处理器 diff --git a/internal/wire/repository.go b/internal/wire/repository.go index 0eff102..ab0e601 100644 --- a/internal/wire/repository.go +++ b/internal/wire/repository.go @@ -23,6 +23,11 @@ var RepositorySet = wire.NewSet( repository.NewVoteRepository, repository.NewScheduleRepository, ProvideUserActivityRepository, + + // 日志相关仓储 + repository.NewOperationLogRepository, + repository.NewLoginLogRepository, + repository.NewDataChangeLogRepository, ) // ProvideUserActivityRepository 提供用户活跃数据仓储 diff --git a/internal/wire/service.go b/internal/wire/service.go index 496c781..a319ee5 100644 --- a/internal/wire/service.go +++ b/internal/wire/service.go @@ -1,6 +1,8 @@ package wire import ( + "time" + "carrot_bbs/internal/cache" "carrot_bbs/internal/config" "carrot_bbs/internal/grpc/runner" @@ -48,6 +50,14 @@ var ServiceSet = wire.NewSet( ProvideAdminCommentService, ProvideAdminGroupService, ProvideAdminDashboardService, + + // 日志服务 + ProvideAsyncLogManager, + ProvideOperationLogService, + ProvideLoginLogService, + ProvideDataChangeLogService, + ProvideLogCleanupService, + ProvideLogService, ) // ProvideJWTService 提供 JWT 服务 @@ -254,3 +264,86 @@ func ProvideAdminDashboardService( ) service.AdminDashboardService { return service.NewAdminDashboardService(db, userRepo, postRepo, commentRepo, groupRepo, activityRepo) } + +// ProvideAsyncLogManager 提供异步日志管理器 +func ProvideAsyncLogManager( + db *gorm.DB, + operationRepo *repository.OperationLogRepository, + loginRepo *repository.LoginLogRepository, + dataChangeRepo *repository.DataChangeLogRepository, + logger *zap.Logger, + cfg *config.Config, +) *service.AsyncLogManager { + asyncCfg := &service.AsyncLogConfig{ + BufferSize: cfg.Log.Async.BufferSize, + BatchSize: cfg.Log.Async.BatchSize, + WorkerCount: cfg.Log.Async.WorkerCount, + EnableFallback: cfg.Log.Async.EnableFallback, + FallbackPath: cfg.Log.Async.FallbackPath, + } + + // 解析刷新间隔 + if duration, err := parseDuration(cfg.Log.Async.FlushInterval); err == nil { + asyncCfg.FlushInterval = duration + } else { + asyncCfg.FlushInterval = 3 * time.Second + } + + return service.NewAsyncLogManager(db, operationRepo, loginRepo, dataChangeRepo, logger, asyncCfg) +} + +// ProvideOperationLogService 提供操作日志服务 +func ProvideOperationLogService( + asyncManager *service.AsyncLogManager, + operationRepo *repository.OperationLogRepository, +) service.OperationLogService { + return service.NewOperationLogService(asyncManager, operationRepo) +} + +// ProvideLoginLogService 提供登录日志服务 +func ProvideLoginLogService( + asyncManager *service.AsyncLogManager, + loginRepo *repository.LoginLogRepository, +) service.LoginLogService { + return service.NewLoginLogService(asyncManager, loginRepo) +} + +// ProvideDataChangeLogService 提供数据变更日志服务 +func ProvideDataChangeLogService( + asyncManager *service.AsyncLogManager, + dataChangeRepo *repository.DataChangeLogRepository, +) service.DataChangeLogService { + return service.NewDataChangeLogService(asyncManager, dataChangeRepo) +} + +// ProvideLogCleanupService 提供日志清理服务 +func ProvideLogCleanupService( + operationRepo *repository.OperationLogRepository, + loginRepo *repository.LoginLogRepository, + dataChangeRepo *repository.DataChangeLogRepository, + logger *zap.Logger, + cfg *config.Config, +) service.LogCleanupService { + cleanupCfg := &service.LogCleanupConfig{ + Enabled: true, + OperationLog: cfg.Log.Retention.OperationLog, + LoginLog: cfg.Log.Retention.LoginLog, + DataChangeLog: cfg.Log.Retention.DataChange, + CleanupTime: "02:00", + } + return service.NewLogCleanupService(operationRepo, loginRepo, dataChangeRepo, cleanupCfg, logger) +} + +// ProvideLogService 提供日志服务组合 +func ProvideLogService( + operationLogService service.OperationLogService, + loginLogService service.LoginLogService, + dataChangeLogService service.DataChangeLogService, +) *service.LogService { + return service.NewLogService(operationLogService, loginLogService, dataChangeLogService) +} + +// parseDuration 解析时长字符串 +func parseDuration(s string) (time.Duration, error) { + return time.ParseDuration(s) +}