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:
|
output_paths:
|
||||||
- stdout
|
- stdout
|
||||||
- ./logs/app.log
|
- ./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:
|
rate_limit:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -35,6 +35,7 @@ require (
|
|||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.3 // indirect
|
github.com/felixge/httpsnoop v1.0.3 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // 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/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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
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.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
|
||||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
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/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 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
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 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
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=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
|||||||
@@ -12,6 +12,42 @@ type LogConfig struct {
|
|||||||
Level string `mapstructure:"level"`
|
Level string `mapstructure:"level"`
|
||||||
Encoding string `mapstructure:"encoding"`
|
Encoding string `mapstructure:"encoding"`
|
||||||
OutputPaths []string `mapstructure:"output_paths"`
|
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 限流配置
|
// RateLimitConfig 限流配置
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ type PostImageResponse struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
ThumbnailURL string `json:"thumbnail_url"`
|
ThumbnailURL string `json:"thumbnail_url"`
|
||||||
|
PreviewURL string `json:"preview_url"` // 列表/网格预览图
|
||||||
|
PreviewURLLarge string `json:"preview_url_large"` // 详情页预览图
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
Height int `json:"height"`
|
Height int `json:"height"`
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
url, err := h.uploadService.UploadImage(c.Request.Context(), file)
|
url, previewURL, previewURLLarge, err := h.uploadService.UploadImage(c.Request.Context(), file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalServerError(c, "failed to upload image")
|
response.InternalServerError(c, "failed to upload image")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, gin.H{"url": url})
|
response.Success(c, gin.H{
|
||||||
|
"url": url,
|
||||||
|
"preview_url": previewURL,
|
||||||
|
"preview_url_large": previewURLLarge,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadAvatar 上传头像
|
// UploadAvatar 上传头像
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -17,6 +19,7 @@ type UserHandler struct {
|
|||||||
userService service.UserService
|
userService service.UserService
|
||||||
activityService service.UserActivityService // 用户活跃统计服务
|
activityService service.UserActivityService // 用户活跃统计服务
|
||||||
jwtService *service.JWTService
|
jwtService *service.JWTService
|
||||||
|
logService *service.LogService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserHandler 创建用户处理器
|
// 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 用户注册
|
// Register 用户注册
|
||||||
func (h *UserHandler) Register(c *gin.Context) {
|
func (h *UserHandler) Register(c *gin.Context) {
|
||||||
type RegisterRequest struct {
|
type RegisterRequest struct {
|
||||||
@@ -54,6 +79,35 @@ func (h *UserHandler) Register(c *gin.Context) {
|
|||||||
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
|
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
|
||||||
refreshToken, _ := h.jwtService.GenerateRefreshToken(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{
|
response.Success(c, gin.H{
|
||||||
"user": dto.ConvertUserToResponse(user),
|
"user": dto.ConvertUserToResponse(user),
|
||||||
"token": accessToken,
|
"token": accessToken,
|
||||||
@@ -94,14 +148,33 @@ func (h *UserHandler) Login(c *gin.Context) {
|
|||||||
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
|
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
|
||||||
refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username)
|
refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username)
|
||||||
|
|
||||||
|
ip := c.ClientIP()
|
||||||
|
userAgent := c.GetHeader("User-Agent")
|
||||||
|
|
||||||
// 记录用户活跃
|
// 记录用户活跃
|
||||||
if h.activityService != nil {
|
if h.activityService != nil {
|
||||||
go func() {
|
go func() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
loginType := model.LoginTypeLogin
|
loginType := model.LoginTypeLogin
|
||||||
|
h.activityService.RecordUserActive(ctx, user.ID, loginType, ip, userAgent)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新登录日志(补充IP和UserAgent)
|
||||||
|
if h.logService != nil {
|
||||||
ip := c.ClientIP()
|
ip := c.ClientIP()
|
||||||
userAgent := c.GetHeader("User-Agent")
|
userAgent := c.GetHeader("User-Agent")
|
||||||
h.activityService.RecordUserActive(ctx, user.ID, loginType, ip, userAgent)
|
go func() {
|
||||||
|
h.logService.LoginLog.RecordLogin(&model.LoginLog{
|
||||||
|
UserID: user.ID,
|
||||||
|
UserName: user.Username,
|
||||||
|
NickName: user.Nickname,
|
||||||
|
LoginType: string(model.LoginTypePassword),
|
||||||
|
Event: string(model.LoginEventLogin),
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
Result: string(model.LoginResultSuccess),
|
||||||
|
})
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,18 +437,43 @@ func (h *UserHandler) RefreshToken(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
user, err := h.userService.GetUserByID(c.Request.Context(), claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
response.InternalServerError(c, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 生成新 token
|
// 生成新 token
|
||||||
accessToken, _ := h.jwtService.GenerateAccessToken(claims.UserID, claims.Username)
|
accessToken, _ := h.jwtService.GenerateAccessToken(user.ID, user.Username)
|
||||||
refreshToken, _ := h.jwtService.GenerateRefreshToken(claims.UserID, claims.Username)
|
refreshToken, _ := h.jwtService.GenerateRefreshToken(user.ID, user.Username)
|
||||||
|
|
||||||
|
ip := c.ClientIP()
|
||||||
|
userAgent := c.GetHeader("User-Agent")
|
||||||
|
|
||||||
// 记录用户活跃
|
// 记录用户活跃
|
||||||
if h.activityService != nil {
|
if h.activityService != nil {
|
||||||
go func() {
|
go func() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
loginType := model.LoginTypeRefresh
|
loginType := model.LoginTypeRefresh
|
||||||
ip := c.ClientIP()
|
h.activityService.RecordUserActive(ctx, user.ID, loginType, ip, userAgent)
|
||||||
userAgent := c.GetHeader("User-Agent")
|
}()
|
||||||
h.activityService.RecordUserActive(ctx, claims.UserID, 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 关注用户
|
// FollowUser 关注用户
|
||||||
func (h *UserHandler) FollowUser(c *gin.Context) {
|
func (h *UserHandler) FollowUser(c *gin.Context) {
|
||||||
userID := c.Param("id")
|
userID := c.Param("id")
|
||||||
@@ -657,6 +745,40 @@ func (h *UserHandler) SendChangePasswordCode(c *gin.Context) {
|
|||||||
response.Success(c, gin.H{"success": true})
|
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 搜索用户
|
// Search 搜索用户
|
||||||
func (h *UserHandler) Search(c *gin.Context) {
|
func (h *UserHandler) Search(c *gin.Context) {
|
||||||
keyword := c.Query("keyword")
|
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"
|
||||||
|
}
|
||||||
@@ -80,6 +80,8 @@ type PostImage struct {
|
|||||||
PostID string `json:"post_id" gorm:"type:varchar(36);index;not null"`
|
PostID string `json:"post_id" gorm:"type:varchar(36);index;not null"`
|
||||||
URL string `json:"url" gorm:"type:text;not null"`
|
URL string `json:"url" gorm:"type:text;not null"`
|
||||||
ThumbnailURL string `json:"thumbnail_url" gorm:"type:text"`
|
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"`
|
Width int `json:"width" gorm:"default:0"`
|
||||||
Height int `json:"height" gorm:"default:0"`
|
Height int `json:"height" gorm:"default:0"`
|
||||||
Size int64 `json:"size" gorm:"default:0"` // 文件大小(字节)
|
Size int64 `json:"size" gorm:"default:0"` // 文件大小(字节)
|
||||||
|
|||||||
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 (
|
import (
|
||||||
"carrot_bbs/internal/model"
|
"carrot_bbs/internal/model"
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -35,10 +36,23 @@ func (r *PostRepository) Create(post *model.Post, images []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 创建图片记录
|
// 创建图片记录
|
||||||
for i, url := range images {
|
// 支持格式:["url", "url|preview_url|preview_url_large", ...]
|
||||||
|
for i, imageData := range images {
|
||||||
|
parts := strings.Split(imageData, "|")
|
||||||
|
url := parts[0]
|
||||||
|
var previewURL, previewURLLarge string
|
||||||
|
if len(parts) >= 2 && parts[1] != "" {
|
||||||
|
previewURL = parts[1]
|
||||||
|
}
|
||||||
|
if len(parts) >= 3 && parts[2] != "" {
|
||||||
|
previewURLLarge = parts[2]
|
||||||
|
}
|
||||||
|
|
||||||
image := &model.PostImage{
|
image := &model.PostImage{
|
||||||
PostID: post.ID,
|
PostID: post.ID,
|
||||||
URL: url,
|
URL: url,
|
||||||
|
PreviewURL: previewURL,
|
||||||
|
PreviewURLLarge: previewURLLarge,
|
||||||
SortOrder: i,
|
SortOrder: i,
|
||||||
}
|
}
|
||||||
if err := tx.Create(image).Error; err != nil {
|
if err := tx.Create(image).Error; err != nil {
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ type Router struct {
|
|||||||
adminCommentHandler *handler.AdminCommentHandler
|
adminCommentHandler *handler.AdminCommentHandler
|
||||||
adminGroupHandler *handler.AdminGroupHandler
|
adminGroupHandler *handler.AdminGroupHandler
|
||||||
adminDashboardHandler *handler.AdminDashboardHandler
|
adminDashboardHandler *handler.AdminDashboardHandler
|
||||||
|
adminLogHandler *handler.AdminLogHandler
|
||||||
|
logService *service.LogService
|
||||||
jwtService *service.JWTService
|
jwtService *service.JWTService
|
||||||
casbinService service.CasbinService
|
casbinService service.CasbinService
|
||||||
}
|
}
|
||||||
@@ -57,6 +59,8 @@ func New(
|
|||||||
adminCommentHandler *handler.AdminCommentHandler,
|
adminCommentHandler *handler.AdminCommentHandler,
|
||||||
adminGroupHandler *handler.AdminGroupHandler,
|
adminGroupHandler *handler.AdminGroupHandler,
|
||||||
adminDashboardHandler *handler.AdminDashboardHandler,
|
adminDashboardHandler *handler.AdminDashboardHandler,
|
||||||
|
adminLogHandler *handler.AdminLogHandler,
|
||||||
|
logService *service.LogService,
|
||||||
activityService service.UserActivityService,
|
activityService service.UserActivityService,
|
||||||
casbinService service.CasbinService,
|
casbinService service.CasbinService,
|
||||||
) *Router {
|
) *Router {
|
||||||
@@ -86,6 +90,8 @@ func New(
|
|||||||
adminCommentHandler: adminCommentHandler,
|
adminCommentHandler: adminCommentHandler,
|
||||||
adminGroupHandler: adminGroupHandler,
|
adminGroupHandler: adminGroupHandler,
|
||||||
adminDashboardHandler: adminDashboardHandler,
|
adminDashboardHandler: adminDashboardHandler,
|
||||||
|
adminLogHandler: adminLogHandler,
|
||||||
|
logService: logService,
|
||||||
jwtService: jwtService,
|
jwtService: jwtService,
|
||||||
casbinService: casbinService,
|
casbinService: casbinService,
|
||||||
}
|
}
|
||||||
@@ -104,6 +110,7 @@ func (r *Router) setupRoutes() {
|
|||||||
c.JSON(200, gin.H{"status": "ok"})
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
// API v1
|
// API v1
|
||||||
v1 := r.engine.Group("/api/v1")
|
v1 := r.engine.Group("/api/v1")
|
||||||
{
|
{
|
||||||
@@ -115,11 +122,32 @@ func (r *Router) setupRoutes() {
|
|||||||
auth.POST("/login", r.userHandler.Login)
|
auth.POST("/login", r.userHandler.Login)
|
||||||
auth.POST("/password/send-code", r.userHandler.SendPasswordResetCode)
|
auth.POST("/password/send-code", r.userHandler.SendPasswordResetCode)
|
||||||
auth.POST("/password/reset", r.userHandler.ResetPassword)
|
auth.POST("/password/reset", r.userHandler.ResetPassword)
|
||||||
|
auth.POST("/logout", r.userHandler.Logout)
|
||||||
auth.POST("/refresh", r.userHandler.RefreshToken)
|
auth.POST("/refresh", r.userHandler.RefreshToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 需要认证的路由
|
// 需要认证的路由
|
||||||
authMiddleware := middleware.Auth(r.jwtService)
|
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 权限中间件(暂不全局启用,可根据需要在特定路由组上使用)
|
// Casbin 权限中间件(暂不全局启用,可根据需要在特定路由组上使用)
|
||||||
// casbinMiddleware := middleware.CasbinAuth(r.casbinService)
|
// casbinMiddleware := middleware.CasbinAuth(r.casbinService)
|
||||||
|
|
||||||
@@ -435,6 +463,17 @@ func (r *Router) setupRoutes() {
|
|||||||
admin.GET("/dashboard/pending-content", r.adminDashboardHandler.GetPendingContent)
|
admin.GET("/dashboard/pending-content", r.adminDashboardHandler.GetPendingContent)
|
||||||
admin.GET("/dashboard/online-users", r.adminDashboardHandler.GetOnlineUsers)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,12 +27,16 @@ type AdminPostService interface {
|
|||||||
SetPostPin(ctx context.Context, postID string, isPinned bool) (*dto.AdminPostDetailResponse, error)
|
SetPostPin(ctx context.Context, postID string, isPinned bool) (*dto.AdminPostDetailResponse, error)
|
||||||
// SetPostFeature 加精/取消加精帖子
|
// SetPostFeature 加精/取消加精帖子
|
||||||
SetPostFeature(ctx context.Context, postID string, isFeatured bool) (*dto.AdminPostDetailResponse, error)
|
SetPostFeature(ctx context.Context, postID string, isFeatured bool) (*dto.AdminPostDetailResponse, error)
|
||||||
|
|
||||||
|
// 日志服务设置
|
||||||
|
SetLogService(logService *LogService)
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminPostServiceImpl 管理端帖子服务实现
|
// adminPostServiceImpl 管理端帖子服务实现
|
||||||
type adminPostServiceImpl struct {
|
type adminPostServiceImpl struct {
|
||||||
postRepo *repository.PostRepository
|
postRepo *repository.PostRepository
|
||||||
cache cache.Cache
|
cache cache.Cache
|
||||||
|
logService *LogService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAdminPostService 创建管理端帖子服务
|
// NewAdminPostService 创建管理端帖子服务
|
||||||
@@ -40,9 +44,15 @@ func NewAdminPostService(postRepo *repository.PostRepository, cacheBackend cache
|
|||||||
return &adminPostServiceImpl{
|
return &adminPostServiceImpl{
|
||||||
postRepo: postRepo,
|
postRepo: postRepo,
|
||||||
cache: cacheBackend,
|
cache: cacheBackend,
|
||||||
|
logService: nil,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLogService 设置日志服务
|
||||||
|
func (s *adminPostServiceImpl) SetLogService(logService *LogService) {
|
||||||
|
s.logService = logService
|
||||||
|
}
|
||||||
|
|
||||||
// GetPostList 获取帖子列表
|
// GetPostList 获取帖子列表
|
||||||
func (s *adminPostServiceImpl) GetPostList(ctx context.Context, page, pageSize int, keyword, status, authorID, startDate, endDate string) ([]dto.AdminPostListResponse, int64, error) {
|
func (s *adminPostServiceImpl) GetPostList(ctx context.Context, page, pageSize int, keyword, status, authorID, startDate, endDate string) ([]dto.AdminPostListResponse, int64, error) {
|
||||||
query := repository.AdminPostListQuery{
|
query := repository.AdminPostListQuery{
|
||||||
@@ -80,7 +90,12 @@ func (s *adminPostServiceImpl) GetPostDetail(ctx context.Context, postID string)
|
|||||||
|
|
||||||
// DeletePost 删除帖子
|
// DeletePost 删除帖子
|
||||||
func (s *adminPostServiceImpl) DeletePost(ctx context.Context, postID string) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -89,6 +104,18 @@ func (s *adminPostServiceImpl) DeletePost(ctx context.Context, postID string) er
|
|||||||
cache.InvalidatePostDetail(s.cache, postID)
|
cache.InvalidatePostDetail(s.cache, postID)
|
||||||
cache.InvalidatePostList(s.cache)
|
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
|
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
|
cache cache.Cache
|
||||||
gorseClient gorse.Client
|
gorseClient gorse.Client
|
||||||
postAIService *PostAIService
|
postAIService *PostAIService
|
||||||
|
logService *LogService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCommentService 创建评论服务
|
// NewCommentService 创建评论服务
|
||||||
@@ -32,9 +33,15 @@ func NewCommentService(commentRepo *repository.CommentRepository, postRepo *repo
|
|||||||
cache: cacheBackend,
|
cache: cacheBackend,
|
||||||
gorseClient: gorseClient,
|
gorseClient: gorseClient,
|
||||||
postAIService: postAIService,
|
postAIService: postAIService,
|
||||||
|
logService: nil,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLogService 设置日志服务
|
||||||
|
func (s *CommentService) SetLogService(logService *LogService) {
|
||||||
|
s.logService = logService
|
||||||
|
}
|
||||||
|
|
||||||
// Create 创建评论
|
// Create 创建评论
|
||||||
func (s *CommentService) Create(ctx context.Context, postID, userID, content string, parentID *string, images string, imageURLs []string) (*model.Comment, error) {
|
func (s *CommentService) Create(ctx context.Context, postID, userID, content string, parentID *string, images string, imageURLs []string) (*model.Comment, error) {
|
||||||
if s.postAIService != nil {
|
if s.postAIService != nil {
|
||||||
@@ -75,6 +82,18 @@ func (s *CommentService) Create(ctx context.Context, postID, userID, content str
|
|||||||
return nil, err
|
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
|
// 重新查询以获取关联的 User
|
||||||
comment, err = s.commentRepo.GetByID(comment.ID)
|
comment, err = s.commentRepo.GetByID(comment.ID)
|
||||||
if err != nil {
|
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
|
IncrementViews(ctx context.Context, postID, userID string) error
|
||||||
|
|
||||||
|
// 日志服务设置
|
||||||
|
SetLogService(logService *LogService)
|
||||||
}
|
}
|
||||||
|
|
||||||
// postServiceImpl 帖子服务实现
|
// postServiceImpl 帖子服务实现
|
||||||
@@ -68,7 +71,8 @@ type postServiceImpl struct {
|
|||||||
cache cache.Cache
|
cache cache.Cache
|
||||||
gorseClient gorse.Client
|
gorseClient gorse.Client
|
||||||
postAIService *PostAIService
|
postAIService *PostAIService
|
||||||
txManager repository.TransactionManager // 事务管理器
|
txManager repository.TransactionManager
|
||||||
|
logService *LogService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPostService 创建帖子服务
|
// NewPostService 创建帖子服务
|
||||||
@@ -80,9 +84,15 @@ func NewPostService(postRepo *repository.PostRepository, systemMessageService Sy
|
|||||||
gorseClient: gorseClient,
|
gorseClient: gorseClient,
|
||||||
postAIService: postAIService,
|
postAIService: postAIService,
|
||||||
txManager: txManager,
|
txManager: txManager,
|
||||||
|
logService: nil,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLogService 设置日志服务
|
||||||
|
func (s *postServiceImpl) SetLogService(logService *LogService) {
|
||||||
|
s.logService = logService
|
||||||
|
}
|
||||||
|
|
||||||
// PostListResult 帖子列表缓存结果
|
// PostListResult 帖子列表缓存结果
|
||||||
type PostListResult struct {
|
type PostListResult struct {
|
||||||
Posts []*model.Post
|
Posts []*model.Post
|
||||||
@@ -103,6 +113,18 @@ func (s *postServiceImpl) Create(ctx context.Context, userID, title, content str
|
|||||||
return nil, err
|
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)
|
cache.InvalidatePostList(s.cache)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"mime"
|
"mime"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"carrot_bbs/internal/pkg/s3"
|
"carrot_bbs/internal/pkg/s3"
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
|
|
||||||
_ "golang.org/x/image/bmp"
|
_ "golang.org/x/image/bmp"
|
||||||
_ "golang.org/x/image/tiff"
|
_ "golang.org/x/image/tiff"
|
||||||
@@ -27,6 +29,13 @@ type UploadService struct {
|
|||||||
userService UserService
|
userService UserService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 预览图配置
|
||||||
|
const (
|
||||||
|
PreviewMaxWidth = 300 // 列表/网格模式最大宽度
|
||||||
|
PreviewMaxHeight = 800 // 详情页最大高度
|
||||||
|
PreviewQuality = 70 // WebP 质量
|
||||||
|
)
|
||||||
|
|
||||||
// NewUploadService 创建上传服务
|
// NewUploadService 创建上传服务
|
||||||
func NewUploadService(s3Client *s3.Client, userService UserService) *UploadService {
|
func NewUploadService(s3Client *s3.Client, userService UserService) *UploadService {
|
||||||
return &UploadService{
|
return &UploadService{
|
||||||
@@ -35,25 +44,31 @@ func NewUploadService(s3Client *s3.Client, userService UserService) *UploadServi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadImage 上传图片
|
// UploadImage 上传图片(返回原图URL和预览图URL)
|
||||||
func (s *UploadService) UploadImage(ctx context.Context, file *multipart.FileHeader) (string, error) {
|
func (s *UploadService) UploadImage(ctx context.Context, file *multipart.FileHeader) (string, string, string, error) {
|
||||||
processedData, contentType, ext, err := prepareImageForUpload(file)
|
processedData, contentType, ext, err := prepareImageForUpload(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 压缩后再计算哈希,确保同一压缩结果映射同一对象名
|
// 压缩后再计算哈希,确保同一压缩结果映射同一对象名
|
||||||
hash := sha256.Sum256(processedData)
|
hash := sha256.Sum256(processedData)
|
||||||
hashStr := fmt.Sprintf("%x", hash)
|
hashStr := fmt.Sprintf("%x", hash)
|
||||||
|
|
||||||
|
// 上传原图
|
||||||
objectName := fmt.Sprintf("images/%s%s", hashStr, ext)
|
objectName := fmt.Sprintf("images/%s%s", hashStr, ext)
|
||||||
|
|
||||||
url, err := s.s3Client.UploadData(ctx, objectName, processedData, contentType)
|
url, err := s.s3Client.UploadData(ctx, objectName, processedData, contentType)
|
||||||
if err != nil {
|
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获取文件扩展名
|
// getExtFromContentType 根据Content-Type获取文件扩展名
|
||||||
@@ -272,3 +287,73 @@ func normalizeImageContentType(contentType string) string {
|
|||||||
return contentType
|
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)
|
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)
|
GetFollowing(ctx context.Context, userID string, page, pageSize int) ([]*model.User, int64, error)
|
||||||
GetFollowingList(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, 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
|
FollowUser(ctx context.Context, followerID, followeeID string) error
|
||||||
UnfollowUser(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)
|
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
|
UnblockUser(ctx context.Context, blockerID, blockedID string) error
|
||||||
GetBlockedUsers(ctx context.Context, blockerID string, page, pageSize int) ([]*model.User, int64, error)
|
GetBlockedUsers(ctx context.Context, blockerID string, page, pageSize int) ([]*model.User, int64, error)
|
||||||
IsBlocked(ctx context.Context, blockerID, blockedID string) (bool, error)
|
IsBlocked(ctx context.Context, blockerID, blockedID string) (bool, error)
|
||||||
|
|
||||||
|
// 日志服务设置
|
||||||
|
SetLogService(logService *LogService)
|
||||||
}
|
}
|
||||||
|
|
||||||
// userServiceImpl 用户服务实现
|
// userServiceImpl 用户服务实现
|
||||||
@@ -62,6 +65,7 @@ type userServiceImpl struct {
|
|||||||
userRepo *repository.UserRepository
|
userRepo *repository.UserRepository
|
||||||
systemMessageService SystemMessageService
|
systemMessageService SystemMessageService
|
||||||
emailCodeService EmailCodeService
|
emailCodeService EmailCodeService
|
||||||
|
logService *LogService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserService 创建用户服务
|
// NewUserService 创建用户服务
|
||||||
@@ -75,9 +79,15 @@ func NewUserService(
|
|||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
systemMessageService: systemMessageService,
|
systemMessageService: systemMessageService,
|
||||||
emailCodeService: NewEmailCodeService(emailService, cacheBackend),
|
emailCodeService: NewEmailCodeService(emailService, cacheBackend),
|
||||||
|
logService: nil,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLogService 设置日志服务
|
||||||
|
func (s *userServiceImpl) SetLogService(logService *LogService) {
|
||||||
|
s.logService = logService
|
||||||
|
}
|
||||||
|
|
||||||
// SendRegisterCode 发送注册验证码
|
// SendRegisterCode 发送注册验证码
|
||||||
func (s *userServiceImpl) SendRegisterCode(ctx context.Context, email string) error {
|
func (s *userServiceImpl) SendRegisterCode(ctx context.Context, email string) error {
|
||||||
user, err := s.userRepo.GetByEmail(email)
|
user, err := s.userRepo.GetByEmail(email)
|
||||||
@@ -241,12 +251,28 @@ func (s *userServiceImpl) Register(ctx context.Context, username, email, passwor
|
|||||||
return nil, err
|
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
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login 用户登录
|
// Login 用户登录
|
||||||
func (s *userServiceImpl) Login(ctx context.Context, account, password string) (*model.User, error) {
|
func (s *userServiceImpl) Login(ctx context.Context, account, password string) (*model.User, error) {
|
||||||
account = strings.TrimSpace(account)
|
account = strings.TrimSpace(account)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
user *model.User
|
user *model.User
|
||||||
err error
|
err error
|
||||||
@@ -258,18 +284,86 @@ func (s *userServiceImpl) Login(ctx context.Context, account, password string) (
|
|||||||
} else {
|
} else {
|
||||||
user, err = s.userRepo.GetByUsername(account)
|
user, err = s.userRepo.GetByUsername(account)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 登录失败处理
|
||||||
|
loginResult := string(model.LoginResultSuccess)
|
||||||
|
var failReason string
|
||||||
|
|
||||||
if err != nil || user == nil {
|
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
|
return nil, ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
if !utils.CheckPasswordHash(password, user.PasswordHash) {
|
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
|
return nil, ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Status != model.UserStatusActive {
|
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
|
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
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +431,27 @@ func (s *userServiceImpl) GetUserByIDWithMutualFollowStatus(ctx context.Context,
|
|||||||
|
|
||||||
// UpdateUser 更新用户
|
// UpdateUser 更新用户
|
||||||
func (s *userServiceImpl) UpdateUser(ctx context.Context, user *model.User) error {
|
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 获取粉丝
|
// GetFollowers 获取粉丝
|
||||||
@@ -542,7 +656,26 @@ func (s *userServiceImpl) ChangePassword(ctx context.Context, userID, oldPasswor
|
|||||||
|
|
||||||
// 更新密码
|
// 更新密码
|
||||||
user.PasswordHash = hashedPassword
|
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 通过邮箱重置密码
|
// ResetPasswordByEmail 通过邮箱重置密码
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ var HandlerSet = wire.NewSet(
|
|||||||
handler.NewAdminCommentHandler,
|
handler.NewAdminCommentHandler,
|
||||||
handler.NewAdminGroupHandler,
|
handler.NewAdminGroupHandler,
|
||||||
handler.NewAdminDashboardHandler,
|
handler.NewAdminDashboardHandler,
|
||||||
|
handler.NewAdminLogHandler,
|
||||||
|
|
||||||
// 需要特殊处理的 Handler
|
// 需要特殊处理的 Handler
|
||||||
ProvideUserHandler,
|
ProvideUserHandler,
|
||||||
@@ -42,16 +43,47 @@ var HandlerSet = wire.NewSet(
|
|||||||
func ProvideUserHandler(
|
func ProvideUserHandler(
|
||||||
userService service.UserService,
|
userService service.UserService,
|
||||||
activityService service.UserActivityService,
|
activityService service.UserActivityService,
|
||||||
|
postService service.PostService,
|
||||||
|
commentService *service.CommentService,
|
||||||
|
logService *service.LogService,
|
||||||
) *handler.UserHandler {
|
) *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 提供帖子处理器
|
// ProvidePostHandler 提供帖子处理器
|
||||||
func ProvidePostHandler(
|
func ProvidePostHandler(
|
||||||
postService service.PostService,
|
postService service.PostService,
|
||||||
userService service.UserService,
|
userService service.UserService,
|
||||||
|
logService *service.LogService,
|
||||||
) *handler.PostHandler {
|
) *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 提供消息处理器
|
// ProvideMessageHandler 提供消息处理器
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ var RepositorySet = wire.NewSet(
|
|||||||
repository.NewVoteRepository,
|
repository.NewVoteRepository,
|
||||||
repository.NewScheduleRepository,
|
repository.NewScheduleRepository,
|
||||||
ProvideUserActivityRepository,
|
ProvideUserActivityRepository,
|
||||||
|
|
||||||
|
// 日志相关仓储
|
||||||
|
repository.NewOperationLogRepository,
|
||||||
|
repository.NewLoginLogRepository,
|
||||||
|
repository.NewDataChangeLogRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProvideUserActivityRepository 提供用户活跃数据仓储
|
// ProvideUserActivityRepository 提供用户活跃数据仓储
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package wire
|
package wire
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"carrot_bbs/internal/cache"
|
"carrot_bbs/internal/cache"
|
||||||
"carrot_bbs/internal/config"
|
"carrot_bbs/internal/config"
|
||||||
"carrot_bbs/internal/grpc/runner"
|
"carrot_bbs/internal/grpc/runner"
|
||||||
@@ -48,6 +50,14 @@ var ServiceSet = wire.NewSet(
|
|||||||
ProvideAdminCommentService,
|
ProvideAdminCommentService,
|
||||||
ProvideAdminGroupService,
|
ProvideAdminGroupService,
|
||||||
ProvideAdminDashboardService,
|
ProvideAdminDashboardService,
|
||||||
|
|
||||||
|
// 日志服务
|
||||||
|
ProvideAsyncLogManager,
|
||||||
|
ProvideOperationLogService,
|
||||||
|
ProvideLoginLogService,
|
||||||
|
ProvideDataChangeLogService,
|
||||||
|
ProvideLogCleanupService,
|
||||||
|
ProvideLogService,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProvideJWTService 提供 JWT 服务
|
// ProvideJWTService 提供 JWT 服务
|
||||||
@@ -254,3 +264,86 @@ func ProvideAdminDashboardService(
|
|||||||
) service.AdminDashboardService {
|
) service.AdminDashboardService {
|
||||||
return service.NewAdminDashboardService(db, userRepo, postRepo, commentRepo, groupRepo, activityRepo)
|
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