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:
405
internal/pkg/hook/hook.go
Normal file
405
internal/pkg/hook/hook.go
Normal file
@@ -0,0 +1,405 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type HookType string
|
||||
|
||||
const (
|
||||
HookPostCreated HookType = "post.created"
|
||||
HookPostUpdated HookType = "post.updated"
|
||||
HookPostDeleted HookType = "post.deleted"
|
||||
HookPostPreModerate HookType = "post.pre_moderate"
|
||||
HookPostModerated HookType = "post.moderated"
|
||||
HookCommentCreated HookType = "comment.created"
|
||||
HookCommentDeleted HookType = "comment.deleted"
|
||||
HookCommentPreModerate HookType = "comment.pre_moderate"
|
||||
HookCommentModerated HookType = "comment.moderated"
|
||||
HookUserCreated HookType = "user.created"
|
||||
HookUserUpdated HookType = "user.updated"
|
||||
HookUserDeleted HookType = "user.deleted"
|
||||
HookUserLogin HookType = "user.login"
|
||||
HookUserLogout HookType = "user.logout"
|
||||
HookGroupCreated HookType = "group.created"
|
||||
HookGroupUpdated HookType = "group.updated"
|
||||
HookGroupDeleted HookType = "group.deleted"
|
||||
HookGroupJoined HookType = "group.joined"
|
||||
HookGroupLeft HookType = "group.left"
|
||||
HookMessageSent HookType = "message.sent"
|
||||
HookMessageRead HookType = "message.read"
|
||||
HookNotificationSent HookType = "notification.sent"
|
||||
HookVoteCreated HookType = "vote.created"
|
||||
HookVoteUpdated HookType = "vote.updated"
|
||||
HookFileUploaded HookType = "file.uploaded"
|
||||
)
|
||||
|
||||
type Priority int
|
||||
|
||||
const (
|
||||
PriorityLowest Priority = 0
|
||||
PriorityLow Priority = 25
|
||||
PriorityNormal Priority = 50
|
||||
PriorityHigh Priority = 75
|
||||
PriorityHighest Priority = 100
|
||||
)
|
||||
|
||||
type HookContext struct {
|
||||
Ctx context.Context
|
||||
HookType HookType
|
||||
UserID uint
|
||||
Data any
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
type HookFunc func(ctx *HookContext) error
|
||||
|
||||
type Hook struct {
|
||||
Name string
|
||||
HookType HookType
|
||||
Func HookFunc
|
||||
Priority Priority
|
||||
Async bool
|
||||
Timeout time.Duration
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type HookStats struct {
|
||||
TotalCalls int64
|
||||
FailedCalls int64
|
||||
TotalTime time.Duration
|
||||
AverageTime time.Duration
|
||||
LastCalledAt time.Time
|
||||
}
|
||||
|
||||
type hookEntry struct {
|
||||
hook *Hook
|
||||
stats HookStats
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
hooks map[HookType][]*hookEntry
|
||||
mu sync.RWMutex
|
||||
asyncPool chan struct{}
|
||||
enabled bool
|
||||
metrics *Metrics
|
||||
}
|
||||
|
||||
type ManagerOptions struct {
|
||||
MaxConcurrentAsync int
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func NewManager(opts *ManagerOptions) *Manager {
|
||||
if opts == nil {
|
||||
opts = &ManagerOptions{
|
||||
MaxConcurrentAsync: 100,
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
if opts.MaxConcurrentAsync <= 0 {
|
||||
opts.MaxConcurrentAsync = 100
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
hooks: make(map[HookType][]*hookEntry),
|
||||
asyncPool: make(chan struct{}, opts.MaxConcurrentAsync),
|
||||
enabled: opts.Enabled,
|
||||
metrics: NewMetrics(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Register(hook *Hook) {
|
||||
if hook == nil || hook.Func == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if hook.Priority == 0 {
|
||||
hook.Priority = PriorityNormal
|
||||
}
|
||||
if hook.Timeout == 0 {
|
||||
hook.Timeout = 30 * time.Second
|
||||
}
|
||||
hook.Enabled = true
|
||||
|
||||
entry := &hookEntry{
|
||||
hook: hook,
|
||||
stats: HookStats{},
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
hooks := m.hooks[hook.HookType]
|
||||
hooks = append(hooks, entry)
|
||||
|
||||
for i := len(hooks) - 1; i > 0; i-- {
|
||||
if hooks[i].hook.Priority > hooks[i-1].hook.Priority {
|
||||
hooks[i], hooks[i-1] = hooks[i-1], hooks[i]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
m.hooks[hook.HookType] = hooks
|
||||
zap.L().Debug("Hook registered",
|
||||
zap.String("name", hook.Name),
|
||||
zap.String("type", string(hook.HookType)),
|
||||
zap.Int("priority", int(hook.Priority)),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *Manager) RegisterFunc(hookType HookType, name string, fn HookFunc, opts ...HookOption) {
|
||||
hook := &Hook{
|
||||
Name: name,
|
||||
HookType: hookType,
|
||||
Func: fn,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(hook)
|
||||
}
|
||||
m.Register(hook)
|
||||
}
|
||||
|
||||
type HookOption func(*Hook)
|
||||
|
||||
func WithPriority(p Priority) HookOption {
|
||||
return func(h *Hook) {
|
||||
h.Priority = p
|
||||
}
|
||||
}
|
||||
|
||||
func WithAsync() HookOption {
|
||||
return func(h *Hook) {
|
||||
h.Async = true
|
||||
}
|
||||
}
|
||||
|
||||
func WithTimeout(timeout time.Duration) HookOption {
|
||||
return func(h *Hook) {
|
||||
h.Timeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Trigger(ctx context.Context, hookType HookType, userID uint, data any) error {
|
||||
return m.TriggerWithMetadata(ctx, hookType, userID, data, nil)
|
||||
}
|
||||
|
||||
func (m *Manager) TriggerWithMetadata(ctx context.Context, hookType HookType, userID uint, data any, metadata map[string]any) error {
|
||||
if !m.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
entries := m.hooks[hookType]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
hookCtx := &HookContext{
|
||||
Ctx: ctx,
|
||||
HookType: hookType,
|
||||
UserID: userID,
|
||||
Data: data,
|
||||
Metadata: metadata,
|
||||
}
|
||||
|
||||
var asyncHooks []*hookEntry
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.hook.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
if entry.hook.Async {
|
||||
asyncHooks = append(asyncHooks, entry)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := m.executeHook(entry, hookCtx); err != nil {
|
||||
zap.L().Error("Hook execution failed",
|
||||
zap.String("name", entry.hook.Name),
|
||||
zap.String("type", string(hookType)),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for _, entry := range asyncHooks {
|
||||
entry := entry
|
||||
go func() {
|
||||
select {
|
||||
case m.asyncPool <- struct{}{}:
|
||||
defer func() { <-m.asyncPool }()
|
||||
if err := m.executeHook(entry, hookCtx); err != nil {
|
||||
zap.L().Error("Async hook execution failed",
|
||||
zap.String("name", entry.hook.Name),
|
||||
zap.String("type", string(hookType)),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
default:
|
||||
zap.L().Warn("Async hook pool full, skipping hook",
|
||||
zap.String("name", entry.hook.Name),
|
||||
zap.String("type", string(hookType)),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) executeHook(entry *hookEntry, ctx *HookContext) error {
|
||||
start := time.Now()
|
||||
|
||||
var err error
|
||||
if entry.hook.Timeout > 0 {
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
zap.L().Error("Hook panic recovered",
|
||||
zap.String("name", entry.hook.Name),
|
||||
zap.Any("panic", r),
|
||||
)
|
||||
done <- nil
|
||||
}
|
||||
}()
|
||||
done <- entry.hook.Func(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err = <-done:
|
||||
case <-time.After(entry.hook.Timeout):
|
||||
err = context.DeadlineExceeded
|
||||
zap.L().Warn("Hook timeout",
|
||||
zap.String("name", entry.hook.Name),
|
||||
zap.Duration("timeout", entry.hook.Timeout),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
err = entry.hook.Func(ctx)
|
||||
}
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
entry.mu.Lock()
|
||||
entry.stats.TotalCalls++
|
||||
entry.stats.TotalTime += duration
|
||||
entry.stats.AverageTime = time.Duration(int64(entry.stats.TotalTime) / entry.stats.TotalCalls)
|
||||
entry.stats.LastCalledAt = time.Now()
|
||||
if err != nil {
|
||||
entry.stats.FailedCalls++
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
|
||||
m.metrics.RecordHookExecution(entry.hook.Name, entry.hook.HookType, duration, err == nil)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) Unregister(hookType HookType, name string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
entries := m.hooks[hookType]
|
||||
for i, entry := range entries {
|
||||
if entry.hook.Name == name {
|
||||
m.hooks[hookType] = append(entries[:i], entries[i+1:]...)
|
||||
zap.L().Debug("Hook unregistered",
|
||||
zap.String("name", name),
|
||||
zap.String("type", string(hookType)),
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Enable(hookType HookType, name string) {
|
||||
m.mu.RLock()
|
||||
entries := m.hooks[hookType]
|
||||
m.mu.RUnlock()
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.hook.Name == name {
|
||||
entry.hook.Enabled = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Disable(hookType HookType, name string) {
|
||||
m.mu.RLock()
|
||||
entries := m.hooks[hookType]
|
||||
m.mu.RUnlock()
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.hook.Name == name {
|
||||
entry.hook.Enabled = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) GetStats(hookType HookType, name string) *HookStats {
|
||||
m.mu.RLock()
|
||||
entries := m.hooks[hookType]
|
||||
m.mu.RUnlock()
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.hook.Name == name {
|
||||
entry.mu.Lock()
|
||||
stats := entry.stats
|
||||
entry.mu.Unlock()
|
||||
return &stats
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetAllStats() map[HookType]map[string]HookStats {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make(map[HookType]map[string]HookStats)
|
||||
for hookType, entries := range m.hooks {
|
||||
result[hookType] = make(map[string]HookStats)
|
||||
for _, entry := range entries {
|
||||
entry.mu.Lock()
|
||||
result[hookType][entry.hook.Name] = entry.stats
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *Manager) GetMetrics() *Metrics {
|
||||
return m.metrics
|
||||
}
|
||||
|
||||
func (m *Manager) Clear(hookType HookType) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.hooks, hookType)
|
||||
}
|
||||
|
||||
func (m *Manager) ClearAll() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.hooks = make(map[HookType][]*hookEntry)
|
||||
}
|
||||
|
||||
func (m *Manager) SetEnabled(enabled bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.enabled = enabled
|
||||
}
|
||||
122
internal/pkg/hook/metrics.go
Normal file
122
internal/pkg/hook/metrics.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Metrics struct {
|
||||
totalCalls atomic.Int64
|
||||
failedCalls atomic.Int64
|
||||
totalTime atomic.Int64
|
||||
hookMetrics map[string]*hookMetric
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type hookMetric struct {
|
||||
calls atomic.Int64
|
||||
failures atomic.Int64
|
||||
totalTime atomic.Int64
|
||||
lastCalled atomic.Int64
|
||||
}
|
||||
|
||||
func NewMetrics() *Metrics {
|
||||
return &Metrics{
|
||||
hookMetrics: make(map[string]*hookMetric),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Metrics) RecordHookExecution(name string, hookType HookType, duration time.Duration, success bool) {
|
||||
m.totalCalls.Add(1)
|
||||
m.totalTime.Add(int64(duration))
|
||||
|
||||
if !success {
|
||||
m.failedCalls.Add(1)
|
||||
}
|
||||
|
||||
key := string(hookType) + ":" + name
|
||||
|
||||
m.mu.Lock()
|
||||
metric, ok := m.hookMetrics[key]
|
||||
if !ok {
|
||||
metric = &hookMetric{}
|
||||
m.hookMetrics[key] = metric
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
metric.calls.Add(1)
|
||||
metric.totalTime.Add(int64(duration))
|
||||
metric.lastCalled.Store(time.Now().Unix())
|
||||
|
||||
if !success {
|
||||
metric.failures.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
type MetricSnapshot struct {
|
||||
Name string
|
||||
Type HookType
|
||||
Calls int64
|
||||
Failures int64
|
||||
TotalTime time.Duration
|
||||
AvgTime time.Duration
|
||||
LastCalled time.Time
|
||||
}
|
||||
|
||||
func (m *Metrics) GetTotalCalls() int64 {
|
||||
return m.totalCalls.Load()
|
||||
}
|
||||
|
||||
func (m *Metrics) GetFailedCalls() int64 {
|
||||
return m.failedCalls.Load()
|
||||
}
|
||||
|
||||
func (m *Metrics) GetTotalTime() time.Duration {
|
||||
return time.Duration(m.totalTime.Load())
|
||||
}
|
||||
|
||||
func (m *Metrics) GetHookMetrics() []MetricSnapshot {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var snapshots []MetricSnapshot
|
||||
for key, metric := range m.hookMetrics {
|
||||
calls := metric.calls.Load()
|
||||
var avgTime time.Duration
|
||||
if calls > 0 {
|
||||
avgTime = time.Duration(metric.totalTime.Load() / calls)
|
||||
}
|
||||
|
||||
hookType, name := parseKey(key)
|
||||
snapshots = append(snapshots, MetricSnapshot{
|
||||
Name: name,
|
||||
Type: hookType,
|
||||
Calls: calls,
|
||||
Failures: metric.failures.Load(),
|
||||
TotalTime: time.Duration(metric.totalTime.Load()),
|
||||
AvgTime: avgTime,
|
||||
LastCalled: time.Unix(metric.lastCalled.Load(), 0),
|
||||
})
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
func parseKey(key string) (HookType, string) {
|
||||
for i := 0; i < len(key); i++ {
|
||||
if key[i] == ':' {
|
||||
return HookType(key[:i]), key[i+1:]
|
||||
}
|
||||
}
|
||||
return HookType(key), ""
|
||||
}
|
||||
|
||||
func (m *Metrics) Reset() {
|
||||
m.totalCalls.Store(0)
|
||||
m.failedCalls.Store(0)
|
||||
m.totalTime.Store(0)
|
||||
|
||||
m.mu.Lock()
|
||||
m.hookMetrics = make(map[string]*hookMetric)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
353
internal/pkg/hook/moderation_hooks.go
Normal file
353
internal/pkg/hook/moderation_hooks.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"carrot_bbs/internal/repository"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type PostModerationHook struct {
|
||||
name string
|
||||
postAIService PostAIService
|
||||
postRepo *repository.PostRepository
|
||||
strictMode bool
|
||||
}
|
||||
|
||||
type PostAIService interface {
|
||||
IsEnabled() bool
|
||||
ModeratePost(ctx context.Context, title, content string, images []string) error
|
||||
ModerateComment(ctx context.Context, content string, images []string) error
|
||||
}
|
||||
|
||||
func NewPostModerationHook(postAIService PostAIService, postRepo *repository.PostRepository, strictMode bool) *PostModerationHook {
|
||||
return &PostModerationHook{
|
||||
name: "moderation.post.ai",
|
||||
postAIService: postAIService,
|
||||
postRepo: postRepo,
|
||||
strictMode: strictMode,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PostModerationHook) Register(manager *Manager) {
|
||||
manager.Register(&Hook{
|
||||
Name: h.name,
|
||||
HookType: HookPostPreModerate,
|
||||
Priority: PriorityHighest,
|
||||
Func: h.Execute,
|
||||
Async: false, // 必须同步执行,因为调用方需要审核结果
|
||||
Timeout: 30 * time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *PostModerationHook) Execute(ctx *HookContext) error {
|
||||
data, ok := ctx.Data.(*PostModerateHookData)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
result, ok := ctx.Metadata["result"].(*ModerationResult)
|
||||
if !ok || result == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if h.postAIService == nil || !h.postAIService.IsEnabled() {
|
||||
result.Approved = true
|
||||
result.ReviewedBy = "system"
|
||||
zap.L().Debug("AI moderation disabled, auto approve post",
|
||||
zap.String("post_id", data.PostID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
err := h.postAIService.ModeratePost(ctx.Ctx, data.Title, data.Content, data.Images)
|
||||
if err != nil {
|
||||
if rejectErr, ok := err.(interface{ UserMessage() string }); ok {
|
||||
result.Approved = false
|
||||
result.RejectReason = rejectErr.UserMessage()
|
||||
result.ReviewedBy = "ai"
|
||||
zap.L().Info("Post rejected by AI moderation",
|
||||
zap.String("post_id", data.PostID),
|
||||
zap.String("reason", result.RejectReason),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
if h.strictMode {
|
||||
result.Approved = false
|
||||
result.Error = err
|
||||
result.ReviewedBy = "ai"
|
||||
return nil
|
||||
}
|
||||
|
||||
result.Approved = true
|
||||
result.ReviewedBy = "system"
|
||||
zap.L().Warn("AI moderation error, fallback approve",
|
||||
zap.String("post_id", data.PostID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
result.Approved = true
|
||||
result.ReviewedBy = "ai"
|
||||
zap.L().Debug("Post approved by AI moderation",
|
||||
zap.String("post_id", data.PostID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
type CommentModerationHook struct {
|
||||
name string
|
||||
postAIService PostAIService
|
||||
commentRepo *repository.CommentRepository
|
||||
strictMode bool
|
||||
}
|
||||
|
||||
func NewCommentModerationHook(postAIService PostAIService, commentRepo *repository.CommentRepository, strictMode bool) *CommentModerationHook {
|
||||
return &CommentModerationHook{
|
||||
name: "moderation.comment.ai",
|
||||
postAIService: postAIService,
|
||||
commentRepo: commentRepo,
|
||||
strictMode: strictMode,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CommentModerationHook) Register(manager *Manager) {
|
||||
manager.Register(&Hook{
|
||||
Name: h.name,
|
||||
HookType: HookCommentPreModerate,
|
||||
Priority: PriorityHighest,
|
||||
Func: h.Execute,
|
||||
Async: false, // 必须同步执行,因为调用方需要审核结果
|
||||
Timeout: 30 * time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CommentModerationHook) Execute(ctx *HookContext) error {
|
||||
data, ok := ctx.Data.(*CommentModerateHookData)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
result, ok := ctx.Metadata["result"].(*ModerationResult)
|
||||
if !ok || result == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if h.postAIService == nil || !h.postAIService.IsEnabled() {
|
||||
result.Approved = true
|
||||
result.ReviewedBy = "system"
|
||||
zap.L().Debug("AI moderation disabled, auto approve comment",
|
||||
zap.String("comment_id", data.CommentID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
err := h.postAIService.ModerateComment(ctx.Ctx, data.Content, data.Images)
|
||||
if err != nil {
|
||||
if rejectErr, ok := err.(interface{ UserMessage() string }); ok {
|
||||
result.Approved = false
|
||||
result.RejectReason = rejectErr.UserMessage()
|
||||
result.ReviewedBy = "ai"
|
||||
zap.L().Info("Comment rejected by AI moderation",
|
||||
zap.String("comment_id", data.CommentID),
|
||||
zap.String("reason", result.RejectReason),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
if h.strictMode {
|
||||
result.Approved = false
|
||||
result.Error = err
|
||||
result.ReviewedBy = "ai"
|
||||
return nil
|
||||
}
|
||||
|
||||
result.Approved = true
|
||||
result.ReviewedBy = "system"
|
||||
zap.L().Warn("AI comment moderation error, fallback approve",
|
||||
zap.String("comment_id", data.CommentID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
result.Approved = true
|
||||
result.ReviewedBy = "ai"
|
||||
zap.L().Debug("Comment approved by AI moderation",
|
||||
zap.String("comment_id", data.CommentID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
type SensitiveWordHook struct {
|
||||
name string
|
||||
sensitive SensitiveService
|
||||
replaceStr string
|
||||
}
|
||||
|
||||
type SensitiveService interface {
|
||||
Check(ctx context.Context, text string) (bool, []string)
|
||||
Replace(ctx context.Context, text string, repl string) string
|
||||
}
|
||||
|
||||
func NewSensitiveWordHook(sensitive SensitiveService, replaceStr string) *SensitiveWordHook {
|
||||
return &SensitiveWordHook{
|
||||
name: "moderation.sensitive_word",
|
||||
sensitive: sensitive,
|
||||
replaceStr: replaceStr,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SensitiveWordHook) RegisterPostHook(manager *Manager) {
|
||||
manager.Register(&Hook{
|
||||
Name: h.name + ".post",
|
||||
HookType: HookPostPreModerate,
|
||||
Priority: PriorityHigh,
|
||||
Func: h.executePost,
|
||||
Async: false,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SensitiveWordHook) RegisterCommentHook(manager *Manager) {
|
||||
manager.Register(&Hook{
|
||||
Name: h.name + ".comment",
|
||||
HookType: HookCommentPreModerate,
|
||||
Priority: PriorityHigh,
|
||||
Func: h.executeComment,
|
||||
Async: false,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SensitiveWordHook) executePost(ctx *HookContext) error {
|
||||
if h.sensitive == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, ok := ctx.Data.(*PostModerateHookData)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
result, ok := ctx.Metadata["result"].(*ModerationResult)
|
||||
if !ok || result == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if result.RejectReason != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
hasSensitive, words := h.sensitive.Check(ctx.Ctx, data.Title+" "+data.Content)
|
||||
if hasSensitive {
|
||||
result.Approved = false
|
||||
result.RejectReason = "内容包含敏感词: " + strings.Join(words, ", ")
|
||||
result.ReviewedBy = "sensitive_word"
|
||||
zap.L().Info("Post rejected by sensitive word filter",
|
||||
zap.String("post_id", data.PostID),
|
||||
zap.Strings("words", words),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SensitiveWordHook) executeComment(ctx *HookContext) error {
|
||||
if h.sensitive == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, ok := ctx.Data.(*CommentModerateHookData)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
result, ok := ctx.Metadata["result"].(*ModerationResult)
|
||||
if !ok || result == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if result.RejectReason != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
hasSensitive, words := h.sensitive.Check(ctx.Ctx, data.Content)
|
||||
if hasSensitive {
|
||||
result.Approved = false
|
||||
result.RejectReason = "内容包含敏感词: " + strings.Join(words, ", ")
|
||||
result.ReviewedBy = "sensitive_word"
|
||||
zap.L().Info("Comment rejected by sensitive word filter",
|
||||
zap.String("comment_id", data.CommentID),
|
||||
zap.Strings("words", words),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type ModerationHooks struct {
|
||||
postAIService PostAIService
|
||||
sensitiveService SensitiveService
|
||||
postRepo *repository.PostRepository
|
||||
commentRepo *repository.CommentRepository
|
||||
strictMode bool
|
||||
replaceStr string
|
||||
}
|
||||
|
||||
func NewModerationHooks(
|
||||
postAIService PostAIService,
|
||||
sensitiveService SensitiveService,
|
||||
postRepo *repository.PostRepository,
|
||||
commentRepo *repository.CommentRepository,
|
||||
strictMode bool,
|
||||
replaceStr string,
|
||||
) *ModerationHooks {
|
||||
return &ModerationHooks{
|
||||
postAIService: postAIService,
|
||||
sensitiveService: sensitiveService,
|
||||
postRepo: postRepo,
|
||||
commentRepo: commentRepo,
|
||||
strictMode: strictMode,
|
||||
replaceStr: replaceStr,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ModerationHooks) RegisterAll(manager *Manager) {
|
||||
postHook := NewPostModerationHook(m.postAIService, m.postRepo, m.strictMode)
|
||||
postHook.Register(manager)
|
||||
|
||||
commentHook := NewCommentModerationHook(m.postAIService, m.commentRepo, m.strictMode)
|
||||
commentHook.Register(manager)
|
||||
|
||||
if m.sensitiveService != nil {
|
||||
sensitiveHook := NewSensitiveWordHook(m.sensitiveService, m.replaceStr)
|
||||
sensitiveHook.RegisterPostHook(manager)
|
||||
sensitiveHook.RegisterCommentHook(manager)
|
||||
}
|
||||
|
||||
zap.L().Info("Registered moderation hooks",
|
||||
zap.Bool("ai_enabled", m.postAIService != nil && m.postAIService.IsEnabled()),
|
||||
zap.Bool("sensitive_enabled", m.sensitiveService != nil),
|
||||
zap.Bool("strict_mode", m.strictMode),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *ModerationHooks) ModeratePost(ctx context.Context, manager *Manager, data *PostModerateHookData) *ModerationResult {
|
||||
result := &ModerationResult{}
|
||||
manager.TriggerWithMetadata(ctx, HookPostPreModerate, data.AuthorID, data, map[string]any{
|
||||
"result": result,
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *ModerationHooks) ModerateComment(ctx context.Context, manager *Manager, data *CommentModerateHookData) *ModerationResult {
|
||||
result := &ModerationResult{}
|
||||
manager.TriggerWithMetadata(ctx, HookCommentPreModerate, data.AuthorID, data, map[string]any{
|
||||
"result": result,
|
||||
})
|
||||
return result
|
||||
}
|
||||
272
internal/pkg/hook/types.go
Normal file
272
internal/pkg/hook/types.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type PostHookData struct {
|
||||
PostID uint
|
||||
Title string
|
||||
Content string
|
||||
AuthorID uint
|
||||
GroupID *uint
|
||||
IsPublished bool
|
||||
}
|
||||
|
||||
type CommentHookData struct {
|
||||
CommentID uint
|
||||
PostID uint
|
||||
Content string
|
||||
AuthorID uint
|
||||
ParentID *uint
|
||||
}
|
||||
|
||||
type UserHookData struct {
|
||||
UserID uint
|
||||
Username string
|
||||
Email string
|
||||
Nickname string
|
||||
}
|
||||
|
||||
type GroupHookData struct {
|
||||
GroupID uint
|
||||
Name string
|
||||
CreatorID uint
|
||||
}
|
||||
|
||||
type MessageHookData struct {
|
||||
MessageID uint
|
||||
SenderID uint
|
||||
ReceiverID uint
|
||||
Content string
|
||||
}
|
||||
|
||||
type NotificationHookData struct {
|
||||
NotificationID uint
|
||||
UserID uint
|
||||
Type string
|
||||
Title string
|
||||
Content string
|
||||
}
|
||||
|
||||
type VoteHookData struct {
|
||||
VoteID uint
|
||||
PostID uint
|
||||
VoterID uint
|
||||
OptionID uint
|
||||
}
|
||||
|
||||
type FileHookData struct {
|
||||
FileID uint
|
||||
FileName string
|
||||
FileSize int64
|
||||
Uploader uint
|
||||
MimeType string
|
||||
}
|
||||
|
||||
type PostModerateHookData struct {
|
||||
PostID string
|
||||
Title string
|
||||
Content string
|
||||
Images []string
|
||||
AuthorID uint
|
||||
GroupID *uint
|
||||
}
|
||||
|
||||
type PostModeratedHookData struct {
|
||||
PostID string
|
||||
AuthorID uint
|
||||
Approved bool
|
||||
RejectReason string
|
||||
ReviewedBy string
|
||||
}
|
||||
|
||||
type CommentModerateHookData struct {
|
||||
CommentID string
|
||||
PostID string
|
||||
Content string
|
||||
Images []string
|
||||
AuthorID uint
|
||||
ParentID *string
|
||||
}
|
||||
|
||||
type CommentModeratedHookData struct {
|
||||
CommentID string
|
||||
PostID string
|
||||
AuthorID uint
|
||||
Approved bool
|
||||
RejectReason string
|
||||
ReviewedBy string
|
||||
}
|
||||
|
||||
type ModerationResult struct {
|
||||
Approved bool
|
||||
RejectReason string
|
||||
ReviewedBy string
|
||||
Error error
|
||||
}
|
||||
|
||||
type BuiltinHooks struct {
|
||||
manager *Manager
|
||||
}
|
||||
|
||||
func NewBuiltinHooks(manager *Manager) *BuiltinHooks {
|
||||
return &BuiltinHooks{manager: manager}
|
||||
}
|
||||
|
||||
func (b *BuiltinHooks) RegisterAll() {
|
||||
b.registerLoggingHooks()
|
||||
b.registerCacheInvalidationHooks()
|
||||
}
|
||||
|
||||
func (b *BuiltinHooks) registerLoggingHooks() {
|
||||
b.manager.RegisterFunc(HookPostCreated, "log.post.created", func(ctx *HookContext) error {
|
||||
if data, ok := ctx.Data.(*PostHookData); ok {
|
||||
zap.L().Info("Post created",
|
||||
zap.Uint("post_id", data.PostID),
|
||||
zap.Uint("author_id", data.AuthorID),
|
||||
zap.String("title", data.Title),
|
||||
zap.Uint("user_id", ctx.UserID),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}, WithAsync(), WithPriority(PriorityLowest))
|
||||
|
||||
b.manager.RegisterFunc(HookUserLogin, "log.user.login", func(ctx *HookContext) error {
|
||||
if data, ok := ctx.Data.(*UserHookData); ok {
|
||||
zap.L().Info("User logged in",
|
||||
zap.Uint("user_id", data.UserID),
|
||||
zap.String("username", data.Username),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}, WithAsync())
|
||||
|
||||
b.manager.RegisterFunc(HookCommentCreated, "log.comment.created", func(ctx *HookContext) error {
|
||||
if data, ok := ctx.Data.(*CommentHookData); ok {
|
||||
zap.L().Info("Comment created",
|
||||
zap.Uint("comment_id", data.CommentID),
|
||||
zap.Uint("post_id", data.PostID),
|
||||
zap.Uint("author_id", data.AuthorID),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}, WithAsync(), WithPriority(PriorityLowest))
|
||||
}
|
||||
|
||||
func (b *BuiltinHooks) registerCacheInvalidationHooks() {
|
||||
}
|
||||
|
||||
type HookTriggerHelper struct {
|
||||
manager *Manager
|
||||
}
|
||||
|
||||
func NewHookTriggerHelper(manager *Manager) *HookTriggerHelper {
|
||||
return &HookTriggerHelper{manager: manager}
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerPostCreated(ctx context.Context, userID uint, data *PostHookData) {
|
||||
h.manager.Trigger(ctx, HookPostCreated, userID, data)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerPostUpdated(ctx context.Context, userID uint, data *PostHookData) {
|
||||
h.manager.Trigger(ctx, HookPostUpdated, userID, data)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerPostDeleted(ctx context.Context, userID uint, postID uint) {
|
||||
h.manager.Trigger(ctx, HookPostDeleted, userID, postID)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerCommentCreated(ctx context.Context, userID uint, data *CommentHookData) {
|
||||
h.manager.Trigger(ctx, HookCommentCreated, userID, data)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerCommentDeleted(ctx context.Context, userID uint, commentID uint) {
|
||||
h.manager.Trigger(ctx, HookCommentDeleted, userID, commentID)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerUserCreated(ctx context.Context, data *UserHookData) {
|
||||
h.manager.Trigger(ctx, HookUserCreated, data.UserID, data)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerUserUpdated(ctx context.Context, userID uint, data *UserHookData) {
|
||||
h.manager.Trigger(ctx, HookUserUpdated, userID, data)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerUserDeleted(ctx context.Context, userID uint) {
|
||||
h.manager.Trigger(ctx, HookUserDeleted, userID, userID)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerUserLogin(ctx context.Context, userID uint, data *UserHookData) {
|
||||
h.manager.Trigger(ctx, HookUserLogin, userID, data)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerUserLogout(ctx context.Context, userID uint) {
|
||||
h.manager.Trigger(ctx, HookUserLogout, userID, userID)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerGroupCreated(ctx context.Context, userID uint, data *GroupHookData) {
|
||||
h.manager.Trigger(ctx, HookGroupCreated, userID, data)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerGroupUpdated(ctx context.Context, userID uint, data *GroupHookData) {
|
||||
h.manager.Trigger(ctx, HookGroupUpdated, userID, data)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerGroupDeleted(ctx context.Context, userID uint, groupID uint) {
|
||||
h.manager.Trigger(ctx, HookGroupDeleted, userID, groupID)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerGroupJoined(ctx context.Context, userID uint, groupID uint) {
|
||||
h.manager.Trigger(ctx, HookGroupJoined, userID, groupID)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerGroupLeft(ctx context.Context, userID uint, groupID uint) {
|
||||
h.manager.Trigger(ctx, HookGroupLeft, userID, groupID)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerMessageSent(ctx context.Context, userID uint, data *MessageHookData) {
|
||||
h.manager.Trigger(ctx, HookMessageSent, userID, data)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerMessageRead(ctx context.Context, userID uint, messageID uint) {
|
||||
h.manager.Trigger(ctx, HookMessageRead, userID, messageID)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerNotificationSent(ctx context.Context, userID uint, data *NotificationHookData) {
|
||||
h.manager.Trigger(ctx, HookNotificationSent, userID, data)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerVoteCreated(ctx context.Context, userID uint, data *VoteHookData) {
|
||||
h.manager.Trigger(ctx, HookVoteCreated, userID, data)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerVoteUpdated(ctx context.Context, userID uint, data *VoteHookData) {
|
||||
h.manager.Trigger(ctx, HookVoteUpdated, userID, data)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerFileUploaded(ctx context.Context, userID uint, data *FileHookData) {
|
||||
h.manager.Trigger(ctx, HookFileUploaded, userID, data)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerPostPreModerate(ctx context.Context, userID uint, data *PostModerateHookData, result *ModerationResult) {
|
||||
h.manager.TriggerWithMetadata(ctx, HookPostPreModerate, userID, data, map[string]any{
|
||||
"result": result,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerPostModerated(ctx context.Context, userID uint, data *PostModeratedHookData) {
|
||||
h.manager.Trigger(ctx, HookPostModerated, userID, data)
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerCommentPreModerate(ctx context.Context, userID uint, data *CommentModerateHookData, result *ModerationResult) {
|
||||
h.manager.TriggerWithMetadata(ctx, HookCommentPreModerate, userID, data, map[string]any{
|
||||
"result": result,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HookTriggerHelper) TriggerCommentModerated(ctx context.Context, userID uint, data *CommentModeratedHookData) {
|
||||
h.manager.Trigger(ctx, HookCommentModerated, userID, data)
|
||||
}
|
||||
Reference in New Issue
Block a user