fix(sora): 增强 Cloudflare 挑战识别并收敛 Sora 请求链路

- 在 failover 场景透传上游响应头并识别 Cloudflare challenge/cf-ray

- 统一 Sora 任务请求的 UA 与代理使用,sentinel 与业务请求保持一致

- 修复流式错误事件 JSON 转义问题并补充相关单元测试
This commit is contained in:
yangjianbo
2026-02-19 15:09:58 +08:00
parent 0832dfb32e
commit 440b87094a
9 changed files with 542 additions and 64 deletions

View File

@@ -5,6 +5,8 @@ package service
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -365,3 +367,166 @@ func TestShouldAttemptSoraTokenRecover(t *testing.T) {
require.False(t, shouldAttemptSoraTokenRecover(http.StatusUnauthorized, "https://auth.openai.com/oauth/token"))
require.False(t, shouldAttemptSoraTokenRecover(http.StatusTooManyRequests, "https://sora.chatgpt.com/backend/video_gen"))
}
type soraClientRequestCall struct {
Path string
UserAgent string
ProxyURL string
}
type soraClientRecordingUpstream struct {
calls []soraClientRequestCall
}
func (u *soraClientRecordingUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
return nil, errors.New("unexpected Do call")
}
func (u *soraClientRecordingUpstream) DoWithTLS(req *http.Request, proxyURL string, _ int64, _ int, _ bool) (*http.Response, error) {
u.calls = append(u.calls, soraClientRequestCall{
Path: req.URL.Path,
UserAgent: req.Header.Get("User-Agent"),
ProxyURL: proxyURL,
})
switch req.URL.Path {
case "/backend-api/sentinel/req":
return newSoraClientMockResponse(http.StatusOK, `{"token":"sentinel-token","turnstile":{"dx":"ok"}}`), nil
case "/backend/nf/create":
return newSoraClientMockResponse(http.StatusOK, `{"id":"task-123"}`), nil
case "/backend/uploads":
return newSoraClientMockResponse(http.StatusOK, `{"id":"upload-123"}`), nil
case "/backend/nf/check":
return newSoraClientMockResponse(http.StatusOK, `{"rate_limit_and_credit_balance":{"estimated_num_videos_remaining":1,"rate_limit_reached":false}}`), nil
default:
return newSoraClientMockResponse(http.StatusOK, `{"ok":true}`), nil
}
}
func newSoraClientMockResponse(statusCode int, body string) *http.Response {
return &http.Response{
StatusCode: statusCode,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
}
func TestSoraDirectClient_TaskUserAgent_DefaultDesktopFallback(t *testing.T) {
client := NewSoraDirectClient(&config.Config{}, nil, nil)
require.Equal(t, soraDesktopUserAgents[0], client.taskUserAgent())
}
func TestSoraDirectClient_CreateVideoTask_UsesSameUserAgentAndProxyForSentinelAndCreate(t *testing.T) {
originPowTokenGenerator := soraPowTokenGenerator
soraPowTokenGenerator = func(_ string) string { return "gAAAAACmock" }
defer func() {
soraPowTokenGenerator = originPowTokenGenerator
}()
upstream := &soraClientRecordingUpstream{}
cfg := &config.Config{
Sora: config.SoraConfig{
Client: config.SoraClientConfig{
BaseURL: "https://sora.chatgpt.com/backend",
},
},
}
client := NewSoraDirectClient(cfg, upstream, nil)
proxyID := int64(9)
account := &Account{
ID: 21,
Platform: PlatformSora,
Type: AccountTypeOAuth,
Concurrency: 1,
ProxyID: &proxyID,
Proxy: &Proxy{
Protocol: "http",
Host: "127.0.0.1",
Port: 8080,
},
Credentials: map[string]any{
"access_token": "access-token",
"expires_at": time.Now().Add(30 * time.Minute).Format(time.RFC3339),
},
}
taskID, err := client.CreateVideoTask(context.Background(), account, SoraVideoRequest{Prompt: "test"})
require.NoError(t, err)
require.Equal(t, "task-123", taskID)
require.Len(t, upstream.calls, 2)
sentinelCall := upstream.calls[0]
createCall := upstream.calls[1]
require.Equal(t, "/backend-api/sentinel/req", sentinelCall.Path)
require.Equal(t, "/backend/nf/create", createCall.Path)
require.Equal(t, "http://127.0.0.1:8080", sentinelCall.ProxyURL)
require.Equal(t, sentinelCall.ProxyURL, createCall.ProxyURL)
require.Equal(t, soraDesktopUserAgents[0], sentinelCall.UserAgent)
require.Equal(t, sentinelCall.UserAgent, createCall.UserAgent)
}
func TestSoraDirectClient_UploadImage_UsesTaskUserAgentAndProxy(t *testing.T) {
upstream := &soraClientRecordingUpstream{}
cfg := &config.Config{
Sora: config.SoraConfig{
Client: config.SoraClientConfig{
BaseURL: "https://sora.chatgpt.com/backend",
},
},
}
client := NewSoraDirectClient(cfg, upstream, nil)
proxyID := int64(3)
account := &Account{
ID: 31,
ProxyID: &proxyID,
Proxy: &Proxy{
Protocol: "http",
Host: "127.0.0.1",
Port: 8080,
},
Credentials: map[string]any{
"access_token": "access-token",
"expires_at": time.Now().Add(30 * time.Minute).Format(time.RFC3339),
},
}
uploadID, err := client.UploadImage(context.Background(), account, []byte("mock-image"), "a.png")
require.NoError(t, err)
require.Equal(t, "upload-123", uploadID)
require.Len(t, upstream.calls, 1)
require.Equal(t, "/backend/uploads", upstream.calls[0].Path)
require.Equal(t, "http://127.0.0.1:8080", upstream.calls[0].ProxyURL)
require.Equal(t, soraDesktopUserAgents[0], upstream.calls[0].UserAgent)
}
func TestSoraDirectClient_PreflightCheck_UsesTaskUserAgentAndProxy(t *testing.T) {
upstream := &soraClientRecordingUpstream{}
cfg := &config.Config{
Sora: config.SoraConfig{
Client: config.SoraClientConfig{
BaseURL: "https://sora.chatgpt.com/backend",
},
},
}
client := NewSoraDirectClient(cfg, upstream, nil)
proxyID := int64(7)
account := &Account{
ID: 41,
ProxyID: &proxyID,
Proxy: &Proxy{
Protocol: "http",
Host: "127.0.0.1",
Port: 8080,
},
Credentials: map[string]any{
"access_token": "access-token",
"expires_at": time.Now().Add(30 * time.Minute).Format(time.RFC3339),
},
}
err := client.PreflightCheck(context.Background(), account, "sora2", SoraModelConfig{Type: "video"})
require.NoError(t, err)
require.Len(t, upstream.calls, 1)
require.Equal(t, "/backend/nf/check", upstream.calls[0].Path)
require.Equal(t, "http://127.0.0.1:8080", upstream.calls[0].ProxyURL)
require.Equal(t, soraDesktopUserAgents[0], upstream.calls[0].UserAgent)
}