diff --git a/backend/internal/service/content_moderation.go b/backend/internal/service/content_moderation.go index acf38bf49b..b25f5ec4dc 100644 --- a/backend/internal/service/content_moderation.go +++ b/backend/internal/service/content_moderation.go @@ -2987,18 +2987,23 @@ type CyberPolicyRecordInput struct { // RecordCyberPolicyEvent 把一次 cyber_policy 硬阻断写入风控中心日志、计入违规计数、 // 并给用户发邮件。当前请求已由 gateway 透传给用户;本方法仅做事后记录/通知/计数。 -// 仅受 risk_control_enabled 总开关约束(不受内容审核 Enabled/Mode/scope/sample 约束)。 +// 受 risk_control_enabled 总开关和内容审核 group/model scope 约束, +// 不受内容审核 Enabled/Mode/sample 约束。 func (s *ContentModerationService) RecordCyberPolicyEvent(ctx context.Context, in CyberPolicyRecordInput) { if s == nil || s.repo == nil { return } - if !s.isRiskControlEnabled(ctx) { + runtimeSnapshot, err := s.loadRuntimeSnapshot(ctx) + if err != nil { + slog.Warn("content_moderation.cyber_runtime_snapshot_load_failed", "error", err) return } - cfg, err := s.loadConfig(ctx) - if err != nil { - slog.Warn("content_moderation.cyber_load_config_failed", "error", err) - cfg = &ContentModerationConfig{} + if !runtimeSnapshot.riskControlEnabled { + return + } + cfg := runtimeSnapshot.config + if !cfg.includesGroup(in.GroupID) || !cfg.includesModel(in.Model) { + return } var userID *int64 if in.UserID > 0 { diff --git a/backend/internal/service/content_moderation_cyber_test.go b/backend/internal/service/content_moderation_cyber_test.go index 3574b2b8f1..84ec5302d8 100644 --- a/backend/internal/service/content_moderation_cyber_test.go +++ b/backend/internal/service/content_moderation_cyber_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "strings" "sync" "testing" @@ -156,6 +157,136 @@ func TestRecordCyberPolicyEvent_WritesLogWhenEnabled(t *testing.T) { "Error should mention flagged or cyber_policy") } +func TestRecordCyberPolicyEvent_RespectsContentModerationScope(t *testing.T) { + groupID := int64(7) + tests := []struct { + name string + config string + groupID *int64 + model string + wantCalls []bool + wantLogs int + wantBanned bool + }{ + { + name: "excluded group", + config: `{"all_groups":false,"group_ids":[8],"ban_threshold":1}`, + groupID: &groupID, + model: "gpt-5", + wantLogs: 0, + }, + { + name: "ungrouped excluded by selected groups", + config: `{"all_groups":false,"group_ids":[7],"ban_threshold":1}`, + groupID: nil, + model: "gpt-5", + wantLogs: 0, + }, + { + name: "excluded model", + config: `{"all_groups":true,"model_filter":{"type":"include","models":["gpt-4o"]},"ban_threshold":1}`, + groupID: &groupID, + model: "gpt-5", + wantLogs: 0, + }, + { + name: "included group and model", + config: `{"enabled":false,"mode":"off","sample_rate":0,"all_groups":false,"group_ids":[7],"model_filter":{"type":"include","models":["gpt-5"]},"ban_threshold":1}`, + groupID: &groupID, + model: "gpt-5", + wantCalls: []bool{false}, + wantLogs: 1, + wantBanned: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &banCountArgsTestRepo{} + userRepo := &contentModerationTestUserRepo{user: &User{ID: 1, Role: RoleUser, Status: StatusActive}} + svc := NewContentModerationService( + &contentModerationTestSettingRepo{values: map[string]string{ + SettingKeyRiskControlEnabled: "true", + SettingKeyContentModerationConfig: tt.config, + }}, + repo, nil, nil, userRepo, nil, nil, nil, + ) + + svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{ + UserID: 1, + GroupID: tt.groupID, + Model: tt.model, + }) + + if tt.wantCalls == nil { + require.Empty(t, repo.snapshotCountCalls()) + } else { + require.Equal(t, tt.wantCalls, repo.snapshotCountCalls()) + } + require.Len(t, repo.snapshotLogs(), tt.wantLogs) + require.Equal(t, tt.wantBanned, userRepo.user.Status == StatusDisabled) + if tt.wantBanned { + require.Len(t, userRepo.updated, 1) + } else { + require.Empty(t, userRepo.updated) + } + }) + } +} + +func TestRecordCyberPolicyEvent_InitialRuntimeSnapshotLoadFailureSkipsEvent(t *testing.T) { + repo := &banCountArgsTestRepo{} + settingRepo := &contentModerationRuntimeSettingRepo{values: map[string]string{ + SettingKeyRiskControlEnabled: "true", + SettingKeyContentModerationConfig: `{invalid`, + }} + svc := NewContentModerationService(settingRepo, repo, nil, nil, nil, nil, nil, nil) + + svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{ + UserID: 1, + Model: "gpt-5", + }) + + require.Empty(t, repo.snapshotCountCalls()) + require.Empty(t, repo.snapshotLogs()) + getValue, getMultiple := settingRepo.calls() + require.Zero(t, getValue) + require.GreaterOrEqual(t, getMultiple, 1) +} + +func TestRecordCyberPolicyEvent_RuntimeSnapshotRefreshFailureKeepsStaleScope(t *testing.T) { + repo := &banCountArgsTestRepo{} + settingRepo := &contentModerationRuntimeSettingRepo{values: map[string]string{ + SettingKeyRiskControlEnabled: "true", + SettingKeyContentModerationConfig: `{"all_groups":true,"model_filter":{"type":"include","models":["gpt-5"]}}`, + }} + svc := NewContentModerationService(settingRepo, repo, nil, nil, nil, nil, nil, nil) + svc.runtimeCacheTTL = time.Minute + + _, err := svc.loadRuntimeSnapshot(context.Background()) + require.NoError(t, err) + current := svc.runtimeSnapshot.Load() + require.NotNil(t, current) + expired := *current + expired.loadedAt = time.Now().Add(-2 * time.Minute) + svc.runtimeSnapshot.Store(&expired) + settingRepo.failMultiple(errors.New("database unavailable")) + + svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{ + UserID: 1, + Model: "gpt-5", + }) + + require.Len(t, repo.snapshotLogs(), 1) + require.Eventually(t, func() bool { + _, calls := settingRepo.calls() + return calls == 2 + }, time.Second, time.Millisecond) + getValue, getMultiple := settingRepo.calls() + require.Zero(t, getValue) + require.Equal(t, 2, getMultiple) +} + // TestRecordCyberPolicyEvent_CreateLogBeforeEmail verifies F7: the moderation // log is persisted BEFORE email delivery, and EmailSent is patched afterwards — // SMTP hangs can no longer swallow the audit record.