From a46249aac5611fca71fb0d955ad19be7602aca71 Mon Sep 17 00:00:00 2001 From: feeeei Date: Sat, 3 Oct 2026 12:08:05 +0800 Subject: [PATCH] =?UTF-8?q?test(account):=20=E8=A1=A5=E5=85=85=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E4=B8=8D=E5=8F=AF=E8=B0=83=E5=BA=A6=E4=BD=9C=E7=94=A8?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:scope 解析归一化、缺省/显式账号级/显式模型级/模型未知兜底的分流判定, 以及显式账号级、显式模型级 401、模型未知三种命中的落库位置与状态 JSON - 前端:未设置 scope 的存量规则按 401 账号级、其余模型级回显并原样保存, 显式 scope 回显与切换后提交,新增规则默认模型级 --- .../service/temp_unsched_scope_test.go | 156 ++++++++++++++++++ .../__tests__/EditAccountModal.spec.ts | 76 +++++++++ 2 files changed, 232 insertions(+) create mode 100644 backend/internal/service/temp_unsched_scope_test.go diff --git a/backend/internal/service/temp_unsched_scope_test.go b/backend/internal/service/temp_unsched_scope_test.go new file mode 100644 index 0000000000..34619dad53 --- /dev/null +++ b/backend/internal/service/temp_unsched_scope_test.go @@ -0,0 +1,156 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type tempUnschedScopeRepoStub struct { + modelNotFoundAccountRepoStub + tempReasons []string +} + +func (r *tempUnschedScopeRepoStub) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, reason string) error { + r.tempCalls++ + r.tempReasons = append(r.tempReasons, reason) + return nil +} + +func newTempUnschedScopeAccount(scope string, extra map[string]any) *Account { + rule := map[string]any{ + "error_code": float64(http.StatusServiceUnavailable), + "keywords": []any{"unavailable"}, + "duration_minutes": float64(10), + } + if scope != "" { + rule["scope"] = scope + } + for k, v := range extra { + rule[k] = v + } + return &Account{ + ID: 202, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Credentials: map[string]any{ + "temp_unschedulable_enabled": true, + "temp_unschedulable_rules": []any{rule}, + }, + } +} + +var tempUnschedScopeBody = []byte(`{"error":{"message":"upstream unavailable"}}`) + +func TestGetTempUnschedulableRules_ParsesScope(t *testing.T) { + mk := func(keyword string, scope any) map[string]any { + rule := map[string]any{ + "error_code": float64(http.StatusServiceUnavailable), + "keywords": []any{keyword}, + "duration_minutes": float64(1), + } + if scope != nil { + rule["scope"] = scope + } + return rule + } + account := &Account{Credentials: map[string]any{ + "temp_unschedulable_rules": []any{ + mk("a", "account"), + mk("b", " Model "), + mk("c", "whatever"), + mk("d", nil), + }, + }} + + rules := account.GetTempUnschedulableRules() + + require.Len(t, rules, 4) + require.Equal(t, TempUnschedScopeAccount, rules[0].Scope) + require.Equal(t, TempUnschedScopeModel, rules[1].Scope, "大小写与首尾空白应被归一化") + require.Empty(t, rules[2].Scope, "未知取值视为未设置") + require.Empty(t, rules[3].Scope) +} + +func TestTempUnschedRuleUsesModelScope(t *testing.T) { + cases := []struct { + name string + scope string + model string + status int + want bool + }{ + {"缺省 模型已知 非401 走模型级", "", "gpt-5.4", http.StatusServiceUnavailable, true}, + {"缺省 401 走账号级", "", "gpt-5.4", http.StatusUnauthorized, false}, + {"缺省 模型未知 走账号级", "", "", http.StatusServiceUnavailable, false}, + {"显式账号级 模型已知 仍账号级", TempUnschedScopeAccount, "gpt-5.4", http.StatusServiceUnavailable, false}, + {"显式模型级 401 仍模型级", TempUnschedScopeModel, "gpt-5.4", http.StatusUnauthorized, true}, + {"显式模型级 模型未知 兜底账号级", TempUnschedScopeModel, " ", http.StatusServiceUnavailable, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := tempUnschedRuleUsesModelScope(TempUnschedulableRule{Scope: tc.scope}, tc.model, tc.status) + require.Equal(t, tc.want, got) + }) + } +} + +func TestHandleTempUnschedulable_ExplicitAccountScopePausesWholeAccountWithKnownModel(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := newTempUnschedScopeAccount(TempUnschedScopeAccount, nil) + + handled := svc.HandleTempUnschedulable(context.Background(), account, http.StatusServiceUnavailable, tempUnschedScopeBody, "gpt-5.4") + + require.True(t, handled) + require.Equal(t, 1, repo.tempCalls) + require.Empty(t, repo.modelRateLimitCalls) + + var state TempUnschedState + require.NoError(t, json.Unmarshal([]byte(repo.tempReasons[0]), &state)) + require.Equal(t, TempUnschedScopeAccount, state.Scope) + require.Empty(t, state.Model) +} + +func TestCheckErrorPolicy_ExplicitModelScopeKeeps401ModelScoped(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := newTempUnschedScopeAccount(TempUnschedScopeModel, map[string]any{ + "error_code": float64(http.StatusUnauthorized), + "keywords": []any{"unauthorized"}, + }) + + result := svc.CheckErrorPolicy(context.Background(), account, http.StatusUnauthorized, + []byte(`{"error":{"message":"unauthorized"}}`), "gpt-5.4") + + require.Equal(t, ErrorPolicyTempUnscheduled, result) + require.Zero(t, repo.tempCalls, "运营人员显式选了模型级,401 也不应连累整个账号") + require.Len(t, repo.modelRateLimitCalls, 1) + require.Equal(t, "gpt-5.4", repo.modelRateLimitCalls[0].scope) + + var state TempUnschedState + require.NoError(t, json.Unmarshal([]byte(repo.modelRateLimitCalls[0].reason), &state)) + require.Equal(t, TempUnschedScopeModel, state.Scope) + require.Equal(t, "gpt-5.4", state.Model) + require.Equal(t, http.StatusUnauthorized, state.StatusCode) +} + +func TestHandleTempUnschedulable_ExplicitModelScopeFallsBackToAccountWhenModelUnknown(t *testing.T) { + repo := &tempUnschedScopeRepoStub{} + svc := &RateLimitService{accountRepo: repo} + account := newTempUnschedScopeAccount(TempUnschedScopeModel, nil) + + handled := svc.HandleTempUnschedulable(context.Background(), account, http.StatusServiceUnavailable, tempUnschedScopeBody) + + require.True(t, handled) + require.Equal(t, 1, repo.tempCalls) + require.Empty(t, repo.modelRateLimitCalls) +} diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 92274571a6..756ab78e3f 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -1731,3 +1731,79 @@ describe('EditAccountModal OpenAI 自动使用重置卡', () => { wrapper.unmount() }) }) + +describe('EditAccountModal temp-unschedulable rule scope', () => { + function buildAccountWithTempUnschedRules(rules: Array>) { + const account = buildAccount() + return { + ...account, + credentials: { + ...account.credentials, + temp_unschedulable_enabled: true, + temp_unschedulable_rules: rules + } + } as any + } + + const checkedScopes = (wrapper: ReturnType) => + wrapper + .findAll('[data-testid^="temp-unsched-scope-"][aria-checked="true"]') + .map((radio) => radio.attributes('data-testid')) + + const savedRules = () => + updateAccountMock.mock.calls[0]?.[1]?.credentials?.temp_unschedulable_rules + + beforeEach(() => { + authIsSimpleMode.value = true + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + }) + + it('shows rules without scope the way the backend treats them and keeps that on save', async () => { + const account = buildAccountWithTempUnschedRules([ + { error_code: 401, keywords: ['unauthorized'], duration_minutes: 10 }, + { error_code: 503, keywords: ['unavailable'], duration_minutes: 30 } + ]) + updateAccountMock.mockResolvedValue(account) + const wrapper = mountModal(account) + + expect(checkedScopes(wrapper)).toEqual(['temp-unsched-scope-account', 'temp-unsched-scope-model']) + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(savedRules().map((rule: { scope: string }) => rule.scope)).toEqual(['account', 'model']) + wrapper.unmount() + }) + + it('respects an explicit scope and saves the card the operator picks', async () => { + const account = buildAccountWithTempUnschedRules([ + { error_code: 401, keywords: ['unauthorized'], duration_minutes: 10, scope: 'model' }, + { error_code: 503, keywords: ['unavailable'], duration_minutes: 30, scope: ' Account ' } + ]) + updateAccountMock.mockResolvedValue(account) + const wrapper = mountModal(account) + + expect(checkedScopes(wrapper)).toEqual(['temp-unsched-scope-model', 'temp-unsched-scope-account']) + + await wrapper.findAll('[data-testid="temp-unsched-scope-model"]')[1].trigger('click') + expect(checkedScopes(wrapper)).toEqual(['temp-unsched-scope-model', 'temp-unsched-scope-model']) + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(savedRules().map((rule: { scope: string }) => rule.scope)).toEqual(['model', 'model']) + wrapper.unmount() + }) + + it('defaults a newly added rule to model scope', async () => { + const wrapper = mountModal(buildAccountWithTempUnschedRules([])) + + const addRule = wrapper + .findAll('button') + .find((button) => button.text().includes('admin.accounts.tempUnschedulable.addRule')) + await addRule!.trigger('click') + + expect(checkedScopes(wrapper)).toEqual(['temp-unsched-scope-model']) + wrapper.unmount() + }) +})