From efa5a2240da6bb2fd6982dddb12842540f440903 Mon Sep 17 00:00:00 2001 From: "jy.liu" Date: Sun, 26 Jul 2026 21:20:37 +0800 Subject: [PATCH 001/257] fix: strip max_tokens from Anthropic count tokens --- ...teway_anthropic_apikey_passthrough_test.go | 3 +- .../gateway_context_management_test.go | 29 +++++++++++++++++++ .../internal/service/gateway_count_tokens.go | 4 +++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go index 3722ec518..d5976c42b 100644 --- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go +++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go @@ -582,7 +582,8 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_CountTokensFiltersGenerationF require.Equal(t, "sys", gjson.GetBytes(sentBody, "system.0.text").String()) require.Equal(t, "hello", gjson.GetBytes(sentBody, "messages.0.content").String()) require.Equal(t, "tool", gjson.GetBytes(sentBody, "tools.0.name").String()) - require.Equal(t, int64(1024), gjson.GetBytes(sentBody, "max_tokens").Int()) + require.False(t, gjson.GetBytes(sentBody, "max_tokens").Exists(), + "count_tokens 请求不得携带生成参数 max_tokens") require.Equal(t, "enabled", gjson.GetBytes(sentBody, "thinking.type").String()) } diff --git a/backend/internal/service/gateway_context_management_test.go b/backend/internal/service/gateway_context_management_test.go index b0f2e5b82..bdeb2c600 100644 --- a/backend/internal/service/gateway_context_management_test.go +++ b/backend/internal/service/gateway_context_management_test.go @@ -596,6 +596,35 @@ func TestBuildCountTokensRequest_OAuthMimicHaiku_PreservesContextManagementEndTo "count_tokens 路径必须含 token-counting beta") } +func TestBuildCountTokensRequest_OAuthMimic_DropsInjectedMaxTokens(t *testing.T) { + // OAuth mimicry injects max_tokens=128000 for normal messages requests. It is + // invalid for Anthropic's count_tokens endpoint and must be stripped on wire. + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil) + + account := &Account{ID: 413, Platform: PlatformAnthropic, Type: AccountTypeOAuth, + Credentials: map[string]any{"access_token": "oauth-tok"}, + Status: StatusActive, Schedulable: true, + } + normalized, _ := normalizeClaudeOAuthRequestBody( + []byte(`{"model":"claude-sonnet-4-5","messages":[]}`), + "claude-sonnet-4-5", claudeOAuthNormalizeOptions{}, + ) + require.Equal(t, int64(128000), gjson.GetBytes(normalized, "max_tokens").Int(), + "precondition: OAuth mimicry injects the Claude Code default") + + svc := &GatewayService{cfg: &config.Config{}} + req, _, err := svc.buildCountTokensRequest( + context.Background(), c, account, normalized, + "oauth-tok", "oauth", "claude-sonnet-4-5", true, + ) + require.NoError(t, err) + require.False(t, gjson.GetBytes(readUpstreamBodyForTest(t, req), "max_tokens").Exists(), + "count_tokens wire body must not contain max_tokens") +} + func TestBuildCountTokensRequest_APIKeyHaiku_StripsContextManagementEndToEnd(t *testing.T) { // API-key + haiku + 客户端 header 不带 context-management beta → final beta 不含 → strip gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index 500f65566..7b66ac959 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -584,6 +584,10 @@ func sanitizeCountTokensRequestBody(body []byte) []byte { "stream", "stop_sequences", "stop", + // Anthropic's /v1/messages/count_tokens accepts request-input fields only. + // max_tokens is a generation parameter; OAuth mimicry may inject it to + // resemble Claude Code messages requests, so it must never reach this endpoint. + "max_tokens", } { if gjson.GetBytes(out, path).Exists() { if next, ok := deleteJSONPathBytes(out, path); ok { From fc495e087d8d68a4b76f5ea9df22c1b1a159b602 Mon Sep 17 00:00:00 2001 From: spongehah <2635879218@qq.com> Date: Mon, 27 Jul 2026 17:40:11 +0800 Subject: [PATCH 002/257] feat(security-audit): allow Qwen3Guard auxiliary fields --- .../securityaudit/prompt_qwen3guard.go | 18 ++++++------------ .../securityaudit/prompt_qwen3guard_test.go | 14 +++++++++++++- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/backend/internal/securityaudit/prompt_qwen3guard.go b/backend/internal/securityaudit/prompt_qwen3guard.go index aa3e9ea9d..90d81cff3 100644 --- a/backend/internal/securityaudit/prompt_qwen3guard.go +++ b/backend/internal/securityaudit/prompt_qwen3guard.go @@ -86,19 +86,13 @@ func NormalizeCategory(value string) string { } func ParseQwen3Guard(content string, enabledScanners []string) (*NormalizedResult, error) { - lines := make([]string, 0, 2) - for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") { - line = strings.TrimSpace(line) - if line != "" { - lines = append(lines, line) - } - } - if len(lines) != 2 { - return nil, &GuardError{Code: ErrorCodeInvalidResponse, Retryable: false} - } var safety string var categoryLine string - for _, line := range lines { + for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } lower := strings.ToLower(line) switch { case strings.HasPrefix(lower, "safety:"): @@ -112,7 +106,7 @@ func ParseQwen3Guard(content string, enabledScanners []string) (*NormalizedResul } categoryLine = strings.TrimSpace(line[len("categories:"):]) default: - return nil, &GuardError{Code: ErrorCodeInvalidResponse} + // Auxiliary Guard fields, such as Refusal, do not affect audit decisions. } } switch strings.ToLower(safety) { diff --git a/backend/internal/securityaudit/prompt_qwen3guard_test.go b/backend/internal/securityaudit/prompt_qwen3guard_test.go index 5db035401..c5ab9944e 100644 --- a/backend/internal/securityaudit/prompt_qwen3guard_test.go +++ b/backend/internal/securityaudit/prompt_qwen3guard_test.go @@ -22,7 +22,7 @@ func TestParseQwen3GuardStrictAndPolicy(t *testing.T) { {"unsafe", "Safety: Unsafe\nCategories: Jailbreak", AllScannerIDs, EventCritical, ActionBlock, false}, {"unknown unsafe", "Safety: Unsafe\nCategories: Future Risk", AllScannerIDs, EventCritical, ActionBlock, false}, {"disabled unsafe warns", "Safety: Unsafe\nCategories: Violent", []string{"PII"}, EventFlag, ActionWarn, false}, - {"extra explanation", "Safety: Safe\nCategories: None\nThis is safe", AllScannerIDs, "", "", true}, + {"extra explanation", "Safety: Safe\nCategories: None\nThis is safe", AllScannerIDs, EventPass, ActionAllow, false}, {"duplicate", "Safety: Safe\nSafety: Safe", AllScannerIDs, "", "", true}, {"duplicate categories", "Safety: Safe\nCategories: None\nCategories: PII", AllScannerIDs, "", "", true}, {"missing categories", "Safety: Safe\n", AllScannerIDs, "", "", true}, @@ -42,6 +42,18 @@ func TestParseQwen3GuardStrictAndPolicy(t *testing.T) { } } +func TestParseQwen3GuardIgnoresAuxiliaryResponseFields(t *testing.T) { + result, err := ParseQwen3Guard("Safety: Unsafe\nCategories: Jailbreak\nRefusal: No", AllScannerIDs) + require.NoError(t, err) + require.Equal(t, "Unsafe", result.Safety) + require.Equal(t, []string{"jailbreak"}, result.Categories) + + serialized, err := json.Marshal(result) + require.NoError(t, err) + require.NotContains(t, string(serialized), "Refusal") + require.NotContains(t, string(serialized), "No") +} + func TestQwen3GuardOfficialCategoriesAliasesAndUnknownAreStable(t *testing.T) { official := "Violent, Non-violent Illegal Acts, Sexual Content or Sexual Acts, PII, Suicide & Self-Harm, Unethical Acts, Politically Sensitive Topics, Copyright Violation, Jailbreak" result, err := ParseQwen3Guard("Safety: Unsafe\nCategories: "+official, AllScannerIDs) From 272735b0a7bcc4a56c1c4ebe45792da444abb010 Mon Sep 17 00:00:00 2001 From: InkCrow Date: Tue, 28 Jul 2026 09:19:13 +0800 Subject: [PATCH 003/257] fix(openai): preserve Codex namespace tools on OAuth Responses forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flattening Codex `type:"namespace"` tool declarations into `namespace__name` renames the tool but cannot rewrite the contract the client already handed the model: Codex tells it to call `to=functions.collaboration.spawn_agent`. Under `tool_mode: code_mode_only` (gpt-5.6-*) collaboration tools are the model's only direct channel, so the rename left it unable to address them — it fell back to `functions.exec` and looped on meaningless shell commands until the session collapsed, with no upstream error anywhere. Fixes #4978. Preserving is what the upstream actually wants. OAuth egress is unconditionally chatgpt.com/backend-api/codex/responses (base_url is read only for API-key accounts), i.e. the party that defines the extension; codex-rs builds one request for both WS and HTTP and falls back WS->HTTP mid-session, so these declarations already reach that host over HTTP — as they do today via the responses-lite carrier and the WS/HTTP bridge, neither of which ever flattened. Namespace names cannot be allowlisted: `features.multi_agent_v2.tool_namespace` is user-configurable (openai/codex#31864 recommends renaming it to `agents`) and MCP/connector namespaces are generated at runtime (mcp__codex_apps__gmail). So the default is inverted rather than extended with more names. - Preserve namespace declarations by default for OpenAI OAuth; the new per-account `extra.openai_responses_flatten_namespaces` restores the previous behavior for deployments routing OAuth traffic to a relay that rejects them. - Keep `input[].namespace` on tool-call items for OAuth non-compact requests. The upstream requires the round-trip ("Missing namespace for function_call '...'. Round-trip the model's function_call item with its namespace field included."), so preserving declarations while stripping the calls would 400 on the second turn. The item-type allowlist mirrors the existing reactive strip. - Keep compact on its current behavior: that endpoint rejects the field outright ("Unknown parameter: 'input[894].namespace'", #4761) and there is no evidence either way about namespace declarations there, so this change does not widen its surface. API-key accounts are untouched — their upstream is a standard Responses API and the reactive retry only clears one index per round trip. - Clear the flatten mapping at the start of each Forward attempt so an account reached through failover cannot restore responses with the previous account's mapping. Admin UI gets an OAuth-only toggle (create/edit/bulk) plus zh/en copy noting the compact exception. --- backend/internal/service/account.go | 16 ++ .../service/openai_gateway_forward.go | 11 +- .../service/openai_oauth_passthrough_test.go | 92 ++++++++++- .../service/openai_responses_namespace.go | 105 ++++++++++++- ...openai_responses_namespace_forward_test.go | 147 ++++++++++++++++++ .../openai_responses_namespace_test.go | 142 +++++++++++++++-- .../account/BulkEditAccountModal.vue | 73 +++++++++ .../components/account/CreateAccountModal.vue | 41 +++++ .../components/account/EditAccountModal.vue | 42 +++++ .../__tests__/BulkEditAccountModal.spec.ts | 28 ++++ .../__tests__/CreateAccountModal.spec.ts | 15 ++ .../__tests__/EditAccountModal.spec.ts | 51 ++++++ .../src/i18n/locales/en/admin/accounts.ts | 3 + .../src/i18n/locales/zh/admin/accounts.ts | 3 + 14 files changed, 743 insertions(+), 26 deletions(-) create mode 100644 backend/internal/service/openai_responses_namespace_forward_test.go diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index b7f6d2266..5728d1a83 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1825,6 +1825,22 @@ func (a *Account) IsOpenAIWSForceHTTPEnabled() bool { return ok && enabled } +// IsOpenAIResponsesFlattenNamespacesEnabled 返回账号级"摊平 Codex namespace 工具"开关。 +// 字段:accounts.extra.openai_responses_flatten_namespaces,缺省 false(原样保留)。 +// +// namespace 是 Codex 后端定义的私有扩展,OAuth 出口恒为 chatgpt.com/backend-api/codex +// (buildUpstreamRequest 只对 API Key 账号取 base_url),即定义方本身,因此默认保留。 +// 该开关只为把流量转发到不认识 namespace 的兼容上游的部署保留退路:打开后恢复 +// 0.1.166 及更早版本的摊平行为。仅对 OpenAI OAuth 账号有效——API Key 走 chat +// completions 回退桥时由桥自行摊平,Grok/Anthropic 出口有各自的适配链路。 +func (a *Account) IsOpenAIResponsesFlattenNamespacesEnabled() bool { + if a == nil || !a.IsOpenAI() || a.Extra == nil { + return false + } + enabled, ok := a.Extra["openai_responses_flatten_namespaces"].(bool) + return ok && enabled +} + // IsOpenAIWSAllowStoreRecoveryEnabled 返回账号级 store 恢复开关。 // 字段:accounts.extra.openai_ws_allow_store_recovery。 func (a *Account) IsOpenAIWSAllowStoreRecoveryEnabled() bool { diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index b3963535b..c4f54c030 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -20,6 +20,7 @@ import ( // Forward forwards request to OpenAI API func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) { clearGrokResponsesClientToolMapping(c) + clearOpenAIResponsesNamespaceNames(c) startTime := time.Now() // 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。 canonicalImageIntentBody := body @@ -62,7 +63,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco // 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。 wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, GetOpenAIClientTransport(c)) passthroughEnabled := account.IsOpenAIPassthroughEnabled() - if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled) { + compactPath := isOpenAIResponsesCompactPath(c) + if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled, compactPath) { body, err = flattenOpenAIResponsesNamespaces(c, body) if err != nil { setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "") @@ -73,7 +75,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } if shouldStripOpenAIResponsesInputNamespaces(account, wsDecision.Transport, passthroughEnabled) { - body, err = stripOpenAIResponsesInputNamespaces(body) + keepToolCallNamespaces := shouldKeepOpenAIResponsesToolCallNamespaces( + account, wsDecision.Transport, passthroughEnabled, compactPath, + ) + body, err = stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces) if err != nil { setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "") c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{ @@ -273,7 +278,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco markPatchSet("model", billingModel) } upstreamModel := billingModel - isCompactRequest := isOpenAIResponsesCompactPath(c) + isCompactRequest := compactPath compactMapped := false if isCompactRequest { compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel) diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 43f8109f8..12bff4a04 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -433,7 +433,77 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali require.NotContains(t, body, "\"name\":\"edit\"") } -func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse(t *testing.T) { +// 「自动透传(仅替换认证)」的默认行为必须真的只替换认证:namespace 声明、 +// namespace 形态的 tool_choice、历史调用项上的 namespace 都原样转发,只清掉 +// 非调用项上的残留 namespace(Codex 协议里只有调用项会带该字段)。 +func TestOpenAIGatewayService_OAuthPassthrough_PreservesNamespaceRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + + originalBody := []byte(`{ + "model":"gpt-5.5", + "stream":true, + "instructions":"local-test-instructions", + "tools":[ + {"type":"function","name":"plain","description":"keep","parameters":{"type":"object"}}, + {"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","description":"spawn","parameters":{"type":"object"}}]} + ], + "tool_choice":{"type":"function","name":"spawn_agent","namespace":"collaboration"}, + "input":[ + {"type":"function_call","call_id":"call_old","name":"spawn_agent","namespace":"collaboration","arguments":"{}"}, + {"type":"message","role":"user","namespace":"residual","content":[{"type":"input_text","text":"keep","namespace":"nested"}]} + ] + }`) + + upstreamSSE := strings.Join([]string{ + `data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"spawn_agent","namespace":"collaboration","arguments":"{}"}}`, + "", + `data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_preserve"}}, + Body: io.NopCloser(strings.NewReader(upstreamSSE)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 125, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, + Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + } + + result, err := svc.Forward(context.Background(), c, account, originalBody) + require.NoError(t, err) + require.NotNil(t, result) + + require.Len(t, gjson.GetBytes(upstream.lastBody, "tools").Array(), 2) + require.Equal(t, "namespace", gjson.GetBytes(upstream.lastBody, "tools.1.type").String()) + require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tools.1.name").String()) + require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "tools.1.tools.0.name").String()) + require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tool_choice.namespace").String()) + require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "tool_choice.name").String()) + require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "input.0.namespace").String()) + require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "input.0.name").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.namespace").Exists()) + require.Equal(t, "nested", gjson.GetBytes(upstream.lastBody, "input.1.content.0.namespace").String()) + require.NotContains(t, string(upstream.lastBody), "collaboration__spawn_agent") + + // 未摊平即无需回程还原,上游事件原样下发。 + downstream := rec.Body.String() + require.Contains(t, downstream, `"name":"spawn_agent"`) + require.Contains(t, downstream, `"namespace":"collaboration"`) +} + +// 兼容开关打开时的旧行为:摊平请求、回程还原。默认路径见 +// TestOpenAIGatewayService_OAuthPassthrough_PreservesNamespaceRequest。 +func TestOpenAIGatewayService_OAuthPassthrough_FlattenEnabledNamespaceRequestAndStreamResponse(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() @@ -475,7 +545,11 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse account := &Account{ ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, - Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + Extra: map[string]any{ + "openai_passthrough": true, + "openai_responses_flatten_namespaces": true, + }, + Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), } result, err := svc.Forward(context.Background(), c, account, originalBody) @@ -501,7 +575,9 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse require.Contains(t, downstream, `"namespace":"collaboration"`) } -func TestOpenAIGatewayService_NativeOAuth_NamespaceRequestAndStreamResponse(t *testing.T) { +// 兼容开关打开时的旧行为;默认保留路径见 +// TestOpenAIGatewayService_OAuthPreservesCodexNamespaceTools。 +func TestOpenAIGatewayService_NativeOAuth_FlattenEnabledNamespaceRequestAndStreamResponse(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -529,6 +605,7 @@ func TestOpenAIGatewayService_NativeOAuth_NamespaceRequestAndStreamResponse(t *t account := &Account{ ID: 124, Name: "native", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, + Extra: map[string]any{"openai_responses_flatten_namespaces": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), } @@ -596,7 +673,8 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceNonStreamingResponse(t * require.Contains(t, rec.Body.String(), `"namespace":"collaboration"`) } -func TestOpenAIGatewayService_OAuthPassthrough_NamespaceCollisionReturnsBadRequest(t *testing.T) { +// 摊平名冲突只在兼容开关打开时才可能发生:默认保留 namespace,不存在平名冲突。 +func TestOpenAIGatewayService_OAuthPassthrough_FlattenEnabledNamespaceCollisionReturnsBadRequest(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -614,7 +692,11 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceCollisionReturnsBadReque account := &Account{ ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, - Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + Extra: map[string]any{ + "openai_passthrough": true, + "openai_responses_flatten_namespaces": true, + }, + Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), } result, err := svc.Forward(context.Background(), c, account, body) diff --git a/backend/internal/service/openai_responses_namespace.go b/backend/internal/service/openai_responses_namespace.go index bd548b69e..73760f567 100644 --- a/backend/internal/service/openai_responses_namespace.go +++ b/backend/internal/service/openai_responses_namespace.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "strings" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/gin-gonic/gin" @@ -14,14 +15,39 @@ import ( const openAIResponsesNamespaceNamesContextKey = "openai_responses_namespace_names" // shouldFlattenOpenAIResponsesNamespaces 判定原生 Responses 转发前是否摊平 -// Codex namespace 工具。WSv2 上游原生支持 namespace,且 WS 出口 -// (openai_ws_forwarder_v2)原样转发上游事件、不经 HTTP 回程还原,摊平后的 -// 平名无法还原会破坏客户端工具匹配,因此实际走 WSv2 分支的请求保持 namespace -// 原样。透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。 -func shouldFlattenOpenAIResponsesNamespaces(account *Account, transport OpenAIUpstreamTransport, passthroughEnabled bool) bool { +// Codex namespace 工具。 +// +// 默认不摊平:OAuth 账号的 HTTP 出口恒为 chatgpt.com/backend-api/codex/responses +// (buildUpstreamRequest 只在 API Key 分支读 base_url),也就是 namespace 扩展的 +// 定义方本身;Codex 客户端对 WS 与 HTTP 两条传输发送同一份 tools(codex-rs +// client.rs build_responses_request 无传输分支,WS 失败后会 session 级回落 HTTP +// 继续发同样的声明)。摊平只改写工具名,改不掉客户端在 tools 描述与 developer +// 消息里写死的 `to=functions..` 寻址约定,模型据此寻址必然落空 +// (issue #4978);命名空间名还可由 features.multi_agent_v2.tool_namespace 自定义、 +// 或由 MCP/connector 动态生成(mcp__codex_apps__gmail),保留名单枚举不完。 +// +// compact 端点例外:已知它的 schema 比 /responses 窄(连 input[].namespace 都会报 +// Unknown parameter,见 issue #4761),而是否接受 namespace 工具声明没有任何实测 +// 证据;compact 只做历史摘要、不需要模型寻址工具,回程也没有工具调用可还原,因此 +// 保持 0.1.166 起就在跑的摊平行为,不随本次默认值翻转扩大风险面。 +// +// 账号开关 openai_responses_flatten_namespaces 为不认识 namespace 的兼容上游保留 +// 退路,打开后恢复旧行为:WSv2 上游原生支持 namespace,且 WS 出口 +// (openai_ws_forwarder_v2)原样转发上游事件、不经 HTTP 回程还原,摊平后的平名 +// 无法还原会破坏客户端工具匹配,因此实际走 WSv2 分支的请求仍保持 namespace 原样; +// 透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。 +func shouldFlattenOpenAIResponsesNamespaces( + account *Account, + transport OpenAIUpstreamTransport, + passthroughEnabled bool, + compactPath bool, +) bool { if account == nil || !account.IsOpenAIOAuth() { return false } + if !compactPath && !account.IsOpenAIResponsesFlattenNamespacesEnabled() { + return false + } if transport == OpenAIUpstreamTransportResponsesWebsocketV2 && !passthroughEnabled { return false } @@ -41,6 +67,50 @@ func shouldStripOpenAIResponsesInputNamespaces(account *Account, transport OpenA return true } +// shouldKeepOpenAIResponsesToolCallNamespaces 判定清理 input 残留 namespace 时是否 +// 保留工具调用项上的 namespace。 +// +// 上游对这个字段有两套互斥要求,判定按「出口 + 端点」而非工具声明内容: +// - /backend-api/codex/responses 会按 namespace 解析历史调用,缺字段直接 400 +// `Missing namespace for function_call '...'. Round-trip the model's +// function_call item with its namespace field included.`(issue #4761 回帖), +// 故 OAuth 非 compact 请求必须保留。 +// - compact 端点的 schema 不含该字段,携带即 400 `Unknown parameter: +// input[N].namespace`(issue #4761 正文),故 compact 一律清理。 +// - API Key 出口是标准 Responses API(api.openai.com 或自定义 base_url),同样 +// 不认识该字段,维持全量清理;否则只能退化成 +// openai_responses_rejected_field_retry 的逐项删除,6 次上限根本盖不住长历史。 +// - 摊平模式下调用项已被改写成平名,残留 namespace 指向的声明已不存在,一律清理。 +func shouldKeepOpenAIResponsesToolCallNamespaces( + account *Account, + transport OpenAIUpstreamTransport, + passthroughEnabled bool, + compactPath bool, +) bool { + if account == nil || !account.IsOpenAIOAuth() { + return false + } + if compactPath { + return false + } + return !shouldFlattenOpenAIResponsesNamespaces(account, transport, passthroughEnabled, compactPath) +} + +// openAIResponsesToolCallItemTypes 是携带 namespace 的调用项类型集合。与 +// removeOpenAIResponsesRejectedNamespaceAtIndex 的反应式白名单保持一致;codex-rs +// protocol/src/models.rs 中只有 FunctionCall 与 CustomToolCall 序列化 namespace, +// 其余类型带该字段一定是非 Codex 客户端或历史残留,清掉才安全。 +var openAIResponsesToolCallItemTypes = map[string]bool{ + "function_call": true, + "tool_call": true, + "custom_tool_call": true, + "mcp_tool_call": true, +} + +func isOpenAIResponsesToolCallItemType(itemType string) bool { + return openAIResponsesToolCallItemTypes[strings.ToLower(strings.TrimSpace(itemType))] +} + func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, error) { if !bytes.Contains(body, []byte(`"namespace"`)) { return body, nil @@ -68,7 +138,11 @@ func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, erro // array items. Namespace declarations and nested namespace fields are left // untouched. Rebuilding the input array once keeps this linear for long // histories and avoids decoding JSON numbers through float64. -func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) { +// +// keepToolCallNamespaces 保留工具调用项(function_call / custom_tool_call 等)上的 +// namespace,让 Codex 调用能按上游要求原样回传;判定见 +// shouldKeepOpenAIResponsesToolCallNamespaces。 +func stripOpenAIResponsesInputNamespaces(body []byte, keepToolCallNamespaces bool) ([]byte, error) { if !bytes.Contains(body, []byte(`"namespace"`)) { return body, nil } @@ -89,7 +163,10 @@ func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) { } first = false itemBody := []byte(item.Raw) - if item.IsObject() && item.Get("namespace").Exists() { + // 先判存在再判类型:长历史里绝大多数是 message/reasoning 等不带 namespace + // 的项,这样它们无需再扫一次 type。 + if item.IsObject() && item.Get("namespace").Exists() && + (!keepToolCallNamespaces || !isOpenAIResponsesToolCallItemType(item.Get("type").String())) { itemBody, stripErr = sjson.DeleteBytes(itemBody, "namespace") if stripErr != nil { return false @@ -99,13 +176,13 @@ func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) { _, _ = rebuilt.Write(itemBody) return true }) + _ = rebuilt.WriteByte(']') if stripErr != nil { return body, fmt.Errorf("delete OpenAI input namespace: %w", stripErr) } if !changed { return body, nil } - _ = rebuilt.WriteByte(']') stripped, err := sjson.SetRawBytes(body, "input", rebuilt.Bytes()) if err != nil { return body, fmt.Errorf("replace OpenAI input after namespace deletion: %w", err) @@ -119,6 +196,18 @@ func setOpenAIResponsesNamespaceNames(c *gin.Context, names map[string]apicompat } } +// clearOpenAIResponsesNamespaceNames 清除上一次尝试登记的摊平名映射。handler 的 +// failover 会在同一个 *gin.Context 上重试下一个账号,映射不清会让保留 namespace 的 +// 账号拿着上一个账号的摊平名做回程还原。 +func clearOpenAIResponsesNamespaceNames(c *gin.Context) { + if c == nil { + return + } + if _, exists := c.Get(openAIResponsesNamespaceNamesContextKey); exists { + c.Set(openAIResponsesNamespaceNamesContextKey, map[string]apicompat.ResponsesNamespaceName(nil)) + } +} + func openAIResponsesNamespaceNames(c *gin.Context) map[string]apicompat.ResponsesNamespaceName { if c == nil { return nil diff --git a/backend/internal/service/openai_responses_namespace_forward_test.go b/backend/internal/service/openai_responses_namespace_forward_test.go new file mode 100644 index 000000000..e99d0199f --- /dev/null +++ b/backend/internal/service/openai_responses_namespace_forward_test.go @@ -0,0 +1,147 @@ +package service + +import ( + "context" + "net/http" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// codexNamespaceRequestBody 模拟 Codex 多智能体请求:collaboration 命名空间声明 + +// 历史里的命名空间调用项 + 带残留 namespace 的普通消息项。 +const codexNamespaceRequestBody = `{ + "model":"gpt-5.6-terra", + "stream":false, + "instructions":"test", + "tools":[ + {"type":"namespace","name":"collaboration","description":"Tools for spawning and managing sub-agents.","tools":[ + {"type":"function","name":"spawn_agent","description":"Call as to=functions.collaboration.spawn_agent","parameters":{"type":"object"}}, + {"type":"function","name":"wait_agent","parameters":{"type":"object"}} + ]}, + {"type":"function","name":"exec","parameters":{"type":"object"}} + ], + "input":[ + {"type":"function_call","namespace":"collaboration","name":"spawn_agent","call_id":"call_1","arguments":"{}"}, + {"type":"message","role":"user","namespace":"leftover","content":[{"type":"input_text","text":"hello"}]} + ] +}` + +const namespaceForwardOKResponse = `{"id":"resp_ns","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}` + +// OAuth 出口即 namespace 扩展的定义方:声明必须原样送达,历史调用项必须保留 +// namespace(缺字段上游会 400 "Missing namespace for function_call"),而非调用项上的 +// 残留 namespace 仍要清掉。回归 issue #4978。 +func TestOpenAIGatewayService_OAuthPreservesCodexNamespaceTools(t *testing.T) { + body := []byte(codexNamespaceRequestBody) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse), + }} + c := newOpenAIRejectedFieldTestContext(body) + + result, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 1) + forwarded := upstream.bodies[0] + + namespaceTool := gjson.GetBytes(forwarded, `tools.#(type=="namespace")`) + require.True(t, namespaceTool.Exists(), "namespace 声明必须原样转发") + require.Equal(t, "collaboration", namespaceTool.Get("name").String()) + require.Equal(t, "spawn_agent", namespaceTool.Get("tools.0.name").String()) + require.Equal(t, "wait_agent", namespaceTool.Get("tools.1.name").String()) + // 摊平名一旦出现,模型就无法按工具描述里的 to=functions.collaboration.spawn_agent 寻址。 + require.NotContains(t, string(forwarded), "collaboration__spawn_agent") + + require.Equal(t, "collaboration", gjson.GetBytes(forwarded, "input.0.namespace").String()) + require.Equal(t, "spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String()) + require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists()) + + // 未摊平即无需回程还原,不得登记映射。 + require.Empty(t, openAIResponsesNamespaceNames(c)) +} + +// compact 端点 schema 更窄:input[].namespace 会 400 Unknown parameter(issue #4761), +// 且没有证据表明它接受 namespace 工具声明。compact 只做历史摘要、不需要模型寻址工具, +// 因此保持既有的摊平 + 全量清理行为,不随默认值翻转扩大风险面。 +func TestOpenAIGatewayService_OAuthCompactKeepsFlattening(t *testing.T) { + body := []byte(codexNamespaceRequestBody) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse), + }} + c := newOpenAIRejectedFieldTestContext(body) + c.Request.URL.Path = "/v1/responses/compact" + + result, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 1) + forwarded := upstream.bodies[0] + + require.False(t, gjson.GetBytes(forwarded, "input.0.namespace").Exists()) + require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists()) + require.False(t, gjson.GetBytes(forwarded, `tools.#(type=="namespace")`).Exists()) + require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String()) +} + +// 账号开关为不认识 namespace 的兼容上游保留退路:打开后恢复 0.1.166 的摊平行为。 +func TestOpenAIGatewayService_OAuthFlattenFlagRestoresLegacyBehavior(t *testing.T) { + body := []byte(codexNamespaceRequestBody) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse), + }} + c := newOpenAIRejectedFieldTestContext(body) + account := newOpenAIOAuthNamespaceTestAccount() + account.Extra = map[string]any{"openai_responses_flatten_namespaces": true} + + result, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), c, account, body, + ) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 1) + forwarded := upstream.bodies[0] + + require.False(t, gjson.GetBytes(forwarded, `tools.#(type=="namespace")`).Exists()) + require.True(t, gjson.GetBytes(forwarded, `tools.#(name=="collaboration__spawn_agent")`).Exists()) + require.True(t, gjson.GetBytes(forwarded, `tools.#(name=="collaboration__wait_agent")`).Exists()) + // 摊平后调用项已改写成平名,不得再带 namespace。 + require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String()) + require.False(t, gjson.GetBytes(forwarded, "input.0.namespace").Exists()) + require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists()) + + names := openAIResponsesNamespaceNames(c) + require.Equal(t, + apicompat.ResponsesNamespaceName{Namespace: "collaboration", Name: "spawn_agent"}, + names["collaboration__spawn_agent"], + ) +} + +// handler 的 failover 在同一个 *gin.Context 上重试下一个账号;保留 namespace 的账号 +// 不得沿用上一个账号登记的摊平名映射做回程还原。 +func TestOpenAIGatewayService_ForwardClearsStaleNamespaceNames(t *testing.T) { + body := []byte(codexNamespaceRequestBody) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse), + }} + c := newOpenAIRejectedFieldTestContext(body) + setOpenAIResponsesNamespaceNames(c, map[string]apicompat.ResponsesNamespaceName{ + "stale__tool": {Namespace: "stale", Name: "tool"}, + }) + + _, err := newOpenAIRejectedFieldTestService(upstream).Forward( + context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body, + ) + + require.NoError(t, err) + require.Empty(t, openAIResponsesNamespaceNames(c)) +} diff --git a/backend/internal/service/openai_responses_namespace_test.go b/backend/internal/service/openai_responses_namespace_test.go index 0b12355a3..80095f329 100644 --- a/backend/internal/service/openai_responses_namespace_test.go +++ b/backend/internal/service/openai_responses_namespace_test.go @@ -12,27 +12,96 @@ func TestShouldFlattenOpenAIResponsesNamespaces(t *testing.T) { oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} grokOAuth := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth} + // 账号级兼容开关:为不认识 namespace 的兼容上游恢复旧的摊平行为。 + flattenOAuth := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"openai_responses_flatten_namespaces": true}, + } + flattenAPIKey := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Extra: map[string]any{"openai_responses_flatten_namespaces": true}, + } tests := []struct { name string account *Account transport OpenAIUpstreamTransport passthroughEnabled bool + compactPath bool want bool }{ - {name: "oauth_http", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true}, - {name: "oauth_http_passthrough", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true}, + // 默认保留:OAuth 出口是 namespace 扩展的定义方,摊平会让模型无法按 + // `to=functions..` 寻址(issue #4978)。 + {name: "oauth_http_default_preserves", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, + {name: "oauth_http_passthrough_default_preserves", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: false}, + {name: "oauth_wsv2_default_preserves", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false}, + // compact 端点 schema 更窄且无实测证据,保持既有摊平行为。 + {name: "oauth_compact_flattens", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: true}, + {name: "oauth_compact_wsv2_preserves", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, compactPath: true, want: false}, + {name: "apikey_compact", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: false}, + {name: "oauth_flatten_enabled_http", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: true}, + {name: "oauth_flatten_enabled_http_passthrough", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true}, // WSv2 出口原样转发上游事件、不做回程还原,摊平会让客户端收到无法匹配的平名。 - {name: "oauth_wsv2", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false}, - // 透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。 - {name: "oauth_wsv2_passthrough", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true}, + {name: "oauth_flatten_enabled_wsv2", account: flattenOAuth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false}, + // 透传账号先于 WSv2 分支经 HTTP 转发返回,开关打开时仍需摊平。 + {name: "oauth_flatten_enabled_wsv2_passthrough", account: flattenOAuth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true}, + // 开关仅对 OAuth 生效:API Key 走 chat completions 回退桥时由桥自行摊平。 + {name: "apikey_flatten_enabled_http", account: flattenAPIKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, {name: "apikey_http", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, {name: "grok_oauth_http", account: grokOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, {name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - require.Equal(t, tt.want, shouldFlattenOpenAIResponsesNamespaces(tt.account, tt.transport, tt.passthroughEnabled)) + require.Equal(t, tt.want, shouldFlattenOpenAIResponsesNamespaces( + tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath, + )) + }) + } +} + +func TestShouldKeepOpenAIResponsesToolCallNamespaces(t *testing.T) { + oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + setupToken := &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken} + flattenOAuth := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{"openai_responses_flatten_namespaces": true}, + } + + tests := []struct { + name string + account *Account + transport OpenAIUpstreamTransport + passthroughEnabled bool + compactPath bool + want bool + }{ + // 上游按 namespace 解析历史调用,缺字段会 400 "Missing namespace for function_call"。 + {name: "oauth_http_keeps", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true}, + {name: "oauth_http_passthrough_keeps", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true}, + // compact 端点 schema 不含该字段,携带即 400 "Unknown parameter: input[N].namespace"。 + {name: "oauth_compact_strips", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: false}, + // 摊平后调用项已是平名,残留 namespace 指向的声明不存在。 + {name: "oauth_flatten_enabled_strips", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, + // WSv2 实际由 shouldStrip 提前短路,此处只钉住策略本身的取值。 + {name: "oauth_wsv2_keeps", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: true}, + // WSv2 + compact 是唯一「不摊平但仍必须清理」的组合,钉住 compact 判定本身, + // 使其不会被误当成可由 shouldFlatten 推导出的冗余分支。 + {name: "oauth_compact_wsv2_strips", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, compactPath: true, want: false}, + // API Key 出口是标准 Responses API,不认识该字段。 + {name: "apikey_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, + {name: "setup_token_strips", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, + {name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, shouldKeepOpenAIResponsesToolCallNamespaces( + tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath, + )) }) } } @@ -85,7 +154,7 @@ func TestStripOpenAIResponsesInputNamespaces(t *testing.T) { ] }`) - stripped, err := stripOpenAIResponsesInputNamespaces(body) + stripped, err := stripOpenAIResponsesInputNamespaces(body, false) require.NoError(t, err) for index := 0; index < 8; index++ { require.False(t, gjson.GetBytes(stripped, "input."+strconv.Itoa(index)+".namespace").Exists()) @@ -106,8 +175,61 @@ func TestStripOpenAIResponsesInputNamespacesLeavesOtherShapesByteExact(t *testin []byte(`{"input":[{"content":{"namespace":"nested-only"}}],"tools":[{"namespace":"keep"}]}`), } for _, body := range tests { - stripped, err := stripOpenAIResponsesInputNamespaces(body) - require.NoError(t, err) - require.Equal(t, body, stripped) + for _, keepToolCallNamespaces := range []bool{false, true} { + stripped, err := stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces) + require.NoError(t, err) + require.Equal(t, body, stripped) + } + } +} + +// 保留模式下只有工具调用项留住 namespace:上游按 namespace 解析历史调用, +// 而 message / reasoning / 输出项带该字段会被 schema 拒绝。 +func TestStripOpenAIResponsesInputNamespacesKeepsToolCallNamespaces(t *testing.T) { + body := []byte(`{ + "meta":9007199254740993, + "input":[ + {"type":"function_call","namespace":"collaboration","name":"spawn_agent","arguments":"{}","large":9007199254740993}, + {"type":"custom_tool_call","namespace":"codex_app","name":"exec","input":"{}"}, + {"type":"tool_call","namespace":"mcp__codex_apps__gmail","name":"send"}, + {"type":"mcp_tool_call","namespace":"mcp__codex_apps__gmail","name":"list"}, + {"type":"message","namespace":"leftover","role":"assistant","content":[{"type":"output_text","text":"hi"}]}, + {"type":"function_call_output","namespace":"leftover","output":"ok"}, + {"type":"reasoning","namespace":"leftover"}, + {"type":"item","namespace":"leftover"} + ] + }`) + + stripped, err := stripOpenAIResponsesInputNamespaces(body, true) + require.NoError(t, err) + + require.Equal(t, "collaboration", gjson.GetBytes(stripped, "input.0.namespace").String()) + require.Equal(t, "codex_app", gjson.GetBytes(stripped, "input.1.namespace").String()) + require.Equal(t, "mcp__codex_apps__gmail", gjson.GetBytes(stripped, "input.2.namespace").String()) + require.Equal(t, "mcp__codex_apps__gmail", gjson.GetBytes(stripped, "input.3.namespace").String()) + for index := 4; index < 8; index++ { + require.False(t, gjson.GetBytes(stripped, "input."+strconv.Itoa(index)+".namespace").Exists()) + } + // 大整数不得经 float64 往返。 + require.Equal(t, gjson.GetBytes(body, "meta").Raw, gjson.GetBytes(stripped, "meta").Raw) + require.Equal(t, gjson.GetBytes(body, "input.0.large").Raw, gjson.GetBytes(stripped, "input.0.large").Raw) + + // 类型比对不区分大小写与首尾空白。 + mixedCase := []byte(`{"input":[{"type":" Function_Call ","namespace":"collaboration","name":"spawn_agent"}]}`) + keptMixedCase, err := stripOpenAIResponsesInputNamespaces(mixedCase, true) + require.NoError(t, err) + require.Equal(t, mixedCase, keptMixedCase) + + // 全部为调用项时无改动,应原样返回。 + callsOnly := []byte(`{"input":[{"type":"function_call","namespace":"collaboration","name":"spawn_agent"}]}`) + unchanged, err := stripOpenAIResponsesInputNamespaces(callsOnly, true) + require.NoError(t, err) + require.Equal(t, callsOnly, unchanged) + + // 关闭保留时回到全量清理。 + strippedAll, err := stripOpenAIResponsesInputNamespaces(body, false) + require.NoError(t, err) + for index := 0; index < 8; index++ { + require.False(t, gjson.GetBytes(strippedAll, "input."+strconv.Itoa(index)+".namespace").Exists()) } } diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index a4268fb92..1c277cb55 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -82,6 +82,57 @@ + +
+
+
+ +

+ {{ t('admin.accounts.openai.flattenNamespacesDesc') }} +

+
+ +
+
+ +
+
+
@@ -1331,6 +1382,16 @@ const allOpenAIOAuth = computed(() => { ) }) +// 严格 OAuth(不含 setup-token):namespace 摊平兼容开关只对 OAuth 账号生效 +const allOpenAIOAuthOnly = computed(() => { + return ( + targetSelectedPlatforms.value.length === 1 && + targetSelectedPlatforms.value[0] === 'openai' && + targetSelectedTypes.value.length > 0 && + targetSelectedTypes.value.every(t => t === 'oauth') + ) +}) + const allOpenAIAPIKey = computed(() => { return ( targetSelectedPlatforms.value.length === 1 && @@ -1398,6 +1459,7 @@ const enableRateMultiplier = ref(false) const enableStatus = ref(false) const enableGroups = ref(false) const enableOpenAIPassthrough = ref(false) +const enableOpenAIFlattenNamespaces = ref(false) const enableOpenAIWSMode = ref(false) const enableOpenAIAPIKeyWSMode = ref(false) const enableUpstreamBillingAutoProbe = ref(false) @@ -1429,6 +1491,8 @@ const rateMultiplier = ref(1) const status = ref<'active' | 'inactive'>('active') const groupIds = ref([]) const openaiPassthroughEnabled = ref(false) +// Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留 +const openaiFlattenNamespacesEnabled = ref(false) const openaiOAuthResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const upstreamBillingAutoProbeMode = ref<'enabled' | 'disabled'>('enabled') @@ -1638,6 +1702,12 @@ const buildUpdatePayload = (): Record | null => { } } + // 同时校验可见性:勾选后又改了目标筛选条件时,不应把该键写到非 OAuth 账号上 + if (enableOpenAIFlattenNamespaces.value && allOpenAIOAuthOnly.value) { + const extra = ensureExtra() + extra.openai_responses_flatten_namespaces = openaiFlattenNamespacesEnabled.value + } + if (enableModelRestriction.value && !isOpenAIModelRestrictionDisabled.value) { // 统一使用 model_mapping 字段 if (modelRestrictionMode.value === 'whitelist') { @@ -1806,6 +1876,7 @@ const handleSubmit = async () => { const hasAnyFieldEnabled = enableBaseUrl.value || enableOpenAIPassthrough.value || + enableOpenAIFlattenNamespaces.value || enableModelRestriction.value || enableCustomErrorCodes.value || enableInterceptWarmup.value || @@ -1946,6 +2017,7 @@ watch( enableStatus.value = false enableGroups.value = false enableOpenAIPassthrough.value = false + enableOpenAIFlattenNamespaces.value = false enableOpenAIWSMode.value = false enableOpenAIAPIKeyWSMode.value = false enableUpstreamBillingAutoProbe.value = false @@ -1958,6 +2030,7 @@ watch( // Reset all values baseUrl.value = '' openaiPassthroughEnabled.value = false + openaiFlattenNamespacesEnabled.value = false modelRestrictionMode.value = 'whitelist' allowedModels.value = [] modelMappings.value = [] diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index f71bbbada..dd63736fd 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2781,6 +2781,37 @@
+ +
+
+
+ +

