feat: 添加日志管理和敏感词过滤功能
- 新增日志管理模块:操作日志、登录日志、数据变更日志 - 新增敏感词过滤器 (sanitizer) - 新增日志异步写入管理器 - 新增日志定时清理服务 - 优化帖子相关服务和上传服务
This commit is contained in:
133
cmd/migrate/main.go
Normal file
133
cmd/migrate/main.go
Normal 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 自动处理
|
||||
// 现有图片需要重新上传才能生成预览图,或者编写额外的后台任务来生成
|
||||
@@ -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
1
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
|
||||
|
||||
3
go.sum
3
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=
|
||||
|
||||
@@ -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 限流配置
|
||||
|
||||
@@ -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 帖子响应(列表用)
|
||||
|
||||
215
internal/handler/admin_log_handler.go
Normal file
215
internal/handler/admin_log_handler.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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 上传头像
|
||||
|
||||
@@ -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")
|
||||
|
||||
200
internal/middleware/access_log.go
Normal file
200
internal/middleware/access_log.go
Normal 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")
|
||||
}
|
||||
56
internal/model/data_change_log.go
Normal file
56
internal/model/data_change_log.go
Normal 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"
|
||||
}
|
||||
87
internal/model/login_log.go
Normal file
87
internal/model/login_log.go
Normal 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"
|
||||
}
|
||||
86
internal/model/operation_log.go
Normal file
86
internal/model/operation_log.go
Normal 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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
139
internal/pkg/sanitizer/sanitizer.go
Normal file
139
internal/pkg/sanitizer/sanitizer.go
Normal 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
|
||||
}
|
||||
136
internal/repository/data_change_log_repo.go
Normal file
136
internal/repository/data_change_log_repo.go
Normal 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
|
||||
}
|
||||
159
internal/repository/login_log_repo.go
Normal file
159
internal/repository/login_log_repo.go
Normal 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
|
||||
}
|
||||
136
internal/repository/operation_log_repo.go
Normal file
136
internal/repository/operation_log_repo.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
304
internal/service/async_log_manager.go
Normal file
304
internal/service/async_log_manager.go
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
205
internal/service/log_cleanup_service.go
Normal file
205
internal/service/log_cleanup_service.go
Normal 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
|
||||
}
|
||||
172
internal/service/log_service.go
Normal file
172
internal/service/log_service.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 通过邮箱重置密码
|
||||
|
||||
@@ -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 提供消息处理器
|
||||
|
||||
@@ -23,6 +23,11 @@ var RepositorySet = wire.NewSet(
|
||||
repository.NewVoteRepository,
|
||||
repository.NewScheduleRepository,
|
||||
ProvideUserActivityRepository,
|
||||
|
||||
// 日志相关仓储
|
||||
repository.NewOperationLogRepository,
|
||||
repository.NewLoginLogRepository,
|
||||
repository.NewDataChangeLogRepository,
|
||||
)
|
||||
|
||||
// ProvideUserActivityRepository 提供用户活跃数据仓储
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user