feat(moderation): make moderation more lenient and add timeout fallback behavior
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).
This commit is contained in:
@@ -215,6 +215,7 @@ func (m *Manager) TriggerWithMetadata(ctx context.Context, hookType HookType, us
|
||||
|
||||
var asyncHooks []*hookEntry
|
||||
|
||||
var firstSyncErr error
|
||||
for _, entry := range entries {
|
||||
if !entry.hook.Enabled {
|
||||
continue
|
||||
@@ -226,6 +227,9 @@ func (m *Manager) TriggerWithMetadata(ctx context.Context, hookType HookType, us
|
||||
}
|
||||
|
||||
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)),
|
||||
@@ -256,12 +260,29 @@ func (m *Manager) TriggerWithMetadata(ctx context.Context, hookType HookType, us
|
||||
}()
|
||||
}
|
||||
|
||||
return nil
|
||||
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)
|
||||
@@ -280,13 +301,17 @@ func (m *Manager) executeHook(entry *hookEntry, ctx *HookContext) error {
|
||||
|
||||
select {
|
||||
case err = <-done:
|
||||
case <-time.After(entry.hook.Timeout):
|
||||
err = context.DeadlineExceeded
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -382,40 +383,69 @@ func (m *ModerationHooks) RegisterAll(manager *Manager) {
|
||||
|
||||
func (m *ModerationHooks) ModeratePost(ctx context.Context, manager *Manager, data *PostModerateHookData) *ModerationResult {
|
||||
result := &ModerationResult{}
|
||||
manager.TriggerWithMetadata(ctx, HookPostPreModerate, data.AuthorID, data, map[string]any{
|
||||
if err := manager.TriggerWithMetadata(ctx, HookPostPreModerate, data.AuthorID, data, map[string]any{
|
||||
"result": result,
|
||||
})
|
||||
}); err != nil {
|
||||
applyModerationFallback(result, err)
|
||||
}
|
||||
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{
|
||||
if err := manager.TriggerWithMetadata(ctx, HookCommentPreModerate, data.AuthorID, data, map[string]any{
|
||||
"result": result,
|
||||
})
|
||||
}); err != nil {
|
||||
applyModerationFallback(result, err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *ModerationHooks) ModerateAvatar(ctx context.Context, manager *Manager, data *UserAvatarModerateHookData) *ModerationResult {
|
||||
result := &ModerationResult{}
|
||||
manager.TriggerWithMetadata(ctx, HookUserAvatarPreModerate, 0, data, map[string]any{
|
||||
if err := manager.TriggerWithMetadata(ctx, HookUserAvatarPreModerate, 0, data, map[string]any{
|
||||
"result": result,
|
||||
})
|
||||
}); err != nil {
|
||||
applyModerationFallback(result, err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *ModerationHooks) ModerateCover(ctx context.Context, manager *Manager, data *UserCoverModerateHookData) *ModerationResult {
|
||||
result := &ModerationResult{}
|
||||
manager.TriggerWithMetadata(ctx, HookUserCoverPreModerate, 0, data, map[string]any{
|
||||
if err := manager.TriggerWithMetadata(ctx, HookUserCoverPreModerate, 0, data, map[string]any{
|
||||
"result": result,
|
||||
})
|
||||
}); err != nil {
|
||||
applyModerationFallback(result, err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *ModerationHooks) ModerateBio(ctx context.Context, manager *Manager, data *UserBioModerateHookData) *ModerationResult {
|
||||
result := &ModerationResult{}
|
||||
manager.TriggerWithMetadata(ctx, HookUserBioPreModerate, 0, data, map[string]any{
|
||||
if err := manager.TriggerWithMetadata(ctx, HookUserBioPreModerate, 0, data, map[string]any{
|
||||
"result": result,
|
||||
})
|
||||
}); err != nil {
|
||||
applyModerationFallback(result, err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// applyModerationFallback handles moderation hook timeouts/cancellations by
|
||||
// falling back to "approved by system" instead of leaving the zero-value
|
||||
// result (Approved=false) which would silently reject otherwise-fine content.
|
||||
func applyModerationFallback(result *ModerationResult, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
result.Approved = true
|
||||
result.ReviewedBy = "system"
|
||||
result.RejectReason = ""
|
||||
result.NeedsReview = false
|
||||
result.Error = err
|
||||
zap.L().Warn("Moderation hook timed out or was cancelled, fallback approve",
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user