The moderation system now uses a "pass/review" strategy instead of "pass/review/block", treating hard violations the same as review (only routing to manual review rather than auto-rejecting). Add fallback-approve behavior in the hook system so slow moderation APIs never silently block normal content when they time out or get cancelled - timed-out moderation now approves with "ReviewedBy: system" instead of leaving the zero-value result that would reject content. Update post/comment/vote services and handlers to use the new unified behavior. Simplify moderation prompts to emphasize "宁可放过,不可误伤" (prefer false positives over false negatives).
432 lines
9.5 KiB
Go
432 lines
9.5 KiB
Go
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"
|
|
HookUserAvatarPreModerate HookType = "user.avatar.pre_moderate"
|
|
HookUserAvatarModerated HookType = "user.avatar.moderated"
|
|
HookUserCoverPreModerate HookType = "user.cover.pre_moderate"
|
|
HookUserCoverModerated HookType = "user.cover.moderated"
|
|
HookUserBioPreModerate HookType = "user.bio.pre_moderate"
|
|
HookUserBioModerated HookType = "user.bio.moderated"
|
|
)
|
|
|
|
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 (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
|
|
|
|
var firstSyncErr error
|
|
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 {
|
|
if firstSyncErr == nil {
|
|
firstSyncErr = err
|
|
}
|
|
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 firstSyncErr
|
|
}
|
|
|
|
func (m *Manager) executeHook(entry *hookEntry, ctx *HookContext) error {
|
|
start := time.Now()
|
|
|
|
// Derive a cancellable context bounded by the hook's Timeout so that
|
|
// long-running hook work (e.g. HTTP calls to the moderation API) is
|
|
// actually cancelled when the timeout fires, instead of leaking and
|
|
// racing on shared metadata after executeHook returns.
|
|
if entry.hook.Timeout > 0 {
|
|
hookCtx, cancel := context.WithTimeout(ctx.Ctx, entry.hook.Timeout)
|
|
defer cancel()
|
|
|
|
ctx = &HookContext{
|
|
Ctx: hookCtx,
|
|
HookType: ctx.HookType,
|
|
UserID: ctx.UserID,
|
|
Data: ctx.Data,
|
|
Metadata: ctx.Metadata,
|
|
}
|
|
}
|
|
|
|
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 <-ctx.Ctx.Done():
|
|
err = ctx.Ctx.Err()
|
|
zap.L().Warn("Hook timeout",
|
|
zap.String("name", entry.hook.Name),
|
|
zap.Duration("timeout", entry.hook.Timeout),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
// Wait for the spawned goroutine to observe cancellation and return,
|
|
// so it cannot keep writing to ctx.Metadata after executeHook returns.
|
|
<-done
|
|
} 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
|
|
}
|