fix(risk-control): 收窄风控 fail-closed 范围并补齐审计异常拦截

#5394 把配置加载失败改成 fail-closed,但该分支位于 risk_control_enabled 与
cfg.Enabled 两道闸门之前,导致从未开启风控的部署也会被 500 打死;同时它没有覆盖
#5388 实际抱怨的场景——审计 API 调用失败仍然放行。

- 配置加载失败恢复 fail-open:loadRuntimeSnapshot 仅在冷启动无快照时报错,此刻
  无从得知运维的阻断意图,拦截会让一次设置读取抖动、或一行损坏的配置 JSON 演变
  成全站 5xx(后者确定性失败,快照永远建不起来,构成永久中断)
- 新增 fail_closed_on_error 开关(默认关):仅在「审计启用 + pre_block 模式」下
  生效,覆盖审计 API 调用失败与无可用审计 Key 两条路径,这才是 #5388 的场景
- fail-closed 响应改为 503 + content_moderation_unavailable,与 prompt-guard 既有
  的不可用语义对齐,不再把系统故障伪装成 content_policy_violation
- Flagged 恒为 false 且不写 flagged 日志,故障拒绝不进入自动封号计数
- 异步 worker 与 observe 模式永不 fail-closed(无可拒绝的客户端)

Refs #5388, #5394
This commit is contained in:
shaw
2026-08-10 09:44:22 +08:00
parent b5e83156d9
commit 7d036c0863
13 changed files with 512 additions and 38 deletions
@@ -47,14 +47,16 @@ type contentModerationConfigRequest struct {
ViolationWindowHours *int `json:"violation_window_hours"`
// cyber_policy 命中是否排除出自动封号计数;前端 RiskControlView 已发送该字段,
// service.UpdateContentModerationConfigInput 已支持,此前 handler 层缺透传导致开关静默失效。
CyberPolicyExcludeFromBanCount *bool `json:"cyber_policy_exclude_from_ban_count"`
RetryCount *int `json:"retry_count"`
HitRetentionDays *int `json:"hit_retention_days"`
NonHitRetentionDays *int `json:"non_hit_retention_days"`
PreHashCheckEnabled *bool `json:"pre_hash_check_enabled"`
BlockedKeywords *[]string `json:"blocked_keywords"`
KeywordBlockingMode *string `json:"keyword_blocking_mode"`
ModelFilter *service.ContentModerationModelFilter `json:"model_filter"`
CyberPolicyExcludeFromBanCount *bool `json:"cyber_policy_exclude_from_ban_count"`
// 审计链路自身异常(审计 API 失败、无可用审计 Key)时是否拒绝请求。
FailClosedOnError *bool `json:"fail_closed_on_error"`
RetryCount *int `json:"retry_count"`
HitRetentionDays *int `json:"hit_retention_days"`
NonHitRetentionDays *int `json:"non_hit_retention_days"`
PreHashCheckEnabled *bool `json:"pre_hash_check_enabled"`
BlockedKeywords *[]string `json:"blocked_keywords"`
KeywordBlockingMode *string `json:"keyword_blocking_mode"`
ModelFilter *service.ContentModerationModelFilter `json:"model_filter"`
}
type contentModerationAPIKeyTestRequest struct {
@@ -112,6 +114,7 @@ func (h *ContentModerationHandler) UpdateConfig(c *gin.Context) {
BanThreshold: req.BanThreshold,
ViolationWindowHours: req.ViolationWindowHours,
CyberPolicyExcludeFromBanCount: req.CyberPolicyExcludeFromBanCount,
FailClosedOnError: req.FailClosedOnError,
RetryCount: req.RetryCount,
HitRetentionDays: req.HitRetentionDays,
NonHitRetentionDays: req.NonHitRetentionDays,
@@ -19,7 +19,15 @@ func contentModerationStatus(decision *service.ContentModerationDecision) int {
return decision.StatusCode
}
// contentModerationErrorCode 优先使用决策自带的错误码,只有内容策略拦截才回退到
// content_policy_violation。审计链路故障(fail-closed)会带
// content_moderation_unavailable,不能让客户端以为自己触犯了内容策略。
func contentModerationErrorCode(decision *service.ContentModerationDecision) string {
if decision != nil {
if code := strings.TrimSpace(decision.ErrorCode); code != "" {
return code
}
}
return "content_policy_violation"
}
@@ -65,10 +65,10 @@ func runSecurityAudit(c *gin.Context, reqLog *zap.Logger, coordinator *securitya
decision.Legacy = &securityaudit.LegacyDecision{
Allowed: legacyDecision.Allowed, Blocked: legacyDecision.Blocked, Flagged: legacyDecision.Flagged,
Message: legacyDecision.Message, StatusCode: legacyDecision.StatusCode,
ErrorCode: "content_policy_violation", Action: legacyDecision.Action,
ErrorCode: contentModerationErrorCode(legacyDecision), Action: legacyDecision.Action,
}
if legacyDecision.Blocked {
decision.Kind, decision.HTTPStatus, decision.ErrorCode, decision.ClientMessage, decision.AllowNextStage = securityaudit.DecisionBlock, contentModerationStatus(legacyDecision), "content_policy_violation", legacyDecision.Message, false
decision.Kind, decision.HTTPStatus, decision.ErrorCode, decision.ClientMessage, decision.AllowNextStage = securityaudit.DecisionBlock, contentModerationStatus(legacyDecision), contentModerationErrorCode(legacyDecision), legacyDecision.Message, false
}
if decision.AllowNextStage && cacheCompletion {
c.Set(securityAuditCompletedContextKey, true)
@@ -2,6 +2,7 @@ package securityaudit
import (
"context"
"strings"
"github.com/Wei-Shaw/sub2api/internal/service"
)
@@ -27,9 +28,24 @@ func (a *LegacyModerationAdapter) Check(ctx context.Context, req Request) (*Lega
if err != nil || decision == nil {
return nil, err
}
return legacyDecisionFromModeration(decision), nil
}
// legacyDecisionFromModeration 把内容审计决策映射成协调器决策。
//
// 审计链路故障(fail-closed)自带 content_moderation_unavailable;内容策略拦截不带
// 错误码,回退到 content_policy_violation。
func legacyDecisionFromModeration(decision *service.ContentModerationDecision) *LegacyDecision {
if decision == nil {
return nil
}
errorCode := strings.TrimSpace(decision.ErrorCode)
if errorCode == "" {
errorCode = "content_policy_violation"
}
return &LegacyDecision{
Allowed: decision.Allowed, Blocked: decision.Blocked, Flagged: decision.Flagged,
Message: decision.Message, StatusCode: decision.StatusCode,
ErrorCode: "content_policy_violation", Action: decision.Action,
}, nil
ErrorCode: errorCode, Action: decision.Action,
}
}
@@ -0,0 +1,52 @@
package securityaudit
import (
"net/http"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
// LegacyModerationAdapter 必须透传决策自带的错误码。审计链路故障(fail-closed)带
// content_moderation_unavailable,不能被压成 content_policy_violation —— 否则客户端
// 会把一次系统不可用当成自己触犯了内容策略。
func TestLegacyModerationAdapter_PropagatesDecisionErrorCode(t *testing.T) {
t.Run("内容策略拦截回退到 content_policy_violation", func(t *testing.T) {
decision := &service.ContentModerationDecision{
Blocked: true, Flagged: true, StatusCode: http.StatusForbidden,
Message: "blocked", Action: service.ContentModerationActionBlock,
}
got := legacyDecisionFromModeration(decision)
require.Equal(t, "content_policy_violation", got.ErrorCode)
})
t.Run("审计链路故障保留独立错误码", func(t *testing.T) {
decision := &service.ContentModerationDecision{
Blocked: true, Flagged: false, StatusCode: http.StatusServiceUnavailable,
Message: service.ContentModerationUnavailableMessage,
Action: service.ContentModerationActionError,
ErrorCode: service.ContentModerationErrorCodeUnavailable,
}
got := legacyDecisionFromModeration(decision)
require.Equal(t, service.ContentModerationErrorCodeUnavailable, got.ErrorCode)
require.False(t, got.Flagged, "系统故障不是内容违规")
})
}
// prioritize 必须把 legacy 的 503 与错误码原样带到网关响应上,而不是压成 403。
func TestPrioritize_KeepsModerationUnavailableStatusAndCode(t *testing.T) {
legacy := &LegacyDecision{
Blocked: true, StatusCode: http.StatusServiceUnavailable,
Message: service.ContentModerationUnavailableMessage,
ErrorCode: service.ContentModerationErrorCodeUnavailable,
Action: service.ContentModerationActionError,
}
decision := prioritize(legacy, nil)
require.Equal(t, DecisionBlock, decision.Kind)
require.Equal(t, http.StatusServiceUnavailable, decision.HTTPStatus)
require.Equal(t, service.ContentModerationErrorCodeUnavailable, decision.ErrorCode)
require.False(t, decision.AllowNextStage)
}
+73 -9
View File
@@ -41,6 +41,14 @@ const (
ContentModerationActionError = "error"
ContentModerationActionCyberPolicy = "cyber_policy" // cyber_policy 硬阻断的风控日志 action(封号计数排除按此值过滤)
// ContentModerationErrorCodeUnavailable 标记"审计链路自身故障导致的拒绝"。
// 与 content_policy_violation 区分开:前者是系统不可用(503,客户端可重试),
// 后者是内容命中策略(403,重试无用)。
ContentModerationErrorCodeUnavailable = "content_moderation_unavailable"
// ContentModerationUnavailableMessage 是 fail-closed 时返回给客户端的文案。
// 不复用 cfg.BlockMessage:那是给违规内容看的,会误导用户以为自己触犯了策略。
ContentModerationUnavailableMessage = "风控审计服务暂时不可用,请稍后重试"
contentModerationKeywordCategory = "keyword"
ContentModerationKeywordModeKeywordOnly = "keyword_only"
@@ -172,6 +180,36 @@ type ContentModerationConfig struct {
// 当次不判定封号,且历史 cyber 行在 CountFlaggedByUserSince 中被排除。
// 默认 false(计入,与历史行为一致;旧配置 JSON 无此字段时反序列化为 false)。
CyberPolicyExcludeFromBanCount bool `json:"cyber_policy_exclude_from_ban_count"`
// FailClosedOnError 为 true 时,pre_block 模式下审计链路自身异常(审计 API 调用
// 失败、无可用审计 Key)拒绝请求而不是放行,返回 503 +
// content_moderation_unavailable。默认 false(放行,与历史行为一致;旧配置 JSON
// 无此字段时反序列化为 false)——这是可用性开关,必须由运维显式打开。
FailClosedOnError bool `json:"fail_closed_on_error"`
}
// failClosedOnError 表示"审计链路自身异常时拒绝请求"的运维意图。
//
// 三个条件缺一不可:审计总开关开着、运维显式打开了 fail-closed、且处于同步前置
// 拦截模式。observe 模式本就没有阻断语义,异步 worker 也没有可拒绝的客户端,
// 对它们 fail-closed 毫无意义。
func (c *ContentModerationConfig) failClosedOnError() bool {
return c != nil && c.Enabled && c.FailClosedOnError && c.Mode == ContentModerationModePreBlock
}
// contentModerationUnavailableDecision 构造 fail-closed 决策。
//
// Flagged 恒为 false 且调用方不写 flagged 日志行,因此这类拒绝永远不会进入
// CountFlaggedByUserSince 的自动封号计数——系统故障不该把用户封掉。
func contentModerationUnavailableDecision() *ContentModerationDecision {
return &ContentModerationDecision{
Allowed: false,
Blocked: true,
Flagged: false,
Message: ContentModerationUnavailableMessage,
StatusCode: http.StatusServiceUnavailable,
Action: ContentModerationActionError,
ErrorCode: ContentModerationErrorCodeUnavailable,
}
}
type ContentModerationConfigView struct {
@@ -207,6 +245,7 @@ type ContentModerationConfigView struct {
KeywordBlockingMode string `json:"keyword_blocking_mode"`
ModelFilter ContentModerationModelFilter `json:"model_filter"`
CyberPolicyExcludeFromBanCount bool `json:"cyber_policy_exclude_from_ban_count"`
FailClosedOnError bool `json:"fail_closed_on_error"`
}
type ContentModerationAPIKeyStatus struct {
@@ -299,6 +338,7 @@ type UpdateContentModerationConfigInput struct {
KeywordBlockingMode *string `json:"keyword_blocking_mode"`
ModelFilter *ContentModerationModelFilter `json:"model_filter"`
CyberPolicyExcludeFromBanCount *bool `json:"cyber_policy_exclude_from_ban_count"`
FailClosedOnError *bool `json:"fail_closed_on_error"`
}
type ContentModerationModelFilter struct {
@@ -383,6 +423,10 @@ type ContentModerationDecision struct {
HighestScore float64 `json:"highest_score"`
CategoryScores map[string]float64 `json:"category_scores"`
Action string `json:"action"`
// ErrorCode 为空时调用方回退到 content_policy_violation。审计链路自身故障
// (fail-closed)填 ContentModerationErrorCodeUnavailable,避免把系统不可用
// 伪装成内容违规。
ErrorCode string `json:"error_code,omitempty"`
}
type ContentModerationLog struct {
@@ -699,6 +743,9 @@ func (s *ContentModerationService) UpdateConfig(ctx context.Context, input Updat
if input.CyberPolicyExcludeFromBanCount != nil {
cfg.CyberPolicyExcludeFromBanCount = *input.CyberPolicyExcludeFromBanCount
}
if input.FailClosedOnError != nil {
cfg.FailClosedOnError = *input.FailClosedOnError
}
if input.Thresholds != nil {
cfg.Thresholds = mergeContentModerationThresholds(ContentModerationDefaultThresholds(), *input.Thresholds)
}
@@ -824,21 +871,20 @@ func (s *ContentModerationService) Check(ctx context.Context, input ContentModer
}
runtimeSnapshot, err := s.loadRuntimeSnapshot(ctx)
if err != nil {
slog.Warn("content_moderation.skip_config_load_failed",
// 这里必须放行。loadRuntimeSnapshot 只在"进程冷启动、快照尚未建立"时返回错误
// (已有快照时它返回陈旧快照并后台刷新,不返回错误),此刻我们既不知道
// risk_control_enabled,也不知道 fail_closed_on_error,无法确认运维的阻断意图。
// 若在此拦截,一次设置读取抖动、或一行损坏的 content_moderation_config JSON,
// 就会让全部网关流量(含从未开启风控的部署)返回 5xx。
// 审计链路自身异常的 fail-closed 由 cfg.failClosedOnError() 在配置已知时处理。
slog.Warn("content_moderation.allow_config_load_failed",
"user_id", input.UserID,
"api_key_id", input.APIKeyID,
"group_id", contentModerationLogGroupID(input.GroupID),
"endpoint", input.Endpoint,
"protocol", input.Protocol,
"error", err)
return &ContentModerationDecision{
Allowed: false,
Blocked: true,
Flagged: false,
Message: "风控系统暂时不可用,请稍后重试",
StatusCode: http.StatusInternalServerError,
Action: ContentModerationActionError,
}, nil
return allow, nil
}
if !runtimeSnapshot.riskControlEnabled {
slog.Info("content_moderation.skip_feature_disabled",
@@ -1028,6 +1074,15 @@ func (s *ContentModerationService) Check(ctx context.Context, input ContentModer
if cfg.Mode == ContentModerationModePreBlock {
s.recordPreBlockSyncMetric(0, ContentModerationActionError)
}
if cfg.failClosedOnError() {
slog.Warn("content_moderation.block_no_audit_api_keys",
"user_id", input.UserID,
"api_key_id", input.APIKeyID,
"group_id", contentModerationLogGroupID(input.GroupID),
"endpoint", input.Endpoint,
"protocol", input.Protocol)
return contentModerationUnavailableDecision(), nil
}
slog.Warn("content_moderation.skip_no_audit_api_keys",
"user_id", input.UserID,
"api_key_id", input.APIKeyID,
@@ -1075,6 +1130,7 @@ func (s *ContentModerationService) checkSync(ctx context.Context, input ContentM
"allow_block", allowBlock,
"queue_delay_ms", queueDelay,
"latency_ms", latency,
"fail_closed", trackPreBlock && cfg.failClosedOnError(),
"error", err)
if queueDelay != nil {
s.asyncErrors.Add(1)
@@ -1083,6 +1139,12 @@ func (s *ContentModerationService) checkSync(ctx context.Context, input ContentM
log := s.buildLog(input, cfg, ContentModerationActionError, false, "", 0, nil, content.ExcerptText(), &latency, queueDelay, err.Error())
_ = s.repo.CreateLog(ctx, log)
}
// trackPreBlock 恰好等价于"同步前置拦截路径"(queueDelay == nil && allowBlock
// && Mode == pre_block),异步 worker 与 observe 模式因此永远走放行分支——
// 它们没有可拒绝的客户端。
if trackPreBlock && cfg.failClosedOnError() {
return contentModerationUnavailableDecision()
}
return allow
}
@@ -2114,6 +2176,7 @@ func defaultContentModerationConfig() *ContentModerationConfig {
Models: []string{},
},
CyberPolicyExcludeFromBanCount: false,
FailClosedOnError: false,
}
}
@@ -2446,6 +2509,7 @@ func (s *ContentModerationService) configView(cfg *ContentModerationConfig) *Con
KeywordBlockingMode: cfg.KeywordBlockingMode,
ModelFilter: cloneContentModerationModelFilter(cfg.ModelFilter),
CyberPolicyExcludeFromBanCount: cfg.CyberPolicyExcludeFromBanCount,
FailClosedOnError: cfg.FailClosedOnError,
}
}
@@ -0,0 +1,267 @@
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
// newFailClosedTestService 起一个把审计 API 指向"已关闭端口"的服务:callModeration
// 必定失败(连接被拒),用来稳定复现"风控后端异常"。
func newFailClosedTestService(t *testing.T, mutate func(cfg *ContentModerationConfig)) (*ContentModerationService, *contentModerationTestRepo) {
t.Helper()
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
deadURL := dead.URL
dead.Close() // 端口关闭 -> 后续调用连接被拒
cfg := defaultContentModerationConfig()
cfg.Enabled = true
cfg.Mode = ContentModerationModePreBlock
cfg.BaseURL = deadURL
cfg.APIKeys = []string{"sk-test"}
cfg.RetryCount = 0
cfg.TimeoutMS = 500
if mutate != nil {
mutate(cfg)
}
rawCfg, err := json.Marshal(cfg)
require.NoError(t, err)
repo := &contentModerationTestRepo{}
svc := NewContentModerationService(
&contentModerationTestSettingRepo{values: map[string]string{
SettingKeyRiskControlEnabled: "true",
SettingKeyContentModerationConfig: string(rawCfg),
}},
repo, &contentModerationTestHashCache{}, nil, nil, nil, nil, nil,
)
return svc, repo
}
func failClosedTestInput() ContentModerationCheckInput {
return ContentModerationCheckInput{
UserID: 1,
Endpoint: "/v1/messages",
Provider: "anthropic",
Protocol: ContentModerationProtocolAnthropicMessages,
Body: []byte(`{"messages":[{"role":"user","content":"hello"}]}`),
}
}
// 回归护栏(#5394):配置加载失败时必须放行。此时快照尚未建立,我们无从得知
// risk_control_enabled 与 fail_closed_on_error,无法确认运维的阻断意图;若拦截,
// 一次设置读取抖动就会让全部网关流量(含从未开启风控的部署)返回 5xx。
func TestContentModerationCheck_ConfigLoadFailureAllows(t *testing.T) {
svc := &ContentModerationService{
settingRepo: &contentModerationTestSettingRepo{
values: map[string]string{SettingKeyRiskControlEnabled: "false"},
err: fmt.Errorf("settings unavailable"),
},
repo: &contentModerationTestRepo{},
}
decision, err := svc.Check(context.Background(), failClosedTestInput())
require.NoError(t, err)
require.True(t, decision.Allowed, "配置加载失败不得阻断流量:意图未知时只能放行")
require.False(t, decision.Blocked)
require.Equal(t, ContentModerationActionAllow, decision.Action)
}
// 损坏的 content_moderation_config JSON 是确定性失败,快照永远建不起来。若在此
// fail-closed,会造成永久性全站中断,且管理员被锁在修复界面之外。
func TestContentModerationCheck_MalformedConfigJSONAllows(t *testing.T) {
svc := &ContentModerationService{
settingRepo: &contentModerationTestSettingRepo{values: map[string]string{
SettingKeyRiskControlEnabled: "true",
SettingKeyContentModerationConfig: `{"enabled": tru`,
}},
repo: &contentModerationTestRepo{},
}
for i := 0; i < 3; i++ {
decision, err := svc.Check(context.Background(), failClosedTestInput())
require.NoError(t, err)
require.True(t, decision.Allowed, "第 %d 次请求:损坏配置不得演变成永久中断", i+1)
}
}
// 默认行为不变:审计 API 挂掉照旧放行,升级不会给存量部署带来可用性变化。
func TestContentModerationCheck_AuditAPIFailureFailsOpenByDefault(t *testing.T) {
svc, repo := newFailClosedTestService(t, nil) // FailClosedOnError 默认 false
decision, err := svc.Check(context.Background(), failClosedTestInput())
require.NoError(t, err)
require.True(t, decision.Allowed, "默认必须 fail-open")
require.False(t, decision.Blocked)
require.Empty(t, repo.snapshotLogs(), "RecordNonHits 关闭时不写日志")
}
// #5388 的真正修复:开关打开后,审计 API 异常必须拒绝请求。
func TestContentModerationCheck_AuditAPIFailureFailsClosedWhenEnabled(t *testing.T) {
svc, repo := newFailClosedTestService(t, func(cfg *ContentModerationConfig) {
cfg.FailClosedOnError = true
})
decision, err := svc.Check(context.Background(), failClosedTestInput())
require.NoError(t, err)
require.False(t, decision.Allowed)
require.True(t, decision.Blocked, "开关打开后审计后端异常必须阻断")
require.Equal(t, http.StatusServiceUnavailable, decision.StatusCode, "系统不可用用 503,不用 403/500")
require.Equal(t, ContentModerationErrorCodeUnavailable, decision.ErrorCode)
require.Equal(t, ContentModerationUnavailableMessage, decision.Message)
require.Equal(t, ContentModerationActionError, decision.Action)
require.False(t, decision.Flagged, "系统故障不是内容违规,Flagged 必须为 false")
for _, log := range repo.snapshotLogs() {
require.False(t, log.Flagged, "fail-closed 不得写 flagged 日志,否则会喂给自动封号计数")
}
}
// RecordNonHits 打开时仍会落一条 error 日志,但必须是非 flagged 的,
// 不能进入 CountFlaggedByUserSince 的封号计数。
func TestContentModerationCheck_FailClosedLogIsNotFlagged(t *testing.T) {
svc, repo := newFailClosedTestService(t, func(cfg *ContentModerationConfig) {
cfg.FailClosedOnError = true
cfg.RecordNonHits = true
})
decision, err := svc.Check(context.Background(), failClosedTestInput())
require.NoError(t, err)
require.True(t, decision.Blocked)
logs := repo.snapshotLogs()
require.Len(t, logs, 1)
require.False(t, logs[0].Flagged, "error 日志必须非 flagged")
require.Equal(t, ContentModerationActionError, logs[0].Action)
}
// observe 模式没有阻断语义,开关打开也不得拦截。
func TestContentModerationCheck_FailClosedIgnoredInObserveMode(t *testing.T) {
svc, _ := newFailClosedTestService(t, func(cfg *ContentModerationConfig) {
cfg.FailClosedOnError = true
cfg.Mode = ContentModerationModeObserve
})
decision, err := svc.Check(context.Background(), failClosedTestInput())
require.NoError(t, err)
require.True(t, decision.Allowed, "observe 模式永不阻断")
}
// 一个审计 Key 都没配:默认放行,开关打开则拒绝。
func TestContentModerationCheck_NoAuditKeysRespectsFailClosedSwitch(t *testing.T) {
t.Run("默认放行", func(t *testing.T) {
svc, _ := newFailClosedTestService(t, func(cfg *ContentModerationConfig) {
cfg.APIKeys = []string{}
})
decision, err := svc.Check(context.Background(), failClosedTestInput())
require.NoError(t, err)
require.True(t, decision.Allowed)
})
t.Run("开关打开则拒绝", func(t *testing.T) {
svc, _ := newFailClosedTestService(t, func(cfg *ContentModerationConfig) {
cfg.APIKeys = []string{}
cfg.FailClosedOnError = true
})
decision, err := svc.Check(context.Background(), failClosedTestInput())
require.NoError(t, err)
require.True(t, decision.Blocked)
require.Equal(t, http.StatusServiceUnavailable, decision.StatusCode)
require.Equal(t, ContentModerationErrorCodeUnavailable, decision.ErrorCode)
})
}
// 审计总开关关闭时,无论 fail_closed_on_error 如何都不得阻断。
func TestContentModerationCheck_FailClosedIgnoredWhenModerationDisabled(t *testing.T) {
svc, _ := newFailClosedTestService(t, func(cfg *ContentModerationConfig) {
cfg.Enabled = false
cfg.FailClosedOnError = true
})
decision, err := svc.Check(context.Background(), failClosedTestInput())
require.NoError(t, err)
require.True(t, decision.Allowed, "审计未启用的部署永远不受 fail-closed 影响")
}
// 风控总开关(risk_control_enabled)关闭时同样不得阻断。
func TestContentModerationCheck_FailClosedIgnoredWhenRiskControlDisabled(t *testing.T) {
cfg := defaultContentModerationConfig()
cfg.Enabled = true
cfg.Mode = ContentModerationModePreBlock
cfg.FailClosedOnError = true
cfg.APIKeys = []string{}
rawCfg, err := json.Marshal(cfg)
require.NoError(t, err)
svc := NewContentModerationService(
&contentModerationTestSettingRepo{values: map[string]string{
SettingKeyRiskControlEnabled: "false",
SettingKeyContentModerationConfig: string(rawCfg),
}},
&contentModerationTestRepo{}, &contentModerationTestHashCache{}, nil, nil, nil, nil, nil,
)
decision, err := svc.Check(context.Background(), failClosedTestInput())
require.NoError(t, err)
require.True(t, decision.Allowed, "风控中心未启用的部署永远不受 fail-closed 影响")
}
func TestContentModerationConfig_FailClosedOnErrorMatrix(t *testing.T) {
build := func(enabled, failClosed bool, mode string) *ContentModerationConfig {
cfg := defaultContentModerationConfig()
cfg.Enabled = enabled
cfg.FailClosedOnError = failClosed
cfg.Mode = mode
return cfg
}
require.True(t, build(true, true, ContentModerationModePreBlock).failClosedOnError())
require.False(t, build(true, false, ContentModerationModePreBlock).failClosedOnError(), "开关关闭")
require.False(t, build(false, true, ContentModerationModePreBlock).failClosedOnError(), "审计未启用")
require.False(t, build(true, true, ContentModerationModeObserve).failClosedOnError(), "observe 模式")
require.False(t, build(true, true, ContentModerationModeOff).failClosedOnError(), "off 模式")
var nilCfg *ContentModerationConfig
require.False(t, nilCfg.failClosedOnError(), "nil 配置不得 panic")
}
func TestContentModerationUpdateConfig_FailClosedOnError(t *testing.T) {
repo := &contentModerationTestSettingRepo{values: map[string]string{}}
svc := NewContentModerationService(repo, &contentModerationTestRepo{}, nil, nil, nil, nil, nil, nil)
ctx := context.Background()
view, err := svc.GetConfig(ctx)
require.NoError(t, err)
require.False(t, view.FailClosedOnError, "默认必须 fail-open")
enable := true
view, err = svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{FailClosedOnError: &enable})
require.NoError(t, err)
require.True(t, view.FailClosedOnError)
saved := &ContentModerationConfig{}
require.NoError(t, json.Unmarshal([]byte(repo.values[SettingKeyContentModerationConfig]), saved))
require.True(t, saved.FailClosedOnError, "必须持久化")
// 未携带该字段的后续更新不得把它冲掉。
view, err = svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{})
require.NoError(t, err)
require.True(t, view.FailClosedOnError)
disable := false
view, err = svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{FailClosedOnError: &disable})
require.NoError(t, err)
require.False(t, view.FailClosedOnError)
}
@@ -57,23 +57,6 @@ func (r *contentModerationTestSettingRepo) GetMultiple(ctx context.Context, keys
return out, nil
}
func TestContentModerationCheck_LoadRuntimeSnapshotFailureBlocks(t *testing.T) {
svc := &ContentModerationService{
settingRepo: &contentModerationTestSettingRepo{err: fmt.Errorf("settings unavailable")},
repo: &contentModerationTestRepo{},
}
decision, err := svc.Check(context.Background(), ContentModerationCheckInput{UserID: 1})
require.NoError(t, err)
require.False(t, decision.Allowed)
require.True(t, decision.Blocked)
require.False(t, decision.Flagged)
require.Equal(t, ContentModerationActionError, decision.Action)
require.Equal(t, "风控系统暂时不可用,请稍后重试", decision.Message)
require.Equal(t, http.StatusInternalServerError, decision.StatusCode)
}
func (r *contentModerationTestSettingRepo) SetMultiple(ctx context.Context, settings map[string]string) error {
if r.values == nil {
r.values = map[string]string{}
+2
View File
@@ -42,6 +42,7 @@ export interface ContentModerationConfig {
keyword_blocking_mode: KeywordBlockingMode
model_filter: ContentModerationModelFilter
cyber_policy_exclude_from_ban_count: boolean
fail_closed_on_error: boolean
}
export type ContentModerationAPIKeyStatusValue = 'unknown' | 'ok' | 'error' | 'frozen'
@@ -122,6 +123,7 @@ export interface UpdateContentModerationConfig {
keyword_blocking_mode?: KeywordBlockingMode
model_filter?: ContentModerationModelFilter
cyber_policy_exclude_from_ban_count?: boolean
fail_closed_on_error?: boolean
}
export interface ContentModerationRuntimeStatus {
@@ -314,6 +314,8 @@ export default {
autoBanHint: 'Disable the user, invalidate auth cache, and send a ban notice after the hit threshold is reached.',
cyberPolicyExcludeBan: 'Exclude Cyber Policy Hits from Ban Count',
cyberPolicyExcludeBanHint: 'When enabled, cyber_policy hits no longer count toward auto-ban violations: no ban judgment on the hit itself, and history rows are excluded from the rolling count. Logs and notice emails are unaffected.',
failClosedOnError: 'Reject Requests When Audit Fails',
failClosedOnErrorHint: 'Pre-block mode only. When enabled, a failed moderation API call or a missing audit key rejects the request with 503 instead of letting it through. When disabled (default), audit failures fail open and availability wins. Enable this only if your moderation service is reliable — otherwise an outage there takes down live traffic.',
violationNotCounted: 'Not counted',
banThreshold: 'Ban Threshold',
violationWindowHours: 'Count Window (hours)',
@@ -314,6 +314,8 @@ export default {
autoBanHint: '命中次数达到阈值后将禁用用户账号、刷新认证缓存并发送封禁通知邮件。',
cyberPolicyExcludeBan: 'cyber_policy 不计入封号次数',
cyberPolicyExcludeBanHint: '开启后,cyber_policy 拦截不再计入自动封号的违规次数:当次不判定封号,历史累计亦排除。风控日志与通知邮件照常。',
failClosedOnError: '审计异常时拒绝请求',
failClosedOnErrorHint: '仅对「前置拦截」模式生效。开启后,审计接口调用失败或无可用审核 Key 时返回 503 拒绝请求,而不是放行;关闭时(默认)审计异常放行,优先保证可用性。开启前请确认审核服务足够稳定,否则审核侧故障会直接中断线上流量。',
violationNotCounted: '未计入封号',
banThreshold: '封禁触发次数',
violationWindowHours: '累计窗口(小时)',
@@ -875,6 +875,13 @@
<label class="input-label">{{ t('admin.riskControl.blockMessage') }}</label>
<input v-model.trim="configForm.block_message" type="text" class="input" />
</div>
<div class="flex items-center justify-between rounded-lg border border-gray-100 p-4 dark:border-dark-700 lg:col-span-2">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.riskControl.failClosedOnError') }}</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.riskControl.failClosedOnErrorHint') }}</p>
</div>
<Toggle v-model="configForm.fail_closed_on_error" />
</div>
<div class="flex items-center justify-between rounded-lg border border-gray-100 p-4 dark:border-dark-700">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.riskControl.emailOnHit') }}</p>
@@ -1250,6 +1257,7 @@ const configForm = reactive({
email_on_hit: true,
auto_ban_enabled: true,
cyber_policy_exclude_from_ban_count: false,
fail_closed_on_error: false,
ban_threshold: 10,
violation_window_hours: 720,
hit_retention_days: 180,
@@ -1728,6 +1736,7 @@ function applyConfig(config: ContentModerationConfig) {
configForm.email_on_hit = config.email_on_hit ?? true
configForm.auto_ban_enabled = config.auto_ban_enabled ?? true
configForm.cyber_policy_exclude_from_ban_count = config.cyber_policy_exclude_from_ban_count ?? false
configForm.fail_closed_on_error = config.fail_closed_on_error ?? false
configForm.ban_threshold = config.ban_threshold || 10
configForm.violation_window_hours = config.violation_window_hours || 720
configForm.hit_retention_days = config.hit_retention_days || 180
@@ -1814,6 +1823,7 @@ async function saveConfig() {
email_on_hit: configForm.email_on_hit,
auto_ban_enabled: configForm.auto_ban_enabled,
cyber_policy_exclude_from_ban_count: configForm.cyber_policy_exclude_from_ban_count,
fail_closed_on_error: configForm.fail_closed_on_error,
ban_threshold: Number(configForm.ban_threshold) || 10,
violation_window_hours: Number(configForm.violation_window_hours) || 720,
hit_retention_days: Number(configForm.hit_retention_days) || 180,
@@ -415,4 +415,69 @@ describe('admin RiskControlView', () => {
'overflow-y-auto',
]))
})
// 防"开关静默失效":后端返回的 fail_closed_on_error 必须回填到表单,并在保存时
// 原样回传,否则运维打开的开关会被下一次保存悄悄清掉。
it('round-trips fail_closed_on_error between load and save', async () => {
getConfig.mockResolvedValue({ ...baseConfig(), fail_closed_on_error: true })
const wrapper = mount(RiskControlView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
BaseDialog: BaseDialogStub,
Icon: true,
Select: true,
Toggle: true,
Pagination: true,
ModelWhitelistSelector: ModelWhitelistSelectorStub,
ProxySelector: true,
},
},
})
await flushPromises()
await findButtonByText(wrapper, 'admin.riskControl.openSettings').trigger('click')
await findButtonByText(wrapper, 'admin.riskControl.saveConfig').trigger('click')
await flushPromises()
expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({
fail_closed_on_error: true,
}))
expect(showError).not.toHaveBeenCalled()
})
it('defaults fail_closed_on_error to false when the backend omits it', async () => {
const { fail_closed_on_error: _omitted, ...withoutSwitch } = {
...baseConfig(),
fail_closed_on_error: false,
}
getConfig.mockResolvedValue(withoutSwitch)
const wrapper = mount(RiskControlView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
BaseDialog: BaseDialogStub,
Icon: true,
Select: true,
Toggle: true,
Pagination: true,
ModelWhitelistSelector: ModelWhitelistSelectorStub,
ProxySelector: true,
},
},
})
await flushPromises()
await findButtonByText(wrapper, 'admin.riskControl.openSettings').trigger('click')
await findButtonByText(wrapper, 'admin.riskControl.saveConfig').trigger('click')
await flushPromises()
expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({
fail_closed_on_error: false,
}))
})
})