Commit 3c619a8d authored by huangenjun's avatar huangenjun
Browse files

refactor: 使用 go-sora2api SDK 替代自建 Sora 客户端



使用 go-sora2api v1.1.0 SDK 替代原有 ~2000 行自建 HTTP/PoW/TLS 指纹代码,
SDK 提供高并发性能优化(实例级 rand、PoW 缓冲区复用、context.Context 支持)。

- 新增 SoraSDKClient 适配器实现 SoraClient 接口
- 精简 sora_client.go 为仅保留接口和类型定义
- 更新 Wire 绑定使用 SoraSDKClient
- 删除 SoraDirectClient、sora_curl_cffi_sidecar、sora_request_guard 等旧代码
Co-Authored-By: default avatarClaude Opus 4.6 <noreply@anthropic.com>
parent ded9b6c1
......@@ -187,9 +187,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, configConfig)
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, configConfig)
soraDirectClient := service.ProvideSoraDirectClient(configConfig, httpUpstream, openAITokenProvider, accountRepository, soraAccountRepository)
soraSDKClient := service.ProvideSoraSDKClient(configConfig, httpUpstream, openAITokenProvider, accountRepository, soraAccountRepository)
soraMediaStorage := service.ProvideSoraMediaStorage(configConfig)
soraGatewayService := service.NewSoraGatewayService(soraDirectClient, soraMediaStorage, rateLimitService, configConfig)
soraGatewayService := service.NewSoraGatewayService(soraSDKClient, soraMediaStorage, rateLimitService, configConfig)
soraGatewayHandler := handler.NewSoraGatewayHandler(gatewayService, soraGatewayService, concurrencyService, billingCacheService, usageRecordWorkerPool, configConfig)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
totpHandler := handler.NewTotpHandler(totpService)
......
......@@ -5,6 +5,7 @@ go 1.25.7
require (
entgo.io/ent v0.14.5
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/DouDOU-start/go-sora2api v1.1.0
github.com/alitto/pond/v2 v2.6.2
github.com/cespare/xxhash/v2 v2.3.0
github.com/dgraph-io/ristretto v0.2.0
......@@ -29,10 +30,10 @@ require (
github.com/tidwall/sjson v1.2.5
github.com/zeromicro/go-zero v1.9.4
go.uber.org/zap v1.24.0
golang.org/x/crypto v0.47.0
golang.org/x/crypto v0.48.0
golang.org/x/net v0.49.0
golang.org/x/sync v0.19.0
golang.org/x/term v0.39.0
golang.org/x/term v0.40.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.44.3
......@@ -46,7 +47,14 @@ require (
github.com/agext/levenshtein v1.2.3 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/bdandy/go-errors v1.2.2 // indirect
github.com/bdandy/go-socks4 v1.2.3 // indirect
github.com/bmatcuk/doublestar v1.3.4 // indirect
github.com/bogdanfinn/fhttp v0.6.8 // indirect
github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect
github.com/bogdanfinn/tls-client v1.14.0 // indirect
github.com/bogdanfinn/utls v1.7.7-barnius // indirect
github.com/bogdanfinn/websocket v1.5.5-barnius // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
......@@ -123,6 +131,7 @@ require (
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect
github.com/testcontainers/testcontainers-go v0.40.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
......@@ -144,9 +153,9 @@ require (
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/grpc v1.75.1 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
......
......@@ -10,6 +10,8 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/DouDOU-start/go-sora2api v1.1.0 h1:PxWiukK77StiHxEngOFwT1rKUn9oTAJJTl07wQUXwiU=
github.com/DouDOU-start/go-sora2api v1.1.0/go.mod h1:dcwpethoKfAsMWskDD9iGgc/3yox2tkthPLSMVGnhkE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
......@@ -20,10 +22,24 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0=
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws4=
github.com/bogdanfinn/fhttp v0.6.8/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M=
github.com/bogdanfinn/quic-go-utls v1.0.9-utls h1:tV6eDEiRbRCcepALSzxR94JUVD3N3ACIiRLgyc2Ep8s=
github.com/bogdanfinn/quic-go-utls v1.0.9-utls/go.mod h1:aHph9B9H9yPOt5xnhWKSOum27DJAqpiHzwX+gjvaXcg=
github.com/bogdanfinn/tls-client v1.14.0 h1:vyk7Cn4BIvLAGVuMfb0tP22OqogfO1lYamquQNEZU1A=
github.com/bogdanfinn/tls-client v1.14.0/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM=
github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU=
github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg=
github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI=
github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
......@@ -279,6 +295,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc=
github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng=
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
......@@ -345,18 +363,21 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
......@@ -364,16 +385,19 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU=
......
This diff is collapsed.
//go:build unit
package service
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// ---------- 辅助解析函数(复制生产代码中的 gjson 解析逻辑,用于单元测试) ----------
// testParseUploadOrCreateTaskID 模拟 UploadImage / CreateImageTask / CreateVideoTask 中
// 用 gjson.GetBytes(respBody, "id") 提取 id 的逻辑。
func testParseUploadOrCreateTaskID(respBody []byte) (string, error) {
id := strings.TrimSpace(gjson.GetBytes(respBody, "id").String())
if id == "" {
return "", assert.AnError // 占位错误,表示 "missing id"
}
return id, nil
}
// testParseFetchRecentImageTask 模拟 fetchRecentImageTask 中的 gjson.ForEach 解析逻辑。
func testParseFetchRecentImageTask(respBody []byte, taskID string) (*SoraImageTaskStatus, bool) {
var found *SoraImageTaskStatus
gjson.GetBytes(respBody, "task_responses").ForEach(func(_, item gjson.Result) bool {
if item.Get("id").String() != taskID {
return true // continue
}
status := strings.TrimSpace(item.Get("status").String())
progress := item.Get("progress_pct").Float()
var urls []string
item.Get("generations").ForEach(func(_, gen gjson.Result) bool {
if u := strings.TrimSpace(gen.Get("url").String()); u != "" {
urls = append(urls, u)
}
return true
})
found = &SoraImageTaskStatus{
ID: taskID,
Status: status,
ProgressPct: progress,
URLs: urls,
}
return false // break
})
if found != nil {
return found, true
}
return &SoraImageTaskStatus{ID: taskID, Status: "processing"}, false
}
// testParseGetVideoTaskPending 模拟 GetVideoTask 中解析 pending 列表的逻辑。
func testParseGetVideoTaskPending(respBody []byte, taskID string) (*SoraVideoTaskStatus, bool) {
pendingResult := gjson.ParseBytes(respBody)
if !pendingResult.IsArray() {
return nil, false
}
var pendingFound *SoraVideoTaskStatus
pendingResult.ForEach(func(_, task gjson.Result) bool {
if task.Get("id").String() != taskID {
return true
}
progress := 0
if v := task.Get("progress_pct"); v.Exists() {
progress = int(v.Float() * 100)
}
status := strings.TrimSpace(task.Get("status").String())
pendingFound = &SoraVideoTaskStatus{
ID: taskID,
Status: status,
ProgressPct: progress,
}
return false
})
if pendingFound != nil {
return pendingFound, true
}
return nil, false
}
// testParseGetVideoTaskDrafts 模拟 GetVideoTask 中解析 drafts 列表的逻辑。
func testParseGetVideoTaskDrafts(respBody []byte, taskID string) (*SoraVideoTaskStatus, bool) {
var draftFound *SoraVideoTaskStatus
gjson.GetBytes(respBody, "items").ForEach(func(_, draft gjson.Result) bool {
if draft.Get("task_id").String() != taskID {
return true
}
kind := strings.TrimSpace(draft.Get("kind").String())
reason := strings.TrimSpace(draft.Get("reason_str").String())
if reason == "" {
reason = strings.TrimSpace(draft.Get("markdown_reason_str").String())
}
urlStr := strings.TrimSpace(draft.Get("downloadable_url").String())
if urlStr == "" {
urlStr = strings.TrimSpace(draft.Get("url").String())
}
if kind == "sora_content_violation" || reason != "" || urlStr == "" {
msg := reason
if msg == "" {
msg = "Content violates guardrails"
}
draftFound = &SoraVideoTaskStatus{
ID: taskID,
Status: "failed",
ErrorMsg: msg,
}
} else {
draftFound = &SoraVideoTaskStatus{
ID: taskID,
Status: "completed",
URLs: []string{urlStr},
}
}
return false
})
if draftFound != nil {
return draftFound, true
}
return nil, false
}
// ===================== Test 1: TestSoraParseUploadResponse =====================
func TestSoraParseUploadResponse(t *testing.T) {
tests := []struct {
name string
body string
wantID string
wantErr bool
}{
{
name: "正常 id",
body: `{"id":"file-abc123","status":"uploaded"}`,
wantID: "file-abc123",
},
{
name: "空 id",
body: `{"id":"","status":"uploaded"}`,
wantErr: true,
},
{
name: "无 id 字段",
body: `{"status":"uploaded"}`,
wantErr: true,
},
{
name: "id 全为空白",
body: `{"id":" ","status":"uploaded"}`,
wantErr: true,
},
{
name: "id 前后有空白",
body: `{"id":" file-trimmed ","status":"uploaded"}`,
wantID: "file-trimmed",
},
{
name: "空 JSON 对象",
body: `{}`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id, err := testParseUploadOrCreateTaskID([]byte(tt.body))
if tt.wantErr {
require.Error(t, err, "应返回错误")
return
}
require.NoError(t, err)
require.Equal(t, tt.wantID, id)
})
}
}
// ===================== Test 2: TestSoraParseCreateTaskResponse =====================
func TestSoraParseCreateTaskResponse(t *testing.T) {
tests := []struct {
name string
body string
wantID string
wantErr bool
}{
{
name: "正常任务 id",
body: `{"id":"task-123"}`,
wantID: "task-123",
},
{
name: "缺失 id",
body: `{"status":"created"}`,
wantErr: true,
},
{
name: "空 id",
body: `{"id":" "}`,
wantErr: true,
},
{
name: "id 为数字(gjson 转字符串)",
body: `{"id":123}`,
wantID: "123",
},
{
name: "id 含特殊字符",
body: `{"id":"task-abc-def-456-ghi"}`,
wantID: "task-abc-def-456-ghi",
},
{
name: "额外字段不影响解析",
body: `{"id":"task-999","type":"image_gen","extra":"data"}`,
wantID: "task-999",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id, err := testParseUploadOrCreateTaskID([]byte(tt.body))
if tt.wantErr {
require.Error(t, err, "应返回错误")
return
}
require.NoError(t, err)
require.Equal(t, tt.wantID, id)
})
}
}
// ===================== Test 3: TestSoraParseFetchRecentImageTask =====================
func TestSoraParseFetchRecentImageTask(t *testing.T) {
tests := []struct {
name string
body string
taskID string
wantFound bool
wantStatus string
wantProgress float64
wantURLs []string
}{
{
name: "匹配已完成任务",
body: `{"task_responses":[{"id":"task-1","status":"completed","progress_pct":1.0,"generations":[{"url":"https://example.com/img.png"}]}]}`,
taskID: "task-1",
wantFound: true,
wantStatus: "completed",
wantProgress: 1.0,
wantURLs: []string{"https://example.com/img.png"},
},
{
name: "匹配处理中任务",
body: `{"task_responses":[{"id":"task-2","status":"processing","progress_pct":0.5,"generations":[]}]}`,
taskID: "task-2",
wantFound: true,
wantStatus: "processing",
wantProgress: 0.5,
wantURLs: nil,
},
{
name: "无匹配任务",
body: `{"task_responses":[{"id":"other","status":"completed"}]}`,
taskID: "task-1",
wantFound: false,
wantStatus: "processing",
},
{
name: "空 task_responses",
body: `{"task_responses":[]}`,
taskID: "task-1",
wantFound: false,
wantStatus: "processing",
},
{
name: "缺少 task_responses 字段",
body: `{"other":"data"}`,
taskID: "task-1",
wantFound: false,
wantStatus: "processing",
},
{
name: "多个任务中精准匹配",
body: `{"task_responses":[{"id":"task-a","status":"completed","progress_pct":1.0,"generations":[{"url":"https://a.com/1.png"}]},{"id":"task-b","status":"processing","progress_pct":0.3,"generations":[]},{"id":"task-c","status":"failed","progress_pct":0}]}`,
taskID: "task-b",
wantFound: true,
wantStatus: "processing",
wantProgress: 0.3,
wantURLs: nil,
},
{
name: "多个 generations",
body: `{"task_responses":[{"id":"task-m","status":"completed","progress_pct":1.0,"generations":[{"url":"https://a.com/1.png"},{"url":"https://a.com/2.png"},{"url":""}]}]}`,
taskID: "task-m",
wantFound: true,
wantStatus: "completed",
wantProgress: 1.0,
wantURLs: []string{"https://a.com/1.png", "https://a.com/2.png"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
status, found := testParseFetchRecentImageTask([]byte(tt.body), tt.taskID)
require.Equal(t, tt.wantFound, found, "found 不匹配")
require.NotNil(t, status)
require.Equal(t, tt.taskID, status.ID)
require.Equal(t, tt.wantStatus, status.Status)
if tt.wantFound {
require.InDelta(t, tt.wantProgress, status.ProgressPct, 0.001, "进度不匹配")
require.Equal(t, tt.wantURLs, status.URLs)
}
})
}
}
// ===================== Test 4: TestSoraParseGetVideoTaskPending =====================
func TestSoraParseGetVideoTaskPending(t *testing.T) {
tests := []struct {
name string
body string
taskID string
wantFound bool
wantStatus string
wantProgress int
}{
{
name: "匹配 pending 任务",
body: `[{"id":"task-1","status":"processing","progress_pct":0.5}]`,
taskID: "task-1",
wantFound: true,
wantStatus: "processing",
wantProgress: 50,
},
{
name: "进度为 0",
body: `[{"id":"task-2","status":"queued","progress_pct":0}]`,
taskID: "task-2",
wantFound: true,
wantStatus: "queued",
wantProgress: 0,
},
{
name: "进度为 1(100%)",
body: `[{"id":"task-3","status":"completing","progress_pct":1.0}]`,
taskID: "task-3",
wantFound: true,
wantStatus: "completing",
wantProgress: 100,
},
{
name: "空数组",
body: `[]`,
taskID: "task-1",
wantFound: false,
},
{
name: "无匹配 id",
body: `[{"id":"task-other","status":"processing","progress_pct":0.3}]`,
taskID: "task-1",
wantFound: false,
},
{
name: "多个任务精准匹配",
body: `[{"id":"task-a","status":"processing","progress_pct":0.2},{"id":"task-b","status":"queued","progress_pct":0},{"id":"task-c","status":"processing","progress_pct":0.8}]`,
taskID: "task-c",
wantFound: true,
wantStatus: "processing",
wantProgress: 80,
},
{
name: "非数组 JSON",
body: `{"id":"task-1","status":"processing"}`,
taskID: "task-1",
wantFound: false,
},
{
name: "无 progress_pct 字段",
body: `[{"id":"task-4","status":"pending"}]`,
taskID: "task-4",
wantFound: true,
wantStatus: "pending",
wantProgress: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
status, found := testParseGetVideoTaskPending([]byte(tt.body), tt.taskID)
require.Equal(t, tt.wantFound, found, "found 不匹配")
if tt.wantFound {
require.NotNil(t, status)
require.Equal(t, tt.taskID, status.ID)
require.Equal(t, tt.wantStatus, status.Status)
require.Equal(t, tt.wantProgress, status.ProgressPct)
}
})
}
}
// ===================== Test 5: TestSoraParseGetVideoTaskDrafts =====================
func TestSoraParseGetVideoTaskDrafts(t *testing.T) {
tests := []struct {
name string
body string
taskID string
wantFound bool
wantStatus string
wantURLs []string
wantErr string
}{
{
name: "正常完成的视频",
body: `{"items":[{"task_id":"task-1","kind":"video","downloadable_url":"https://example.com/video.mp4"}]}`,
taskID: "task-1",
wantFound: true,
wantStatus: "completed",
wantURLs: []string{"https://example.com/video.mp4"},
},
{
name: "使用 url 字段回退",
body: `{"items":[{"task_id":"task-2","kind":"video","url":"https://example.com/fallback.mp4"}]}`,
taskID: "task-2",
wantFound: true,
wantStatus: "completed",
wantURLs: []string{"https://example.com/fallback.mp4"},
},
{
name: "内容违规",
body: `{"items":[{"task_id":"task-3","kind":"sora_content_violation","reason_str":"Content policy violation"}]}`,
taskID: "task-3",
wantFound: true,
wantStatus: "failed",
wantErr: "Content policy violation",
},
{
name: "内容违规 - markdown_reason_str 回退",
body: `{"items":[{"task_id":"task-4","kind":"sora_content_violation","markdown_reason_str":"Markdown reason"}]}`,
taskID: "task-4",
wantFound: true,
wantStatus: "failed",
wantErr: "Markdown reason",
},
{
name: "内容违规 - 无 reason 使用默认消息",
body: `{"items":[{"task_id":"task-5","kind":"sora_content_violation"}]}`,
taskID: "task-5",
wantFound: true,
wantStatus: "failed",
wantErr: "Content violates guardrails",
},
{
name: "有 reason_str 但非 violation kind(仍判定失败)",
body: `{"items":[{"task_id":"task-6","kind":"video","reason_str":"Some error occurred"}]}`,
taskID: "task-6",
wantFound: true,
wantStatus: "failed",
wantErr: "Some error occurred",
},
{
name: "空 URL 判定为失败",
body: `{"items":[{"task_id":"task-7","kind":"video","downloadable_url":"","url":""}]}`,
taskID: "task-7",
wantFound: true,
wantStatus: "failed",
wantErr: "Content violates guardrails",
},
{
name: "无匹配 task_id",
body: `{"items":[{"task_id":"task-other","kind":"video","downloadable_url":"https://example.com/video.mp4"}]}`,
taskID: "task-1",
wantFound: false,
},
{
name: "空 items",
body: `{"items":[]}`,
taskID: "task-1",
wantFound: false,
},
{
name: "缺少 items 字段",
body: `{"other":"data"}`,
taskID: "task-1",
wantFound: false,
},
{
name: "多个 items 精准匹配",
body: `{"items":[{"task_id":"task-a","kind":"video","downloadable_url":"https://a.com/a.mp4"},{"task_id":"task-b","kind":"sora_content_violation","reason_str":"Bad content"},{"task_id":"task-c","kind":"video","downloadable_url":"https://c.com/c.mp4"}]}`,
taskID: "task-b",
wantFound: true,
wantStatus: "failed",
wantErr: "Bad content",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
status, found := testParseGetVideoTaskDrafts([]byte(tt.body), tt.taskID)
require.Equal(t, tt.wantFound, found, "found 不匹配")
if !tt.wantFound {
return
}
require.NotNil(t, status)
require.Equal(t, tt.taskID, status.ID)
require.Equal(t, tt.wantStatus, status.Status)
if tt.wantErr != "" {
require.Equal(t, tt.wantErr, status.ErrorMsg)
}
if tt.wantURLs != nil {
require.Equal(t, tt.wantURLs, status.URLs)
}
})
}
}
This diff is collapsed.
package service
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
)
const soraCurlCFFISidecarDefaultTimeoutSeconds = 60
type soraCurlCFFISidecarRequest struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string][]string `json:"headers,omitempty"`
BodyBase64 string `json:"body_base64,omitempty"`
ProxyURL string `json:"proxy_url,omitempty"`
SessionKey string `json:"session_key,omitempty"`
Impersonate string `json:"impersonate,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
}
type soraCurlCFFISidecarResponse struct {
StatusCode int `json:"status_code"`
Status int `json:"status"`
Headers map[string]any `json:"headers"`
BodyBase64 string `json:"body_base64"`
Body string `json:"body"`
Error string `json:"error"`
}
func (c *SoraDirectClient) doHTTPViaCurlCFFISidecar(req *http.Request, proxyURL string, account *Account) (*http.Response, error) {
if req == nil || req.URL == nil {
return nil, errors.New("request url is nil")
}
if c == nil || c.cfg == nil {
return nil, errors.New("sora curl_cffi sidecar config is nil")
}
if !c.cfg.Sora.Client.CurlCFFISidecar.Enabled {
return nil, errors.New("sora curl_cffi sidecar is disabled")
}
endpoint := c.curlCFFISidecarEndpoint()
if endpoint == "" {
return nil, errors.New("sora curl_cffi sidecar base_url is empty")
}
bodyBytes, err := readAndRestoreRequestBody(req)
if err != nil {
return nil, fmt.Errorf("sora curl_cffi sidecar read request body failed: %w", err)
}
headers := make(map[string][]string, len(req.Header)+1)
for key, vals := range req.Header {
copied := make([]string, len(vals))
copy(copied, vals)
headers[key] = copied
}
if strings.TrimSpace(req.Host) != "" {
if _, ok := headers["Host"]; !ok {
headers["Host"] = []string{req.Host}
}
}
payload := soraCurlCFFISidecarRequest{
Method: req.Method,
URL: req.URL.String(),
Headers: headers,
ProxyURL: strings.TrimSpace(proxyURL),
SessionKey: c.sidecarSessionKey(account, proxyURL),
Impersonate: c.curlCFFIImpersonate(),
TimeoutSeconds: c.curlCFFISidecarTimeoutSeconds(),
}
if len(bodyBytes) > 0 {
payload.BodyBase64 = base64.StdEncoding.EncodeToString(bodyBytes)
}
encoded, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("sora curl_cffi sidecar marshal request failed: %w", err)
}
sidecarReq, err := http.NewRequestWithContext(req.Context(), http.MethodPost, endpoint, bytes.NewReader(encoded))
if err != nil {
return nil, fmt.Errorf("sora curl_cffi sidecar build request failed: %w", err)
}
sidecarReq.Header.Set("Content-Type", "application/json")
sidecarReq.Header.Set("Accept", "application/json")
httpClient := &http.Client{Timeout: time.Duration(payload.TimeoutSeconds) * time.Second}
sidecarResp, err := httpClient.Do(sidecarReq)
if err != nil {
return nil, fmt.Errorf("sora curl_cffi sidecar request failed: %w", err)
}
defer func() {
_ = sidecarResp.Body.Close()
}()
sidecarRespBody, err := io.ReadAll(io.LimitReader(sidecarResp.Body, 8<<20))
if err != nil {
return nil, fmt.Errorf("sora curl_cffi sidecar read response failed: %w", err)
}
if sidecarResp.StatusCode != http.StatusOK {
redacted := truncateForLog([]byte(logredact.RedactText(string(sidecarRespBody))), 512)
return nil, fmt.Errorf("sora curl_cffi sidecar http status=%d body=%s", sidecarResp.StatusCode, redacted)
}
var payloadResp soraCurlCFFISidecarResponse
if err := json.Unmarshal(sidecarRespBody, &payloadResp); err != nil {
return nil, fmt.Errorf("sora curl_cffi sidecar parse response failed: %w", err)
}
if msg := strings.TrimSpace(payloadResp.Error); msg != "" {
return nil, fmt.Errorf("sora curl_cffi sidecar upstream error: %s", msg)
}
statusCode := payloadResp.StatusCode
if statusCode <= 0 {
statusCode = payloadResp.Status
}
if statusCode <= 0 {
return nil, errors.New("sora curl_cffi sidecar response missing status code")
}
responseBody := []byte(payloadResp.Body)
if strings.TrimSpace(payloadResp.BodyBase64) != "" {
decoded, err := base64.StdEncoding.DecodeString(payloadResp.BodyBase64)
if err != nil {
return nil, fmt.Errorf("sora curl_cffi sidecar decode body failed: %w", err)
}
responseBody = decoded
}
respHeaders := make(http.Header)
for key, rawVal := range payloadResp.Headers {
for _, v := range convertSidecarHeaderValue(rawVal) {
respHeaders.Add(key, v)
}
}
return &http.Response{
StatusCode: statusCode,
Header: respHeaders,
Body: io.NopCloser(bytes.NewReader(responseBody)),
ContentLength: int64(len(responseBody)),
Request: req,
}, nil
}
func readAndRestoreRequestBody(req *http.Request) ([]byte, error) {
if req == nil || req.Body == nil {
return nil, nil
}
bodyBytes, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
_ = req.Body.Close()
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
req.ContentLength = int64(len(bodyBytes))
return bodyBytes, nil
}
func (c *SoraDirectClient) curlCFFISidecarEndpoint() string {
if c == nil || c.cfg == nil {
return ""
}
raw := strings.TrimSpace(c.cfg.Sora.Client.CurlCFFISidecar.BaseURL)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil || strings.TrimSpace(parsed.Scheme) == "" || strings.TrimSpace(parsed.Host) == "" {
return raw
}
if path := strings.TrimSpace(parsed.Path); path == "" || path == "/" {
parsed.Path = "/request"
}
return parsed.String()
}
func (c *SoraDirectClient) curlCFFISidecarTimeoutSeconds() int {
if c == nil || c.cfg == nil {
return soraCurlCFFISidecarDefaultTimeoutSeconds
}
timeoutSeconds := c.cfg.Sora.Client.CurlCFFISidecar.TimeoutSeconds
if timeoutSeconds <= 0 {
return soraCurlCFFISidecarDefaultTimeoutSeconds
}
return timeoutSeconds
}
func (c *SoraDirectClient) curlCFFIImpersonate() string {
if c == nil || c.cfg == nil {
return "chrome131"
}
impersonate := strings.TrimSpace(c.cfg.Sora.Client.CurlCFFISidecar.Impersonate)
if impersonate == "" {
return "chrome131"
}
return impersonate
}
func (c *SoraDirectClient) sidecarSessionReuseEnabled() bool {
if c == nil || c.cfg == nil {
return true
}
return c.cfg.Sora.Client.CurlCFFISidecar.SessionReuseEnabled
}
func (c *SoraDirectClient) sidecarSessionTTLSeconds() int {
if c == nil || c.cfg == nil {
return 3600
}
ttl := c.cfg.Sora.Client.CurlCFFISidecar.SessionTTLSeconds
if ttl < 0 {
return 3600
}
return ttl
}
func convertSidecarHeaderValue(raw any) []string {
switch val := raw.(type) {
case nil:
return nil
case string:
if strings.TrimSpace(val) == "" {
return nil
}
return []string{val}
case []any:
out := make([]string, 0, len(val))
for _, item := range val {
s := strings.TrimSpace(fmt.Sprint(item))
if s != "" {
out = append(out, s)
}
}
return out
case []string:
out := make([]string, 0, len(val))
for _, item := range val {
if strings.TrimSpace(item) != "" {
out = append(out, item)
}
}
return out
default:
s := strings.TrimSpace(fmt.Sprint(val))
if s == "" {
return nil
}
return []string{s}
}
}
......@@ -9,6 +9,7 @@ import (
"io"
"log"
"math"
"math/rand"
"mime"
"net"
"net/http"
......@@ -669,7 +670,7 @@ func processSoraCharacterUsername(usernameHint string) string {
if usernameHint == "" {
usernameHint = "character"
}
return fmt.Sprintf("%s%d", usernameHint, soraRandInt(900)+100)
return fmt.Sprintf("%s%d", usernameHint, rand.Intn(900)+100)
}
func (s *SoraGatewayService) resolveWatermarkFreeURL(ctx context.Context, account *Account, generationID string, opts soraWatermarkOptions) (string, string, error) {
......
......@@ -181,7 +181,7 @@ func (s *SoraMediaStorage) downloadAndStore(ctx context.Context, mediaType, rawU
return relative, nil
}
if s.debug {
log.Printf("[SoraStorage] 下载失败(%d/%d): %s err=%v", attempt, retries, sanitizeSoraLogURL(rawURL), err)
log.Printf("[SoraStorage] 下载失败(%d/%d): %s err=%v", attempt, retries, sanitizeMediaLogURL(rawURL), err)
}
if attempt < retries {
time.Sleep(time.Duration(attempt*attempt) * time.Second)
......@@ -252,7 +252,7 @@ func (s *SoraMediaStorage) downloadOnce(ctx context.Context, root, mediaType, ra
relative := path.Join("/", mediaType, datePath, filename)
if s.debug {
log.Printf("[SoraStorage] 已落地 %s -> %s", sanitizeSoraLogURL(rawURL), relative)
log.Printf("[SoraStorage] 已落地 %s -> %s", sanitizeMediaLogURL(rawURL), relative)
}
return relative, nil
}
......@@ -305,3 +305,19 @@ func removePartialDownload(root *os.Root, filePath string) {
}
_ = root.Remove(filePath)
}
// sanitizeMediaLogURL 脱敏 URL 用于日志记录(去除 query 参数中可能的 token 信息)
func sanitizeMediaLogURL(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
if len(rawURL) > 80 {
return rawURL[:80] + "..."
}
return rawURL
}
safe := parsed.Scheme + "://" + parsed.Host + parsed.Path
if len(safe) > 120 {
return safe[:120] + "..."
}
return safe
}
package service
import (
"fmt"
"math"
"net/http"
"net/url"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/util/soraerror"
"github.com/google/uuid"
)
type soraChallengeCooldownEntry struct {
Until time.Time
StatusCode int
CFRay string
ConsecutiveChallenges int
LastChallengeAt time.Time
}
type soraSidecarSessionEntry struct {
SessionKey string
ExpiresAt time.Time
LastUsedAt time.Time
}
func (c *SoraDirectClient) cloudflareChallengeCooldownSeconds() int {
if c == nil || c.cfg == nil {
return 900
}
cooldown := c.cfg.Sora.Client.CloudflareChallengeCooldownSeconds
if cooldown <= 0 {
return 0
}
return cooldown
}
func (c *SoraDirectClient) checkCloudflareChallengeCooldown(account *Account, proxyURL string) error {
if c == nil {
return nil
}
if account == nil || account.ID <= 0 {
return nil
}
cooldownSeconds := c.cloudflareChallengeCooldownSeconds()
if cooldownSeconds <= 0 {
return nil
}
key := soraAccountProxyKey(account, proxyURL)
now := time.Now()
c.challengeCooldownMu.RLock()
entry, ok := c.challengeCooldowns[key]
c.challengeCooldownMu.RUnlock()
if !ok {
return nil
}
if !entry.Until.After(now) {
c.challengeCooldownMu.Lock()
delete(c.challengeCooldowns, key)
c.challengeCooldownMu.Unlock()
return nil
}
remaining := int(math.Ceil(entry.Until.Sub(now).Seconds()))
if remaining < 1 {
remaining = 1
}
message := fmt.Sprintf("Sora request cooling down due to recent Cloudflare challenge. Retry in %d seconds.", remaining)
if entry.ConsecutiveChallenges > 1 {
message = fmt.Sprintf("%s (streak=%d)", message, entry.ConsecutiveChallenges)
}
if entry.CFRay != "" {
message = fmt.Sprintf("%s (last cf-ray: %s)", message, entry.CFRay)
}
return &SoraUpstreamError{
StatusCode: http.StatusTooManyRequests,
Message: message,
Headers: make(http.Header),
}
}
func (c *SoraDirectClient) recordCloudflareChallengeCooldown(account *Account, proxyURL string, statusCode int, headers http.Header, body []byte) {
if c == nil {
return
}
if account == nil || account.ID <= 0 {
return
}
cooldownSeconds := c.cloudflareChallengeCooldownSeconds()
if cooldownSeconds <= 0 {
return
}
key := soraAccountProxyKey(account, proxyURL)
now := time.Now()
cfRay := soraerror.ExtractCloudflareRayID(headers, body)
c.challengeCooldownMu.Lock()
c.cleanupExpiredChallengeCooldownsLocked(now)
streak := 1
existing, ok := c.challengeCooldowns[key]
if ok && now.Sub(existing.LastChallengeAt) <= 30*time.Minute {
streak = existing.ConsecutiveChallenges + 1
}
effectiveCooldown := soraComputeChallengeCooldownSeconds(cooldownSeconds, streak)
until := now.Add(time.Duration(effectiveCooldown) * time.Second)
if ok && existing.Until.After(until) {
until = existing.Until
if existing.ConsecutiveChallenges > streak {
streak = existing.ConsecutiveChallenges
}
if cfRay == "" {
cfRay = existing.CFRay
}
}
c.challengeCooldowns[key] = soraChallengeCooldownEntry{
Until: until,
StatusCode: statusCode,
CFRay: cfRay,
ConsecutiveChallenges: streak,
LastChallengeAt: now,
}
c.challengeCooldownMu.Unlock()
if c.debugEnabled() {
remain := int(math.Ceil(until.Sub(now).Seconds()))
if remain < 0 {
remain = 0
}
c.debugLogf("cloudflare_challenge_cooldown_set key=%s status=%d remain_s=%d streak=%d cf_ray=%s", key, statusCode, remain, streak, cfRay)
}
}
func soraComputeChallengeCooldownSeconds(baseSeconds, streak int) int {
if baseSeconds <= 0 {
return 0
}
if streak < 1 {
streak = 1
}
multiplier := streak
if multiplier > 4 {
multiplier = 4
}
cooldown := baseSeconds * multiplier
if cooldown > 3600 {
cooldown = 3600
}
return cooldown
}
func (c *SoraDirectClient) clearCloudflareChallengeCooldown(account *Account, proxyURL string) {
if c == nil {
return
}
if account == nil || account.ID <= 0 {
return
}
key := soraAccountProxyKey(account, proxyURL)
c.challengeCooldownMu.Lock()
_, existed := c.challengeCooldowns[key]
if existed {
delete(c.challengeCooldowns, key)
}
c.challengeCooldownMu.Unlock()
if existed && c.debugEnabled() {
c.debugLogf("cloudflare_challenge_cooldown_cleared key=%s", key)
}
}
func (c *SoraDirectClient) sidecarSessionKey(account *Account, proxyURL string) string {
if c == nil || !c.sidecarSessionReuseEnabled() {
return ""
}
if account == nil || account.ID <= 0 {
return ""
}
key := soraAccountProxyKey(account, proxyURL)
now := time.Now()
ttlSeconds := c.sidecarSessionTTLSeconds()
c.sidecarSessionMu.Lock()
defer c.sidecarSessionMu.Unlock()
c.cleanupExpiredSidecarSessionsLocked(now)
if existing, exists := c.sidecarSessions[key]; exists {
existing.LastUsedAt = now
c.sidecarSessions[key] = existing
return existing.SessionKey
}
expiresAt := now.Add(time.Duration(ttlSeconds) * time.Second)
if ttlSeconds <= 0 {
expiresAt = now.Add(365 * 24 * time.Hour)
}
newEntry := soraSidecarSessionEntry{
SessionKey: "sora-" + uuid.NewString(),
ExpiresAt: expiresAt,
LastUsedAt: now,
}
c.sidecarSessions[key] = newEntry
if c.debugEnabled() {
c.debugLogf("sidecar_session_created key=%s ttl_s=%d", key, ttlSeconds)
}
return newEntry.SessionKey
}
func (c *SoraDirectClient) cleanupExpiredChallengeCooldownsLocked(now time.Time) {
if c == nil || len(c.challengeCooldowns) == 0 {
return
}
for key, entry := range c.challengeCooldowns {
if !entry.Until.After(now) {
delete(c.challengeCooldowns, key)
}
}
}
func (c *SoraDirectClient) cleanupExpiredSidecarSessionsLocked(now time.Time) {
if c == nil || len(c.sidecarSessions) == 0 {
return
}
for key, entry := range c.sidecarSessions {
if !entry.ExpiresAt.After(now) {
delete(c.sidecarSessions, key)
}
}
}
func soraAccountProxyKey(account *Account, proxyURL string) string {
accountID := int64(0)
if account != nil {
accountID = account.ID
}
return fmt.Sprintf("account:%d|proxy:%s", accountID, normalizeSoraProxyKey(proxyURL))
}
func normalizeSoraProxyKey(proxyURL string) string {
raw := strings.TrimSpace(proxyURL)
if raw == "" {
return "direct"
}
parsed, err := url.Parse(raw)
if err != nil {
return strings.ToLower(raw)
}
scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme))
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
port := strings.TrimSpace(parsed.Port())
if host == "" {
return strings.ToLower(raw)
}
if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
port = ""
}
if port != "" {
host = host + ":" + port
}
if scheme == "" {
scheme = "proxy"
}
return scheme + "://" + host
}
This diff is collapsed.
......@@ -206,14 +206,14 @@ func ProvideSoraMediaStorage(cfg *config.Config) *SoraMediaStorage {
return NewSoraMediaStorage(cfg)
}
func ProvideSoraDirectClient(
func ProvideSoraSDKClient(
cfg *config.Config,
httpUpstream HTTPUpstream,
tokenProvider *OpenAITokenProvider,
accountRepo AccountRepository,
soraAccountRepo SoraAccountRepository,
) *SoraDirectClient {
client := NewSoraDirectClient(cfg, httpUpstream, tokenProvider)
) *SoraSDKClient {
client := NewSoraSDKClient(cfg, httpUpstream, tokenProvider)
client.SetAccountRepositories(accountRepo, soraAccountRepo)
return client
}
......@@ -306,8 +306,8 @@ var ProviderSet = wire.NewSet(
NewGatewayService,
ProvideSoraMediaStorage,
ProvideSoraMediaCleanupService,
ProvideSoraDirectClient,
wire.Bind(new(SoraClient), new(*SoraDirectClient)),
ProvideSoraSDKClient,
wire.Bind(new(SoraClient), new(*SoraSDKClient)),
NewSoraGatewayService,
NewOpenAIGatewayService,
NewOAuthService,
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment