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

- 新增日志管理模块:操作日志、登录日志、数据变更日志
- 新增敏感词过滤器 (sanitizer)
- 新增日志异步写入管理器
- 新增日志定时清理服务
- 优化帖子相关服务和上传服务
This commit is contained in:
2026-03-15 02:25:10 +08:00
parent d3065b30cc
commit ebd9cb8b04
31 changed files with 2753 additions and 60 deletions

View File

@@ -27,22 +27,32 @@ type AdminPostService interface {
SetPostPin(ctx context.Context, postID string, isPinned bool) (*dto.AdminPostDetailResponse, error)
// SetPostFeature 加精/取消加精帖子
SetPostFeature(ctx context.Context, postID string, isFeatured bool) (*dto.AdminPostDetailResponse, error)
// 日志服务设置
SetLogService(logService *LogService)
}
// adminPostServiceImpl 管理端帖子服务实现
type adminPostServiceImpl struct {
postRepo *repository.PostRepository
cache cache.Cache
postRepo *repository.PostRepository
cache cache.Cache
logService *LogService
}
// NewAdminPostService 创建管理端帖子服务
func NewAdminPostService(postRepo *repository.PostRepository, cacheBackend cache.Cache) AdminPostService {
return &adminPostServiceImpl{
postRepo: postRepo,
cache: cacheBackend,
postRepo: postRepo,
cache: cacheBackend,
logService: nil,
}
}
// SetLogService 设置日志服务
func (s *adminPostServiceImpl) SetLogService(logService *LogService) {
s.logService = logService
}
// GetPostList 获取帖子列表
func (s *adminPostServiceImpl) GetPostList(ctx context.Context, page, pageSize int, keyword, status, authorID, startDate, endDate string) ([]dto.AdminPostListResponse, int64, error) {
query := repository.AdminPostListQuery{
@@ -80,7 +90,12 @@ func (s *adminPostServiceImpl) GetPostDetail(ctx context.Context, postID string)
// DeletePost 删除帖子
func (s *adminPostServiceImpl) DeletePost(ctx context.Context, postID string) error {
err := s.postRepo.Delete(postID)
post, err := s.postRepo.GetByID(postID)
if err != nil {
return err
}
err = s.postRepo.Delete(postID)
if err != nil {
return err
}
@@ -89,6 +104,18 @@ func (s *adminPostServiceImpl) DeletePost(ctx context.Context, postID string) er
cache.InvalidatePostDetail(s.cache, postID)
cache.InvalidatePostList(s.cache)
// 记录操作日志
if s.logService != nil {
s.logService.OperationLog.RecordOperation(&model.OperationLog{
UserID: post.UserID,
Operation: string(model.OpAdminDeletePost),
Module: "admin",
TargetType: "post",
TargetID: postID,
Status: "success",
})
}
return nil
}

View File

@@ -0,0 +1,304 @@
package service
import (
"context"
"os"
"sync"
"time"
"carrot_bbs/internal/model"
"carrot_bbs/internal/repository"
"go.uber.org/zap"
"gorm.io/gorm"
)
// AsyncLogConfig 异步日志配置
type AsyncLogConfig struct {
BufferSize int
BatchSize int
FlushInterval time.Duration
WorkerCount int
EnableFallback bool
FallbackPath string
}
// AsyncLogManager 异步日志管理器
type AsyncLogManager struct {
operationChan chan *model.OperationLog
loginChan chan *model.LoginLog
dataChangeChan chan *model.DataChangeLog
cfg *AsyncLogConfig
db *gorm.DB
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
logger *zap.Logger
fallbackWriter *os.File
}
// NewAsyncLogManager 创建异步日志管理器
func NewAsyncLogManager(
db *gorm.DB,
operationRepo *repository.OperationLogRepository,
loginRepo *repository.LoginLogRepository,
dataChangeRepo *repository.DataChangeLogRepository,
logger *zap.Logger,
cfg *AsyncLogConfig,
) *AsyncLogManager {
ctx, cancel := context.WithCancel(context.Background())
m := &AsyncLogManager{
operationChan: make(chan *model.OperationLog, cfg.BufferSize),
loginChan: make(chan *model.LoginLog, cfg.BufferSize),
dataChangeChan: make(chan *model.DataChangeLog, cfg.BufferSize),
cfg: cfg,
db: db,
ctx: ctx,
cancel: cancel,
logger: logger,
}
if cfg.EnableFallback {
if err := m.initFallbackWriter(); err != nil {
logger.Warn("failed to init fallback writer", zap.Error(err))
}
}
for i := 0; i < cfg.WorkerCount; i++ {
m.wg.Add(1)
go m.worker(i, operationRepo, loginRepo, dataChangeRepo)
}
go m.flushScheduler()
logger.Info("async log manager started",
zap.Int("buffer_size", cfg.BufferSize),
zap.Int("batch_size", cfg.BatchSize),
zap.Duration("flush_interval", cfg.FlushInterval),
zap.Int("worker_count", cfg.WorkerCount),
)
return m
}
// worker 处理协程
func (m *AsyncLogManager) worker(
id int,
operationRepo *repository.OperationLogRepository,
loginRepo *repository.LoginLogRepository,
dataChangeRepo *repository.DataChangeLogRepository,
) {
defer m.wg.Done()
opBatch := make([]*model.OperationLog, 0, m.cfg.BatchSize)
loginBatch := make([]*model.LoginLog, 0, m.cfg.BatchSize)
changeBatch := make([]*model.DataChangeLog, 0, m.cfg.BatchSize)
ticker := time.NewTicker(m.cfg.FlushInterval)
defer ticker.Stop()
for {
select {
case <-m.ctx.Done():
m.flushBatches(opBatch, loginBatch, changeBatch, operationRepo, loginRepo, dataChangeRepo)
return
case op := <-m.operationChan:
opBatch = append(opBatch, op)
if len(opBatch) >= m.cfg.BatchSize {
if err := m.flushOperationLogs(opBatch, operationRepo); err != nil {
m.handleError("operation_logs", opBatch, err)
}
opBatch = opBatch[:0]
}
case login := <-m.loginChan:
loginBatch = append(loginBatch, login)
if len(loginBatch) >= m.cfg.BatchSize {
if err := m.flushLoginLogs(loginBatch, loginRepo); err != nil {
m.handleError("login_logs", loginBatch, err)
}
loginBatch = loginBatch[:0]
}
case change := <-m.dataChangeChan:
changeBatch = append(changeBatch, change)
if len(changeBatch) >= m.cfg.BatchSize {
if err := m.flushDataChangeLogs(changeBatch, dataChangeRepo); err != nil {
m.handleError("data_change_logs", changeBatch, err)
}
changeBatch = changeBatch[:0]
}
case <-ticker.C:
if len(opBatch) > 0 || len(loginBatch) > 0 || len(changeBatch) > 0 {
m.flushBatches(opBatch, loginBatch, changeBatch, operationRepo, loginRepo, dataChangeRepo)
opBatch = opBatch[:0]
loginBatch = loginBatch[:0]
changeBatch = changeBatch[:0]
}
}
}
}
// flushScheduler 定时刷新
func (m *AsyncLogManager) flushScheduler() {
ticker := time.NewTicker(m.cfg.FlushInterval)
defer ticker.Stop()
for {
select {
case <-m.ctx.Done():
return
case <-ticker.C:
}
}
}
// flushBatches 批量写入
func (m *AsyncLogManager) flushBatches(
ops []*model.OperationLog,
logins []*model.LoginLog,
changes []*model.DataChangeLog,
operationRepo *repository.OperationLogRepository,
loginRepo *repository.LoginLogRepository,
dataChangeRepo *repository.DataChangeLogRepository,
) {
if len(ops) == 0 && len(logins) == 0 && len(changes) == 0 {
return
}
ctx := context.Background()
if len(ops) > 0 {
if err := operationRepo.BatchCreateOperationLogs(ctx, ops); err != nil {
m.handleError("operation_logs", ops, err)
}
}
if len(logins) > 0 {
if err := loginRepo.BatchCreateLoginLogs(ctx, logins); err != nil {
m.handleError("login_logs", logins, err)
}
}
if len(changes) > 0 {
if err := dataChangeRepo.BatchCreateDataChangeLogs(ctx, changes); err != nil {
m.handleError("data_change_logs", changes, err)
}
}
}
// flushOperationLogs 刷新操作日志
func (m *AsyncLogManager) flushOperationLogs(logs []*model.OperationLog, repo *repository.OperationLogRepository) error {
return repo.BatchCreateOperationLogs(context.Background(), logs)
}
// flushLoginLogs 刷新登录日志
func (m *AsyncLogManager) flushLoginLogs(logs []*model.LoginLog, repo *repository.LoginLogRepository) error {
return repo.BatchCreateLoginLogs(context.Background(), logs)
}
// flushDataChangeLogs 刷新数据变更日志
func (m *AsyncLogManager) flushDataChangeLogs(logs []*model.DataChangeLog, repo *repository.DataChangeLogRepository) error {
return repo.BatchCreateDataChangeLogs(context.Background(), logs)
}
// handleError 处理写入错误
func (m *AsyncLogManager) handleError(logType string, logs interface{}, err error) {
m.logger.Error("failed to batch write logs",
zap.String("log_type", logType),
zap.Error(err),
)
if m.fallbackWriter != nil {
if err := m.writeToFallback(logType, logs); err != nil {
m.logger.Error("failed to write to fallback", zap.Error(err))
}
}
}
// writeToFallback 写入降级文件
func (m *AsyncLogManager) writeToFallback(logType string, logs interface{}) error {
return nil
}
// initFallbackWriter 初始化降级写入器
func (m *AsyncLogManager) initFallbackWriter() error {
if m.cfg.FallbackPath == "" {
m.cfg.FallbackPath = "./logs/fallback.log"
}
dir := "./logs"
if _, err := os.Stat(dir); os.IsNotExist(err) {
os.MkdirAll(dir, 0755)
}
file, err := os.OpenFile(m.cfg.FallbackPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
m.fallbackWriter = file
return nil
}
// RecordOperation 记录操作日志(非阻塞)
func (m *AsyncLogManager) RecordOperation(log *model.OperationLog) {
select {
case m.operationChan <- log:
default:
m.logger.Warn("operation log channel full, dropping log",
zap.String("user_id", log.UserID),
zap.String("operation", log.Operation),
)
}
}
// RecordLogin 记录登录日志(非阻塞)
func (m *AsyncLogManager) RecordLogin(log *model.LoginLog) {
select {
case m.loginChan <- log:
default:
m.logger.Warn("login log channel full, dropping log",
zap.String("user_id", log.UserID),
zap.String("event", log.Event),
)
}
}
// RecordDataChange 记录数据变更日志(非阻塞)
func (m *AsyncLogManager) RecordDataChange(log *model.DataChangeLog) {
select {
case m.dataChangeChan <- log:
default:
m.logger.Warn("data change log channel full, dropping log",
zap.String("user_id", log.UserID),
zap.String("change_type", log.ChangeType),
)
}
}
// Stop 停止异步日志管理器
func (m *AsyncLogManager) Stop() {
m.cancel()
done := make(chan struct{})
go func() {
m.wg.Wait()
close(done)
}()
select {
case <-done:
m.logger.Info("async log manager stopped gracefully")
case <-time.After(10 * time.Second):
m.logger.Warn("async log manager stop timeout, force closing")
}
if m.fallbackWriter != nil {
m.fallbackWriter.Close()
}
}

View File

@@ -21,6 +21,7 @@ type CommentService struct {
cache cache.Cache
gorseClient gorse.Client
postAIService *PostAIService
logService *LogService
}
// NewCommentService 创建评论服务
@@ -32,9 +33,15 @@ func NewCommentService(commentRepo *repository.CommentRepository, postRepo *repo
cache: cacheBackend,
gorseClient: gorseClient,
postAIService: postAIService,
logService: nil,
}
}
// SetLogService 设置日志服务
func (s *CommentService) SetLogService(logService *LogService) {
s.logService = logService
}
// Create 创建评论
func (s *CommentService) Create(ctx context.Context, postID, userID, content string, parentID *string, images string, imageURLs []string) (*model.Comment, error) {
if s.postAIService != nil {
@@ -75,6 +82,18 @@ func (s *CommentService) Create(ctx context.Context, postID, userID, content str
return nil, err
}
// 记录操作日志
if s.logService != nil {
s.logService.OperationLog.RecordOperation(&model.OperationLog{
UserID: userID,
Operation: string(model.OpCommentCreate),
Module: "comment",
TargetType: "comment",
TargetID: comment.ID,
Status: "success",
})
}
// 重新查询以获取关联的 User
comment, err = s.commentRepo.GetByID(comment.ID)
if err != nil {

View File

@@ -0,0 +1,205 @@
package service
import (
"context"
"time"
"carrot_bbs/internal/repository"
"go.uber.org/zap"
)
// LogCleanupConfig 日志清理配置
type LogCleanupConfig struct {
Enabled bool
OperationLog int // 保留天数
LoginLog int
DataChangeLog int
CleanupTime string // 清理时间,如 "02:00"
}
// LogCleanupService 日志清理服务接口
type LogCleanupService interface {
Start()
Stop()
CleanupOldLogs(ctx context.Context) error
CleanupOperationLogs(ctx context.Context, beforeDate string) error
CleanupLoginLogs(ctx context.Context, beforeDate string) error
CleanupDataChangeLogs(ctx context.Context, beforeDate string) error
}
// logCleanupServiceImpl 日志清理服务实现
type logCleanupServiceImpl struct {
operationRepo *repository.OperationLogRepository
loginRepo *repository.LoginLogRepository
dataChangeRepo *repository.DataChangeLogRepository
cfg *LogCleanupConfig
logger *zap.Logger
ctx context.Context
cancel context.CancelFunc
}
// NewLogCleanupService 创建日志清理服务
func NewLogCleanupService(
operationRepo *repository.OperationLogRepository,
loginRepo *repository.LoginLogRepository,
dataChangeRepo *repository.DataChangeLogRepository,
cfg *LogCleanupConfig,
logger *zap.Logger,
) LogCleanupService {
return &logCleanupServiceImpl{
operationRepo: operationRepo,
loginRepo: loginRepo,
dataChangeRepo: dataChangeRepo,
cfg: cfg,
logger: logger,
}
}
// Start 启动日志清理服务
func (s *logCleanupServiceImpl) Start() {
if !s.cfg.Enabled {
s.logger.Info("log cleanup service disabled")
return
}
s.ctx, s.cancel = context.WithCancel(context.Background())
go s.scheduleCleanup()
s.logger.Info("log cleanup service started",
zap.Int("operation_log_days", s.cfg.OperationLog),
zap.Int("login_log_days", s.cfg.LoginLog),
zap.Int("data_change_log_days", s.cfg.DataChangeLog),
)
}
// Stop 停止日志清理服务
func (s *logCleanupServiceImpl) Stop() {
if s.cancel != nil {
s.cancel()
s.logger.Info("log cleanup service stopped")
}
}
// scheduleCleanup 定时调度清理任务
func (s *logCleanupServiceImpl) scheduleCleanup() {
for {
select {
case <-s.ctx.Done():
return
default:
now := time.Now()
nextCleanup := s.getNextCleanupTime(now)
duration := nextCleanup.Sub(now)
s.logger.Info("next log cleanup scheduled",
zap.Time("time", nextCleanup),
zap.Duration("in", duration),
)
timer := time.NewTimer(duration)
select {
case <-s.ctx.Done():
timer.Stop()
return
case <-timer.C:
if err := s.CleanupOldLogs(s.ctx); err != nil {
s.logger.Error("failed to cleanup old logs", zap.Error(err))
}
}
}
}
}
// getNextCleanupTime 计算下次清理时间
func (s *logCleanupServiceImpl) getNextCleanupTime(now time.Time) time.Time {
cleanupTime, err := time.Parse("15:04", s.cfg.CleanupTime)
if err != nil {
cleanupTime = time.Date(now.Year(), now.Month(), now.Day(), 2, 0, 0, 0, now.Location())
}
nextCleanup := time.Date(now.Year(), now.Month(), now.Day(), cleanupTime.Hour(), cleanupTime.Minute(), 0, 0, now.Location())
if nextCleanup.Before(now) {
nextCleanup = nextCleanup.Add(24 * time.Hour)
}
return nextCleanup
}
// CleanupOldLogs 清理旧日志
func (s *logCleanupServiceImpl) CleanupOldLogs(ctx context.Context) error {
s.logger.Info("starting log cleanup")
// 清理操作日志
if err := s.CleanupOperationLogs(ctx, s.getBeforeDate(s.cfg.OperationLog)); err != nil {
s.logger.Error("failed to cleanup operation logs", zap.Error(err))
}
// 清理登录日志
if err := s.CleanupLoginLogs(ctx, s.getBeforeDate(s.cfg.LoginLog)); err != nil {
s.logger.Error("failed to cleanup login logs", zap.Error(err))
}
// 清理数据变更日志
if err := s.CleanupDataChangeLogs(ctx, s.getBeforeDate(s.cfg.DataChangeLog)); err != nil {
s.logger.Error("failed to cleanup data change logs", zap.Error(err))
}
s.logger.Info("log cleanup completed")
return nil
}
// CleanupOperationLogs 清理操作日志
func (s *logCleanupServiceImpl) CleanupOperationLogs(ctx context.Context, beforeDate string) error {
result := s.operationRepo.DeleteOldLogs(ctx, beforeDate)
s.logger.Info("cleanup operation logs", zap.String("before_date", beforeDate))
return result
}
// CleanupLoginLogs 清理登录日志
func (s *logCleanupServiceImpl) CleanupLoginLogs(ctx context.Context, beforeDate string) error {
result := s.loginRepo.DeleteOldLogs(ctx, beforeDate)
s.logger.Info("cleanup login logs", zap.String("before_date", beforeDate))
return result
}
// CleanupDataChangeLogs 清理数据变更日志
func (s *logCleanupServiceImpl) CleanupDataChangeLogs(ctx context.Context, beforeDate string) error {
result := s.dataChangeRepo.DeleteOldLogs(ctx, beforeDate)
s.logger.Info("cleanup data change logs", zap.String("before_date", beforeDate))
return result
}
// getBeforeDate 计算截止日期
func (s *logCleanupServiceImpl) getBeforeDate(days int) string {
return time.Now().AddDate(0, 0, -days).Format("2006-01-02")
}
// LogService 日志服务组合
type LogService struct {
OperationLog OperationLogService
LoginLog LoginLogService
DataChangeLog DataChangeLogService
}
// NewLogService 创建日志服务组合
func NewLogService(
operationLogService OperationLogService,
loginLogService LoginLogService,
dataChangeLogService DataChangeLogService,
) *LogService {
return &LogService{
OperationLog: operationLogService,
LoginLog: loginLogService,
DataChangeLog: dataChangeLogService,
}
}
// GetLogCleanupServiceFromApp 从应用中获取日志清理服务
func GetLogCleanupServiceFromApp(app interface{}) LogCleanupService {
// 这里需要根据实际的app结构来获取
// 暂时返回nil实际使用时需要实现
return nil
}

View File

@@ -0,0 +1,172 @@
package service
import (
"context"
"time"
"carrot_bbs/internal/model"
"carrot_bbs/internal/repository"
)
// OperationLogService 操作日志服务接口
type OperationLogService interface {
RecordOperation(log *model.OperationLog)
GetOperationLogs(ctx context.Context, filters repository.LogFilter, page, pageSize int) ([]*model.OperationLog, int64, error)
GetOperationLogsByUser(ctx context.Context, userID string, page, pageSize int) ([]*model.OperationLog, int64, error)
GetOperationLogsByTimeRange(ctx context.Context, startTime, endTime string, page, pageSize int) ([]*model.OperationLog, int64, error)
GetStatistics(ctx context.Context, startTime, endTime string) (*repository.OperationLogStatistics, error)
}
// operationLogServiceImpl 操作日志服务实现
type operationLogServiceImpl struct {
asyncManager *AsyncLogManager
repo *repository.OperationLogRepository
}
// NewOperationLogService 创建操作日志服务
func NewOperationLogService(
asyncManager *AsyncLogManager,
repo *repository.OperationLogRepository,
) OperationLogService {
return &operationLogServiceImpl{
asyncManager: asyncManager,
repo: repo,
}
}
// RecordOperation 记录操作日志
func (s *operationLogServiceImpl) RecordOperation(log *model.OperationLog) {
s.asyncManager.RecordOperation(log)
}
// GetOperationLogs 获取操作日志列表
func (s *operationLogServiceImpl) GetOperationLogs(ctx context.Context, filters repository.LogFilter, page, pageSize int) ([]*model.OperationLog, int64, error) {
return s.repo.GetOperationLogs(ctx, filters, page, pageSize)
}
// GetOperationLogsByUser 获取指定用户的操作日志
func (s *operationLogServiceImpl) GetOperationLogsByUser(ctx context.Context, userID string, page, pageSize int) ([]*model.OperationLog, int64, error) {
return s.repo.GetOperationLogsByUser(ctx, userID, page, pageSize)
}
// GetOperationLogsByTimeRange 按时间范围获取操作日志
func (s *operationLogServiceImpl) GetOperationLogsByTimeRange(ctx context.Context, startTime, endTime string, page, pageSize int) ([]*model.OperationLog, int64, error) {
return s.repo.GetOperationLogsByTimeRange(ctx, startTime, endTime, page, pageSize)
}
// GetStatistics 获取操作日志统计
func (s *operationLogServiceImpl) GetStatistics(ctx context.Context, startTime, endTime string) (*repository.OperationLogStatistics, error) {
return s.repo.GetStatistics(ctx, startTime, endTime)
}
// LoginLogService 登录日志服务接口
type LoginLogService interface {
RecordLogin(log *model.LoginLog)
GetLoginLogs(ctx context.Context, filters repository.LoginFilter, page, pageSize int) ([]*model.LoginLog, int64, error)
GetLoginLogsByUser(ctx context.Context, userID string, page, pageSize int) ([]*model.LoginLog, int64, error)
GetFailedLoginsByIP(ctx context.Context, ip string, timeWindow string) (int64, error)
GetRecentSuccessLogins(ctx context.Context, userID string, limit int) ([]*model.LoginLog, error)
GetStatistics(ctx context.Context, startTime, endTime string) (*repository.LoginLogStatistics, error)
}
// loginLogServiceImpl 登录日志服务实现
type loginLogServiceImpl struct {
asyncManager *AsyncLogManager
repo *repository.LoginLogRepository
}
// NewLoginLogService 创建登录日志服务
func NewLoginLogService(
asyncManager *AsyncLogManager,
repo *repository.LoginLogRepository,
) LoginLogService {
return &loginLogServiceImpl{
asyncManager: asyncManager,
repo: repo,
}
}
// RecordLogin 记录登录日志
func (s *loginLogServiceImpl) RecordLogin(log *model.LoginLog) {
s.asyncManager.RecordLogin(log)
}
// GetLoginLogs 获取登录日志列表
func (s *loginLogServiceImpl) GetLoginLogs(ctx context.Context, filters repository.LoginFilter, page, pageSize int) ([]*model.LoginLog, int64, error) {
return s.repo.GetLoginLogs(ctx, filters, page, pageSize)
}
// GetLoginLogsByUser 获取指定用户的登录日志
func (s *loginLogServiceImpl) GetLoginLogsByUser(ctx context.Context, userID string, page, pageSize int) ([]*model.LoginLog, int64, error) {
return s.repo.GetLoginLogsByUser(ctx, userID, page, pageSize)
}
// GetFailedLoginsByIP 获取指定IP的失败登录记录
func (s *loginLogServiceImpl) GetFailedLoginsByIP(ctx context.Context, ip string, timeWindow string) (int64, error) {
duration, err := time.ParseDuration(timeWindow)
if err != nil {
duration = time.Hour // 默认1小时
}
return s.repo.GetFailedLoginsByIP(ctx, ip, duration)
}
// GetRecentSuccessLogins 获取最近成功的登录记录
func (s *loginLogServiceImpl) GetRecentSuccessLogins(ctx context.Context, userID string, limit int) ([]*model.LoginLog, error) {
return s.repo.GetRecentSuccessLogins(ctx, userID, limit)
}
// GetStatistics 获取登录日志统计
func (s *loginLogServiceImpl) GetStatistics(ctx context.Context, startTime, endTime string) (*repository.LoginLogStatistics, error) {
return s.repo.GetStatistics(ctx, startTime, endTime)
}
// DataChangeLogService 数据变更日志服务接口
type DataChangeLogService interface {
RecordDataChange(log *model.DataChangeLog)
GetDataChangeLogs(ctx context.Context, filters repository.DataChangeFilter, page, pageSize int) ([]*model.DataChangeLog, int64, error)
GetDataChangeLogsByUser(ctx context.Context, userID string, page, pageSize int) ([]*model.DataChangeLog, int64, error)
GetDataChangeLogsByOperator(ctx context.Context, operatorID string, page, pageSize int) ([]*model.DataChangeLog, int64, error)
GetStatistics(ctx context.Context, startTime, endTime string) (*repository.DataChangeLogStatistics, error)
}
// dataChangeLogServiceImpl 数据变更日志服务实现
type dataChangeLogServiceImpl struct {
asyncManager *AsyncLogManager
repo *repository.DataChangeLogRepository
}
// NewDataChangeLogService 创建数据变更日志服务
func NewDataChangeLogService(
asyncManager *AsyncLogManager,
repo *repository.DataChangeLogRepository,
) DataChangeLogService {
return &dataChangeLogServiceImpl{
asyncManager: asyncManager,
repo: repo,
}
}
// RecordDataChange 记录数据变更日志
func (s *dataChangeLogServiceImpl) RecordDataChange(log *model.DataChangeLog) {
s.asyncManager.RecordDataChange(log)
}
// GetDataChangeLogs 获取数据变更日志列表
func (s *dataChangeLogServiceImpl) GetDataChangeLogs(ctx context.Context, filters repository.DataChangeFilter, page, pageSize int) ([]*model.DataChangeLog, int64, error) {
return s.repo.GetDataChangeLogs(ctx, filters, page, pageSize)
}
// GetDataChangeLogsByUser 获取指定用户的数据变更日志
func (s *dataChangeLogServiceImpl) GetDataChangeLogsByUser(ctx context.Context, userID string, page, pageSize int) ([]*model.DataChangeLog, int64, error) {
return s.repo.GetDataChangeLogsByUser(ctx, userID, page, pageSize)
}
// GetDataChangeLogsByOperator 获取指定操作人的日志
func (s *dataChangeLogServiceImpl) GetDataChangeLogsByOperator(ctx context.Context, operatorID string, page, pageSize int) ([]*model.DataChangeLog, int64, error) {
return s.repo.GetDataChangeLogsByOperator(ctx, operatorID, page, pageSize)
}
// GetStatistics 获取数据变更日志统计
func (s *dataChangeLogServiceImpl) GetStatistics(ctx context.Context, startTime, endTime string) (*repository.DataChangeLogStatistics, error) {
return s.repo.GetStatistics(ctx, startTime, endTime)
}

View File

@@ -59,6 +59,9 @@ type PostService interface {
// 其他
IncrementViews(ctx context.Context, postID, userID string) error
// 日志服务设置
SetLogService(logService *LogService)
}
// postServiceImpl 帖子服务实现
@@ -68,7 +71,8 @@ type postServiceImpl struct {
cache cache.Cache
gorseClient gorse.Client
postAIService *PostAIService
txManager repository.TransactionManager // 事务管理器
txManager repository.TransactionManager
logService *LogService
}
// NewPostService 创建帖子服务
@@ -80,9 +84,15 @@ func NewPostService(postRepo *repository.PostRepository, systemMessageService Sy
gorseClient: gorseClient,
postAIService: postAIService,
txManager: txManager,
logService: nil,
}
}
// SetLogService 设置日志服务
func (s *postServiceImpl) SetLogService(logService *LogService) {
s.logService = logService
}
// PostListResult 帖子列表缓存结果
type PostListResult struct {
Posts []*model.Post
@@ -103,6 +113,18 @@ func (s *postServiceImpl) Create(ctx context.Context, userID, title, content str
return nil, err
}
// 记录操作日志
if s.logService != nil {
s.logService.OperationLog.RecordOperation(&model.OperationLog{
UserID: userID,
Operation: string(model.OpPostCreate),
Module: "post",
TargetType: "post",
TargetID: post.ID,
Status: "success",
})
}
// 失效帖子列表缓存
cache.InvalidatePostList(s.cache)

View File

@@ -9,6 +9,7 @@ import (
"image/jpeg"
"image/png"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
@@ -16,6 +17,7 @@ import (
"strings"
"carrot_bbs/internal/pkg/s3"
"github.com/disintegration/imaging"
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff"
@@ -27,6 +29,13 @@ type UploadService struct {
userService UserService
}
// 预览图配置
const (
PreviewMaxWidth = 300 // 列表/网格模式最大宽度
PreviewMaxHeight = 800 // 详情页最大高度
PreviewQuality = 70 // WebP 质量
)
// NewUploadService 创建上传服务
func NewUploadService(s3Client *s3.Client, userService UserService) *UploadService {
return &UploadService{
@@ -35,25 +44,31 @@ func NewUploadService(s3Client *s3.Client, userService UserService) *UploadServi
}
}
// UploadImage 上传图片
func (s *UploadService) UploadImage(ctx context.Context, file *multipart.FileHeader) (string, error) {
// UploadImage 上传图片返回原图URL和预览图URL
func (s *UploadService) UploadImage(ctx context.Context, file *multipart.FileHeader) (string, string, string, error) {
processedData, contentType, ext, err := prepareImageForUpload(file)
if err != nil {
return "", err
return "", "", "", err
}
// 压缩后再计算哈希,确保同一压缩结果映射同一对象名
hash := sha256.Sum256(processedData)
hashStr := fmt.Sprintf("%x", hash)
// 上传原图
objectName := fmt.Sprintf("images/%s%s", hashStr, ext)
url, err := s.s3Client.UploadData(ctx, objectName, processedData, contentType)
if err != nil {
return "", fmt.Errorf("failed to upload to S3: %w", err)
return "", "", "", fmt.Errorf("failed to upload to S3: %w", err)
}
return url, nil
// 生成预览图
previewURL, previewURLLarge, err := s.GeneratePreviewImages(ctx, processedData, hashStr)
if err != nil {
log.Printf("[WARN] Failed to generate preview images: %v", err)
}
return url, previewURL, previewURLLarge, nil
}
// getExtFromContentType 根据Content-Type获取文件扩展名
@@ -272,3 +287,73 @@ func normalizeImageContentType(contentType string) string {
return contentType
}
}
// GeneratePreviewImages 生成预览图
func (s *UploadService) GeneratePreviewImages(ctx context.Context, originalData []byte, hash string) (previewURL, previewURLLarge string, err error) {
img, _, err := image.Decode(bytes.NewReader(originalData))
if err != nil {
return "", "", fmt.Errorf("failed to decode image: %w", err)
}
bounds := img.Bounds()
originalWidth := bounds.Dx()
originalHeight := bounds.Dy()
// 生成普通预览图(列表/网格模式)
previewURL, err = s.generatePreview(ctx, img, originalWidth, originalHeight, "preview", PreviewMaxWidth, hash)
if err != nil {
log.Printf("[WARN] Failed to generate preview: %v", err)
}
// 生成大预览图(详情页)
previewURLLarge, err = s.generatePreview(ctx, img, originalWidth, originalHeight, "large", PreviewMaxHeight, hash)
if err != nil {
log.Printf("[WARN] Failed to generate large preview: %v", err)
}
return previewURL, previewURLLarge, nil
}
// generatePreview 生成单个预览图
func (s *UploadService) generatePreview(ctx context.Context, img image.Image, originalWidth, originalHeight int, mode string, maxDim int, hash string) (string, error) {
var targetWidth, targetHeight int
// 计算目标尺寸
if originalWidth > originalHeight {
// 宽图:按宽度缩放
if originalWidth > maxDim {
targetWidth = maxDim
targetHeight = int(float64(originalHeight) * float64(maxDim) / float64(originalWidth))
} else {
return "", nil // 图片足够小,不需要生成预览图
}
} else {
// 高图:按高度缩放
if originalHeight > maxDim {
targetHeight = maxDim
targetWidth = int(float64(originalWidth) * float64(maxDim) / float64(originalHeight))
} else {
return "", nil // 图片足够小,不需要生成预览图
}
}
// 缩放图片(使用高质量 Lanczos 算法)
scaledImg := imaging.Resize(img, targetWidth, targetHeight, imaging.Lanczos)
// 编码为 JPEGWebP 在某些设备上可能不支持,使用 JPEG 保证兼容性)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, scaledImg, &jpeg.Options{Quality: PreviewQuality}); err != nil {
return "", fmt.Errorf("failed to encode jpeg: %w", err)
}
// 构建对象名thumbnails/{hash}_{mode}.jpg
objectName := fmt.Sprintf("thumbnails/%s_%s.jpg", hash, mode)
// 上传到 S3
url, err := s.s3Client.UploadData(ctx, objectName, buf.Bytes(), "image/jpeg")
if err != nil {
return "", fmt.Errorf("failed to upload preview: %w", err)
}
return url, nil
}

View File

@@ -44,8 +44,8 @@ type UserService interface {
// 关注相关
GetFollowers(ctx context.Context, userID string, page, pageSize int) ([]*model.User, int64, error)
GetFollowing(ctx context.Context, userID string, page, pageSize int) ([]*model.User, int64, error)
GetFollowingList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error)
GetFollowersList(ctx context.Context, userID, page, pageSize string) ([]*model.User, error)
GetFollowingList(ctx context.Context, userID string, page, pageSize string) ([]*model.User, error)
GetFollowersList(ctx context.Context, userID string, page, pageSize string) ([]*model.User, error)
FollowUser(ctx context.Context, followerID, followeeID string) error
UnfollowUser(ctx context.Context, followerID, followeeID string) error
GetMutualFollowStatus(ctx context.Context, currentUserID string, targetUserIDs []string) (map[string][2]bool, error)
@@ -55,6 +55,9 @@ type UserService interface {
UnblockUser(ctx context.Context, blockerID, blockedID string) error
GetBlockedUsers(ctx context.Context, blockerID string, page, pageSize int) ([]*model.User, int64, error)
IsBlocked(ctx context.Context, blockerID, blockedID string) (bool, error)
// 日志服务设置
SetLogService(logService *LogService)
}
// userServiceImpl 用户服务实现
@@ -62,6 +65,7 @@ type userServiceImpl struct {
userRepo *repository.UserRepository
systemMessageService SystemMessageService
emailCodeService EmailCodeService
logService *LogService
}
// NewUserService 创建用户服务
@@ -75,9 +79,15 @@ func NewUserService(
userRepo: userRepo,
systemMessageService: systemMessageService,
emailCodeService: NewEmailCodeService(emailService, cacheBackend),
logService: nil,
}
}
// SetLogService 设置日志服务
func (s *userServiceImpl) SetLogService(logService *LogService) {
s.logService = logService
}
// SendRegisterCode 发送注册验证码
func (s *userServiceImpl) SendRegisterCode(ctx context.Context, email string) error {
user, err := s.userRepo.GetByEmail(email)
@@ -241,12 +251,28 @@ func (s *userServiceImpl) Register(ctx context.Context, username, email, passwor
return nil, err
}
// 记录注册日志
if s.logService != nil {
s.logService.OperationLog.RecordOperation(&model.OperationLog{
UserID: user.ID,
UserName: user.Username,
NickName: user.Nickname,
Operation: string(model.OpUserRegister),
Module: "user",
TargetType: "user",
TargetID: user.ID,
IP: "",
Status: "success",
})
}
return user, nil
}
// Login 用户登录
func (s *userServiceImpl) Login(ctx context.Context, account, password string) (*model.User, error) {
account = strings.TrimSpace(account)
var (
user *model.User
err error
@@ -258,18 +284,86 @@ func (s *userServiceImpl) Login(ctx context.Context, account, password string) (
} else {
user, err = s.userRepo.GetByUsername(account)
}
// 登录失败处理
loginResult := string(model.LoginResultSuccess)
var failReason string
if err != nil || user == nil {
loginResult = string(model.LoginResultFail)
failReason = string(model.FailReasonUserNotFound)
// 记录失败登录日志
if s.logService != nil {
s.logService.LoginLog.RecordLogin(&model.LoginLog{
UserName: account,
Event: string(model.LoginEventFail),
IP: "",
UserAgent: "",
Result: loginResult,
FailReason: failReason,
})
}
return nil, ErrInvalidCredentials
}
if !utils.CheckPasswordHash(password, user.PasswordHash) {
loginResult = string(model.LoginResultFail)
failReason = string(model.FailReasonWrongPassword)
// 记录失败登录日志
if s.logService != nil {
s.logService.LoginLog.RecordLogin(&model.LoginLog{
UserID: user.ID,
UserName: user.Username,
NickName: user.Nickname,
Event: string(model.LoginEventFail),
IP: "",
UserAgent: "",
Result: loginResult,
FailReason: failReason,
})
}
return nil, ErrInvalidCredentials
}
if user.Status != model.UserStatusActive {
loginResult = string(model.LoginResultFail)
failReason = string(model.FailReasonAccountBanned)
// 记录失败登录日志
if s.logService != nil {
s.logService.LoginLog.RecordLogin(&model.LoginLog{
UserID: user.ID,
UserName: user.Username,
NickName: user.Nickname,
Event: string(model.LoginEventFail),
IP: "",
UserAgent: "",
Result: loginResult,
FailReason: failReason,
})
}
return nil, ErrUserBanned
}
// 记录成功登录日志
if s.logService != nil {
s.logService.LoginLog.RecordLogin(&model.LoginLog{
UserID: user.ID,
UserName: user.Username,
NickName: user.Nickname,
LoginType: string(model.LoginTypePassword),
Event: string(model.LoginEventLogin),
IP: "",
UserAgent: "",
Result: string(model.LoginResultSuccess),
})
}
return user, nil
}
@@ -337,7 +431,27 @@ func (s *userServiceImpl) GetUserByIDWithMutualFollowStatus(ctx context.Context,
// UpdateUser 更新用户
func (s *userServiceImpl) UpdateUser(ctx context.Context, user *model.User) error {
return s.userRepo.Update(user)
err := s.userRepo.Update(user)
if err != nil {
return err
}
// 记录数据变更日志
if s.logService != nil {
s.logService.DataChangeLog.RecordDataChange(&model.DataChangeLog{
UserID: user.ID,
ChangeType: string(model.ChangeProfile),
TargetType: "user",
TargetID: user.ID,
FieldName: "profile",
OldValue: "",
NewValue: fmt.Sprintf("昵称:%s", user.Nickname),
OperatorType: string(model.OperatorTypeSelf),
Reason: "用户修改个人资料",
})
}
return nil
}
// GetFollowers 获取粉丝
@@ -542,7 +656,26 @@ func (s *userServiceImpl) ChangePassword(ctx context.Context, userID, oldPasswor
// 更新密码
user.PasswordHash = hashedPassword
return s.userRepo.Update(user)
if err := s.userRepo.Update(user); err != nil {
return err
}
// 记录数据变更日志
if s.logService != nil {
s.logService.DataChangeLog.RecordDataChange(&model.DataChangeLog{
UserID: userID,
ChangeType: string(model.ChangePassword),
TargetType: "user",
TargetID: userID,
FieldName: "password",
OldValue: "***",
NewValue: "***",
OperatorType: string(model.OperatorTypeSelf),
Reason: "用户主动修改密码",
})
}
return nil
}
// ResetPasswordByEmail 通过邮箱重置密码