mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 20:12:39 +08:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
635a27189f | ||
|
|
8b3fe664dc | ||
|
|
a4d263f62f | ||
|
|
ae81dfd933 | ||
|
|
35cab3c814 | ||
|
|
770e35b474 | ||
|
|
846dd310a3 | ||
|
|
9b4575e434 | ||
|
|
1f4cfc44c1 | ||
|
|
e592c5f9e0 | ||
|
|
9fd7e76231 | ||
|
|
785b61d424 | ||
|
|
2d3e845205 | ||
|
|
80af44cad7 | ||
|
|
c899c8cf37 | ||
|
|
4c4ff36380 | ||
|
|
1e08c4c56f | ||
|
|
2eb24814fe | ||
|
|
1b04e03cc4 | ||
|
|
2be047d1cd | ||
|
|
db725a775a | ||
|
|
825ca7b1fc | ||
|
|
54a2bcfd15 | ||
|
|
27e8f69a9e | ||
|
|
684ab20a0b | ||
|
|
0173830df6 | ||
|
|
724565e4aa | ||
|
|
a03de418c8 | ||
|
|
61ebdbdd43 | ||
|
|
a1f1a0cc6b | ||
|
|
954d44c19c | ||
|
|
38081ef72e | ||
|
|
3c20f9a666 | ||
|
|
e1b76e2245 | ||
|
|
f970bd48c9 | ||
|
|
a0802f00b6 | ||
|
|
0b9f40e230 | ||
|
|
0b26acac07 | ||
|
|
7e2e9ba050 | ||
|
|
2a42833c4c | ||
|
|
30d2589ef0 | ||
|
|
d60f4e442b | ||
|
|
ddf4c6fd81 | ||
|
|
ff7ab2308d | ||
|
|
0204ce285e | ||
|
|
b74cb7891c |
@@ -1,6 +1,8 @@
|
||||
.PHONY: build build-backend build-frontend test test-backend test-frontend test-frontend-critical
|
||||
|
||||
FRONTEND_CRITICAL_VITEST := \
|
||||
src/api/__tests__/client.spec.ts \
|
||||
src/api/__tests__/tokenRefresh.spec.ts \
|
||||
src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \
|
||||
src/views/auth/__tests__/WechatCallbackView.spec.ts \
|
||||
src/views/user/__tests__/PaymentView.spec.ts \
|
||||
|
||||
@@ -747,7 +747,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:
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.169
|
||||
0.1.170
|
||||
|
||||
@@ -88,6 +88,7 @@ func provideCleanup(
|
||||
schedulerSnapshot *service.SchedulerSnapshotService,
|
||||
tokenRefresh *service.TokenRefreshService,
|
||||
accountExpiry *service.AccountExpiryService,
|
||||
codexVersionSync *service.OpenAICodexVersionSyncService,
|
||||
proxyExpiry *service.ProxyExpiryService,
|
||||
subscriptionExpiry *service.SubscriptionExpiryService,
|
||||
usageCleanup *service.UsageCleanupService,
|
||||
@@ -236,6 +237,10 @@ func provideCleanup(
|
||||
accountExpiry.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"OpenAICodexVersionSyncService", func() error {
|
||||
codexVersionSync.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"ProxyExpiryService", func() error {
|
||||
proxyExpiry.Stop()
|
||||
return nil
|
||||
|
||||
@@ -57,6 +57,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
emailService := service.NewEmailService(settingRepository, emailCache)
|
||||
turnstileVerifier := repository.NewTurnstileVerifier()
|
||||
turnstileService := service.NewTurnstileService(settingService, turnstileVerifier)
|
||||
tencentCaptchaVerifier := repository.NewTencentCaptchaVerifier()
|
||||
tencentCaptchaService := service.NewTencentCaptchaService(settingService, tencentCaptchaVerifier)
|
||||
emailQueueService := service.ProvideEmailQueueService(emailService)
|
||||
promoCodeRepository := repository.NewPromoCodeRepository(client)
|
||||
billingCache := repository.NewBillingCache(redisClient)
|
||||
@@ -78,7 +80,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig)
|
||||
affiliateRepository := repository.NewAffiliateRepository(client, db)
|
||||
affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService)
|
||||
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository)
|
||||
authService := service.ProvideAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, tencentCaptchaService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository)
|
||||
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
|
||||
redeemCache := repository.NewRedeemCache(redisClient)
|
||||
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService)
|
||||
@@ -206,7 +208,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig)
|
||||
backupHandler := admin.NewBackupHandler(backupService, userService, imageStorageSettingService)
|
||||
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService)
|
||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService, rateLimitService)
|
||||
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
|
||||
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService)
|
||||
@@ -320,6 +322,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
|
||||
opsIngressRejectAggregator := service.ProvideOpsIngressRejectAggregator(opsRepository, opsService)
|
||||
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
||||
openAICodexVersionSyncService := service.ProvideOpenAICodexVersionSyncService(settingRepository, settingService, gitHubReleaseClient)
|
||||
proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository)
|
||||
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db)
|
||||
batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig)
|
||||
@@ -327,7 +330,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db)
|
||||
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
|
||||
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService)
|
||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService)
|
||||
application := &Application{
|
||||
Server: httpServer,
|
||||
PromptAudit: promptService,
|
||||
@@ -371,6 +374,7 @@ func provideCleanup(
|
||||
schedulerSnapshot *service.SchedulerSnapshotService,
|
||||
tokenRefresh *service.TokenRefreshService,
|
||||
accountExpiry *service.AccountExpiryService,
|
||||
codexVersionSync *service.OpenAICodexVersionSyncService,
|
||||
proxyExpiry *service.ProxyExpiryService,
|
||||
subscriptionExpiry *service.SubscriptionExpiryService,
|
||||
usageCleanup *service.UsageCleanupService,
|
||||
@@ -518,6 +522,10 @@ func provideCleanup(
|
||||
accountExpiry.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"OpenAICodexVersionSyncService", func() error {
|
||||
codexVersionSync.Stop()
|
||||
return nil
|
||||
}},
|
||||
{"ProxyExpiryService", func() error {
|
||||
proxyExpiry.Stop()
|
||||
return nil
|
||||
|
||||
@@ -40,6 +40,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
nil,
|
||||
)
|
||||
accountExpirySvc := service.NewAccountExpiryService(nil, time.Second)
|
||||
codexVersionSyncSvc := service.NewOpenAICodexVersionSyncService(nil, nil, nil, time.Second)
|
||||
proxyExpirySvc := service.NewProxyExpiryService(nil, time.Second)
|
||||
subscriptionExpirySvc := service.NewSubscriptionExpiryService(nil, time.Second)
|
||||
pricingSvc := service.NewPricingService(cfg, nil)
|
||||
@@ -65,6 +66,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
|
||||
schedulerSnapshotSvc,
|
||||
tokenRefreshSvc,
|
||||
accountExpirySvc,
|
||||
codexVersionSyncSvc,
|
||||
proxyExpirySvc,
|
||||
subscriptionExpirySvc,
|
||||
&service.UsageCleanupService{},
|
||||
|
||||
@@ -161,6 +161,8 @@ 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/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.3.52 // indirect
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.52 // 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
|
||||
|
||||
@@ -178,6 +178,8 @@ github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
@@ -232,6 +234,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
@@ -265,6 +269,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@@ -298,6 +304,8 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -330,6 +338,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
@@ -353,6 +363,10 @@ github.com/stripe/stripe-go/v85 v85.0.0 h1:HMlFJXW6I/9WvkeSAtj8V7dI5pzeDu4gS1Taq
|
||||
github.com/stripe/stripe-go/v85 v85.0.0/go.mod h1:5P+HGFenpWgak27T5Is6JMsmDfUC1yJnjhhmquz7kXw=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.3.52 h1:bPz4h9cPAD2psXNdVNHZUM/V13P05rtXoFIFn1IIPoA=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.3.52/go.mod h1:KDlcSxrt2pw9nenvXpw/VHipuokbA0j2iRXV+2gF5j8=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.52 h1:agyo5WB5bclK346U0Y4G40c//eA5qZbtBVGdp3HhuK8=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.52/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
|
||||
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=
|
||||
|
||||
@@ -32,7 +32,7 @@ const (
|
||||
|
||||
// DefaultCSPPolicy is the default Content-Security-Policy with nonce support
|
||||
// __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware
|
||||
const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
|
||||
// UMQ(用户消息队列)模式常量
|
||||
const (
|
||||
@@ -907,6 +907,18 @@ type GatewayConfig struct {
|
||||
// ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。
|
||||
// 用于网关未透传/改写 User-Agent 时的兼容兜底(默认关闭,避免影响其他客户端)。
|
||||
ForceCodexCLI bool `mapstructure:"force_codex_cli"`
|
||||
// DisableCodexIdentityEnforcement: 关闭「强制统一 Codex 出站身份」。上游 /backend-api/codex
|
||||
// 在容量紧张时按客户端身份分优先级降载,被降载的请求会拿到 HTTP 200 + 流内
|
||||
// server_is_overloaded,该次请求失败。默认强制统一出口:所有 OAuth 出站的
|
||||
// User-Agent / originator / version 都改写为网关规范身份,确保没有请求带着第三方或陈旧身份
|
||||
// 出站。置 true 后退回「仅按最终 User-Agent 配对 originator」的收口语义,供上游策略变动时回滚。
|
||||
//
|
||||
// 取反义命名是为了让零值安全:该开关会发布为进程级快照,未经 viper 加载而手工构造的
|
||||
// Config(测试、工具)其零值必须落在「强制统一开启」这一侧,否则会静默丢掉这层保护。
|
||||
DisableCodexIdentityEnforcement bool `mapstructure:"disable_codex_identity_enforcement"`
|
||||
// DisableCodexOriginatorNormalization: 已废弃,等价于 DisableCodexIdentityEnforcement。
|
||||
// 保留以兼容既有配置文件;加载时会折叠进新键,不要在新代码里直接读取。
|
||||
DisableCodexOriginatorNormalization bool `mapstructure:"disable_codex_originator_normalization"`
|
||||
// CodexImageGenerationBridgeEnabled: 是否为 Codex `/v1/responses` 自动注入 image_generation 工具和桥接指令。
|
||||
// 默认关闭,避免纯文本 Codex 请求被意外改写;显式携带 image_generation 工具的请求仍按分组能力转发。
|
||||
CodexImageGenerationBridgeEnabled bool `mapstructure:"codex_image_generation_bridge_enabled"`
|
||||
@@ -1766,6 +1778,13 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
|
||||
cfg.Gateway.ForcedCodexInstructionsTemplate = string(content)
|
||||
}
|
||||
|
||||
// 兼容旧键 gateway.disable_codex_originator_normalization:语义已被
|
||||
// disable_codex_identity_enforcement 取代(身份改写升级为强制统一出口),
|
||||
// 任一为 true 即关闭强制统一。
|
||||
if cfg.Gateway.DisableCodexOriginatorNormalization {
|
||||
cfg.Gateway.DisableCodexIdentityEnforcement = true
|
||||
}
|
||||
|
||||
// 兼容旧键 gateway.openai_ws.sticky_previous_response_ttl_seconds。
|
||||
// 新键未配置(<=0)时回退旧键;新键优先。
|
||||
if cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds <= 0 && cfg.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds > 0 {
|
||||
@@ -2217,6 +2236,8 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.max_account_switches", 10)
|
||||
viper.SetDefault("gateway.max_account_switches_gemini", 3)
|
||||
viper.SetDefault("gateway.force_codex_cli", false)
|
||||
viper.SetDefault("gateway.disable_codex_identity_enforcement", false)
|
||||
viper.SetDefault("gateway.disable_codex_originator_normalization", false)
|
||||
viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false)
|
||||
viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false)
|
||||
viper.SetDefault("gateway.openai_compact_model", "gpt-5.4")
|
||||
|
||||
@@ -144,7 +144,7 @@ func TestApplyOAuthCredentialsRejectsMalformedOpenAILongContextBillingBeforeMuta
|
||||
|
||||
func TestOpenAIOAuthCodexPATBoundaryRejectsMalformedOpenAILongContextBillingValueBeforeTokenValidation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil)
|
||||
handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
router.POST("/openai/create-from-codex-pat", handler.CreateAccountFromCodexPAT)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -17,7 +20,61 @@ import (
|
||||
type OpenAIOAuthHandler struct {
|
||||
openaiOAuthService *service.OpenAIOAuthService
|
||||
adminService service.AdminService
|
||||
quotaService *service.OpenAIQuotaService
|
||||
quotaService openAIQuotaService
|
||||
rateLimitService openAIAccountStateRecoverer
|
||||
}
|
||||
|
||||
type openAIQuotaService interface {
|
||||
QueryUsage(ctx context.Context, accountID int64) (*service.OpenAIQuotaUsage, error)
|
||||
CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *service.OpenAIRateLimitResetCredits) error
|
||||
ResetCredit(ctx context.Context, accountID int64) (*service.OpenAIQuotaResetResult, error)
|
||||
}
|
||||
|
||||
type openAIAccountStateRecoverer interface {
|
||||
RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error)
|
||||
}
|
||||
|
||||
const (
|
||||
openAIQuotaResetWarningCacheRefreshFailed = "reset_credit_cache_refresh_failed"
|
||||
openAIQuotaResetWarningAccountRecoveryFailed = "account_state_recovery_failed"
|
||||
openAIQuotaResetWarningAccountRefreshFailed = "account_state_refresh_failed"
|
||||
)
|
||||
|
||||
// openAIQuotaResetPostProcessTimeout bounds the work performed AFTER the
|
||||
// (non-refundable) reset credit has already been consumed upstream. The whole
|
||||
// request must stay comfortably inside the panel HTTP client timeout, otherwise
|
||||
// the browser aborts a mutation that already succeeded and the operator retries
|
||||
// it — spending a second credit.
|
||||
const openAIQuotaResetPostProcessTimeout = 8 * time.Second
|
||||
|
||||
type openAIQuotaResetResponse struct {
|
||||
service.OpenAIQuotaResetResult
|
||||
Quota *service.OpenAIQuotaUsage `json:"quota,omitempty"`
|
||||
Account *dto.Account `json:"account,omitempty"`
|
||||
CacheRefreshed bool `json:"cache_refreshed"`
|
||||
AccountStateRecovered bool `json:"account_state_recovered"`
|
||||
WarningCode string `json:"warning_code,omitempty"`
|
||||
}
|
||||
|
||||
// openAIQuotaRefreshResponse is the reset-credit-persisting variant of the quota
|
||||
// query. The usage payload is embedded so the shape stays identical to the plain
|
||||
// query; cache_persisted reports whether the snapshot write succeeded, because a
|
||||
// failed display-cache write must never discard a successful upstream read.
|
||||
type openAIQuotaRefreshResponse struct {
|
||||
service.OpenAIQuotaUsage
|
||||
CachePersisted bool `json:"cache_persisted"`
|
||||
}
|
||||
|
||||
// openAIQuotaResetPostProcessContext detaches the post-reset bookkeeping from the
|
||||
// client connection. The credit is already spent at that point, so account-state
|
||||
// recovery must complete even if the operator closes the tab (mirrors
|
||||
// systemUpdateContext, added for the same reason in #4504).
|
||||
func openAIQuotaResetPostProcessContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
base := context.Background()
|
||||
if ctx != nil {
|
||||
base = context.WithoutCancel(ctx)
|
||||
}
|
||||
return context.WithTimeout(base, openAIQuotaResetPostProcessTimeout)
|
||||
}
|
||||
|
||||
func oauthPlatformFromPath(c *gin.Context) string {
|
||||
@@ -29,12 +86,22 @@ func NewOpenAIOAuthHandler(
|
||||
openaiOAuthService *service.OpenAIOAuthService,
|
||||
adminService service.AdminService,
|
||||
quotaService *service.OpenAIQuotaService,
|
||||
rateLimitService *service.RateLimitService,
|
||||
) *OpenAIOAuthHandler {
|
||||
return &OpenAIOAuthHandler{
|
||||
h := &OpenAIOAuthHandler{
|
||||
openaiOAuthService: openaiOAuthService,
|
||||
adminService: adminService,
|
||||
quotaService: quotaService,
|
||||
}
|
||||
// Assign through explicit nil checks: storing a nil *Service in an interface
|
||||
// field yields a non-nil interface, which would silently defeat the
|
||||
// `== nil` capability guards below and panic instead of returning 400.
|
||||
if quotaService != nil {
|
||||
h.quotaService = quotaService
|
||||
}
|
||||
if rateLimitService != nil {
|
||||
h.rateLimitService = rateLimitService
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// OpenAIGenerateAuthURLRequest represents the request for generating OpenAI auth URL
|
||||
@@ -420,6 +487,7 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
|
||||
response.BadRequest(c, "openai quota service is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -428,6 +496,47 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
|
||||
response.Success(c, usage)
|
||||
}
|
||||
|
||||
// RefreshQuota queries the rate-limit / quota usage AND persists the reset-credit
|
||||
// snapshot so the card can be rehydrated without an upstream round-trip.
|
||||
// POST /api/v1/admin/openai/accounts/:id/quota/refresh
|
||||
//
|
||||
// It is a POST (not a GET with a side-effect flag) because it writes account
|
||||
// state: the audit middleware only records mutating verbs, so a persisting GET
|
||||
// would mutate the database without an audit trail.
|
||||
func (h *OpenAIOAuthHandler) RefreshQuota(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
if h.quotaService == nil {
|
||||
response.BadRequest(c, "openai quota service is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if usage == nil {
|
||||
response.Error(c, http.StatusInternalServerError, "openai quota query returned an empty result")
|
||||
return
|
||||
}
|
||||
|
||||
refreshResponse := openAIQuotaRefreshResponse{OpenAIQuotaUsage: *usage}
|
||||
// A failed snapshot write leaves the previous cache intact — report it as a
|
||||
// partial success instead of discarding the usage payload we just fetched,
|
||||
// which would leave the card without a credit count at all.
|
||||
if err := h.quotaService.CacheResetCreditsSnapshot(c.Request.Context(), accountID, usage.RateLimitResetCredits); err != nil {
|
||||
slog.Warn("openai_quota_reset_credit_cache_persist_failed", "account_id", accountID, "error", err)
|
||||
response.Success(c, refreshResponse)
|
||||
return
|
||||
}
|
||||
refreshResponse.CachePersisted = true
|
||||
response.Success(c, refreshResponse)
|
||||
}
|
||||
|
||||
// CreateShadowRequest is the request body for CreateShadow.
|
||||
type CreateShadowRequest struct {
|
||||
Name string `json:"name"`
|
||||
@@ -482,5 +591,63 @@ func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
if result == nil {
|
||||
response.Error(c, http.StatusInternalServerError, "openai quota reset returned an empty result")
|
||||
return
|
||||
}
|
||||
|
||||
resetResponse := openAIQuotaResetResponse{OpenAIQuotaResetResult: *result}
|
||||
postCtx, cancelPost := openAIQuotaResetPostProcessContext(c.Request.Context())
|
||||
defer cancelPost()
|
||||
|
||||
// Step 1 — unblocking the account is the whole point of consuming a credit
|
||||
// (#3672 / #3740), so it runs FIRST and is never gated on the display cache.
|
||||
// Recovery is DB-only and leaves the manual `schedulable` switch untouched.
|
||||
if h.rateLimitService == nil {
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed
|
||||
response.Success(c, resetResponse)
|
||||
return
|
||||
}
|
||||
if _, err := h.rateLimitService.RecoverAccountState(postCtx, accountID, service.AccountRecoveryOptions{
|
||||
InvalidateToken: true,
|
||||
}); err != nil {
|
||||
// Recovery failures are almost always storage-level; the remaining steps
|
||||
// share that dependency, so stop here instead of compounding the failure.
|
||||
slog.Warn("openai_quota_reset_account_recovery_failed", "account_id", accountID, "error", err)
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed
|
||||
response.Success(c, resetResponse)
|
||||
return
|
||||
}
|
||||
resetResponse.AccountStateRecovered = true
|
||||
|
||||
// Step 2 — refresh the reset-credit display cache. A failure here is reported
|
||||
// but must not hide the recovered account row produced by step 3.
|
||||
usage, usageErr := h.quotaService.QueryUsage(postCtx, accountID)
|
||||
switch {
|
||||
case usageErr != nil || usage == nil:
|
||||
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", usageErr)
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed
|
||||
default:
|
||||
if err := h.quotaService.CacheResetCreditsSnapshot(postCtx, accountID, usage.RateLimitResetCredits); err != nil {
|
||||
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", err)
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed
|
||||
} else {
|
||||
resetResponse.Quota = usage
|
||||
resetResponse.CacheRefreshed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 — hand back the post-recovery account row so the list drops the
|
||||
// stale rate-limit badge without waiting for the next poll.
|
||||
account, err := h.adminService.GetAccount(postCtx, accountID)
|
||||
if err != nil {
|
||||
slog.Warn("openai_quota_reset_account_refresh_failed", "account_id", accountID, "error", err)
|
||||
if resetResponse.WarningCode == "" {
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningAccountRefreshFailed
|
||||
}
|
||||
response.Success(c, resetResponse)
|
||||
return
|
||||
}
|
||||
resetResponse.Account = dto.AccountFromService(account)
|
||||
response.Success(c, resetResponse)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
type openAIQuotaWorkflowStub struct {
|
||||
resetResult *service.OpenAIQuotaResetResult
|
||||
resetErr error
|
||||
queryResult *service.OpenAIQuotaUsage
|
||||
queryErr error
|
||||
cacheErr error
|
||||
|
||||
resetCalls int
|
||||
queryCalls int
|
||||
cacheCalls int
|
||||
|
||||
queryCtxErr error
|
||||
cacheCtxErr error
|
||||
}
|
||||
|
||||
func (s *openAIQuotaWorkflowStub) ResetCredit(context.Context, int64) (*service.OpenAIQuotaResetResult, error) {
|
||||
s.resetCalls++
|
||||
return s.resetResult, s.resetErr
|
||||
}
|
||||
|
||||
func (s *openAIQuotaWorkflowStub) QueryUsage(ctx context.Context, _ int64) (*service.OpenAIQuotaUsage, error) {
|
||||
s.queryCalls++
|
||||
s.queryCtxErr = ctx.Err()
|
||||
return s.queryResult, s.queryErr
|
||||
}
|
||||
|
||||
func (s *openAIQuotaWorkflowStub) CacheResetCreditsSnapshot(ctx context.Context, _ int64, _ *service.OpenAIRateLimitResetCredits) error {
|
||||
s.cacheCalls++
|
||||
s.cacheCtxErr = ctx.Err()
|
||||
return s.cacheErr
|
||||
}
|
||||
|
||||
type openAIAccountStateRecovererStub struct {
|
||||
err error
|
||||
calls int
|
||||
accountID int64
|
||||
lastOptions service.AccountRecoveryOptions
|
||||
lastCtxErr error
|
||||
}
|
||||
|
||||
func (s *openAIAccountStateRecovererStub) RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error) {
|
||||
s.calls++
|
||||
s.accountID = accountID
|
||||
s.lastOptions = options
|
||||
s.lastCtxErr = ctx.Err()
|
||||
return &service.SuccessfulTestRecoveryResult{}, s.err
|
||||
}
|
||||
|
||||
type openAIResetAdminServiceStub struct {
|
||||
service.AdminService
|
||||
account *service.Account
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *openAIResetAdminServiceStub) GetAccount(context.Context, int64) (*service.Account, error) {
|
||||
s.calls++
|
||||
return s.account, s.err
|
||||
}
|
||||
|
||||
type openAIQuotaResetEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Data openAIQuotaResetResponse `json:"data"`
|
||||
}
|
||||
|
||||
type openAIQuotaRefreshEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Data openAIQuotaRefreshResponse `json:"data"`
|
||||
}
|
||||
|
||||
func performOpenAIQuotaResetRequest(t *testing.T, handler *OpenAIOAuthHandler) (int, openAIQuotaResetEnvelope) {
|
||||
t.Helper()
|
||||
return performOpenAIQuotaResetRequestWithContext(t, handler, nil)
|
||||
}
|
||||
|
||||
// performOpenAIQuotaResetRequestWithContext drives the reset endpoint, optionally
|
||||
// with an already-canceled request context (client disconnect simulation).
|
||||
func performOpenAIQuotaResetRequestWithContext(t *testing.T, handler *OpenAIOAuthHandler, ctx context.Context) (int, openAIQuotaResetEnvelope) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/openai/accounts/:id/reset-quota", handler.ResetQuota)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/openai/accounts/42/reset-quota", nil)
|
||||
if ctx != nil {
|
||||
request = request.WithContext(ctx)
|
||||
}
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
var envelope openAIQuotaResetEnvelope
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &envelope))
|
||||
return recorder.Code, envelope
|
||||
}
|
||||
|
||||
func performOpenAIQuotaRefreshRequest(t *testing.T, handler *OpenAIOAuthHandler) (int, openAIQuotaRefreshEnvelope) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/openai/accounts/:id/quota/refresh", handler.RefreshQuota)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/openai/accounts/42/quota/refresh", nil)
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
var envelope openAIQuotaRefreshEnvelope
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &envelope))
|
||||
return recorder.Code, envelope
|
||||
}
|
||||
|
||||
func successfulOpenAIQuotaWorkflowStub() *openAIQuotaWorkflowStub {
|
||||
return &openAIQuotaWorkflowStub{
|
||||
resetResult: &service.OpenAIQuotaResetResult{
|
||||
Code: "success",
|
||||
WindowsReset: 1,
|
||||
},
|
||||
queryResult: &service.OpenAIQuotaUsage{
|
||||
FetchedAt: 123,
|
||||
RateLimitResetCredits: &service.OpenAIRateLimitResetCredits{
|
||||
AvailableCount: 0,
|
||||
Credits: []service.OpenAIRateLimitResetCreditDetail{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func recoveredAccountStub() *openAIResetAdminServiceStub {
|
||||
return &openAIResetAdminServiceStub{account: &service.Account{
|
||||
ID: 42,
|
||||
Name: "recovered",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: false,
|
||||
}}
|
||||
}
|
||||
|
||||
func TestOpenAIResetQuota_ResetFailureStopsWorkflow(t *testing.T) {
|
||||
quota := &openAIQuotaWorkflowStub{resetErr: errors.New("upstream reset failed")}
|
||||
recoverer := &openAIAccountStateRecovererStub{}
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: &openAIResetAdminServiceStub{},
|
||||
quotaService: quota,
|
||||
rateLimitService: recoverer,
|
||||
}
|
||||
|
||||
status, _ := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, status)
|
||||
require.Equal(t, 1, quota.resetCalls)
|
||||
require.Zero(t, quota.queryCalls)
|
||||
require.Zero(t, quota.cacheCalls)
|
||||
require.Zero(t, recoverer.calls)
|
||||
}
|
||||
|
||||
// Account-state recovery is the reason the credit was spent (#3672 / #3740), so it
|
||||
// must run before — and independently of — the reset-credit display cache.
|
||||
func TestOpenAIResetQuota_RecoversAccountStateBeforeRefreshingCache(t *testing.T) {
|
||||
quota := successfulOpenAIQuotaWorkflowStub()
|
||||
recoverer := &openAIAccountStateRecovererStub{}
|
||||
adminService := recoveredAccountStub()
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: adminService,
|
||||
quotaService: quota,
|
||||
rateLimitService: recoverer,
|
||||
}
|
||||
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Empty(t, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.True(t, envelope.Data.CacheRefreshed)
|
||||
require.NotNil(t, envelope.Data.Quota)
|
||||
require.NotNil(t, envelope.Data.Account)
|
||||
require.Equal(t, int64(42), envelope.Data.Account.ID)
|
||||
require.False(t, envelope.Data.Account.Schedulable, "manual scheduling switch must not be flipped")
|
||||
require.Equal(t, int64(42), recoverer.accountID)
|
||||
require.True(t, recoverer.lastOptions.InvalidateToken)
|
||||
require.Equal(t, 1, quota.resetCalls)
|
||||
require.Equal(t, 1, quota.queryCalls)
|
||||
require.Equal(t, 1, quota.cacheCalls)
|
||||
require.Equal(t, 1, recoverer.calls)
|
||||
require.Equal(t, 1, adminService.calls)
|
||||
}
|
||||
|
||||
func TestOpenAIResetQuota_RecoveryFailureStopsWorkflow(t *testing.T) {
|
||||
quota := successfulOpenAIQuotaWorkflowStub()
|
||||
recoverer := &openAIAccountStateRecovererStub{err: errors.New("recovery failed")}
|
||||
adminService := recoveredAccountStub()
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: adminService,
|
||||
quotaService: quota,
|
||||
rateLimitService: recoverer,
|
||||
}
|
||||
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
|
||||
require.False(t, envelope.Data.AccountStateRecovered)
|
||||
require.False(t, envelope.Data.CacheRefreshed)
|
||||
require.Nil(t, envelope.Data.Quota)
|
||||
require.Nil(t, envelope.Data.Account)
|
||||
require.Equal(t, 1, recoverer.calls)
|
||||
require.Zero(t, quota.queryCalls)
|
||||
require.Zero(t, quota.cacheCalls)
|
||||
require.Zero(t, adminService.calls)
|
||||
}
|
||||
|
||||
func TestOpenAIResetQuota_MissingRecovererReportsRecoveryFailure(t *testing.T) {
|
||||
quota := successfulOpenAIQuotaWorkflowStub()
|
||||
adminService := recoveredAccountStub()
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: adminService,
|
||||
quotaService: quota,
|
||||
}
|
||||
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
|
||||
require.False(t, envelope.Data.AccountStateRecovered)
|
||||
require.Zero(t, quota.queryCalls)
|
||||
require.Zero(t, adminService.calls)
|
||||
}
|
||||
|
||||
// A failed cache refresh must not hide the recovered account row: the operator
|
||||
// still needs the list to drop the stale rate-limit badge.
|
||||
func TestOpenAIResetQuota_QueryFailureStillRecoversAndReturnsAccount(t *testing.T) {
|
||||
quota := successfulOpenAIQuotaWorkflowStub()
|
||||
quota.queryResult = nil
|
||||
quota.queryErr = errors.New("upstream query failed")
|
||||
recoverer := &openAIAccountStateRecovererStub{}
|
||||
adminService := recoveredAccountStub()
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: adminService,
|
||||
quotaService: quota,
|
||||
rateLimitService: recoverer,
|
||||
}
|
||||
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.False(t, envelope.Data.CacheRefreshed)
|
||||
require.Nil(t, envelope.Data.Quota)
|
||||
require.NotNil(t, envelope.Data.Account)
|
||||
require.Equal(t, 1, quota.queryCalls)
|
||||
require.Zero(t, quota.cacheCalls)
|
||||
require.Equal(t, 1, recoverer.calls)
|
||||
require.Equal(t, 1, adminService.calls)
|
||||
}
|
||||
|
||||
func TestOpenAIResetQuota_CacheFailureStillRecoversAndReturnsAccount(t *testing.T) {
|
||||
quota := successfulOpenAIQuotaWorkflowStub()
|
||||
quota.cacheErr = errors.New("cache write failed")
|
||||
recoverer := &openAIAccountStateRecovererStub{}
|
||||
adminService := recoveredAccountStub()
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: adminService,
|
||||
quotaService: quota,
|
||||
rateLimitService: recoverer,
|
||||
}
|
||||
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.False(t, envelope.Data.CacheRefreshed)
|
||||
require.Nil(t, envelope.Data.Quota)
|
||||
require.NotNil(t, envelope.Data.Account)
|
||||
require.Equal(t, 1, quota.cacheCalls)
|
||||
require.Equal(t, 1, adminService.calls)
|
||||
}
|
||||
|
||||
func TestOpenAIResetQuota_AccountRefreshFailureReportsRecoveredState(t *testing.T) {
|
||||
quota := successfulOpenAIQuotaWorkflowStub()
|
||||
recoverer := &openAIAccountStateRecovererStub{}
|
||||
adminService := &openAIResetAdminServiceStub{err: errors.New("account refresh failed")}
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: adminService,
|
||||
quotaService: quota,
|
||||
rateLimitService: recoverer,
|
||||
}
|
||||
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.CacheRefreshed)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.NotNil(t, envelope.Data.Quota)
|
||||
require.Nil(t, envelope.Data.Account)
|
||||
require.Equal(t, 1, adminService.calls)
|
||||
}
|
||||
|
||||
// The first (most actionable) failure wins so the UI never downgrades a cache
|
||||
// problem into a cosmetic "could not reload the row" message.
|
||||
func TestOpenAIResetQuota_CacheAndAccountFailureKeepsFirstWarning(t *testing.T) {
|
||||
quota := successfulOpenAIQuotaWorkflowStub()
|
||||
quota.cacheErr = errors.New("cache write failed")
|
||||
recoverer := &openAIAccountStateRecovererStub{}
|
||||
adminService := &openAIResetAdminServiceStub{err: errors.New("account refresh failed")}
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: adminService,
|
||||
quotaService: quota,
|
||||
rateLimitService: recoverer,
|
||||
}
|
||||
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.Nil(t, envelope.Data.Account)
|
||||
}
|
||||
|
||||
// The credit is non-refundable once consumed, so post-reset bookkeeping must
|
||||
// survive a client disconnect instead of leaving the account rate-limited.
|
||||
func TestOpenAIResetQuota_PostProcessingSurvivesClientCancellation(t *testing.T) {
|
||||
quota := successfulOpenAIQuotaWorkflowStub()
|
||||
recoverer := &openAIAccountStateRecovererStub{}
|
||||
adminService := recoveredAccountStub()
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: adminService,
|
||||
quotaService: quota,
|
||||
rateLimitService: recoverer,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
status, _ := performOpenAIQuotaResetRequestWithContext(t, handler, ctx)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, 1, recoverer.calls)
|
||||
require.NoError(t, recoverer.lastCtxErr, "recovery must not inherit the canceled client context")
|
||||
require.NoError(t, quota.queryCtxErr)
|
||||
require.NoError(t, quota.cacheCtxErr)
|
||||
require.Equal(t, 1, adminService.calls)
|
||||
}
|
||||
|
||||
func TestOpenAIRefreshQuota_PersistsSnapshot(t *testing.T) {
|
||||
quota := successfulOpenAIQuotaWorkflowStub()
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: &openAIResetAdminServiceStub{},
|
||||
quotaService: quota,
|
||||
}
|
||||
|
||||
status, envelope := performOpenAIQuotaRefreshRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.True(t, envelope.Data.CachePersisted)
|
||||
require.Equal(t, int64(123), envelope.Data.FetchedAt)
|
||||
require.Equal(t, 1, quota.queryCalls)
|
||||
require.Equal(t, 1, quota.cacheCalls)
|
||||
require.Zero(t, quota.resetCalls)
|
||||
}
|
||||
|
||||
// A rejected snapshot write must never discard the usage payload: otherwise the
|
||||
// card loses its credit count and the reset button stays disabled forever.
|
||||
func TestOpenAIRefreshQuota_PersistFailureStillReturnsUsage(t *testing.T) {
|
||||
quota := successfulOpenAIQuotaWorkflowStub()
|
||||
quota.queryResult = &service.OpenAIQuotaUsage{
|
||||
FetchedAt: 456,
|
||||
RateLimitResetCredits: &service.OpenAIRateLimitResetCredits{
|
||||
AvailableCount: 2,
|
||||
},
|
||||
}
|
||||
quota.cacheErr = errors.New("expiration details unavailable")
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: &openAIResetAdminServiceStub{},
|
||||
quotaService: quota,
|
||||
}
|
||||
|
||||
status, envelope := performOpenAIQuotaRefreshRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.False(t, envelope.Data.CachePersisted)
|
||||
require.Equal(t, int64(456), envelope.Data.FetchedAt)
|
||||
require.NotNil(t, envelope.Data.RateLimitResetCredits)
|
||||
require.Equal(t, 2, envelope.Data.RateLimitResetCredits.AvailableCount)
|
||||
require.Equal(t, 1, quota.cacheCalls)
|
||||
}
|
||||
|
||||
// An empty-but-successful upstream read must not be dereferenced blindly.
|
||||
func TestOpenAIQuotaEmptyUsageIsHandledWithoutPanic(t *testing.T) {
|
||||
t.Run("refresh reports an internal error", func(t *testing.T) {
|
||||
quota := successfulOpenAIQuotaWorkflowStub()
|
||||
quota.queryResult = nil
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: &openAIResetAdminServiceStub{},
|
||||
quotaService: quota,
|
||||
}
|
||||
|
||||
status, envelope := performOpenAIQuotaRefreshRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, status)
|
||||
require.False(t, envelope.Data.CachePersisted)
|
||||
require.Zero(t, quota.cacheCalls)
|
||||
})
|
||||
|
||||
t.Run("reset degrades to a cache warning", func(t *testing.T) {
|
||||
quota := successfulOpenAIQuotaWorkflowStub()
|
||||
quota.queryResult = nil
|
||||
recoverer := &openAIAccountStateRecovererStub{}
|
||||
adminService := recoveredAccountStub()
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: adminService,
|
||||
quotaService: quota,
|
||||
rateLimitService: recoverer,
|
||||
}
|
||||
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.NotNil(t, envelope.Data.Account)
|
||||
require.Zero(t, quota.cacheCalls)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAIRefreshQuota_QueryFailureIsReported(t *testing.T) {
|
||||
quota := &openAIQuotaWorkflowStub{queryErr: errors.New("upstream query failed")}
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: &openAIResetAdminServiceStub{},
|
||||
quotaService: quota,
|
||||
}
|
||||
|
||||
status, _ := performOpenAIQuotaRefreshRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, status)
|
||||
require.Equal(t, 1, quota.queryCalls)
|
||||
require.Zero(t, quota.cacheCalls)
|
||||
}
|
||||
|
||||
// Storing a nil *Service in an interface field would make the capability guards
|
||||
// non-nil and panic on the first call; the constructor must keep them nil.
|
||||
func TestNewOpenAIOAuthHandlerKeepsNilQuotaCapabilitiesGuarded(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil, nil)
|
||||
require.Nil(t, handler.quotaService)
|
||||
require.Nil(t, handler.rateLimitService)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
router.GET("/openai/accounts/:id/quota", handler.QueryQuota)
|
||||
router.POST("/openai/accounts/:id/quota/refresh", handler.RefreshQuota)
|
||||
router.POST("/openai/accounts/:id/reset-quota", handler.ResetQuota)
|
||||
|
||||
for _, tc := range []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/openai/accounts/42/quota"},
|
||||
{http.MethodPost, "/openai/accounts/42/quota/refresh"},
|
||||
{http.MethodPost, "/openai/accounts/42/reset-quota"},
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(tc.method, tc.path, nil))
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code, "%s %s", tc.method, tc.path)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func TestCreateShadow_ReturnsCreatedShadow(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
stub := &stubAdminService{}
|
||||
h := NewOpenAIOAuthHandler(nil, stub, nil)
|
||||
h := NewOpenAIOAuthHandler(nil, stub, nil, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
|
||||
@@ -54,7 +54,7 @@ func TestCreateShadow_ReturnsCreatedShadow(t *testing.T) {
|
||||
func TestCreateShadow_InvalidID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil)
|
||||
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
|
||||
@@ -72,7 +72,7 @@ func TestCreateShadow_ServiceError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
stub := &stubAdminService{createSparkShadowErr: errors.New("database unavailable")}
|
||||
h := NewOpenAIOAuthHandler(nil, stub, nil)
|
||||
h := NewOpenAIOAuthHandler(nil, stub, nil, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
|
||||
@@ -91,7 +91,7 @@ func TestCreateShadow_ServiceError(t *testing.T) {
|
||||
func TestCreateShadow_BadBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil)
|
||||
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
|
||||
|
||||
@@ -156,6 +156,11 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
TurnstileEnabled: settings.TurnstileEnabled,
|
||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||
TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured,
|
||||
TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
|
||||
TencentCaptchaAppID: settings.TencentCaptchaAppID,
|
||||
TencentCaptchaAppSecretKeyConfigured: settings.TencentCaptchaAppSecretKeyConfigured,
|
||||
TencentCaptchaCloudSecretIDConfigured: settings.TencentCaptchaCloudSecretIDConfigured,
|
||||
TencentCaptchaCloudSecretKeyConfigured: settings.TencentCaptchaCloudSecretKeyConfigured,
|
||||
APIKeyACLTrustForwardedIP: settings.APIKeyACLTrustForwardedIP,
|
||||
ForwardedClientIPHeaders: settings.ForwardedClientIPHeaders,
|
||||
LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled,
|
||||
@@ -279,6 +284,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
EnableClientDatelineNormalization: settings.EnableClientDatelineNormalization,
|
||||
AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion,
|
||||
OpenAICodexUserAgent: settings.OpenAICodexUserAgent,
|
||||
OpenAICodexClientVersion: settings.OpenAICodexClientVersion,
|
||||
OpenAICodexClientVersionSynced: settings.OpenAICodexClientVersionSynced,
|
||||
OpenAICodexVersionAutoSyncEnabled: settings.OpenAICodexVersionAutoSyncEnabled,
|
||||
MinCodexVersion: settings.MinCodexVersion,
|
||||
MaxCodexVersion: settings.MaxCodexVersion,
|
||||
CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist,
|
||||
|
||||
@@ -107,6 +107,21 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if req.TurnstileSecretKey != "" {
|
||||
changed = append(changed, "turnstile_secret_key")
|
||||
}
|
||||
if before.TencentCaptchaEnabled != after.TencentCaptchaEnabled {
|
||||
changed = append(changed, "tencent_captcha_enabled")
|
||||
}
|
||||
if before.TencentCaptchaAppID != after.TencentCaptchaAppID {
|
||||
changed = append(changed, "tencent_captcha_app_id")
|
||||
}
|
||||
if req.TencentCaptchaAppSecretKey != "" {
|
||||
changed = append(changed, "tencent_captcha_app_secret_key")
|
||||
}
|
||||
if req.TencentCaptchaCloudSecretID != "" {
|
||||
changed = append(changed, "tencent_captcha_cloud_secret_id")
|
||||
}
|
||||
if req.TencentCaptchaCloudSecretKey != "" {
|
||||
changed = append(changed, "tencent_captcha_cloud_secret_key")
|
||||
}
|
||||
if before.APIKeyACLTrustForwardedIP != after.APIKeyACLTrustForwardedIP {
|
||||
changed = append(changed, "api_key_acl_trust_forwarded_ip")
|
||||
}
|
||||
@@ -443,6 +458,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.OpenAICodexUserAgent != after.OpenAICodexUserAgent {
|
||||
changed = append(changed, "openai_codex_user_agent")
|
||||
}
|
||||
if before.OpenAICodexClientVersion != after.OpenAICodexClientVersion {
|
||||
changed = append(changed, "openai_codex_client_version")
|
||||
}
|
||||
if before.OpenAICodexVersionAutoSyncEnabled != after.OpenAICodexVersionAutoSyncEnabled {
|
||||
changed = append(changed, "openai_codex_version_auto_sync_enabled")
|
||||
}
|
||||
if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource {
|
||||
changed = append(changed, "payment_visible_method_alipay_source")
|
||||
}
|
||||
|
||||
@@ -65,3 +65,75 @@ func TestUpdateSettingsSMTPFromAliasIsWritable(t *testing.T) {
|
||||
|
||||
require.Equal(t, "new@example.com", repo.values[service.SettingKeySMTPFrom])
|
||||
}
|
||||
|
||||
func TestUpdateSettingsRejectsTwoCaptchaProviders(t *testing.T) {
|
||||
h, _ := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeyTurnstileEnabled: "true",
|
||||
service.SettingKeyTurnstileSiteKey: "site-key",
|
||||
service.SettingKeyTurnstileSecretKey: "turnstile-secret",
|
||||
})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{
|
||||
"turnstile_enabled": true,
|
||||
"turnstile_site_key": "site-key",
|
||||
"turnstile_secret_key": "turnstile-secret",
|
||||
"tencent_captcha_enabled": true,
|
||||
"tencent_captcha_app_id": "123456789",
|
||||
"tencent_captcha_app_secret_key": "app-secret",
|
||||
"tencent_captcha_cloud_secret_id": "cloud-secret-id",
|
||||
"tencent_captcha_cloud_secret_key": "cloud-secret-key",
|
||||
}, nil)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "cannot be enabled at the same time")
|
||||
}
|
||||
|
||||
func TestUpdateSettingsRequiresFourTencentCaptchaCredentialsWhenEnabled(t *testing.T) {
|
||||
h, _ := newStepUpSwitchTestHandler(t, map[string]string{})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{
|
||||
"tencent_captcha_enabled": true,
|
||||
"tencent_captcha_app_id": "123456789",
|
||||
}, nil)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "AppSecretKey")
|
||||
}
|
||||
|
||||
func TestUpdateSettingsRetainsStoredTencentCaptchaCredentialsWhenInputsEmpty(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeyTencentCaptchaAppSecretKey: "stored-app-secret",
|
||||
service.SettingKeyTencentCaptchaCloudSecretID: "stored-cloud-secret-id",
|
||||
service.SettingKeyTencentCaptchaCloudSecretKey: "stored-cloud-secret-key",
|
||||
})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{
|
||||
"tencent_captcha_enabled": true,
|
||||
"tencent_captcha_app_id": "123456789",
|
||||
"tencent_captcha_app_secret_key": "",
|
||||
"tencent_captcha_cloud_secret_id": "",
|
||||
"tencent_captcha_cloud_secret_key": "",
|
||||
}, nil)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "stored-app-secret", repo.values[service.SettingKeyTencentCaptchaAppSecretKey])
|
||||
require.Equal(t, "stored-cloud-secret-id", repo.values[service.SettingKeyTencentCaptchaCloudSecretID])
|
||||
require.Equal(t, "stored-cloud-secret-key", repo.values[service.SettingKeyTencentCaptchaCloudSecretKey])
|
||||
}
|
||||
|
||||
func TestUpdateSettingsValidatesTencentCaptchaAppIDWhenEnabledFlagIsOmitted(t *testing.T) {
|
||||
h, _ := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeyTencentCaptchaEnabled: "true",
|
||||
service.SettingKeyTencentCaptchaAppID: "123456789",
|
||||
service.SettingKeyTencentCaptchaAppSecretKey: "stored-app-secret",
|
||||
service.SettingKeyTencentCaptchaCloudSecretID: "stored-cloud-secret-id",
|
||||
service.SettingKeyTencentCaptchaCloudSecretKey: "stored-cloud-secret-key",
|
||||
})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{
|
||||
"tencent_captcha_app_id": "not-a-number",
|
||||
}, nil)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "positive integer")
|
||||
}
|
||||
|
||||
@@ -64,6 +64,23 @@ func TestDiffSettings_NoChangeWhenEqual(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsAuditRequestDoesNotInheritStoredTencentSecrets(t *testing.T) {
|
||||
req := UpdateSettingsRequest{
|
||||
TencentCaptchaAppSecretKey: " ",
|
||||
TencentCaptchaCloudSecretID: "\t",
|
||||
TencentCaptchaCloudSecretKey: "\n",
|
||||
}
|
||||
|
||||
auditReq := settingsAuditRequest(req)
|
||||
req.TencentCaptchaAppSecretKey = "stored-app-secret"
|
||||
req.TencentCaptchaCloudSecretID = "stored-secret-id"
|
||||
req.TencentCaptchaCloudSecretKey = "stored-secret-key"
|
||||
|
||||
require.Empty(t, auditReq.TencentCaptchaAppSecretKey)
|
||||
require.Empty(t, auditReq.TencentCaptchaCloudSecretID)
|
||||
require.Empty(t, auditReq.TencentCaptchaCloudSecretKey)
|
||||
}
|
||||
|
||||
func TestDiffSettings_DetectsCompactHomeChange(t *testing.T) {
|
||||
changed := diffSettings(
|
||||
&service.SystemSettings{},
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
@@ -53,6 +54,13 @@ type UpdateSettingsRequest struct {
|
||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||
TurnstileSecretKey string `json:"turnstile_secret_key"`
|
||||
|
||||
// 腾讯天御验证码设置
|
||||
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
|
||||
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
|
||||
TencentCaptchaAppSecretKey string `json:"tencent_captcha_app_secret_key"`
|
||||
TencentCaptchaCloudSecretID string `json:"tencent_captcha_cloud_secret_id"`
|
||||
TencentCaptchaCloudSecretKey string `json:"tencent_captcha_cloud_secret_key"`
|
||||
|
||||
// API Key IP 访问控制设置
|
||||
APIKeyACLTrustForwardedIP *bool `json:"api_key_acl_trust_forwarded_ip"`
|
||||
ForwardedClientIPHeaders *[]string `json:"forwarded_client_ip_headers"`
|
||||
@@ -236,6 +244,8 @@ type UpdateSettingsRequest struct {
|
||||
EnableClientDatelineNormalization *bool `json:"enable_client_dateline_normalization"`
|
||||
AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"`
|
||||
OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
|
||||
OpenAICodexClientVersion *string `json:"openai_codex_client_version"`
|
||||
OpenAICodexVersionAutoSyncEnabled *bool `json:"openai_codex_version_auto_sync_enabled"`
|
||||
|
||||
// codex_cli_only 加固(global-only)
|
||||
MinCodexVersion string `json:"min_codex_version"`
|
||||
@@ -436,6 +446,13 @@ func omittedSettingKeys(sentFields map[string]json.RawMessage) service.OmittedSe
|
||||
return omitted
|
||||
}
|
||||
|
||||
func settingsAuditRequest(req UpdateSettingsRequest) UpdateSettingsRequest {
|
||||
req.TencentCaptchaAppSecretKey = strings.TrimSpace(req.TencentCaptchaAppSecretKey)
|
||||
req.TencentCaptchaCloudSecretID = strings.TrimSpace(req.TencentCaptchaCloudSecretID)
|
||||
req.TencentCaptchaCloudSecretKey = strings.TrimSpace(req.TencentCaptchaCloudSecretKey)
|
||||
return req
|
||||
}
|
||||
|
||||
func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
var sentFields map[string]json.RawMessage
|
||||
if err := c.ShouldBindBodyWith(&sentFields, binding.JSON); err != nil {
|
||||
@@ -447,6 +464,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
auditReq := settingsAuditRequest(req)
|
||||
omitted := omittedSettingKeys(sentFields)
|
||||
|
||||
previousSettings, err := h.settingService.GetAllSettings(c.Request.Context())
|
||||
@@ -563,6 +581,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
req.SMTPPassword = strings.TrimSpace(req.SMTPPassword)
|
||||
req.SMTPFrom = strings.TrimSpace(req.SMTPFrom)
|
||||
req.SMTPFromName = strings.TrimSpace(req.SMTPFromName)
|
||||
req.TencentCaptchaAppID = strings.TrimSpace(req.TencentCaptchaAppID)
|
||||
req.TencentCaptchaAppSecretKey = strings.TrimSpace(req.TencentCaptchaAppSecretKey)
|
||||
req.TencentCaptchaCloudSecretID = strings.TrimSpace(req.TencentCaptchaCloudSecretID)
|
||||
req.TencentCaptchaCloudSecretKey = strings.TrimSpace(req.TencentCaptchaCloudSecretKey)
|
||||
if req.SMTPPort <= 0 {
|
||||
req.SMTPPort = 587
|
||||
}
|
||||
@@ -584,6 +606,19 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
req.SMTPUseTLS = previousSettings.SMTPUseTLS
|
||||
}
|
||||
|
||||
turnstileEnabled := req.TurnstileEnabled
|
||||
if _, sent := sentFields["turnstile_enabled"]; !sent {
|
||||
turnstileEnabled = previousSettings.TurnstileEnabled
|
||||
}
|
||||
tencentCaptchaEnabled := req.TencentCaptchaEnabled
|
||||
if _, sent := sentFields["tencent_captcha_enabled"]; !sent {
|
||||
tencentCaptchaEnabled = previousSettings.TencentCaptchaEnabled
|
||||
}
|
||||
if turnstileEnabled && tencentCaptchaEnabled {
|
||||
response.BadRequest(c, "Cloudflare Turnstile and Tencent Captcha cannot be enabled at the same time")
|
||||
return
|
||||
}
|
||||
|
||||
// Turnstile 参数验证
|
||||
if req.TurnstileEnabled {
|
||||
// 检查必填字段
|
||||
@@ -611,6 +646,38 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if tencentCaptchaEnabled {
|
||||
if _, sent := sentFields["tencent_captcha_app_id"]; !sent {
|
||||
req.TencentCaptchaAppID = previousSettings.TencentCaptchaAppID
|
||||
}
|
||||
appID, err := strconv.ParseUint(req.TencentCaptchaAppID, 10, 64)
|
||||
if err != nil || appID == 0 {
|
||||
response.BadRequest(c, "Tencent Captcha CaptchaAppId must be a positive integer when enabled")
|
||||
return
|
||||
}
|
||||
if req.TencentCaptchaAppSecretKey == "" {
|
||||
req.TencentCaptchaAppSecretKey = previousSettings.TencentCaptchaAppSecretKey
|
||||
}
|
||||
if req.TencentCaptchaCloudSecretID == "" {
|
||||
req.TencentCaptchaCloudSecretID = previousSettings.TencentCaptchaCloudSecretID
|
||||
}
|
||||
if req.TencentCaptchaCloudSecretKey == "" {
|
||||
req.TencentCaptchaCloudSecretKey = previousSettings.TencentCaptchaCloudSecretKey
|
||||
}
|
||||
if req.TencentCaptchaAppSecretKey == "" {
|
||||
response.BadRequest(c, "Tencent Captcha AppSecretKey is required when enabled")
|
||||
return
|
||||
}
|
||||
if req.TencentCaptchaCloudSecretID == "" {
|
||||
response.BadRequest(c, "Tencent Cloud SecretId is required when Tencent Captcha is enabled")
|
||||
return
|
||||
}
|
||||
if req.TencentCaptchaCloudSecretKey == "" {
|
||||
response.BadRequest(c, "Tencent Cloud SecretKey is required when Tencent Captcha is enabled")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// TOTP 双因素认证参数验证
|
||||
// 只有手动配置了加密密钥才允许启用 TOTP 功能
|
||||
if req.TotpEnabled && !previousSettings.TotpEnabled {
|
||||
@@ -1276,6 +1343,15 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.OpenAICodexClientVersion != nil {
|
||||
// 该值会被拼进出站 User-Agent 与 version 头,必须是合法版本号;空串表示跟随自动同步。
|
||||
normalized := strings.TrimSpace(*req.OpenAICodexClientVersion)
|
||||
if normalized != "" && service.NormalizeCodexClientVersion(normalized) == "" {
|
||||
response.Error(c, http.StatusBadRequest, "openai_codex_client_version must be empty or a valid version (e.g. 0.146.0)")
|
||||
return
|
||||
}
|
||||
req.OpenAICodexClientVersion = &normalized
|
||||
}
|
||||
|
||||
// codex_cli_only 加固:最低/最高 Codex 版本(空=禁用,或合法 semver;max>=min)
|
||||
if req.MinCodexVersion != "" && !semverPattern.MatchString(req.MinCodexVersion) {
|
||||
@@ -1349,6 +1425,11 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
TurnstileEnabled: req.TurnstileEnabled,
|
||||
TurnstileSiteKey: req.TurnstileSiteKey,
|
||||
TurnstileSecretKey: req.TurnstileSecretKey,
|
||||
TencentCaptchaEnabled: req.TencentCaptchaEnabled,
|
||||
TencentCaptchaAppID: req.TencentCaptchaAppID,
|
||||
TencentCaptchaAppSecretKey: req.TencentCaptchaAppSecretKey,
|
||||
TencentCaptchaCloudSecretID: req.TencentCaptchaCloudSecretID,
|
||||
TencentCaptchaCloudSecretKey: req.TencentCaptchaCloudSecretKey,
|
||||
APIKeyACLTrustForwardedIP: func() bool {
|
||||
if req.APIKeyACLTrustForwardedIP != nil {
|
||||
return *req.APIKeyACLTrustForwardedIP
|
||||
@@ -1555,6 +1636,20 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.OpenAICodexUserAgent
|
||||
}(),
|
||||
OpenAICodexClientVersion: func() string {
|
||||
if req.OpenAICodexClientVersion != nil {
|
||||
return *req.OpenAICodexClientVersion
|
||||
}
|
||||
return previousSettings.OpenAICodexClientVersion
|
||||
}(),
|
||||
// 同步值由自动同步任务独占写入,面板保存时原样带回,避免被清空。
|
||||
OpenAICodexClientVersionSynced: previousSettings.OpenAICodexClientVersionSynced,
|
||||
OpenAICodexVersionAutoSyncEnabled: func() bool {
|
||||
if req.OpenAICodexVersionAutoSyncEnabled != nil {
|
||||
return *req.OpenAICodexVersionAutoSyncEnabled
|
||||
}
|
||||
return previousSettings.OpenAICodexVersionAutoSyncEnabled
|
||||
}(),
|
||||
MinCodexVersion: strings.TrimSpace(req.MinCodexVersion),
|
||||
MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion),
|
||||
CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist),
|
||||
@@ -1844,7 +1939,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
h.auditSettingsUpdate(c, previousSettings, settings, previousAuthSourceDefaults, authSourceDefaults, req)
|
||||
h.auditSettingsUpdate(c, previousSettings, settings, previousAuthSourceDefaults, authSourceDefaults, auditReq)
|
||||
|
||||
// 重新获取设置返回
|
||||
updatedSettings, err := h.settingService.GetAllSettings(c.Request.Context())
|
||||
@@ -1907,6 +2002,11 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
TurnstileEnabled: updatedSettings.TurnstileEnabled,
|
||||
TurnstileSiteKey: updatedSettings.TurnstileSiteKey,
|
||||
TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured,
|
||||
TencentCaptchaEnabled: updatedSettings.TencentCaptchaEnabled,
|
||||
TencentCaptchaAppID: updatedSettings.TencentCaptchaAppID,
|
||||
TencentCaptchaAppSecretKeyConfigured: updatedSettings.TencentCaptchaAppSecretKeyConfigured,
|
||||
TencentCaptchaCloudSecretIDConfigured: updatedSettings.TencentCaptchaCloudSecretIDConfigured,
|
||||
TencentCaptchaCloudSecretKeyConfigured: updatedSettings.TencentCaptchaCloudSecretKeyConfigured,
|
||||
APIKeyACLTrustForwardedIP: updatedSettings.APIKeyACLTrustForwardedIP,
|
||||
ForwardedClientIPHeaders: updatedSettings.ForwardedClientIPHeaders,
|
||||
LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled,
|
||||
@@ -2027,6 +2127,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
EnableClientDatelineNormalization: updatedSettings.EnableClientDatelineNormalization,
|
||||
AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion,
|
||||
OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent,
|
||||
OpenAICodexClientVersion: updatedSettings.OpenAICodexClientVersion,
|
||||
OpenAICodexClientVersionSynced: updatedSettings.OpenAICodexClientVersionSynced,
|
||||
OpenAICodexVersionAutoSyncEnabled: updatedSettings.OpenAICodexVersionAutoSyncEnabled,
|
||||
MinCodexVersion: updatedSettings.MinCodexVersion,
|
||||
MaxCodexVersion: updatedSettings.MaxCodexVersion,
|
||||
CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAuthRequestsBindTencentCaptchaProof(t *testing.T) {
|
||||
const payload = `{"email":"user@example.com","password":"secret-123","tencent_captcha_ticket":"ticket-value","tencent_captcha_randstr":"@rand-value"}`
|
||||
|
||||
var login LoginRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(payload), &login))
|
||||
proof := captchaProof(login.TurnstileToken, login.TencentCaptchaTicket, login.TencentCaptchaRandstr)
|
||||
require.Equal(t, "ticket-value", proof.TencentTicket)
|
||||
require.Equal(t, "@rand-value", proof.TencentRandstr)
|
||||
|
||||
var pending createPendingOAuthAccountRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(payload), &pending))
|
||||
proof = captchaProof(pending.TurnstileToken, pending.TencentCaptchaTicket, pending.TencentCaptchaRandstr)
|
||||
require.Equal(t, "ticket-value", proof.TencentTicket)
|
||||
require.Equal(t, "@rand-value", proof.TencentRandstr)
|
||||
}
|
||||
@@ -113,6 +113,9 @@ func clearDingTalkCookie(c *gin.Context, name string, secure bool) {
|
||||
// DingTalkOAuthStart 启动 DingTalk Connect OAuth 登录流程。
|
||||
// GET /api/v1/auth/oauth/dingtalk/start?redirect=/dashboard&intent=login
|
||||
func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) {
|
||||
if !h.requireTencentCaptchaForOAuthLoginStart(c) {
|
||||
return
|
||||
}
|
||||
cfg, err := h.getDingTalkOAuthConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
frontendCB := dingTalkOAuthDefaultFrontendCB
|
||||
@@ -165,7 +168,7 @@ func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
respondOAuthStart(c, authURL)
|
||||
}
|
||||
|
||||
// ─── buildDingTalkAuthorizeURL ─────────────────────────────────────────────
|
||||
|
||||
@@ -59,6 +59,9 @@ func (h *AuthHandler) CompleteGoogleOAuthRegistration(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) {
|
||||
if !h.requireTencentCaptchaForOAuthLoginStart(c) {
|
||||
return
|
||||
}
|
||||
cfg, err := h.getEmailOAuthConfig(c.Request.Context(), provider)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -90,7 +93,7 @@ func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
respondOAuthStart(c, authURL)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) emailOAuthCallback(c *gin.Context, provider string) {
|
||||
|
||||
@@ -48,19 +48,23 @@ func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userSe
|
||||
|
||||
// RegisterRequest represents the registration request payload
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
VerifyCode string `json:"verify_code"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
PromoCode string `json:"promo_code"` // 注册优惠码
|
||||
InvitationCode string `json:"invitation_code"` // 邀请码
|
||||
AffCode string `json:"aff_code"` // 邀请返利码
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
VerifyCode string `json:"verify_code"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
|
||||
PromoCode string `json:"promo_code"` // 注册优惠码
|
||||
InvitationCode string `json:"invitation_code"` // 邀请码
|
||||
AffCode string `json:"aff_code"` // 邀请返利码
|
||||
}
|
||||
|
||||
// SendVerifyCodeRequest 发送验证码请求
|
||||
type SendVerifyCodeRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
|
||||
}
|
||||
|
||||
// SendVerifyCodeResponse 发送验证码响应
|
||||
@@ -71,9 +75,19 @@ type SendVerifyCodeResponse struct {
|
||||
|
||||
// LoginRequest represents the login request payload
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
|
||||
}
|
||||
|
||||
func captchaProof(turnstileToken, tencentTicket, tencentRandstr string) service.CaptchaProof {
|
||||
return service.CaptchaProof{
|
||||
TurnstileToken: turnstileToken,
|
||||
TencentTicket: tencentTicket,
|
||||
TencentRandstr: tencentRandstr,
|
||||
}
|
||||
}
|
||||
|
||||
// AuthResponse 认证响应格式(匹配前端期望)
|
||||
@@ -169,8 +183,9 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Turnstile 验证(邮箱验证码注册场景避免重复校验一次性 token)
|
||||
if err := h.authService.VerifyTurnstileForRegister(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c), req.VerifyCode); err != nil {
|
||||
// 验证当前启用的验证码(邮箱验证码注册场景避免重复校验一次性票据)
|
||||
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
if err := h.authService.VerifyCaptchaForRegister(c.Request.Context(), proof, ip.GetClientIP(c), req.VerifyCode); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
@@ -201,8 +216,8 @@ func (h *AuthHandler) SendVerifyCode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Turnstile 验证
|
||||
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
|
||||
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
@@ -228,8 +243,8 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Turnstile 验证
|
||||
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
|
||||
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
@@ -573,8 +588,10 @@ func (h *AuthHandler) ValidateInvitationCode(c *gin.Context) {
|
||||
|
||||
// ForgotPasswordRequest 忘记密码请求
|
||||
type ForgotPasswordRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
|
||||
}
|
||||
|
||||
// ForgotPasswordResponse 忘记密码响应
|
||||
@@ -591,8 +608,8 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Turnstile 验证
|
||||
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
|
||||
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@ func (e *linuxDoTokenExchangeError) Error() string {
|
||||
// LinuxDoOAuthStart 启动 LinuxDo Connect OAuth 登录流程。
|
||||
// GET /api/v1/auth/oauth/linuxdo/start?redirect=/dashboard
|
||||
func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
|
||||
if !h.requireTencentCaptchaForOAuthLoginStart(c) {
|
||||
return
|
||||
}
|
||||
cfg, err := h.getLinuxDoOAuthConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -147,7 +150,7 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
respondOAuthStart(c, authURL)
|
||||
}
|
||||
|
||||
// LinuxDoOAuthCallback 处理 OAuth 回调:创建/登录用户,然后重定向到前端。
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type oauthStartCaptchaRequest struct {
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
|
||||
}
|
||||
|
||||
type oauthStartResponse struct {
|
||||
AuthorizeURL string `json:"authorize_url"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) requireTencentCaptchaForOAuthLoginStart(c *gin.Context) bool {
|
||||
if strings.HasSuffix(strings.TrimRight(c.Request.URL.Path, "/"), "/bind/start") {
|
||||
return true
|
||||
}
|
||||
|
||||
var req oauthStartCaptchaRequest
|
||||
if c.Request.Method == http.MethodPost {
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
}
|
||||
if err := h.authService.VerifyTencentCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{
|
||||
TencentTicket: req.TencentCaptchaTicket,
|
||||
TencentRandstr: req.TencentCaptchaRandstr,
|
||||
}, ip.GetClientIP(c)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func respondOAuthStart(c *gin.Context, authorizeURL string) {
|
||||
if c.Request.Method == http.MethodPost {
|
||||
response.Success(c, oauthStartResponse{AuthorizeURL: authorizeURL})
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, authorizeURL)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type oauthCaptchaSettingRepo struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (r *oauthCaptchaSettingRepo) Get(context.Context, string) (*service.Setting, error) {
|
||||
return nil, service.ErrSettingNotFound
|
||||
}
|
||||
func (r *oauthCaptchaSettingRepo) GetValue(_ context.Context, key string) (string, error) {
|
||||
value, ok := r.values[key]
|
||||
if !ok {
|
||||
return "", service.ErrSettingNotFound
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
func (r *oauthCaptchaSettingRepo) Set(context.Context, string, string) error { return nil }
|
||||
func (r *oauthCaptchaSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
|
||||
values := make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
if value, ok := r.values[key]; ok {
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
func (r *oauthCaptchaSettingRepo) SetMultiple(context.Context, map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
func (r *oauthCaptchaSettingRepo) GetAll(context.Context) (map[string]string, error) {
|
||||
return r.values, nil
|
||||
}
|
||||
func (r *oauthCaptchaSettingRepo) Delete(context.Context, string) error { return nil }
|
||||
|
||||
type oauthCaptchaVerifier struct {
|
||||
calls int
|
||||
proof service.TencentCaptchaProof
|
||||
}
|
||||
|
||||
func (v *oauthCaptchaVerifier) VerifyTicket(_ context.Context, _ service.TencentCaptchaCredentials, proof service.TencentCaptchaProof, _ string) (*service.TencentCaptchaVerifyResponse, error) {
|
||||
v.calls++
|
||||
v.proof = proof
|
||||
return &service.TencentCaptchaVerifyResponse{CaptchaCode: 1}, nil
|
||||
}
|
||||
|
||||
func newOAuthCaptchaTestHandler(enabled bool) (*AuthHandler, *oauthCaptchaVerifier) {
|
||||
values := map[string]string{}
|
||||
if enabled {
|
||||
values = map[string]string{
|
||||
service.SettingKeyTencentCaptchaEnabled: "true",
|
||||
service.SettingKeyTencentCaptchaAppID: "123456789",
|
||||
service.SettingKeyTencentCaptchaAppSecretKey: "app-secret",
|
||||
service.SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id",
|
||||
service.SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key",
|
||||
}
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
settings := service.NewSettingService(&oauthCaptchaSettingRepo{values: values}, cfg)
|
||||
verifier := &oauthCaptchaVerifier{}
|
||||
authService := service.NewAuthService(nil, nil, nil, nil, cfg, settings, nil, nil, nil, nil, nil, nil, nil)
|
||||
authService.SetTencentCaptchaService(service.NewTencentCaptchaService(settings, verifier))
|
||||
return &AuthHandler{authService: authService, settingSvc: settings, cfg: cfg}, verifier
|
||||
}
|
||||
|
||||
func oauthStartHandlers() map[string]func(*AuthHandler, *gin.Context) {
|
||||
return map[string]func(*AuthHandler, *gin.Context){
|
||||
"github": func(h *AuthHandler, c *gin.Context) { h.GitHubOAuthStart(c) },
|
||||
"google": func(h *AuthHandler, c *gin.Context) { h.GoogleOAuthStart(c) },
|
||||
"linuxdo": func(h *AuthHandler, c *gin.Context) { h.LinuxDoOAuthStart(c) },
|
||||
"dingtalk": func(h *AuthHandler, c *gin.Context) { h.DingTalkOAuthStart(c) },
|
||||
"wechat": func(h *AuthHandler, c *gin.Context) { h.WeChatOAuthStart(c) },
|
||||
"oidc": func(h *AuthHandler, c *gin.Context) { h.OIDCOAuthStart(c) },
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthStartGetRejectsAnonymousLoginWhenTencentEnabledWithoutSideEffects(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for provider, start := range oauthStartHandlers() {
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
handler, verifier := newOAuthCaptchaTestHandler(true)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/"+provider+"/start?intent=bind_current_user", nil)
|
||||
|
||||
start(handler, c)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED")
|
||||
require.Empty(t, recorder.Header().Get("Location"))
|
||||
require.Empty(t, recorder.Header().Values("Set-Cookie"))
|
||||
require.Zero(t, verifier.calls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthStartPostReturnsAuthorizeURLAfterTencentVerification(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for provider := range oauthStartHandlers() {
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
handler, verifier := newOAuthCaptchaTestHandler(true)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/oauth/"+provider+"/start",
|
||||
strings.NewReader(`{"tencent_captcha_ticket":"ticket-value","tencent_captcha_randstr":"@rand-value"}`),
|
||||
)
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
require.True(t, handler.requireTencentCaptchaForOAuthLoginStart(c))
|
||||
respondOAuthStart(c, "https://provider.example/authorize")
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), `"authorize_url":"https://provider.example/authorize"`)
|
||||
require.Equal(t, 1, verifier.calls)
|
||||
require.Equal(t, service.TencentCaptchaProof{Ticket: "ticket-value", Randstr: "@rand-value"}, verifier.proof)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthStartPostRequiresTencentProofWhenEnabled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for provider := range oauthStartHandlers() {
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
handler, verifier := newOAuthCaptchaTestHandler(true)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/"+provider+"/start", strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
require.False(t, handler.requireTencentCaptchaForOAuthLoginStart(c))
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED")
|
||||
require.Zero(t, verifier.calls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthBindingPathRemainsOutsideTencentGate(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := &AuthHandler{}
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/bind/start", nil)
|
||||
|
||||
require.True(t, handler.requireTencentCaptchaForOAuthLoginStart(c))
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
func TestOAuthStartGetRemainsCompatibleWhenTencentDisabled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler, verifier := newOAuthCaptchaTestHandler(false)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/start", nil)
|
||||
|
||||
require.True(t, handler.requireTencentCaptchaForOAuthLoginStart(c))
|
||||
respondOAuthStart(c, "https://provider.example/authorize")
|
||||
|
||||
require.Equal(t, http.StatusFound, recorder.Code)
|
||||
require.Equal(t, "https://provider.example/authorize", recorder.Header().Get("Location"))
|
||||
require.Zero(t, verifier.calls)
|
||||
}
|
||||
@@ -66,20 +66,25 @@ type bindPendingOAuthLoginRequest struct {
|
||||
}
|
||||
|
||||
type createPendingOAuthAccountRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
VerifyCode string `json:"verify_code,omitempty"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
InvitationCode string `json:"invitation_code,omitempty"`
|
||||
AffCode string `json:"aff_code,omitempty"`
|
||||
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
||||
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
VerifyCode string `json:"verify_code,omitempty"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
TurnstileToken string `json:"turnstile_token,omitempty"`
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket,omitempty"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr,omitempty"`
|
||||
InvitationCode string `json:"invitation_code,omitempty"`
|
||||
AffCode string `json:"aff_code,omitempty"`
|
||||
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
||||
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
|
||||
}
|
||||
|
||||
type sendPendingOAuthVerifyCodeRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
TurnstileToken string `json:"turnstile_token,omitempty"`
|
||||
PendingAuthToken string `json:"pending_auth_token,omitempty"`
|
||||
PendingOAuthToken string `json:"pending_oauth_token,omitempty"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
TurnstileToken string `json:"turnstile_token,omitempty"`
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket,omitempty"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr,omitempty"`
|
||||
PendingAuthToken string `json:"pending_auth_token,omitempty"`
|
||||
PendingOAuthToken string `json:"pending_oauth_token,omitempty"`
|
||||
}
|
||||
|
||||
func (r bindPendingOAuthLoginRequest) adoptionDecision() oauthAdoptionDecisionRequest {
|
||||
@@ -564,7 +569,8 @@ func (h *AuthHandler) SendPendingOAuthVerifyCode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
|
||||
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
@@ -1754,6 +1760,11 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
|
||||
if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
tokenPair, user, err := h.authService.RegisterOAuthEmailAccount(
|
||||
c.Request.Context(),
|
||||
|
||||
@@ -115,6 +115,9 @@ type oidcJWK struct {
|
||||
// OIDCOAuthStart 启动通用 OIDC OAuth 登录流程。
|
||||
// GET /api/v1/auth/oauth/oidc/start?redirect=/dashboard
|
||||
func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) {
|
||||
if !h.requireTencentCaptchaForOAuthLoginStart(c) {
|
||||
return
|
||||
}
|
||||
cfg, err := h.getOIDCOAuthConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -190,7 +193,7 @@ func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
respondOAuthStart(c, authURL)
|
||||
}
|
||||
|
||||
// OIDCOAuthCallback 处理 OIDC 回调:校验 id_token、创建/登录用户并重定向到前端。
|
||||
|
||||
@@ -96,6 +96,9 @@ type wechatPaymentOAuthContext struct {
|
||||
// WeChatOAuthStart starts the WeChat OAuth login flow and stores the short-lived
|
||||
// browser cookies required by the rebuild pending-auth bridge.
|
||||
func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
|
||||
if !h.requireTencentCaptchaForOAuthLoginStart(c) {
|
||||
return
|
||||
}
|
||||
cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), c.Query("mode"), c)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -145,7 +148,7 @@ func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
respondOAuthStart(c, authURL)
|
||||
}
|
||||
|
||||
// WeChatOAuthCallback exchanges the code with WeChat, resolves openid/unionid,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func ensureCompositeTargetPlatform(c *gin.Context, apiKey *service.APIKey, model string) {
|
||||
@@ -55,3 +58,42 @@ func effectiveAPIKeyPlatform(c *gin.Context, apiKey *service.APIKey) string {
|
||||
}
|
||||
return apiKey.Group.Platform
|
||||
}
|
||||
|
||||
func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, bool) {
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return "", nil, false
|
||||
}
|
||||
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
|
||||
return "", nil, false
|
||||
}
|
||||
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI {
|
||||
return "", nil, false
|
||||
}
|
||||
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, true
|
||||
}
|
||||
|
||||
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool) {
|
||||
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return body, false
|
||||
}
|
||||
return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings)
|
||||
}
|
||||
|
||||
func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) {
|
||||
if c == nil || c.Request == nil {
|
||||
return
|
||||
}
|
||||
// The Messages bridge synthesizes a default OpenAI effort when
|
||||
// output_config.effort is omitted. Bind the group policy only for an
|
||||
// explicit client value so the ceiling does not alter that default.
|
||||
effort := gjson.GetBytes(body, "output_config.effort")
|
||||
if !effort.Exists() || effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" {
|
||||
return
|
||||
}
|
||||
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings))
|
||||
}
|
||||
|
||||
@@ -77,6 +77,46 @@ func TestCompositeTargetPlatformResolvedAllowsConcreteGroupWithoutResolution(t *
|
||||
require.True(t, compositeTargetPlatformResolved(c, apiKey, "llama-4-maverick"))
|
||||
}
|
||||
|
||||
func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
group := &service.Group{
|
||||
Platform: service.PlatformComposite,
|
||||
MaxReasoningEffort: "medium",
|
||||
ReasoningEffortMappings: []service.ReasoningEffortMapping{
|
||||
{From: "max", To: "xhigh"},
|
||||
},
|
||||
}
|
||||
apiKey := &service.APIKey{Group: group}
|
||||
body := []byte(`{"reasoning":{"effort":"max"}}`)
|
||||
|
||||
openAICtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
openAICtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
openAICtx.Request = openAICtx.Request.WithContext(service.WithResolvedTargetPlatform(openAICtx.Request.Context(), service.PlatformOpenAI))
|
||||
got, changed := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(got))
|
||||
|
||||
bindOpenAIReasoningEffortPolicyForMessagesRequest(openAICtx, apiKey, []byte(`{"output_config":{"effort":"max"}}`))
|
||||
bound, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(bound))
|
||||
|
||||
omittedCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
omittedCtx.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
omittedCtx.Request = omittedCtx.Request.WithContext(service.WithResolvedTargetPlatform(omittedCtx.Request.Context(), service.PlatformOpenAI))
|
||||
bindOpenAIReasoningEffortPolicyForMessagesRequest(omittedCtx, apiKey, []byte(`{"model":"gpt-5"}`))
|
||||
omitted, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, omitted)
|
||||
|
||||
grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok))
|
||||
got, changed = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
|
||||
func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
|
||||
@@ -56,11 +56,16 @@ type SystemSettings struct {
|
||||
SMTPFromName string `json:"smtp_from_name"`
|
||||
SMTPUseTLS bool `json:"smtp_use_tls"`
|
||||
|
||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||
TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"`
|
||||
APIKeyACLTrustForwardedIP bool `json:"api_key_acl_trust_forwarded_ip"`
|
||||
ForwardedClientIPHeaders []string `json:"forwarded_client_ip_headers"`
|
||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||
TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"`
|
||||
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
|
||||
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
|
||||
TencentCaptchaAppSecretKeyConfigured bool `json:"tencent_captcha_app_secret_key_configured"`
|
||||
TencentCaptchaCloudSecretIDConfigured bool `json:"tencent_captcha_cloud_secret_id_configured"`
|
||||
TencentCaptchaCloudSecretKeyConfigured bool `json:"tencent_captcha_cloud_secret_key_configured"`
|
||||
APIKeyACLTrustForwardedIP bool `json:"api_key_acl_trust_forwarded_ip"`
|
||||
ForwardedClientIPHeaders []string `json:"forwarded_client_ip_headers"`
|
||||
|
||||
LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"`
|
||||
LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"`
|
||||
@@ -199,6 +204,9 @@ type SystemSettings struct {
|
||||
EnableClientDatelineNormalization bool `json:"enable_client_dateline_normalization"`
|
||||
AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
|
||||
OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
|
||||
OpenAICodexClientVersion string `json:"openai_codex_client_version"`
|
||||
OpenAICodexClientVersionSynced string `json:"openai_codex_client_version_synced"`
|
||||
OpenAICodexVersionAutoSyncEnabled bool `json:"openai_codex_version_auto_sync_enabled"`
|
||||
|
||||
// codex_cli_only 加固
|
||||
MinCodexVersion string `json:"min_codex_version"`
|
||||
@@ -338,6 +346,8 @@ type PublicSettings struct {
|
||||
LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
|
||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
|
||||
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
|
||||
SiteName string `json:"site_name"`
|
||||
SiteLogo string `json:"site_logo"`
|
||||
SiteSubtitle string `json:"site_subtitle"`
|
||||
|
||||
@@ -54,19 +54,23 @@ type modelPlazaModel struct {
|
||||
|
||||
// modelPlazaGroup 广场分组条目(白名单字段)。
|
||||
type modelPlazaGroup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform"`
|
||||
SubscriptionType string `json:"subscription_type"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"`
|
||||
PeakRateEnabled bool `json:"peak_rate_enabled"`
|
||||
PeakStart string `json:"peak_start"`
|
||||
PeakEnd string `json:"peak_end"`
|
||||
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
Models []modelPlazaModel `json:"models"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform"`
|
||||
SubscriptionType string `json:"subscription_type"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"`
|
||||
PeakRateEnabled bool `json:"peak_rate_enabled"`
|
||||
PeakStart string `json:"peak_start"`
|
||||
PeakEnd string `json:"peak_end"`
|
||||
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
// 生图独立倍率:为 true 时图片计费模型的实付倍率取 ImageRateMultiplier,
|
||||
// 不取分组/用户专属倍率。
|
||||
ImageRateIndependent bool `json:"image_rate_independent"`
|
||||
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
|
||||
Models []modelPlazaModel `json:"models"`
|
||||
}
|
||||
|
||||
// modelPlazaResponse 广场页响应。
|
||||
@@ -164,18 +168,20 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo
|
||||
})
|
||||
}
|
||||
dto := modelPlazaGroup{
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
Platform: g.Platform,
|
||||
SubscriptionType: g.SubscriptionType,
|
||||
RateMultiplier: g.RateMultiplier,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
PeakEnd: g.PeakEnd,
|
||||
PeakRateMultiplier: g.PeakRateMultiplier,
|
||||
IsExclusive: g.IsExclusive,
|
||||
Models: models,
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
Platform: g.Platform,
|
||||
SubscriptionType: g.SubscriptionType,
|
||||
RateMultiplier: g.RateMultiplier,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
PeakEnd: g.PeakEnd,
|
||||
PeakRateMultiplier: g.PeakRateMultiplier,
|
||||
IsExclusive: g.IsExclusive,
|
||||
ImageRateIndependent: g.ImageRateIndependent,
|
||||
ImageRateMultiplier: g.ImageRateMultiplier,
|
||||
Models: models,
|
||||
}
|
||||
if rate, ok := userRates[g.ID]; ok {
|
||||
dto.UserRateMultiplier = &rate
|
||||
|
||||
@@ -91,6 +91,7 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
|
||||
"id", "name", "description", "platform", "subscription_type",
|
||||
"rate_multiplier", "user_rate_multiplier", "is_exclusive", "models",
|
||||
"peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier",
|
||||
"image_rate_independent", "image_rate_multiplier",
|
||||
} {
|
||||
_, exists := decoded[key]
|
||||
require.Truef(t, exists, "plaza group DTO must expose %q", key)
|
||||
|
||||
@@ -80,10 +80,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
|
||||
return
|
||||
}
|
||||
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI {
|
||||
if cappedBody, changed := service.ApplyOpenAIReasoningEffortPolicy(body, apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings); changed {
|
||||
body = cappedBody
|
||||
}
|
||||
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
|
||||
body = cappedBody
|
||||
}
|
||||
reqStream, ok := parseOpenAICompatibleStream(body)
|
||||
if !ok {
|
||||
|
||||
@@ -313,10 +313,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
|
||||
return
|
||||
}
|
||||
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI {
|
||||
if cappedBody, changed := service.ApplyOpenAIReasoningEffortPolicy(body, apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings); changed {
|
||||
body = cappedBody
|
||||
}
|
||||
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
|
||||
body = cappedBody
|
||||
}
|
||||
|
||||
reqStream, ok := parseOpenAICompatibleStream(body)
|
||||
@@ -951,6 +949,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
|
||||
return
|
||||
}
|
||||
bindOpenAIReasoningEffortPolicyForMessagesRequest(c, apiKey, body)
|
||||
routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel)
|
||||
preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel)
|
||||
reqStream := gjson.GetBytes(body, "stream").Bool()
|
||||
@@ -1613,7 +1612,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
reqLog.Info("openai.websocket_ingress_started")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
userAgent := strings.TrimSpace(c.GetHeader("User-Agent"))
|
||||
ctx := c.Request.Context()
|
||||
clientLifecycleCtx := c.Request.Context()
|
||||
ctx := clientLifecycleCtx
|
||||
maxIngressConnections := 0
|
||||
if h.cfg != nil {
|
||||
maxIngressConnections = h.cfg.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey
|
||||
@@ -2007,12 +2007,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
zap.Int("candidate_count", scheduleDecision.CandidateCount),
|
||||
)
|
||||
|
||||
maxReasoningEffort := ""
|
||||
var reasoningEffortMappings []service.ReasoningEffortMapping
|
||||
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI {
|
||||
maxReasoningEffort = apiKey.Group.MaxReasoningEffort
|
||||
reasoningEffortMappings = apiKey.Group.ReasoningEffortMappings
|
||||
}
|
||||
maxReasoningEffort, reasoningEffortMappings, _ := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
var requestPayloadHash string
|
||||
// Passthrough rejects overlapping response.create frames, so one immutable
|
||||
// turn-tagged slot preserves the exact mapping used for the in-flight request.
|
||||
@@ -2023,6 +2018,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
// openAIWSTurnPricing 的注释——绝不能用建连时刻初始化。
|
||||
var turnPricing openAIWSTurnPricing
|
||||
hooks := &service.OpenAIWSIngressHooks{
|
||||
ClientLifecycleContext: clientLifecycleCtx,
|
||||
InitialRequestModel: reqModel,
|
||||
MaxReasoningEffort: maxReasoningEffort,
|
||||
ReasoningEffortMappings: reasoningEffortMappings,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -45,6 +46,11 @@ type passkeyFinishRequest struct {
|
||||
Credential json.RawMessage `json:"credential" binding:"required"`
|
||||
}
|
||||
|
||||
type passkeyBeginLoginRequest struct {
|
||||
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
|
||||
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
|
||||
}
|
||||
|
||||
type passkeyRenameRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
@@ -70,6 +76,15 @@ func (h *PasskeyHandler) BeginLogin(c *gin.Context) {
|
||||
if !h.requirePasskeysEnabled(c) {
|
||||
return
|
||||
}
|
||||
var req passkeyBeginLoginRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if err := h.authService.VerifyTencentCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{
|
||||
TencentTicket: req.TencentCaptchaTicket,
|
||||
TencentRandstr: req.TencentCaptchaRandstr,
|
||||
}, ip.GetClientIP(c)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
assertion, token, err := h.passkeys.BeginLogin(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -15,8 +16,30 @@ import (
|
||||
)
|
||||
|
||||
type passkeySwitchSettingRepo struct {
|
||||
value string
|
||||
err error
|
||||
value string
|
||||
values map[string]string
|
||||
err error
|
||||
}
|
||||
|
||||
type passkeyCaptchaVerifierStub struct {
|
||||
calls int
|
||||
proof service.TencentCaptchaProof
|
||||
}
|
||||
|
||||
func (s *passkeyCaptchaVerifierStub) VerifyTicket(_ context.Context, _ service.TencentCaptchaCredentials, proof service.TencentCaptchaProof, _ string) (*service.TencentCaptchaVerifyResponse, error) {
|
||||
s.calls++
|
||||
s.proof = proof
|
||||
return &service.TencentCaptchaVerifyResponse{CaptchaCode: 1}, nil
|
||||
}
|
||||
|
||||
type passkeyBeginSessionStoreStub struct {
|
||||
service.PasskeySessionStore
|
||||
storeCalls int
|
||||
}
|
||||
|
||||
func (s *passkeyBeginSessionStoreStub) Store(context.Context, *service.PasskeySession, time.Duration) (string, error) {
|
||||
s.storeCalls++
|
||||
return "passkey-session", nil
|
||||
}
|
||||
|
||||
func (r *passkeySwitchSettingRepo) Get(context.Context, string) (*service.Setting, error) {
|
||||
@@ -27,7 +50,10 @@ func (r *passkeySwitchSettingRepo) GetValue(context.Context, string) (string, er
|
||||
}
|
||||
func (r *passkeySwitchSettingRepo) Set(context.Context, string, string) error { return nil }
|
||||
func (r *passkeySwitchSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) {
|
||||
return map[string]string{}, nil
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
return r.values, nil
|
||||
}
|
||||
func (r *passkeySwitchSettingRepo) SetMultiple(context.Context, map[string]string) error {
|
||||
return nil
|
||||
@@ -87,6 +113,74 @@ func TestPasskeyBeginLoginReportsSettingStoreFailure(t *testing.T) {
|
||||
require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED")
|
||||
}
|
||||
|
||||
func newTencentProtectedPasskeyHandler(t *testing.T) (*PasskeyHandler, *passkeyCaptchaVerifierStub, *passkeyBeginSessionStoreStub) {
|
||||
t.Helper()
|
||||
cfg := &config.Config{WebAuthn: config.WebAuthnConfig{
|
||||
Enabled: true,
|
||||
RPDisplayName: "Sub2API",
|
||||
RPID: "sub2api.example.com",
|
||||
RPOrigins: []string{"https://sub2api.example.com"},
|
||||
}}
|
||||
repo := &passkeySwitchSettingRepo{
|
||||
value: "true",
|
||||
values: map[string]string{
|
||||
service.SettingKeyTencentCaptchaEnabled: "true",
|
||||
service.SettingKeyTencentCaptchaAppID: "123456789",
|
||||
service.SettingKeyTencentCaptchaAppSecretKey: "app-secret",
|
||||
service.SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id",
|
||||
service.SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key",
|
||||
},
|
||||
}
|
||||
settings := service.NewSettingService(repo, cfg)
|
||||
verifier := &passkeyCaptchaVerifierStub{}
|
||||
authService := service.NewAuthService(nil, nil, nil, nil, cfg, settings, nil, nil, nil, nil, nil, nil, nil)
|
||||
authService.SetTencentCaptchaService(service.NewTencentCaptchaService(settings, verifier))
|
||||
sessions := &passkeyBeginSessionStoreStub{}
|
||||
passkeys, err := service.NewPasskeyService(cfg, nil, sessions, nil)
|
||||
require.NoError(t, err)
|
||||
return NewPasskeyHandler(passkeys, authService, settings), verifier, sessions
|
||||
}
|
||||
|
||||
func newPasskeyBeginLoginContext(body string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
recorder := httptest.NewRecorder()
|
||||
ginContext, _ := gin.CreateTestContext(recorder)
|
||||
ginContext.Request = httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/passkey/login/begin",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
ginContext.Request.Header.Set("Content-Type", "application/json")
|
||||
return ginContext, recorder
|
||||
}
|
||||
|
||||
func TestPasskeyBeginLoginRejectsMissingTencentCaptchaProof(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler, verifier, sessions := newTencentProtectedPasskeyHandler(t)
|
||||
ginContext, recorder := newPasskeyBeginLoginContext(`{}`)
|
||||
|
||||
handler.BeginLogin(ginContext)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED")
|
||||
require.Zero(t, verifier.calls)
|
||||
require.Zero(t, sessions.storeCalls)
|
||||
}
|
||||
|
||||
func TestPasskeyBeginLoginAcceptsTencentCaptchaProofBeforeCeremony(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler, verifier, sessions := newTencentProtectedPasskeyHandler(t)
|
||||
ginContext, recorder := newPasskeyBeginLoginContext(
|
||||
`{"tencent_captcha_ticket":"ticket-value","tencent_captcha_randstr":"@rand-value"}`,
|
||||
)
|
||||
|
||||
handler.BeginLogin(ginContext)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, 1, verifier.calls)
|
||||
require.Equal(t, service.TencentCaptchaProof{Ticket: "ticket-value", Randstr: "@rand-value"}, verifier.proof)
|
||||
require.Equal(t, 1, sessions.storeCalls)
|
||||
}
|
||||
|
||||
func TestPasskeyCredentialListRemainsAvailableWhenSignInDisabled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewPasskeyHandler(nil, nil, nil)
|
||||
|
||||
@@ -60,6 +60,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
LoginAgreementDocuments: publicLoginAgreementDocumentsToDTO(settings.LoginAgreementDocuments),
|
||||
TurnstileEnabled: settings.TurnstileEnabled,
|
||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||
TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
|
||||
TencentCaptchaAppID: settings.TencentCaptchaAppID,
|
||||
SiteName: settings.SiteName,
|
||||
SiteLogo: settings.SiteLogo,
|
||||
SiteSubtitle: settings.SiteSubtitle,
|
||||
|
||||
@@ -82,6 +82,38 @@ func TestSettingHandler_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t
|
||||
require.True(t, resp.Data.ForceEmailOnThirdPartySignup)
|
||||
}
|
||||
|
||||
func TestSettingHandler_GetPublicSettings_ExposesTencentCaptchaConfiguration(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
repo := &settingHandlerPublicRepoStub{
|
||||
values: map[string]string{
|
||||
service.SettingKeyTencentCaptchaEnabled: "true",
|
||||
service.SettingKeyTencentCaptchaAppID: "123456789",
|
||||
},
|
||||
}
|
||||
h := NewSettingHandler(service.NewSettingService(repo, &config.Config{}), "test-version")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/settings/public", nil)
|
||||
|
||||
h.GetPublicSettings(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
|
||||
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
|
||||
require.Equal(t, 0, resp.Code)
|
||||
require.True(t, resp.Data.TencentCaptchaEnabled)
|
||||
require.Equal(t, "123456789", resp.Data.TencentCaptchaAppID)
|
||||
}
|
||||
|
||||
func TestSettingHandler_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewSettingHandler(service.NewSettingService(&settingHandlerPublicRepoStub{
|
||||
|
||||
@@ -230,6 +230,7 @@ func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*paymen
|
||||
Amount: stripe.Int64(amountInMinorUnit),
|
||||
Reason: stripe.String(string(stripe.RefundReasonRequestedByCustomer)),
|
||||
}
|
||||
params.SetIdempotencyKey(fmt.Sprintf("re-%s-%d", req.OrderID, amountInMinorUnit))
|
||||
params.Context = ctx
|
||||
|
||||
r, err := s.sc.V1Refunds.Create(ctx, params)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//go:build unit
|
||||
|
||||
package provider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/stretchr/testify/require"
|
||||
stripe "github.com/stripe/stripe-go/v85"
|
||||
)
|
||||
|
||||
type stripeRefundBackend struct {
|
||||
params []*stripe.RefundCreateParams
|
||||
}
|
||||
|
||||
func (b *stripeRefundBackend) Call(_ string, _ string, _ string, params stripe.ParamsContainer, v stripe.LastResponseSetter) error {
|
||||
b.params = append(b.params, params.(*stripe.RefundCreateParams))
|
||||
refund := v.(*stripe.Refund)
|
||||
refund.ID = "re_123"
|
||||
refund.Status = stripe.RefundStatusSucceeded
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stripeRefundBackend) CallStreaming(string, string, string, stripe.ParamsContainer, stripe.StreamingLastResponseSetter) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stripeRefundBackend) CallRaw(string, string, string, []byte, *stripe.Params, stripe.LastResponseSetter) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stripeRefundBackend) CallMultipart(string, string, string, string, *bytes.Buffer, *stripe.Params, stripe.LastResponseSetter) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stripeRefundBackend) SetMaxNetworkRetries(int64) {}
|
||||
|
||||
func TestStripeRefundUsesStableAmountSpecificIdempotencyKey(t *testing.T) {
|
||||
backend := &stripeRefundBackend{}
|
||||
client := stripe.NewClient("sk_test", stripe.WithBackends(&stripe.Backends{API: backend}))
|
||||
provider := &Stripe{
|
||||
config: map[string]string{"currency": "CNY"},
|
||||
initialized: true,
|
||||
sc: client,
|
||||
}
|
||||
|
||||
refund := func(amount string) {
|
||||
_, err := provider.Refund(context.Background(), payment.RefundRequest{
|
||||
TradeNo: "pi_123",
|
||||
OrderID: "sub2_order_456",
|
||||
Amount: amount,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
refund("12.34")
|
||||
refund("12.34")
|
||||
refund("12.35")
|
||||
|
||||
require.Len(t, backend.params, 3)
|
||||
require.Equal(t, int64(1234), *backend.params[0].Amount)
|
||||
require.Equal(t, "re-sub2_order_456-1234", *backend.params[0].IdempotencyKey)
|
||||
require.Equal(t, backend.params[0].IdempotencyKey, backend.params[1].IdempotencyKey)
|
||||
require.Equal(t, int64(1235), *backend.params[2].Amount)
|
||||
require.Equal(t, "re-sub2_order_456-1235", *backend.params[2].IdempotencyKey)
|
||||
require.NotEqual(t, *backend.params[0].IdempotencyKey, *backend.params[2].IdempotencyKey)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const (
|
||||
ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
|
||||
// OAuth endpoints
|
||||
AuthorizeURL = "https://claude.ai/oauth/authorize"
|
||||
AuthorizeURL = "https://claude.com/cai/oauth/authorize"
|
||||
TokenURL = "https://platform.claude.com/v1/oauth/token"
|
||||
RedirectURI = "https://platform.claude.com/oauth/code/callback"
|
||||
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthorizeURLMatchesClaudeCodeCLI(t *testing.T) {
|
||||
const want = "https://claude.com/cai/oauth/authorize"
|
||||
if AuthorizeURL != want {
|
||||
t.Fatalf("AuthorizeURL = %q, want %q", AuthorizeURL, want)
|
||||
}
|
||||
|
||||
authURL := BuildAuthorizationURL("state-value", "challenge-value", ScopeOAuth)
|
||||
if !strings.HasPrefix(authURL, want+"?") {
|
||||
t.Fatalf("BuildAuthorizationURL() = %q, want prefix %q", authURL, want+"?")
|
||||
}
|
||||
for _, part := range []string{
|
||||
"code=true",
|
||||
"client_id=" + ClientID,
|
||||
"response_type=code",
|
||||
"code_challenge=challenge-value",
|
||||
"code_challenge_method=S256",
|
||||
"state=state-value",
|
||||
} {
|
||||
if !strings.Contains(authURL, part) {
|
||||
t.Fatalf("BuildAuthorizationURL() missing %q\nURL: %s", part, authURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_Stop_Idempotent(t *testing.T) {
|
||||
store := NewSessionStore()
|
||||
|
||||
|
||||
@@ -57,17 +57,6 @@ var codexOfficialClientOriginators = map[string]bool{
|
||||
"codex_sdk_ts": true, // TypeScript SDK
|
||||
}
|
||||
|
||||
// IsBrowserUserAgent 判断 User-Agent 是否来自浏览器(Chrome/Firefox/Safari/Edge/Opera 等)。
|
||||
// 所有现代浏览器的 UA 均以 "Mozilla/" 作为前缀,CLI 工具(codex/claude/curl/postman/python-requests 等)不会。
|
||||
// 该判定用于避免 Cloudflare 对浏览器型 UA 在 OpenAI 上游接口上触发 JS 质询。
|
||||
func IsBrowserUserAgent(userAgent string) bool {
|
||||
ua := strings.TrimSpace(userAgent)
|
||||
if ua == "" {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(strings.ToLower(ua), "mozilla/")
|
||||
}
|
||||
|
||||
// IsCodexCLIRequest checks if the User-Agent indicates a Codex CLI request
|
||||
func IsCodexCLIRequest(userAgent string) bool {
|
||||
ua := normalizeCodexClientHeader(userAgent)
|
||||
@@ -255,6 +244,83 @@ func canonicalizeCodexOriginator(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// CodexCLIOriginator 官方 Codex CLI 默认 originator(codex-rs DEFAULT_ORIGINATOR),
|
||||
// 也是身份归一化的目标身份。
|
||||
const CodexCLIOriginator = "codex_cli_rs"
|
||||
|
||||
// CodexUserAgentVersion 提取 Codex UA 的完整版本段,即 `{client}/{version} (...` 中的 version。
|
||||
// 与 ParseCodexEngineVersion 的区别:后者只取三段数字用于引擎版本比较(会丢掉 -alpha.4
|
||||
// 之类的预发布后缀),本函数保留原样,因为出站 version 头必须与 UA 版本段逐字一致。
|
||||
// 取不到(非 Codex 形态 UA)时返回空串。
|
||||
func CodexUserAgentVersion(userAgent string) string {
|
||||
ua := strings.TrimSpace(userAgent)
|
||||
slash := strings.IndexByte(ua, '/')
|
||||
if slash <= 0 {
|
||||
return ""
|
||||
}
|
||||
rest := ua[slash+1:]
|
||||
if space := strings.IndexByte(rest, ' '); space >= 0 {
|
||||
rest = rest[:space]
|
||||
}
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
|
||||
// SetCodexUserAgentVersion 用 version 重建 Codex 形态 UA 中的版本声明,其余部分
|
||||
// (客户端名、OS / 架构 / 终端指纹)原样保留;UA 不是 `{client}/{version}` 形态时返回空串,
|
||||
// 由调用方决定整体回退。
|
||||
//
|
||||
// 尾部官方客户端标识组 `(name; version)` 与首段是同一个版本声明的两个出口
|
||||
// (CODEX_INTERNAL_ORIGINATOR_OVERRIDE 场景,如 `cccc/0.142.0 ... (codex-tui; 0.142.0)`),
|
||||
// 必须一并更新,否则会拼出首段声明新版本、尾部仍是旧版本的自相矛盾身份。
|
||||
// 仅在括号组确为官方客户端标识时才改写,避免误伤 OS 组(如 `(Ubuntu 22.4.0; x86_64)`)。
|
||||
func SetCodexUserAgentVersion(userAgent, version string) string {
|
||||
ua := strings.TrimSpace(userAgent)
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" {
|
||||
return ""
|
||||
}
|
||||
slash := strings.IndexByte(ua, '/')
|
||||
if slash <= 0 {
|
||||
return ""
|
||||
}
|
||||
client := strings.TrimSpace(ua[:slash])
|
||||
if client == "" {
|
||||
return ""
|
||||
}
|
||||
rest := ua[slash+1:]
|
||||
tail := ""
|
||||
if space := strings.IndexByte(rest, ' '); space >= 0 {
|
||||
tail = rest[space:]
|
||||
} else if strings.TrimSpace(rest) == "" {
|
||||
// `client/` 没有版本段,不是可重建的 Codex 形态。
|
||||
return ""
|
||||
}
|
||||
return rewriteCodexUATrailerVersion(client+"/"+version+tail, version)
|
||||
}
|
||||
|
||||
// rewriteCodexUATrailerVersion 把尾部官方客户端标识组 `(name; version)` 的版本改成 version。
|
||||
// 括号组缺少 `;` 分隔的版本、或 name 不是官方 originator 时原样返回。
|
||||
func rewriteCodexUATrailerVersion(ua, version string) string {
|
||||
open := strings.LastIndex(ua, "(")
|
||||
if open < 0 {
|
||||
return ua
|
||||
}
|
||||
closeIdx := strings.Index(ua[open+1:], ")")
|
||||
if closeIdx < 0 {
|
||||
return ua
|
||||
}
|
||||
inner := ua[open+1 : open+1+closeIdx]
|
||||
semi := strings.Index(inner, ";")
|
||||
if semi < 0 {
|
||||
return ua
|
||||
}
|
||||
name := strings.TrimSpace(inner[:semi])
|
||||
if name == "" || !IsCodexOfficialClientOriginator(name) {
|
||||
return ua
|
||||
}
|
||||
return ua[:open+1] + name + "; " + version + ua[open+1+closeIdx:]
|
||||
}
|
||||
|
||||
// codexEngineVersionPattern 提取版本段开头的三段数字 X.Y.Z(忽略 -alpha 等后缀)。
|
||||
var codexEngineVersionPattern = regexp.MustCompile(`^(\d+\.\d+\.\d+)`)
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCodexUserAgentVersion(t *testing.T) {
|
||||
require.Equal(t, "0.146.0", CodexUserAgentVersion("codex_cli_rs/0.146.0 (Ubuntu 22.4.0; x86_64) xterm-256color"))
|
||||
require.Equal(t, "0.146.0", CodexUserAgentVersion(" codex_cli_rs/0.146.0 "))
|
||||
// 预发布后缀原样保留:出站 version 头必须与 UA 版本段逐字一致。
|
||||
require.Equal(t, "0.147.0-alpha.4", CodexUserAgentVersion("codex-tui/0.147.0-alpha.4 (Mac OS X 14.0; arm64) iTerm"))
|
||||
// 非 `{client}/{version}` 形态取不到版本段。
|
||||
require.Empty(t, CodexUserAgentVersion("curl 8.7.1"))
|
||||
require.Empty(t, CodexUserAgentVersion("/0.146.0"))
|
||||
require.Empty(t, CodexUserAgentVersion(""))
|
||||
}
|
||||
|
||||
// 管理员在面板 / 账号上配置的 UA 填写于某个历史版本,逐字沿用会把出站身份钉死在陈旧版本上。
|
||||
// 重建只动版本声明,OS / 架构 / 终端指纹必须原样保留——那是该配置项唯一不可替代的价值。
|
||||
func TestSetCodexUserAgentVersion(t *testing.T) {
|
||||
require.Equal(t,
|
||||
"codex_cli_rs/0.146.0 (Ubuntu 22.4.0; x86_64) xterm-256color",
|
||||
SetCodexUserAgentVersion("codex_cli_rs/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color", "0.146.0"),
|
||||
)
|
||||
require.Equal(t, "codex_cli_rs/0.146.0", SetCodexUserAgentVersion("codex_cli_rs/0.1.0", "0.146.0"))
|
||||
|
||||
// 尾部官方客户端标识组与首段是同一个版本声明的两个出口,必须一并更新,
|
||||
// 否则拼出首段声明新版本、尾部仍是旧版本的自相矛盾身份。
|
||||
require.Equal(t,
|
||||
"cccc/0.146.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.146.0)",
|
||||
SetCodexUserAgentVersion("cccc/0.142.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.142.0)", "0.146.0"),
|
||||
)
|
||||
// OS 括号组不是客户端标识,不得被误改。
|
||||
require.Equal(t,
|
||||
"codex_cli_rs/0.146.0 (Ubuntu 22.4.0; x86_64)",
|
||||
SetCodexUserAgentVersion("codex_cli_rs/0.125.0 (Ubuntu 22.4.0; x86_64)", "0.146.0"),
|
||||
)
|
||||
|
||||
// 无法重建时返回空串,由调用方决定整体回退,绝不拼出畸形身份。
|
||||
require.Empty(t, SetCodexUserAgentVersion("not-a-codex-client", "0.146.0"))
|
||||
require.Empty(t, SetCodexUserAgentVersion("codex_cli_rs/", "0.146.0"))
|
||||
require.Empty(t, SetCodexUserAgentVersion("/0.1.0", "0.146.0"))
|
||||
require.Empty(t, SetCodexUserAgentVersion("codex_cli_rs/0.1.0", ""))
|
||||
}
|
||||
@@ -149,6 +149,7 @@ type UserUsageTrendPoint struct {
|
||||
type UserSpendingRankingItem struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
ActualCost float64 `json:"actual_cost"` // 实际扣除
|
||||
Requests int64 `json:"requests"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -56,6 +56,7 @@ var schedulerNeutralExtraKeyPrefixes = []string{
|
||||
"codex_secondary_",
|
||||
"codex_5h_",
|
||||
"codex_7d_",
|
||||
"codex_reset_credit_",
|
||||
"passive_usage_",
|
||||
"upstream_billing_probe",
|
||||
"upstream_billing_rate_sync",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
captcha "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha/v20190722"
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
|
||||
)
|
||||
|
||||
const tencentCaptchaEndpoint = "captcha.tencentcloudapi.com"
|
||||
|
||||
type tencentCaptchaAPI interface {
|
||||
DescribeCaptchaResultWithContext(context.Context, *captcha.DescribeCaptchaResultRequest) (*captcha.DescribeCaptchaResultResponse, error)
|
||||
}
|
||||
|
||||
type tencentCaptchaClientFactory func(secretID, secretKey string) (tencentCaptchaAPI, error)
|
||||
|
||||
type tencentCaptchaVerifier struct {
|
||||
newClient tencentCaptchaClientFactory
|
||||
}
|
||||
|
||||
func NewTencentCaptchaVerifier() service.TencentCaptchaVerifier {
|
||||
return &tencentCaptchaVerifier{newClient: newTencentCaptchaSDKClient}
|
||||
}
|
||||
|
||||
func newTencentCaptchaSDKClient(secretID, secretKey string) (tencentCaptchaAPI, error) {
|
||||
clientProfile := profile.NewClientProfile()
|
||||
clientProfile.HttpProfile.Endpoint = tencentCaptchaEndpoint
|
||||
clientProfile.HttpProfile.ReqMethod = "POST"
|
||||
clientProfile.HttpProfile.ReqTimeout = 5
|
||||
return captcha.NewClient(common.NewCredential(secretID, secretKey), "", clientProfile)
|
||||
}
|
||||
|
||||
func (v *tencentCaptchaVerifier) VerifyTicket(ctx context.Context, credentials service.TencentCaptchaCredentials, proof service.TencentCaptchaProof, remoteIP string) (*service.TencentCaptchaVerifyResponse, error) {
|
||||
client, err := v.newClient(credentials.CloudSecretID, credentials.CloudSecretKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create tencent captcha client: %w", err)
|
||||
}
|
||||
request := captcha.NewDescribeCaptchaResultRequest()
|
||||
request.CaptchaType = common.Uint64Ptr(9)
|
||||
request.Ticket = common.StringPtr(proof.Ticket)
|
||||
request.UserIp = common.StringPtr(remoteIP)
|
||||
request.Randstr = common.StringPtr(proof.Randstr)
|
||||
request.CaptchaAppId = common.Uint64Ptr(credentials.AppID)
|
||||
request.AppSecretKey = common.StringPtr(credentials.AppSecretKey)
|
||||
|
||||
response, err := client.DescribeCaptchaResultWithContext(ctx, request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("describe captcha result: %w", err)
|
||||
}
|
||||
if response == nil || response.Response == nil {
|
||||
return nil, fmt.Errorf("describe captcha result: empty response")
|
||||
}
|
||||
return &service.TencentCaptchaVerifyResponse{
|
||||
CaptchaCode: valueOrZero(response.Response.CaptchaCode),
|
||||
CaptchaMsg: valueOrEmpty(response.Response.CaptchaMsg),
|
||||
RequestID: valueOrEmpty(response.Response.RequestId),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func valueOrZero(value *int64) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func valueOrEmpty(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//go:build unit
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
capcha "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha/v20190722"
|
||||
)
|
||||
|
||||
type tencentCaptchaAPIStub struct {
|
||||
request *capcha.DescribeCaptchaResultRequest
|
||||
response *capcha.DescribeCaptchaResultResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *tencentCaptchaAPIStub) DescribeCaptchaResultWithContext(_ context.Context, request *capcha.DescribeCaptchaResultRequest) (*capcha.DescribeCaptchaResultResponse, error) {
|
||||
s.request = request
|
||||
return s.response, s.err
|
||||
}
|
||||
|
||||
func TestTencentCaptchaVerifierMapsCredentialsRequestAndResponse(t *testing.T) {
|
||||
code := int64(1)
|
||||
message := "OK"
|
||||
requestID := "request-id"
|
||||
client := &tencentCaptchaAPIStub{response: &capcha.DescribeCaptchaResultResponse{
|
||||
Response: &capcha.DescribeCaptchaResultResponseParams{
|
||||
CaptchaCode: &code,
|
||||
CaptchaMsg: &message,
|
||||
RequestId: &requestID,
|
||||
},
|
||||
}}
|
||||
var gotSecretID, gotSecretKey string
|
||||
verifier := &tencentCaptchaVerifier{
|
||||
newClient: func(secretID, secretKey string) (tencentCaptchaAPI, error) {
|
||||
gotSecretID, gotSecretKey = secretID, secretKey
|
||||
return client, nil
|
||||
},
|
||||
}
|
||||
|
||||
result, err := verifier.VerifyTicket(context.Background(), service.TencentCaptchaCredentials{
|
||||
AppID: 123456789,
|
||||
AppSecretKey: "app-secret",
|
||||
CloudSecretID: "cloud-secret-id",
|
||||
CloudSecretKey: "cloud-secret-key",
|
||||
}, service.TencentCaptchaProof{Ticket: "ticket", Randstr: "@rand"}, "203.0.113.10")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "cloud-secret-id", gotSecretID)
|
||||
require.Equal(t, "cloud-secret-key", gotSecretKey)
|
||||
require.NotNil(t, client.request)
|
||||
require.Equal(t, uint64(9), *client.request.CaptchaType)
|
||||
require.Equal(t, uint64(123456789), *client.request.CaptchaAppId)
|
||||
require.Equal(t, "app-secret", *client.request.AppSecretKey)
|
||||
require.Equal(t, "ticket", *client.request.Ticket)
|
||||
require.Equal(t, "@rand", *client.request.Randstr)
|
||||
require.Equal(t, "203.0.113.10", *client.request.UserIp)
|
||||
require.Equal(t, &service.TencentCaptchaVerifyResponse{
|
||||
CaptchaCode: 1,
|
||||
CaptchaMsg: "OK",
|
||||
RequestID: "request-id",
|
||||
}, result)
|
||||
}
|
||||
@@ -713,10 +713,10 @@ func TestUsageLogRepositoryGetUserSpendingRanking(t *testing.T) {
|
||||
start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := start.Add(24 * time.Hour)
|
||||
|
||||
rows := sqlmock.NewRows([]string{"user_id", "email", "actual_cost", "requests", "tokens", "total_actual_cost", "total_requests", "total_tokens"}).
|
||||
AddRow(int64(2), "beta@example.com", 12.5, int64(9), int64(900), 40.0, int64(30), int64(2600)).
|
||||
AddRow(int64(1), "alpha@example.com", 12.5, int64(8), int64(800), 40.0, int64(30), int64(2600)).
|
||||
AddRow(int64(3), "gamma@example.com", 4.25, int64(5), int64(300), 40.0, int64(30), int64(2600))
|
||||
rows := sqlmock.NewRows([]string{"user_id", "email", "username", "actual_cost", "requests", "tokens", "total_actual_cost", "total_requests", "total_tokens"}).
|
||||
AddRow(int64(2), "beta@example.com", "beta", 12.5, int64(9), int64(900), 40.0, int64(30), int64(2600)).
|
||||
AddRow(int64(1), "alpha@example.com", "alpha", 12.5, int64(8), int64(800), 40.0, int64(30), int64(2600)).
|
||||
AddRow(int64(3), "gamma@example.com", "", 4.25, int64(5), int64(300), 40.0, int64(30), int64(2600))
|
||||
|
||||
mock.ExpectQuery("WITH user_spend AS \\(").
|
||||
WithArgs(start, end, 12).
|
||||
@@ -726,8 +726,8 @@ func TestUsageLogRepositoryGetUserSpendingRanking(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, &usagestats.UserSpendingRankingResponse{
|
||||
Ranking: []usagestats.UserSpendingRankingItem{
|
||||
{UserID: 2, Email: "beta@example.com", ActualCost: 12.5, Requests: 9, Tokens: 900},
|
||||
{UserID: 1, Email: "alpha@example.com", ActualCost: 12.5, Requests: 8, Tokens: 800},
|
||||
{UserID: 2, Email: "beta@example.com", Username: "beta", ActualCost: 12.5, Requests: 9, Tokens: 900},
|
||||
{UserID: 1, Email: "alpha@example.com", Username: "alpha", ActualCost: 12.5, Requests: 8, Tokens: 800},
|
||||
{UserID: 3, Email: "gamma@example.com", ActualCost: 4.25, Requests: 5, Tokens: 300},
|
||||
},
|
||||
TotalActualCost: 40.0,
|
||||
|
||||
@@ -150,18 +150,20 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi
|
||||
SELECT
|
||||
u.user_id,
|
||||
COALESCE(us.email, '') as email,
|
||||
COALESCE(us.username, '') as username,
|
||||
COALESCE(SUM(u.actual_cost), 0) as actual_cost,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(u.input_tokens + u.output_tokens + u.cache_creation_tokens + u.cache_read_tokens), 0) as tokens
|
||||
FROM usage_logs u
|
||||
LEFT JOIN users us ON u.user_id = us.id
|
||||
WHERE u.created_at >= $1 AND u.created_at < $2
|
||||
GROUP BY u.user_id, us.email
|
||||
GROUP BY u.user_id, us.email, us.username
|
||||
),
|
||||
ranked AS (
|
||||
SELECT
|
||||
user_id,
|
||||
email,
|
||||
username,
|
||||
actual_cost,
|
||||
requests,
|
||||
tokens,
|
||||
@@ -175,6 +177,7 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi
|
||||
SELECT
|
||||
user_id,
|
||||
email,
|
||||
username,
|
||||
actual_cost,
|
||||
requests,
|
||||
tokens,
|
||||
@@ -202,7 +205,7 @@ func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTi
|
||||
totalTokens := int64(0)
|
||||
for rows.Next() {
|
||||
var row UserSpendingRankingItem
|
||||
if err = rows.Scan(&row.UserID, &row.Email, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost, &totalRequests, &totalTokens); err != nil {
|
||||
if err = rows.Scan(&row.UserID, &row.Email, &row.Username, &row.ActualCost, &row.Requests, &row.Tokens, &totalActualCost, &totalRequests, &totalTokens); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ranking = append(ranking, row)
|
||||
|
||||
@@ -859,6 +859,49 @@ func (r *userRepository) DeductBalance(ctx context.Context, id int64, amount flo
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeductAvailableBalance atomically deducts min(amount, max(balance, 0)).
|
||||
// Unlike DeductBalance, this refund-specific operation never increases an
|
||||
// existing deficit or permits a concurrent deduction to cause an overdraft.
|
||||
func (r *userRepository) DeductAvailableBalance(ctx context.Context, id int64, amount float64) (deducted float64, err error) {
|
||||
if amount < 0 {
|
||||
return 0, fmt.Errorf("deduction amount must be nonnegative")
|
||||
}
|
||||
const updateSQL = `
|
||||
WITH target AS (
|
||||
SELECT id, balance
|
||||
FROM users
|
||||
WHERE id = $2 AND deleted_at IS NULL
|
||||
FOR UPDATE
|
||||
), updated AS (
|
||||
UPDATE users AS u
|
||||
SET balance = target.balance - LEAST($1, GREATEST(target.balance, 0)), updated_at = NOW()
|
||||
FROM target
|
||||
WHERE u.id = target.id AND u.deleted_at IS NULL
|
||||
RETURNING target.balance - u.balance AS deducted
|
||||
)
|
||||
SELECT deducted FROM updated
|
||||
`
|
||||
rows, err := clientFromContext(ctx, r.client).QueryContext(ctx, updateSQL, amount, id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
}()
|
||||
if !rows.Next() {
|
||||
if rowsErr := rows.Err(); rowsErr != nil {
|
||||
return 0, rowsErr
|
||||
}
|
||||
return 0, service.ErrUserNotFound
|
||||
}
|
||||
if err := rows.Scan(&deducted); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return deducted, rows.Err()
|
||||
}
|
||||
|
||||
// AdjustBalance 原子地把 delta 累加到余额上,结果为负时整条语句不生效。
|
||||
// 相比"读余额 → 算新值 → 整行写回",这里把读与写压进同一条 UPDATE,
|
||||
// 并发的计费扣款不会被旧快照覆盖。
|
||||
|
||||
@@ -4,6 +4,7 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -479,6 +480,30 @@ func (s *UserRepoSuite) TestDeductBalance_AllowsOverdraft() {
|
||||
s.Require().InDelta(-5.0, got.Balance, 1e-6, "Balance should be -5.0 after overdraft")
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestDeductAvailableBalance_ClampsToNonnegativeBalance() {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
balance float64
|
||||
requested float64
|
||||
wantDeduct float64
|
||||
wantBalance float64
|
||||
}{
|
||||
{name: "enough balance", balance: 10, requested: 4, wantDeduct: 4, wantBalance: 6},
|
||||
{name: "insufficient balance", balance: 5, requested: 10, wantDeduct: 5, wantBalance: 0},
|
||||
{name: "negative balance unchanged", balance: -3, requested: 10, wantDeduct: 0, wantBalance: -3},
|
||||
} {
|
||||
s.Run(tc.name, func() {
|
||||
user := s.mustCreateUser(&service.User{Email: "available-" + strings.ReplaceAll(tc.name, " ", "-") + "@test.com", Balance: tc.balance})
|
||||
deducted, err := s.repo.DeductAvailableBalance(s.ctx, user.ID, tc.requested)
|
||||
s.Require().NoError(err)
|
||||
s.Require().InDelta(tc.wantDeduct, deducted, 1e-6)
|
||||
got, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err)
|
||||
s.Require().InDelta(tc.wantBalance, got.Balance, 1e-6)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Concurrency ---
|
||||
|
||||
func (s *UserRepoSuite) TestUpdateConcurrency() {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
_ "github.com/Wei-Shaw/sub2api/ent/runtime"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"entgo.io/ent/dialect"
|
||||
entsql "entgo.io/ent/dialect/sql"
|
||||
)
|
||||
|
||||
func TestUserSubscriptionGetByIDForUpdateLocksRow(t *testing.T) {
|
||||
var capturedSQL string
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(captureEntQueryMatcher{actual: &capturedSQL}))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
driver := entsql.OpenDB(dialect.Postgres, db)
|
||||
client := dbent.NewClient(dbent.Driver(driver))
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
repo := NewUserSubscriptionRepository(client)
|
||||
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
mock.ExpectQuery("locked subscription").WillReturnRows(
|
||||
sqlmock.NewRows(usersubscription.Columns).AddRow(
|
||||
int64(7), now, now, nil, int64(11), int64(13), now, now.AddDate(0, 0, 30), "active",
|
||||
nil, nil, nil, 0.0, 0.0, 0.0, nil, now, "renewal",
|
||||
),
|
||||
)
|
||||
|
||||
sub, err := repo.GetByIDForUpdate(context.Background(), 7)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(7), sub.ID)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
require.Contains(t, strings.ToUpper(normalizeSQLWhitespace(capturedSQL)), "FOR UPDATE")
|
||||
}
|
||||
@@ -75,6 +75,18 @@ func (r *userSubscriptionRepository) GetByID(ctx context.Context, id int64) (*se
|
||||
return userSubscriptionEntityToService(m), nil
|
||||
}
|
||||
|
||||
func (r *userSubscriptionRepository) GetByIDForUpdate(ctx context.Context, id int64) (*service.UserSubscription, error) {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
m, err := client.UserSubscription.Query().
|
||||
Where(usersubscription.IDEQ(id)).
|
||||
ForUpdate().
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
return nil, translatePersistenceError(err, service.ErrSubscriptionNotFound, nil)
|
||||
}
|
||||
return userSubscriptionEntityToService(m), nil
|
||||
}
|
||||
|
||||
func (r *userSubscriptionRepository) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.UserSubscription, error) {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
queryCtx := mixins.SkipSoftDelete(ctx)
|
||||
|
||||
@@ -149,6 +149,7 @@ var ProviderSet = wire.NewSet(
|
||||
|
||||
// HTTP service ports (DI Strategy A: return interface directly)
|
||||
NewTurnstileVerifier,
|
||||
NewTencentCaptchaVerifier,
|
||||
ProvidePricingRemoteClient,
|
||||
ProvideGitHubReleaseClient,
|
||||
NewProxyExitInfoProber,
|
||||
|
||||
@@ -419,7 +419,7 @@ func contentTexts(value any) []string {
|
||||
continue
|
||||
}
|
||||
typeName := strings.ToLower(stringValue(object["type"]))
|
||||
if typeName != "" && typeName != "text" && typeName != "input_text" {
|
||||
if typeName != "" && typeName != "text" && typeName != "input_text" && typeName != "output_text" {
|
||||
continue
|
||||
}
|
||||
if text := stringValue(object["text"]); text != "" {
|
||||
|
||||
@@ -336,6 +336,38 @@ func TestBlockingPromptSnapshotLimitsInputToLatestUserAndPreviousOutput(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentTextsIncludesSupportedTextTypes(t *testing.T) {
|
||||
value := []any{
|
||||
map[string]any{"type": "text", "text": "plain text"},
|
||||
map[string]any{"type": "input_text", "text": "input text"},
|
||||
map[string]any{"type": "output_text", "text": "output text"},
|
||||
map[string]any{"type": "image_url", "text": "ignored text"},
|
||||
}
|
||||
|
||||
require.Equal(t, []string{"plain text", "input text", "output text"}, contentTexts(value))
|
||||
}
|
||||
|
||||
func TestResponsesOutputTextIncludedInFullAndLatestTurnSnapshots(t *testing.T) {
|
||||
body := []byte(`{"input":[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"earlier user input"}]},
|
||||
{"type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","annotations":[],"text":"captured previous assistant output"}]},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"captured latest user input"}]}
|
||||
]}`)
|
||||
|
||||
req := Request{Protocol: "openai_responses", Body: body}
|
||||
full, err := ExtractPromptSnapshot(req)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, full.ScanText, "captured previous assistant output")
|
||||
require.Contains(t, full.FullPrompt, "captured previous assistant output")
|
||||
require.Equal(t, 3, full.MessageCount)
|
||||
|
||||
latestTurn, err := ExtractBlockingPromptSnapshot(req, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "captured latest user input"+promptAuditPrioritySeparator+"captured previous assistant output", latestTurn.ScanText)
|
||||
require.Equal(t, 2, latestTurn.MessageCount)
|
||||
require.NotContains(t, latestTurn.ScanText, "earlier user input")
|
||||
}
|
||||
|
||||
func TestBlockingPromptSnapshotPreservesFullScopeByDefaultAndWithoutUserInput(t *testing.T) {
|
||||
req := Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"system","content":"system instruction"},{"role":"user","content":"older user input"},{"role":"assistant","content":"previous output"},{"role":"user","content":"latest user input"}]}`)}
|
||||
full, err := ExtractPromptSnapshot(req)
|
||||
|
||||
@@ -741,6 +741,11 @@ func TestAPIContracts(t *testing.T) {
|
||||
"turnstile_enabled": true,
|
||||
"turnstile_site_key": "site-key",
|
||||
"turnstile_secret_key_configured": true,
|
||||
"tencent_captcha_enabled": false,
|
||||
"tencent_captcha_app_id": "",
|
||||
"tencent_captcha_app_secret_key_configured": false,
|
||||
"tencent_captcha_cloud_secret_id_configured": false,
|
||||
"tencent_captcha_cloud_secret_key_configured": false,
|
||||
"linuxdo_connect_enabled": false,
|
||||
"linuxdo_connect_client_id": "",
|
||||
"linuxdo_connect_client_secret_configured": false,
|
||||
@@ -926,6 +931,9 @@ func TestAPIContracts(t *testing.T) {
|
||||
"openai_advanced_scheduler_effective_weight_previous_response": "5",
|
||||
"openai_advanced_scheduler_effective_weight_session_sticky": "3",
|
||||
"openai_codex_user_agent": "",
|
||||
"openai_codex_client_version": "",
|
||||
"openai_codex_client_version_synced": "",
|
||||
"openai_codex_version_auto_sync_enabled": true,
|
||||
"openai_fast_policy_settings": {
|
||||
"rules": []
|
||||
},
|
||||
@@ -1066,6 +1074,11 @@ func TestAPIContracts(t *testing.T) {
|
||||
"turnstile_enabled": false,
|
||||
"turnstile_site_key": "",
|
||||
"turnstile_secret_key_configured": false,
|
||||
"tencent_captcha_enabled": false,
|
||||
"tencent_captcha_app_id": "",
|
||||
"tencent_captcha_app_secret_key_configured": false,
|
||||
"tencent_captcha_cloud_secret_id_configured": false,
|
||||
"tencent_captcha_cloud_secret_key_configured": false,
|
||||
"linuxdo_connect_enabled": false,
|
||||
"linuxdo_connect_client_id": "",
|
||||
"linuxdo_connect_client_secret_configured": false,
|
||||
@@ -1216,6 +1229,9 @@ func TestAPIContracts(t *testing.T) {
|
||||
"openai_advanced_scheduler_effective_weight_previous_response": "5",
|
||||
"openai_advanced_scheduler_effective_weight_session_sticky": "3",
|
||||
"openai_codex_user_agent": "",
|
||||
"openai_codex_client_version": "",
|
||||
"openai_codex_client_version_synced": "",
|
||||
"openai_codex_version_auto_sync_enabled": true,
|
||||
"openai_fast_policy_settings": {
|
||||
"rules": []
|
||||
},
|
||||
@@ -2163,6 +2179,9 @@ func (stubUserSubscriptionRepo) Create(ctx context.Context, sub *service.UserSub
|
||||
func (stubUserSubscriptionRepo) GetByID(ctx context.Context, id int64) (*service.UserSubscription, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (stubUserSubscriptionRepo) GetByIDForUpdate(ctx context.Context, id int64) (*service.UserSubscription, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
func (stubUserSubscriptionRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.UserSubscription, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
@@ -176,6 +176,10 @@ func (f fakeGoogleSubscriptionRepo) GetByID(ctx context.Context, id int64) (*ser
|
||||
}
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (f fakeGoogleSubscriptionRepo) GetByIDForUpdate(ctx context.Context, id int64) (*service.UserSubscription, error) {
|
||||
return f.GetByID(ctx, id)
|
||||
}
|
||||
func (f fakeGoogleSubscriptionRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.UserSubscription, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
@@ -1683,6 +1683,10 @@ func (r *stubUserSubscriptionRepo) GetByID(ctx context.Context, id int64) (*serv
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubUserSubscriptionRepo) GetByIDForUpdate(ctx context.Context, id int64) (*service.UserSubscription, error) {
|
||||
return r.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (r *stubUserSubscriptionRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.UserSubscription, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ const (
|
||||
NonceTemplate = "__CSP_NONCE__"
|
||||
// CloudflareInsightsDomain is the domain for Cloudflare Web Analytics
|
||||
CloudflareInsightsDomain = "https://static.cloudflareinsights.com"
|
||||
// TencentCaptchaDomain is the Tencent Captcha 2.0 Web SDK domain.
|
||||
TencentCaptchaDomain = "https://turing.captcha.qcloud.com"
|
||||
// TencentCaptchaStaticDomain is the Tencent Captcha static asset domain.
|
||||
TencentCaptchaStaticDomain = "https://*.captcha.gtimg.com"
|
||||
// StripeDomain is the domain for Stripe.js SDK
|
||||
StripeDomain = "https://*.stripe.com"
|
||||
// AirwallexStaticDomain 是 Airwallex 生产环境 SDK 脚本域名。
|
||||
@@ -35,6 +39,9 @@ var requiredCSPDirectiveValues = []struct {
|
||||
value string
|
||||
}{
|
||||
{"script-src", CloudflareInsightsDomain},
|
||||
{"script-src", TencentCaptchaDomain},
|
||||
{"frame-src", TencentCaptchaDomain},
|
||||
{"style-src", TencentCaptchaStaticDomain},
|
||||
{"script-src", StripeDomain},
|
||||
{"frame-src", StripeDomain},
|
||||
{"script-src", AirwallexStaticDomain},
|
||||
@@ -127,8 +134,8 @@ func isAPIRoutePath(c *gin.Context) bool {
|
||||
strings.HasPrefix(path, "/images")
|
||||
}
|
||||
|
||||
// enhanceCSPPolicy 确保 CSP 策略包含 nonce 支持和支付 SDK 必需域名。
|
||||
// 这样旧配置文件没有及时补域名时,前端支付组件仍能正常加载。
|
||||
// enhanceCSPPolicy 确保 CSP 策略包含 nonce 支持和运行时组件必需域名。
|
||||
// 这样旧配置文件没有及时补域名时,验证码和支付组件仍能正常加载。
|
||||
func enhanceCSPPolicy(policy string) string {
|
||||
// Add nonce placeholder to script-src if not present
|
||||
if !strings.Contains(policy, NonceTemplate) && !strings.Contains(policy, "'nonce-") {
|
||||
|
||||
@@ -192,6 +192,7 @@ func TestSecurityHeaders(t *testing.T) {
|
||||
assert.NotEmpty(t, csp)
|
||||
// Default policy should contain these elements
|
||||
assert.Contains(t, csp, "default-src 'self'")
|
||||
assert.Contains(t, csp, TencentCaptchaDomain)
|
||||
})
|
||||
|
||||
t.Run("uses_default_policy_when_whitespace_only", func(t *testing.T) {
|
||||
@@ -313,6 +314,16 @@ func TestEnhanceCSPPolicy(t *testing.T) {
|
||||
assert.Equal(t, 1, count)
|
||||
})
|
||||
|
||||
t.Run("adds_tencent_captcha_domain_for_web_sdk", func(t *testing.T) {
|
||||
policy := "default-src 'self'; script-src 'self' __CSP_NONCE__"
|
||||
enhanced := enhanceCSPPolicy(policy)
|
||||
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "script-src", TencentCaptchaDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "frame-src", TencentCaptchaDomain))
|
||||
assert.Equal(t, 1, countDirectiveValue(enhanced, "style-src", TencentCaptchaStaticDomain))
|
||||
assert.Contains(t, config.DefaultCSPPolicy, "style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com")
|
||||
})
|
||||
|
||||
t.Run("handles_policy_without_script_src", func(t *testing.T) {
|
||||
policy := "default-src 'self'"
|
||||
enhanced := enhanceCSPPolicy(policy)
|
||||
|
||||
@@ -437,6 +437,7 @@ func registerOpenAIOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
openai.POST("/create-from-oauth", h.Admin.OpenAIOAuth.CreateAccountFromOAuth)
|
||||
openai.POST("/create-from-codex-pat", h.Admin.OpenAIOAuth.CreateAccountFromCodexPAT)
|
||||
openai.GET("/accounts/:id/quota", h.Admin.OpenAIOAuth.QueryQuota)
|
||||
openai.POST("/accounts/:id/quota/refresh", h.Admin.OpenAIOAuth.RefreshQuota)
|
||||
openai.POST("/accounts/:id/reset-quota", h.Admin.OpenAIOAuth.ResetQuota)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,13 @@ func RegisterAuthRoutes(
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}), h.Auth.ResetPassword)
|
||||
auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart)
|
||||
auth.POST("/oauth/linuxdo/start", rateLimiter.LimitWithOptions("oauth-linuxdo-start", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}), h.Auth.LinuxDoOAuthStart)
|
||||
auth.GET("/oauth/github/start", h.Auth.GitHubOAuthStart)
|
||||
auth.POST("/oauth/github/start", rateLimiter.LimitWithOptions("oauth-github-start", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}), h.Auth.GitHubOAuthStart)
|
||||
auth.GET("/oauth/github/callback", h.Auth.GitHubOAuthCallback)
|
||||
auth.POST("/oauth/github/complete-registration",
|
||||
rateLimiter.LimitWithOptions("oauth-github-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||||
@@ -82,6 +88,9 @@ func RegisterAuthRoutes(
|
||||
h.Auth.CompleteGitHubOAuthRegistration,
|
||||
)
|
||||
auth.GET("/oauth/google/start", h.Auth.GoogleOAuthStart)
|
||||
auth.POST("/oauth/google/start", rateLimiter.LimitWithOptions("oauth-google-start", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}), h.Auth.GoogleOAuthStart)
|
||||
auth.GET("/oauth/google/callback", h.Auth.GoogleOAuthCallback)
|
||||
auth.POST("/oauth/google/complete-registration",
|
||||
rateLimiter.LimitWithOptions("oauth-google-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||||
@@ -97,6 +106,9 @@ func RegisterAuthRoutes(
|
||||
})
|
||||
auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback)
|
||||
auth.GET("/oauth/wechat/start", h.Auth.WeChatOAuthStart)
|
||||
auth.POST("/oauth/wechat/start", rateLimiter.LimitWithOptions("oauth-wechat-start", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}), h.Auth.WeChatOAuthStart)
|
||||
auth.GET("/oauth/wechat/bind/start", func(c *gin.Context) {
|
||||
query := c.Request.URL.Query()
|
||||
query.Set("intent", "bind_current_user")
|
||||
@@ -167,6 +179,9 @@ func RegisterAuthRoutes(
|
||||
h.Auth.CreateWeChatOAuthAccount,
|
||||
)
|
||||
auth.GET("/oauth/oidc/start", h.Auth.OIDCOAuthStart)
|
||||
auth.POST("/oauth/oidc/start", rateLimiter.LimitWithOptions("oauth-oidc-start", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}), h.Auth.OIDCOAuthStart)
|
||||
auth.GET("/oauth/oidc/bind/start", func(c *gin.Context) {
|
||||
query := c.Request.URL.Query()
|
||||
query.Set("intent", "bind_current_user")
|
||||
@@ -193,6 +208,9 @@ func RegisterAuthRoutes(
|
||||
h.Auth.CreateOIDCOAuthAccount,
|
||||
)
|
||||
auth.GET("/oauth/dingtalk/start", h.Auth.DingTalkOAuthStart)
|
||||
auth.POST("/oauth/dingtalk/start", rateLimiter.LimitWithOptions("oauth-dingtalk-start", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}), h.Auth.DingTalkOAuthStart)
|
||||
auth.GET("/oauth/dingtalk/bind/start", func(c *gin.Context) {
|
||||
query := c.Request.URL.Query()
|
||||
query.Set("intent", "bind_current_user")
|
||||
|
||||
@@ -653,8 +653,9 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
||||
req.Header.Set("User-Agent", codexCLIUserAgent)
|
||||
}
|
||||
setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount)
|
||||
// 与真实转发一致:originator 与最终 User-Agent 首段配套,否则上游 404(issue #3901)。
|
||||
enforceCodexIdentityHeaders(req.Header)
|
||||
// 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入,否则测试用的身份
|
||||
// 与该账号真实出站的身份不是同一个(issue #3901 的配对不变式由收口保证)。
|
||||
enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent())
|
||||
}
|
||||
|
||||
// 账号级请求头覆写:测试请求与真实转发保持一致的最终头
|
||||
@@ -1866,8 +1867,9 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
|
||||
req.Header.Set("User-Agent", codexCLIUserAgent)
|
||||
}
|
||||
setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount)
|
||||
// 与真实转发一致:originator 与最终 User-Agent 首段配套(原 opencode 与 Codex UA 错配会 404,issue #3901)。
|
||||
enforceCodexIdentityHeaders(req.Header)
|
||||
// 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入,否则测试用的身份
|
||||
// 与该账号真实出站的身份不是同一个(issue #3901 的配对不变式由收口保证)。
|
||||
enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent())
|
||||
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
|
||||
@@ -113,7 +113,7 @@ const (
|
||||
openAIProbeCacheTTL = 10 * time.Minute
|
||||
grokProbeRetryTTL = 1 * time.Minute
|
||||
grokFreeQuotaWindow = 24 * time.Hour
|
||||
openAICodexProbeVersion = "0.144.1"
|
||||
openAICodexProbeVersion = codexCLIVersion // 与网关出站身份同源,避免两处硬编码版本各自漂移
|
||||
)
|
||||
|
||||
// UsageCache 封装账户使用量相关的缓存
|
||||
@@ -767,9 +767,10 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco
|
||||
req.Header.Set("User-Agent", strings.TrimSpace(fp.UserAgent))
|
||||
}
|
||||
}
|
||||
// 与真实转发一致:originator 与最终 User-Agent(可能来自指纹缓存,如 codex-tui)首段配套,
|
||||
// 否则探针被上游 404(issue #3901)。
|
||||
enforceCodexIdentityHeaders(req.Header)
|
||||
// 与真实转发一致:账号级自定义 UA 同样作为管理员显式配置传入。
|
||||
// 上面写进 header 的指纹缓存 UA 只在强制统一被关闭时才参与配对(保持回滚后的历史语义);
|
||||
// 强制统一开启时客户端身份不参与构造,探针与真实转发用同一套规范身份出站。
|
||||
enforceCodexIdentityHeadersWithUA(req.Header, account.GetOpenAIUserAgent())
|
||||
setOpenAIChatGPTAccountHeaders(req.Header, account)
|
||||
|
||||
proxyURL := ""
|
||||
|
||||
@@ -41,14 +41,20 @@ func TestAdminService_CreateCompositeGroupCopiesAccountsFromConcreteGroups(t *te
|
||||
svc := &adminServiceImpl{groupRepo: groupRepo}
|
||||
|
||||
group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{
|
||||
Name: "Composite",
|
||||
Platform: PlatformComposite,
|
||||
RateMultiplier: 1,
|
||||
Name: "Composite",
|
||||
Platform: PlatformComposite,
|
||||
RateMultiplier: 1,
|
||||
MaxReasoningEffort: "medium",
|
||||
ReasoningEffortMappings: []ReasoningEffortMapping{
|
||||
{From: "max", To: "xhigh"},
|
||||
},
|
||||
CopyAccountsFromGroupIDs: []int64{10, 20, 10},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PlatformComposite, groupRepo.created.Platform)
|
||||
require.Equal(t, "medium", groupRepo.created.MaxReasoningEffort)
|
||||
require.Equal(t, []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, groupRepo.created.ReasoningEffortMappings)
|
||||
require.Equal(t, int64(99), group.ID)
|
||||
require.Equal(t, int64(2), group.AccountCount)
|
||||
require.ElementsMatch(t, []int64{10, 20}, copiedFrom)
|
||||
@@ -82,13 +88,19 @@ func TestAdminService_UpdateCompositeGroupCopiesAccountsFromConcreteGroups(t *te
|
||||
},
|
||||
}
|
||||
svc := &adminServiceImpl{groupRepo: groupRepo}
|
||||
maxReasoningEffort := "low"
|
||||
reasoningEffortMappings := []ReasoningEffortMapping{{From: "max", To: "high"}}
|
||||
|
||||
group, err := svc.UpdateGroup(context.Background(), 99, &UpdateGroupInput{
|
||||
MaxReasoningEffort: &maxReasoningEffort,
|
||||
ReasoningEffortMappings: &reasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: []int64{10, 20},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PlatformComposite, group.Platform)
|
||||
require.Equal(t, "low", group.MaxReasoningEffort)
|
||||
require.Equal(t, reasoningEffortMappings, group.ReasoningEffortMappings)
|
||||
require.Equal(t, int64(99), clearedGroupID)
|
||||
require.ElementsMatch(t, []int64{10, 20}, copiedFrom)
|
||||
require.Equal(t, int64(99), boundGroupID)
|
||||
|
||||
@@ -296,8 +296,8 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesReasoningEffortPolicy(t *testi
|
||||
},
|
||||
Group: &Group{
|
||||
ID: groupID,
|
||||
Name: "openai",
|
||||
Platform: PlatformOpenAI,
|
||||
Name: "composite",
|
||||
Platform: PlatformComposite,
|
||||
Status: StatusActive,
|
||||
SubscriptionType: SubscriptionTypeStandard,
|
||||
RateMultiplier: 1,
|
||||
@@ -313,6 +313,7 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesReasoningEffortPolicy(t *testi
|
||||
|
||||
require.NotNil(t, roundTrip)
|
||||
require.NotNil(t, roundTrip.Group)
|
||||
require.Equal(t, PlatformComposite, roundTrip.Group.Platform)
|
||||
require.Equal(t, "medium", roundTrip.Group.MaxReasoningEffort)
|
||||
require.Equal(t, apiKey.Group.ReasoningEffortMappings, roundTrip.Group.ReasoningEffortMappings)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ var (
|
||||
ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required")
|
||||
ErrInvitationCodeInvalid = infraerrors.BadRequest("INVITATION_CODE_INVALID", "invalid or used invitation code")
|
||||
ErrOAuthInvitationRequired = infraerrors.Forbidden("OAUTH_INVITATION_REQUIRED", "invitation code required to complete oauth registration")
|
||||
ErrCaptchaProviderConflict = infraerrors.ServiceUnavailable("CAPTCHA_PROVIDER_CONFLICT", "multiple captcha providers are enabled")
|
||||
)
|
||||
|
||||
// maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。
|
||||
@@ -74,6 +75,7 @@ type AuthService struct {
|
||||
settingService *SettingService
|
||||
emailService *EmailService
|
||||
turnstileService *TurnstileService
|
||||
tencentCaptchaService *TencentCaptchaService
|
||||
emailQueueService *EmailQueueService
|
||||
promoService *PromoService
|
||||
affiliateService *AffiliateService
|
||||
@@ -81,6 +83,12 @@ type AuthService struct {
|
||||
userPlatformQuotaRepo UserPlatformQuotaRepository
|
||||
}
|
||||
|
||||
type CaptchaProof struct {
|
||||
TurnstileToken string
|
||||
TencentTicket string
|
||||
TencentRandstr string
|
||||
}
|
||||
|
||||
type DefaultSubscriptionAssigner interface {
|
||||
AssignOrExtendSubscription(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, bool, error)
|
||||
}
|
||||
@@ -132,6 +140,10 @@ func (s *AuthService) EntClient() *dbent.Client {
|
||||
return s.entClient
|
||||
}
|
||||
|
||||
func (s *AuthService) SetTencentCaptchaService(tencentCaptchaService *TencentCaptchaService) {
|
||||
s.tencentCaptchaService = tencentCaptchaService
|
||||
}
|
||||
|
||||
// Register 用户注册,返回token和用户
|
||||
func (s *AuthService) Register(ctx context.Context, email, password string) (string, *User, error) {
|
||||
return s.RegisterWithVerification(ctx, email, password, "", "", "", "")
|
||||
@@ -373,47 +385,92 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string, loc
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyTurnstileForRegister 在注册场景下验证 Turnstile。
|
||||
// 当邮箱验证开启且已提交验证码时,说明验证码发送阶段已完成 Turnstile 校验,
|
||||
// VerifyCaptchaForRegister 在注册场景下验证当前启用的验证码。
|
||||
// 当邮箱验证开启且已提交验证码时,说明验证码发送阶段已完成验证码校验,
|
||||
// 此处跳过二次校验,避免一次性 token 在注册提交时重复使用导致误报失败。
|
||||
func (s *AuthService) VerifyTurnstileForRegister(ctx context.Context, token, remoteIP, verifyCode string) error {
|
||||
func (s *AuthService) VerifyCaptchaForRegister(ctx context.Context, proof CaptchaProof, remoteIP, verifyCode string) error {
|
||||
if s.IsEmailVerifyEnabled(ctx) && strings.TrimSpace(verifyCode) != "" {
|
||||
logger.LegacyPrintf("service.auth", "%s", "[Auth] Email verify flow detected, skip duplicate Turnstile check on register")
|
||||
logger.LegacyPrintf("service.auth", "%s", "[Auth] Email verify flow detected, skip duplicate captcha check on register")
|
||||
return nil
|
||||
}
|
||||
return s.VerifyTurnstile(ctx, token, remoteIP)
|
||||
return s.VerifyCaptcha(ctx, proof, remoteIP)
|
||||
}
|
||||
|
||||
// VerifyTurnstile 验证Turnstile token
|
||||
func (s *AuthService) VerifyTurnstile(ctx context.Context, token string, remoteIP string) error {
|
||||
func (s *AuthService) VerifyCaptcha(ctx context.Context, proof CaptchaProof, remoteIP string) error {
|
||||
required := s.cfg != nil && s.cfg.Server.Mode == "release" && s.cfg.Turnstile.Required
|
||||
|
||||
if required {
|
||||
if s.settingService == nil {
|
||||
logger.LegacyPrintf("service.auth", "%s", "[Auth] Turnstile required but settings service is not configured")
|
||||
return ErrTurnstileNotConfigured
|
||||
}
|
||||
enabled := s.settingService.IsTurnstileEnabled(ctx)
|
||||
secretConfigured := s.settingService.GetTurnstileSecretKey(ctx) != ""
|
||||
if !enabled || !secretConfigured {
|
||||
logger.LegacyPrintf("service.auth", "[Auth] Turnstile required but not configured (enabled=%v, secret_configured=%v)", enabled, secretConfigured)
|
||||
return ErrTurnstileNotConfigured
|
||||
}
|
||||
}
|
||||
|
||||
if s.turnstileService == nil {
|
||||
if s.settingService == nil {
|
||||
if required {
|
||||
logger.LegacyPrintf("service.auth", "%s", "[Auth] Turnstile required but service not configured")
|
||||
return ErrTurnstileNotConfigured
|
||||
}
|
||||
return nil // 服务未配置则跳过验证
|
||||
return nil
|
||||
}
|
||||
|
||||
if !required && s.settingService != nil && s.settingService.IsTurnstileEnabled(ctx) && s.settingService.GetTurnstileSecretKey(ctx) == "" {
|
||||
logger.LegacyPrintf("service.auth", "%s", "[Auth] Turnstile enabled but secret key not configured")
|
||||
providerConfig, err := s.settingService.GetCaptchaProviderConfig(ctx)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.auth", "%s", "[Auth] Failed to read captcha provider settings")
|
||||
return ErrServiceUnavailable
|
||||
}
|
||||
turnstileEnabled := providerConfig.TurnstileEnabled
|
||||
tencentEnabled := providerConfig.Tencent.Enabled
|
||||
if turnstileEnabled && tencentEnabled {
|
||||
return ErrCaptchaProviderConflict
|
||||
}
|
||||
if tencentEnabled {
|
||||
if s.tencentCaptchaService == nil {
|
||||
return ErrTencentCaptchaNotConfigured
|
||||
}
|
||||
return s.tencentCaptchaService.VerifyTicketWithConfig(ctx, providerConfig.Tencent, proof.TencentTicket, proof.TencentRandstr, remoteIP)
|
||||
}
|
||||
if turnstileEnabled {
|
||||
if s.turnstileService == nil || strings.TrimSpace(providerConfig.TurnstileSecretKey) == "" {
|
||||
return ErrTurnstileNotConfigured
|
||||
}
|
||||
return s.turnstileService.VerifyTokenWithSecret(ctx, providerConfig.TurnstileSecretKey, proof.TurnstileToken, remoteIP)
|
||||
}
|
||||
if required {
|
||||
return ErrTurnstileNotConfigured
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyTencentCaptchaIfEnabled 仅保护新增的腾讯验证码动作入口,
|
||||
// 不扩大 Cloudflare Turnstile 的既有覆盖范围。
|
||||
func (s *AuthService) VerifyTencentCaptchaIfEnabled(ctx context.Context, proof CaptchaProof, remoteIP string) error {
|
||||
if s == nil || s.settingService == nil {
|
||||
return ErrServiceUnavailable
|
||||
}
|
||||
|
||||
return s.turnstileService.VerifyToken(ctx, token, remoteIP)
|
||||
providerConfig, err := s.settingService.GetCaptchaProviderConfig(ctx)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.auth", "%s", "[Auth] Failed to read captcha provider settings")
|
||||
return ErrServiceUnavailable
|
||||
}
|
||||
if !providerConfig.Tencent.Enabled {
|
||||
return nil
|
||||
}
|
||||
if providerConfig.TurnstileEnabled {
|
||||
return ErrCaptchaProviderConflict
|
||||
}
|
||||
if s.tencentCaptchaService == nil {
|
||||
return ErrTencentCaptchaNotConfigured
|
||||
}
|
||||
return s.tencentCaptchaService.VerifyTicketWithConfig(
|
||||
ctx,
|
||||
providerConfig.Tencent,
|
||||
proof.TencentTicket,
|
||||
proof.TencentRandstr,
|
||||
remoteIP,
|
||||
)
|
||||
}
|
||||
|
||||
// VerifyTurnstileForRegister 保留旧内部接口,生产 handler 使用 VerifyCaptchaForRegister。
|
||||
func (s *AuthService) VerifyTurnstileForRegister(ctx context.Context, token, remoteIP, verifyCode string) error {
|
||||
return s.VerifyCaptchaForRegister(ctx, CaptchaProof{TurnstileToken: token}, remoteIP, verifyCode)
|
||||
}
|
||||
|
||||
// VerifyTurnstile 保留旧内部接口,生产 handler 使用 VerifyCaptcha。
|
||||
func (s *AuthService) VerifyTurnstile(ctx context.Context, token string, remoteIP string) error {
|
||||
return s.VerifyCaptcha(ctx, CaptchaProof{TurnstileToken: token}, remoteIP)
|
||||
}
|
||||
|
||||
// IsTurnstileEnabled 检查是否启用Turnstile验证
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newAuthServiceForCaptchaRepoTest(repo *settingRepoStub, required bool, turnstileVerifier TurnstileVerifier, tencentVerifier TencentCaptchaVerifier) *AuthService {
|
||||
cfg := &config.Config{
|
||||
Server: config.ServerConfig{Mode: "release"},
|
||||
Turnstile: config.TurnstileConfig{Required: required},
|
||||
}
|
||||
settingService := NewSettingService(repo, cfg)
|
||||
turnstileService := NewTurnstileService(settingService, turnstileVerifier)
|
||||
tencentService := NewTencentCaptchaService(settingService, tencentVerifier)
|
||||
svc := NewAuthService(nil, &userRepoStub{}, nil, nil, cfg, settingService, nil, turnstileService, nil, nil, nil, nil, nil)
|
||||
svc.SetTencentCaptchaService(tencentService)
|
||||
return svc
|
||||
}
|
||||
|
||||
func newAuthServiceForCaptchaTest(settings map[string]string, required bool, turnstileVerifier TurnstileVerifier, tencentVerifier TencentCaptchaVerifier) *AuthService {
|
||||
cfg := &config.Config{
|
||||
Server: config.ServerConfig{Mode: "release"},
|
||||
Turnstile: config.TurnstileConfig{Required: required},
|
||||
}
|
||||
settingService := NewSettingService(&settingRepoStub{values: settings}, cfg)
|
||||
var turnstileService *TurnstileService
|
||||
if turnstileVerifier != nil {
|
||||
turnstileService = NewTurnstileService(settingService, turnstileVerifier)
|
||||
}
|
||||
svc := NewAuthService(nil, &userRepoStub{}, nil, nil, cfg, settingService, nil, turnstileService, nil, nil, nil, nil, nil)
|
||||
if tencentVerifier != nil {
|
||||
svc.SetTencentCaptchaService(NewTencentCaptchaService(settingService, tencentVerifier))
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
func tencentCaptchaSettings() map[string]string {
|
||||
return map[string]string{
|
||||
SettingKeyTencentCaptchaEnabled: "true",
|
||||
SettingKeyTencentCaptchaAppID: "123456789",
|
||||
SettingKeyTencentCaptchaAppSecretKey: "app-secret",
|
||||
SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id",
|
||||
SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key",
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCaptchaUsesTencentWhenEnabled(t *testing.T) {
|
||||
verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}}
|
||||
svc := newAuthServiceForCaptchaTest(tencentCaptchaSettings(), false, nil, verifier)
|
||||
|
||||
err := svc.VerifyCaptcha(context.Background(), CaptchaProof{
|
||||
TencentTicket: "ticket",
|
||||
TencentRandstr: "@rand",
|
||||
}, "203.0.113.10")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, verifier.calls)
|
||||
}
|
||||
|
||||
func TestVerifyCaptchaRejectsDirtyDoubleEnabledSettings(t *testing.T) {
|
||||
settings := tencentCaptchaSettings()
|
||||
settings[SettingKeyTurnstileEnabled] = "true"
|
||||
settings[SettingKeyTurnstileSecretKey] = "turnstile-secret"
|
||||
turnstileVerifier := &turnstileVerifierSpy{}
|
||||
tencentVerifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}}
|
||||
svc := newAuthServiceForCaptchaTest(settings, false, turnstileVerifier, tencentVerifier)
|
||||
|
||||
err := svc.VerifyCaptcha(context.Background(), CaptchaProof{
|
||||
TurnstileToken: "turnstile-token",
|
||||
TencentTicket: "ticket",
|
||||
TencentRandstr: "@rand",
|
||||
}, "203.0.113.10")
|
||||
|
||||
require.ErrorIs(t, err, ErrCaptchaProviderConflict)
|
||||
require.Zero(t, turnstileVerifier.called)
|
||||
require.Zero(t, tencentVerifier.calls)
|
||||
}
|
||||
|
||||
func TestVerifyCaptchaRequiredModeAcceptsCompleteTencentProvider(t *testing.T) {
|
||||
verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}}
|
||||
svc := newAuthServiceForCaptchaTest(tencentCaptchaSettings(), true, nil, verifier)
|
||||
|
||||
err := svc.VerifyCaptcha(context.Background(), CaptchaProof{
|
||||
TencentTicket: "ticket",
|
||||
TencentRandstr: "@rand",
|
||||
}, "203.0.113.10")
|
||||
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestVerifyCaptchaForRegisterSkipsDuplicateTencentTicketAfterEmailCode(t *testing.T) {
|
||||
settings := tencentCaptchaSettings()
|
||||
settings[SettingKeyEmailVerifyEnabled] = "true"
|
||||
verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}}
|
||||
svc := newAuthServiceForCaptchaTest(settings, true, nil, verifier)
|
||||
|
||||
err := svc.VerifyCaptchaForRegister(context.Background(), CaptchaProof{}, "203.0.113.10", "123456")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, verifier.calls)
|
||||
}
|
||||
|
||||
func TestVerifyCaptchaFailsClosedWhenProviderSettingsCannotBeRead(t *testing.T) {
|
||||
repo := &settingRepoStub{err: errors.New("settings unavailable")}
|
||||
svc := newAuthServiceForCaptchaRepoTest(repo, false, &turnstileVerifierSpy{}, &tencentCaptchaVerifierStub{})
|
||||
|
||||
err := svc.VerifyCaptcha(context.Background(), CaptchaProof{}, "203.0.113.10")
|
||||
|
||||
require.ErrorIs(t, err, ErrServiceUnavailable)
|
||||
}
|
||||
|
||||
func TestVerifyCaptchaReadsProviderConfigurationOnce(t *testing.T) {
|
||||
repo := &settingRepoStub{values: tencentCaptchaSettings()}
|
||||
verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}}
|
||||
svc := newAuthServiceForCaptchaRepoTest(repo, false, &turnstileVerifierSpy{}, verifier)
|
||||
|
||||
err := svc.VerifyCaptcha(context.Background(), CaptchaProof{
|
||||
TencentTicket: "ticket",
|
||||
TencentRandstr: "@rand",
|
||||
}, "203.0.113.10")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, repo.getMultipleCalls)
|
||||
require.Zero(t, repo.getValueCalls)
|
||||
require.Equal(t, 1, verifier.calls)
|
||||
}
|
||||
|
||||
func TestVerifyCaptchaRejectsEnabledTencentProviderWithIncompleteCredentials(t *testing.T) {
|
||||
repo := &settingRepoStub{values: map[string]string{
|
||||
SettingKeyTencentCaptchaEnabled: "true",
|
||||
SettingKeyTencentCaptchaAppID: "123456789",
|
||||
}}
|
||||
verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}}
|
||||
svc := newAuthServiceForCaptchaRepoTest(repo, false, &turnstileVerifierSpy{}, verifier)
|
||||
|
||||
err := svc.VerifyCaptcha(context.Background(), CaptchaProof{
|
||||
TencentTicket: "ticket",
|
||||
TencentRandstr: "@rand",
|
||||
}, "203.0.113.10")
|
||||
|
||||
require.ErrorIs(t, err, ErrTencentCaptchaNotConfigured)
|
||||
require.Equal(t, 1, repo.getMultipleCalls)
|
||||
require.Zero(t, verifier.calls)
|
||||
}
|
||||
|
||||
func TestVerifyTencentCaptchaIfEnabledVerifiesTencentProof(t *testing.T) {
|
||||
verifier := &tencentCaptchaVerifierStub{response: &TencentCaptchaVerifyResponse{CaptchaCode: 1}}
|
||||
svc := newAuthServiceForCaptchaTest(tencentCaptchaSettings(), false, nil, verifier)
|
||||
|
||||
err := svc.VerifyTencentCaptchaIfEnabled(context.Background(), CaptchaProof{
|
||||
TencentTicket: "ticket",
|
||||
TencentRandstr: "@rand",
|
||||
}, "203.0.113.10")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, verifier.calls)
|
||||
require.Equal(t, TencentCaptchaProof{Ticket: "ticket", Randstr: "@rand"}, verifier.proof)
|
||||
}
|
||||
|
||||
func TestVerifyTencentCaptchaIfEnabledDoesNotExpandTurnstileCoverage(t *testing.T) {
|
||||
settings := map[string]string{
|
||||
SettingKeyTurnstileEnabled: "true",
|
||||
SettingKeyTurnstileSecretKey: "turnstile-secret",
|
||||
}
|
||||
turnstileVerifier := &turnstileVerifierSpy{}
|
||||
svc := newAuthServiceForCaptchaTest(settings, false, turnstileVerifier, nil)
|
||||
|
||||
err := svc.VerifyTencentCaptchaIfEnabled(context.Background(), CaptchaProof{}, "203.0.113.10")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, turnstileVerifier.called)
|
||||
}
|
||||
|
||||
func TestVerifyTencentCaptchaIfEnabledFailsClosedOnSettingReadError(t *testing.T) {
|
||||
repo := &settingRepoStub{err: errors.New("settings unavailable")}
|
||||
svc := newAuthServiceForCaptchaRepoTest(repo, false, &turnstileVerifierSpy{}, &tencentCaptchaVerifierStub{})
|
||||
|
||||
err := svc.VerifyTencentCaptchaIfEnabled(context.Background(), CaptchaProof{}, "203.0.113.10")
|
||||
|
||||
require.ErrorIs(t, err, ErrServiceUnavailable)
|
||||
}
|
||||
@@ -14,8 +14,10 @@ import (
|
||||
)
|
||||
|
||||
type settingRepoStub struct {
|
||||
values map[string]string
|
||||
err error
|
||||
values map[string]string
|
||||
err error
|
||||
getValueCalls int
|
||||
getMultipleCalls int
|
||||
}
|
||||
|
||||
func (s *settingRepoStub) Get(ctx context.Context, key string) (*Setting, error) {
|
||||
@@ -23,6 +25,7 @@ func (s *settingRepoStub) Get(ctx context.Context, key string) (*Setting, error)
|
||||
}
|
||||
|
||||
func (s *settingRepoStub) GetValue(ctx context.Context, key string) (string, error) {
|
||||
s.getValueCalls++
|
||||
if s.err != nil {
|
||||
return "", s.err
|
||||
}
|
||||
@@ -37,6 +40,7 @@ func (s *settingRepoStub) Set(ctx context.Context, key, value string) error {
|
||||
}
|
||||
|
||||
func (s *settingRepoStub) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
|
||||
s.getMultipleCalls++
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
|
||||
@@ -41,7 +41,11 @@ type PlazaGroup struct {
|
||||
PeakEnd string
|
||||
PeakRateMultiplier float64
|
||||
IsExclusive bool
|
||||
Models []PlazaModel
|
||||
// 图片按次实付倍率:ImageRateIndependent 为 true 时,图片计费模型的实付
|
||||
// = 档位价 × ImageRateMultiplier,不乘分组/用户专属倍率(与计费口径一致)。
|
||||
ImageRateIndependent bool
|
||||
ImageRateMultiplier float64
|
||||
Models []PlazaModel
|
||||
}
|
||||
|
||||
// ListPlazaGroups 返回模型广场数据:每个活跃分组附带其可用模型与定价。
|
||||
@@ -50,6 +54,8 @@ type PlazaGroup struct {
|
||||
// 平台隔离),仅把顶层从渠道换成分组:
|
||||
// - 渠道按 lower(name) 排序后遍历,保证同名模型去重结果确定;
|
||||
// - 同分组同名模型「先见者胜」,仅当已存条目无定价而新条目有定价时升级替换;
|
||||
// - 图片计费模型的档位价按实收口径合成(分组图片价 > 渠道档位价 > 渠道默认按次价,
|
||||
// 见 plazaImageDisplayPricing);
|
||||
// - 每个模型附带 LiteLLM 官方参考价(查不到为 nil);
|
||||
// - 只返回 Models 非空的分组;分组按 RateMultiplier 升序(同倍率按名称),
|
||||
// 组内模型按名称排序。
|
||||
@@ -70,22 +76,26 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err
|
||||
})
|
||||
|
||||
byGroup := make(map[int64]*PlazaGroup, len(groups))
|
||||
groupEnt := make(map[int64]*Group, len(groups))
|
||||
order := make([]int64, 0, len(groups))
|
||||
for i := range groups {
|
||||
g := groups[i]
|
||||
g := &groups[i]
|
||||
byGroup[g.ID] = &PlazaGroup{
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
Platform: g.Platform,
|
||||
SubscriptionType: g.SubscriptionType,
|
||||
RateMultiplier: g.RateMultiplier,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
PeakEnd: g.PeakEnd,
|
||||
PeakRateMultiplier: g.PeakRateMultiplier,
|
||||
IsExclusive: g.IsExclusive,
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
Platform: g.Platform,
|
||||
SubscriptionType: g.SubscriptionType,
|
||||
RateMultiplier: g.RateMultiplier,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
PeakEnd: g.PeakEnd,
|
||||
PeakRateMultiplier: g.PeakRateMultiplier,
|
||||
IsExclusive: g.IsExclusive,
|
||||
ImageRateIndependent: g.ImageRateIndependent,
|
||||
ImageRateMultiplier: g.ImageRateMultiplier,
|
||||
}
|
||||
groupEnt[g.ID] = g
|
||||
order = append(order, g.ID)
|
||||
}
|
||||
|
||||
@@ -115,10 +125,11 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err
|
||||
if m.Platform != pg.Platform {
|
||||
continue
|
||||
}
|
||||
pricing := plazaImageDisplayPricing(m.Pricing, groupEnt[gid])
|
||||
if at, seen := idx[m.Name]; seen {
|
||||
// 先见者胜;仅当已存条目无定价而新条目有定价时升级。
|
||||
if pg.Models[at].Pricing == nil && m.Pricing != nil {
|
||||
pg.Models[at].Pricing = m.Pricing
|
||||
if pg.Models[at].Pricing == nil && pricing != nil {
|
||||
pg.Models[at].Pricing = pricing
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -126,7 +137,7 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err
|
||||
pg.Models = append(pg.Models, PlazaModel{
|
||||
Name: m.Name,
|
||||
Platform: m.Platform,
|
||||
Pricing: m.Pricing,
|
||||
Pricing: pricing,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -155,6 +166,53 @@ func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, err
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// plazaImageDisplayPricing 为图片计费模型合成展示定价,使档位价与实收口径一致:
|
||||
// 每档(1K/2K/4K)单价 = 分组图片价 > 渠道同档位价 > 渠道默认按次价,无价的档不展示。
|
||||
// 分组未配任何图片价、或定价非图片模式时原样返回。返回克隆,不修改入参
|
||||
// (渠道定价指针指向缓存共享数据)。
|
||||
func plazaImageDisplayPricing(p *ChannelModelPricing, g *Group) *ChannelModelPricing {
|
||||
if p == nil || g == nil || p.BillingMode != BillingModeImage {
|
||||
return p
|
||||
}
|
||||
if g.ImagePrice1K == nil && g.ImagePrice2K == nil && g.ImagePrice4K == nil {
|
||||
return p
|
||||
}
|
||||
channelTierPrice := func(label string) *float64 {
|
||||
for i := range p.Intervals {
|
||||
if p.Intervals[i].TierLabel == label && p.Intervals[i].PerRequestPrice != nil {
|
||||
return p.Intervals[i].PerRequestPrice
|
||||
}
|
||||
}
|
||||
return p.PerRequestPrice
|
||||
}
|
||||
tiers := []struct {
|
||||
label string
|
||||
groupPrice *float64
|
||||
}{
|
||||
{"1K", g.ImagePrice1K},
|
||||
{"2K", g.ImagePrice2K},
|
||||
{"4K", g.ImagePrice4K},
|
||||
}
|
||||
clone := *p
|
||||
clone.Intervals = make([]PricingInterval, 0, len(tiers))
|
||||
for i, t := range tiers {
|
||||
price := t.groupPrice
|
||||
if price == nil {
|
||||
price = channelTierPrice(t.label)
|
||||
}
|
||||
if price == nil {
|
||||
continue
|
||||
}
|
||||
v := *price
|
||||
clone.Intervals = append(clone.Intervals, PricingInterval{
|
||||
TierLabel: t.label,
|
||||
PerRequestPrice: &v,
|
||||
SortOrder: i,
|
||||
})
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
// lookupOfficialPricing 查询模型的 LiteLLM 官方参考价,带 memo 避免同名模型重复转换。
|
||||
// pricingService 为 nil(测试场景)或查不到时返回 nil。
|
||||
func (s *ChannelService) lookupOfficialPricing(modelName string, memo map[string]*PlazaOfficialPricing) *PlazaOfficialPricing {
|
||||
|
||||
@@ -173,6 +173,80 @@ func TestListPlazaGroups_OfficialPricingFill(t *testing.T) {
|
||||
require.Nil(t, byName["token-absent"].OfficialPricing)
|
||||
}
|
||||
|
||||
func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) {
|
||||
// 图片计费模型:档位价按实收口径合成(分组图片价 > 渠道档位价 > 渠道默认按次价),
|
||||
// 分组独立倍率字段透传;未配图片价的分组保持渠道定价原样。
|
||||
perReq := 0.2
|
||||
tier4K := 0.3
|
||||
imgPrice := 0.02
|
||||
channels := []Channel{{
|
||||
ID: 1, Name: "img-ch", Status: StatusActive, GroupIDs: []int64{10, 20},
|
||||
ModelPricing: []ChannelModelPricing{{
|
||||
Platform: "openai",
|
||||
Models: []string{"gpt-image-2"},
|
||||
BillingMode: BillingModeImage,
|
||||
PerRequestPrice: &perReq,
|
||||
Intervals: []PricingInterval{{TierLabel: "4K", PerRequestPrice: &tier4K}},
|
||||
}},
|
||||
}}
|
||||
groups := []Group{
|
||||
{ID: 10, Name: "g-media", Platform: "openai", RateMultiplier: 1,
|
||||
ImagePrice1K: &imgPrice, ImageRateIndependent: true, ImageRateMultiplier: 1},
|
||||
{ID: 20, Name: "g-plain", Platform: "openai", RateMultiplier: 0.1},
|
||||
}
|
||||
svc := newPlazaChannelService(channels, groups, nil)
|
||||
out, err := svc.ListPlazaGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 2)
|
||||
byName := map[string]PlazaGroup{}
|
||||
for _, g := range out {
|
||||
byName[g.Name] = g
|
||||
}
|
||||
|
||||
media := byName["g-media"]
|
||||
require.True(t, media.ImageRateIndependent)
|
||||
require.InDelta(t, 1.0, media.ImageRateMultiplier, 1e-9)
|
||||
require.Len(t, media.Models, 1)
|
||||
p := media.Models[0].Pricing
|
||||
require.NotNil(t, p)
|
||||
require.Len(t, p.Intervals, 3)
|
||||
tierPrices := map[string]float64{}
|
||||
for _, iv := range p.Intervals {
|
||||
require.NotNil(t, iv.PerRequestPrice)
|
||||
tierPrices[iv.TierLabel] = *iv.PerRequestPrice
|
||||
}
|
||||
require.InDelta(t, 0.02, tierPrices["1K"], 1e-9, "1K 用分组图片价")
|
||||
require.InDelta(t, 0.2, tierPrices["2K"], 1e-9, "2K 分组未配,回落渠道默认按次价")
|
||||
require.InDelta(t, 0.3, tierPrices["4K"], 1e-9, "4K 分组未配,回落渠道档位价")
|
||||
|
||||
plain := byName["g-plain"]
|
||||
require.False(t, plain.ImageRateIndependent)
|
||||
require.Len(t, plain.Models, 1)
|
||||
pp := plain.Models[0].Pricing
|
||||
require.NotNil(t, pp)
|
||||
require.Len(t, pp.Intervals, 1, "未配分组图片价:渠道定价原样")
|
||||
require.InDelta(t, 0.2, *pp.PerRequestPrice, 1e-9)
|
||||
|
||||
// 合成为克隆,渠道原始定价不被修改
|
||||
require.Len(t, channels[0].ModelPricing[0].Intervals, 1)
|
||||
}
|
||||
|
||||
func TestListPlazaGroups_GroupImagePriceIgnoredForNonImageModes(t *testing.T) {
|
||||
// token 模式定价不受分组图片价影响。
|
||||
imgPrice := 0.02
|
||||
channels := []Channel{plazaPricedChannel(1, "ch", []int64{10}, "openai", "gpt-5")}
|
||||
groups := []Group{{ID: 10, Name: "g", Platform: "openai", RateMultiplier: 1, ImagePrice1K: &imgPrice}}
|
||||
svc := newPlazaChannelService(channels, groups, nil)
|
||||
out, err := svc.ListPlazaGroups(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 1)
|
||||
p := out[0].Models[0].Pricing
|
||||
require.NotNil(t, p)
|
||||
require.Empty(t, p.Intervals)
|
||||
require.NotNil(t, p.InputPrice)
|
||||
require.Nil(t, p.PerRequestPrice)
|
||||
}
|
||||
|
||||
func TestListPlazaGroups_RepoErrorsPropagate(t *testing.T) {
|
||||
sentinel := errors.New("boom")
|
||||
repo := &mockChannelRepository{
|
||||
|
||||
@@ -164,6 +164,13 @@ const (
|
||||
SettingKeyTurnstileSiteKey = "turnstile_site_key" // Turnstile Site Key
|
||||
SettingKeyTurnstileSecretKey = "turnstile_secret_key" // Turnstile Secret Key
|
||||
|
||||
// 腾讯天御验证码设置
|
||||
SettingKeyTencentCaptchaEnabled = "tencent_captcha_enabled"
|
||||
SettingKeyTencentCaptchaAppID = "tencent_captcha_app_id"
|
||||
SettingKeyTencentCaptchaAppSecretKey = "tencent_captcha_app_secret_key"
|
||||
SettingKeyTencentCaptchaCloudSecretID = "tencent_captcha_cloud_secret_id"
|
||||
SettingKeyTencentCaptchaCloudSecretKey = "tencent_captcha_cloud_secret_key"
|
||||
|
||||
// API Key IP 访问控制设置
|
||||
SettingKeyAPIKeyACLTrustForwardedIP = "api_key_acl_trust_forwarded_ip" // API Key IP 白/黑名单是否信任转发 IP
|
||||
SettingKeyForwardedClientIPHeaders = "forwarded_client_ip_headers" // 自定义 CDN 客户端 IP 请求头(JSON 数组)
|
||||
@@ -526,6 +533,16 @@ const (
|
||||
// 当客户端 UA 被识别为浏览器(Chrome/Firefox/Safari/Edge 等)时,转发给 OpenAI 上游前会替换为此值,
|
||||
// 用于避免 Cloudflare 对浏览器型 UA 的质询拦截。
|
||||
SettingKeyOpenAICodexUserAgent = "openai_codex_user_agent"
|
||||
// SettingKeyOpenAICodexClientVersion 网关对 ChatGPT 上游声明的 Codex 客户端版本号(管理员覆写)。
|
||||
// 空值表示跟随自动同步值;自动同步也没有结果时回退到内置常量。
|
||||
// 上游在容量紧张时按客户端身份分优先级降载,陈旧版本会被优先丢弃,故该值需保持跟随官方发布。
|
||||
SettingKeyOpenAICodexClientVersion = "openai_codex_client_version"
|
||||
// SettingKeyOpenAICodexClientVersionSynced 自动同步任务写入的官方 Codex 最新稳定版版本号。
|
||||
// 由 OpenAICodexVersionSyncService 独占写入,面板只读展示;管理员覆写请用
|
||||
// SettingKeyOpenAICodexClientVersion。
|
||||
SettingKeyOpenAICodexClientVersionSynced = "openai_codex_client_version_synced"
|
||||
// SettingKeyOpenAICodexVersionAutoSyncEnabled 是否启用 Codex 客户端版本号自动同步(默认 true)。
|
||||
SettingKeyOpenAICodexVersionAutoSyncEnabled = "openai_codex_version_auto_sync_enabled"
|
||||
// SettingKeyOpenAIAllowClaudeCodeCodexPlugin 已废弃:历史全局开关只作为升级迁移输入读取。
|
||||
// 迁移后等价规则写入 SettingKeyCodexCLIOnlyWhitelist,不再参与运行时判定。
|
||||
SettingKeyOpenAIAllowClaudeCodeCodexPlugin = "openai_allow_claude_code_codex_plugin"
|
||||
|
||||
@@ -577,9 +577,10 @@ func TestGatewayServiceRecordUsage_DroppedUsageLogFallsBackToSyncCreate(t *testi
|
||||
require.NoError(t, usageRepo.lastCtxErr)
|
||||
}
|
||||
|
||||
func TestGatewayServiceRecordUsage_BillingErrorSkipsUsageLogWrite(t *testing.T) {
|
||||
func TestGatewayServiceRecordUsage_BillingErrorWritesUnsettledUsageLog(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{}
|
||||
billingRepo := &openAIRecordUsageBillingRepoStub{err: context.DeadlineExceeded}
|
||||
billingErr := errors.New("billing tx failed")
|
||||
billingRepo := &openAIRecordUsageBillingRepoStub{err: billingErr}
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
subRepo := &openAIRecordUsageSubRepoStub{}
|
||||
svc := newGatewayRecordUsageServiceWithBillingRepoForTest(usageRepo, billingRepo, userRepo, subRepo)
|
||||
@@ -599,9 +600,16 @@ func TestGatewayServiceRecordUsage_BillingErrorSkipsUsageLogWrite(t *testing.T)
|
||||
Account: &Account{ID: 705},
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, billingErr)
|
||||
require.Equal(t, 1, billingRepo.calls)
|
||||
require.Equal(t, 0, usageRepo.calls)
|
||||
require.Equal(t, 1, usageRepo.calls)
|
||||
require.NotNil(t, usageRepo.lastLog)
|
||||
require.Equal(t, 10, usageRepo.lastLog.InputTokens)
|
||||
require.Equal(t, 6, usageRepo.lastLog.OutputTokens)
|
||||
require.Greater(t, usageRepo.lastLog.InputCost, 0.0)
|
||||
require.Greater(t, usageRepo.lastLog.OutputCost, 0.0)
|
||||
require.Greater(t, usageRepo.lastLog.TotalCost, 0.0)
|
||||
require.Zero(t, usageRepo.lastLog.ActualCost)
|
||||
}
|
||||
|
||||
func TestGatewayServiceRecordUsage_ReasoningEffortPersisted(t *testing.T) {
|
||||
|
||||
@@ -633,6 +633,7 @@ type UpstreamFailoverError struct {
|
||||
ResponseHeaders http.Header // 上游响应头,用于透传 cf-ray/cf-mitigated/content-type 等诊断信息
|
||||
ForceCacheBilling bool // Antigravity 粘性会话切换时设为 true
|
||||
RetryableOnSameAccount bool // 临时性错误(如 Google 间歇性 400、空响应),应在同一账号上重试 N 次再切换
|
||||
RequestScopedTransient bool // 故障因素与账号无关(如上游按客户端身份/模型容量降载):可同账号重试,但不得据此对账号做临时封禁
|
||||
SafeToFailoverAfterWrite bool // 仅写出 SSE 注释等非语义字节时,仍可在同一客户端流中切换账号
|
||||
Stage GatewayFailureStage
|
||||
Scope GatewayFailureScope
|
||||
@@ -684,6 +685,11 @@ func (s *GatewayService) TempUnscheduleRetryableError(ctx context.Context, accou
|
||||
if failoverErr == nil || !failoverErr.RetryableOnSameAccount {
|
||||
return
|
||||
}
|
||||
// 请求级瞬时故障与账号健康无关:封禁只会把与故障无关的账号一并摘掉,
|
||||
// 而故障因素(客户端身份、模型容量)在下一个账号上完全相同。
|
||||
if failoverErr.RequestScopedTransient {
|
||||
return
|
||||
}
|
||||
// 根据状态码选择封禁策略
|
||||
switch failoverErr.StatusCode {
|
||||
case http.StatusBadRequest:
|
||||
|
||||
@@ -786,6 +786,8 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
|
||||
}, s.billingDeps(), s.usageBillingRepo)
|
||||
|
||||
if billingErr != nil {
|
||||
usageLog.ActualCost = 0
|
||||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
|
||||
return billingErr
|
||||
}
|
||||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
|
||||
|
||||
@@ -122,6 +122,43 @@ func TestGrokContentPolicy403DoesNotMutateOrFailover(t *testing.T) {
|
||||
require.Zero(t, repo.tempUnschedCalls)
|
||||
}
|
||||
|
||||
func TestGrokNonFailoverDoesNotApplyGenericTempUnschedulablePolicy(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &grokQuotaAccountRepo{}
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: repo,
|
||||
rateLimitService: NewRateLimitService(repo, nil, nil, nil, nil),
|
||||
}
|
||||
account := &Account{
|
||||
ID: 5099,
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"temp_unschedulable_enabled": true,
|
||||
"temp_unschedulable_rules": []any{map[string]any{
|
||||
"error_code": float64(http.StatusForbidden),
|
||||
"keywords": []any{"text is sensitive"},
|
||||
"duration_minutes": float64(1),
|
||||
}},
|
||||
},
|
||||
}
|
||||
body := []byte(`{"error":{"code":"new_sensitive","message":"text is sensitive"}}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
resp := &http.Response{StatusCode: http.StatusForbidden, Header: http.Header{}}
|
||||
|
||||
got := svc.failoverOpenAIUpstreamHTTPError(
|
||||
context.Background(), c, account, resp, body, "text is sensitive", "",
|
||||
)
|
||||
|
||||
require.Nil(t, got)
|
||||
require.Zero(t, repo.tempUnschedCalls)
|
||||
require.Zero(t, repo.rateLimitedCalls)
|
||||
require.Zero(t, repo.updateCalls)
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
}
|
||||
|
||||
func TestGrokContentPolicy403SharedErrorFallbackDoesNotMutate(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"error":{"code":"content_filter","message":"prohibited content"}}`)
|
||||
|
||||
@@ -264,8 +264,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(c
|
||||
req.Header.Set("Session_ID", isolated)
|
||||
req.Header.Set("Conversation_ID", isolated)
|
||||
}
|
||||
s.overrideBrowserUserAgent(ctx, account, req)
|
||||
enforceCodexIdentityHeaders(req.Header)
|
||||
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
return req, nil
|
||||
}
|
||||
@@ -402,8 +401,7 @@ func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context
|
||||
if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
|
||||
req.Header.Set("User-Agent", codexCLIUserAgent)
|
||||
}
|
||||
s.overrideBrowserUserAgent(ctx, account, req)
|
||||
enforceCodexIdentityHeaders(req.Header)
|
||||
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
|
||||
}
|
||||
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
|
||||
@@ -88,7 +88,7 @@ func TestForwardAlphaSearchOAuthPreservesWire(t *testing.T) {
|
||||
require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("Authorization"))
|
||||
require.Equal(t, "chatgpt-account", upstream.lastReq.Header.Get("chatgpt-account-id"))
|
||||
require.Equal(t, "application/json", upstream.lastReq.Header.Get("Accept"))
|
||||
require.Equal(t, "0.144.1", upstream.lastReq.Header.Get("Version"))
|
||||
require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta"))
|
||||
require.JSONEq(t, string(body), string(upstream.lastBody))
|
||||
}
|
||||
@@ -153,7 +153,7 @@ func TestForwardAlphaSearchPATUsesResponsesWebSearchFallback(t *testing.T) {
|
||||
require.Equal(t, "application/json", upstream.lastReq.Header.Get("Content-Type"))
|
||||
require.Equal(t, "text/event-stream", upstream.lastReq.Header.Get("Accept"))
|
||||
require.Equal(t, "responses=experimental", upstream.lastReq.Header.Get("OpenAI-Beta"))
|
||||
require.Equal(t, "0.144.1", upstream.lastReq.Header.Get("Version"))
|
||||
require.Equal(t, codexCLIVersion, upstream.lastReq.Header.Get("Version"))
|
||||
require.Equal(t, `{"turn_id":"turn-1"}`, upstream.lastReq.Header.Get("X-Codex-Turn-Metadata"))
|
||||
require.Equal(t, "codex_cli_rs", upstream.lastReq.Header.Get("Originator"))
|
||||
require.Empty(t, upstream.lastReq.Header.Get("X-Codex-Beta-Features"))
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- mock: 只记录临时不可调度写入,其余方法不应被调用 ---
|
||||
|
||||
type capacityShedAccountRepoStub struct {
|
||||
AccountRepository // 嵌入接口,未实现的方法会 panic(不应被调用)
|
||||
|
||||
tempUnschedCalls int
|
||||
}
|
||||
|
||||
func (r *capacityShedAccountRepoStub) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, _ string) error {
|
||||
r.tempUnschedCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
// 上游容量降载是请求级信号:故障因素(客户端身份、模型容量)与账号无关,
|
||||
// 同账号重试用尽后不得把账号临时摘掉——否则一个被降载的请求会顺着 failover
|
||||
// 把整池账号逐个封禁,而每个账号都会以同一个错误失败。
|
||||
func TestTempUnscheduleRetryableErrorSkipsRequestScopedTransient(t *testing.T) {
|
||||
t.Run("请求级瞬时故障不写账号状态", func(t *testing.T) {
|
||||
repo := &capacityShedAccountRepoStub{}
|
||||
svc := &GatewayService{accountRepo: repo}
|
||||
|
||||
svc.TempUnscheduleRetryableError(context.Background(), 1, &UpstreamFailoverError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
RetryableOnSameAccount: true,
|
||||
RequestScopedTransient: true,
|
||||
})
|
||||
|
||||
require.Zero(t, repo.tempUnschedCalls)
|
||||
})
|
||||
|
||||
// 对照组:同样的 502 在未标记请求级瞬时故障时仍按原有语义临时摘号,
|
||||
// 确认上面的断言来自新增守卫而非其他前置条件。
|
||||
t.Run("未标记时保持原有临时摘号语义", func(t *testing.T) {
|
||||
repo := &capacityShedAccountRepoStub{}
|
||||
svc := &GatewayService{accountRepo: repo}
|
||||
|
||||
svc.TempUnscheduleRetryableError(context.Background(), 1, &UpstreamFailoverError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
RetryableOnSameAccount: true,
|
||||
})
|
||||
|
||||
require.Equal(t, 1, repo.tempUnschedCalls)
|
||||
})
|
||||
}
|
||||
|
||||
// 非池模式账号同样要先在同账号重试:换号不改变降载因素。
|
||||
func TestStreamFailedEventCapacityShedRetriesOnSameAccount(t *testing.T) {
|
||||
nonPool := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
|
||||
for _, code := range []string{"server_is_overloaded", "slow_down"} {
|
||||
payload := []byte(`{"type":"response.failed","response":{"error":{"code":"` + code + `"}}}`)
|
||||
require.True(t, isOpenAIUpstreamCapacityShedEvent(payload), code)
|
||||
require.True(t, openAIStreamFailedEventRetryableOnSameAccount(nonPool, payload, "overloaded"), code)
|
||||
}
|
||||
|
||||
// 非降载的 failed 事件在非池模式下仍不做同账号重试,避免放大改动面。
|
||||
other := []byte(`{"type":"response.failed","response":{"error":{"code":"server_error"}}}`)
|
||||
require.False(t, isOpenAIUpstreamCapacityShedEvent(other))
|
||||
require.False(t, openAIStreamFailedEventRetryableOnSameAccount(nonPool, other, "boom"))
|
||||
}
|
||||
|
||||
// 出站身份的版本声明只能有一个来源:UA 的版本段、version 头、探针版本三处必须同源,
|
||||
// 各自硬编码会漂移成互相矛盾的身份,而自相矛盾或陈旧的身份会被上游优先降载。
|
||||
func TestCodexOutboundVersionHasSingleSource(t *testing.T) {
|
||||
require.True(t,
|
||||
strings.HasPrefix(codexCLIUserAgent, "codex_cli_rs/"+codexCLIVersion+" "),
|
||||
"codexCLIUserAgent=%q 必须以 codexCLIVersion=%q 作为版本段", codexCLIUserAgent, codexCLIVersion,
|
||||
)
|
||||
require.Equal(t, codexCLIVersion, openAICodexProbeVersion)
|
||||
require.GreaterOrEqual(t, CompareVersions(codexCLIVersion, codexUpstreamMinVersion), 0,
|
||||
"codexCLIVersion=%q 不得低于上游最低门槛 %q", codexCLIVersion, codexUpstreamMinVersion,
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,10 @@ package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/google/uuid"
|
||||
@@ -12,21 +15,142 @@ import (
|
||||
// 若请求携带 version 且低于该值,上游直接 404(issue #3901,2026-07 实测)。
|
||||
const codexUpstreamMinVersion = "0.144.0"
|
||||
|
||||
// codexClientVersionMaxLen 官方版本号均为短 ASCII 串,远低于此上限。
|
||||
const codexClientVersionMaxLen = 64
|
||||
|
||||
// codexClientVersionPattern 允许 0.146.0 与 0.147.0-alpha.4 两类官方形态。
|
||||
var codexClientVersionPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+){1,3}(-[0-9A-Za-z.]+)?$`)
|
||||
|
||||
// NormalizeCodexClientVersion 校验并归一化 Codex 客户端版本号,非法值返回空串。
|
||||
// 该值会被拼进出站 User-Agent 与 version 头,必须拒绝任意字节,避免管理员误填或
|
||||
// 自动同步拿到异常值时把不可控内容透给上游。
|
||||
func NormalizeCodexClientVersion(version string) string {
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" || len(version) > codexClientVersionMaxLen || !codexClientVersionPattern.MatchString(version) {
|
||||
return ""
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// buildCodexCLIUserAgent 按版本号拼出规范 Codex CLI User-Agent。
|
||||
// UA 形态只在 codexCLIUserAgentSuffix 一处定义,避免多处拼装漂移。
|
||||
func buildCodexCLIUserAgent(version string) string {
|
||||
if version = NormalizeCodexClientVersion(version); version == "" {
|
||||
return codexCLIUserAgent
|
||||
}
|
||||
return openai.CodexCLIOriginator + "/" + version + codexCLIUserAgentSuffix
|
||||
}
|
||||
|
||||
// codexIdentityEnforcement 控制 enforceCodexIdentityHeaders 是否强制统一出站身份,
|
||||
// 由 gateway.disable_codex_identity_enforcement 在服务构造时取反发布。
|
||||
// 默认开启:上游在容量紧张时按客户端身份分优先级降载,被降载的请求会拿到
|
||||
// HTTP 200 + 流内 server_is_overloaded,本次请求即失败;强制统一出口可确保没有
|
||||
// 请求带着第三方或陈旧身份出站。关闭后退回「仅按最终 UA 配对 originator」的收口语义。
|
||||
var codexIdentityEnforcement = func() *atomic.Bool {
|
||||
v := &atomic.Bool{}
|
||||
v.Store(true)
|
||||
return v
|
||||
}()
|
||||
|
||||
// SetCodexIdentityEnforcementEnabled 发布 Codex 出站身份强制统一开关。
|
||||
// enforceCodexIdentityHeaders 是所有出站路径共用的纯函数收口点,无法在热路径注入配置,
|
||||
// 故由持有配置的服务在构造时发布进程级快照。
|
||||
func SetCodexIdentityEnforcementEnabled(enabled bool) {
|
||||
codexIdentityEnforcement.Store(enabled)
|
||||
}
|
||||
|
||||
// codexCanonicalUserAgentResolver 返回当前生效的规范 Codex User-Agent(后台设置 / 自动同步版本号)。
|
||||
// 由 SettingService 在装配时注入;解析器内部自带 TTL 缓存,热路径不触库。
|
||||
type codexCanonicalUserAgentResolver func() string
|
||||
|
||||
var (
|
||||
codexCanonicalUAMu sync.RWMutex
|
||||
codexCanonicalUAResolver codexCanonicalUserAgentResolver
|
||||
)
|
||||
|
||||
// SetCodexCanonicalUserAgentResolver 注入规范 User-Agent 解析器。
|
||||
// 未注入或解析结果非法时回退到编译期常量 codexCLIUserAgent。
|
||||
func SetCodexCanonicalUserAgentResolver(resolver func() string) {
|
||||
codexCanonicalUAMu.Lock()
|
||||
defer codexCanonicalUAMu.Unlock()
|
||||
codexCanonicalUAResolver = resolver
|
||||
}
|
||||
|
||||
// codexCanonicalUserAgent 返回出站规范 User-Agent。
|
||||
func codexCanonicalUserAgent() string {
|
||||
codexCanonicalUAMu.RLock()
|
||||
resolver := codexCanonicalUAResolver
|
||||
codexCanonicalUAMu.RUnlock()
|
||||
if resolver != nil {
|
||||
if ua := strings.TrimSpace(resolver()); ua != "" {
|
||||
return ua
|
||||
}
|
||||
}
|
||||
return codexCLIUserAgent
|
||||
}
|
||||
|
||||
// codexOutboundIdentity 出站身份三元组,三者必须同源自洽:
|
||||
// originator 与 User-Agent 首段配套(否则上游 404,issue #3901),
|
||||
// version 等于 User-Agent 的版本段且不低于上游门槛。
|
||||
type codexOutboundIdentity struct {
|
||||
userAgent string
|
||||
originator string
|
||||
version string
|
||||
}
|
||||
|
||||
// resolveCodexOutboundIdentity 由候选 User-Agent 推导自洽的出站身份。
|
||||
// candidateUA 为空时使用规范 User-Agent;推导不出官方身份时整体回退为规范 CLI 身份。
|
||||
//
|
||||
// 候选 UA(面板 / 账号级的管理员显式配置)只贡献客户端名与 OS / 架构 / 终端指纹,
|
||||
// 其自带的版本段一律用当前生效版本重建:一条填写于某个历史版本的 UA 否则会把出站身份
|
||||
// 永久钉死在陈旧版本上,绕过版本自动同步,落回上游优先降载的那一侧。
|
||||
// 需要固定版本请填「Codex 客户端版本号」并关闭自动同步。
|
||||
func resolveCodexOutboundIdentity(candidateUA string) codexOutboundIdentity {
|
||||
canonical := codexCanonicalUserAgent()
|
||||
ua := strings.TrimSpace(candidateUA)
|
||||
if ua == "" {
|
||||
ua = canonical
|
||||
}
|
||||
originator, pairedUA, ok := openai.PairCodexClientIdentity(ua)
|
||||
if !ok {
|
||||
if originator, pairedUA, ok = openai.PairCodexClientIdentity(canonical); !ok {
|
||||
originator, pairedUA = openai.CodexCLIOriginator, codexCLIUserAgent
|
||||
}
|
||||
}
|
||||
// 生效版本只有一个来源:规范身份(面板版本号 → 自动同步值 → 内置常量,见
|
||||
// SettingService.GetOpenAICodexClientVersion)。UA 与 version 头由此同源派生。
|
||||
version := codexClientVersionFromUA(canonical)
|
||||
if rebuilt := openai.SetCodexUserAgentVersion(pairedUA, version); rebuilt != "" {
|
||||
pairedUA = rebuilt
|
||||
}
|
||||
return codexOutboundIdentity{userAgent: pairedUA, originator: originator, version: version}
|
||||
}
|
||||
|
||||
// codexClientVersionFromUA 取 UA 的版本段作为生效版本;
|
||||
// 非法或低于上游门槛(低于则上游 404,issue #3901)时回退编译期常量。
|
||||
func codexClientVersionFromUA(ua string) string {
|
||||
version := NormalizeCodexClientVersion(openai.CodexUserAgentVersion(ua))
|
||||
if version == "" || CompareVersions(version, codexUpstreamMinVersion) < 0 {
|
||||
return codexCLIVersion
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// ensureCodexIdentityHeaders 补齐 OAuth(ChatGPT 内部接口)出站请求所需的 Codex 身份头。
|
||||
// 已有 User-Agent 与 version 保持不变,交给紧随其后的 enforceCodexIdentityHeaders
|
||||
// 做官方身份配对与最低版本校正。
|
||||
// 已有 User-Agent 与 version 保持不变,交给紧随其后的 enforceCodexIdentityHeaders 收口。
|
||||
func ensureCodexIdentityHeaders(h http.Header) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
identity := resolveCodexOutboundIdentity("")
|
||||
if strings.TrimSpace(h.Get("user-agent")) == "" {
|
||||
h.Set("user-agent", codexCLIUserAgent)
|
||||
h.Set("user-agent", identity.userAgent)
|
||||
}
|
||||
if strings.TrimSpace(h.Get("originator")) == "" {
|
||||
h.Set("originator", "codex_cli_rs")
|
||||
h.Set("originator", identity.originator)
|
||||
}
|
||||
if strings.TrimSpace(h.Get("version")) == "" {
|
||||
h.Set("version", codexCLIVersion)
|
||||
h.Set("version", identity.version)
|
||||
}
|
||||
h.Set("OpenAI-Beta", "responses=experimental")
|
||||
}
|
||||
@@ -41,20 +165,47 @@ func applyOpenAICodexProbeHeaders(h http.Header) {
|
||||
}
|
||||
|
||||
// enforceCodexIdentityHeaders 收口 OAuth(ChatGPT 内部接口)出站请求的客户端身份头。
|
||||
// 上游要求 originator 与 User-Agent 首段配套且为官方客户端标识,version 头(若携带)
|
||||
// 不低于 0.144.0,任一不满足即 404(issue #3901)。以最终 User-Agent 为准推导配套
|
||||
// originator;推导不出官方身份(第三方 UA / UA 缺失)时整体回退为默认 Codex CLI 身份。
|
||||
//
|
||||
// 仅对携带 originator 的请求生效;需要从缺失身份头恢复的调用方应先调用
|
||||
// ensureCodexIdentityHeaders。
|
||||
// 必须在所有 User-Agent 改写(自定义 UA / ForceCodexCLI / 浏览器 UA 兜底)之后调用。
|
||||
// 见 enforceCodexIdentityHeadersWithUA;无账号级自定义 User-Agent 时使用本函数。
|
||||
func enforceCodexIdentityHeaders(h http.Header) {
|
||||
enforceCodexIdentityHeadersWithUA(h, "")
|
||||
}
|
||||
|
||||
// enforceCodexIdentityHeadersWithUA 强制统一 OAuth 出站身份:User-Agent / originator / version
|
||||
// 一律改写为网关的规范身份,客户端自报身份不参与构造。上游在容量紧张时按客户端身份分优先级
|
||||
// 降载,被降载的请求会拿到 HTTP 200 + 流内 server_is_overloaded;统一出口可确保没有请求带着
|
||||
// 第三方或陈旧身份出站,也天然满足 originator 与 UA 首段配套的上游校验(issue #3901)。
|
||||
//
|
||||
// overrideUA 是账号级自定义 User-Agent:管理员的显式配置仍然生效,但只贡献客户端名与
|
||||
// OS / 架构 / 终端指纹——版本段与 originator 都由规范身份重建,不允许出现自相矛盾或陈旧的身份。
|
||||
//
|
||||
// 强制统一被 gateway.disable_codex_identity_enforcement 关闭时,退回「按最终 User-Agent 配对
|
||||
// originator + version 门槛校正」的收口语义,供上游策略变动时回滚。
|
||||
//
|
||||
// 仅对携带 originator 的请求生效:compat 桥接等非 ChatGPT 内部接口路径会显式删除 originator,
|
||||
// 不应被补回。需要从缺失身份头恢复的调用方应先调用 ensureCodexIdentityHeaders。
|
||||
// 必须在所有 User-Agent 改写之后调用。
|
||||
func enforceCodexIdentityHeadersWithUA(h http.Header, overrideUA string) {
|
||||
if h == nil || h.Get("originator") == "" {
|
||||
return
|
||||
}
|
||||
if !codexIdentityEnforcement.Load() {
|
||||
pairCodexIdentityHeaders(h)
|
||||
return
|
||||
}
|
||||
identity := resolveCodexOutboundIdentity(overrideUA)
|
||||
h.Set("user-agent", identity.userAgent)
|
||||
h.Set("originator", identity.originator)
|
||||
h.Set("version", identity.version)
|
||||
}
|
||||
|
||||
// pairCodexIdentityHeaders 是关闭强制统一后的兜底收口:保留客户端真实身份,
|
||||
// 仅保证 originator 与最终 User-Agent 首段配套、version 不低于上游门槛(issue #3901)。
|
||||
func pairCodexIdentityHeaders(h http.Header) {
|
||||
originator, pairedUA, ok := openai.PairCodexClientIdentity(h.Get("user-agent"))
|
||||
if !ok {
|
||||
originator, pairedUA = "codex_cli_rs", codexCLIUserAgent
|
||||
identity := resolveCodexOutboundIdentity("")
|
||||
originator, pairedUA = identity.originator, identity.userAgent
|
||||
h.Set("version", identity.version)
|
||||
}
|
||||
h.Set("user-agent", pairedUA)
|
||||
h.Set("originator", originator)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -16,6 +17,9 @@ func requireOpenAICodexProbeHeaders(t *testing.T, h http.Header) {
|
||||
require.NotEmpty(t, h.Get("X-Codex-Window-ID"))
|
||||
}
|
||||
|
||||
// 强制统一出口:无论客户端自报什么身份,OAuth 出站的 User-Agent / originator / version
|
||||
// 一律是网关规范身份。上游在容量紧张时按客户端身份分优先级降载,统一出口确保没有请求
|
||||
// 带着第三方或陈旧身份出站。
|
||||
func TestEnsureCodexIdentityHeaders(t *testing.T) {
|
||||
t.Run("补齐缺失身份头", func(t *testing.T) {
|
||||
h := make(http.Header)
|
||||
@@ -29,102 +33,76 @@ func TestEnsureCodexIdentityHeaders(t *testing.T) {
|
||||
require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta"))
|
||||
})
|
||||
|
||||
t.Run("保留已有官方UA和合法version并重新配对", func(t *testing.T) {
|
||||
const tuiUA = "codex-tui/9.9.9 (Mac OS X 14.0; arm64) iTerm (codex-tui; 9.9.9)"
|
||||
t.Run("官方非 CLI 客户端身份同样被统一", func(t *testing.T) {
|
||||
h := make(http.Header)
|
||||
h.Set("user-agent", tuiUA)
|
||||
h.Set("user-agent", "codex_vscode/9.9.9 (Mac OS X 14.0; arm64) vscode (codex_vscode; 9.9.9)")
|
||||
h.Set("version", "9.9.9")
|
||||
h.Set("OpenAI-Beta", "assistants=v2")
|
||||
|
||||
ensureCodexIdentityHeaders(h)
|
||||
enforceCodexIdentityHeaders(h)
|
||||
|
||||
require.Equal(t, "codex-tui", h.Get("originator"))
|
||||
require.Equal(t, tuiUA, h.Get("user-agent"))
|
||||
require.Equal(t, "9.9.9", h.Get("version"))
|
||||
require.Equal(t, "codex_cli_rs", h.Get("originator"))
|
||||
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
|
||||
require.Equal(t, codexCLIVersion, h.Get("version"))
|
||||
require.Equal(t, "responses=experimental", h.Get("OpenAI-Beta"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnforceCodexIdentityHeaders(t *testing.T) {
|
||||
const tuiUA = "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
originator string
|
||||
userAgent string
|
||||
version string
|
||||
wantOriginator string
|
||||
wantUA string
|
||||
wantVersion string
|
||||
name string
|
||||
originator string
|
||||
userAgent string
|
||||
version string
|
||||
}{
|
||||
{
|
||||
name: "错配 originator 按最终 UA 重配",
|
||||
originator: "codex_cli_rs",
|
||||
userAgent: tuiUA,
|
||||
wantOriginator: "codex-tui",
|
||||
wantUA: tuiUA,
|
||||
name: "TUI 身份",
|
||||
originator: "codex-tui",
|
||||
userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)",
|
||||
},
|
||||
{
|
||||
name: "官方配套身份原样保留",
|
||||
originator: "codex-tui",
|
||||
userAgent: tuiUA,
|
||||
wantOriginator: "codex-tui",
|
||||
wantUA: tuiUA,
|
||||
name: "错配 originator",
|
||||
originator: "codex_cli_rs",
|
||||
userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)",
|
||||
},
|
||||
{
|
||||
name: "第三方 UA 整体回退默认身份",
|
||||
originator: "opencode",
|
||||
userAgent: "luna/1.0.0",
|
||||
wantOriginator: "codex_cli_rs",
|
||||
wantUA: codexCLIUserAgent,
|
||||
name: "官方 vscode 身份",
|
||||
originator: "codex_vscode",
|
||||
userAgent: "codex_vscode/1.2.3 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.2.3)",
|
||||
},
|
||||
{
|
||||
name: "UA 缺失回退默认身份",
|
||||
originator: "codex_vscode",
|
||||
wantOriginator: "codex_cli_rs",
|
||||
wantUA: codexCLIUserAgent,
|
||||
name: "第三方客户端身份",
|
||||
originator: "opencode",
|
||||
userAgent: "luna/1.0.0",
|
||||
version: "2.1.0",
|
||||
},
|
||||
{
|
||||
name: "originator override UA 首段被尾部真实身份重写",
|
||||
originator: "cccc",
|
||||
userAgent: "cccc/0.142.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.142.0)",
|
||||
wantOriginator: "codex-tui",
|
||||
wantUA: "codex-tui/0.142.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.142.0)",
|
||||
name: "浏览器型 UA(原浏览器兜底已被统一出口吸收)",
|
||||
originator: "codex_cli_rs",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
},
|
||||
{
|
||||
name: "低于门槛的 version 提升为内置版本",
|
||||
originator: "codex_cli_rs",
|
||||
userAgent: "codex_cli_rs/0.125.0",
|
||||
version: "0.125.0",
|
||||
wantOriginator: "codex_cli_rs",
|
||||
wantUA: "codex_cli_rs/0.125.0",
|
||||
wantVersion: codexCLIVersion,
|
||||
name: "UA 缺失",
|
||||
originator: "codex_vscode",
|
||||
},
|
||||
{
|
||||
name: "达标 version 原样保留",
|
||||
originator: "codex_cli_rs",
|
||||
userAgent: "codex_cli_rs/0.145.0",
|
||||
version: "0.145.0",
|
||||
wantOriginator: "codex_cli_rs",
|
||||
wantUA: "codex_cli_rs/0.145.0",
|
||||
wantVersion: "0.145.0",
|
||||
name: "originator override 的真实 TUI 客户端",
|
||||
originator: "cccc",
|
||||
userAgent: "cccc/0.142.0 (Ubuntu 22.4.0; x86_64) screen (codex-tui; 0.142.0)",
|
||||
},
|
||||
{
|
||||
name: "未携带 version 不注入",
|
||||
originator: "codex_cli_rs",
|
||||
userAgent: "codex_cli_rs/0.98.0",
|
||||
wantOriginator: "codex_cli_rs",
|
||||
wantUA: "codex_cli_rs/0.98.0",
|
||||
name: "陈旧客户端版本",
|
||||
originator: "codex_cli_rs",
|
||||
userAgent: "codex_cli_rs/0.125.0",
|
||||
version: "0.125.0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := make(http.Header)
|
||||
if tt.originator != "" {
|
||||
h.Set("originator", tt.originator)
|
||||
}
|
||||
h.Set("originator", tt.originator)
|
||||
if tt.userAgent != "" {
|
||||
h.Set("user-agent", tt.userAgent)
|
||||
}
|
||||
@@ -134,15 +112,187 @@ func TestEnforceCodexIdentityHeaders(t *testing.T) {
|
||||
|
||||
enforceCodexIdentityHeaders(h)
|
||||
|
||||
require.Equal(t, tt.wantOriginator, h.Get("originator"))
|
||||
require.Equal(t, tt.wantUA, h.Get("user-agent"))
|
||||
require.Equal(t, tt.wantVersion, h.Get("version"))
|
||||
require.Equal(t, "codex_cli_rs", h.Get("originator"))
|
||||
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
|
||||
require.Equal(t, codexCLIVersion, h.Get("version"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// enforce 本身仍只负责收口:缺少 originator 时必须保持 no-op,由需要恢复身份的
|
||||
// 调用方先显式调用 ensureCodexIdentityHeaders。
|
||||
// 账号级自定义 UA 是管理员的显式配置,仍然生效;但它只贡献客户端名与 OS / 架构 / 终端指纹,
|
||||
// originator 与版本段一律由规范身份重建,不允许出现自相矛盾或陈旧的身份。
|
||||
func TestEnforceCodexIdentityHeadersWithAccountOverrideUA(t *testing.T) {
|
||||
t.Run("官方形态覆写 UA 保留指纹但重建版本段", func(t *testing.T) {
|
||||
h := make(http.Header)
|
||||
h.Set("originator", "codex-tui")
|
||||
h.Set("user-agent", "luna/1.0.0")
|
||||
h.Set("version", "2.1.0")
|
||||
|
||||
enforceCodexIdentityHeadersWithUA(h, "codex_vscode/0.150.0 (Ubuntu 22.4.0; x86_64) vscode")
|
||||
|
||||
require.Equal(t, "codex_vscode", h.Get("originator"))
|
||||
require.Equal(t, "codex_vscode/"+codexCLIVersion+" (Ubuntu 22.4.0; x86_64) vscode", h.Get("user-agent"))
|
||||
require.Equal(t, codexCLIVersion, h.Get("version"))
|
||||
})
|
||||
|
||||
t.Run("非官方形态覆写 UA 回退规范身份", func(t *testing.T) {
|
||||
h := make(http.Header)
|
||||
h.Set("originator", "codex_cli_rs")
|
||||
|
||||
enforceCodexIdentityHeadersWithUA(h, "luna/1.0.0")
|
||||
|
||||
require.Equal(t, "codex_cli_rs", h.Get("originator"))
|
||||
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
|
||||
require.Equal(t, codexCLIVersion, h.Get("version"))
|
||||
})
|
||||
|
||||
// 回归:覆写 UA 填写于某个历史版本时,其版本段必须被重建而不是逐字沿用——
|
||||
// 否则这条配置会绕过版本自动同步,把出站身份永久钉死在陈旧版本上,
|
||||
// 稳定落在上游优先降载的那一侧(UA 与 version 头也不再同源)。
|
||||
t.Run("陈旧覆写 UA 的版本段被重建", func(t *testing.T) {
|
||||
h := make(http.Header)
|
||||
h.Set("originator", "codex_cli_rs")
|
||||
|
||||
enforceCodexIdentityHeadersWithUA(h, "codex_cli_rs/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color")
|
||||
|
||||
require.Equal(t, "codex_cli_rs", h.Get("originator"))
|
||||
require.Equal(t, "codex_cli_rs/"+codexCLIVersion+" (Ubuntu 22.4.0; x86_64) xterm-256color", h.Get("user-agent"))
|
||||
require.Equal(t, codexCLIVersion, h.Get("version"))
|
||||
require.NotContains(t, h.Get("user-agent"), "0.125.0")
|
||||
})
|
||||
|
||||
// 陈旧覆写 UA 同样跟随自动同步到的新版本,无需管理员重新编辑那条 UA。
|
||||
t.Run("陈旧覆写 UA 跟随同步版本", func(t *testing.T) {
|
||||
SetCodexCanonicalUserAgentResolver(func() string {
|
||||
return "codex_cli_rs/0.200.1" + codexCLIUserAgentSuffix
|
||||
})
|
||||
t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) })
|
||||
|
||||
h := make(http.Header)
|
||||
h.Set("originator", "codex_cli_rs")
|
||||
|
||||
enforceCodexIdentityHeadersWithUA(h, "codex-tui/0.125.0 (Mac OS X 14.0; arm64) iTerm")
|
||||
|
||||
require.Equal(t, "codex-tui", h.Get("originator"))
|
||||
require.Equal(t, "codex-tui/0.200.1 (Mac OS X 14.0; arm64) iTerm", h.Get("user-agent"))
|
||||
require.Equal(t, "0.200.1", h.Get("version"))
|
||||
})
|
||||
}
|
||||
|
||||
// 规范身份跟随注入的解析器(后台面板 UA / 自动同步版本号),无需重启或发版。
|
||||
//
|
||||
// 不得给本用例加 t.Parallel():它改写进程级解析器。
|
||||
func TestEnforceCodexIdentityHeadersFollowsCanonicalResolver(t *testing.T) {
|
||||
SetCodexCanonicalUserAgentResolver(func() string {
|
||||
return "codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color"
|
||||
})
|
||||
t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) })
|
||||
|
||||
h := make(http.Header)
|
||||
h.Set("originator", "codex-tui")
|
||||
h.Set("user-agent", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)")
|
||||
|
||||
enforceCodexIdentityHeaders(h)
|
||||
|
||||
require.Equal(t, "codex_cli_rs", h.Get("originator"))
|
||||
require.Equal(t, "codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color", h.Get("user-agent"))
|
||||
require.Equal(t, "0.200.1", h.Get("version"))
|
||||
}
|
||||
|
||||
// 解析器返回非法值(配置被写坏、同步到异常内容)时必须回退到内置身份,
|
||||
// 绝不能把不可控内容拼进出站身份。
|
||||
//
|
||||
// 不得给本用例加 t.Parallel():它改写进程级解析器。
|
||||
func TestEnforceCodexIdentityHeadersRejectsInvalidCanonicalUA(t *testing.T) {
|
||||
SetCodexCanonicalUserAgentResolver(func() string { return "not-a-codex-client" })
|
||||
t.Cleanup(func() { SetCodexCanonicalUserAgentResolver(nil) })
|
||||
|
||||
h := make(http.Header)
|
||||
h.Set("originator", "codex_cli_rs")
|
||||
|
||||
enforceCodexIdentityHeaders(h)
|
||||
|
||||
require.Equal(t, "codex_cli_rs", h.Get("originator"))
|
||||
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
|
||||
require.Equal(t, codexCLIVersion, h.Get("version"))
|
||||
}
|
||||
|
||||
// 开关是进程级快照,零值 Config(测试 / 工具手工构造,不经 viper)必须落在「强制统一开启」
|
||||
// 一侧,否则任意一处零值构造都会静默关掉全局保护。
|
||||
//
|
||||
// 不得给本文件的开关类用例加 t.Parallel():它们改写进程级状态。
|
||||
func TestCodexIdentityEnforcementZeroValueConfigKeepsItEnabled(t *testing.T) {
|
||||
var cfg config.Config
|
||||
require.False(t, cfg.Gateway.DisableCodexIdentityEnforcement,
|
||||
"零值必须表示强制统一开启;若改为正向命名,零值会静默关闭保护")
|
||||
|
||||
SetCodexIdentityEnforcementEnabled(!cfg.Gateway.DisableCodexIdentityEnforcement)
|
||||
t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) })
|
||||
|
||||
h := make(http.Header)
|
||||
h.Set("originator", "codex-tui")
|
||||
h.Set("user-agent", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)")
|
||||
|
||||
enforceCodexIdentityHeaders(h)
|
||||
|
||||
require.Equal(t, "codex_cli_rs", h.Get("originator"))
|
||||
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
|
||||
}
|
||||
|
||||
// 关闭强制统一后退回配对语义:客户端真实身份逐字保留,仅保证 originator 与 UA 首段配套
|
||||
// (issue #3901),供上游策略变动时回滚。
|
||||
func TestEnforceCodexIdentityHeaders_EnforcementDisabled(t *testing.T) {
|
||||
const tuiUA = "codex-tui/0.145.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.145.2)"
|
||||
|
||||
SetCodexIdentityEnforcementEnabled(false)
|
||||
t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) })
|
||||
|
||||
h := make(http.Header)
|
||||
h.Set("originator", "codex-tui")
|
||||
h.Set("user-agent", tuiUA)
|
||||
h.Set("version", "0.145.2")
|
||||
|
||||
enforceCodexIdentityHeaders(h)
|
||||
|
||||
require.Equal(t, "codex-tui", h.Get("originator"))
|
||||
require.Equal(t, tuiUA, h.Get("user-agent"))
|
||||
require.Equal(t, "0.145.2", h.Get("version"))
|
||||
}
|
||||
|
||||
// 关闭强制统一后,第三方 UA 仍整体回退为规范身份并对齐 version。
|
||||
func TestEnforceCodexIdentityHeaders_EnforcementDisabledThirdPartyFallback(t *testing.T) {
|
||||
SetCodexIdentityEnforcementEnabled(false)
|
||||
t.Cleanup(func() { SetCodexIdentityEnforcementEnabled(true) })
|
||||
|
||||
h := make(http.Header)
|
||||
h.Set("originator", "opencode")
|
||||
h.Set("user-agent", "luna/1.0.0")
|
||||
h.Set("version", "2.1.0")
|
||||
|
||||
enforceCodexIdentityHeaders(h)
|
||||
|
||||
require.Equal(t, "codex_cli_rs", h.Get("originator"))
|
||||
require.Equal(t, codexCLIUserAgent, h.Get("user-agent"))
|
||||
require.Equal(t, codexCLIVersion, h.Get("version"))
|
||||
}
|
||||
|
||||
// 收口必须幂等:透传等路径可能先后多次经过收口。
|
||||
func TestEnforceCodexIdentityHeadersIsIdempotent(t *testing.T) {
|
||||
h := make(http.Header)
|
||||
h.Set("originator", "codex-tui")
|
||||
h.Set("user-agent", "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)")
|
||||
|
||||
enforceCodexIdentityHeaders(h)
|
||||
firstUA, firstVersion := h.Get("user-agent"), h.Get("version")
|
||||
enforceCodexIdentityHeaders(h)
|
||||
|
||||
require.Equal(t, firstUA, h.Get("user-agent"))
|
||||
require.Equal(t, firstVersion, h.Get("version"))
|
||||
require.Equal(t, "codex_cli_rs", h.Get("originator"))
|
||||
}
|
||||
|
||||
// 缺少 originator 时必须保持 no-op:compat 桥接等非 ChatGPT 内部接口路径会显式删除
|
||||
// originator,不应被补回身份头。
|
||||
func TestEnforceCodexIdentityHeaders_NoOriginatorIsNoop(t *testing.T) {
|
||||
h := make(http.Header)
|
||||
h.Set("user-agent", "third-party-client/1.0.0")
|
||||
@@ -150,5 +300,24 @@ func TestEnforceCodexIdentityHeaders_NoOriginatorIsNoop(t *testing.T) {
|
||||
enforceCodexIdentityHeaders(h)
|
||||
|
||||
require.Empty(t, h.Get("originator"))
|
||||
require.Empty(t, h.Get("version"))
|
||||
require.Equal(t, "third-party-client/1.0.0", h.Get("user-agent"))
|
||||
}
|
||||
|
||||
func TestNormalizeCodexClientVersion(t *testing.T) {
|
||||
require.Equal(t, "0.146.0", NormalizeCodexClientVersion(" 0.146.0 "))
|
||||
require.Equal(t, "0.147.0-alpha.4", NormalizeCodexClientVersion("0.147.0-alpha.4"))
|
||||
require.Equal(t, "1.2", NormalizeCodexClientVersion("1.2"))
|
||||
require.Empty(t, NormalizeCodexClientVersion(""))
|
||||
require.Empty(t, NormalizeCodexClientVersion("v0.146.0"))
|
||||
require.Empty(t, NormalizeCodexClientVersion("0.146.0 (Ubuntu)"))
|
||||
require.Empty(t, NormalizeCodexClientVersion("0.146.0\r\nX-Injected: 1"))
|
||||
require.Empty(t, NormalizeCodexClientVersion("latest"))
|
||||
}
|
||||
|
||||
func TestBuildCodexCLIUserAgent(t *testing.T) {
|
||||
require.Equal(t, "codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix, buildCodexCLIUserAgent("0.200.1"))
|
||||
// 非法版本号必须回退到内置 UA,不能拼出畸形身份。
|
||||
require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent("bogus version"))
|
||||
require.Equal(t, codexCLIUserAgent, buildCodexCLIUserAgent(""))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// openAICodexVersionSyncInterval 自动同步间隔。上游客户端发版频率是天级,
|
||||
// 6 小时足够及时跟上,同时把对 GitHub API 的调用压到每天 4 次。
|
||||
openAICodexVersionSyncInterval = 6 * time.Hour
|
||||
// openAICodexVersionSyncTimeout 单次同步的整体超时。
|
||||
openAICodexVersionSyncTimeout = 30 * time.Second
|
||||
// openAICodexVersionSyncRepo 官方 Codex 客户端仓库。
|
||||
openAICodexVersionSyncRepo = "openai/codex"
|
||||
// openAICodexVersionSyncPerPage 回退路径单次拉取的 release 数量(主路径见
|
||||
// fetchLatestStableVersion)。该仓库预发布极密集——0.145.0 与 0.146.0 之间隔着 20 多个
|
||||
// alpha,实测 30 条里只有 2 条稳定版,第二条已排在第 26 位,因此这个页大小不能再往下调,
|
||||
// 否则整页扫不到稳定版、同步会静默停更。
|
||||
openAICodexVersionSyncPerPage = 30
|
||||
// openAICodexVersionTagPrefix 客户端 release 的 tag 前缀(如 rust-v0.146.0)。
|
||||
// 同仓库还有其他组件的 tag(如 rusty-v8-*),必须按前缀过滤,否则会同步到无关版本号。
|
||||
openAICodexVersionTagPrefix = "rust-v"
|
||||
)
|
||||
|
||||
// OpenAICodexVersionSyncService 周期性把官方 Codex 客户端的最新稳定版版本号同步到设置,
|
||||
// 供出站规范身份使用,避免为了跟上游版本而发新版本。
|
||||
//
|
||||
// 同步值写入 SettingKeyOpenAICodexClientVersionSynced(本服务独占写入);管理员在面板填写的
|
||||
// SettingKeyOpenAICodexClientVersion 优先级更高,因此手工固定版本不会被同步覆盖。
|
||||
type OpenAICodexVersionSyncService struct {
|
||||
settingRepo SettingRepository
|
||||
settingService *SettingService
|
||||
githubClient GitHubReleaseClient
|
||||
interval time.Duration
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewOpenAICodexVersionSyncService(
|
||||
settingRepo SettingRepository,
|
||||
settingService *SettingService,
|
||||
githubClient GitHubReleaseClient,
|
||||
interval time.Duration,
|
||||
) *OpenAICodexVersionSyncService {
|
||||
return &OpenAICodexVersionSyncService{
|
||||
settingRepo: settingRepo,
|
||||
settingService: settingService,
|
||||
githubClient: githubClient,
|
||||
interval: interval,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OpenAICodexVersionSyncService) Start() {
|
||||
if s == nil || s.settingRepo == nil || s.githubClient == nil || s.interval <= 0 {
|
||||
return
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
ticker := time.NewTicker(s.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
s.runInitial()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.runOnce()
|
||||
case <-s.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *OpenAICodexVersionSyncService) Stop() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.stopOnce.Do(func() {
|
||||
close(s.stopCh)
|
||||
})
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
// runInitial 执行启动时的首次同步。若同步值在一个同步周期内已被刷新过则跳过:
|
||||
// 频繁重启、滚动发布或崩溃重启会让「启动即同步」放大成对 GitHub 的连续请求,
|
||||
// 而版本号是天级变化的,重启后没有立刻重新拉取的必要。
|
||||
func (s *OpenAICodexVersionSyncService) runInitial() {
|
||||
if s.syncedWithinInterval() {
|
||||
return
|
||||
}
|
||||
s.runOnce()
|
||||
}
|
||||
|
||||
// syncedWithinInterval 判断已同步值是否仍在一个同步周期内。
|
||||
// 借设置行自身的 UpdatedAt 判断,无需额外记录时间戳的设置项。
|
||||
// 读取失败或尚无有效同步值时返回 false,让启动同步照常执行。
|
||||
func (s *OpenAICodexVersionSyncService) syncedWithinInterval() bool {
|
||||
if s.interval <= 0 {
|
||||
return false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), openAICodexVersionSyncTimeout)
|
||||
defer cancel()
|
||||
|
||||
setting, err := s.settingRepo.Get(ctx, SettingKeyOpenAICodexClientVersionSynced)
|
||||
if err != nil || setting == nil || setting.UpdatedAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
if NormalizeCodexClientVersion(setting.Value) == "" {
|
||||
return false
|
||||
}
|
||||
return time.Since(setting.UpdatedAt) < s.interval
|
||||
}
|
||||
|
||||
func (s *OpenAICodexVersionSyncService) runOnce() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), openAICodexVersionSyncTimeout)
|
||||
defer cancel()
|
||||
|
||||
if !s.autoSyncEnabled(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
latest := s.fetchLatestStableVersion(ctx)
|
||||
if latest == "" {
|
||||
return
|
||||
}
|
||||
|
||||
current := NormalizeCodexClientVersion(s.currentSyncedVersion(ctx))
|
||||
// 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步的版本号降级。
|
||||
if current != "" && CompareVersions(latest, current) <= 0 {
|
||||
return
|
||||
}
|
||||
if err := s.settingRepo.Set(ctx, SettingKeyOpenAICodexClientVersionSynced, latest); err != nil {
|
||||
slog.Warn("openai_codex_version_sync_persist_failed", "version", latest, "error", err)
|
||||
return
|
||||
}
|
||||
s.settingService.InvalidateOpenAICodexClientVersionCache()
|
||||
slog.Info("openai_codex_version_synced", "previous", current, "version", latest)
|
||||
}
|
||||
|
||||
// fetchLatestStableVersion 取官方最新稳定版客户端版本号;取不到时返回空串,
|
||||
// 由调用方保持既有值(不清空、不降级),各失败分支自行落日志。
|
||||
//
|
||||
// 主路径 /releases/latest:该端点本身就排除 draft 与 prerelease,直接给出最新正式发布,
|
||||
// 因此不受该仓库预发布密度的影响,也不需要为了「窗口里得有一条稳定版」而多拉数据——
|
||||
// 实测单条 release 约 0.3MB,而 per_page=30 的列表页约 10MB。
|
||||
//
|
||||
// 回退列表扫描:latest 是跨 tag 家族按 published_at 取的,若同仓库其他组件
|
||||
// (如 rusty-v8-*)某天发了正式 release 而成为 latest,主路径会被 rust-v 前缀过滤挡掉;
|
||||
// 此时必须扫一页 release 才能继续跟随官方版本,否则版本号会静默停更。
|
||||
// 两条路径共用同一套过滤(前缀 / draft / prerelease / 版本号形态),语义不会分叉。
|
||||
func (s *OpenAICodexVersionSyncService) fetchLatestStableVersion(ctx context.Context) string {
|
||||
release, err := s.githubClient.FetchLatestRelease(ctx, openAICodexVersionSyncRepo)
|
||||
if err != nil {
|
||||
slog.Warn("openai_codex_version_sync_latest_fetch_failed", "error", err)
|
||||
} else if version := latestCodexStableReleaseVersion([]*GitHubRelease{release}); version != "" {
|
||||
return version
|
||||
}
|
||||
|
||||
// 主路径没拿到可用版本(抓取失败,或 latest 不是客户端 tag 家族的稳定版)。
|
||||
releases, err := s.githubClient.FetchRecentReleases(ctx, openAICodexVersionSyncRepo, openAICodexVersionSyncPerPage)
|
||||
if err != nil {
|
||||
slog.Warn("openai_codex_version_sync_fetch_failed", "error", err)
|
||||
return ""
|
||||
}
|
||||
version := latestCodexStableReleaseVersion(releases)
|
||||
if version == "" {
|
||||
slog.Warn("openai_codex_version_sync_no_stable_release", "repo", openAICodexVersionSyncRepo)
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// autoSyncEnabled 读取面板开关。缺失或空值视为开启,与设置默认值一致;
|
||||
// 读取失败时保持开启,避免一次数据库抖动就静默停掉版本跟随。
|
||||
func (s *OpenAICodexVersionSyncService) autoSyncEnabled(ctx context.Context) bool {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAICodexVersionAutoSyncEnabled)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(value) == "true"
|
||||
}
|
||||
|
||||
func (s *OpenAICodexVersionSyncService) currentSyncedVersion(ctx context.Context) string {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAICodexClientVersionSynced)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// latestCodexStableReleaseVersion 从 release 列表里挑出最大的稳定版客户端版本号。
|
||||
// 过滤条件:tag 前缀为 rust-v(排除同仓库其他组件的 tag)、非草稿、非预发布、
|
||||
// 版本号不带 -alpha/-beta 之类后缀。取最大值而非最新发布,避免重新发布历史 tag 造成回退。
|
||||
// 主路径的单条 /releases/latest 结果也走本函数(单元素切片),保证两条取数路径的过滤语义一致。
|
||||
func latestCodexStableReleaseVersion(releases []*GitHubRelease) string {
|
||||
best := ""
|
||||
for _, release := range releases {
|
||||
if release == nil || release.Draft || release.Prerelease {
|
||||
continue
|
||||
}
|
||||
tag := strings.TrimSpace(release.TagName)
|
||||
if !strings.HasPrefix(tag, openAICodexVersionTagPrefix) {
|
||||
continue
|
||||
}
|
||||
version := NormalizeCodexClientVersion(strings.TrimPrefix(tag, openAICodexVersionTagPrefix))
|
||||
if version == "" || strings.Contains(version, "-") {
|
||||
continue
|
||||
}
|
||||
if best == "" || CompareVersions(version, best) > 0 {
|
||||
best = version
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- mock: 只实现同步任务用到的读写,其余方法不应被调用 ---
|
||||
|
||||
type codexVersionSyncSettingRepoStub struct {
|
||||
SettingRepository // 嵌入接口,未实现的方法会 panic(不应被调用)
|
||||
|
||||
mu sync.Mutex
|
||||
values map[string]string
|
||||
getErr error
|
||||
setErr error
|
||||
updatedAt time.Time
|
||||
writes []string
|
||||
}
|
||||
|
||||
func newCodexVersionSyncSettingRepoStub(values map[string]string) *codexVersionSyncSettingRepoStub {
|
||||
if values == nil {
|
||||
values = map[string]string{}
|
||||
}
|
||||
return &codexVersionSyncSettingRepoStub{values: values}
|
||||
}
|
||||
|
||||
func (r *codexVersionSyncSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.getErr != nil {
|
||||
return "", r.getErr
|
||||
}
|
||||
return r.values[key], nil
|
||||
}
|
||||
|
||||
func (r *codexVersionSyncSettingRepoStub) Set(_ context.Context, key, value string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.setErr != nil {
|
||||
return r.setErr
|
||||
}
|
||||
r.values[key] = value
|
||||
r.writes = append(r.writes, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *codexVersionSyncSettingRepoStub) syncedWrites() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.writes...)
|
||||
}
|
||||
|
||||
type codexVersionSyncGitHubStub struct {
|
||||
GitHubReleaseClient // 嵌入接口,未实现的方法会 panic(不应被调用)
|
||||
|
||||
// 主路径 /releases/latest;latest 为 nil 且 latestErr 为 nil 时模拟「拿不到可用值」,
|
||||
// 使调用落到回退的列表扫描上。
|
||||
latest *GitHubRelease
|
||||
latestErr error
|
||||
latestCalls int
|
||||
|
||||
releases []*GitHubRelease
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (c *codexVersionSyncGitHubStub) FetchLatestRelease(_ context.Context, _ string) (*GitHubRelease, error) {
|
||||
c.latestCalls++
|
||||
if c.latestErr != nil {
|
||||
return nil, c.latestErr
|
||||
}
|
||||
return c.latest, nil
|
||||
}
|
||||
|
||||
func (c *codexVersionSyncGitHubStub) FetchRecentReleases(_ context.Context, _ string, _ int) ([]*GitHubRelease, error) {
|
||||
c.calls++
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
return c.releases, nil
|
||||
}
|
||||
|
||||
func newCodexVersionSyncService(
|
||||
repo SettingRepository,
|
||||
github GitHubReleaseClient,
|
||||
) *OpenAICodexVersionSyncService {
|
||||
return NewOpenAICodexVersionSyncService(repo, &SettingService{}, github, openAICodexVersionSyncInterval)
|
||||
}
|
||||
|
||||
// 同仓库同时发布预发布版与其他组件的 tag,必须只认 rust-v 前缀的稳定版,
|
||||
// 否则会把无关组件的版本号(如 rusty-v8-v150.4.0)当成客户端版本同步出去。
|
||||
func TestLatestCodexStableReleaseVersion(t *testing.T) {
|
||||
releases := []*GitHubRelease{
|
||||
{TagName: "rusty-v8-v150.4.0"},
|
||||
{TagName: "rust-v0.147.0-alpha.4", Prerelease: true},
|
||||
{TagName: "rust-v0.146.0"},
|
||||
{TagName: "rust-v0.145.0"},
|
||||
{TagName: "rust-v0.999.0", Draft: true},
|
||||
{TagName: "not-a-tag"},
|
||||
nil,
|
||||
}
|
||||
|
||||
require.Equal(t, "0.146.0", latestCodexStableReleaseVersion(releases))
|
||||
require.Empty(t, latestCodexStableReleaseVersion(nil))
|
||||
require.Empty(t, latestCodexStableReleaseVersion([]*GitHubRelease{{TagName: "rusty-v8-v150.4.0"}}))
|
||||
// 预发布 tag 即使漏标 Prerelease 也要被版本号后缀挡住。
|
||||
require.Empty(t, latestCodexStableReleaseVersion([]*GitHubRelease{{TagName: "rust-v0.147.0-alpha.4"}}))
|
||||
}
|
||||
|
||||
func TestOpenAICodexVersionSyncWritesLatestStableVersion(t *testing.T) {
|
||||
repo := newCodexVersionSyncSettingRepoStub(nil)
|
||||
github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{
|
||||
{TagName: "rust-v0.145.0"},
|
||||
{TagName: "rust-v0.146.0"},
|
||||
}}
|
||||
|
||||
newCodexVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Equal(t, []string{"0.146.0"}, repo.syncedWrites())
|
||||
}
|
||||
|
||||
// 只向前推进:上游偶发返回旧数据或重新发布历史 tag 时不把已同步版本降级。
|
||||
func TestOpenAICodexVersionSyncNeverMovesBackwards(t *testing.T) {
|
||||
repo := newCodexVersionSyncSettingRepoStub(map[string]string{
|
||||
SettingKeyOpenAICodexClientVersionSynced: "0.146.0",
|
||||
})
|
||||
github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.145.0"}}}
|
||||
|
||||
newCodexVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Empty(t, repo.syncedWrites())
|
||||
}
|
||||
|
||||
func TestOpenAICodexVersionSyncSkippedWhenDisabled(t *testing.T) {
|
||||
repo := newCodexVersionSyncSettingRepoStub(map[string]string{
|
||||
SettingKeyOpenAICodexVersionAutoSyncEnabled: "false",
|
||||
})
|
||||
github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}}
|
||||
|
||||
newCodexVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Zero(t, github.latestCalls, "关闭自动同步后不应请求上游")
|
||||
require.Zero(t, github.calls, "关闭自动同步后不应请求上游")
|
||||
require.Empty(t, repo.syncedWrites())
|
||||
}
|
||||
|
||||
// 面板开关缺失或为空一律视为开启,与设置默认值一致。
|
||||
func TestOpenAICodexVersionSyncEnabledByDefault(t *testing.T) {
|
||||
for _, value := range []string{"", "true"} {
|
||||
repo := newCodexVersionSyncSettingRepoStub(map[string]string{
|
||||
SettingKeyOpenAICodexVersionAutoSyncEnabled: value,
|
||||
})
|
||||
github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}}
|
||||
|
||||
newCodexVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Equal(t, []string{"0.146.0"}, repo.syncedWrites(), "开关值 %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
// 抓取失败保持既有值,不清空、不降级。两条取数路径都失败才算真正拿不到。
|
||||
func TestOpenAICodexVersionSyncKeepsValueOnFetchError(t *testing.T) {
|
||||
repo := newCodexVersionSyncSettingRepoStub(map[string]string{
|
||||
SettingKeyOpenAICodexClientVersionSynced: "0.146.0",
|
||||
})
|
||||
github := &codexVersionSyncGitHubStub{
|
||||
latestErr: errors.New("network down"),
|
||||
err: errors.New("network down"),
|
||||
}
|
||||
|
||||
newCodexVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Equal(t, 1, github.latestCalls)
|
||||
require.Equal(t, 1, github.calls)
|
||||
require.Empty(t, repo.syncedWrites())
|
||||
value, err := repo.GetValue(context.Background(), SettingKeyOpenAICodexClientVersionSynced)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "0.146.0", value)
|
||||
}
|
||||
|
||||
// 主路径 /releases/latest:该端点已排除 draft / prerelease,直接给出最新正式发布,
|
||||
// 因此不受该仓库预发布密度影响,也不必再去拉 ~10MB 的列表页。
|
||||
func TestOpenAICodexVersionSyncUsesLatestReleaseEndpoint(t *testing.T) {
|
||||
repo := newCodexVersionSyncSettingRepoStub(nil)
|
||||
github := &codexVersionSyncGitHubStub{
|
||||
latest: &GitHubRelease{TagName: "rust-v0.146.0"},
|
||||
// 列表若被调用会给出不同答案,用于证明取值确实来自主路径。
|
||||
releases: []*GitHubRelease{{TagName: "rust-v0.145.0"}},
|
||||
}
|
||||
|
||||
newCodexVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Equal(t, 1, github.latestCalls)
|
||||
require.Zero(t, github.calls, "主路径可用时不应再拉列表页")
|
||||
require.Equal(t, []string{"0.146.0"}, repo.syncedWrites())
|
||||
}
|
||||
|
||||
// 回退列表扫描:latest 是跨 tag 家族按 published_at 取的,主路径拿不到客户端稳定版时
|
||||
// 必须继续扫一页 release,否则版本号会静默停更。三种拿不到的形态都要回退。
|
||||
func TestOpenAICodexVersionSyncFallsBackToReleaseList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
latest *GitHubRelease
|
||||
latestErr error
|
||||
}{
|
||||
// 同仓库其他组件(rusty-v8-*)某天发了正式 release 而成为 latest。
|
||||
{name: "latest 属于其他 tag 家族", latest: &GitHubRelease{TagName: "rusty-v8-v150.4.0"}},
|
||||
// 端点语义若变化(返回 draft/prerelease),过滤仍然拦得住,并照常回退。
|
||||
{name: "latest 是预发布", latest: &GitHubRelease{TagName: "rust-v0.147.0-alpha.4", Prerelease: true}},
|
||||
{name: "latest 抓取失败", latestErr: errors.New("network down")},
|
||||
// 上游返回空对象:不得因此 panic,按拿不到处理。
|
||||
{name: "latest 为空", latest: nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := newCodexVersionSyncSettingRepoStub(nil)
|
||||
github := &codexVersionSyncGitHubStub{
|
||||
latest: tt.latest,
|
||||
latestErr: tt.latestErr,
|
||||
releases: []*GitHubRelease{
|
||||
{TagName: "rust-v0.147.0-alpha.4", Prerelease: true},
|
||||
{TagName: "rust-v0.146.0"},
|
||||
{TagName: "rust-v0.145.0"},
|
||||
},
|
||||
}
|
||||
|
||||
newCodexVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Equal(t, 1, github.latestCalls)
|
||||
require.Equal(t, 1, github.calls)
|
||||
require.Equal(t, []string{"0.146.0"}, repo.syncedWrites())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 两条路径共用同一套过滤:主路径的单条 latest 同样要过前缀 / 版本号形态校验,
|
||||
// 不能因为「端点保证是正式发布」就直接采信 tag。
|
||||
func TestOpenAICodexVersionSyncLatestSharesFiltering(t *testing.T) {
|
||||
repo := newCodexVersionSyncSettingRepoStub(nil)
|
||||
github := &codexVersionSyncGitHubStub{
|
||||
// 仓库里确实存在 rust-vv0.99.0-alpha.8 / rust-vrust-v0.145.0-alpha.6 这类畸形 tag:
|
||||
// 剥掉前缀后不是合法版本号,必须被拒绝而不是写成 v0.99.0。
|
||||
latest: &GitHubRelease{TagName: "rust-vv0.99.0"},
|
||||
releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}},
|
||||
}
|
||||
|
||||
newCodexVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Equal(t, []string{"0.146.0"}, repo.syncedWrites())
|
||||
}
|
||||
|
||||
// 依赖缺失时 Start 必须直接返回,不能起一个空转的 goroutine。
|
||||
func TestOpenAICodexVersionSyncStartRequiresDependencies(t *testing.T) {
|
||||
require.NotPanics(t, func() {
|
||||
svc := NewOpenAICodexVersionSyncService(nil, nil, nil, openAICodexVersionSyncInterval)
|
||||
svc.Start()
|
||||
svc.Stop()
|
||||
})
|
||||
}
|
||||
|
||||
// --- mock: 版本号读取只用到 GetMultiple ---
|
||||
|
||||
type codexVersionSettingRepoStub struct {
|
||||
SettingRepository // 嵌入接口,未实现的方法会 panic(不应被调用)
|
||||
|
||||
values map[string]string
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *codexVersionSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
out := make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
out[key] = r.values[key]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 规范 UA 解析会先读面板的完整 UA 键。
|
||||
func (r *codexVersionSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
|
||||
if r.err != nil {
|
||||
return "", r.err
|
||||
}
|
||||
return r.values[key], nil
|
||||
}
|
||||
|
||||
// 版本号优先级:管理员面板覆写 → 自动同步值 → 内置常量。
|
||||
// 管理员覆写必须压过同步值,否则「固定版本」的诉求会被 3 小时后的同步冲掉。
|
||||
func TestGetOpenAICodexClientVersionPriority(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
override string
|
||||
synced string
|
||||
want string
|
||||
}{
|
||||
{name: "面板覆写优先", override: "0.150.0", synced: "0.146.0", want: "0.150.0"},
|
||||
{name: "覆写为空时用同步值", synced: "0.146.0", want: "0.146.0"},
|
||||
{name: "两者皆空时用内置常量", want: codexCLIVersion},
|
||||
{name: "非法覆写回退同步值", override: "latest", synced: "0.146.0", want: "0.146.0"},
|
||||
{name: "非法同步值回退内置常量", synced: "not-a-version", want: codexCLIVersion},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
|
||||
SettingKeyOpenAICodexClientVersion: tt.override,
|
||||
SettingKeyOpenAICodexClientVersionSynced: tt.synced,
|
||||
}}, nil)
|
||||
|
||||
require.Equal(t, tt.want, svc.GetOpenAICodexClientVersion(context.Background()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 读取失败必须回退内置常量,不能返回空串把畸形身份拼给上游。
|
||||
func TestGetOpenAICodexClientVersionFallsBackOnError(t *testing.T) {
|
||||
svc := NewSettingService(&codexVersionSettingRepoStub{err: errors.New("db down")}, nil)
|
||||
require.Equal(t, codexCLIVersion, svc.GetOpenAICodexClientVersion(context.Background()))
|
||||
}
|
||||
|
||||
// 规范 UA:面板未填完整 UA 时按当前生效版本号拼出标准 CLI 形态。
|
||||
func TestGetOpenAICodexCanonicalUserAgentBuildsFromVersion(t *testing.T) {
|
||||
svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
|
||||
SettingKeyOpenAICodexClientVersionSynced: "0.200.1",
|
||||
}}, nil)
|
||||
|
||||
require.Equal(t,
|
||||
"codex_cli_rs/0.200.1"+codexCLIUserAgentSuffix,
|
||||
svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
|
||||
)
|
||||
}
|
||||
|
||||
// 回归:面板完整 UA 是唯一能改 OS / 架构 / 终端指纹的地方,必须保留;但它填写于某个
|
||||
// 历史版本,逐字沿用会绕过版本自动同步、把出站身份永久钉死在陈旧版本上——而陈旧身份
|
||||
// 正是上游优先降载的那一侧。因此只借它的指纹,版本段一律用生效版本重建。
|
||||
func TestGetOpenAICodexCanonicalUserAgentRebuildsPanelUAVersion(t *testing.T) {
|
||||
t.Run("陈旧面板 UA 跟随生效版本", func(t *testing.T) {
|
||||
svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
|
||||
// 历史面板 placeholder 的原文,照抄填写过的存量部署就是这个值。
|
||||
SettingKeyOpenAICodexUserAgent: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color",
|
||||
SettingKeyOpenAICodexClientVersionSynced: "0.200.1",
|
||||
}}, nil)
|
||||
|
||||
require.Equal(t,
|
||||
"codex_cli_rs/0.200.1 (Ubuntu 22.4.0; x86_64) xterm-256color",
|
||||
svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("自定义指纹原样保留", func(t *testing.T) {
|
||||
svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
|
||||
SettingKeyOpenAICodexUserAgent: "codex_cli_rs/0.140.0 (Mac OS X 15.1.0; arm64) iTerm.app",
|
||||
SettingKeyOpenAICodexClientVersionSynced: "0.200.1",
|
||||
}}, nil)
|
||||
|
||||
require.Equal(t,
|
||||
"codex_cli_rs/0.200.1 (Mac OS X 15.1.0; arm64) iTerm.app",
|
||||
svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
|
||||
)
|
||||
})
|
||||
|
||||
// 面板版本号覆写优先级仍然高于同步值:管理员固定版本的诉求不被重建绕开。
|
||||
t.Run("面板版本号覆写优先", func(t *testing.T) {
|
||||
svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
|
||||
SettingKeyOpenAICodexUserAgent: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color",
|
||||
SettingKeyOpenAICodexClientVersion: "0.150.0",
|
||||
SettingKeyOpenAICodexClientVersionSynced: "0.200.1",
|
||||
}}, nil)
|
||||
|
||||
require.Equal(t,
|
||||
"codex_cli_rs/0.150.0 (Ubuntu 22.4.0; x86_64) xterm-256color",
|
||||
svc.GetOpenAICodexCanonicalUserAgent(context.Background()),
|
||||
)
|
||||
})
|
||||
|
||||
// 非 `{client}/{version}` 形态无法重建,原样返回,由收口整体回退规范身份。
|
||||
t.Run("非 Codex 形态原样返回", func(t *testing.T) {
|
||||
svc := NewSettingService(&codexVersionSettingRepoStub{values: map[string]string{
|
||||
SettingKeyOpenAICodexUserAgent: "not-a-codex-client",
|
||||
}}, nil)
|
||||
|
||||
require.Equal(t, "not-a-codex-client", svc.GetOpenAICodexCanonicalUserAgent(context.Background()))
|
||||
})
|
||||
}
|
||||
|
||||
func (r *codexVersionSyncSettingRepoStub) Get(_ context.Context, key string) (*Setting, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.getErr != nil {
|
||||
return nil, r.getErr
|
||||
}
|
||||
value, ok := r.values[key]
|
||||
if !ok {
|
||||
return nil, ErrSettingNotFound
|
||||
}
|
||||
return &Setting{Key: key, Value: value, UpdatedAt: r.updatedAt}, nil
|
||||
}
|
||||
|
||||
// 启动同步防抖:同步值仍在一个周期内时跳过,避免频繁重启/滚动发布把启动同步
|
||||
// 放大成对 GitHub 的连续请求。
|
||||
func TestOpenAICodexVersionSyncInitialSkipsWhenRecentlySynced(t *testing.T) {
|
||||
repo := newCodexVersionSyncSettingRepoStub(map[string]string{
|
||||
SettingKeyOpenAICodexClientVersionSynced: "0.146.0",
|
||||
})
|
||||
repo.updatedAt = time.Now().Add(-time.Hour)
|
||||
github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.147.0"}}}
|
||||
|
||||
newCodexVersionSyncService(repo, github).runInitial()
|
||||
|
||||
require.Zero(t, github.calls, "同步值仍在周期内时不应请求上游")
|
||||
require.Empty(t, repo.syncedWrites())
|
||||
}
|
||||
|
||||
func TestOpenAICodexVersionSyncInitialRunsWhenStaleOrMissing(t *testing.T) {
|
||||
t.Run("同步值已过期", func(t *testing.T) {
|
||||
repo := newCodexVersionSyncSettingRepoStub(map[string]string{
|
||||
SettingKeyOpenAICodexClientVersionSynced: "0.146.0",
|
||||
})
|
||||
repo.updatedAt = time.Now().Add(-7 * time.Hour)
|
||||
github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.147.0"}}}
|
||||
|
||||
newCodexVersionSyncService(repo, github).runInitial()
|
||||
|
||||
require.Equal(t, 1, github.calls)
|
||||
require.Equal(t, []string{"0.147.0"}, repo.syncedWrites())
|
||||
})
|
||||
|
||||
// 首次部署尚无同步值:必须立刻同步,不能被防抖挡住。
|
||||
t.Run("尚无同步值", func(t *testing.T) {
|
||||
repo := newCodexVersionSyncSettingRepoStub(nil)
|
||||
repo.updatedAt = time.Now()
|
||||
github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.146.0"}}}
|
||||
|
||||
newCodexVersionSyncService(repo, github).runInitial()
|
||||
|
||||
require.Equal(t, 1, github.calls)
|
||||
require.Equal(t, []string{"0.146.0"}, repo.syncedWrites())
|
||||
})
|
||||
}
|
||||
|
||||
// 版本比较必须按段取数字:字典序会把 0.99.0 判为大于 0.146.0,
|
||||
// 从而让「取最大值」和「只向前推进」两处逻辑同时判错。
|
||||
func TestCodexVersionComparisonIsNumericNotLexical(t *testing.T) {
|
||||
require.Greater(t, CompareVersions("0.146.0", "0.99.0"), 0)
|
||||
|
||||
require.Equal(t, "0.146.0", latestCodexStableReleaseVersion([]*GitHubRelease{
|
||||
{TagName: "rust-v0.99.0"},
|
||||
{TagName: "rust-v0.146.0"},
|
||||
}))
|
||||
|
||||
repo := newCodexVersionSyncSettingRepoStub(map[string]string{
|
||||
SettingKeyOpenAICodexClientVersionSynced: "0.146.0",
|
||||
})
|
||||
github := &codexVersionSyncGitHubStub{releases: []*GitHubRelease{{TagName: "rust-v0.99.0"}}}
|
||||
|
||||
newCodexVersionSyncService(repo, github).runOnce()
|
||||
|
||||
require.Empty(t, repo.syncedWrites(), "0.99.0 低于已同步的 0.146.0,不得写入")
|
||||
}
|
||||
@@ -1020,27 +1020,16 @@ func TestForwardAsAnthropic_OAuthRestoresCodexIdentityHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
const tuiUA = "codex-tui/9.9.9 (Mac OS X 14.0; arm64) iTerm (codex-tui; 9.9.9)"
|
||||
const vscodeUA = "codex_vscode/9.9.9 (Mac OS X 14.0; arm64) vscode (codex_vscode; 9.9.9)"
|
||||
// messages 桥接路径同样强制统一出站身份:三类客户端身份都收敛到规范身份。
|
||||
tests := []struct {
|
||||
name string
|
||||
userAgent string
|
||||
originator string
|
||||
wantUserAgent string
|
||||
wantOriginator string
|
||||
name string
|
||||
userAgent string
|
||||
originator string
|
||||
}{
|
||||
{
|
||||
name: "官方UA逐字保留并重新配对",
|
||||
userAgent: tuiUA,
|
||||
originator: "opencode",
|
||||
wantUserAgent: tuiUA,
|
||||
wantOriginator: "codex-tui",
|
||||
},
|
||||
{
|
||||
name: "第三方UA回退为默认Codex身份",
|
||||
userAgent: "third-party-client/1.0.0",
|
||||
originator: "opencode",
|
||||
wantUserAgent: codexCLIUserAgent,
|
||||
wantOriginator: "codex_cli_rs",
|
||||
},
|
||||
{name: "官方vscode身份", userAgent: vscodeUA, originator: "opencode"},
|
||||
{name: "TUI身份", userAgent: tuiUA, originator: "opencode"},
|
||||
{name: "第三方UA", userAgent: "third-party-client/1.0.0", originator: "opencode"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -1073,7 +1062,7 @@ func TestForwardAsAnthropic_OAuthRestoresCodexIdentityHeaders(t *testing.T) {
|
||||
result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
requireOpenAIMessagesCodexIdentity(t, upstream.lastReq, tt.wantUserAgent, tt.wantOriginator)
|
||||
requireOpenAIMessagesCodexIdentity(t, upstream.lastReq, codexCLIUserAgent, "codex_cli_rs")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,11 @@ func (s *OpenAIGatewayService) failoverOpenAIUpstreamHTTPError(
|
||||
upstreamModel string,
|
||||
) *UpstreamFailoverError {
|
||||
shouldFailover := s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody)
|
||||
tempUnscheduled := false
|
||||
if c != nil && account != nil && account.Platform != PlatformGrok && !shouldFailover && !IsResponseCommitted(c) && s.rateLimitService != nil {
|
||||
tempUnscheduled = s.rateLimitService.CheckErrorPolicy(ctx, account, resp.StatusCode, respBody, upstreamModel) == ErrorPolicyTempUnscheduled
|
||||
shouldFailover = tempUnscheduled
|
||||
}
|
||||
if account != nil && account.Platform == PlatformGrok {
|
||||
shouldFailover = s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody)
|
||||
}
|
||||
@@ -116,8 +121,8 @@ func (s *OpenAIGatewayService) failoverOpenAIUpstreamHTTPError(
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
shouldDisable := false
|
||||
if account.Platform != PlatformGrok {
|
||||
shouldDisable := tempUnscheduled
|
||||
if account.Platform != PlatformGrok && !tempUnscheduled {
|
||||
shouldDisable = s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody, upstreamModel)
|
||||
}
|
||||
return newOpenAIUpstreamFailoverError(
|
||||
|
||||
@@ -1098,13 +1098,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
||||
req.Header.Set("user-agent", codexCLIUserAgent)
|
||||
}
|
||||
|
||||
// 浏览器型 UA 兜底:仅 OAuth(ChatGPT 内部接口)账号生效,若最终 user-agent 仍为浏览器
|
||||
// (Chrome/Firefox/Safari/Edge 等),替换为后台配置的 Codex UA,避免 Cloudflare 触发 JS 质询。
|
||||
s.overrideBrowserUserAgent(ctx, account, req)
|
||||
|
||||
// 终态收口:originator 必须与最终 User-Agent 首段配套且为官方身份,否则上游 404(issue #3901)。
|
||||
// 终态收口:强制统一 OAuth 出站身份(User-Agent / originator / version 同源自洽)。
|
||||
// 客户端自报身份不参与构造,浏览器型 UA 也因此不会再到达上游(原浏览器 UA 兜底已被吸收)。
|
||||
if account.Type == AccountTypeOAuth {
|
||||
enforceCodexIdentityHeaders(req.Header)
|
||||
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
|
||||
}
|
||||
|
||||
// Ensure required headers exist
|
||||
@@ -1118,26 +1115,12 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// overrideBrowserUserAgent 检查请求的最终 user-agent,若为浏览器 UA 则替换为后台配置的 Codex UA。
|
||||
// 用于规避 Cloudflare 对浏览器型 UA 在 ChatGPT 内部接口上的访问质询。
|
||||
// 影响范围严格限定:仅 OAuth(Codex/ChatGPT 内部接口)账号生效;API Key 等其他账号原样透传。
|
||||
// 仅在识别为浏览器(Mozilla/...)时改写,其他 CLI/工具 UA 不动。
|
||||
func (s *OpenAIGatewayService) overrideBrowserUserAgent(ctx context.Context, account *Account, req *http.Request) {
|
||||
if req == nil || account == nil {
|
||||
return
|
||||
// codexIdentityOverrideUA 返回账号级显式配置的出站 User-Agent,供强制统一身份时作为覆写来源。
|
||||
// ForceCodexCLI 语义是「强制使用 Codex CLI 身份」,等价于使用网关规范身份,故返回空串;
|
||||
// 该优先级与历史行为一致(ForceCodexCLI 在账号自定义 UA 之后生效)。
|
||||
func (s *OpenAIGatewayService) codexIdentityOverrideUA(account *Account) string {
|
||||
if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
|
||||
return ""
|
||||
}
|
||||
if account.Type != AccountTypeOAuth {
|
||||
return
|
||||
}
|
||||
currentUA := req.Header.Get("user-agent")
|
||||
if !openai.IsBrowserUserAgent(currentUA) {
|
||||
return
|
||||
}
|
||||
codexUA := DefaultOpenAICodexUserAgent
|
||||
if s != nil && s.settingService != nil {
|
||||
if v := strings.TrimSpace(s.settingService.GetOpenAICodexUserAgent(ctx)); v != "" {
|
||||
codexUA = v
|
||||
}
|
||||
}
|
||||
req.Header.Set("user-agent", codexUA)
|
||||
return account.GetOpenAIUserAgent()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -246,6 +247,14 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
}
|
||||
}
|
||||
}
|
||||
if account.Platform == PlatformOpenAI {
|
||||
if policyBody, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, responsesBody); changed {
|
||||
responsesBody = policyBody
|
||||
if responsesReq.Reasoning != nil {
|
||||
responsesReq.Reasoning.Effort = gjson.GetBytes(responsesBody, "reasoning.effort").String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4c. Apply OpenAI fast policy (may filter service_tier or block the request).
|
||||
// Mirrors the Claude anthropic-beta "fast-mode-2026-02-01" filter, but keyed
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -78,6 +79,14 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions(
|
||||
if normalizedBody, normalized := NormalizeGLMOpenAIReasoningEffort(chatBody, upstreamModel); normalized {
|
||||
chatBody = normalizedBody
|
||||
}
|
||||
if account.Platform == PlatformOpenAI {
|
||||
if policyBody, changed := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, chatBody); changed {
|
||||
chatBody = policyBody
|
||||
if effectiveEffort := strings.TrimSpace(gjson.GetBytes(chatBody, "reasoning_effort").String()); effectiveEffort != "" {
|
||||
reasoningEffort = &effectiveEffort
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unlike forwardResponsesViaRawChatCompletions, applyOpenAIFastPolicyToBody
|
||||
// is intentionally skipped: Anthropic Messages bodies carry no service_tier,
|
||||
// so the converted Chat Completions body never contains one and the policy
|
||||
|
||||
@@ -54,7 +54,16 @@ func TestForwardAsAnthropic_ForceChatCompletionsPreservesFinalModelReasoningEffo
|
||||
mapped string
|
||||
effortJSON string
|
||||
wantEffort string
|
||||
maxPolicy string
|
||||
}{
|
||||
{
|
||||
name: "policy caps converted effort",
|
||||
model: "gpt-5.6-luna",
|
||||
mapped: "gpt-5.6-luna",
|
||||
effortJSON: `,"output_config":{"effort":"max"}`,
|
||||
wantEffort: "medium",
|
||||
maxPolicy: "medium",
|
||||
},
|
||||
{
|
||||
name: "GPT56 max",
|
||||
model: "luna",
|
||||
@@ -103,7 +112,11 @@ func TestForwardAsAnthropic_ForceChatCompletionsPreservesFinalModelReasoningEffo
|
||||
account.Credentials["model_mapping"] = map[string]any{tt.model: tt.mapped}
|
||||
|
||||
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
|
||||
result, err := svc.ForwardAsAnthropic(context.Background(), c, account, []byte(body), "", "")
|
||||
ctx := context.Background()
|
||||
if tt.maxPolicy != "" {
|
||||
ctx = WithOpenAIReasoningEffortPolicy(ctx, tt.maxPolicy, nil)
|
||||
}
|
||||
result, err := svc.ForwardAsAnthropic(ctx, c, account, []byte(body), "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, tt.mapped, gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
@@ -420,7 +433,7 @@ func TestForwardAsAnthropic_ForceChatCompletionsStreamReadErrorSkipsFinalize(t *
|
||||
func TestForwardAsAnthropic_ResponsesSupportedAccountStillUsesResponsesEndpoint(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
|
||||
body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"output_config":{"effort":"high"},"stream":false}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
|
||||
@@ -449,12 +462,16 @@ func TestForwardAsAnthropic_ResponsesSupportedAccountStillUsesResponsesEndpoint(
|
||||
openai_compat.ExtraKeyResponsesSupported: true,
|
||||
}
|
||||
|
||||
result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
|
||||
ctx := WithOpenAIReasoningEffortPolicy(context.Background(), "medium", nil)
|
||||
result, err := svc.ForwardAsAnthropic(ctx, c, account, body, "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, strings.HasSuffix(upstream.lastReq.URL.Path, "/responses"),
|
||||
"responses-capable account must stay on /v1/responses, got %s", upstream.lastReq.URL.String())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
|
||||
require.Equal(t, "medium", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
|
||||
require.NotNil(t, result.ReasoningEffort)
|
||||
require.Equal(t, "medium", *result.ReasoningEffort)
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists())
|
||||
require.Equal(t, "third-party-client/1.0.0", upstream.lastReq.Header.Get("User-Agent"))
|
||||
require.Equal(t, "opencode", upstream.lastReq.Header.Get("originator"))
|
||||
|
||||
@@ -443,15 +443,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
||||
if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
|
||||
req.Header.Set("user-agent", codexCLIUserAgent)
|
||||
}
|
||||
// 浏览器型 UA 兜底:仅 OAuth(ChatGPT 内部接口)账号生效,若最终 user-agent 仍为浏览器
|
||||
// (Chrome/Firefox/Safari/Edge 等),替换为后台配置的 Codex UA,避免 Cloudflare 触发 JS 质询。
|
||||
s.overrideBrowserUserAgent(ctx, account, req)
|
||||
|
||||
// 终态收口:originator 必须与最终 User-Agent 首段配套且为官方身份,非官方 UA 整体回退为
|
||||
// 默认 Codex CLI 身份(承接原「非 Codex UA 安全兜底」,并修复其把 codex-tui 等官方 UA 改写为
|
||||
// codex_cli_rs 造成的 originator 错配 404),详见 issue #3901。
|
||||
// 终态收口:透传路径的 OAuth 与非透传完全一致,同样强制统一出站身份
|
||||
// (User-Agent / originator / version 同源自洽),客户端自报身份不会到达上游。
|
||||
if account.Type == AccountTypeOAuth {
|
||||
enforceCodexIdentityHeaders(req.Header)
|
||||
enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
|
||||
}
|
||||
|
||||
if req.Header.Get("content-type") == "" {
|
||||
@@ -767,15 +762,34 @@ func openAIStreamDataStartsClientOutput(data, eventType string) bool {
|
||||
return !openAIStreamEventIsPreamble(eventType)
|
||||
}
|
||||
|
||||
// openAIStreamFailedEventErrorCode 提取流内 failed 事件的错误码(小写),
|
||||
// 兼容 response.failed 的嵌套形态与裸 error 形态。
|
||||
func openAIStreamFailedEventErrorCode(payload []byte) string {
|
||||
code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String()))
|
||||
if code == "" {
|
||||
code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String()))
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// isOpenAIUpstreamCapacityShedEvent 判断流内 failed 事件是否为上游容量降载信号。
|
||||
// 上游在容量紧张时会把请求丢进降载路径:HTTP 200 之后立刻推 event: error
|
||||
// (code=server_is_overloaded / slow_down)并以 response.failed 收尾。
|
||||
func isOpenAIUpstreamCapacityShedEvent(payload []byte) bool {
|
||||
switch openAIStreamFailedEventErrorCode(payload) {
|
||||
case "server_is_overloaded", "slow_down":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int {
|
||||
if isOpenAIContextWindowError(message, payload) {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String()))
|
||||
if code == "" {
|
||||
code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String()))
|
||||
}
|
||||
code := openAIStreamFailedEventErrorCode(payload)
|
||||
errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.type").String()))
|
||||
if errType == "" {
|
||||
errType = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.type").String()))
|
||||
@@ -925,7 +939,17 @@ func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool
|
||||
}
|
||||
|
||||
func openAIStreamFailedEventRetryableOnSameAccount(account *Account, payload []byte, message string) bool {
|
||||
if account == nil || !account.IsPoolMode() {
|
||||
if account == nil {
|
||||
return false
|
||||
}
|
||||
// 容量降载是请求级信号,不是账号级故障:上游只是让本次请求稍后再试。
|
||||
// 换账号并不改变被降载的因素(客户端身份、模型容量都与账号无关),
|
||||
// 只会让单个请求把整池账号逐个消耗掉,最终仍以同一个错误告终。
|
||||
// 因此先在同一账号上做有界重试,用尽后才按常规流程切号。
|
||||
if isOpenAIUpstreamCapacityShedEvent(payload) {
|
||||
return true
|
||||
}
|
||||
if !account.IsPoolMode() {
|
||||
return false
|
||||
}
|
||||
semanticStatus := openAIStreamFailedEventSemanticStatus(payload, message)
|
||||
@@ -1013,6 +1037,7 @@ func (s *OpenAIGatewayService) newOpenAIStreamFailoverError(
|
||||
ResponseBody: body,
|
||||
ResponseHeaders: headers,
|
||||
RetryableOnSameAccount: openAIStreamFailedEventRetryableOnSameAccount(account, payload, message),
|
||||
RequestScopedTransient: isOpenAIUpstreamCapacityShedEvent(payload),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -936,9 +936,10 @@ func TestOpenAIGatewayServiceRecordUsage_GeneratesRequestIDWhenAllSourcesMissing
|
||||
require.Equal(t, billingRepo.lastCmd.RequestID, usageRepo.lastLog.RequestID)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceRecordUsage_BillingErrorSkipsUsageLogWrite(t *testing.T) {
|
||||
func TestOpenAIGatewayServiceRecordUsage_BillingErrorWritesUnsettledUsageLog(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{}
|
||||
billingRepo := &openAIRecordUsageBillingRepoStub{err: errors.New("billing tx failed")}
|
||||
billingErr := errors.New("billing tx failed")
|
||||
billingRepo := &openAIRecordUsageBillingRepoStub{err: billingErr}
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
subRepo := &openAIRecordUsageSubRepoStub{}
|
||||
svc := newOpenAIRecordUsageServiceWithBillingRepoForTest(usageRepo, billingRepo, userRepo, subRepo, nil)
|
||||
@@ -958,9 +959,16 @@ func TestOpenAIGatewayServiceRecordUsage_BillingErrorSkipsUsageLogWrite(t *testi
|
||||
Account: &Account{ID: 30048},
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, billingErr)
|
||||
require.Equal(t, 1, billingRepo.calls)
|
||||
require.Equal(t, 0, usageRepo.calls)
|
||||
require.Equal(t, 1, usageRepo.calls)
|
||||
require.NotNil(t, usageRepo.lastLog)
|
||||
require.Equal(t, 8, usageRepo.lastLog.InputTokens)
|
||||
require.Equal(t, 4, usageRepo.lastLog.OutputTokens)
|
||||
require.Greater(t, usageRepo.lastLog.InputCost, 0.0)
|
||||
require.Greater(t, usageRepo.lastLog.OutputCost, 0.0)
|
||||
require.Greater(t, usageRepo.lastLog.TotalCost, 0.0)
|
||||
require.Zero(t, usageRepo.lastLog.ActualCost)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceRecordUsage_UpdatesAPIKeyQuotaWhenConfigured(t *testing.T) {
|
||||
|
||||
@@ -35,8 +35,13 @@ const (
|
||||
openaiStickySessionTTL = time.Hour // 粘性会话TTL
|
||||
// 与真实 Codex CLI 的 User-Agent 结构对齐:
|
||||
// {originator}/{version} ({OS} {OS_version}; {arch}) {terminal}
|
||||
// 旧值 "codex_cli_rs/0.125.0" 缺少 OS/架构/终端后缀,易被上游指纹识别为非官方客户端。
|
||||
codexCLIUserAgent = "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color"
|
||||
// 缺少 OS/架构/终端后缀的形态易被上游指纹识别为非官方客户端。
|
||||
// 该后缀是 UA 形态的唯一定义处,buildCodexCLIUserAgent 按运行时版本号复用它。
|
||||
codexCLIUserAgentSuffix = " (Ubuntu 22.4.0; x86_64) xterm-256color"
|
||||
// codexCLIUserAgent 是编译期兜底 UA;运行时优先使用由后台版本号拼出的规范 UA。
|
||||
// 版本段必须来自 codexCLIVersion:UA 与 version 头是同一个版本声明的两个出口,
|
||||
// 各自硬编码会漂移成互相矛盾的身份。
|
||||
codexCLIUserAgent = "codex_cli_rs/" + codexCLIVersion + codexCLIUserAgentSuffix
|
||||
// codex_cli_only 拒绝时单个请求头日志长度上限(字符)
|
||||
codexCLIOnlyHeaderValueMaxBytes = 256
|
||||
|
||||
@@ -51,7 +56,12 @@ const (
|
||||
openAIWSRetryJitterRatioDefault = 0.2
|
||||
openAICompactSessionSeedKey = "openai_compact_session_seed"
|
||||
openAIUpstreamEndpointContextKey = "openai_actual_upstream_endpoint"
|
||||
codexCLIVersion = "0.144.1"
|
||||
// codexCLIVersion 是网关对上游声明的 Codex 客户端版本,同时供 codexCLIUserAgent
|
||||
// 与 version 头使用。上游 /backend-api/codex 在容量紧张时按客户端身份分优先级降载,
|
||||
// 陈旧版本会被优先丢弃(HTTP 200 + 流内 server_is_overloaded);非官方客户端配不出
|
||||
// 官方身份时整体回退到本常量,因此它必须跟随官方 CLI 的当前发布版本,
|
||||
// 落后多个版本会让这些请求稳定落在被优先丢弃的一侧。
|
||||
codexCLIVersion = "0.146.0"
|
||||
// Codex 限额快照仅用于后台展示/诊断,不需要每个成功请求都立即落库。
|
||||
openAICodexSnapshotPersistMinInterval = 30 * time.Second
|
||||
// 配额自动暂停时,超过该时长仍未刷新的 used% 快照视为陈旧,不再据此暂停账号。
|
||||
@@ -471,6 +481,11 @@ func NewOpenAIGatewayService(
|
||||
settingService *SettingService,
|
||||
userPlatformQuotaRepo UserPlatformQuotaRepository,
|
||||
) *OpenAIGatewayService {
|
||||
// enforceCodexIdentityHeaders 是 HTTP / 透传 / WS / 探针 等出站路径共用的纯函数收口点,
|
||||
// 拿不到配置,故在此发布进程级开关快照。配置取反义,零值即「强制统一出口开启」。
|
||||
if cfg != nil {
|
||||
SetCodexIdentityEnforcementEnabled(!cfg.Gateway.DisableCodexIdentityEnforcement)
|
||||
}
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: accountRepo,
|
||||
usageLogRepo: usageLogRepo,
|
||||
|
||||
@@ -31,6 +31,18 @@ type stubOpenAIAccountRepo struct {
|
||||
accounts []Account
|
||||
}
|
||||
|
||||
type tempUnschedulableOpenAIAccountRepo struct {
|
||||
stubOpenAIAccountRepo
|
||||
modelRateLimitAccountID int64
|
||||
modelRateLimitKey string
|
||||
}
|
||||
|
||||
func (r *tempUnschedulableOpenAIAccountRepo) SetModelRateLimit(_ context.Context, accountID int64, modelKey string, _ time.Time, _ ...string) error {
|
||||
r.modelRateLimitAccountID = accountID
|
||||
r.modelRateLimitKey = modelKey
|
||||
return nil
|
||||
}
|
||||
|
||||
type snapshotUpdateAccountRepo struct {
|
||||
stubOpenAIAccountRepo
|
||||
updateExtraCalls chan map[string]any
|
||||
@@ -80,6 +92,113 @@ func (r stubOpenAIAccountRepo) ListSchedulableUngroupedByPlatform(ctx context.Co
|
||||
return r.ListSchedulableByPlatform(ctx, platform)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_ForwardAsAnthropic_TempUnschedulableReturnsFailoverWithoutCommit(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
upstreamBody := []byte(`{"error":{"message":"Our servers are currently overloaded. Please try again later."}}`)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(bytes.NewReader(upstreamBody)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(strings.Join([]string{
|
||||
`data: {"type":"response.output_text.delta","delta":"ok"}`,
|
||||
"",
|
||||
`data: {"type":"response.completed","response":{"id":"resp_second","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}`,
|
||||
"",
|
||||
}, "\n"))),
|
||||
},
|
||||
}}
|
||||
repo := &tempUnschedulableOpenAIAccountRepo{}
|
||||
rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{
|
||||
Enabled: false, AllowInsecureHTTP: true,
|
||||
}}},
|
||||
httpUpstream: upstream,
|
||||
rateLimitService: rateLimitService,
|
||||
}
|
||||
account := &Account{
|
||||
ID: 5099, Name: "temporary-unschedulable", Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey, Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "http://upstream.example",
|
||||
"model_provider": "env-openai",
|
||||
"temp_unschedulable_enabled": true,
|
||||
"temp_unschedulable_rules": []any{map[string]any{
|
||||
"error_code": float64(http.StatusBadRequest),
|
||||
"keywords": []any{"our servers are currently overloaded", "please try again later"},
|
||||
"duration_minutes": float64(1),
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
|
||||
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusBadRequest, failoverErr.StatusCode)
|
||||
require.True(t, failoverErr.ShouldRetryNextAccount())
|
||||
require.Equal(t, account.ID, repo.modelRateLimitAccountID, "temporary unschedulability should exclude this account from reselection")
|
||||
require.Equal(t, "gpt-5.4", repo.modelRateLimitKey)
|
||||
require.False(t, IsResponseCommitted(c))
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Empty(t, rec.Body.String())
|
||||
|
||||
secondRec := httptest.NewRecorder()
|
||||
secondContext, _ := gin.CreateTestContext(secondRec)
|
||||
secondContext.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
|
||||
secondContext.Request.Header.Set("Content-Type", "application/json")
|
||||
secondAccount := *account
|
||||
secondAccount.ID = 5100
|
||||
secondAccount.Name = "healthy-failover-account"
|
||||
result, secondErr := svc.ForwardAsAnthropic(context.Background(), secondContext, &secondAccount, body, "", "")
|
||||
require.NoError(t, secondErr)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "resp_second", result.ResponseID)
|
||||
require.NotEmpty(t, secondRec.Body.String())
|
||||
}
|
||||
|
||||
func TestFailoverOpenAIUpstreamHTTPError_NilContextSkipsTempUnschedulablePolicy(t *testing.T) {
|
||||
repo := &tempUnschedulableOpenAIAccountRepo{}
|
||||
svc := &OpenAIGatewayService{
|
||||
rateLimitService: NewRateLimitService(repo, nil, &config.Config{}, nil, nil),
|
||||
}
|
||||
account := &Account{
|
||||
ID: 5099, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"temp_unschedulable_enabled": true,
|
||||
"temp_unschedulable_rules": []any{map[string]any{
|
||||
"error_code": float64(http.StatusBadRequest),
|
||||
"keywords": []any{"servers are currently overloaded"},
|
||||
"duration_minutes": float64(1),
|
||||
}},
|
||||
},
|
||||
}
|
||||
body := []byte(`{"error":{"message":"Our servers are currently overloaded."}}`)
|
||||
resp := &http.Response{StatusCode: http.StatusBadRequest, Header: http.Header{}}
|
||||
|
||||
got := svc.failoverOpenAIUpstreamHTTPError(
|
||||
context.Background(), nil, account, resp, body,
|
||||
"Our servers are currently overloaded.", "gpt-5.4",
|
||||
)
|
||||
|
||||
require.Nil(t, got)
|
||||
require.Zero(t, repo.modelRateLimitAccountID)
|
||||
require.Empty(t, repo.modelRateLimitKey)
|
||||
}
|
||||
|
||||
type groupAwareStubOpenAIAccountRepo struct {
|
||||
stubOpenAIAccountRepo
|
||||
}
|
||||
@@ -1573,6 +1692,10 @@ func TestOpenAIStreamingResponseFailedBeforeOutputServerOverloadedCodeReturnsFai
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
|
||||
require.Contains(t, string(failoverErr.ResponseBody), "Please retry later")
|
||||
// 容量降载是请求级信号:非池模式账号也要先在同账号重试,且不得据此临时封禁账号。
|
||||
// 否则单个被降载的请求会把整池账号逐个消耗掉,而降载因素在每个账号上都相同。
|
||||
require.True(t, failoverErr.RetryableOnSameAccount)
|
||||
require.True(t, failoverErr.RequestScopedTransient)
|
||||
require.False(t, c.Writer.Written())
|
||||
require.Empty(t, rec.Body.String())
|
||||
}
|
||||
@@ -2864,25 +2987,26 @@ func TestOpenAIBuildUpstreamRequestPreservesCodexIdentityHeaders(t *testing.T) {
|
||||
func TestOpenAIBuildUpstreamRequestOAuthOfficialClientOriginatorCompatibility(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// 上游要求 originator 与最终 User-Agent 首段配套(issue #3901):
|
||||
// originator 一律由最终 UA 推导;推导不出官方身份时整体回退默认 Codex CLI 身份。
|
||||
// 强制统一出口:客户端自报的 originator / User-Agent 都不参与上游身份构造,
|
||||
// 一律改写为网关规范身份,天然满足 originator 与 UA 首段配套的上游校验(issue #3901)。
|
||||
tests := []struct {
|
||||
name string
|
||||
userAgent string
|
||||
originator string
|
||||
wantOriginator string
|
||||
wantUA string
|
||||
name string
|
||||
userAgent string
|
||||
originator string
|
||||
}{
|
||||
{name: "official ua pairs originator", userAgent: "Codex Desktop/1.2.3", wantOriginator: "Codex Desktop", wantUA: "Codex Desktop/1.2.3"},
|
||||
{name: "official desktop ua", userAgent: "Codex Desktop/1.2.3"},
|
||||
{
|
||||
name: "mismatched originator repaired from ua",
|
||||
userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)",
|
||||
originator: "codex_cli_rs",
|
||||
wantOriginator: "codex-tui",
|
||||
wantUA: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)",
|
||||
name: "mismatched originator",
|
||||
userAgent: "codex_vscode/0.140.2 (Mac OS X 14.0; arm64) vscode (codex_vscode; 0.140.2)",
|
||||
originator: "codex_cli_rs",
|
||||
},
|
||||
{name: "official originator without ua falls back to default identity", originator: "codex_vscode", wantOriginator: "codex_cli_rs", wantUA: codexCLIUserAgent},
|
||||
{name: "third-party ua masked to default identity", userAgent: "luna/1.2.0", wantOriginator: "codex_cli_rs", wantUA: codexCLIUserAgent},
|
||||
{
|
||||
name: "tui identity",
|
||||
userAgent: "codex-tui/0.140.2 (Mac OS X 14.0; arm64) iTerm (codex-tui; 0.140.2)",
|
||||
originator: "codex-tui",
|
||||
},
|
||||
{name: "official originator without ua", originator: "codex_vscode"},
|
||||
{name: "third-party ua", userAgent: "luna/1.2.0"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -2906,8 +3030,9 @@ func TestOpenAIBuildUpstreamRequestOAuthOfficialClientOriginatorCompatibility(t
|
||||
isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator"))
|
||||
req, err := svc.buildUpstreamRequest(c.Request.Context(), c, account, []byte(`{"model":"gpt-5"}`), "token", false, "", isCodexCLI)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantOriginator, req.Header.Get("originator"))
|
||||
require.Equal(t, tt.wantUA, req.Header.Get("User-Agent"))
|
||||
require.Equal(t, "codex_cli_rs", req.Header.Get("originator"))
|
||||
require.Equal(t, codexCLIUserAgent, req.Header.Get("User-Agent"))
|
||||
require.Equal(t, codexCLIVersion, req.Header.Get("version"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user