From 87dd5f5d72738dc6a844fda58e4045ef26c05238 Mon Sep 17 00:00:00 2001 From: bwlc <1350701980@qq.com> Date: Sat, 6 Jun 2026 16:18:14 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(openai):=20=E5=88=87=E7=BB=84=E5=90=8E?= =?UTF-8?q?=E5=89=A5=E7=A6=BB=E5=A4=B1=E9=85=8D=E7=9A=84=20previous=5Fresp?= =?UTF-8?q?onse=5Fid=EF=BC=8C=E4=BF=AE=E5=A4=8D=E8=B7=A8=E7=BB=84=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E9=89=B4=E6=9D=83=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户从公开组切回订阅组后,客户端沿用旧 Responses 会话 ID,订阅组调度到 不拥有该会话链的账号,上游因会话链鉴权不匹配返回“鉴权失败,请检查 API Key”。 两处修复: - openai_ws_state_store: 本地热缓存 responseToAccount 改为按 {groupID}:{responseID} 命名空间,与 Redis 层 sticky_session:{groupID}:... 一致,避免单实例下跨组命中 其他分组遗留的本地绑定,确保 StickyPreviousHit 信号可信。 - openai_gateway_handler: 转发前若 previous_response_id 未在当前分组命中粘连账号 (StickyPreviousHit=false),主动剥离并改用完整 input 重建上下文;带 function_call_output 的工具续链保持原样(与 WS 重连恢复逻辑一致)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../internal/handler/openai_gateway_handler.go | 11 +++++++++++ backend/internal/service/channel_service.go | 15 +++++++++++++++ backend/internal/service/openai_ws_state_store.go | 15 +++++++++++---- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 951c263dd4..5f811ff25b 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -358,6 +358,17 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { if channelMapping.Mapped { forwardBody = h.gatewayService.ReplaceModelInBody(body, channelMapping.MappedModel) } + // 切组/会话失配防护:previous_response_id 未在当前分组命中粘连账号(StickyPreviousHit=false) + // 说明会话链不属于本次账号,携带它会触发上游会话链鉴权失败,故主动剥离改用完整 input 重建。 + // 带 function_call_output 的工具续链无法重建,保持原样(与 WS 重连恢复一致)。 + if previousResponseID != "" && !scheduleDecision.StickyPreviousHit && + !service.ValidateFunctionCallOutputContextBytes(forwardBody).HasFunctionCallOutput { + forwardBody = service.RemovePreviousResponseIDFromBody(forwardBody) + reqLog.Debug("openai.previous_response_id_stripped_cross_group", + zap.Int64("account_id", account.ID), + zap.String("schedule_layer", scheduleDecision.Layer), + ) + } writerSizeBeforeForward := c.Writer.Size() result, err := func() (*service.OpenAIForwardResult, error) { defer func() { diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 4bf0147f38..18bd4d5324 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -572,6 +572,21 @@ func ReplaceModelInBody(body []byte, newModel string) []byte { return newBody } +// RemovePreviousResponseIDFromBody 删除请求体中的 previous_response_id,用于会话失配时改用完整 input 重建上下文。 +func RemovePreviousResponseIDFromBody(body []byte) []byte { + if len(body) == 0 { + return body + } + if !gjson.GetBytes(body, "previous_response_id").Exists() { + return body + } + newBody, err := sjson.DeleteBytes(body, "previous_response_id") + if err != nil { + return body + } + return newBody +} + // validateChannelConfig 校验渠道的定价和映射配置(冲突检测 + 区间校验 + 计费模式校验)。 // Create 和 Update 共用此函数,避免重复。 func validateChannelConfig(pricing []ChannelModelPricing, mapping map[string]map[string]string) error { diff --git a/backend/internal/service/openai_ws_state_store.go b/backend/internal/service/openai_ws_state_store.go index b606baa1a3..d3b6891b0a 100644 --- a/backend/internal/service/openai_ws_state_store.go +++ b/backend/internal/service/openai_ws_state_store.go @@ -100,9 +100,10 @@ func (s *defaultOpenAIWSStateStore) BindResponseAccount(ctx context.Context, gro s.maybeCleanup() expiresAt := time.Now().Add(ttl) + mapKey := openAIWSResponseAccountMapKey(groupID, id) s.responseToAccountMu.Lock() - ensureBindingCapacity(s.responseToAccount, id, openAIWSStateStoreMaxEntriesPerMap) - s.responseToAccount[id] = openAIWSAccountBinding{accountID: accountID, expiresAt: expiresAt} + ensureBindingCapacity(s.responseToAccount, mapKey, openAIWSStateStoreMaxEntriesPerMap) + s.responseToAccount[mapKey] = openAIWSAccountBinding{accountID: accountID, expiresAt: expiresAt} s.responseToAccountMu.Unlock() if s.cache == nil { @@ -122,8 +123,9 @@ func (s *defaultOpenAIWSStateStore) GetResponseAccount(ctx context.Context, grou s.maybeCleanup() now := time.Now() + mapKey := openAIWSResponseAccountMapKey(groupID, id) s.responseToAccountMu.RLock() - if binding, ok := s.responseToAccount[id]; ok { + if binding, ok := s.responseToAccount[mapKey]; ok { if now.Before(binding.expiresAt) { accountID := binding.accountID s.responseToAccountMu.RUnlock() @@ -153,7 +155,7 @@ func (s *defaultOpenAIWSStateStore) DeleteResponseAccount(ctx context.Context, g return nil } s.responseToAccountMu.Lock() - delete(s.responseToAccount, id) + delete(s.responseToAccount, openAIWSResponseAccountMapKey(groupID, id)) s.responseToAccountMu.Unlock() if s.cache == nil { @@ -417,6 +419,11 @@ func openAIWSResponseAccountCacheKey(responseID string) string { return openAIWSResponseAccountCachePrefix + hex.EncodeToString(sum[:]) } +// openAIWSResponseAccountMapKey 本地热缓存按分组隔离的 key,与 Redis 层保持一致,避免跨组命中。 +func openAIWSResponseAccountMapKey(groupID int64, responseID string) string { + return fmt.Sprintf("%d:%s", groupID, responseID) +} + func normalizeOpenAIWSTTL(ttl time.Duration) time.Duration { if ttl <= 0 { return time.Hour From 9a0e439803ad5ce28733885137ebf5eb5b8529ce Mon Sep 17 00:00:00 2001 From: bwlc <1350701980@qq.com> Date: Sun, 7 Jun 2026 20:30:56 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(openai):=20=E8=B7=A8=E7=BB=84=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=A4=B1=E9=85=8D=E4=BF=9D=E6=8A=A4=E7=A7=BB=E5=88=B0?= =?UTF-8?q?=E7=94=9F=E6=95=88=E7=9A=84=20WSv2=20=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 87dd5f5d 把 previous_response_id 剥离保护加在了 HTTP Responses 路径,但该路径 在 previousResponseID != "" 时已无条件返回 400(0fcddce6 引入),剥离块恒不可达, RemovePreviousResponseIDFromBody 也只被这段死分支调用、无覆盖。 - 删除 HTTP Responses 路径的死代码剥离块,留注释指明保护应在 WSv2 路径。 - 在 ResponsesWebSocket 首包(wsFirstMessage)处补回等价保护:previous_response_id 未命中当前分组粘连账号(StickyPreviousHit=false)时剥离,改用首包 input 重建; 带 function_call_output 的工具续链保持原样。这是 previous_response_id 真正生效、 会触发跨组会话链鉴权失败的路径。 - 为 RemovePreviousResponseIDFromBody 增加单元测试(现已有真实 caller)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../handler/openai_gateway_handler.go | 23 +++++++-------- .../internal/service/channel_service_test.go | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 5f811ff25b..6730529743 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -358,17 +358,6 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { if channelMapping.Mapped { forwardBody = h.gatewayService.ReplaceModelInBody(body, channelMapping.MappedModel) } - // 切组/会话失配防护:previous_response_id 未在当前分组命中粘连账号(StickyPreviousHit=false) - // 说明会话链不属于本次账号,携带它会触发上游会话链鉴权失败,故主动剥离改用完整 input 重建。 - // 带 function_call_output 的工具续链无法重建,保持原样(与 WS 重连恢复一致)。 - if previousResponseID != "" && !scheduleDecision.StickyPreviousHit && - !service.ValidateFunctionCallOutputContextBytes(forwardBody).HasFunctionCallOutput { - forwardBody = service.RemovePreviousResponseIDFromBody(forwardBody) - reqLog.Debug("openai.previous_response_id_stripped_cross_group", - zap.Int64("account_id", account.ID), - zap.String("schedule_layer", scheduleDecision.Layer), - ) - } writerSizeBeforeForward := c.Writer.Size() result, err := func() (*service.OpenAIForwardResult, error) { defer func() { @@ -1504,6 +1493,18 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { if channelMappingWS.Mapped { wsFirstMessage = h.gatewayService.ReplaceModelInBody(firstMessage, channelMappingWS.MappedModel) } + // 切组/会话失配防护:previous_response_id 未在当前分组命中粘连账号(StickyPreviousHit=false), + // 说明该会话链不属于本次调度到的账号,原样转发会触发上游会话链鉴权失败(“鉴权失败,请检查 API Key”)。 + // 故剥离首包里的 previous_response_id,改用首包内 input 重建上下文;带 function_call_output 的 + // 工具续链无法重建,保持原样。仅作用于首轮首包,后续 turn 的续链由 WS 转发层既有逻辑处理。 + if previousResponseID != "" && !scheduleDecision.StickyPreviousHit && + !service.ValidateFunctionCallOutputContextBytes(wsFirstMessage).HasFunctionCallOutput { + wsFirstMessage = service.RemovePreviousResponseIDFromBody(wsFirstMessage) + reqLog.Debug("openai.websocket_previous_response_id_stripped_cross_group", + zap.Int64("account_id", account.ID), + zap.String("schedule_layer", scheduleDecision.Layer), + ) + } // WebSocket 首包可能很大,hash 必须在 hooks 外算成字符串,避免 AfterTurn 闭包保活请求体。 requestPayloadHash = service.HashUsageRequestPayload(wsFirstMessage) diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index e737a21125..381b8c6c25 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -9,6 +9,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) // --------------------------------------------------------------------------- @@ -1921,6 +1922,33 @@ func TestReplaceModelInBody_InvalidJSON(t *testing.T) { require.Equal(t, arrayBody, result2) } +func TestRemovePreviousResponseIDFromBody(t *testing.T) { + t.Run("empty body returned as-is", func(t *testing.T) { + require.Equal(t, []byte{}, RemovePreviousResponseIDFromBody([]byte{})) + require.Nil(t, RemovePreviousResponseIDFromBody(nil)) + }) + + t.Run("no previous_response_id field is a no-op", func(t *testing.T) { + body := []byte(`{"model":"gpt-5","input":"hi"}`) + result := RemovePreviousResponseIDFromBody(body) + require.Equal(t, body, result) + }) + + t.Run("strips previous_response_id and preserves other fields", func(t *testing.T) { + body := []byte(`{"model":"gpt-5","previous_response_id":"resp_abc","input":"hi"}`) + result := RemovePreviousResponseIDFromBody(body) + require.False(t, gjson.GetBytes(result, "previous_response_id").Exists()) + require.Equal(t, "gpt-5", gjson.GetBytes(result, "model").String()) + require.Equal(t, "hi", gjson.GetBytes(result, "input").String()) + }) + + t.Run("empty-string previous_response_id is also stripped", func(t *testing.T) { + body := []byte(`{"model":"gpt-5","previous_response_id":""}`) + result := RemovePreviousResponseIDFromBody(body) + require.False(t, gjson.GetBytes(result, "previous_response_id").Exists()) + }) +} + // =========================================================================== // 7. isPlatformPricingMatch // ===========================================================================