feat: 添加日志管理和敏感词过滤功能 #1

Merged
lan merged 1 commits from feature/logs-and-sanitizer into master 2026-03-15 12:10:14 +08:00
31 changed files with 2753 additions and 60 deletions

133
cmd/migrate/main.go Normal file
View File

@@ -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 自动处理
// 现有图片需要重新上传才能生成预览图,或者编写额外的后台任务来生成

View File

@@ -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

1
go.mod
View File

@@ -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

3
go.sum
View File

@@ -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=

View File

@@ -12,6 +12,42 @@ type LogConfig struct {
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 限流配置

View File

@@ -107,6 +107,8 @@ type PostImageResponse struct {
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"`
}

View File

@@ -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")
}
}

View File

@@ -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 上传头像

View File

@@ -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,14 +148,33 @@ 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
h.activityService.RecordUserActive(ctx, user.ID, loginType, ip, userAgent)
}()
}
// 更新登录日志补充IP和UserAgent
if h.logService != nil {
ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
h.activityService.RecordUserActive(ctx, user.ID, loginType, ip, userAgent)
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),
})
}()
}
@@ -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")

View File

@@ -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")
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -80,6 +80,8 @@ type PostImage struct {
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"` // 文件大小(字节)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -3,6 +3,7 @@ package repository
import (
"carrot_bbs/internal/model"
"context"
"strings"
"time"
"gorm.io/gorm"
@@ -35,10 +36,23 @@ 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,
PreviewURL: previewURL,
PreviewURLLarge: previewURLLarge,
SortOrder: i,
}
if err := tx.Create(image).Error; err != nil {

View File

@@ -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,6 +463,17 @@ 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)
}
}
}
}

View File

@@ -27,12 +27,16 @@ 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
logService *LogService
}
// NewAdminPostService 创建管理端帖子服务
@@ -40,9 +44,15 @@ func NewAdminPostService(postRepo *repository.PostRepository, cacheBackend cache
return &adminPostServiceImpl{
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
}

View File

@@ -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()
}
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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)
// 编码为 JPEGWebP 在某些设备上可能不支持,使用 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
}

View File

@@ -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 通过邮箱重置密码

View File

@@ -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 提供消息处理器

View File

@@ -23,6 +23,11 @@ var RepositorySet = wire.NewSet(
repository.NewVoteRepository,
repository.NewScheduleRepository,
ProvideUserActivityRepository,
// 日志相关仓储
repository.NewOperationLogRepository,
repository.NewLoginLogRepository,
repository.NewDataChangeLogRepository,
)
// ProvideUserActivityRepository 提供用户活跃数据仓储

View File

@@ -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)
}