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

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