上游对单次请求下发 error.code=cyber_policy 硬阻断时,网关在所有端点 (/v1/responses、/v1/chat/completions、/v1/messages、WebSocket)及流式/ 非流式路径下,将该结果原样透传给客户端,绝不 failover、换号或同步拦截; 命中后异步完成审计与计费: - 风控中心记录 cyber_policy 留痕并发送通知邮件,落库先于发信,SMTP 阻塞 不影响留痕 - ops 错误请求记录,状态码对齐客户端实际接收(流式 200 / 非流式 400) - 用量明细标记 request_type=cyber,按上游真实 token 计费,HTTP 与 WebSocket 计费口径统一,零 token 命中不误扣 - 会话级自动屏蔽(管理员开关,默认关):命中的会话在可配 TTL 内本地拦截 不再发往上游,仅屏蔽该会话不影响同 Key 其他会话 - 封号计数排除开关:可选让 cyber 命中不计入自动封号,命中当次不判定且 历史行在违规计数中一并排除 WebSocket 多轮连接下 cyber 标记按 turn 生命周期管理,逐轮独立检测与记录; 透传的错误响应不被兜底逻辑追加内容污染。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
303 lines
9.9 KiB
Go
303 lines
9.9 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// cyberOrderingTestRepo records the sequence of repo calls to verify F7 ordering.
|
|
type cyberOrderingTestRepo struct {
|
|
mu sync.Mutex
|
|
calls []string
|
|
emailSents []bool // EmailSent value captured at each CreateLog call
|
|
}
|
|
|
|
func (r *cyberOrderingTestRepo) CreateLog(ctx context.Context, log *ContentModerationLog) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.calls = append(r.calls, "create")
|
|
if log != nil {
|
|
r.emailSents = append(r.emailSents, log.EmailSent)
|
|
log.ID = 1 // simulate DB-assigned ID so UpdateLogEmailSent guard passes
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *cyberOrderingTestRepo) UpdateLogEmailSent(ctx context.Context, id int64, sent bool) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.calls = append(r.calls, "update_email_sent")
|
|
return nil
|
|
}
|
|
|
|
func (r *cyberOrderingTestRepo) ListLogs(ctx context.Context, filter ContentModerationLogFilter) ([]ContentModerationLog, *pagination.PaginationResult, error) {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
func (r *cyberOrderingTestRepo) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time, excludeCyberPolicy bool) (int, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (r *cyberOrderingTestRepo) CleanupExpiredLogs(ctx context.Context, hitBefore time.Time, nonHitBefore time.Time) (*ContentModerationCleanupResult, error) {
|
|
return &ContentModerationCleanupResult{}, nil
|
|
}
|
|
|
|
func (r *cyberOrderingTestRepo) snapshot() []string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out := make([]string, len(r.calls))
|
|
copy(out, r.calls)
|
|
return out
|
|
}
|
|
|
|
func (r *cyberOrderingTestRepo) snapshotEmailSents() []bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out := make([]bool, len(r.emailSents))
|
|
copy(out, r.emailSents)
|
|
return out
|
|
}
|
|
|
|
func TestRecordCyberPolicyEvent_DisabledWhenRiskControlOff(t *testing.T) {
|
|
repo := &contentModerationTestRepo{}
|
|
svc := NewContentModerationService(
|
|
&contentModerationTestSettingRepo{values: map[string]string{
|
|
SettingKeyRiskControlEnabled: "false",
|
|
}},
|
|
repo,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
)
|
|
|
|
svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
|
|
UserID: 1,
|
|
UserEmail: "u@x.com",
|
|
Model: "gpt-5",
|
|
Endpoint: "/v1/responses",
|
|
UpstreamMessage: "flagged",
|
|
UpstreamBody: `{"error":{"code":"cyber_policy"}}`,
|
|
UpstreamStatus: 400,
|
|
})
|
|
|
|
require.Empty(t, repo.snapshotLogs(), "CreateLog must NOT be called when risk_control_enabled is off")
|
|
}
|
|
|
|
func TestRecordCyberPolicyEvent_WritesLogWhenEnabled(t *testing.T) {
|
|
repo := &contentModerationTestRepo{}
|
|
svc := NewContentModerationService(
|
|
&contentModerationTestSettingRepo{values: map[string]string{
|
|
SettingKeyRiskControlEnabled: "true",
|
|
}},
|
|
repo,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil, // emailService=nil: email path safely skipped
|
|
)
|
|
|
|
svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
|
|
UserID: 1,
|
|
UserEmail: "u@x.com",
|
|
Model: "gpt-5",
|
|
Endpoint: "/v1/responses",
|
|
UpstreamMessage: "flagged",
|
|
UpstreamBody: `{"error":{"code":"cyber_policy"}}`,
|
|
UpstreamStatus: 400,
|
|
})
|
|
|
|
logs := repo.snapshotLogs()
|
|
require.Len(t, logs, 1)
|
|
log := logs[0]
|
|
|
|
require.Equal(t, "cyber_policy", log.Action)
|
|
require.True(t, log.Flagged)
|
|
require.Equal(t, "cyber_policy", log.HighestCategory)
|
|
require.Contains(t, log.Error, "flagged")
|
|
require.False(t, log.AutoBanned)
|
|
// emailService is nil, so EmailSent must be false
|
|
require.False(t, log.EmailSent)
|
|
|
|
// UserID pointer must be set
|
|
require.NotNil(t, log.UserID)
|
|
require.Equal(t, int64(1), *log.UserID)
|
|
|
|
// score for cyber_policy is always 1.0
|
|
require.Equal(t, 1.0, log.HighestScore)
|
|
|
|
// mode must be post_upstream
|
|
require.Equal(t, "post_upstream", log.Mode)
|
|
|
|
// provider
|
|
require.Equal(t, "openai", log.Provider)
|
|
|
|
// model
|
|
require.Equal(t, "gpt-5", log.Model)
|
|
|
|
// endpoint
|
|
require.Equal(t, "/v1/responses", log.Endpoint)
|
|
|
|
// violation count >= 1 (side-effects ran)
|
|
require.GreaterOrEqual(t, log.ViolationCount, 1)
|
|
|
|
// Error field should also contain the upstream body JSON
|
|
require.True(t, strings.Contains(log.Error, "cyber_policy") || strings.Contains(log.Error, "flagged"),
|
|
"Error should mention flagged or cyber_policy")
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Note on email ordering: EmailService is a concrete type with no injectable
|
|
// send interface, so SMTP-success cannot be simulated in unit tests.
|
|
// With emailService=nil the email block is skipped and UpdateLogEmailSent is not
|
|
// called (correct: logPersisted && emailSent guard). The test therefore asserts
|
|
// the two invariants that ARE observable without real SMTP:
|
|
// 1. CreateLog runs first (calls[0]=="create").
|
|
// 2. The log is stored with EmailSent=false (not pre-set to true).
|
|
//
|
|
// The update_email_sent path is covered by integration/e2e tests where a real
|
|
// (or test-double) SMTP endpoint is available.
|
|
func TestRecordCyberPolicyEvent_CreateLogBeforeEmail(t *testing.T) {
|
|
repo := &cyberOrderingTestRepo{}
|
|
svc := NewContentModerationService(
|
|
&contentModerationTestSettingRepo{values: map[string]string{
|
|
SettingKeyRiskControlEnabled: "true",
|
|
}},
|
|
repo,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
nil, // emailService=nil: email path safely skipped; see doc comment above
|
|
)
|
|
|
|
svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
|
|
RequestID: "req-1",
|
|
UserID: 7,
|
|
UserEmail: "u@example.com",
|
|
Model: "gpt-5",
|
|
UpstreamMessage: "blocked",
|
|
})
|
|
|
|
calls := repo.snapshot()
|
|
require.GreaterOrEqual(t, len(calls), 1, "CreateLog must be called")
|
|
require.Equal(t, "create", calls[0], "CreateLog must run first (F7: log-before-email)")
|
|
|
|
// EmailSent must be false when the log is first persisted (new code sets it
|
|
// false before CreateLog; email result is patched via UpdateLogEmailSent).
|
|
emailSents := repo.snapshotEmailSents()
|
|
require.NotEmpty(t, emailSents, "CreateLog must have captured EmailSent value")
|
|
require.False(t, emailSents[0], "log must be stored with EmailSent=false initially (F7)")
|
|
|
|
// With emailService=nil, no email is sent, so UpdateLogEmailSent must NOT
|
|
// be called (logPersisted && emailSent guard correctly suppresses the patch).
|
|
require.NotContains(t, calls, "update_email_sent",
|
|
"UpdateLogEmailSent must not be called when no email was sent")
|
|
}
|
|
|
|
// banCountArgsTestRepo 在 contentModerationTestRepo 基础上记录
|
|
// CountFlaggedByUserSince 收到的 excludeCyberPolicy 参数,供透传断言。
|
|
type banCountArgsTestRepo struct {
|
|
contentModerationTestRepo
|
|
argsMu sync.Mutex
|
|
countCalls []bool
|
|
}
|
|
|
|
func (r *banCountArgsTestRepo) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time, excludeCyberPolicy bool) (int, error) {
|
|
r.argsMu.Lock()
|
|
r.countCalls = append(r.countCalls, excludeCyberPolicy)
|
|
r.argsMu.Unlock()
|
|
return r.contentModerationTestRepo.CountFlaggedByUserSince(ctx, userID, since, excludeCyberPolicy)
|
|
}
|
|
|
|
func (r *banCountArgsTestRepo) snapshotCountCalls() []bool {
|
|
r.argsMu.Lock()
|
|
defer r.argsMu.Unlock()
|
|
out := make([]bool, len(r.countCalls))
|
|
copy(out, r.countCalls)
|
|
return out
|
|
}
|
|
|
|
func TestApplyFlaggedAccountSideEffects_PassesExcludeCyberFlag(t *testing.T) {
|
|
repo := &banCountArgsTestRepo{}
|
|
svc := NewContentModerationService(
|
|
&contentModerationTestSettingRepo{values: map[string]string{}},
|
|
repo, nil, nil, nil, nil, nil,
|
|
)
|
|
userID := int64(42)
|
|
|
|
cfgExclude := defaultContentModerationConfig()
|
|
cfgExclude.CyberPolicyExcludeFromBanCount = true
|
|
svc.applyFlaggedAccountSideEffects(context.Background(), cfgExclude, &ContentModerationLog{Flagged: true, UserID: &userID})
|
|
|
|
cfgDefault := defaultContentModerationConfig() // 默认 false
|
|
svc.applyFlaggedAccountSideEffects(context.Background(), cfgDefault, &ContentModerationLog{Flagged: true, UserID: &userID})
|
|
|
|
require.Equal(t, []bool{true, false}, repo.snapshotCountCalls(),
|
|
"applyFlaggedAccountSideEffects 必须把 cfg.CyberPolicyExcludeFromBanCount 透传给 COUNT 查询")
|
|
}
|
|
|
|
func TestRecordCyberPolicyEvent_ExcludeFromBanCount_SkipsBanJudgment(t *testing.T) {
|
|
repo := &banCountArgsTestRepo{}
|
|
svc := NewContentModerationService(
|
|
&contentModerationTestSettingRepo{values: map[string]string{
|
|
SettingKeyRiskControlEnabled: "true",
|
|
SettingKeyContentModerationConfig: `{"cyber_policy_exclude_from_ban_count":true}`,
|
|
}},
|
|
repo, nil, nil, nil, nil, nil,
|
|
)
|
|
|
|
svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
|
|
UserID: 1,
|
|
UserEmail: "u@x.com",
|
|
Model: "gpt-5",
|
|
Endpoint: "/v1/responses",
|
|
UpstreamMessage: "flagged",
|
|
UpstreamStatus: 400,
|
|
})
|
|
|
|
require.Empty(t, repo.snapshotCountCalls(), "开关开时不得执行封号计数查询")
|
|
logs := repo.snapshotLogs()
|
|
require.Len(t, logs, 1, "风控日志必须照记")
|
|
require.True(t, logs[0].Flagged, "日志仍标记 Flagged=true(列表可见可筛)")
|
|
require.Equal(t, "cyber_policy", logs[0].Action)
|
|
require.Equal(t, 0, logs[0].ViolationCount, "不参与计数时 ViolationCount 保持 0")
|
|
require.False(t, logs[0].AutoBanned)
|
|
}
|
|
|
|
func TestRecordCyberPolicyEvent_DefaultCountsTowardBan(t *testing.T) {
|
|
repo := &banCountArgsTestRepo{}
|
|
svc := NewContentModerationService(
|
|
&contentModerationTestSettingRepo{values: map[string]string{
|
|
SettingKeyRiskControlEnabled: "true",
|
|
}},
|
|
repo, nil, nil, nil, nil, nil,
|
|
)
|
|
|
|
svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
|
|
UserID: 1,
|
|
UserEmail: "u@x.com",
|
|
Model: "gpt-5",
|
|
Endpoint: "/v1/responses",
|
|
UpstreamMessage: "flagged",
|
|
UpstreamStatus: 400,
|
|
})
|
|
|
|
require.Equal(t, []bool{false}, repo.snapshotCountCalls(),
|
|
"默认配置必须执行计数查询且不排除 cyber 行")
|
|
logs := repo.snapshotLogs()
|
|
require.Len(t, logs, 1)
|
|
require.GreaterOrEqual(t, logs[0].ViolationCount, 1, "默认路径行为不变(现状回归)")
|
|
}
|