| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456 |
- package service
- import (
- "context"
- "encoding/json"
- "errors"
- "sync"
- "testing"
- "time"
- "github.com/stretchr/testify/require"
- )
- type contentModerationRuntimeSettingRepo struct {
- mu sync.Mutex
- values map[string]string
- getValueCalls int
- getMultipleCalls int
- getMultipleErr error
- getMultipleStart chan<- struct{}
- getMultipleWait <-chan struct{}
- }
- func (r *contentModerationRuntimeSettingRepo) Get(_ context.Context, key string) (*Setting, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- value, ok := r.values[key]
- if !ok {
- return nil, ErrSettingNotFound
- }
- return &Setting{Key: key, Value: value}, nil
- }
- func (r *contentModerationRuntimeSettingRepo) GetValue(_ context.Context, key string) (string, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- r.getValueCalls++
- value, ok := r.values[key]
- if !ok {
- return "", ErrSettingNotFound
- }
- return value, nil
- }
- func (r *contentModerationRuntimeSettingRepo) Set(_ context.Context, key, value string) error {
- r.mu.Lock()
- defer r.mu.Unlock()
- if r.values == nil {
- r.values = make(map[string]string)
- }
- r.values[key] = value
- return nil
- }
- func (r *contentModerationRuntimeSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
- r.mu.Lock()
- r.getMultipleCalls++
- if err := r.getMultipleErr; err != nil {
- r.mu.Unlock()
- return nil, err
- }
- out := make(map[string]string, len(keys))
- for _, key := range keys {
- if value, ok := r.values[key]; ok {
- out[key] = value
- }
- }
- start := r.getMultipleStart
- wait := r.getMultipleWait
- r.getMultipleStart = nil
- r.getMultipleWait = nil
- r.mu.Unlock()
- if start != nil {
- start <- struct{}{}
- }
- if wait != nil {
- <-wait
- }
- return out, nil
- }
- func (r *contentModerationRuntimeSettingRepo) SetMultiple(_ context.Context, values map[string]string) error {
- r.mu.Lock()
- defer r.mu.Unlock()
- if r.values == nil {
- r.values = make(map[string]string)
- }
- for key, value := range values {
- r.values[key] = value
- }
- return nil
- }
- func (r *contentModerationRuntimeSettingRepo) GetAll(_ context.Context) (map[string]string, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- out := make(map[string]string, len(r.values))
- for key, value := range r.values {
- out[key] = value
- }
- return out, nil
- }
- func (r *contentModerationRuntimeSettingRepo) Delete(_ context.Context, key string) error {
- r.mu.Lock()
- defer r.mu.Unlock()
- delete(r.values, key)
- return nil
- }
- func (r *contentModerationRuntimeSettingRepo) calls() (getValue, getMultiple int) {
- r.mu.Lock()
- defer r.mu.Unlock()
- return r.getValueCalls, r.getMultipleCalls
- }
- func (r *contentModerationRuntimeSettingRepo) failMultiple(err error) {
- r.mu.Lock()
- defer r.mu.Unlock()
- r.getMultipleErr = err
- }
- func (r *contentModerationRuntimeSettingRepo) blockNextMultiple(start chan<- struct{}, wait <-chan struct{}) {
- r.mu.Lock()
- defer r.mu.Unlock()
- r.getMultipleStart = start
- r.getMultipleWait = wait
- }
- func runtimeCacheTestConfig(t *testing.T, keywords ...string) string {
- t.Helper()
- cfg := defaultContentModerationConfig()
- cfg.Enabled = true
- cfg.Mode = ContentModerationModePreBlock
- cfg.KeywordBlockingMode = ContentModerationKeywordModeKeywordOnly
- cfg.BlockedKeywords = keywords
- raw, err := json.Marshal(cfg)
- require.NoError(t, err)
- return string(raw)
- }
- func runtimeCacheTestService(repo *contentModerationRuntimeSettingRepo, ttl time.Duration) *ContentModerationService {
- return &ContentModerationService{
- settingRepo: repo,
- repo: &contentModerationTestRepo{},
- runtimeCacheTTL: ttl,
- }
- }
- func runtimeCacheTestInput(text string) ContentModerationCheckInput {
- return ContentModerationCheckInput{
- Protocol: ContentModerationProtocolOpenAIChat,
- Model: "risk-cache-test",
- Body: []byte(`{"messages":[{"role":"user","content":"` + text + `"}]}`),
- }
- }
- func TestContentModerationRuntimeSnapshotCachesSettings(t *testing.T) {
- repo := &contentModerationRuntimeSettingRepo{values: map[string]string{
- SettingKeyRiskControlEnabled: "true",
- SettingKeyContentModerationConfig: runtimeCacheTestConfig(t, "blocked"),
- }}
- svc := runtimeCacheTestService(repo, time.Hour)
- for range 20 {
- decision, err := svc.Check(context.Background(), runtimeCacheTestInput("clean prompt"))
- require.NoError(t, err)
- require.True(t, decision.Allowed)
- }
- getValue, getMultiple := repo.calls()
- require.Zero(t, getValue)
- require.Equal(t, 1, getMultiple)
- }
- func TestContentModerationRuntimeSnapshotUpdateConfigIsImmediate(t *testing.T) {
- repo := &contentModerationRuntimeSettingRepo{values: map[string]string{
- SettingKeyRiskControlEnabled: "true",
- SettingKeyContentModerationConfig: runtimeCacheTestConfig(t, "old-keyword"),
- }}
- svc := runtimeCacheTestService(repo, time.Hour)
- decision, err := svc.Check(context.Background(), runtimeCacheTestInput("new-keyword"))
- require.NoError(t, err)
- require.True(t, decision.Allowed)
- keywords := []string{"new-keyword"}
- _, err = svc.UpdateConfig(context.Background(), UpdateContentModerationConfigInput{
- BlockedKeywords: &keywords,
- })
- require.NoError(t, err)
- decision, err = svc.Check(context.Background(), runtimeCacheTestInput("new-keyword"))
- require.NoError(t, err)
- require.True(t, decision.Blocked)
- _, getMultiple := repo.calls()
- require.Equal(t, 1, getMultiple)
- }
- func TestContentModerationRuntimeSnapshotUpdateWinsOverInitialLoad(t *testing.T) {
- repo := &contentModerationRuntimeSettingRepo{values: map[string]string{
- SettingKeyRiskControlEnabled: "true",
- SettingKeyContentModerationConfig: runtimeCacheTestConfig(t, "old-keyword"),
- }}
- svc := runtimeCacheTestService(repo, time.Hour)
- refreshStarted := make(chan struct{}, 1)
- releaseRefresh := make(chan struct{})
- released := false
- defer func() {
- if !released {
- close(releaseRefresh)
- }
- }()
- repo.blockNextMultiple(refreshStarted, releaseRefresh)
- initialCheckDone := make(chan error, 1)
- go func() {
- decision, err := svc.Check(context.Background(), runtimeCacheTestInput("clean prompt"))
- if err == nil && (decision == nil || !decision.Allowed) {
- err = errors.New("unexpected initial moderation decision")
- }
- initialCheckDone <- err
- }()
- require.Eventually(t, func() bool {
- select {
- case <-refreshStarted:
- return true
- default:
- return false
- }
- }, time.Second, time.Millisecond)
- updateDone := make(chan error, 1)
- go func() {
- keywords := []string{"new-keyword"}
- _, updateErr := svc.UpdateConfig(context.Background(), UpdateContentModerationConfigInput{
- BlockedKeywords: &keywords,
- })
- updateDone <- updateErr
- }()
- select {
- case updateErr := <-updateDone:
- require.NoError(t, updateErr)
- t.Fatal("configuration update completed before the initial load released its lock")
- case <-time.After(10 * time.Millisecond):
- }
- close(releaseRefresh)
- released = true
- require.NoError(t, <-initialCheckDone)
- require.NoError(t, <-updateDone)
- decision, err := svc.Check(context.Background(), runtimeCacheTestInput("new-keyword"))
- require.NoError(t, err)
- require.True(t, decision.Blocked)
- decision, err = svc.Check(context.Background(), runtimeCacheTestInput("old-keyword"))
- require.NoError(t, err)
- require.True(t, decision.Allowed)
- }
- func TestContentModerationRuntimeSnapshotRefreshFailureKeepsStaleConfig(t *testing.T) {
- repo := &contentModerationRuntimeSettingRepo{values: map[string]string{
- SettingKeyRiskControlEnabled: "true",
- SettingKeyContentModerationConfig: runtimeCacheTestConfig(t, "blocked"),
- }}
- svc := runtimeCacheTestService(repo, time.Nanosecond)
- input := runtimeCacheTestInput("blocked")
- decision, err := svc.Check(context.Background(), input)
- require.NoError(t, err)
- require.True(t, decision.Blocked)
- repo.failMultiple(errors.New("database unavailable"))
- decision, err = svc.Check(context.Background(), input)
- require.NoError(t, err)
- require.True(t, decision.Blocked)
- // Windows 单调时钟步进约 0.5ms,两次相邻调用可能读到同一时刻,需反复调用才能观察到 TTL 过期。
- require.Eventually(t, func() bool {
- _, _ = svc.Check(context.Background(), input)
- _, calls := repo.calls()
- return calls >= 2
- }, time.Second, time.Millisecond)
- decision, err = svc.Check(context.Background(), input)
- require.NoError(t, err)
- require.True(t, decision.Blocked)
- }
- func TestContentModerationRuntimeSnapshotRefreshFailureBacksOff(t *testing.T) {
- repo := &contentModerationRuntimeSettingRepo{values: map[string]string{
- SettingKeyRiskControlEnabled: "true",
- SettingKeyContentModerationConfig: runtimeCacheTestConfig(t, "blocked"),
- }}
- svc := runtimeCacheTestService(repo, time.Minute)
- input := runtimeCacheTestInput("blocked")
- decision, err := svc.Check(context.Background(), input)
- require.NoError(t, err)
- require.True(t, decision.Blocked)
- current := svc.runtimeSnapshot.Load()
- require.NotNil(t, current)
- expired := *current
- expired.loadedAt = time.Now().Add(-2 * time.Minute)
- svc.runtimeSnapshot.Store(&expired)
- repo.failMultiple(errors.New("database unavailable"))
- decision, err = svc.Check(context.Background(), input)
- require.NoError(t, err)
- require.True(t, decision.Blocked)
- require.Eventually(t, func() bool {
- _, calls := repo.calls()
- return calls == 2
- }, time.Second, time.Millisecond)
- for range 100 {
- decision, err = svc.Check(context.Background(), input)
- require.NoError(t, err)
- require.True(t, decision.Blocked)
- }
- _, calls := repo.calls()
- require.Equal(t, 2, calls)
- }
- func TestContentModerationRuntimeSnapshotRefreshReusesUnchangedMatcher(t *testing.T) {
- repo := &contentModerationRuntimeSettingRepo{values: map[string]string{
- SettingKeyRiskControlEnabled: "true",
- SettingKeyContentModerationConfig: runtimeCacheTestConfig(t, "blocked"),
- }}
- svc := runtimeCacheTestService(repo, time.Minute)
- input := runtimeCacheTestInput("blocked")
- decision, err := svc.Check(context.Background(), input)
- require.NoError(t, err)
- require.True(t, decision.Blocked)
- current := svc.runtimeSnapshot.Load()
- require.NotNil(t, current)
- expired := *current
- expired.loadedAt = time.Now().Add(-2 * time.Minute)
- svc.runtimeSnapshot.Store(&expired)
- decision, err = svc.Check(context.Background(), input)
- require.NoError(t, err)
- require.True(t, decision.Blocked)
- require.Eventually(t, func() bool {
- refreshed := svc.runtimeSnapshot.Load()
- return refreshed != nil && refreshed.loadedAt.After(expired.loadedAt)
- }, time.Second, time.Millisecond)
- refreshed := svc.runtimeSnapshot.Load()
- require.Same(t, current.config, refreshed.config)
- require.Same(t, current.keywordMatcher, refreshed.keywordMatcher)
- _, calls := repo.calls()
- require.Equal(t, 2, calls)
- }
- func TestContentModerationRuntimeSnapshotUpdateWinsOverInFlightRefresh(t *testing.T) {
- repo := &contentModerationRuntimeSettingRepo{values: map[string]string{
- SettingKeyRiskControlEnabled: "true",
- SettingKeyContentModerationConfig: runtimeCacheTestConfig(t, "old-keyword"),
- }}
- svc := runtimeCacheTestService(repo, time.Minute)
- decision, err := svc.Check(context.Background(), runtimeCacheTestInput("old-keyword"))
- require.NoError(t, err)
- require.True(t, decision.Blocked)
- current := svc.runtimeSnapshot.Load()
- require.NotNil(t, current)
- expired := *current
- expired.loadedAt = time.Now().Add(-2 * time.Minute)
- svc.runtimeSnapshot.Store(&expired)
- refreshStarted := make(chan struct{}, 1)
- releaseRefresh := make(chan struct{})
- repo.blockNextMultiple(refreshStarted, releaseRefresh)
- decision, err = svc.Check(context.Background(), runtimeCacheTestInput("clean prompt"))
- require.NoError(t, err)
- require.True(t, decision.Allowed)
- require.Eventually(t, func() bool {
- select {
- case <-refreshStarted:
- return true
- default:
- return false
- }
- }, time.Second, time.Millisecond)
- updateDone := make(chan error, 1)
- go func() {
- keywords := []string{"new-keyword"}
- _, updateErr := svc.UpdateConfig(context.Background(), UpdateContentModerationConfigInput{
- BlockedKeywords: &keywords,
- })
- updateDone <- updateErr
- }()
- select {
- case updateErr := <-updateDone:
- require.NoError(t, updateErr)
- t.Fatal("configuration update completed before the in-flight refresh released its lock")
- case <-time.After(10 * time.Millisecond):
- }
- close(releaseRefresh)
- require.NoError(t, <-updateDone)
- decision, err = svc.Check(context.Background(), runtimeCacheTestInput("new-keyword"))
- require.NoError(t, err)
- require.True(t, decision.Blocked)
- decision, err = svc.Check(context.Background(), runtimeCacheTestInput("old-keyword"))
- require.NoError(t, err)
- require.True(t, decision.Allowed)
- }
- func TestContentModerationRuntimeSnapshotConcurrentReadAndReplace(t *testing.T) {
- repo := &contentModerationRuntimeSettingRepo{values: map[string]string{
- SettingKeyRiskControlEnabled: "true",
- SettingKeyContentModerationConfig: runtimeCacheTestConfig(t, "blocked-0"),
- }}
- svc := runtimeCacheTestService(repo, time.Hour)
- _, err := svc.Check(context.Background(), runtimeCacheTestInput("clean prompt"))
- require.NoError(t, err)
- var wg sync.WaitGroup
- errs := make(chan error, 8)
- for range 8 {
- wg.Add(1)
- go func() {
- defer wg.Done()
- for range 100 {
- decision, checkErr := svc.Check(context.Background(), runtimeCacheTestInput("clean prompt"))
- if checkErr != nil {
- errs <- checkErr
- return
- }
- if decision == nil || !decision.Allowed {
- errs <- errors.New("unexpected moderation decision")
- return
- }
- }
- }()
- }
- for i := 1; i <= 20; i++ {
- keywords := []string{"blocked-" + time.Duration(i).String()}
- _, err := svc.UpdateConfig(context.Background(), UpdateContentModerationConfigInput{
- BlockedKeywords: &keywords,
- })
- require.NoError(t, err)
- }
- wg.Wait()
- close(errs)
- for err := range errs {
- require.NoError(t, err)
- }
- }
|