feat(moderation): make moderation more lenient and add timeout fallback behavior
All checks were successful
Build Backend / build (push) Successful in 2m20s
Build Backend / build-docker (push) Successful in 1m7s

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:
lafay
2026-07-08 01:47:48 +08:00
parent eb931bf1c6
commit a971fb0e29
9 changed files with 324 additions and 267 deletions

View File

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