feat: add hook system, QR code login, and layered cache
- Implement extensible hook system for content moderation with builtin and AI-powered moderation hooks - Add QR code login feature with SSE for real-time status updates and scan/confirm/cancel workflow - Introduce layered cache with local LRU + Redis backend for improved read performance - Refactor post and comment services to use hook-based moderation instead of direct AI service calls - Add local cache configuration options (size, buckets, ttl)
This commit is contained in:
@@ -2,19 +2,18 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"carrot_bbs/internal/cache"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/gorse"
|
||||
"carrot_bbs/internal/pkg/hook"
|
||||
"carrot_bbs/internal/repository"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CommentService 评论服务
|
||||
type CommentService struct {
|
||||
commentRepo *repository.CommentRepository
|
||||
postRepo *repository.PostRepository
|
||||
@@ -23,10 +22,10 @@ type CommentService struct {
|
||||
gorseClient gorse.Client
|
||||
postAIService *PostAIService
|
||||
logService *LogService
|
||||
hookManager *hook.Manager
|
||||
}
|
||||
|
||||
// NewCommentService 创建评论服务
|
||||
func NewCommentService(commentRepo *repository.CommentRepository, postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService, cacheBackend cache.Cache) *CommentService {
|
||||
func NewCommentService(commentRepo *repository.CommentRepository, postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService, cacheBackend cache.Cache, hookManager *hook.Manager) *CommentService {
|
||||
return &CommentService{
|
||||
commentRepo: commentRepo,
|
||||
postRepo: postRepo,
|
||||
@@ -35,6 +34,7 @@ func NewCommentService(commentRepo *repository.CommentRepository, postRepo *repo
|
||||
gorseClient: gorseClient,
|
||||
postAIService: postAIService,
|
||||
logService: nil,
|
||||
hookManager: hookManager,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +113,9 @@ func (s *CommentService) reviewCommentAsync(
|
||||
parentUserID string,
|
||||
postOwnerID string,
|
||||
) {
|
||||
// 未启用AI时,直接通过审核并发送后续通知
|
||||
if s.postAIService == nil || !s.postAIService.IsEnabled() {
|
||||
if s.hookManager == nil {
|
||||
if err := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); err != nil {
|
||||
zap.L().Warn("Failed to publish comment without AI moderation",
|
||||
zap.L().Warn("Failed to publish comment without hook manager",
|
||||
zap.Error(err),
|
||||
)
|
||||
return
|
||||
@@ -132,40 +131,38 @@ func (s *CommentService) reviewCommentAsync(
|
||||
return
|
||||
}
|
||||
|
||||
err := s.postAIService.ModerateComment(context.Background(), content, imageURLs)
|
||||
if err != nil {
|
||||
var rejectedErr *CommentModerationRejectedError
|
||||
if errors.As(err, &rejectedErr) {
|
||||
if delErr := s.commentRepo.Delete(commentID); delErr != nil {
|
||||
zap.L().Warn("Failed to delete rejected comment",
|
||||
zap.String("commentID", commentID),
|
||||
zap.Error(delErr),
|
||||
)
|
||||
}
|
||||
s.notifyCommentModerationRejected(userID, rejectedErr.Reason)
|
||||
return
|
||||
}
|
||||
var authorID uint
|
||||
fmt.Sscanf(userID, "%d", &authorID)
|
||||
|
||||
// 审核服务异常时降级放行,避免评论长期pending
|
||||
if updateErr := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished); updateErr != nil {
|
||||
zap.L().Warn("Failed to publish comment after moderation error",
|
||||
result := &hook.ModerationResult{}
|
||||
s.hookManager.TriggerWithMetadata(context.Background(), hook.HookCommentPreModerate, authorID, &hook.CommentModerateHookData{
|
||||
CommentID: commentID,
|
||||
PostID: postID,
|
||||
Content: content,
|
||||
Images: imageURLs,
|
||||
AuthorID: authorID,
|
||||
ParentID: parentID,
|
||||
}, map[string]interface{}{
|
||||
"result": result,
|
||||
})
|
||||
|
||||
s.hookManager.Trigger(context.Background(), hook.HookCommentModerated, authorID, &hook.CommentModeratedHookData{
|
||||
CommentID: commentID,
|
||||
PostID: postID,
|
||||
AuthorID: authorID,
|
||||
Approved: result.Approved,
|
||||
RejectReason: result.RejectReason,
|
||||
ReviewedBy: result.ReviewedBy,
|
||||
})
|
||||
|
||||
if !result.Approved {
|
||||
if delErr := s.commentRepo.Delete(commentID); delErr != nil {
|
||||
zap.L().Warn("Failed to delete rejected comment",
|
||||
zap.String("commentID", commentID),
|
||||
zap.Error(updateErr),
|
||||
)
|
||||
return
|
||||
}
|
||||
if statsErr := s.applyCommentPublishedStats(commentID); statsErr != nil {
|
||||
zap.L().Warn("Failed to apply published stats for comment",
|
||||
zap.String("commentID", commentID),
|
||||
zap.Error(statsErr),
|
||||
zap.Error(delErr),
|
||||
)
|
||||
}
|
||||
s.invalidatePostCaches(postID)
|
||||
zap.L().Warn("Comment moderation failed, fallback publish comment",
|
||||
zap.String("commentID", commentID),
|
||||
zap.Error(err),
|
||||
)
|
||||
s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID)
|
||||
s.notifyCommentModerationRejected(userID, result.RejectReason)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
@@ -12,6 +11,7 @@ import (
|
||||
"carrot_bbs/internal/cache"
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/gorse"
|
||||
"carrot_bbs/internal/pkg/hook"
|
||||
"carrot_bbs/internal/repository"
|
||||
)
|
||||
|
||||
@@ -73,10 +73,10 @@ type postServiceImpl struct {
|
||||
postAIService *PostAIService
|
||||
txManager repository.TransactionManager
|
||||
logService *LogService
|
||||
hookManager *hook.Manager
|
||||
}
|
||||
|
||||
// NewPostService 创建帖子服务
|
||||
func NewPostService(postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService, cacheBackend cache.Cache, txManager repository.TransactionManager) PostService {
|
||||
func NewPostService(postRepo *repository.PostRepository, systemMessageService SystemMessageService, gorseClient gorse.Client, postAIService *PostAIService, cacheBackend cache.Cache, txManager repository.TransactionManager, hookManager *hook.Manager) PostService {
|
||||
return &postServiceImpl{
|
||||
postRepo: postRepo,
|
||||
systemMessageService: systemMessageService,
|
||||
@@ -85,6 +85,7 @@ func NewPostService(postRepo *repository.PostRepository, systemMessageService Sy
|
||||
postAIService: postAIService,
|
||||
txManager: txManager,
|
||||
logService: nil,
|
||||
hookManager: hookManager,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,40 +148,48 @@ func (s *postServiceImpl) reviewPostAsync(postID, userID, title, content string,
|
||||
}
|
||||
}()
|
||||
|
||||
// 未启用AI时,直接发布
|
||||
if s.postAIService == nil || !s.postAIService.IsEnabled() {
|
||||
if s.hookManager == nil {
|
||||
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); err != nil {
|
||||
log.Printf("[WARN] Failed to publish post without AI moderation: %v", err)
|
||||
log.Printf("[WARN] Failed to publish post without hook manager: %v", err)
|
||||
} else {
|
||||
s.invalidatePostCaches(postID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
err := s.postAIService.ModeratePost(context.Background(), title, content, images)
|
||||
if err != nil {
|
||||
var rejectedErr *PostModerationRejectedError
|
||||
if errors.As(err, &rejectedErr) {
|
||||
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, rejectedErr.UserMessage(), "ai"); updateErr != nil {
|
||||
log.Printf("[WARN] Failed to reject post %s: %v", postID, updateErr)
|
||||
} else {
|
||||
s.invalidatePostCaches(postID)
|
||||
}
|
||||
s.notifyModerationRejected(userID, rejectedErr.Reason)
|
||||
return
|
||||
}
|
||||
var authorID uint
|
||||
fmt.Sscanf(userID, "%d", &authorID)
|
||||
|
||||
// 规则审核不可用时,降级为发布,避免长时间pending
|
||||
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "system"); updateErr != nil {
|
||||
log.Printf("[WARN] Failed to publish post %s after moderation error: %v", postID, updateErr)
|
||||
result := &hook.ModerationResult{}
|
||||
s.hookManager.TriggerWithMetadata(context.Background(), hook.HookPostPreModerate, authorID, &hook.PostModerateHookData{
|
||||
PostID: postID,
|
||||
Title: title,
|
||||
Content: content,
|
||||
Images: images,
|
||||
AuthorID: authorID,
|
||||
}, map[string]interface{}{
|
||||
"result": result,
|
||||
})
|
||||
|
||||
s.hookManager.Trigger(context.Background(), hook.HookPostModerated, authorID, &hook.PostModeratedHookData{
|
||||
PostID: postID,
|
||||
AuthorID: authorID,
|
||||
Approved: result.Approved,
|
||||
RejectReason: result.RejectReason,
|
||||
ReviewedBy: result.ReviewedBy,
|
||||
})
|
||||
|
||||
if !result.Approved {
|
||||
if updateErr := s.updateModerationStatusWithRetry(postID, model.PostStatusRejected, result.RejectReason, result.ReviewedBy); updateErr != nil {
|
||||
log.Printf("[WARN] Failed to reject post %s: %v", postID, updateErr)
|
||||
} else {
|
||||
s.invalidatePostCaches(postID)
|
||||
}
|
||||
log.Printf("[WARN] Post moderation failed, fallback publish post=%s err=%v", postID, err)
|
||||
s.notifyModerationRejected(userID, result.RejectReason)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", "ai"); err != nil {
|
||||
if err := s.updateModerationStatusWithRetry(postID, model.PostStatusPublished, "", result.ReviewedBy); err != nil {
|
||||
log.Printf("[WARN] Failed to publish post %s: %v", postID, err)
|
||||
return
|
||||
}
|
||||
|
||||
291
internal/service/qrcode_login_service.go
Normal file
291
internal/service/qrcode_login_service.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"carrot_bbs/internal/model"
|
||||
"carrot_bbs/internal/pkg/redis"
|
||||
"carrot_bbs/internal/pkg/sse"
|
||||
)
|
||||
|
||||
// Lua 脚本:原子性地检查状态并更新为 scanned
|
||||
const scanScript = `
|
||||
local key = KEYS[1]
|
||||
local status = redis.call('HGET', key, 'status')
|
||||
if status == 'pending' then
|
||||
redis.call('HMSET', key, 'status', 'scanned', 'user_id', ARGV[1])
|
||||
return 1
|
||||
elseif status == 'scanned' then
|
||||
return 2
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`
|
||||
|
||||
const (
|
||||
qrcodeSessionPrefix = "qrcode:session:"
|
||||
qrcodeSessionTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
// QRCodeStatus 二维码状态
|
||||
type QRCodeStatus string
|
||||
|
||||
const (
|
||||
QRCodeStatusPending QRCodeStatus = "pending"
|
||||
QRCodeStatusScanned QRCodeStatus = "scanned"
|
||||
QRCodeStatusConfirmed QRCodeStatus = "confirmed"
|
||||
QRCodeStatusCancelled QRCodeStatus = "cancelled"
|
||||
QRCodeStatusExpired QRCodeStatus = "expired"
|
||||
)
|
||||
|
||||
// QRCodeSession 二维码会话
|
||||
type QRCodeSession struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Status QRCodeStatus `json:"status"`
|
||||
UserID string `json:"user_id"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
// QRCodeLoginService 二维码登录服务
|
||||
type QRCodeLoginService struct {
|
||||
redis *redis.Client
|
||||
sseHub *sse.Hub
|
||||
jwtService *JWTService
|
||||
userService UserService
|
||||
activityService UserActivityService
|
||||
logService *LogService
|
||||
}
|
||||
|
||||
// NewQRCodeLoginService 创建二维码登录服务
|
||||
func NewQRCodeLoginService(redis *redis.Client, sseHub *sse.Hub, jwtService *JWTService, userService UserService, activityService UserActivityService, logService *LogService) *QRCodeLoginService {
|
||||
return &QRCodeLoginService{
|
||||
redis: redis,
|
||||
sseHub: sseHub,
|
||||
jwtService: jwtService,
|
||||
userService: userService,
|
||||
activityService: activityService,
|
||||
logService: logService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSession 创建二维码会话
|
||||
func (s *QRCodeLoginService) CreateSession(ctx context.Context) (*QRCodeSession, error) {
|
||||
sessionID := uuid.New().String()
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(qrcodeSessionTTL)
|
||||
|
||||
session := &QRCodeSession{
|
||||
SessionID: sessionID,
|
||||
Status: QRCodeStatusPending,
|
||||
CreatedAt: now.Unix(),
|
||||
ExpiresAt: expiresAt.Unix(),
|
||||
}
|
||||
|
||||
key := qrcodeSessionPrefix + sessionID
|
||||
data := map[string]interface{}{
|
||||
"status": string(session.Status),
|
||||
"user_id": "",
|
||||
"created_at": session.CreatedAt,
|
||||
"expires_at": session.ExpiresAt,
|
||||
}
|
||||
|
||||
if err := s.redis.HMSet(ctx, key, data); err != nil {
|
||||
return nil, fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
if _, err := s.redis.Expire(ctx, key, qrcodeSessionTTL); err != nil {
|
||||
return nil, fmt.Errorf("failed to set session ttl: %w", err)
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// GetSession 获取会话
|
||||
func (s *QRCodeLoginService) GetSession(ctx context.Context, sessionID string) (*QRCodeSession, error) {
|
||||
key := qrcodeSessionPrefix + sessionID
|
||||
data, err := s.redis.HGetAll(ctx, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get session: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("session not found or expired")
|
||||
}
|
||||
|
||||
session := &QRCodeSession{
|
||||
SessionID: sessionID,
|
||||
Status: QRCodeStatus(data["status"]),
|
||||
UserID: data["user_id"],
|
||||
}
|
||||
|
||||
// 解析时间戳
|
||||
if v := data["created_at"]; v != "" {
|
||||
fmt.Sscanf(v, "%d", &session.CreatedAt)
|
||||
}
|
||||
if v := data["expires_at"]; v != "" {
|
||||
fmt.Sscanf(v, "%d", &session.ExpiresAt)
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if time.Now().Unix() > session.ExpiresAt {
|
||||
session.Status = QRCodeStatusExpired
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// Scan 扫描二维码
|
||||
func (s *QRCodeLoginService) Scan(ctx context.Context, sessionID, userID string) error {
|
||||
key := qrcodeSessionPrefix + sessionID
|
||||
|
||||
// 使用 Lua 脚本实现原子性检查和更新,避免竞态条件
|
||||
result, err := s.redis.GetClient().Eval(ctx, scanScript, []string{key}, userID).Int()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan: %w", err)
|
||||
}
|
||||
|
||||
switch result {
|
||||
case 0:
|
||||
return fmt.Errorf("session not found or expired")
|
||||
case 2:
|
||||
return fmt.Errorf("qrcode already scanned")
|
||||
}
|
||||
|
||||
// 获取用户信息用于推送
|
||||
user, err := s.userService.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user info: %w", err)
|
||||
}
|
||||
|
||||
// 推送扫码事件
|
||||
payload := map[string]any{
|
||||
"user": map[string]any{
|
||||
"id": user.ID,
|
||||
"nickname": user.Nickname,
|
||||
"avatar": user.Avatar,
|
||||
},
|
||||
}
|
||||
s.sseHub.PublishToUser(sessionID, "scanned", payload)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Confirm 确认登录
|
||||
func (s *QRCodeLoginService) Confirm(ctx context.Context, sessionID, userID string, ip, userAgent string) (*LoginResponse, error) {
|
||||
session, err := s.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if session.Status != QRCodeStatusScanned {
|
||||
return nil, fmt.Errorf("invalid session status")
|
||||
}
|
||||
|
||||
if session.UserID != userID {
|
||||
return nil, fmt.Errorf("unauthorized")
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
user, err := s.userService.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
// 复用JWT生成逻辑
|
||||
accessToken, err := s.jwtService.GenerateAccessToken(user.ID, user.Username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate access token: %w", err)
|
||||
}
|
||||
|
||||
refreshToken, err := s.jwtService.GenerateRefreshToken(user.ID, user.Username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
key := qrcodeSessionPrefix + sessionID
|
||||
if err := s.redis.HSet(ctx, key, "status", string(QRCodeStatusConfirmed)); err != nil {
|
||||
return nil, fmt.Errorf("failed to update status: %w", err)
|
||||
}
|
||||
|
||||
// 复用活跃记录
|
||||
if s.activityService != nil {
|
||||
go func() {
|
||||
s.activityService.RecordUserActive(context.Background(), userID, model.LoginTypeLogin, ip, userAgent)
|
||||
}()
|
||||
}
|
||||
|
||||
// 复用登录日志
|
||||
if s.logService != nil {
|
||||
go func() {
|
||||
s.logService.LoginLog.RecordLogin(&model.LoginLog{
|
||||
UserID: user.ID,
|
||||
UserName: user.Username,
|
||||
NickName: user.Nickname,
|
||||
LoginType: string(model.LoginTypeQRCode),
|
||||
Event: string(model.LoginEventLogin),
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
Result: string(model.LoginResultSuccess),
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// 推送确认事件
|
||||
response := &LoginResponse{
|
||||
Token: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
User: user,
|
||||
}
|
||||
s.sseHub.PublishToUser(sessionID, "confirmed", response)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// LoginResponse 登录响应
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
User *model.User `json:"user"`
|
||||
}
|
||||
|
||||
// Cancel 取消登录
|
||||
func (s *QRCodeLoginService) Cancel(ctx context.Context, sessionID, userID string) error {
|
||||
session, err := s.GetSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if session.Status != QRCodeStatusScanned {
|
||||
return fmt.Errorf("invalid session status")
|
||||
}
|
||||
|
||||
if session.UserID != userID {
|
||||
return fmt.Errorf("unauthorized")
|
||||
}
|
||||
|
||||
key := qrcodeSessionPrefix + sessionID
|
||||
if err := s.redis.HSet(ctx, key, "status", string(QRCodeStatusCancelled)); err != nil {
|
||||
return fmt.Errorf("failed to update status: %w", err)
|
||||
}
|
||||
|
||||
// 推送取消事件
|
||||
s.sseHub.PublishToUser(sessionID, "cancelled", map[string]interface{}{})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSSEHub 获取SSE Hub
|
||||
func (s *QRCodeLoginService) GetSSEHub() *sse.Hub {
|
||||
return s.sseHub
|
||||
}
|
||||
|
||||
// GetUserService 获取用户服务
|
||||
func (s *QRCodeLoginService) GetUserService() UserService {
|
||||
return s.userService
|
||||
}
|
||||
Reference in New Issue
Block a user