+ {{ t('admin.accounts.openai.flattenNamespacesDesc') }} +

+
+ +
+
+
) => { const interceptWarmupRequests = ref(false) const autoPauseOnExpired = ref(true) const openaiPassthroughEnabled = ref(false) +// OpenAI Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留 +const openaiFlattenNamespacesEnabled = ref(false) const openAILongContextBillingEnabled = ref(false) const openAILongContextBillingTouched = ref(false) const openAICompactMode = ref('auto') @@ -4231,6 +4264,7 @@ watch( } if (newPlatform !== 'openai') { openaiPassthroughEnabled.value = false + openaiFlattenNamespacesEnabled.value = false openAIEndpointCapabilities.value = ['chat_completions', 'embeddings'] openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF @@ -4656,6 +4690,7 @@ const resetForm = () => { interceptWarmupRequests.value = false autoPauseOnExpired.value = true openaiPassthroughEnabled.value = false + openaiFlattenNamespacesEnabled.value = false openAILongContextBillingEnabled.value = false openAILongContextBillingTouched.value = false openAICompactMode.value = 'auto' @@ -4740,6 +4775,12 @@ const buildOpenAIExtra = (base?: Record): Record
+ +
+
+
+ +

+ {{ t('admin.accounts.openai.flattenNamespacesDesc') }} +

+
+ +
+
+
('') @@ -3276,6 +3309,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { // Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key) openaiPassthroughEnabled.value = false + openaiFlattenNamespacesEnabled.value = false openAILongContextBillingEnabled.value = false editPlanType.value = '' openAICompactMode.value = 'auto' @@ -3292,6 +3326,8 @@ const syncFormFromAccount = (newAccount: Account | null) => { webSearchEmulationMode.value = 'default' if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'setup-token' || newAccount.type === 'apikey')) { openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true + openaiFlattenNamespacesEnabled.value = + newAccount.type === 'oauth' && extra?.openai_responses_flatten_namespaces === true const longContextBillingValue = extra?.openai_long_context_billing_enabled openAILongContextBillingEnabled.value = longContextBillingValue === true // plan_type 手动覆盖仅 OAuth 有实际调度语义(IsOpenAIChatGPTSubscription 要求 oauth),故只对 oauth 回填 @@ -4526,6 +4562,12 @@ const handleSubmit = async () => { delete newExtra.openai_passthrough delete newExtra.openai_oauth_passthrough } + // 缺省即保留 namespace,不写空值,避免 extra 里堆积默认项 + if (props.account.type === 'oauth' && openaiFlattenNamespacesEnabled.value) { + newExtra.openai_responses_flatten_namespaces = true + } else { + delete newExtra.openai_responses_flatten_namespaces + } if (isSparkShadow.value) { delete newExtra.openai_long_context_billing_enabled } else { diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index cedabc58d..51702e8fe 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -237,6 +237,34 @@ describe('BulkEditAccountModal', () => { }) }) + it('OpenAI OAuth 批量编辑可开启 namespace 摊平兼容开关', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['oauth'] + }) + + await wrapper.get('#bulk-edit-openai-flatten-namespaces-enabled').setValue(true) + await wrapper.get('#bulk-edit-openai-flatten-namespaces-toggle').trigger('click') + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1) + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + extra: { + openai_responses_flatten_namespaces: true + } + }) + }) + + it('namespace 摊平开关不对 setup-token 等非 OAuth 选择展示', async () => { + const wrapper = mountModal({ + selectedPlatforms: ['openai'], + selectedTypes: ['oauth', 'setup-token'] + }) + + expect(wrapper.find('#bulk-edit-openai-flatten-namespaces-enabled').exists()).toBe(false) + }) + it('OpenAI OAuth 批量编辑应提交 OAuth 专属 WS mode 字段(含 http_bridge)', async () => { const wrapper = mountModal({ selectedPlatforms: ['openai'], diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 13035a9d3..f2e0c61a8 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -167,6 +167,21 @@ describe('CreateAccountModal OpenAI long-context billing', () => { expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false) }) + // namespace 摊平是仅 OAuth 的兼容开关:API Key 走 chat completions 回退桥时由桥自行摊平 + it('shows the Codex namespace flatten toggle only for OpenAI OAuth accounts', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + + expect(wrapper.find('[data-testid="create-openai-flatten-namespaces-toggle"]').exists()).toBe( + true + ) + + await selectButtonByText(wrapper, 'API Key') + expect(wrapper.find('[data-testid="create-openai-flatten-namespaces-toggle"]').exists()).toBe( + false + ) + }) + it('enables upstream billing probes by default for new OpenAI API key accounts', async () => { await submitApiKeyAccount('openai') diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 8cd20e89e..e5797bf0c 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -416,6 +416,57 @@ describe('EditAccountModal', () => { expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_long_context_billing_enabled).toBe(false) }) + it('loads and clears the OAuth-only Codex namespace flatten toggle', async () => { + const account = buildAccount() + account.type = 'oauth' + account.extra = { + openai_responses_flatten_namespaces: true + } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + const toggle = wrapper.get('[data-testid="edit-openai-flatten-namespaces-toggle"]') + + // 关闭后应从 extra 中删除该键,而不是写入 false + await toggle.trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty( + 'openai_responses_flatten_namespaces' + ) + }) + + it('submits the Codex namespace flatten toggle when switched on', async () => { + const account = buildAccount() + account.type = 'oauth' + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + await wrapper.get('[data-testid="edit-openai-flatten-namespaces-toggle"]').trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_responses_flatten_namespaces).toBe( + true + ) + }) + + it('hides the Codex namespace flatten toggle for non-OAuth OpenAI accounts', async () => { + const account = buildAccount() + const wrapper = mountModal(account) + + expect(wrapper.find('[data-testid="edit-openai-flatten-namespaces-toggle"]').exists()).toBe( + false + ) + }) + it('defaults legacy OpenAI accounts to long-context billing disabled', async () => { const account = buildAccount() updateAccountMock.mockReset() diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 483459061..54146fab4 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -489,6 +489,9 @@ export default { oauthPassthrough: 'Auto passthrough (auth only)', oauthPassthroughDesc: 'When enabled, this OpenAI account uses automatic passthrough: the gateway forwards request/response as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering.', + flattenNamespaces: 'Flatten Codex namespace tools (compatibility)', + flattenNamespacesDesc: + 'Disabled by default: Codex namespace tool declarations are forwarded as-is on /responses, which is what the ChatGPT Codex backend expects. Enable only when this OAuth account is routed to a relay that rejects namespace tools — flattening renames them to namespace__tool, which breaks models that address collaboration tools as functions... Compaction requests always flatten regardless of this switch.', longContextBilling: 'API long-context pricing', longContextBillingDesc: 'Disabled by default. Enable only when this account\'s upstream charges OpenAI API long-context rates above the model threshold.', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 98d22a453..5827300c2 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -556,6 +556,9 @@ export default { oauthPassthrough: '自动透传(仅替换认证)', oauthPassthroughDesc: '开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;如遇兼容性问题可随时关闭回滚。', + flattenNamespaces: '摊平 Codex namespace 工具(兼容)', + flattenNamespacesDesc: + '默认关闭:/responses 上的 namespace 工具声明原样转发,这正是 ChatGPT Codex 后端期望的形态。仅当该 OAuth 账号指向不认识 namespace 的兼容上游时才开启——摊平会把工具改名为 namespace__tool,使按 functions.<命名空间>.<工具> 寻址的模型(如 gpt-5.6 多智能体)无法调用。压缩(compact)请求不受该开关影响,始终摊平。', longContextBilling: 'API 长上下文计费', longContextBillingDesc: '默认关闭。仅当该账号的上游会按模型阈值收取 OpenAI API 长上下文费率时开启。', responsesWebsocketsV2: 'Responses WebSocket v2', From edac19e87a35aa340fc1a4481942e691d4426928 Mon Sep 17 00:00:00 2001 From: 17Yuns <68860562+17Yuns@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:28:46 +0800 Subject: [PATCH 004/257] fix(email): generate standards-compliant SMTP messages --- backend/internal/service/email_message.go | 111 +++++++++++++++++ .../internal/service/email_message_test.go | 117 ++++++++++++++++++ backend/internal/service/email_service.go | 17 +-- .../notification_email_service_test.go | 18 +++ .../ops_scheduled_report_service_test.go | 6 +- 5 files changed, 254 insertions(+), 15 deletions(-) create mode 100644 backend/internal/service/email_message.go create mode 100644 backend/internal/service/email_message_test.go diff --git a/backend/internal/service/email_message.go b/backend/internal/service/email_message.go new file mode 100644 index 000000000..fc325c59c --- /dev/null +++ b/backend/internal/service/email_message.go @@ -0,0 +1,111 @@ +package service + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "mime" + "mime/quotedprintable" + "net/mail" + "strings" + "time" +) + +type smtpMessage struct { + envelopeFrom string + envelopeTo string + data []byte +} + +func buildSMTPMessage(config *SMTPConfig, to, subject, body string) (smtpMessage, error) { + if config == nil { + return smtpMessage{}, errors.New("missing SMTP configuration") + } + + fromAddress, err := parseSMTPAddress(config.From, "from") + if err != nil { + return smtpMessage{}, err + } + recipientAddress, err := parseSMTPAddress(to, "recipient") + if err != nil { + return smtpMessage{}, err + } + messageID, err := generateEmailMessageID(fromAddress.Address, config.Host) + if err != nil { + return smtpMessage{}, fmt.Errorf("generate message ID: %w", err) + } + + fromName := sanitizeEmailHeader(config.FromName) + if strings.TrimSpace(fromName) == "" { + fromName = fromAddress.Name + } + fromHeader := (&mail.Address{ + Name: fromName, + Address: fromAddress.Address, + }).String() + toHeader := (&mail.Address{ + Name: recipientAddress.Name, + Address: recipientAddress.Address, + }).String() + subjectHeader := mime.QEncoding.Encode("UTF-8", sanitizeEmailHeader(subject)) + + var message bytes.Buffer + fmt.Fprintf(&message, "From: %s\r\n", fromHeader) + fmt.Fprintf(&message, "To: %s\r\n", toHeader) + fmt.Fprintf(&message, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)) + fmt.Fprintf(&message, "Message-ID: %s\r\n", messageID) + fmt.Fprintf(&message, "Subject: %s\r\n", subjectHeader) + fmt.Fprint(&message, "MIME-Version: 1.0\r\n"+ + "Content-Type: text/html; charset=UTF-8\r\n"+ + "Content-Transfer-Encoding: quoted-printable\r\n\r\n") + + bodyWriter := quotedprintable.NewWriter(&message) + if _, err := bodyWriter.Write([]byte(body)); err != nil { + return smtpMessage{}, fmt.Errorf("encode email body: %w", err) + } + if err := bodyWriter.Close(); err != nil { + return smtpMessage{}, fmt.Errorf("close email body encoder: %w", err) + } + + return smtpMessage{ + envelopeFrom: fromAddress.Address, + envelopeTo: recipientAddress.Address, + data: message.Bytes(), + }, nil +} + +func parseSMTPAddress(value, field string) (*mail.Address, error) { + if strings.ContainsAny(value, "\r\n") { + return nil, fmt.Errorf("invalid SMTP %s address: contains a line break", field) + } + + cleaned := strings.TrimSpace(value) + address, err := mail.ParseAddress(cleaned) + if err != nil || strings.TrimSpace(address.Address) == "" { + if err == nil { + err = fmt.Errorf("address is empty") + } + return nil, fmt.Errorf("invalid SMTP %s address: %w", field, err) + } + return address, nil +} + +func generateEmailMessageID(fromAddress, smtpHost string) (string, error) { + randomID := make([]byte, 16) + if _, err := rand.Read(randomID); err != nil { + return "", err + } + + domain := strings.TrimSpace(sanitizeEmailHeader(smtpHost)) + if at := strings.LastIndexByte(fromAddress, '@'); at >= 0 && at < len(fromAddress)-1 { + domain = fromAddress[at+1:] + } + domain = strings.Trim(domain, "[]<>") + if domain == "" { + domain = "localhost" + } + + return fmt.Sprintf("<%s@%s>", hex.EncodeToString(randomID), domain), nil +} diff --git a/backend/internal/service/email_message_test.go b/backend/internal/service/email_message_test.go new file mode 100644 index 000000000..b5632dbd0 --- /dev/null +++ b/backend/internal/service/email_message_test.go @@ -0,0 +1,117 @@ +//go:build unit + +package service + +import ( + "bytes" + "io" + "mime" + "mime/quotedprintable" + "net/mail" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildSMTPMessageProducesStandardsCompliantMIME(t *testing.T) { + config := &SMTPConfig{ + Host: "smtp.example.com", + From: "reply@example.com", + FromName: "Sub2API 通知", + } + body := "\n验证码:123456 & ready\n" + + message, err := buildSMTPMessage(config, "User ", "邮箱验证码", body) + require.NoError(t, err) + require.Equal(t, "reply@example.com", message.envelopeFrom) + require.Equal(t, "user@example.net", message.envelopeTo) + + parsed, err := mail.ReadMessage(bytes.NewReader(message.data)) + require.NoError(t, err) + + from, err := mail.ParseAddress(parsed.Header.Get("From")) + require.NoError(t, err) + require.Equal(t, "Sub2API 通知", from.Name) + require.Equal(t, "reply@example.com", from.Address) + + recipient, err := mail.ParseAddress(parsed.Header.Get("To")) + require.NoError(t, err) + require.Equal(t, "User", recipient.Name) + require.Equal(t, "user@example.net", recipient.Address) + + decodedSubject, err := new(mime.WordDecoder).DecodeHeader(parsed.Header.Get("Subject")) + require.NoError(t, err) + require.Equal(t, "邮箱验证码", decodedSubject) + require.NotEmpty(t, parsed.Header.Get("Date")) + _, err = mail.ParseDate(parsed.Header.Get("Date")) + require.NoError(t, err) + require.Regexp(t, regexp.MustCompile(`^<[0-9a-f]{32}@example\.com>$`), parsed.Header.Get("Message-ID")) + require.Equal(t, "1.0", parsed.Header.Get("MIME-Version")) + require.Equal(t, "quoted-printable", parsed.Header.Get("Content-Transfer-Encoding")) + + mediaType, params, err := mime.ParseMediaType(parsed.Header.Get("Content-Type")) + require.NoError(t, err) + require.Equal(t, "text/html", mediaType) + require.Equal(t, "UTF-8", params["charset"]) + + decodedBody, err := io.ReadAll(quotedprintable.NewReader(parsed.Body)) + require.NoError(t, err) + require.Equal(t, strings.ReplaceAll(body, "\n", "\r\n"), string(decodedBody)) +} + +func TestBuildSMTPMessagePreventsHeaderInjection(t *testing.T) { + config := &SMTPConfig{ + Host: "smtp.example.com", + From: "reply@example.com", + FromName: "Sender\r\nBcc: hidden@example.com", + } + + message, err := buildSMTPMessage(config, "user@example.net", "Subject\r\nCc: hidden@example.com", "body") + require.NoError(t, err) + + parsed, err := mail.ReadMessage(bytes.NewReader(message.data)) + require.NoError(t, err) + require.Empty(t, parsed.Header.Get("Bcc")) + require.Empty(t, parsed.Header.Get("Cc")) + + decodedSubject, err := new(mime.WordDecoder).DecodeHeader(parsed.Header.Get("Subject")) + require.NoError(t, err) + require.Equal(t, "SubjectCc: hidden@example.com", decodedSubject) +} + +func TestBuildSMTPMessageRejectsInvalidConfiguration(t *testing.T) { + _, err := buildSMTPMessage(nil, "user@example.net", "subject", "body") + require.ErrorContains(t, err, "missing SMTP configuration") + + _, err = buildSMTPMessage(&SMTPConfig{Host: "smtp.example.com"}, "user@example.net", "subject", "body") + require.ErrorContains(t, err, "invalid SMTP from address") + + _, err = buildSMTPMessage(&SMTPConfig{ + Host: "smtp.example.com", + From: "reply@example.com", + }, "invalid recipient <>", "subject", "body") + require.ErrorContains(t, err, "invalid SMTP recipient address") + + _, err = buildSMTPMessage(&SMTPConfig{ + Host: "smtp.example.com", + From: "reply@example.com", + }, "user@example.net\r\nBcc: hidden@example.net", "subject", "body") + require.ErrorContains(t, err, "invalid SMTP recipient address") +} + +func TestBuildSMTPMessageUsesUniqueMessageIDs(t *testing.T) { + config := &SMTPConfig{Host: "smtp.example.com", From: "reply@example.com"} + + first, err := buildSMTPMessage(config, "user@example.net", "subject", "body") + require.NoError(t, err) + second, err := buildSMTPMessage(config, "user@example.net", "subject", "body") + require.NoError(t, err) + + firstParsed, err := mail.ReadMessage(bytes.NewReader(first.data)) + require.NoError(t, err) + secondParsed, err := mail.ReadMessage(bytes.NewReader(second.data)) + require.NoError(t, err) + require.NotEqual(t, firstParsed.Header.Get("Message-ID"), secondParsed.Header.Get("Message-ID")) +} diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 663c39c2f..082525754 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -186,26 +186,19 @@ const smtpIOTimeout = 20 * time.Second // SendEmailWithConfig 使用指定配置发送邮件 func (s *EmailService) SendEmailWithConfig(config *SMTPConfig, to, subject, body string) error { - // Sanitize all SMTP header fields to prevent header injection (CR/LF removal). - to = sanitizeEmailHeader(to) - subject = sanitizeEmailHeader(subject) - - from := sanitizeEmailHeader(config.From) - if config.FromName != "" { - from = fmt.Sprintf("%s <%s>", sanitizeEmailHeader(config.FromName), sanitizeEmailHeader(config.From)) + message, err := buildSMTPMessage(config, to, subject, body) + if err != nil { + return err } - msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s", - from, to, subject, body) - addr := fmt.Sprintf("%s:%d", config.Host, config.Port) auth := smtp.PlainAuth("", config.Username, config.Password, config.Host) if config.UseTLS { - return s.sendMailTLS(addr, auth, config.From, to, []byte(msg), config.Host) + return s.sendMailTLS(addr, auth, message.envelopeFrom, message.envelopeTo, message.data, config.Host) } - return s.sendMailPlain(addr, auth, config.From, to, []byte(msg), config.Host) + return s.sendMailPlain(addr, auth, message.envelopeFrom, message.envelopeTo, message.data, config.Host) } // sendMailPlain sends mail without TLS using a dialer with timeout. diff --git a/backend/internal/service/notification_email_service_test.go b/backend/internal/service/notification_email_service_test.go index 772eb45e9..9f1276140 100644 --- a/backend/internal/service/notification_email_service_test.go +++ b/backend/internal/service/notification_email_service_test.go @@ -4,7 +4,10 @@ import ( "bufio" "context" "errors" + "io" + "mime/quotedprintable" "net" + "net/mail" "strings" "sync" "sync/atomic" @@ -598,6 +601,21 @@ func (s *notificationEmailTestSMTPServer) lastMessage() string { return s.messageBodies[len(s.messageBodies)-1] } +func (s *notificationEmailTestSMTPServer) lastMessageBody(t *testing.T) string { + t.Helper() + + message, err := mail.ReadMessage(strings.NewReader(s.lastMessage())) + require.NoError(t, err) + + bodyReader := io.Reader(message.Body) + if strings.EqualFold(message.Header.Get("Content-Transfer-Encoding"), "quoted-printable") { + bodyReader = quotedprintable.NewReader(message.Body) + } + body, err := io.ReadAll(bodyReader) + require.NoError(t, err) + return string(body) +} + func (s *notificationEmailTestSMTPServer) close() { _ = s.listener.Close() s.wg.Wait() diff --git a/backend/internal/service/ops_scheduled_report_service_test.go b/backend/internal/service/ops_scheduled_report_service_test.go index e4bfcbf05..0904f10c3 100644 --- a/backend/internal/service/ops_scheduled_report_service_test.go +++ b/backend/internal/service/ops_scheduled_report_service_test.go @@ -131,9 +131,9 @@ func TestOpsScheduledReportLegacyTemplateReceivesSummaryHTML(t *testing.T) { require.NoError(t, err) require.Equal(t, 1, attempts) require.Equal(t, int64(1), smtpServer.messageCount()) - message := smtpServer.lastMessage() - require.Contains(t, message, `
`) - require.Contains(t, message, `

日报

`) + messageBody := smtpServer.lastMessageBody(t) + require.Contains(t, messageBody, `
`) + require.Contains(t, messageBody, `

日报

`) } func TestFormatOpsReportIntegerGroupsDigits(t *testing.T) { From e3e033bb359f1dba628eae38a57d2cf5c9072b9b Mon Sep 17 00:00:00 2001 From: keaipiao <71310981+keaipiao@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:46:12 -0700 Subject: [PATCH 005/257] fix(payment): preserve UTF-8 in EasyPay errors --- backend/internal/payment/provider/easypay.go | 7 ++++++- .../payment/provider/easypay_refund_test.go | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index f1c17427a..c04f86479 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -15,6 +15,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/Wei-Shaw/sub2api/internal/payment" ) @@ -477,7 +478,11 @@ func summarizeEasyPayResponse(body []byte) string { return "" } if len(summary) > maxEasypayErrorSummary { - return summary[:maxEasypayErrorSummary] + "..." + truncated := summary[:maxEasypayErrorSummary] + for len(truncated) > 0 && !utf8.ValidString(truncated) { + truncated = truncated[:len(truncated)-1] + } + return truncated + "..." } return summary } diff --git a/backend/internal/payment/provider/easypay_refund_test.go b/backend/internal/payment/provider/easypay_refund_test.go index 3b7632987..8d535b512 100644 --- a/backend/internal/payment/provider/easypay_refund_test.go +++ b/backend/internal/payment/provider/easypay_refund_test.go @@ -7,6 +7,7 @@ import ( "net/url" "strings" "testing" + "unicode/utf8" "github.com/Wei-Shaw/sub2api/internal/payment" ) @@ -179,6 +180,18 @@ func TestEasyPayRefundResponseErrors(t *testing.T) { } } +func TestSummarizeEasyPayResponsePreservesUTF8(t *testing.T) { + t.Parallel() + + summary := summarizeEasyPayResponse([]byte(strings.Repeat("错", 171))) + if !utf8.ValidString(summary) { + t.Fatalf("summarizeEasyPayResponse returned invalid UTF-8: %q", summary) + } + if !strings.HasSuffix(summary, "...") { + t.Fatalf("summarizeEasyPayResponse() = %q, want truncated suffix", summary) + } +} + func TestEasyPayCustomMethodsUseConfiguredUpstreamType(t *testing.T) { t.Parallel() From 92dc61d401958899334c947a466b7328319eaa2b Mon Sep 17 00:00:00 2001 From: Heatherm Huang Date: Wed, 29 Jul 2026 12:30:41 +0800 Subject: [PATCH 006/257] fix(channels): show composite models by platform Expand visible Composite groups into each configured concrete model platform while preserving ordinary group isolation and empty-state behavior.\n\nFixes #4985 --- .../handler/available_channel_handler.go | 37 ++++++- .../handler/available_channel_handler_test.go | 98 +++++++++++++++++++ 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index 05d64e200..300eb1b1a 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -12,12 +12,11 @@ import ( // AvailableChannelHandler 处理用户侧「可用渠道」查询。 // -// 用户侧接口委托 ChannelService.ListAvailable,并在返回前做三层过滤: +// 用户侧接口委托 ChannelService.ListAvailable,并在返回前做四层过滤: // 1. 行过滤:只保留状态为 Active 且与当前用户可访问分组有交集的渠道; // 2. 分组过滤:渠道的 Groups 只保留用户可访问的那些; -// 3. 平台过滤:渠道的 SupportedModels 只保留平台在用户可见 Groups 中出现过的模型, -// 防止"渠道同时挂在 antigravity / anthropic 两个平台的分组上,用户只访问 -// antigravity,却看到 anthropic 模型"这类跨平台信息泄漏; +// 3. 平台过滤:普通分组只保留自身平台模型;Composite 分组按渠道已配置的具体模型平台 +// 展开。这样既防止普通分组跨平台泄漏,也让 Composite 正确展示其多平台能力; // 4. 字段白名单:仅返回用户需要的字段(省略 BillingModelSource / RestrictModels // / 内部 ID / Status 等管理字段)。 type AvailableChannelHandler struct { @@ -172,19 +171,47 @@ func (h *AvailableChannelHandler) List(c *gin.Context) { } // buildPlatformSections 把一个渠道按 visibleGroups 的平台集合拆成有序的 section 列表: -// 每个 section 对应一个平台,只包含该平台的 groups 和 supported_models。 +// 每个 section 对应一个具体平台,只包含该平台的 groups 和 supported_models。 +// +// Composite 分组可访问渠道中所有已配置的具体平台,因此会被展开到每个有支持模型的 +// 平台 section。普通分组仍严格留在自身平台,避免跨平台模型信息泄漏。Composite 渠道 +// 尚未配置任何模型时保留 composite section,以便前端继续展示该分组和“未配置模型”状态。 // 输出按 platform 字母序稳定排序,便于前端等效比较与回归测试。 func buildPlatformSections( ch service.AvailableChannel, visibleGroups []userAvailableGroup, ) []userChannelPlatformSection { groupsByPlatform := make(map[string][]userAvailableGroup, 4) + compositeGroups := make([]userAvailableGroup, 0, 1) for _, g := range visibleGroups { if g.Platform == "" { continue } + if g.Platform == service.PlatformComposite { + compositeGroups = append(compositeGroups, g) + continue + } groupsByPlatform[g.Platform] = append(groupsByPlatform[g.Platform], g) } + + if len(compositeGroups) > 0 { + modelPlatforms := make(map[string]struct{}, len(ch.SupportedModels)) + for i := range ch.SupportedModels { + if platform := ch.SupportedModels[i].Platform; platform != "" { + modelPlatforms[platform] = struct{}{} + } + } + if len(modelPlatforms) == 0 { + groupsByPlatform[service.PlatformComposite] = append( + groupsByPlatform[service.PlatformComposite], + compositeGroups..., + ) + } else { + for platform := range modelPlatforms { + groupsByPlatform[platform] = append(groupsByPlatform[platform], compositeGroups...) + } + } + } if len(groupsByPlatform) == 0 { return nil } diff --git a/backend/internal/handler/available_channel_handler_test.go b/backend/internal/handler/available_channel_handler_test.go index bb5329798..24d7d1cb4 100644 --- a/backend/internal/handler/available_channel_handler_test.go +++ b/backend/internal/handler/available_channel_handler_test.go @@ -155,3 +155,101 @@ func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) { require.Len(t, sections[0].SupportedModels, 1) require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name) } + +func TestBuildPlatformSections_CompositeGroupExpandsAcrossConfiguredModelPlatforms(t *testing.T) { + anthropicPrice := 3e-6 + openAIPrice := 2.5e-6 + ch := service.AvailableChannel{ + Name: "composite-channel", + SupportedModels: []service.SupportedModel{ + { + Name: "claude-sonnet-4-6", + Platform: service.PlatformAnthropic, + Pricing: &service.ChannelModelPricing{InputPrice: &anthropicPrice}, + }, + { + Name: "gpt-5", + Platform: service.PlatformOpenAI, + Pricing: &service.ChannelModelPricing{InputPrice: &openAIPrice}, + }, + }, + } + visible := []userAvailableGroup{ + {ID: 9, Name: "composite", Platform: service.PlatformComposite}, + } + + sections := buildPlatformSections(ch, visible) + + require.Len(t, sections, 2) + require.Equal(t, service.PlatformAnthropic, sections[0].Platform) + require.Equal(t, service.PlatformOpenAI, sections[1].Platform) + for _, section := range sections { + require.Len(t, section.Groups, 1) + require.Equal(t, int64(9), section.Groups[0].ID) + require.Equal(t, service.PlatformComposite, section.Groups[0].Platform) + require.Len(t, section.SupportedModels, 1) + require.Equal(t, section.Platform, section.SupportedModels[0].Platform) + require.NotNil(t, section.SupportedModels[0].Pricing) + } + require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name) + require.Equal(t, "gpt-5", sections[1].SupportedModels[0].Name) +} + +func TestBuildPlatformSections_OrdinaryGroupRemainsPlatformIsolated(t *testing.T) { + ch := service.AvailableChannel{ + SupportedModels: []service.SupportedModel{ + {Name: "claude-sonnet-4-6", Platform: service.PlatformAnthropic}, + {Name: "gpt-5", Platform: service.PlatformOpenAI}, + }, + } + visible := []userAvailableGroup{ + {ID: 1, Name: "anthropic-only", Platform: service.PlatformAnthropic}, + } + + sections := buildPlatformSections(ch, visible) + + require.Len(t, sections, 1) + require.Equal(t, service.PlatformAnthropic, sections[0].Platform) + require.Len(t, sections[0].SupportedModels, 1) + require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name) +} + +func TestBuildPlatformSections_CompositeAndOrdinaryGroupsShareConcreteSection(t *testing.T) { + ch := service.AvailableChannel{ + SupportedModels: []service.SupportedModel{ + {Name: "claude-sonnet-4-6", Platform: service.PlatformAnthropic}, + {Name: "gpt-5", Platform: service.PlatformOpenAI}, + }, + } + visible := []userAvailableGroup{ + {ID: 1, Name: "anthropic-only", Platform: service.PlatformAnthropic}, + {ID: 9, Name: "composite", Platform: service.PlatformComposite}, + } + + sections := buildPlatformSections(ch, visible) + + require.Len(t, sections, 2) + require.Equal(t, service.PlatformAnthropic, sections[0].Platform) + require.Equal(t, []int64{1, 9}, []int64{ + sections[0].Groups[0].ID, + sections[0].Groups[1].ID, + }) + require.Equal(t, service.PlatformOpenAI, sections[1].Platform) + require.Len(t, sections[1].Groups, 1) + require.Equal(t, int64(9), sections[1].Groups[0].ID) +} + +func TestBuildPlatformSections_CompositeWithoutModelsKeepsEmptyCompositeSection(t *testing.T) { + visible := []userAvailableGroup{ + {ID: 9, Name: "composite", Platform: service.PlatformComposite}, + } + + sections := buildPlatformSections(service.AvailableChannel{ + SupportedModels: []service.SupportedModel{{Name: "invalid-without-platform"}}, + }, visible) + + require.Len(t, sections, 1) + require.Equal(t, service.PlatformComposite, sections[0].Platform) + require.Len(t, sections[0].Groups, 1) + require.Empty(t, sections[0].SupportedModels) +} From 0353cdadfef569138b3ec84885cffb6f0cabcc43 Mon Sep 17 00:00:00 2001 From: Ricardo-binZzz <285964759+Ricardo-binZzz@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:42:52 +0800 Subject: [PATCH 007/257] fix(logging): record cleanup success at info level --- backend/internal/service/ops_cleanup_service.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/ops_cleanup_service.go b/backend/internal/service/ops_cleanup_service.go index cfd2f1eed..38e94fb17 100644 --- a/backend/internal/service/ops_cleanup_service.go +++ b/backend/internal/service/ops_cleanup_service.go @@ -15,6 +15,7 @@ import ( "github.com/google/uuid" "github.com/redis/go-redis/v9" "github.com/robfig/cron/v3" + "go.uber.org/zap" ) const ( @@ -285,7 +286,10 @@ func (s *OpsCleanupService) runScheduled() { return } s.recordHeartbeatSuccess(runAt, time.Since(startedAt), counts) - logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] cleanup complete: %s", counts) + logger.L().Info("[OpsCleanup] cleanup complete", + zap.String("component", "service.ops_cleanup"), + zap.String("deleted_counts", counts.String()), + ) } func (s *OpsCleanupService) runCleanupOnce(ctx context.Context) (opsCleanupDeletedCounts, error) { From 0010894f99a68eeff7bf39b972e0cf369c2a7346 Mon Sep 17 00:00:00 2001 From: Ricardo-binZzz <285964759+Ricardo-binZzz@users.noreply.github.com> Date: Wed, 29 Jul 2026 07:29:26 +0800 Subject: [PATCH 008/257] fix(deploy): prevent application privilege gains --- .github/workflows/backend-ci.yml | 1 + deploy/docker-compose.dev.yml | 2 + deploy/docker-compose.local.yml | 2 + deploy/docker-compose.standalone.yml | 2 + deploy/docker-compose.yml | 2 + deploy/tests/docker-compose-security-test.sh | 44 ++++++++++++++++++++ 6 files changed, 53 insertions(+) create mode 100755 deploy/tests/docker-compose-security-test.sh diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index b91734495..77729863d 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -16,6 +16,7 @@ jobs: run: | /bin/bash -n deploy/apple-container.sh /bin/bash deploy/tests/apple-container-test.sh + /bin/sh deploy/tests/docker-compose-security-test.sh /bin/sh deploy/test-caddyfile-cache.sh test: diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index db00ac9de..8603f121d 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -17,6 +17,8 @@ services: NPM_CONFIG_REGISTRY: ${NPM_CONFIG_REGISTRY:-https://registry.npmmirror.com} container_name: sub2api-dev restart: unless-stopped + security_opt: + - no-new-privileges:true ports: - "${BIND_HOST:-127.0.0.1}:${SERVER_PORT:-8080}:8080" volumes: diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index 364fcc2ec..588862e2f 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -27,6 +27,8 @@ services: image: weishaw/sub2api:latest container_name: sub2api restart: unless-stopped + security_opt: + - no-new-privileges:true ulimits: nofile: soft: 100000 diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index 04a46a8dd..cd0a00887 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -15,6 +15,8 @@ services: image: weishaw/sub2api:latest container_name: sub2api restart: unless-stopped + security_opt: + - no-new-privileges:true ulimits: nofile: soft: 100000 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 3e1e69a89..68a51e78d 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -19,6 +19,8 @@ services: image: weishaw/sub2api:latest container_name: sub2api restart: unless-stopped + security_opt: + - no-new-privileges:true ulimits: nofile: soft: 100000 diff --git a/deploy/tests/docker-compose-security-test.sh b/deploy/tests/docker-compose-security-test.sh new file mode 100755 index 000000000..b6cfe768d --- /dev/null +++ b/deploy/tests/docker-compose-security-test.sh @@ -0,0 +1,44 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +cd "$repo_root" + +check_application_security_opt() { + file=$1 + count=$( + awk ' + $0 == " sub2api:" { + in_application = 1 + next + } + in_application && $0 ~ /^ [A-Za-z0-9_-]+:$/ { + in_application = 0 + } + in_application && $0 == " security_opt:" { + in_security_opt = 1 + next + } + in_application && in_security_opt && $0 == " - no-new-privileges:true" { + count++ + } + END { print count + 0 } + ' "$file" + ) + + if [ "$count" -ne 1 ]; then + printf '%s must enable no-new-privileges exactly once for the sub2api service\n' "$file" >&2 + exit 1 + fi +} + +for compose_file in \ + deploy/docker-compose.yml \ + deploy/docker-compose.local.yml \ + deploy/docker-compose.standalone.yml \ + deploy/docker-compose.dev.yml +do + check_application_security_opt "$compose_file" +done + +printf 'docker compose security test passed\n' From b74cb7891ccfac92ac9cbb9f340aa69244aa2153 Mon Sep 17 00:00:00 2001 From: Nick <126941599+coo1white@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:31:18 +0700 Subject: [PATCH 009/257] fix(grok): bump pinned Grok CLI version to 0.2.114 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cli-chat-proxy.grok.com` turns away requests that do not name a supported client version — the failure mode #3952 was opened for. The pin has sat at 0.2.93 since 2026-07-17, while https://x.ai/cli/stable and `npm view @xai-official/grok version` both report 0.2.114. The version was written out in three places, which is how they came to drift: - `repository/http_upstream.go` (OAuth traffic through the CLI proxy) - `pkg/xai/billing.go` (billing and quota probes) - `service/openai_gateway_grok.go` (gateway request headers) `xai.CLIClientVersion` is now the single source, and the other two build their own client identity from it, so the next bump is one line. `internal/pkg/xai` is a leaf package both layers already import, so this adds no cycle. `CLIUserAgent` derives from the same constant. Checked against the real 0.2.114 binary rather than the version feed alone: the `x-grok-client-version` header name, the `xai-grok-cli` token-auth value, and the version string all match what the CLI sends. The pinned value is also the floor an operator override must clear, so some fixtures carried a meaning relative to it rather than a fixed number. Those were moved, not search-and-replaced: - the "valid override" case now uses 0.2.115-alpha.1; at 0.2.95-alpha.1 it would fall below the new floor, be dropped, and stop testing acceptance at all - the prerelease-at-the-minimum case tracks the pin to 0.2.114-beta.1, or it becomes a copy of the below-minimum case - three of the five malformed-semver entries sat below the new floor and would have been turned away for being old, not for being malformed Co-Authored-By: Claude Opus 5 --- README.md | 2 +- backend/internal/pkg/xai/billing.go | 5 ++- backend/internal/repository/http_upstream.go | 3 +- .../internal/repository/http_upstream_test.go | 42 ++++++++++--------- .../internal/service/openai_gateway_grok.go | 2 +- 5 files changed, 30 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index eb0f4ba3f..1cf3716f5 100644 --- a/README.md +++ b/README.md @@ -743,7 +743,7 @@ The Grok OAuth flow uses PKCE and does not require committing private secrets. T | `XAI_OAUTH_AUTHORIZE_URL` | `https://auth.x.ai/oauth2/authorize` | | `XAI_OAUTH_TOKEN_URL` | `https://auth.x.ai/oauth2/token` | | `XAI_BASE_URL` | `https://api.x.ai/v1`; runtime-diagnostics override (account `base_url` controls request forwarding) | -| `XAI_GROK_CLI_VERSION` | `0.2.93`; optional override for the client identity sent to `cli-chat-proxy.grok.com` | +| `XAI_GROK_CLI_VERSION` | `0.2.114`; optional override for the client identity sent to `cli-chat-proxy.grok.com`. The pinned value is also the floor: an override below it is dropped | Administrators can create Grok OAuth or API-key accounts from the dashboard. OAuth authorization and reauthorization are also available through the admin API: diff --git a/backend/internal/pkg/xai/billing.go b/backend/internal/pkg/xai/billing.go index d8580ebab..3c620a988 100644 --- a/backend/internal/pkg/xai/billing.go +++ b/backend/internal/pkg/xai/billing.go @@ -15,8 +15,11 @@ const ( CLITokenAuthHeader = "x-xai-token-auth" CLITokenAuthValue = "xai-grok-cli" CLIClientVersionHeader = "x-grok-client-version" + // CLIClientVersion is the one place the pinned Grok CLI version lives. The + // repository and service layers build their own client identity from it, so + // one bump here covers OAuth traffic and billing probes together. // Keep in sync with https://x.ai/cli/stable. - CLIClientVersion = "0.2.93" + CLIClientVersion = "0.2.114" CLIUserAgent = "grok-pager/" + CLIClientVersion + " grok-shell/" + CLIClientVersion + " (macos; aarch64)" BillingWeeklyPath = "/billing?format=credits" diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index ec42e189f..68a986648 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -30,6 +30,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil" "github.com/Wei-Shaw/sub2api/internal/pkg/servertiming" "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" "golang.org/x/mod/semver" @@ -76,7 +77,7 @@ const ( // allowing operators to bump it without waiting for a Sub2API release. grokCLIProxyHost = "cli-chat-proxy.grok.com" grokOfficialAPIHost = "api.x.ai" - grokCLIStableVersion = "0.2.93" + grokCLIStableVersion = xai.CLIClientVersion grokCLIVersionOverride = "XAI_GROK_CLI_VERSION" grokFallbackBodyLimit = 64 << 10 ) diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go index 35df74799..ac0c064b2 100644 --- a/backend/internal/repository/http_upstream_test.go +++ b/backend/internal/repository/http_upstream_test.go @@ -235,9 +235,9 @@ func TestHTTPUpstreamDoAppliesGrokCLIIdentityBeforeOAuthRoundTrip(t *testing.T) require.Equal(t, http.StatusOK, resp.StatusCode) require.NoError(t, resp.Body.Close()) - require.Equal(t, "0.2.93", capturedHeaders.Get("x-grok-client-version")) + require.Equal(t, "0.2.114", capturedHeaders.Get("x-grok-client-version")) require.Equal(t, "xai-grok-cli", capturedHeaders.Get("X-XAI-Token-Auth")) - require.Equal(t, "xai-grok-workspace/0.2.93", capturedHeaders.Get("User-Agent")) + require.Equal(t, "xai-grok-workspace/0.2.114", capturedHeaders.Get("User-Agent")) }) } } @@ -458,61 +458,63 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version")) + require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) require.Equal(t, "xai-grok-cli", req.Header.Get("X-XAI-Token-Auth")) - require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent")) + require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) }) t.Run("accepts a valid operator override", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95-alpha.1") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115-alpha.1") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/chat/completions", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.95-alpha.1", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.95-alpha.1", req.Header.Get("User-Agent")) + require.Equal(t, "0.2.115-alpha.1", req.Header.Get("x-grok-client-version")) + require.Equal(t, "xai-grok-workspace/0.2.115-alpha.1", req.Header.Get("User-Agent")) }) t.Run("rejects an unsafe override", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.95\r\nX-Injected: true") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.115\r\nX-Injected: true") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version")) + require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) require.Empty(t, req.Header.Get("X-Injected")) }) t.Run("rejects an override below the supported minimum", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.92") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.113") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent")) + require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) + require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) }) t.Run("rejects a prerelease override at the minimum version", func(t *testing.T) { - t.Setenv("XAI_GROK_CLI_VERSION", "0.2.93-beta.1") + t.Setenv("XAI_GROK_CLI_VERSION", "0.2.114-beta.1") req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil) require.NoError(t, err) applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent")) + require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) + require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) }) + // Every entry sits above the pinned minimum, so a rejection here can only be + // caused by the malformed semver and never by the version being too old. for _, version := range []string{ - "0.2.093", - "0.2.94-alpha..1", + "0.2.0115", + "0.2.115-alpha..1", "0.3", "1", - "0.2.95+build.1", + "0.2.115+build.1", } { t.Run("rejects invalid semver "+version, func(t *testing.T) { t.Setenv("XAI_GROK_CLI_VERSION", version) @@ -521,8 +523,8 @@ func TestApplyGrokCLIProxyHeaders(t *testing.T) { applyGrokCLIProxyHeaders(req) - require.Equal(t, "0.2.93", req.Header.Get("x-grok-client-version")) - require.Equal(t, "xai-grok-workspace/0.2.93", req.Header.Get("User-Agent")) + require.Equal(t, "0.2.114", req.Header.Get("x-grok-client-version")) + require.Equal(t, "xai-grok-workspace/0.2.114", req.Header.Get("User-Agent")) }) } diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index fdfd0bf32..e9048a770 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -23,7 +23,7 @@ const ( grokComposerImageBridgeVisionModel = "grok-build-0.1" grokComposerImageBridgeMaxOutputTokens = 512 grokUpstreamUserAgent = "sub2api-grok/1.0" - grokCLIVersion = "0.2.93" + grokCLIVersion = xai.CLIClientVersion grokDefaultResponsesModel = "grok-4.5" grokRateLimitFallbackCooldown = 2 * time.Minute grokRateLimitRepeatCooldown = 10 * time.Minute From 54b1f8f6b359f04c36fc9e1ceb25575f9ce52ec6 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 29 Jul 2026 16:55:42 +0800 Subject: [PATCH 010/257] fix(anthropic): recognize auto mode classifier --- .../internal/service/claude_code_validator.go | 53 ++++++- .../service/claude_code_validator_test.go | 134 ++++++++++++++++++ 2 files changed, 185 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/claude_code_validator.go b/backend/internal/service/claude_code_validator.go index 923f30b7c..d6acd0865 100644 --- a/backend/internal/service/claude_code_validator.go +++ b/backend/internal/service/claude_code_validator.go @@ -48,9 +48,14 @@ var claudeCodeSystemPrompts = []string{ } const ( + // These markers identify Claude Code's official security-monitor classifier + // request without coupling validation to every wording change in the prompt. + claudeCodeSecurityMonitorPromptPrefix = "You are a security monitor for autonomous AI coding agents." + claudeCodeSecurityMonitorPromptMinLen = 10_000 + // claudeCodeBillingHeaderPrefix 是 Claude Code 在 system 数组首块注入的计费归因块前缀。 - // 该块存在于所有真实 Claude Code CLI 请求中(含安全监视器等无身份 prose 的子请求), - // 格式固定、不随提示词改版漂移,是比身份 prose 更稳定的客户端标识。 + // 大多数真实 CLI 请求(含部分无身份 prose 的子请求)会携带该块;不携带该块的 + // 固定官方辅助请求由独立规则识别。该格式比身份 prose 更稳定。 // 生成见 gateway_billing_block.go;同类识别见 pkg/apicompat/anthropic_to_responses.go。 claudeCodeBillingHeaderPrefix = "x-anthropic-billing-header" // claudeCodeEntrypointMarker 标识计费块携带入口归因字段。不绑定具体入口值 @@ -167,6 +172,10 @@ func (v *ClaudeCodeValidator) hasClaudeCodeSystemPrompt(body map[string]any) boo return false } + if isClaudeCodeSecurityMonitorPrompt(systemEntries) { + return true + } + // 检查每个 system entry for _, entry := range systemEntries { entryMap, ok := entry.(map[string]any) @@ -196,6 +205,46 @@ func (v *ClaudeCodeValidator) hasClaudeCodeSystemPrompt(body map[string]any) boo return false } +func isClaudeCodeSecurityMonitorPrompt(systemEntries []any) bool { + if len(systemEntries) != 1 { + return false + } + + entry, ok := systemEntries[0].(map[string]any) + if !ok { + return false + } + + entryType, ok := entry["type"].(string) + if !ok || entryType != "text" { + return false + } + + text, ok := entry["text"].(string) + if !ok || len(text) < claudeCodeSecurityMonitorPromptMinLen || + !strings.HasPrefix(text, claudeCodeSecurityMonitorPromptPrefix) { + return false + } + + markers := []string{ + "## Threat Model", + "- ``:", + "## HARD BLOCK", + "## SOFT BLOCK", + "## Classification Process", + "## Output Format", + "yes", + "no", + } + for _, marker := range markers { + if !strings.Contains(text, marker) { + return false + } + } + + return true +} + // bestSimilarityScore 计算文本与所有 Claude Code 模板的最佳相似度 func (v *ClaudeCodeValidator) bestSimilarityScore(text string) float64 { normalizedText := normalizePrompt(text) diff --git a/backend/internal/service/claude_code_validator_test.go b/backend/internal/service/claude_code_validator_test.go index d54177d13..18c0147b9 100644 --- a/backend/internal/service/claude_code_validator_test.go +++ b/backend/internal/service/claude_code_validator_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" @@ -135,6 +136,139 @@ func TestClaudeCodeValidator_BillingBlockRecognizedWithoutIdentityPrompt(t *test require.True(t, ok) } +func TestClaudeCodeValidator_SecurityMonitorWithoutBillingBlock(t *testing.T) { + monitorPrompt, err := os.ReadFile("testdata/security_monitor_system_prompt.txt") + require.NoError(t, err) + + validHeaders := map[string]string{ + "User-Agent": "claude-cli/2.1.220 (external, cli)", + "X-App": "cli", + "anthropic-beta": "claude-code-20250219", + "anthropic-version": "2023-06-01", + } + validBody := func(prompt string) map[string]any { + return map[string]any{ + "model": "claude-haiku-4-5-20251001", + "system": []any{ + map[string]any{"type": "text", "text": prompt}, + }, + "metadata": map[string]any{"user_id": claudeCodeMetadataUserIDJSON}, + } + } + + tests := []struct { + name string + headers map[string]string + body map[string]any + wantAccept bool + }{ + { + name: "official classifier request", + headers: validHeaders, + body: validBody(string(monitorPrompt)), + wantAccept: true, + }, + { + name: "non-Claude user agent", + headers: map[string]string{ + "User-Agent": "curl/8.0.0", + "X-App": "cli", + "anthropic-beta": "claude-code-20250219", + "anthropic-version": "2023-06-01", + }, + body: validBody(string(monitorPrompt)), + }, + { + name: "missing X-App", + headers: map[string]string{ + "User-Agent": validHeaders["User-Agent"], + "anthropic-beta": validHeaders["anthropic-beta"], + "anthropic-version": validHeaders["anthropic-version"], + }, + body: validBody(string(monitorPrompt)), + }, + { + name: "missing anthropic-beta", + headers: map[string]string{ + "User-Agent": validHeaders["User-Agent"], + "X-App": validHeaders["X-App"], + "anthropic-version": validHeaders["anthropic-version"], + }, + body: validBody(string(monitorPrompt)), + }, + { + name: "missing anthropic-version", + headers: map[string]string{ + "User-Agent": validHeaders["User-Agent"], + "X-App": validHeaders["X-App"], + "anthropic-beta": validHeaders["anthropic-beta"], + }, + body: validBody(string(monitorPrompt)), + }, + { + name: "missing metadata", + headers: validHeaders, + body: map[string]any{ + "model": "claude-haiku-4-5-20251001", + "system": []any{map[string]any{"type": "text", "text": string(monitorPrompt)}}, + }, + }, + { + name: "invalid metadata user ID", + headers: validHeaders, + body: func() map[string]any { + body := validBody(string(monitorPrompt)) + body["metadata"] = map[string]any{"user_id": "invalid"} + return body + }(), + }, + { + name: "unrelated prompt", + headers: validHeaders, + body: validBody("You are a different security classifier for coding agents."), + wantAccept: false, + }, + { + name: "opening sentence alone", + headers: validHeaders, + body: validBody(claudeCodeSecurityMonitorPromptPrefix), + wantAccept: false, + }, + { + name: "opening sentence plus arbitrary altered suffix", + headers: validHeaders, + body: validBody(claudeCodeSecurityMonitorPromptPrefix + "\n\n" + + strings.Repeat("This is arbitrary altered classifier content. ", 300)), + wantAccept: false, + }, + { + name: "multiple system entries without billing block", + headers: validHeaders, + body: func() map[string]any { + body := validBody(string(monitorPrompt)) + body["system"] = append(body["system"].([]any), map[string]any{ + "type": "text", + "text": "Additional unrelated system content.", + }) + return body + }(), + wantAccept: false, + }, + } + + validator := NewClaudeCodeValidator() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil) + for name, value := range tt.headers { + req.Header.Set(name, value) + } + + require.Equal(t, tt.wantAccept, validator.Validate(req, tt.body)) + }) + } +} + func TestClaudeCodeValidator_BillingBlockVSCodeEntrypointRecognized(t *testing.T) { // 回归:Claude Code 在 VSCode 扩展内运行时,计费块入口为 cc_entrypoint=claude-vscode // 而非 cli。其安全监视器子请求同样不携带身份 prose,此前写死 cc_entrypoint=cli 的 From ac90355a878cef2a32e6d6b4a77ed59c091286c0 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 29 Jul 2026 18:35:56 +0800 Subject: [PATCH 011/257] fix(account): skip unschedulable token refresh candidates --- backend/internal/repository/account_repo.go | 1 + .../repository/account_repo_integration_test.go | 9 +++++++++ .../repository/account_repo_temp_unsched_test.go | 2 ++ .../token_refresh_service_candidates_test.go | 14 ++++++++++++++ 4 files changed, 26 insertions(+) diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 212523a4f..1d4f9d0d0 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -1107,6 +1107,7 @@ func (r *accountRepository) ListOAuthRefreshCandidatePage(ctx context.Context, o SELECT id FROM accounts WHERE deleted_at IS NULL + AND schedulable = TRUE AND platform = ANY($1) AND id > $2` if options.ActiveOnly { diff --git a/backend/internal/repository/account_repo_integration_test.go b/backend/internal/repository/account_repo_integration_test.go index fa4eed7f5..55f4cd64d 100644 --- a/backend/internal/repository/account_repo_integration_test.go +++ b/backend/internal/repository/account_repo_integration_test.go @@ -327,6 +327,14 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus "expires_at": now.Add(30 * time.Minute).Format(time.RFC3339), }, }) + unschedulable := mustCreateAccount(s.T(), s.client, &service.Account{ + Name: "grok-oauth-unschedulable-excluded", + Platform: service.PlatformGrok, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + Credentials: map[string]any{"refresh_token": "refresh-unschedulable"}, + }) + s.Require().NoError(s.client.Account.UpdateOneID(unschedulable.ID).SetSchedulable(false).Exec(s.ctx)) mustCreateAccount(s.T(), s.client, &service.Account{ Name: "grok-api-key-excluded", Platform: service.PlatformGrok, @@ -386,6 +394,7 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus first := firstPage.Accounts s.Require().Len(first, 2) s.Require().Equal([]int64{valid1.ID, valid2.ID}, []int64{first[0].ID, first[1].ID}) + s.Require().NotContains([]int64{first[0].ID, first[1].ID}, unschedulable.ID) options.AfterID = first[len(first)-1].ID secondPage, err := s.repo.ListOAuthRefreshCandidatePage(s.ctx, options) diff --git a/backend/internal/repository/account_repo_temp_unsched_test.go b/backend/internal/repository/account_repo_temp_unsched_test.go index df1e7abf3..b7604e6c3 100644 --- a/backend/internal/repository/account_repo_temp_unsched_test.go +++ b/backend/internal/repository/account_repo_temp_unsched_test.go @@ -276,6 +276,8 @@ func TestAccountRepository_ListOAuthRefreshCandidatePage_SQLFilter(t *testing.T) normalized := normalizeSQLWhitespace(capturedSQL) require.Contains(t, normalized, "deleted_at IS NULL") + require.Contains(t, normalized, "schedulable = TRUE", + "permanently unschedulable accounts must not remain OAuth refresh candidates") require.Contains(t, normalized, "status = 'active'") // setup-token 的 access_token 同为 8h 短期令牌,必须与 oauth 一起纳入后台刷新候选 require.Contains(t, normalized, "type IN ('oauth', 'setup-token')") diff --git a/backend/internal/service/token_refresh_service_candidates_test.go b/backend/internal/service/token_refresh_service_candidates_test.go index ac4e3cf4f..285caa3b9 100644 --- a/backend/internal/service/token_refresh_service_candidates_test.go +++ b/backend/internal/service/token_refresh_service_candidates_test.go @@ -51,6 +51,7 @@ func (r *tokenRefreshCandidateRepo) ListOAuthRefreshCandidatePage(_ context.Cont } } if options.ActiveOnly && account.Status != StatusActive || + !account.Schedulable || account.Type != AccountTypeOAuth || !platformAllowed || options.RequireRefreshToken && strings.TrimSpace(refreshToken) == "" || @@ -122,6 +123,7 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, + Schedulable: true, Credentials: map[string]any{"refresh_token": "refresh-token"}, }, { @@ -129,6 +131,7 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, + Schedulable: true, Credentials: map[string]any{}, }, { @@ -143,6 +146,7 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing Platform: PlatformAntigravity, Type: AccountTypeOAuth, Status: StatusActive, + Schedulable: true, Credentials: map[string]any{"refresh_token": "refresh-token"}, TempUnschedulableUntil: &future, TempUnschedulableReason: "token refresh retry exhausted: network timeout", @@ -152,6 +156,7 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing Platform: "other", Type: AccountTypeOAuth, Status: StatusActive, + Schedulable: true, Credentials: map[string]any{"refresh_token": "refresh-token"}, }, { @@ -159,11 +164,20 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing Platform: PlatformAntigravity, Type: AccountTypeOAuth, Status: StatusActive, + Schedulable: true, Credentials: map[string]any{"refresh_token": "refresh-token"}, Extra: map[string]any{"privacy_mode": AntigravityPrivacySet}, TempUnschedulableUntil: &future, TempUnschedulableReason: "OAuth 401: unauthorized", }, + { + ID: 7, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: false, + Credentials: map[string]any{"refresh_token": "permanently-rejected-token"}, + }, }, } svc := &TokenRefreshService{ From 711056f5b78e15faca5666b3ed52b4c6322b46f7 Mon Sep 17 00:00:00 2001 From: Zhixuan Jiang Date: Wed, 29 Jul 2026 08:58:33 -0400 Subject: [PATCH 012/257] fix: clarify passkey deployment guidance --- frontend/src/i18n/locales/en/admin/settings.ts | 1 + frontend/src/i18n/locales/zh/admin/settings.ts | 1 + frontend/src/views/admin/SettingsView.vue | 3 +++ .../src/views/admin/__tests__/SettingsView.spec.ts | 10 +++++++++- 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 5e54b2221..305c33841 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -144,6 +144,7 @@ export default { passkeyRPID: 'RP ID', passkeyOrigins: 'Allowed HTTPS origins', passkeyValueNotConfigured: 'Not configured', + passkeyDeploymentHint: 'Ask the server operator to set webauthn.enabled to true, configure webauthn.rp_id (domain only) and webauthn.rp_origins (full HTTPS origins), then restart the service.', stepUp: 'Step-up 2FA for Sensitive Operations', stepUpHint: 'When enabled, sensitive operations (account/proxy export, backup creation and download, S3 config changes, promoting admins) require a recent TOTP verification (valid for 15 minutes). Your own account must have 2FA enabled before turning this on; turning it off also requires step-up verification.', stepUpEnableRequiresTotp: 'Enable 2FA (TOTP) for your own account in Profile before turning on step-up verification.', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 4989305c4..ae625b876 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -144,6 +144,7 @@ export default { passkeyRPID: 'RP ID', passkeyOrigins: '允许的 HTTPS 来源', passkeyValueNotConfigured: '未配置', + passkeyDeploymentHint: '请由服务器运维在部署配置中将 webauthn.enabled 设为 true,填写 webauthn.rp_id(仅域名)与 webauthn.rp_origins(完整 HTTPS 来源),然后重启服务。', stepUp: '敏感操作二次验证 (step-up 2FA)', stepUpHint: '开启后,账号/代理导出、备份创建与下载、S3 配置修改、提升管理员等敏感操作需要先完成 TOTP 二次验证(15 分钟内有效)。开启前需本人已启用 2FA;关闭该开关本身也需要二次验证。', stepUpEnableRequiresTotp: '开启敏感操作二次验证前,请先在个人资料中为当前账号启用 2FA (TOTP)。', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index e3047e684..3a8baf4bd 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1631,6 +1631,9 @@ ) }}

+

+ {{ t("admin.settings.security.passkeyDeploymentHint") }} +

diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index 322231468..36d505b3d 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -225,6 +225,8 @@ vi.mock("vue-i18n", async () => { "admin.settings.upstreamBillingProbe.intervalHint": "范围 5–1440 分钟。", "admin.settings.upstreamBillingProbe.saved": "上游倍率自动探测设置已保存", "admin.settings.upstreamBillingProbe.saveFailed": "保存上游倍率自动探测设置失败", + "admin.settings.security.passkeyDeploymentHint": + "请由服务器运维在部署配置中将 webauthn.enabled 设为 true,填写 webauthn.rp_id(仅域名)与 webauthn.rp_origins(完整 HTTPS 来源),然后重启服务。", "admin.settings.site.uploadImage": "上传图片", "admin.settings.site.remove": "移除", "admin.settings.platformQuota.platform": "平台", @@ -737,6 +739,7 @@ describe("admin SettingsView payment visible method controls", () => { expect(toggle.attributes("disabled")).toBeUndefined(); expect(settings.text()).toContain("sub3.nebula-spaces.com"); expect(settings.text()).toContain("https://sub3.nebula-spaces.com"); + expect(settings.text()).not.toContain("webauthn.enabled"); await toggle.setValue(false); await wrapper.find("form").trigger("submit.prevent"); @@ -762,9 +765,14 @@ describe("admin SettingsView payment visible method controls", () => { const settings = wrapper.get('[data-testid="passkey-settings"]'); expect(settings.get('[data-testid="passkey-toggle"]').attributes("disabled")).toBeDefined(); - expect(settings.get('[data-testid="passkey-config-status"]').text()).toContain( + const status = settings.get('[data-testid="passkey-config-status"]'); + expect(status.text()).toContain( "admin.settings.security.passkeyNotConfigured", ); + expect(status.text()).toContain("webauthn.enabled"); + expect(status.text()).toContain("webauthn.rp_id"); + expect(status.text()).toContain("webauthn.rp_origins"); + expect(status.text()).toContain("然后重启服务"); }); it("loads, edits, validates, and saves forwarded client-IP headers", async () => { From 0ee9ea5765b45353d4f423284e44991ffe98eddc Mon Sep 17 00:00:00 2001 From: BayinForge Date: Wed, 29 Jul 2026 20:53:49 +0800 Subject: [PATCH 013/257] fix(payment): keep subscription plan titles readable --- .../payment/SubscriptionPlanCard.vue | 19 +++--- .../__tests__/SubscriptionPlanCard.spec.ts | 62 +++++++++++++++++++ .../views/user/__tests__/PaymentView.spec.ts | 56 +++++++++++++++++ 3 files changed, 130 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/payment/SubscriptionPlanCard.vue b/frontend/src/components/payment/SubscriptionPlanCard.vue index 5b34056bf..0d60f6779 100644 --- a/frontend/src/components/payment/SubscriptionPlanCard.vue +++ b/frontend/src/components/payment/SubscriptionPlanCard.vue @@ -14,12 +14,12 @@
-
-

{{ plan.name }}

- - {{ pLabel }} - -
+

+ {{ plan.name }} +

{{ plan.description }}

@@ -30,7 +30,12 @@ {{ plan.price }} {{ plan.currency }}
- / {{ validitySuffix }} +
+ + {{ pLabel }} + + / {{ validitySuffix }} +
{{ planCurrencySymbol }}{{ plan.original_price }} {{ discountText }} diff --git a/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts b/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts index dc8f99378..853af980b 100644 --- a/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts +++ b/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts @@ -86,4 +86,66 @@ describe("SubscriptionPlanCard", () => { expect(mountPlanCard("openai", { currency: "USD" }).text()).toContain("$10USD"); expect(mountPlanCard("openai", { currency: "" }).text()).toContain("$10"); }); + + it.each([ + ["long Chinese", "企业全球加速专业订阅套餐(含高级模型与优先支持)"], + ["long English", "Enterprise Global Acceleration Subscription with Priority Support"], + ["unbroken token", "EnterpriseGlobalAccelerationSubscriptionWithPrioritySupport1234567890"], + ])("keeps the full %s plan title accessible in a bounded two-line area", (_label, name) => { + const wrapper = mountPlanCard("openai", { name }); + const title = wrapper.get("h3"); + + expect(title.text()).toBe(name); + expect(title.attributes("title")).toBe(name); + expect(title.classes()).toEqual(expect.arrayContaining([ + "min-w-0", + "h-12", + "break-words", + "line-clamp-2", + "[overflow-wrap:anywhere]", + ])); + expect(title.classes()).not.toContain("truncate"); + }); + + it("keeps title, badge, price, description, and purchase action in separate bounded regions", () => { + const wrapper = mountPlanCard("openai", { + name: "Enterprise Global Acceleration Subscription with Priority Support", + price: 123.45, + currency: "USD", + description: "Includes advanced models and priority support.", + }); + const title = wrapper.get("h3"); + const badge = wrapper.findAll("span").find((node) => node.text() === "OpenAI"); + const price = wrapper.findAll("span").find((node) => node.text() === "123.45"); + + expect(title.element.parentElement?.classList).toContain("min-w-0"); + expect(title.element.parentElement?.classList).toContain("flex-1"); + expect(badge?.classes()).toContain("shrink-0"); + expect([...(badge?.element.parentElement?.classList ?? [])]).toEqual(expect.arrayContaining([ + "flex", + "items-center", + "justify-end", + ])); + expect(badge?.element.parentElement?.textContent).toContain("/ 30payment.days"); + expect(badge?.element.parentElement?.parentElement?.classList).toContain("shrink-0"); + expect(price?.element.parentElement?.parentElement?.classList).toContain("shrink-0"); + expect(wrapper.get("p").text()).toBe("Includes advanced models and priority support."); + expect(wrapper.get("button").text()).toBe("payment.subscribeNow"); + }); + + it("keeps short plan titles compact and aligned", () => { + const wrapper = mountPlanCard("openai", { name: "Pro", description: "" }); + const title = wrapper.get("h3"); + const badge = wrapper.findAll("span").find((node) => node.text() === "OpenAI"); + + expect(title.text()).toBe("Pro"); + expect(title.attributes("title")).toBe("Pro"); + expect(title.classes()).toEqual(expect.arrayContaining(["text-base", "font-bold", "h-12"])); + expect([...(badge?.element.parentElement?.classList ?? [])]).toEqual(expect.arrayContaining([ + "flex", + "items-center", + "justify-end", + ])); + expect(badge?.element.parentElement?.textContent).toContain("/ 30payment.days"); + }); }); diff --git a/frontend/src/views/user/__tests__/PaymentView.spec.ts b/frontend/src/views/user/__tests__/PaymentView.spec.ts index aa7c40134..a3d688d5a 100644 --- a/frontend/src/views/user/__tests__/PaymentView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentView.spec.ts @@ -3,6 +3,7 @@ import { flushPromises, shallowMount } from '@vue/test-utils' import PaymentView from '../PaymentView.vue' import { PAYMENT_RECOVERY_STORAGE_KEY } from '@/components/payment/paymentFlow' import { formatPaymentAmount } from '@/components/payment/currency' +import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue' import type { CheckoutInfoResponse, MethodLimit, SubscriptionPlan } from '@/types/payment' const routeState = vi.hoisted(() => ({ @@ -236,6 +237,61 @@ async function mountSubscriptionConfirm(options: Parameters ({ + ...basePlan, + id: index + 1, + name: `Plan ${index + 1}`, + })) + getCheckoutInfo.mockReset().mockResolvedValue(checkoutInfoFixture({ plans })) + bridgeInvoke.mockReset() + window.localStorage.clear() + ;(window as Window & { WeixinJSBridge?: { invoke: typeof bridgeInvoke } }).WeixinJSBridge = undefined + + const wrapper = shallowMount(PaymentView, { + global: { + stubs: { + AppLayout: { + template: '
', + }, + Teleport: true, + Transition: false, + }, + }, + }) + await flushPromises() + await flushPromises() + return wrapper +} + +describe('PaymentView subscription plan grid', () => { + it.each([3, 4, 6])('keeps %i plans on the existing mobile/tablet/desktop grid', async (planCount) => { + const wrapper = await mountSubscriptionPlanList(planCount) + const cards = wrapper.findAllComponents(SubscriptionPlanCard) + + expect(cards).toHaveLength(planCount) + expect([...(cards[0].element.parentElement?.classList ?? [])]).toEqual(expect.arrayContaining([ + 'grid', + 'grid-cols-1', + 'sm:grid-cols-2', + 'lg:grid-cols-3', + ])) + }) +}) + describe('PaymentView subscription confirmation amounts', () => { it('shows converted CNY pay amount using the subscription rate, not the balance multiplier', async () => { const wrapper = await mountSubscriptionConfirm({ From 352b21f4e2a01141ba3dc5bcea090ec1f7dc9b7c Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 29 Jul 2026 23:25:02 +0800 Subject: [PATCH 014/257] fix(claude): relax marker to tolerate category element in classifier output Real claude-cli/2.1.220 samples insert ... between and , breaking the previous literal marker. Also fix an unchecked type assertion in the test suite. --- backend/internal/service/claude_code_validator.go | 2 +- .../service/claude_code_validator_test.go | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/claude_code_validator.go b/backend/internal/service/claude_code_validator.go index d6acd0865..52f351f1b 100644 --- a/backend/internal/service/claude_code_validator.go +++ b/backend/internal/service/claude_code_validator.go @@ -233,7 +233,7 @@ func isClaudeCodeSecurityMonitorPrompt(systemEntries []any) bool { "## SOFT BLOCK", "## Classification Process", "## Output Format", - "yes", + "yes", "no", } for _, marker := range markers { diff --git a/backend/internal/service/claude_code_validator_test.go b/backend/internal/service/claude_code_validator_test.go index 18c0147b9..8fd0f2126 100644 --- a/backend/internal/service/claude_code_validator_test.go +++ b/backend/internal/service/claude_code_validator_test.go @@ -168,6 +168,17 @@ func TestClaudeCodeValidator_SecurityMonitorWithoutBillingBlock(t *testing.T) { body: validBody(string(monitorPrompt)), wantAccept: true, }, + { + name: "classifier output with category element", + headers: validHeaders, + body: validBody(strings.Replace( + string(monitorPrompt), + "yes", + "yesExact BLOCK Rule Name", + 1, + )), + wantAccept: true, + }, { name: "non-Claude user agent", headers: map[string]string{ @@ -246,7 +257,9 @@ func TestClaudeCodeValidator_SecurityMonitorWithoutBillingBlock(t *testing.T) { headers: validHeaders, body: func() map[string]any { body := validBody(string(monitorPrompt)) - body["system"] = append(body["system"].([]any), map[string]any{ + system, ok := body["system"].([]any) + require.True(t, ok) + body["system"] = append(system, map[string]any{ "type": "text", "text": "Additional unrelated system content.", }) From 7b6111f2f704022833fc4931c0fe99a296702181 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 30 Jul 2026 02:37:14 +0800 Subject: [PATCH 015/257] fix(subscription): align quota windows with subscription term --- .../repository/user_subscription_repo.go | 10 +- ...user_subscription_repo_integration_test.go | 19 ++ .../subscription_assign_idempotency_test.go | 6 +- .../subscription_monthly_window_test.go | 260 ++++++++++++++++++ .../service/subscription_reset_quota_test.go | 31 +++ .../internal/service/subscription_service.go | 43 +-- backend/internal/service/user_subscription.go | 53 +++- .../user_subscription_daily_quota_test.go | 7 +- 8 files changed, 401 insertions(+), 28 deletions(-) create mode 100644 backend/internal/service/subscription_monthly_window_test.go diff --git a/backend/internal/repository/user_subscription_repo.go b/backend/internal/repository/user_subscription_repo.go index 37f06a038..671f847e8 100644 --- a/backend/internal/repository/user_subscription_repo.go +++ b/backend/internal/repository/user_subscription_repo.go @@ -358,12 +358,18 @@ func (r *userSubscriptionRepository) UpdateNotes(ctx context.Context, subscripti func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int64, start time.Time) error { client := clientFromContext(ctx, r.client) - _, err := client.UserSubscription.UpdateOneID(id). + n, err := client.UserSubscription.Update(). + Where( + usersubscription.IDEQ(id), + usersubscription.DailyWindowStartIsNil(), + usersubscription.WeeklyWindowStartIsNil(), + usersubscription.MonthlyWindowStartIsNil(), + ). SetDailyWindowStart(start). SetWeeklyWindowStart(start). SetMonthlyWindowStart(start). Save(ctx) - return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil) + return r.translateConditionalWindowReset(ctx, client, id, n, err) } func (r *userSubscriptionRepository) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error { diff --git a/backend/internal/repository/user_subscription_repo_integration_test.go b/backend/internal/repository/user_subscription_repo_integration_test.go index 96eead494..1d1964a20 100644 --- a/backend/internal/repository/user_subscription_repo_integration_test.go +++ b/backend/internal/repository/user_subscription_repo_integration_test.go @@ -463,6 +463,25 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows() { s.Require().WithinDuration(activateAt, *got.DailyWindowStart, time.Microsecond) } +func (s *UserSubscriptionRepoSuite) TestActivateWindows_StaleActivationPreservesExistingWindows() { + user := s.mustCreateUser("activate-cas@test.com", service.RoleUser) + group := s.mustCreateGroup("g-activate-cas") + sub := s.mustCreateSubscription(user.ID, group.ID, nil) + activatedAt := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + manualResetAt := activatedAt.Add(2 * time.Hour) + + s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt)) + s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, true, true, manualResetAt)) + // Simulate a concurrent request carrying the original unactivated snapshot. + s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt.Add(time.Hour))) + + got, err := s.repo.GetByID(s.ctx, sub.ID) + s.Require().NoError(err) + s.Require().WithinDuration(manualResetAt, *got.DailyWindowStart, time.Microsecond) + s.Require().WithinDuration(manualResetAt, *got.WeeklyWindowStart, time.Microsecond) + s.Require().WithinDuration(manualResetAt, *got.MonthlyWindowStart, time.Microsecond) +} + func (s *UserSubscriptionRepoSuite) TestResetDailyUsage() { user := s.mustCreateUser("resetd@test.com", service.RoleUser) group := s.mustCreateGroup("g-resetd") diff --git a/backend/internal/service/subscription_assign_idempotency_test.go b/backend/internal/service/subscription_assign_idempotency_test.go index 151b5c792..581918210 100644 --- a/backend/internal/service/subscription_assign_idempotency_test.go +++ b/backend/internal/service/subscription_assign_idempotency_test.go @@ -417,9 +417,9 @@ func TestAssignSubscriptionRenewsExpiredSemanticMatch(t *testing.T) { require.False(t, sub.StartsAt.Before(before)) require.False(t, sub.StartsAt.After(after)) require.Equal(t, sub.StartsAt.AddDate(0, 0, 30), sub.ExpiresAt) - require.Equal(t, startOfDay(sub.StartsAt), *sub.DailyWindowStart) - require.Equal(t, startOfDay(sub.StartsAt), *sub.WeeklyWindowStart) - require.Equal(t, startOfDay(sub.StartsAt), *sub.MonthlyWindowStart) + require.Equal(t, sub.StartsAt, *sub.DailyWindowStart) + require.Equal(t, sub.StartsAt, *sub.WeeklyWindowStart) + require.Equal(t, sub.StartsAt, *sub.MonthlyWindowStart) require.Zero(t, sub.DailyUsageUSD) require.Zero(t, sub.WeeklyUsageUSD) require.Zero(t, sub.MonthlyUsageUSD) diff --git a/backend/internal/service/subscription_monthly_window_test.go b/backend/internal/service/subscription_monthly_window_test.go new file mode 100644 index 000000000..6cd4410c7 --- /dev/null +++ b/backend/internal/service/subscription_monthly_window_test.go @@ -0,0 +1,260 @@ +//go:build unit + +package service + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type activateWindowUserSubRepo struct { + userSubRepoNoop + windowStart time.Time +} + +type monthlyResetUserSubRepo struct { + userSubRepoNoop + resetCalled bool + resetAt time.Time +} + +func (r *monthlyResetUserSubRepo) ResetMonthlyUsage(_ context.Context, _ int64, _ *time.Time, resetAt time.Time) error { + r.resetCalled = true + r.resetAt = resetAt + return nil +} + +func (r *activateWindowUserSubRepo) ActivateWindows(_ context.Context, _ int64, start time.Time) error { + r.windowStart = start + return nil +} + +func TestDelayedFirstUseAnchorsMonthlyWindowAtActivation(t *testing.T) { + repo := &activateWindowUserSubRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + startsAt := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC) + activatedAt := time.Date(2026, 7, 10, 23, 30, 0, 0, time.UTC) + svc.now = func() time.Time { return activatedAt } + sub := &UserSubscription{ + ID: 1, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(45 * 24 * time.Hour), + } + + require.NoError(t, svc.CheckAndActivateWindow(context.Background(), sub)) + + require.Equal(t, activatedAt, repo.windowStart) + monthlyWindowStart := repo.windowStart + resetAt, ok := sub.automaticWindowStartAt(&monthlyWindowStart, 30*24*time.Hour, activatedAt.Add(30*24*time.Hour)) + require.True(t, ok) + require.Equal(t, activatedAt.Add(30*24*time.Hour), resetAt) + require.NotEqual(t, startsAt.Add(30*24*time.Hour), resetAt) +} + +func TestThirtyDaySubscriptionDoesNotResetMonthlyQuotaBeforeExpiry(t *testing.T) { + startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC) + expiresAt := startsAt.Add(30 * 24 * time.Hour) + renewed := renewedSubscriptionTerm(&UserSubscription{}, "", startsAt, expiresAt) + + require.Equal(t, startsAt, *renewed.MonthlyWindowStart) + require.False(t, renewed.NeedsMonthlyResetAt(expiresAt.Add(-time.Second))) + require.True(t, renewed.NeedsMonthlyResetAt(expiresAt)) + require.False(t, renewed.canAutomaticallyResetMonthlyAt(expiresAt)) + require.Equal(t, expiresAt, *renewed.MonthlyResetTime()) +} + +func TestCheckAndResetWindowsDoesNotResetExactThirtyDayLegacyMonthlyWindow(t *testing.T) { + windowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC) + now := startsAt.Add(30 * 24 * time.Hour) + repo := &monthlyResetUserSubRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } + sub := &UserSubscription{ + ID: 1, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(30 * 24 * time.Hour), + MonthlyWindowStart: &windowStart, + MonthlyUsageUSD: 12, + } + + require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub)) + require.False(t, repo.resetCalled) + require.Equal(t, 12.0, sub.MonthlyUsageUSD) + require.Equal(t, windowStart, *sub.MonthlyWindowStart) +} + +func TestCheckAndResetWindowsResetsPartialFinalMonthlySubscriptions(t *testing.T) { + for _, durationDays := range []int{31, 45} { + t.Run(strconv.Itoa(durationDays)+"_days", func(t *testing.T) { + legacyWindowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC) + now := startsAt.Add(30 * 24 * time.Hour) + repo := &monthlyResetUserSubRepo{} + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } + sub := &UserSubscription{ + ID: 2, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(time.Duration(durationDays) * 24 * time.Hour), + MonthlyWindowStart: &legacyWindowStart, + MonthlyUsageUSD: 12, + } + + require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub)) + require.True(t, repo.resetCalled) + require.Equal(t, startsAt.Add(30*24*time.Hour), repo.resetAt) + require.Zero(t, sub.MonthlyUsageUSD) + }) + } +} + +func TestNormalizeExpiredWindowsKeepsLegacyMonthlyUsageBeforeExpiry(t *testing.T) { + windowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + startsAt := windowStart.Add(23*time.Hour + 30*time.Minute) + now := windowStart.Add(30 * 24 * time.Hour) + subs := []UserSubscription{{ + StartsAt: startsAt, + ExpiresAt: startsAt.Add(30 * 24 * time.Hour), + MonthlyWindowStart: &windowStart, + MonthlyUsageUSD: 12, + }} + + normalizeExpiredWindowsAt(subs, now) + + require.Equal(t, 12.0, subs[0].MonthlyUsageUSD) + require.Equal(t, windowStart, *subs[0].MonthlyWindowStart) +} + +func TestNormalizeExpiredWindowsResetsMonthlyUsageWithPartialFinalPeriod(t *testing.T) { + startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC) + windowStart := startsAt.Add(-23*time.Hour - 30*time.Minute) + now := startsAt.Add(30 * 24 * time.Hour) + subs := []UserSubscription{{ + StartsAt: startsAt, + ExpiresAt: startsAt.Add(31 * 24 * time.Hour), + MonthlyWindowStart: &windowStart, + MonthlyUsageUSD: 12, + }} + + normalizeExpiredWindowsAt(subs, now) + + require.Zero(t, subs[0].MonthlyUsageUSD) + require.Nil(t, subs[0].MonthlyWindowStart) +} + +func TestValidateAndCheckLimitsKeepsLegacyMonthlyUsageBeforeExpiry(t *testing.T) { + windowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + startsAt := windowStart.Add(23*time.Hour + 30*time.Minute) + now := windowStart.Add(30 * 24 * time.Hour) + limit := 10.0 + sub := &UserSubscription{ + Status: SubscriptionStatusActive, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(30 * 24 * time.Hour), + MonthlyWindowStart: &windowStart, + MonthlyUsageUSD: 12, + } + svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil) + svc.now = func() time.Time { return now } + + needsMaintenance, err := svc.ValidateAndCheckLimits(sub, &Group{MonthlyLimitUSD: &limit}) + + require.ErrorIs(t, err, ErrMonthlyLimitExceeded) + require.False(t, needsMaintenance) + require.Equal(t, 12.0, sub.MonthlyUsageUSD) +} + +func TestValidateAndCheckLimitsResetsMonthlyUsageWithPartialFinalPeriod(t *testing.T) { + startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC) + windowStart := startsAt.Add(-23*time.Hour - 30*time.Minute) + now := startsAt.Add(30 * 24 * time.Hour) + limit := 10.0 + sub := &UserSubscription{ + Status: SubscriptionStatusActive, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(45 * 24 * time.Hour), + MonthlyWindowStart: &windowStart, + MonthlyUsageUSD: 12, + } + svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil) + svc.now = func() time.Time { return now } + + needsMaintenance, err := svc.ValidateAndCheckLimits(sub, &Group{MonthlyLimitUSD: &limit}) + + require.NoError(t, err) + require.True(t, needsMaintenance) + require.Zero(t, sub.MonthlyUsageUSD) +} + +func TestValidateAndCheckLimitsRejectsExactExpiry(t *testing.T) { + now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC) + sub := &UserSubscription{Status: SubscriptionStatusActive, ExpiresAt: now} + svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil) + svc.now = func() time.Time { return now } + + needsMaintenance, err := svc.ValidateAndCheckLimits(sub, &Group{}) + + require.ErrorIs(t, err, ErrSubscriptionExpired) + require.False(t, needsMaintenance) +} + +func TestAutomaticWindowsAllowPartialFinalDailyAndWeeklyPeriods(t *testing.T) { + startsAt := time.Date(2026, 7, 1, 15, 45, 0, 0, time.UTC) + legacyWindowStart := startOfDay(startsAt) + tests := []struct { + name string + period time.Duration + expiresAt time.Time + }{ + {name: "daily", period: 24 * time.Hour, expiresAt: startsAt.Add(36 * time.Hour)}, + {name: "weekly", period: 7 * 24 * time.Hour, expiresAt: startsAt.Add(10 * 24 * time.Hour)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: tt.expiresAt} + resetAt, ok := sub.automaticWindowStartAt(&legacyWindowStart, tt.period, startsAt.Add(tt.period)) + + require.True(t, ok) + require.Equal(t, startsAt.Add(tt.period), resetAt) + }) + } +} + +func TestAutomaticWindowPreservesPersistedManualAnchorAndAdvancesWholePeriods(t *testing.T) { + startsAt := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + manualAnchor := startsAt.Add(5 * 24 * time.Hour) + sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: startsAt.Add(100 * 24 * time.Hour)} + + resetAt, ok := sub.automaticWindowStartAt(&manualAnchor, 30*24*time.Hour, manualAnchor.Add(65*24*time.Hour)) + + require.True(t, ok) + require.Equal(t, manualAnchor.Add(60*24*time.Hour), resetAt) +} + +func TestAutomaticWindowPreservesPeriodAlignedLaterMidnightManualAnchor(t *testing.T) { + startsAt := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + manualAnchor := startOfDay(startsAt).Add(30 * 24 * time.Hour) + sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: startsAt.Add(100 * 24 * time.Hour)} + + resetAt, ok := sub.automaticWindowStartAt(&manualAnchor, 30*24*time.Hour, startsAt.Add(60*24*time.Hour)) + + require.True(t, ok) + require.Equal(t, manualAnchor.Add(30*24*time.Hour), resetAt) +} + +func TestAutomaticWindowPreservesExactMidnightManualAnchor(t *testing.T) { + startsAt := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + manualAnchor := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC) + sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: startsAt.Add(100 * 24 * time.Hour)} + + resetAt, ok := sub.automaticWindowStartAt(&manualAnchor, 30*24*time.Hour, manualAnchor.Add(30*24*time.Hour)) + + require.True(t, ok) + require.Equal(t, manualAnchor.Add(30*24*time.Hour), resetAt) +} diff --git a/backend/internal/service/subscription_reset_quota_test.go b/backend/internal/service/subscription_reset_quota_test.go index e4ed45ec4..df16db1d5 100644 --- a/backend/internal/service/subscription_reset_quota_test.go +++ b/backend/internal/service/subscription_reset_quota_test.go @@ -24,6 +24,7 @@ type resetQuotaUserSubRepoStub struct { resetDailyErr error resetWeeklyErr error resetMonthlyErr error + windowStart time.Time } func (r *resetQuotaUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserSubscription, error) { @@ -38,6 +39,7 @@ func (r *resetQuotaUserSubRepoStub) ResetUsageWindows(_ context.Context, _ int64 r.resetDailyCalled = resetDaily r.resetWeeklyCalled = resetWeekly r.resetMonthlyCalled = resetMonthly + r.windowStart = windowStart if resetDaily && r.resetDailyErr != nil { return r.resetDailyErr } @@ -93,6 +95,8 @@ func TestAdminResetQuota_ResetBoth(t *testing.T) { sub: &UserSubscription{ID: 1, UserID: 10, GroupID: 20}, } svc := newResetQuotaSvc(stub) + resetAt := time.Date(2026, 7, 1, 10, 37, 42, 123, time.UTC) + svc.now = func() time.Time { return resetAt } result, err := svc.AdminResetQuota(context.Background(), 1, true, true, false) @@ -101,6 +105,9 @@ func TestAdminResetQuota_ResetBoth(t *testing.T) { require.True(t, stub.resetDailyCalled, "应调用 ResetDailyUsage") require.True(t, stub.resetWeeklyCalled, "应调用 ResetWeeklyUsage") require.False(t, stub.resetMonthlyCalled, "不应调用 ResetMonthlyUsage") + require.Equal(t, resetAt, stub.windowStart) + require.Equal(t, resetAt, *result.DailyWindowStart) + require.Equal(t, resetAt, *result.WeeklyWindowStart) } func TestAdminResetQuota_ResetDailyOnly(t *testing.T) { @@ -203,6 +210,30 @@ func TestAdminResetQuota_ResetMonthlyOnly(t *testing.T) { require.True(t, stub.resetMonthlyCalled, "应调用 ResetMonthlyUsage") } +func TestAdminResetQuota_BeforeStartsAtSameDayPreservesAutomaticBoundary(t *testing.T) { + startsAt := time.Date(2026, 7, 1, 15, 0, 0, 0, time.UTC) + resetAt := time.Date(2026, 7, 1, 10, 37, 42, 123, time.UTC) + stub := &resetQuotaUserSubRepoStub{ + sub: &UserSubscription{ + ID: 10, + UserID: 10, + GroupID: 20, + StartsAt: startsAt, + ExpiresAt: startsAt.Add(45 * 24 * time.Hour), + }, + } + svc := newResetQuotaSvc(stub) + svc.now = func() time.Time { return resetAt } + + result, err := svc.AdminResetQuota(context.Background(), 10, false, false, true) + + require.NoError(t, err) + require.Equal(t, resetAt, *result.MonthlyWindowStart) + boundary, ok := result.automaticWindowStartAt(result.MonthlyWindowStart, 30*24*time.Hour, resetAt.Add(30*24*time.Hour)) + require.True(t, ok) + require.Equal(t, resetAt.Add(30*24*time.Hour), boundary) +} + func TestAdminResetQuota_ResetMonthlyUsageError(t *testing.T) { dbErr := errors.New("db error") stub := &resetQuotaUserSubRepoStub{ diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go index fbe99bf90..a55e89825 100644 --- a/backend/internal/service/subscription_service.go +++ b/backend/internal/service/subscription_service.go @@ -55,6 +55,7 @@ type SubscriptionService struct { subCacheJitter int // 抖动百分比 maintenanceQueue *SubscriptionMaintenanceQueue + now func() time.Time } // NewSubscriptionService 创建订阅服务 @@ -64,6 +65,7 @@ func NewSubscriptionService(groupRepo GroupRepository, userSubRepo UserSubscript userSubRepo: userSubRepo, billingCacheService: billingCacheService, entClient: entClient, + now: time.Now, } svc.initSubCache(cfg) svc.initMaintenanceQueue(cfg) @@ -368,7 +370,7 @@ func (s *SubscriptionService) withSubscriptionUpdateTx(ctx context.Context, fn f func renewedSubscriptionTerm(existingSub *UserSubscription, notes string, startsAt, expiresAt time.Time) *UserSubscription { renewed := *existingSub - windowStart := startOfDay(startsAt) + windowStart := startsAt renewed.StartsAt = startsAt renewed.ExpiresAt = expiresAt renewed.Status = SubscriptionStatusActive @@ -803,20 +805,24 @@ func (s *SubscriptionService) List(ctx context.Context, page, pageSize int, user // normalizeExpiredWindows 将已过期窗口的数据清零(仅影响返回数据,不影响数据库) // 这确保前端显示正确的当前窗口状态,而不是过期窗口的历史数据 func normalizeExpiredWindows(subs []UserSubscription) { + normalizeExpiredWindowsAt(subs, time.Now()) +} + +func normalizeExpiredWindowsAt(subs []UserSubscription, now time.Time) { for i := range subs { sub := &subs[i] // 日窗口过期:清零展示数据 - if sub.NeedsDailyReset() { + if sub.canAutomaticallyResetDailyAt(now) { sub.DailyWindowStart = nil sub.DailyUsageUSD = 0 } // 周窗口过期:清零展示数据 - if sub.NeedsWeeklyReset() { + if sub.canAutomaticallyResetWeeklyAt(now) { sub.WeeklyWindowStart = nil sub.WeeklyUsageUSD = 0 } // 月窗口过期:清零展示数据 - if sub.NeedsMonthlyReset() { + if sub.canAutomaticallyResetMonthlyAt(now) { sub.MonthlyWindowStart = nil sub.MonthlyUsageUSD = 0 } @@ -842,17 +848,18 @@ func startOfDay(t time.Time) time.Time { // CheckAndActivateWindow 检查并激活窗口(首次使用时) func (s *SubscriptionService) CheckAndActivateWindow(ctx context.Context, sub *UserSubscription) error { + return s.checkAndActivateWindowAt(ctx, sub, s.now()) +} + +func (s *SubscriptionService) checkAndActivateWindowAt(ctx context.Context, sub *UserSubscription, now time.Time) error { if sub.IsWindowActivated() { return nil } - // 使用当天零点作为窗口起始时间 - windowStart := startOfDay(time.Now()) - return s.userSubRepo.ActivateWindows(ctx, sub.ID, windowStart) + return s.userSubRepo.ActivateWindows(ctx, sub.ID, now) } // AdminResetQuota manually resets the daily, weekly, and/or monthly usage windows. -// Uses startOfDay(now) as the new window start, matching automatic resets. func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionID int64, resetDaily, resetWeekly, resetMonthly bool) (*UserSubscription, error) { if !resetDaily && !resetWeekly && !resetMonthly { return nil, ErrInvalidInput @@ -861,7 +868,7 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI if err != nil { return nil, err } - windowStart := startOfDay(time.Now()) + windowStart := s.now() if err := s.userSubRepo.ResetUsageWindows(ctx, sub.ID, resetDaily, resetWeekly, resetMonthly, windowStart); err != nil { return nil, err } @@ -878,12 +885,11 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI // CheckAndResetWindows 检查并重置过期的窗口 func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *UserSubscription) error { - // 使用当天零点作为新窗口起始时间 - windowStart := startOfDay(time.Now()) + now := s.now() needsInvalidateCache := false // 日窗口重置(24小时) - if sub.NeedsDailyReset() { + if windowStart, ok := sub.automaticWindowStartAt(sub.DailyWindowStart, 24*time.Hour, now); !sub.HasOneTimeDailyQuota() && ok { expectedWindowStart := sub.DailyWindowStart if err := s.userSubRepo.ResetDailyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil { return err @@ -894,7 +900,7 @@ func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *Use } // 周窗口重置(7天) - if sub.NeedsWeeklyReset() { + if windowStart, ok := sub.automaticWindowStartAt(sub.WeeklyWindowStart, 7*24*time.Hour, now); ok { expectedWindowStart := sub.WeeklyWindowStart if err := s.userSubRepo.ResetWeeklyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil { return err @@ -905,7 +911,7 @@ func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *Use } // 月窗口重置(30天) - if sub.NeedsMonthlyReset() { + if windowStart, ok := sub.automaticWindowStartAt(sub.MonthlyWindowStart, 30*24*time.Hour, now); ok { expectedWindowStart := sub.MonthlyWindowStart if err := s.userSubRepo.ResetMonthlyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil { return err @@ -971,6 +977,7 @@ func (s *SubscriptionService) CheckUsageLimits(ctx context.Context, sub *UserSub // 仅做内存检查,不触发 DB 写入。调用方必须在放行请求前同步完成窗口维护。 // 返回 needsMaintenance 表示是否需要执行窗口维护并回读数据库快照。 func (s *SubscriptionService) ValidateAndCheckLimits(sub *UserSubscription, group *Group) (needsMaintenance bool, err error) { + now := s.now() // 1. 验证订阅状态 if sub.Status == SubscriptionStatusExpired { return false, ErrSubscriptionExpired @@ -978,21 +985,21 @@ func (s *SubscriptionService) ValidateAndCheckLimits(sub *UserSubscription, grou if sub.Status == SubscriptionStatusSuspended { return false, ErrSubscriptionSuspended } - if sub.IsExpired() { + if !sub.ExpiresAt.After(now) { return false, ErrSubscriptionExpired } // 2. 内存中修正过期窗口的用量,确保预检查不会误拒绝用户。 // 调用方随后同步推进 DB 窗口,并用回读快照重新校验。 - if sub.NeedsDailyReset() { + if sub.canAutomaticallyResetDailyAt(now) { sub.DailyUsageUSD = 0 needsMaintenance = true } - if sub.NeedsWeeklyReset() { + if sub.canAutomaticallyResetWeeklyAt(now) { sub.WeeklyUsageUSD = 0 needsMaintenance = true } - if sub.NeedsMonthlyReset() { + if sub.canAutomaticallyResetMonthlyAt(now) { sub.MonthlyUsageUSD = 0 needsMaintenance = true } diff --git a/backend/internal/service/user_subscription.go b/backend/internal/service/user_subscription.go index 12e3dd428..3a73554a4 100644 --- a/backend/internal/service/user_subscription.go +++ b/backend/internal/service/user_subscription.go @@ -85,17 +85,66 @@ func (s *UserSubscription) NeedsDailyResetAt(now time.Time) bool { } func (s *UserSubscription) NeedsWeeklyReset() bool { + return s.NeedsWeeklyResetAt(time.Now()) +} + +func (s *UserSubscription) NeedsWeeklyResetAt(now time.Time) bool { if s.WeeklyWindowStart == nil { return false } - return time.Since(*s.WeeklyWindowStart) >= 7*24*time.Hour + return !now.Before(s.WeeklyWindowStart.Add(7 * 24 * time.Hour)) } func (s *UserSubscription) NeedsMonthlyReset() bool { + return s.NeedsMonthlyResetAt(time.Now()) +} + +func (s *UserSubscription) NeedsMonthlyResetAt(now time.Time) bool { if s.MonthlyWindowStart == nil { return false } - return time.Since(*s.MonthlyWindowStart) >= 30*24*time.Hour + return !now.Before(s.MonthlyWindowStart.Add(30 * 24 * time.Hour)) +} + +func (s *UserSubscription) canAutomaticallyResetDailyAt(now time.Time) bool { + _, ok := s.automaticWindowStartAt(s.DailyWindowStart, 24*time.Hour, now) + return !s.HasOneTimeDailyQuota() && ok +} + +func (s *UserSubscription) canAutomaticallyResetWeeklyAt(now time.Time) bool { + _, ok := s.automaticWindowStartAt(s.WeeklyWindowStart, 7*24*time.Hour, now) + return ok +} + +func (s *UserSubscription) canAutomaticallyResetMonthlyAt(now time.Time) bool { + _, ok := s.automaticWindowStartAt(s.MonthlyWindowStart, 30*24*time.Hour, now) + return ok +} + +func (s *UserSubscription) automaticWindowStartAt(previous *time.Time, period time.Duration, now time.Time) (time.Time, bool) { + if previous == nil { + return time.Time{}, false + } + + anchor := *previous + // Older subscriptions initialized their first windows at midnight on their + // start date. Only that initial value is unambiguous; later midnight anchors + // may be manual resets and must remain authoritative. + legacyAnchor := startOfDay(s.StartsAt) + if legacyAnchor.Before(s.StartsAt) && anchor.Equal(legacyAnchor) { + anchor = s.StartsAt + } + next := anchor.Add(period) + if now.Before(next) || !next.Before(s.ExpiresAt) { + return time.Time{}, false + } + + periods := now.Sub(anchor) / period + lastPeriodBeforeExpiry := (s.ExpiresAt.Sub(anchor) - 1) / period + if periods > lastPeriodBeforeExpiry { + periods = lastPeriodBeforeExpiry + } + return anchor.Add(periods * period), true } func (s *UserSubscription) DailyResetTime() *time.Time { diff --git a/backend/internal/service/user_subscription_daily_quota_test.go b/backend/internal/service/user_subscription_daily_quota_test.go index bc0481216..6e62eb353 100644 --- a/backend/internal/service/user_subscription_daily_quota_test.go +++ b/backend/internal/service/user_subscription_daily_quota_test.go @@ -58,7 +58,7 @@ func TestAssignOrExtendSubscription_ExpiredDailyCardStartsNewOneTimeQuota(t *tes require.True(t, renewed.StartsAt.After(oldStart), "重新购买过期订阅时应重置当前周期 StartsAt") require.False(t, renewed.ExpiresAt.After(renewed.StartsAt.AddDate(0, 0, 1))) require.NotNil(t, renewed.DailyWindowStart) - require.Equal(t, startOfDay(renewed.StartsAt), *renewed.DailyWindowStart) + require.Equal(t, renewed.StartsAt, *renewed.DailyWindowStart) require.Equal(t, 0.0, renewed.DailyUsageUSD) require.Equal(t, 0.0, renewed.WeeklyUsageUSD) require.Equal(t, 0.0, renewed.MonthlyUsageUSD) @@ -160,17 +160,18 @@ func TestCheckAndResetWindows_DailyCardDoesNotResetDailyUsage(t *testing.T) { } func TestCheckAndResetWindows_MultiDaySubscriptionStillResetsDailyUsage(t *testing.T) { - now := time.Now() + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC) startsAt := now.Add(-48 * time.Hour) dailyWindowStart := now.Add(-25 * time.Hour) repo := &dailyResetTrackingUserSubRepo{} svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + svc.now = func() time.Time { return now } sub := &UserSubscription{ ID: 1, UserID: 10, GroupID: 20, StartsAt: startsAt, - ExpiresAt: startsAt.AddDate(0, 0, 2), + ExpiresAt: startsAt.AddDate(0, 0, 4), DailyUsageUSD: 10, DailyWindowStart: &dailyWindowStart, } From fb40211305dbe90948f0497c2404838776f1d914 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 30 Jul 2026 12:47:05 +0800 Subject: [PATCH 016/257] fix(frontend): correct subscription expiry labels --- .../src/i18n/locales/en/admin/channels.ts | 4 +- .../src/i18n/locales/zh/admin/channels.ts | 4 +- .../utils/__tests__/subscriptionQuota.spec.ts | 64 +++++++++++++++++++ frontend/src/utils/subscriptionQuota.ts | 49 ++++++++++++++ .../src/views/admin/SubscriptionsView.vue | 33 ++++++++-- frontend/src/views/user/SubscriptionsView.vue | 18 ++++-- 6 files changed, 161 insertions(+), 11 deletions(-) create mode 100644 frontend/src/utils/__tests__/subscriptionQuota.spec.ts diff --git a/frontend/src/i18n/locales/en/admin/channels.ts b/frontend/src/i18n/locales/en/admin/channels.ts index 96c0a6cbd..7907b1ee9 100644 --- a/frontend/src/i18n/locales/en/admin/channels.ts +++ b/frontend/src/i18n/locales/en/admin/channels.ts @@ -623,7 +623,9 @@ export default { quotaEndsInMinutes: 'Quota ends in {minutes}m', quotaEndsInHoursMinutes: 'Quota ends in {hours}h {minutes}m', quotaEndsInDaysHours: 'Quota ends in {days}d {hours}h', - daysRemaining: 'days remaining', + daysRemaining: '{days} days remaining', + hoursMinutesRemaining: '{hours}h {minutes}m remaining', + minutesRemaining: '{minutes}m remaining', remainingDays: 'Remaining days', noExpiration: 'No expiration', status: { diff --git a/frontend/src/i18n/locales/zh/admin/channels.ts b/frontend/src/i18n/locales/zh/admin/channels.ts index cdfb185c8..b95be9352 100644 --- a/frontend/src/i18n/locales/zh/admin/channels.ts +++ b/frontend/src/i18n/locales/zh/admin/channels.ts @@ -623,7 +623,9 @@ export default { quotaEndsInMinutes: '额度将在 {minutes} 分钟后结束', quotaEndsInHoursMinutes: '额度将在 {hours} 小时 {minutes} 分钟后结束', quotaEndsInDaysHours: '额度将在 {days} 天 {hours} 小时后结束', - daysRemaining: '天剩余', + daysRemaining: '剩余 {days} 天', + hoursMinutesRemaining: '剩余 {hours} 小时 {minutes} 分钟', + minutesRemaining: '剩余 {minutes} 分钟', remainingDays: '剩余天数', noExpiration: '无过期时间', status: { diff --git a/frontend/src/utils/__tests__/subscriptionQuota.spec.ts b/frontend/src/utils/__tests__/subscriptionQuota.spec.ts new file mode 100644 index 000000000..9cc8bc707 --- /dev/null +++ b/frontend/src/utils/__tests__/subscriptionQuota.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' + +import { getExpirationDateRelation, getRemainingExpiryDuration } from '../subscriptionQuota' + +describe('subscription expiry timing', () => { + it('uses local calendar dates for today and tomorrow', () => { + const now = new Date(2026, 2, 7, 23, 30) + + expect(getExpirationDateRelation(new Date(2026, 2, 7, 23, 45), now)).toBe('today') + expect(getExpirationDateRelation(new Date(2026, 2, 8, 3, 30), now)).toBe('tomorrow') + }) + + it('treats the exact expiry instant and elapsed expiries as expired', () => { + const now = new Date(2026, 6, 30, 9, 0) + + expect(getExpirationDateRelation(now, now)).toBe('expired') + expect(getRemainingExpiryDuration(now, now)).toBeNull() + expect(getExpirationDateRelation(new Date(2026, 6, 30, 8, 59), now)).toBe('expired') + expect(getRemainingExpiryDuration(new Date(2026, 6, 30, 8, 59), now)).toBeNull() + }) + + it('rejects invalid target and current dates', () => { + const invalid = new Date('invalid') + const valid = new Date(2026, 6, 30, 9, 0) + + expect(getExpirationDateRelation(invalid, valid)).toBeNull() + expect(getExpirationDateRelation(valid, invalid)).toBeNull() + expect(getRemainingExpiryDuration(invalid, valid)).toBeNull() + expect(getRemainingExpiryDuration(valid, invalid)).toBeNull() + }) + + it('returns rounded-up hours and minutes for an expiry under 24 hours away', () => { + const now = new Date(2026, 6, 30, 9, 0) + + expect(getRemainingExpiryDuration(new Date(2026, 6, 31, 8, 30), now)).toEqual({ + unit: 'hoursMinutes', + hours: 23, + minutes: 30 + }) + expect(getRemainingExpiryDuration(new Date(now.getTime() + 1), now)).toEqual({ + unit: 'hoursMinutes', + hours: 0, + minutes: 1 + }) + expect(getRemainingExpiryDuration(new Date(now.getTime() + 23 * 60 * 60 * 1000 + 1), now)).toEqual({ + unit: 'hoursMinutes', + hours: 23, + minutes: 1 + }) + }) + + it('preserves rounded-up day display from 24 hours onward', () => { + const now = new Date(2026, 6, 30, 9, 0) + + expect(getRemainingExpiryDuration(new Date(now.getTime() + 24 * 60 * 60 * 1000), now)).toEqual({ + unit: 'days', + days: 1 + }) + expect(getRemainingExpiryDuration(new Date(now.getTime() + 24 * 60 * 60 * 1000 + 1), now)).toEqual({ + unit: 'days', + days: 2 + }) + }) +}) diff --git a/frontend/src/utils/subscriptionQuota.ts b/frontend/src/utils/subscriptionQuota.ts index 719737beb..d4135aa4b 100644 --- a/frontend/src/utils/subscriptionQuota.ts +++ b/frontend/src/utils/subscriptionQuota.ts @@ -2,6 +2,12 @@ import type { UserSubscription } from '@/types' const ONE_DAY_MS = 24 * 60 * 60 * 1000 +export type ExpirationDateRelation = 'expired' | 'today' | 'tomorrow' | 'later' + +export type RemainingExpiryDuration = + | { unit: 'days'; days: number } + | { unit: 'hoursMinutes'; hours: number; minutes: number } + export interface RemainingDurationParts { days: number hours: number @@ -40,3 +46,46 @@ export function getRemainingDurationParts( return { days, hours, minutes } } + +export function getExpirationDateRelation( + targetAt: Date | string, + now: Date = new Date() +): ExpirationDateRelation | null { + const target = targetAt instanceof Date ? targetAt : new Date(targetAt) + const targetTime = target.getTime() + const nowTime = now.getTime() + + if (!Number.isFinite(targetTime) || !Number.isFinite(nowTime)) return null + if (targetTime <= nowTime) return 'expired' + + const targetDay = Date.UTC(target.getFullYear(), target.getMonth(), target.getDate()) + const currentDay = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) + const calendarDays = Math.round((targetDay - currentDay) / ONE_DAY_MS) + + if (calendarDays === 0) return 'today' + if (calendarDays === 1) return 'tomorrow' + return 'later' +} + +export function getRemainingExpiryDuration( + targetAt: Date | string, + now: Date = new Date() +): RemainingExpiryDuration | null { + const targetTime = targetAt instanceof Date ? targetAt.getTime() : new Date(targetAt).getTime() + const nowTime = now.getTime() + + if (!Number.isFinite(targetTime) || !Number.isFinite(nowTime)) return null + + const diffMs = targetTime - nowTime + if (diffMs <= 0) return null + if (diffMs >= ONE_DAY_MS) { + return { unit: 'days', days: Math.ceil(diffMs / ONE_DAY_MS) } + } + + const totalMinutes = Math.ceil(diffMs / (60 * 1000)) + return { + unit: 'hoursMinutes', + hours: Math.floor(totalMinutes / 60), + minutes: totalMinutes % 60 + } +} diff --git a/frontend/src/views/admin/SubscriptionsView.vue b/frontend/src/views/admin/SubscriptionsView.vue index ff44e52a2..b23fa2fdc 100644 --- a/frontend/src/views/admin/SubscriptionsView.vue +++ b/frontend/src/views/admin/SubscriptionsView.vue @@ -353,9 +353,14 @@ > {{ formatDateTimeToMinute(value) }} -
- {{ getDaysRemaining(value) }} {{ t('admin.subscriptions.daysRemaining') }} -
+
{{ t('admin.subscriptions.noExpiration') @@ -777,7 +782,12 @@ import Select from '@/components/common/Select.vue' import GroupBadge from '@/components/common/GroupBadge.vue' import GroupOptionItem from '@/components/common/GroupOptionItem.vue' import Icon from '@/components/icons/Icon.vue' -import { getRemainingDurationParts, isOneTimeDailyQuota, type RemainingDurationParts } from '@/utils/subscriptionQuota' +import { + getRemainingDurationParts, + getRemainingExpiryDuration, + isOneTimeDailyQuota, + type RemainingDurationParts +} from '@/utils/subscriptionQuota' const { t } = useI18n() const appStore = useAppStore() @@ -1334,6 +1344,21 @@ const getDaysRemaining = (expiresAt: string): number | null => { return Math.ceil(diff / (1000 * 60 * 60 * 24)) } +const formatRemainingExpiry = (expiresAt: string): string | null => { + const duration = getRemainingExpiryDuration(expiresAt) + if (!duration) return null + if (duration.unit === 'days') { + return t('admin.subscriptions.daysRemaining', { days: duration.days }) + } + if (duration.hours) { + return t('admin.subscriptions.hoursMinutesRemaining', { + hours: duration.hours, + minutes: duration.minutes + }) + } + return t('admin.subscriptions.minutesRemaining', { minutes: duration.minutes }) +} + const isExpiringSoon = (expiresAt: string): boolean => { const days = getDaysRemaining(expiresAt) return days !== null && days <= 7 diff --git a/frontend/src/views/user/SubscriptionsView.vue b/frontend/src/views/user/SubscriptionsView.vue index 744529848..7b7a71d3c 100644 --- a/frontend/src/views/user/SubscriptionsView.vue +++ b/frontend/src/views/user/SubscriptionsView.vue @@ -259,7 +259,12 @@ import Icon from '@/components/icons/Icon.vue' import { formatDateTimeToMinute } from '@/utils/format' import { hasPeakRate, formatPeakRateWindow, serverTimezoneLabel } from '@/utils/peak-rate' import { platformBorderClass, platformBadgeClass, platformButtonClass, platformLabel } from '@/utils/platformColors' -import { getRemainingDurationParts, isOneTimeDailyQuota, type RemainingDurationParts } from '@/utils/subscriptionQuota' +import { + getExpirationDateRelation, + getRemainingDurationParts, + isOneTimeDailyQuota, + type RemainingDurationParts +} from '@/utils/subscriptionQuota' function platformAccentDotClass(p: string): string { switch (p) { @@ -317,17 +322,20 @@ function formatExpirationDate(expiresAt: string): string { const expires = new Date(expiresAt) const diff = expires.getTime() - now.getTime() const days = Math.ceil(diff / (1000 * 60 * 60 * 24)) + const relation = getExpirationDateRelation(expires, now) - if (days < 0) { + if (relation === null) return '' + + if (relation === 'expired') { return t('userSubscriptions.status.expired') } const dateStr = formatDateTimeToMinute(expires) - if (days === 0) { + if (relation === 'today') { return `${dateStr} (${t('common.today')})` } - if (days === 1) { + if (relation === 'tomorrow') { return `${dateStr} (${t('common.tomorrow')})` } @@ -340,7 +348,7 @@ function getExpirationClass(expiresAt: string): string { const diff = expires.getTime() - now.getTime() const days = Math.ceil(diff / (1000 * 60 * 60 * 24)) - if (days <= 0) return 'text-red-600 dark:text-red-400 font-medium' + if (diff <= 0) return 'text-red-600 dark:text-red-400 font-medium' if (days <= 3) return 'text-red-600 dark:text-red-400' if (days <= 7) return 'text-orange-600 dark:text-orange-400' return 'text-gray-700 dark:text-gray-300' From 3d99acb0ad64758cdd3c8f4f2cbfcd37dbf99501 Mon Sep 17 00:00:00 2001 From: feeeei Date: Wed, 29 Jul 2026 23:38:39 +0800 Subject: [PATCH 017/257] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=B9=BF=E5=9C=BAUI:=E7=AD=9B=E9=80=89=E6=A0=8F=E6=8D=A2?= =?UTF-8?q?=E8=A1=8C=E5=AF=B9=E9=BD=90=E3=80=81=E6=A8=A1=E5=9E=8B=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E4=B8=8E=E8=A1=A8=E6=A0=BC=E7=95=99=E7=99=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 筛选栏各级标签移出 flex-wrap 容器,chip 单独成组,换行后左边缘与「全部」对齐 - 模型排序增加二级规则:同官方输出价时按名称降序(gpt-5.6 先于 gpt-5.5) - 按图片/按次计费的模型沉到分组末尾,不再与 token 模型按官方价混排 - 表格顶到卡片边缘,左右留白改由首列/末列 padding 提供,hover 与分区底色横贯整行 --- .../components/modelPlaza/PlazaFilterBar.vue | 126 +++++++++--------- .../modelPlaza/PlazaGroupSection.vue | 8 +- .../modelPlaza/PlazaModelPricingTable.vue | 20 ++- .../__tests__/PlazaModelPricingTable.spec.ts | 61 +++++++++ 4 files changed, 145 insertions(+), 70 deletions(-) diff --git a/frontend/src/components/modelPlaza/PlazaFilterBar.vue b/frontend/src/components/modelPlaza/PlazaFilterBar.vue index a849d25de..4371105a2 100644 --- a/frontend/src/components/modelPlaza/PlazaFilterBar.vue +++ b/frontend/src/components/modelPlaza/PlazaFilterBar.vue @@ -1,81 +1,87 @@