mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #3812 from Wei-Shaw/refactor/split-bloated-files-r2
refactor: 纯移动拆分 8 个后端大文件与 i18n 语言包
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,758 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *SettingHandler) auditSettingsUpdate(c *gin.Context, before *service.SystemSettings, after *service.SystemSettings, beforeAuthSourceDefaults *service.AuthSourceDefaultSettings, afterAuthSourceDefaults *service.AuthSourceDefaultSettings, req UpdateSettingsRequest) {
|
||||
if before == nil || after == nil {
|
||||
return
|
||||
}
|
||||
|
||||
changed := diffSettings(before, after, beforeAuthSourceDefaults, afterAuthSourceDefaults, req)
|
||||
if len(changed) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
subject, _ := middleware.GetAuthSubjectFromContext(c)
|
||||
role, _ := middleware.GetUserRoleFromContext(c)
|
||||
slog.Info("settings updated",
|
||||
"audit", true,
|
||||
"user_id", subject.UserID,
|
||||
"role", role,
|
||||
"changed", changed,
|
||||
)
|
||||
}
|
||||
|
||||
func diffSettings(before *service.SystemSettings, after *service.SystemSettings, beforeAuthSourceDefaults *service.AuthSourceDefaultSettings, afterAuthSourceDefaults *service.AuthSourceDefaultSettings, req UpdateSettingsRequest) []string {
|
||||
changed := make([]string, 0, 20)
|
||||
if before.RegistrationEnabled != after.RegistrationEnabled {
|
||||
changed = append(changed, "registration_enabled")
|
||||
}
|
||||
if before.EmailVerifyEnabled != after.EmailVerifyEnabled {
|
||||
changed = append(changed, "email_verify_enabled")
|
||||
}
|
||||
if !equalStringSlice(before.RegistrationEmailSuffixWhitelist, after.RegistrationEmailSuffixWhitelist) {
|
||||
changed = append(changed, "registration_email_suffix_whitelist")
|
||||
}
|
||||
if before.PromoCodeEnabled != after.PromoCodeEnabled {
|
||||
changed = append(changed, "promo_code_enabled")
|
||||
}
|
||||
if before.InvitationCodeEnabled != after.InvitationCodeEnabled {
|
||||
changed = append(changed, "invitation_code_enabled")
|
||||
}
|
||||
if before.PasswordResetEnabled != after.PasswordResetEnabled {
|
||||
changed = append(changed, "password_reset_enabled")
|
||||
}
|
||||
if before.FrontendURL != after.FrontendURL {
|
||||
changed = append(changed, "frontend_url")
|
||||
}
|
||||
if before.TotpEnabled != after.TotpEnabled {
|
||||
changed = append(changed, "totp_enabled")
|
||||
}
|
||||
if before.LoginAgreementEnabled != after.LoginAgreementEnabled {
|
||||
changed = append(changed, "login_agreement_enabled")
|
||||
}
|
||||
if before.LoginAgreementMode != after.LoginAgreementMode {
|
||||
changed = append(changed, "login_agreement_mode")
|
||||
}
|
||||
if before.LoginAgreementUpdatedAt != after.LoginAgreementUpdatedAt {
|
||||
changed = append(changed, "login_agreement_updated_at")
|
||||
}
|
||||
if !equalLoginAgreementDocuments(before.LoginAgreementDocuments, after.LoginAgreementDocuments) {
|
||||
changed = append(changed, "login_agreement_documents")
|
||||
}
|
||||
if before.SMTPHost != after.SMTPHost {
|
||||
changed = append(changed, "smtp_host")
|
||||
}
|
||||
if before.SMTPPort != after.SMTPPort {
|
||||
changed = append(changed, "smtp_port")
|
||||
}
|
||||
if before.SMTPUsername != after.SMTPUsername {
|
||||
changed = append(changed, "smtp_username")
|
||||
}
|
||||
if req.SMTPPassword != "" {
|
||||
changed = append(changed, "smtp_password")
|
||||
}
|
||||
if before.SMTPFrom != after.SMTPFrom {
|
||||
changed = append(changed, "smtp_from_email")
|
||||
}
|
||||
if before.SMTPFromName != after.SMTPFromName {
|
||||
changed = append(changed, "smtp_from_name")
|
||||
}
|
||||
if before.SMTPUseTLS != after.SMTPUseTLS {
|
||||
changed = append(changed, "smtp_use_tls")
|
||||
}
|
||||
if before.TurnstileEnabled != after.TurnstileEnabled {
|
||||
changed = append(changed, "turnstile_enabled")
|
||||
}
|
||||
if before.TurnstileSiteKey != after.TurnstileSiteKey {
|
||||
changed = append(changed, "turnstile_site_key")
|
||||
}
|
||||
if req.TurnstileSecretKey != "" {
|
||||
changed = append(changed, "turnstile_secret_key")
|
||||
}
|
||||
if before.APIKeyACLTrustForwardedIP != after.APIKeyACLTrustForwardedIP {
|
||||
changed = append(changed, "api_key_acl_trust_forwarded_ip")
|
||||
}
|
||||
if before.LinuxDoConnectEnabled != after.LinuxDoConnectEnabled {
|
||||
changed = append(changed, "linuxdo_connect_enabled")
|
||||
}
|
||||
if before.LinuxDoConnectClientID != after.LinuxDoConnectClientID {
|
||||
changed = append(changed, "linuxdo_connect_client_id")
|
||||
}
|
||||
if req.LinuxDoConnectClientSecret != "" {
|
||||
changed = append(changed, "linuxdo_connect_client_secret")
|
||||
}
|
||||
if before.LinuxDoConnectRedirectURL != after.LinuxDoConnectRedirectURL {
|
||||
changed = append(changed, "linuxdo_connect_redirect_url")
|
||||
}
|
||||
if before.DingTalkConnectEnabled != after.DingTalkConnectEnabled {
|
||||
changed = append(changed, "dingtalk_connect_enabled")
|
||||
}
|
||||
if before.DingTalkConnectClientID != after.DingTalkConnectClientID {
|
||||
changed = append(changed, "dingtalk_connect_client_id")
|
||||
}
|
||||
if req.DingTalkConnectClientSecret != "" {
|
||||
changed = append(changed, "dingtalk_connect_client_secret")
|
||||
}
|
||||
if before.DingTalkConnectRedirectURL != after.DingTalkConnectRedirectURL {
|
||||
changed = append(changed, "dingtalk_connect_redirect_url")
|
||||
}
|
||||
if before.DingTalkConnectCorpRestrictionPolicy != after.DingTalkConnectCorpRestrictionPolicy {
|
||||
changed = append(changed, "dingtalk_connect_corp_restriction_policy")
|
||||
}
|
||||
if before.DingTalkConnectInternalCorpID != after.DingTalkConnectInternalCorpID {
|
||||
changed = append(changed, "dingtalk_connect_internal_corp_id")
|
||||
}
|
||||
if before.DingTalkConnectBypassRegistration != after.DingTalkConnectBypassRegistration {
|
||||
changed = append(changed, "dingtalk_connect_bypass_registration")
|
||||
}
|
||||
if before.DingTalkConnectSyncCorpEmail != after.DingTalkConnectSyncCorpEmail {
|
||||
changed = append(changed, "dingtalk_connect_sync_corp_email")
|
||||
}
|
||||
if before.DingTalkConnectSyncDisplayName != after.DingTalkConnectSyncDisplayName {
|
||||
changed = append(changed, "dingtalk_connect_sync_display_name")
|
||||
}
|
||||
if before.DingTalkConnectSyncDept != after.DingTalkConnectSyncDept {
|
||||
changed = append(changed, "dingtalk_connect_sync_dept")
|
||||
}
|
||||
if before.DingTalkConnectSyncCorpEmailAttrKey != after.DingTalkConnectSyncCorpEmailAttrKey {
|
||||
changed = append(changed, "dingtalk_connect_sync_corp_email_attr_key")
|
||||
}
|
||||
if before.DingTalkConnectSyncDisplayNameAttrKey != after.DingTalkConnectSyncDisplayNameAttrKey {
|
||||
changed = append(changed, "dingtalk_connect_sync_display_name_attr_key")
|
||||
}
|
||||
if before.DingTalkConnectSyncDeptAttrKey != after.DingTalkConnectSyncDeptAttrKey {
|
||||
changed = append(changed, "dingtalk_connect_sync_dept_attr_key")
|
||||
}
|
||||
if before.WeChatConnectEnabled != after.WeChatConnectEnabled {
|
||||
changed = append(changed, "wechat_connect_enabled")
|
||||
}
|
||||
if before.WeChatConnectAppID != after.WeChatConnectAppID {
|
||||
changed = append(changed, "wechat_connect_app_id")
|
||||
}
|
||||
if req.WeChatConnectAppSecret != "" {
|
||||
changed = append(changed, "wechat_connect_app_secret")
|
||||
}
|
||||
if before.WeChatConnectOpenAppID != after.WeChatConnectOpenAppID {
|
||||
changed = append(changed, "wechat_connect_open_app_id")
|
||||
}
|
||||
if req.WeChatConnectOpenAppSecret != "" {
|
||||
changed = append(changed, "wechat_connect_open_app_secret")
|
||||
}
|
||||
if before.WeChatConnectMPAppID != after.WeChatConnectMPAppID {
|
||||
changed = append(changed, "wechat_connect_mp_app_id")
|
||||
}
|
||||
if req.WeChatConnectMPAppSecret != "" {
|
||||
changed = append(changed, "wechat_connect_mp_app_secret")
|
||||
}
|
||||
if before.WeChatConnectMobileAppID != after.WeChatConnectMobileAppID {
|
||||
changed = append(changed, "wechat_connect_mobile_app_id")
|
||||
}
|
||||
if req.WeChatConnectMobileAppSecret != "" {
|
||||
changed = append(changed, "wechat_connect_mobile_app_secret")
|
||||
}
|
||||
if before.WeChatConnectOpenEnabled != after.WeChatConnectOpenEnabled {
|
||||
changed = append(changed, "wechat_connect_open_enabled")
|
||||
}
|
||||
if before.WeChatConnectMPEnabled != after.WeChatConnectMPEnabled {
|
||||
changed = append(changed, "wechat_connect_mp_enabled")
|
||||
}
|
||||
if before.WeChatConnectMobileEnabled != after.WeChatConnectMobileEnabled {
|
||||
changed = append(changed, "wechat_connect_mobile_enabled")
|
||||
}
|
||||
if before.WeChatConnectMode != after.WeChatConnectMode {
|
||||
changed = append(changed, "wechat_connect_mode")
|
||||
}
|
||||
if before.WeChatConnectScopes != after.WeChatConnectScopes {
|
||||
changed = append(changed, "wechat_connect_scopes")
|
||||
}
|
||||
if before.WeChatConnectRedirectURL != after.WeChatConnectRedirectURL {
|
||||
changed = append(changed, "wechat_connect_redirect_url")
|
||||
}
|
||||
if before.WeChatConnectFrontendRedirectURL != after.WeChatConnectFrontendRedirectURL {
|
||||
changed = append(changed, "wechat_connect_frontend_redirect_url")
|
||||
}
|
||||
if before.OIDCConnectEnabled != after.OIDCConnectEnabled {
|
||||
changed = append(changed, "oidc_connect_enabled")
|
||||
}
|
||||
if before.OIDCConnectProviderName != after.OIDCConnectProviderName {
|
||||
changed = append(changed, "oidc_connect_provider_name")
|
||||
}
|
||||
if before.OIDCConnectClientID != after.OIDCConnectClientID {
|
||||
changed = append(changed, "oidc_connect_client_id")
|
||||
}
|
||||
if req.OIDCConnectClientSecret != "" {
|
||||
changed = append(changed, "oidc_connect_client_secret")
|
||||
}
|
||||
if before.OIDCConnectIssuerURL != after.OIDCConnectIssuerURL {
|
||||
changed = append(changed, "oidc_connect_issuer_url")
|
||||
}
|
||||
if before.OIDCConnectDiscoveryURL != after.OIDCConnectDiscoveryURL {
|
||||
changed = append(changed, "oidc_connect_discovery_url")
|
||||
}
|
||||
if before.OIDCConnectAuthorizeURL != after.OIDCConnectAuthorizeURL {
|
||||
changed = append(changed, "oidc_connect_authorize_url")
|
||||
}
|
||||
if before.OIDCConnectTokenURL != after.OIDCConnectTokenURL {
|
||||
changed = append(changed, "oidc_connect_token_url")
|
||||
}
|
||||
if before.OIDCConnectUserInfoURL != after.OIDCConnectUserInfoURL {
|
||||
changed = append(changed, "oidc_connect_userinfo_url")
|
||||
}
|
||||
if before.OIDCConnectJWKSURL != after.OIDCConnectJWKSURL {
|
||||
changed = append(changed, "oidc_connect_jwks_url")
|
||||
}
|
||||
if before.OIDCConnectScopes != after.OIDCConnectScopes {
|
||||
changed = append(changed, "oidc_connect_scopes")
|
||||
}
|
||||
if before.OIDCConnectRedirectURL != after.OIDCConnectRedirectURL {
|
||||
changed = append(changed, "oidc_connect_redirect_url")
|
||||
}
|
||||
if before.OIDCConnectFrontendRedirectURL != after.OIDCConnectFrontendRedirectURL {
|
||||
changed = append(changed, "oidc_connect_frontend_redirect_url")
|
||||
}
|
||||
if before.OIDCConnectTokenAuthMethod != after.OIDCConnectTokenAuthMethod {
|
||||
changed = append(changed, "oidc_connect_token_auth_method")
|
||||
}
|
||||
if before.OIDCConnectUsePKCE != after.OIDCConnectUsePKCE {
|
||||
changed = append(changed, "oidc_connect_use_pkce")
|
||||
}
|
||||
if before.OIDCConnectValidateIDToken != after.OIDCConnectValidateIDToken {
|
||||
changed = append(changed, "oidc_connect_validate_id_token")
|
||||
}
|
||||
if before.OIDCConnectAllowedSigningAlgs != after.OIDCConnectAllowedSigningAlgs {
|
||||
changed = append(changed, "oidc_connect_allowed_signing_algs")
|
||||
}
|
||||
if before.OIDCConnectClockSkewSeconds != after.OIDCConnectClockSkewSeconds {
|
||||
changed = append(changed, "oidc_connect_clock_skew_seconds")
|
||||
}
|
||||
if before.OIDCConnectRequireEmailVerified != after.OIDCConnectRequireEmailVerified {
|
||||
changed = append(changed, "oidc_connect_require_email_verified")
|
||||
}
|
||||
if before.OIDCConnectUserInfoEmailPath != after.OIDCConnectUserInfoEmailPath {
|
||||
changed = append(changed, "oidc_connect_userinfo_email_path")
|
||||
}
|
||||
if before.OIDCConnectUserInfoIDPath != after.OIDCConnectUserInfoIDPath {
|
||||
changed = append(changed, "oidc_connect_userinfo_id_path")
|
||||
}
|
||||
if before.OIDCConnectUserInfoUsernamePath != after.OIDCConnectUserInfoUsernamePath {
|
||||
changed = append(changed, "oidc_connect_userinfo_username_path")
|
||||
}
|
||||
if before.SiteName != after.SiteName {
|
||||
changed = append(changed, "site_name")
|
||||
}
|
||||
if before.SiteLogo != after.SiteLogo {
|
||||
changed = append(changed, "site_logo")
|
||||
}
|
||||
if before.SiteSubtitle != after.SiteSubtitle {
|
||||
changed = append(changed, "site_subtitle")
|
||||
}
|
||||
if before.APIBaseURL != after.APIBaseURL {
|
||||
changed = append(changed, "api_base_url")
|
||||
}
|
||||
if before.ContactInfo != after.ContactInfo {
|
||||
changed = append(changed, "contact_info")
|
||||
}
|
||||
if before.DocURL != after.DocURL {
|
||||
changed = append(changed, "doc_url")
|
||||
}
|
||||
if before.HomeContent != after.HomeContent {
|
||||
changed = append(changed, "home_content")
|
||||
}
|
||||
if before.HideCcsImportButton != after.HideCcsImportButton {
|
||||
changed = append(changed, "hide_ccs_import_button")
|
||||
}
|
||||
if before.DefaultConcurrency != after.DefaultConcurrency {
|
||||
changed = append(changed, "default_concurrency")
|
||||
}
|
||||
if before.DefaultBalance != after.DefaultBalance {
|
||||
changed = append(changed, "default_balance")
|
||||
}
|
||||
if before.AffiliateRebateRate != after.AffiliateRebateRate {
|
||||
changed = append(changed, "affiliate_rebate_rate")
|
||||
}
|
||||
if before.AffiliateRebateFreezeHours != after.AffiliateRebateFreezeHours {
|
||||
changed = append(changed, "affiliate_rebate_freeze_hours")
|
||||
}
|
||||
if before.AffiliateRebateDurationDays != after.AffiliateRebateDurationDays {
|
||||
changed = append(changed, "affiliate_rebate_duration_days")
|
||||
}
|
||||
if before.AffiliateRebatePerInviteeCap != after.AffiliateRebatePerInviteeCap {
|
||||
changed = append(changed, "affiliate_rebate_per_invitee_cap")
|
||||
}
|
||||
if !equalDefaultSubscriptions(before.DefaultSubscriptions, after.DefaultSubscriptions) {
|
||||
changed = append(changed, "default_subscriptions")
|
||||
}
|
||||
if before.EnableModelFallback != after.EnableModelFallback {
|
||||
changed = append(changed, "enable_model_fallback")
|
||||
}
|
||||
if before.FallbackModelAnthropic != after.FallbackModelAnthropic {
|
||||
changed = append(changed, "fallback_model_anthropic")
|
||||
}
|
||||
if before.FallbackModelOpenAI != after.FallbackModelOpenAI {
|
||||
changed = append(changed, "fallback_model_openai")
|
||||
}
|
||||
if before.FallbackModelGemini != after.FallbackModelGemini {
|
||||
changed = append(changed, "fallback_model_gemini")
|
||||
}
|
||||
if before.FallbackModelAntigravity != after.FallbackModelAntigravity {
|
||||
changed = append(changed, "fallback_model_antigravity")
|
||||
}
|
||||
if before.EnableIdentityPatch != after.EnableIdentityPatch {
|
||||
changed = append(changed, "enable_identity_patch")
|
||||
}
|
||||
if before.IdentityPatchPrompt != after.IdentityPatchPrompt {
|
||||
changed = append(changed, "identity_patch_prompt")
|
||||
}
|
||||
if before.OpsMonitoringEnabled != after.OpsMonitoringEnabled {
|
||||
changed = append(changed, "ops_monitoring_enabled")
|
||||
}
|
||||
if before.OpsRealtimeMonitoringEnabled != after.OpsRealtimeMonitoringEnabled {
|
||||
changed = append(changed, "ops_realtime_monitoring_enabled")
|
||||
}
|
||||
if before.OpsQueryModeDefault != after.OpsQueryModeDefault {
|
||||
changed = append(changed, "ops_query_mode_default")
|
||||
}
|
||||
if before.OpsMetricsIntervalSeconds != after.OpsMetricsIntervalSeconds {
|
||||
changed = append(changed, "ops_metrics_interval_seconds")
|
||||
}
|
||||
if before.MinClaudeCodeVersion != after.MinClaudeCodeVersion {
|
||||
changed = append(changed, "min_claude_code_version")
|
||||
}
|
||||
if before.MaxClaudeCodeVersion != after.MaxClaudeCodeVersion {
|
||||
changed = append(changed, "max_claude_code_version")
|
||||
}
|
||||
if before.MinCodexVersion != after.MinCodexVersion {
|
||||
changed = append(changed, "min_codex_version")
|
||||
}
|
||||
if before.MaxCodexVersion != after.MaxCodexVersion {
|
||||
changed = append(changed, "max_codex_version")
|
||||
}
|
||||
if before.CodexCLIOnlyAllowAppServerClients != after.CodexCLIOnlyAllowAppServerClients {
|
||||
changed = append(changed, "codex_cli_only_allow_app_server_clients")
|
||||
}
|
||||
if before.CodexCLIOnlyEngineFingerprintSignals != after.CodexCLIOnlyEngineFingerprintSignals {
|
||||
changed = append(changed, "codex_cli_only_engine_fingerprint_signals")
|
||||
}
|
||||
if before.CodexCLIOnlyBlacklist != after.CodexCLIOnlyBlacklist {
|
||||
changed = append(changed, "codex_cli_only_blacklist")
|
||||
}
|
||||
if before.CodexCLIOnlyWhitelist != after.CodexCLIOnlyWhitelist {
|
||||
changed = append(changed, "codex_cli_only_whitelist")
|
||||
}
|
||||
if before.AllowUngroupedKeyScheduling != after.AllowUngroupedKeyScheduling {
|
||||
changed = append(changed, "allow_ungrouped_key_scheduling")
|
||||
}
|
||||
if before.BackendModeEnabled != after.BackendModeEnabled {
|
||||
changed = append(changed, "backend_mode_enabled")
|
||||
}
|
||||
if before.PurchaseSubscriptionEnabled != after.PurchaseSubscriptionEnabled {
|
||||
changed = append(changed, "purchase_subscription_enabled")
|
||||
}
|
||||
if before.PurchaseSubscriptionURL != after.PurchaseSubscriptionURL {
|
||||
changed = append(changed, "purchase_subscription_url")
|
||||
}
|
||||
if before.TableDefaultPageSize != after.TableDefaultPageSize {
|
||||
changed = append(changed, "table_default_page_size")
|
||||
}
|
||||
if !equalIntSlice(before.TablePageSizeOptions, after.TablePageSizeOptions) {
|
||||
changed = append(changed, "table_page_size_options")
|
||||
}
|
||||
if before.CustomMenuItems != after.CustomMenuItems {
|
||||
changed = append(changed, "custom_menu_items")
|
||||
}
|
||||
if before.CustomEndpoints != after.CustomEndpoints {
|
||||
changed = append(changed, "custom_endpoints")
|
||||
}
|
||||
if before.EnableFingerprintUnification != after.EnableFingerprintUnification {
|
||||
changed = append(changed, "enable_fingerprint_unification")
|
||||
}
|
||||
if before.EnableMetadataPassthrough != after.EnableMetadataPassthrough {
|
||||
changed = append(changed, "enable_metadata_passthrough")
|
||||
}
|
||||
if before.EnableCCHSigning != after.EnableCCHSigning {
|
||||
changed = append(changed, "enable_cch_signing")
|
||||
}
|
||||
if before.EnableClaudeOAuthSystemPromptInjection != after.EnableClaudeOAuthSystemPromptInjection {
|
||||
changed = append(changed, "enable_claude_oauth_system_prompt_injection")
|
||||
}
|
||||
if before.ClaudeOAuthSystemPrompt != after.ClaudeOAuthSystemPrompt {
|
||||
changed = append(changed, "claude_oauth_system_prompt")
|
||||
}
|
||||
if before.ClaudeOAuthSystemPromptBlocks != after.ClaudeOAuthSystemPromptBlocks {
|
||||
changed = append(changed, "claude_oauth_system_prompt_blocks")
|
||||
}
|
||||
if before.EnableAnthropicCacheTTL1hInjection != after.EnableAnthropicCacheTTL1hInjection {
|
||||
changed = append(changed, "enable_anthropic_cache_ttl_1h_injection")
|
||||
}
|
||||
if before.RewriteMessageCacheControl != after.RewriteMessageCacheControl {
|
||||
changed = append(changed, "rewrite_message_cache_control")
|
||||
}
|
||||
if before.EnableClientDatelineNormalization != after.EnableClientDatelineNormalization {
|
||||
changed = append(changed, "enable_client_dateline_normalization")
|
||||
}
|
||||
if before.AntigravityUserAgentVersion != after.AntigravityUserAgentVersion {
|
||||
changed = append(changed, "antigravity_user_agent_version")
|
||||
}
|
||||
if before.OpenAICodexUserAgent != after.OpenAICodexUserAgent {
|
||||
changed = append(changed, "openai_codex_user_agent")
|
||||
}
|
||||
if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource {
|
||||
changed = append(changed, "payment_visible_method_alipay_source")
|
||||
}
|
||||
if before.PaymentVisibleMethodWxpaySource != after.PaymentVisibleMethodWxpaySource {
|
||||
changed = append(changed, "payment_visible_method_wxpay_source")
|
||||
}
|
||||
if before.PaymentVisibleMethodAlipayEnabled != after.PaymentVisibleMethodAlipayEnabled {
|
||||
changed = append(changed, "payment_visible_method_alipay_enabled")
|
||||
}
|
||||
if before.PaymentVisibleMethodWxpayEnabled != after.PaymentVisibleMethodWxpayEnabled {
|
||||
changed = append(changed, "payment_visible_method_wxpay_enabled")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerEnabled != after.OpenAIAdvancedSchedulerEnabled {
|
||||
changed = append(changed, "openai_advanced_scheduler_enabled")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerStickyWeightedEnabled != after.OpenAIAdvancedSchedulerStickyWeightedEnabled {
|
||||
changed = append(changed, "openai_advanced_scheduler_sticky_weighted_enabled")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled != after.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled {
|
||||
changed = append(changed, "openai_advanced_scheduler_subscription_priority_enabled")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerLBTopK != after.OpenAIAdvancedSchedulerLBTopK {
|
||||
changed = append(changed, "openai_advanced_scheduler_lb_top_k")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerWeightPriority != after.OpenAIAdvancedSchedulerWeightPriority {
|
||||
changed = append(changed, "openai_advanced_scheduler_weight_priority")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerWeightLoad != after.OpenAIAdvancedSchedulerWeightLoad {
|
||||
changed = append(changed, "openai_advanced_scheduler_weight_load")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerWeightQueue != after.OpenAIAdvancedSchedulerWeightQueue {
|
||||
changed = append(changed, "openai_advanced_scheduler_weight_queue")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerWeightErrorRate != after.OpenAIAdvancedSchedulerWeightErrorRate {
|
||||
changed = append(changed, "openai_advanced_scheduler_weight_error_rate")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerWeightTTFT != after.OpenAIAdvancedSchedulerWeightTTFT {
|
||||
changed = append(changed, "openai_advanced_scheduler_weight_ttft")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerWeightReset != after.OpenAIAdvancedSchedulerWeightReset {
|
||||
changed = append(changed, "openai_advanced_scheduler_weight_reset")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerWeightQuotaHeadroom != after.OpenAIAdvancedSchedulerWeightQuotaHeadroom {
|
||||
changed = append(changed, "openai_advanced_scheduler_weight_quota_headroom")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerWeightPreviousResponse != after.OpenAIAdvancedSchedulerWeightPreviousResponse {
|
||||
changed = append(changed, "openai_advanced_scheduler_weight_previous_response")
|
||||
}
|
||||
if before.OpenAIAdvancedSchedulerWeightSessionSticky != after.OpenAIAdvancedSchedulerWeightSessionSticky {
|
||||
changed = append(changed, "openai_advanced_scheduler_weight_session_sticky")
|
||||
}
|
||||
// 余额、订阅到期与账号限额通知
|
||||
if before.BalanceLowNotifyEnabled != after.BalanceLowNotifyEnabled {
|
||||
changed = append(changed, "balance_low_notify_enabled")
|
||||
}
|
||||
if before.BalanceLowNotifyThreshold != after.BalanceLowNotifyThreshold {
|
||||
changed = append(changed, "balance_low_notify_threshold")
|
||||
}
|
||||
if before.BalanceLowNotifyRechargeURL != after.BalanceLowNotifyRechargeURL {
|
||||
changed = append(changed, "balance_low_notify_recharge_url")
|
||||
}
|
||||
if before.SubscriptionExpiryNotifyEnabled != after.SubscriptionExpiryNotifyEnabled {
|
||||
changed = append(changed, "subscription_expiry_notify_enabled")
|
||||
}
|
||||
if before.AccountQuotaNotifyEnabled != after.AccountQuotaNotifyEnabled {
|
||||
changed = append(changed, "account_quota_notify_enabled")
|
||||
}
|
||||
if !equalNotifyEmailEntries(before.AccountQuotaNotifyEmails, after.AccountQuotaNotifyEmails) {
|
||||
changed = append(changed, "account_quota_notify_emails")
|
||||
}
|
||||
if before.ChannelMonitorEnabled != after.ChannelMonitorEnabled {
|
||||
changed = append(changed, "channel_monitor_enabled")
|
||||
}
|
||||
if before.ChannelMonitorDefaultIntervalSeconds != after.ChannelMonitorDefaultIntervalSeconds {
|
||||
changed = append(changed, "channel_monitor_default_interval_seconds")
|
||||
}
|
||||
if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled {
|
||||
changed = append(changed, "available_channels_enabled")
|
||||
}
|
||||
if before.AffiliateEnabled != after.AffiliateEnabled {
|
||||
changed = append(changed, "affiliate_enabled")
|
||||
}
|
||||
if before.RiskControlEnabled != after.RiskControlEnabled {
|
||||
changed = append(changed, "risk_control_enabled")
|
||||
}
|
||||
if before.CyberSessionBlockEnabled != after.CyberSessionBlockEnabled {
|
||||
changed = append(changed, "cyber_session_block_enabled")
|
||||
}
|
||||
if before.CyberSessionBlockTTLSeconds != after.CyberSessionBlockTTLSeconds {
|
||||
changed = append(changed, "cyber_session_block_ttl_seconds")
|
||||
}
|
||||
// Default platform quotas(JSON map,整体比较)
|
||||
if !equalPlatformQuotaSettings(before.DefaultPlatformQuotas, after.DefaultPlatformQuotas) {
|
||||
changed = append(changed, service.SettingKeyDefaultPlatformQuotas)
|
||||
}
|
||||
changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults)
|
||||
return changed
|
||||
}
|
||||
|
||||
func appendAuthSourceDefaultChanges(changed []string, before *service.AuthSourceDefaultSettings, after *service.AuthSourceDefaultSettings) []string {
|
||||
if before == nil {
|
||||
before = &service.AuthSourceDefaultSettings{}
|
||||
}
|
||||
if after == nil {
|
||||
after = &service.AuthSourceDefaultSettings{}
|
||||
}
|
||||
|
||||
type providerDefaultGrantField struct {
|
||||
name string
|
||||
before service.ProviderDefaultGrantSettings
|
||||
after service.ProviderDefaultGrantSettings
|
||||
}
|
||||
|
||||
fields := []providerDefaultGrantField{
|
||||
{name: "email", before: before.Email, after: after.Email},
|
||||
{name: "linuxdo", before: before.LinuxDo, after: after.LinuxDo},
|
||||
{name: "oidc", before: before.OIDC, after: after.OIDC},
|
||||
{name: "wechat", before: before.WeChat, after: after.WeChat},
|
||||
{name: "github", before: before.GitHub, after: after.GitHub},
|
||||
{name: "google", before: before.Google, after: after.Google},
|
||||
{name: "dingtalk", before: before.DingTalk, after: after.DingTalk},
|
||||
}
|
||||
for _, field := range fields {
|
||||
if field.before.Balance != field.after.Balance {
|
||||
changed = append(changed, "auth_source_default_"+field.name+"_balance")
|
||||
}
|
||||
if field.before.Concurrency != field.after.Concurrency {
|
||||
changed = append(changed, "auth_source_default_"+field.name+"_concurrency")
|
||||
}
|
||||
if !equalDefaultSubscriptions(field.before.Subscriptions, field.after.Subscriptions) {
|
||||
changed = append(changed, "auth_source_default_"+field.name+"_subscriptions")
|
||||
}
|
||||
if field.before.GrantOnSignup != field.after.GrantOnSignup {
|
||||
changed = append(changed, "auth_source_default_"+field.name+"_grant_on_signup")
|
||||
}
|
||||
if field.before.GrantOnFirstBind != field.after.GrantOnFirstBind {
|
||||
changed = append(changed, "auth_source_default_"+field.name+"_grant_on_first_bind")
|
||||
}
|
||||
// Platform quotas diff:整体替换语义,发单个 JSON key。
|
||||
if !equalPlatformQuotaSettings(field.before.PlatformQuotas, field.after.PlatformQuotas) {
|
||||
changed = append(changed, service.SettingKeyAuthSourcePlatformQuotas(field.name))
|
||||
}
|
||||
}
|
||||
if before.ForceEmailOnThirdPartySignup != after.ForceEmailOnThirdPartySignup {
|
||||
changed = append(changed, "force_email_on_third_party_signup")
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func normalizeDefaultSubscriptions(input []dto.DefaultSubscriptionSetting) []dto.DefaultSubscriptionSetting {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
normalized := make([]dto.DefaultSubscriptionSetting, 0, len(input))
|
||||
for _, item := range input {
|
||||
if item.GroupID <= 0 || item.ValidityDays <= 0 {
|
||||
continue
|
||||
}
|
||||
if item.ValidityDays > service.MaxValidityDays {
|
||||
item.ValidityDays = service.MaxValidityDays
|
||||
}
|
||||
normalized = append(normalized, item)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeOptionalDefaultSubscriptions(input *[]dto.DefaultSubscriptionSetting) *[]dto.DefaultSubscriptionSetting {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
normalized := normalizeDefaultSubscriptions(*input)
|
||||
return &normalized
|
||||
}
|
||||
|
||||
func float64ValueOrDefault(value *float64, fallback float64) float64 {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func intValueOrDefault(value *int, fallback int) int {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func boolValueOrDefault(value *bool, fallback bool) bool {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func defaultSubscriptionsValueOrDefault(input *[]dto.DefaultSubscriptionSetting, fallback []service.DefaultSubscriptionSetting) []service.DefaultSubscriptionSetting {
|
||||
if input == nil {
|
||||
return fallback
|
||||
}
|
||||
result := make([]service.DefaultSubscriptionSetting, 0, len(*input))
|
||||
for _, item := range *input {
|
||||
result = append(result, service.DefaultSubscriptionSetting{
|
||||
GroupID: item.GroupID,
|
||||
ValidityDays: item.ValidityDays,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// platformQuotasValueOrDefault 处理 auth-source platform quota 的 nil 语义:
|
||||
// nil = 请求未包含该字段(保留 fallback),non-nil(含 empty map)= 整体覆盖。
|
||||
// 注意:JSON null 与字段省略等价——两者均反序列化为 nil map,因此都保留旧值;
|
||||
// 若要清空某 source 的所有 quota 配置,须显式发空对象 {}。
|
||||
func platformQuotasValueOrDefault(value, fallback map[string]*service.DefaultPlatformQuotaSetting) map[string]*service.DefaultPlatformQuotaSetting {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func equalStringSlice(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].GroupID != b[i].GroupID || a[i].ValidityDays != b[i].ValidityDays {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalLoginAgreementDocuments(a, b []service.LoginAgreementDocument) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].ID != b[i].ID || a[i].Title != b[i].Title || a[i].ContentMD != b[i].ContentMD {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalIntSlice(a, b []int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalNotifyEmailEntries(a, b []service.NotifyEmailEntry) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].Email != b[i].Email || a[i].Verified != b[i].Verified || a[i].Disabled != b[i].Disabled {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// equalNullableFloat compares two *float64 values treating nil as a distinct case.
|
||||
func equalNullableFloat(a, b *float64) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
// slotOf returns the *float64 for the given window from a DefaultPlatformQuotaSetting.
|
||||
func slotOf(s *service.DefaultPlatformQuotaSetting, win string) *float64 {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
switch win {
|
||||
case "daily":
|
||||
return s.DailyLimitUSD
|
||||
case "weekly":
|
||||
return s.WeeklyLimitUSD
|
||||
case "monthly":
|
||||
return s.MonthlyLimitUSD
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// equalPlatformQuotaSettings reports whether two platform-quota maps are identical across all allowed slots.
|
||||
func equalPlatformQuotaSettings(before, after map[string]*service.DefaultPlatformQuotaSetting) bool {
|
||||
for _, platform := range service.AllowedQuotaPlatforms {
|
||||
b := before[platform]
|
||||
a := after[platform]
|
||||
if !equalNullableFloat(slotOf(b, "daily"), slotOf(a, "daily")) {
|
||||
return false
|
||||
}
|
||||
if !equalNullableFloat(slotOf(b, "weekly"), slotOf(a, "weekly")) {
|
||||
return false
|
||||
}
|
||||
if !equalNullableFloat(slotOf(b, "monthly"), slotOf(a, "monthly")) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stringSetting(value *string, fallback string) string {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestSMTPRequest 测试SMTP连接请求
|
||||
type TestSMTPRequest struct {
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
SMTPUsername string `json:"smtp_username"`
|
||||
SMTPPassword string `json:"smtp_password"`
|
||||
SMTPUseTLS bool `json:"smtp_use_tls"`
|
||||
}
|
||||
|
||||
// TestSMTPConnection 测试SMTP连接
|
||||
// POST /api/v1/admin/settings/test-smtp
|
||||
func (h *SettingHandler) TestSMTPConnection(c *gin.Context) {
|
||||
var req TestSMTPRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
req.SMTPHost = strings.TrimSpace(req.SMTPHost)
|
||||
req.SMTPUsername = strings.TrimSpace(req.SMTPUsername)
|
||||
|
||||
var savedConfig *service.SMTPConfig
|
||||
if cfg, err := h.emailService.GetSMTPConfig(c.Request.Context()); err == nil && cfg != nil {
|
||||
savedConfig = cfg
|
||||
}
|
||||
|
||||
if req.SMTPHost == "" && savedConfig != nil {
|
||||
req.SMTPHost = savedConfig.Host
|
||||
}
|
||||
if req.SMTPPort <= 0 {
|
||||
if savedConfig != nil && savedConfig.Port > 0 {
|
||||
req.SMTPPort = savedConfig.Port
|
||||
} else {
|
||||
req.SMTPPort = 587
|
||||
}
|
||||
}
|
||||
if req.SMTPUsername == "" && savedConfig != nil {
|
||||
req.SMTPUsername = savedConfig.Username
|
||||
}
|
||||
password := strings.TrimSpace(req.SMTPPassword)
|
||||
if password == "" && savedConfig != nil {
|
||||
password = savedConfig.Password
|
||||
}
|
||||
if req.SMTPHost == "" {
|
||||
response.BadRequest(c, "SMTP host is required")
|
||||
return
|
||||
}
|
||||
|
||||
config := &service.SMTPConfig{
|
||||
Host: req.SMTPHost,
|
||||
Port: req.SMTPPort,
|
||||
Username: req.SMTPUsername,
|
||||
Password: password,
|
||||
UseTLS: req.SMTPUseTLS,
|
||||
}
|
||||
|
||||
err := h.emailService.TestSMTPConnectionWithConfig(config)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "SMTP connection test failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"message": "SMTP connection successful"})
|
||||
}
|
||||
|
||||
// SendTestEmailRequest 发送测试邮件请求
|
||||
type SendTestEmailRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
SMTPHost string `json:"smtp_host"`
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
SMTPUsername string `json:"smtp_username"`
|
||||
SMTPPassword string `json:"smtp_password"`
|
||||
SMTPFrom string `json:"smtp_from_email"`
|
||||
SMTPFromName string `json:"smtp_from_name"`
|
||||
SMTPUseTLS bool `json:"smtp_use_tls"`
|
||||
}
|
||||
|
||||
// SendTestEmail 发送测试邮件
|
||||
// POST /api/v1/admin/settings/send-test-email
|
||||
func (h *SettingHandler) SendTestEmail(c *gin.Context) {
|
||||
var req SendTestEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
req.SMTPHost = strings.TrimSpace(req.SMTPHost)
|
||||
req.SMTPUsername = strings.TrimSpace(req.SMTPUsername)
|
||||
req.SMTPFrom = strings.TrimSpace(req.SMTPFrom)
|
||||
req.SMTPFromName = strings.TrimSpace(req.SMTPFromName)
|
||||
|
||||
var savedConfig *service.SMTPConfig
|
||||
if cfg, err := h.emailService.GetSMTPConfig(c.Request.Context()); err == nil && cfg != nil {
|
||||
savedConfig = cfg
|
||||
}
|
||||
|
||||
if req.SMTPHost == "" && savedConfig != nil {
|
||||
req.SMTPHost = savedConfig.Host
|
||||
}
|
||||
if req.SMTPPort <= 0 {
|
||||
if savedConfig != nil && savedConfig.Port > 0 {
|
||||
req.SMTPPort = savedConfig.Port
|
||||
} else {
|
||||
req.SMTPPort = 587
|
||||
}
|
||||
}
|
||||
if req.SMTPUsername == "" && savedConfig != nil {
|
||||
req.SMTPUsername = savedConfig.Username
|
||||
}
|
||||
password := strings.TrimSpace(req.SMTPPassword)
|
||||
if password == "" && savedConfig != nil {
|
||||
password = savedConfig.Password
|
||||
}
|
||||
if req.SMTPFrom == "" && savedConfig != nil {
|
||||
req.SMTPFrom = savedConfig.From
|
||||
}
|
||||
if req.SMTPFromName == "" && savedConfig != nil {
|
||||
req.SMTPFromName = savedConfig.FromName
|
||||
}
|
||||
if req.SMTPHost == "" {
|
||||
response.BadRequest(c, "SMTP host is required")
|
||||
return
|
||||
}
|
||||
|
||||
config := &service.SMTPConfig{
|
||||
Host: req.SMTPHost,
|
||||
Port: req.SMTPPort,
|
||||
Username: req.SMTPUsername,
|
||||
Password: password,
|
||||
From: req.SMTPFrom,
|
||||
FromName: req.SMTPFromName,
|
||||
UseTLS: req.SMTPUseTLS,
|
||||
}
|
||||
|
||||
siteName := h.settingService.GetSiteName(c.Request.Context())
|
||||
subject := "[" + siteName + "] Test Email"
|
||||
body := `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #f5f5f5; margin: 0; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; }
|
||||
.content { padding: 40px 30px; text-align: center; }
|
||||
.success { color: #10b981; font-size: 48px; margin-bottom: 20px; }
|
||||
.footer { background-color: #f8f9fa; padding: 20px; text-align: center; color: #999; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>` + siteName + `</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="success">✓</div>
|
||||
<h2>Email Configuration Successful!</h2>
|
||||
<p>This is a test email to verify your SMTP settings are working correctly.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>This is an automated test message.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
if err := h.emailService.SendEmailWithConfig(config, req.Email, subject, body); err != nil {
|
||||
response.BadRequest(c, "Failed to send test email: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"message": "Test email sent successfully"})
|
||||
}
|
||||
|
||||
// ListEmailTemplates returns all editable notification email templates.
|
||||
// GET /api/v1/admin/settings/email-templates
|
||||
func (h *SettingHandler) ListEmailTemplates(c *gin.Context) {
|
||||
if h.notificationEmailService == nil {
|
||||
response.InternalError(c, "notification email service is not configured")
|
||||
return
|
||||
}
|
||||
events := h.notificationEmailService.ListEventInfos()
|
||||
templates, err := h.notificationEmailService.ListTemplates(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.EmailTemplateListResponse{
|
||||
Events: emailTemplateEventOptionsToDTO(events),
|
||||
Locales: h.notificationEmailService.SupportedLocales(),
|
||||
Templates: emailTemplateSummariesToDTO(templates),
|
||||
Placeholders: emailTemplatePlaceholderUnion(events),
|
||||
})
|
||||
}
|
||||
|
||||
// GetEmailTemplate returns one editable notification email template.
|
||||
// GET /api/v1/admin/settings/email-templates/:event/:locale
|
||||
func (h *SettingHandler) GetEmailTemplate(c *gin.Context) {
|
||||
if h.notificationEmailService == nil {
|
||||
response.InternalError(c, "notification email service is not configured")
|
||||
return
|
||||
}
|
||||
tmpl, err := h.notificationEmailService.GetTemplate(c.Request.Context(), c.Param("event"), c.Param("locale"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, emailTemplateDetailToDTO(tmpl))
|
||||
}
|
||||
|
||||
// UpdateEmailTemplate saves an override for one event/locale template.
|
||||
// PUT /api/v1/admin/settings/email-templates/:event/:locale
|
||||
func (h *SettingHandler) UpdateEmailTemplate(c *gin.Context) {
|
||||
if h.notificationEmailService == nil {
|
||||
response.InternalError(c, "notification email service is not configured")
|
||||
return
|
||||
}
|
||||
var req dto.UpdateEmailTemplateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
tmpl, err := h.notificationEmailService.UpdateTemplate(c.Request.Context(), c.Param("event"), c.Param("locale"), req.Subject, req.HTML)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, emailTemplateDetailToDTO(tmpl))
|
||||
}
|
||||
|
||||
// RestoreOfficialEmailTemplate removes an override and returns the built-in template.
|
||||
// POST /api/v1/admin/settings/email-templates/:event/:locale/restore-official
|
||||
func (h *SettingHandler) RestoreOfficialEmailTemplate(c *gin.Context) {
|
||||
if h.notificationEmailService == nil {
|
||||
response.InternalError(c, "notification email service is not configured")
|
||||
return
|
||||
}
|
||||
tmpl, err := h.notificationEmailService.RestoreOfficialTemplate(c.Request.Context(), c.Param("event"), c.Param("locale"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, emailTemplateDetailToDTO(tmpl))
|
||||
}
|
||||
|
||||
// PreviewEmailTemplate renders a template with safe sample variables without saving it.
|
||||
// POST /api/v1/admin/settings/email-templates/preview
|
||||
func (h *SettingHandler) PreviewEmailTemplate(c *gin.Context) {
|
||||
if h.notificationEmailService == nil {
|
||||
response.InternalError(c, "notification email service is not configured")
|
||||
return
|
||||
}
|
||||
var req dto.PreviewEmailTemplateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
preview, err := h.notificationEmailService.PreviewTemplate(c.Request.Context(), service.NotificationEmailPreviewInput{
|
||||
Event: req.Event,
|
||||
Locale: req.Locale,
|
||||
Subject: req.Subject,
|
||||
HTML: req.HTML,
|
||||
Variables: req.Variables,
|
||||
})
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.EmailTemplatePreviewResponse{Subject: preview.Subject, HTML: preview.HTML})
|
||||
}
|
||||
|
||||
func emailTemplateEventOptionsToDTO(events []service.NotificationEmailEventInfo) []dto.EmailTemplateEventOption {
|
||||
items := make([]dto.EmailTemplateEventOption, 0, len(events))
|
||||
for _, event := range events {
|
||||
items = append(items, dto.EmailTemplateEventOption{
|
||||
Value: event.Event,
|
||||
Label: event.Label,
|
||||
Description: event.Description,
|
||||
Category: event.Category,
|
||||
Optional: event.Optional,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func emailTemplateSummariesToDTO(templates []service.NotificationEmailTemplate) []dto.EmailTemplateSummary {
|
||||
items := make([]dto.EmailTemplateSummary, 0, len(templates))
|
||||
for _, tmpl := range templates {
|
||||
items = append(items, dto.EmailTemplateSummary{
|
||||
Event: tmpl.Event,
|
||||
Locale: tmpl.Locale,
|
||||
Subject: tmpl.Subject,
|
||||
IsCustom: tmpl.IsCustom,
|
||||
UpdatedAt: emailTemplateUpdatedAt(tmpl),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func emailTemplateDetailToDTO(tmpl service.NotificationEmailTemplate) dto.EmailTemplateDetail {
|
||||
return dto.EmailTemplateDetail{
|
||||
Event: tmpl.Event,
|
||||
Locale: tmpl.Locale,
|
||||
Subject: tmpl.Subject,
|
||||
HTML: tmpl.HTML,
|
||||
IsCustom: tmpl.IsCustom,
|
||||
UpdatedAt: emailTemplateUpdatedAt(tmpl),
|
||||
Placeholders: tmpl.Placeholders,
|
||||
}
|
||||
}
|
||||
|
||||
func emailTemplateUpdatedAt(tmpl service.NotificationEmailTemplate) string {
|
||||
if tmpl.UpdatedAt == nil {
|
||||
return ""
|
||||
}
|
||||
return tmpl.UpdatedAt.Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
|
||||
func emailTemplatePlaceholderUnion(events []service.NotificationEmailEventInfo) []string {
|
||||
seen := make(map[string]struct{})
|
||||
placeholders := make([]string, 0)
|
||||
for _, event := range events {
|
||||
for _, placeholder := range event.Placeholders {
|
||||
if _, ok := seen[placeholder]; ok {
|
||||
continue
|
||||
}
|
||||
seen[placeholder] = struct{}{}
|
||||
placeholders = append(placeholders, placeholder)
|
||||
}
|
||||
}
|
||||
return placeholders
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetAdminAPIKey 获取管理员 API Key 状态
|
||||
// GET /api/v1/admin/settings/admin-api-key
|
||||
func (h *SettingHandler) GetAdminAPIKey(c *gin.Context) {
|
||||
maskedKey, exists, err := h.settingService.GetAdminAPIKeyStatus(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"exists": exists,
|
||||
"masked_key": maskedKey,
|
||||
})
|
||||
}
|
||||
|
||||
// RegenerateAdminAPIKey 生成/重新生成管理员 API Key
|
||||
// POST /api/v1/admin/settings/admin-api-key/regenerate
|
||||
func (h *SettingHandler) RegenerateAdminAPIKey(c *gin.Context) {
|
||||
key, err := h.settingService.GenerateAdminAPIKey(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"key": key, // 完整 key 只在生成时返回一次
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteAdminAPIKey 删除管理员 API Key
|
||||
// DELETE /api/v1/admin/settings/admin-api-key
|
||||
func (h *SettingHandler) DeleteAdminAPIKey(c *gin.Context) {
|
||||
if err := h.settingService.DeleteAdminAPIKey(c.Request.Context()); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"message": "Admin API key deleted"})
|
||||
}
|
||||
|
||||
// GetOverloadCooldownSettings 获取529过载冷却配置
|
||||
// GET /api/v1/admin/settings/overload-cooldown
|
||||
func (h *SettingHandler) GetOverloadCooldownSettings(c *gin.Context) {
|
||||
settings, err := h.settingService.GetOverloadCooldownSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.OverloadCooldownSettings{
|
||||
Enabled: settings.Enabled,
|
||||
CooldownMinutes: settings.CooldownMinutes,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateOverloadCooldownSettingsRequest 更新529过载冷却配置请求
|
||||
type UpdateOverloadCooldownSettingsRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
CooldownMinutes int `json:"cooldown_minutes"`
|
||||
}
|
||||
|
||||
// UpdateOverloadCooldownSettings 更新529过载冷却配置
|
||||
// PUT /api/v1/admin/settings/overload-cooldown
|
||||
func (h *SettingHandler) UpdateOverloadCooldownSettings(c *gin.Context) {
|
||||
var req UpdateOverloadCooldownSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
settings := &service.OverloadCooldownSettings{
|
||||
Enabled: req.Enabled,
|
||||
CooldownMinutes: req.CooldownMinutes,
|
||||
}
|
||||
|
||||
if err := h.settingService.SetOverloadCooldownSettings(c.Request.Context(), settings); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updatedSettings, err := h.settingService.GetOverloadCooldownSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.OverloadCooldownSettings{
|
||||
Enabled: updatedSettings.Enabled,
|
||||
CooldownMinutes: updatedSettings.CooldownMinutes,
|
||||
})
|
||||
}
|
||||
|
||||
// GetRateLimit429CooldownSettings 获取429默认回避配置
|
||||
// GET /api/v1/admin/settings/rate-limit-429-cooldown
|
||||
func (h *SettingHandler) GetRateLimit429CooldownSettings(c *gin.Context) {
|
||||
settings, err := h.settingService.GetRateLimit429CooldownSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.RateLimit429CooldownSettings{
|
||||
Enabled: settings.Enabled,
|
||||
CooldownSeconds: settings.CooldownSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRateLimit429CooldownSettingsRequest 更新429默认回避配置请求
|
||||
type UpdateRateLimit429CooldownSettingsRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
CooldownSeconds int `json:"cooldown_seconds"`
|
||||
}
|
||||
|
||||
// UpdateRateLimit429CooldownSettings 更新429默认回避配置
|
||||
// PUT /api/v1/admin/settings/rate-limit-429-cooldown
|
||||
func (h *SettingHandler) UpdateRateLimit429CooldownSettings(c *gin.Context) {
|
||||
var req UpdateRateLimit429CooldownSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
settings := &service.RateLimit429CooldownSettings{
|
||||
Enabled: req.Enabled,
|
||||
CooldownSeconds: req.CooldownSeconds,
|
||||
}
|
||||
|
||||
if err := h.settingService.SetRateLimit429CooldownSettings(c.Request.Context(), settings); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
updatedSettings, err := h.settingService.GetRateLimit429CooldownSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.RateLimit429CooldownSettings{
|
||||
Enabled: updatedSettings.Enabled,
|
||||
CooldownSeconds: updatedSettings.CooldownSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
// GetStreamTimeoutSettings 获取流超时处理配置
|
||||
// GET /api/v1/admin/settings/stream-timeout
|
||||
func (h *SettingHandler) GetStreamTimeoutSettings(c *gin.Context) {
|
||||
settings, err := h.settingService.GetStreamTimeoutSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.StreamTimeoutSettings{
|
||||
Enabled: settings.Enabled,
|
||||
Action: settings.Action,
|
||||
TempUnschedMinutes: settings.TempUnschedMinutes,
|
||||
ThresholdCount: settings.ThresholdCount,
|
||||
ThresholdWindowMinutes: settings.ThresholdWindowMinutes,
|
||||
})
|
||||
}
|
||||
|
||||
// GetRectifierSettings 获取请求整流器配置
|
||||
// GET /api/v1/admin/settings/rectifier
|
||||
func (h *SettingHandler) GetRectifierSettings(c *gin.Context) {
|
||||
settings, err := h.settingService.GetRectifierSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
patterns := settings.APIKeySignaturePatterns
|
||||
if patterns == nil {
|
||||
patterns = []string{}
|
||||
}
|
||||
response.Success(c, dto.RectifierSettings{
|
||||
Enabled: settings.Enabled,
|
||||
ThinkingSignatureEnabled: settings.ThinkingSignatureEnabled,
|
||||
ThinkingBudgetEnabled: settings.ThinkingBudgetEnabled,
|
||||
APIKeySignatureEnabled: settings.APIKeySignatureEnabled,
|
||||
APIKeySignaturePatterns: patterns,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRectifierSettingsRequest 更新整流器配置请求
|
||||
type UpdateRectifierSettingsRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ThinkingSignatureEnabled bool `json:"thinking_signature_enabled"`
|
||||
ThinkingBudgetEnabled bool `json:"thinking_budget_enabled"`
|
||||
APIKeySignatureEnabled bool `json:"apikey_signature_enabled"`
|
||||
APIKeySignaturePatterns []string `json:"apikey_signature_patterns"`
|
||||
}
|
||||
|
||||
// UpdateRectifierSettings 更新请求整流器配置
|
||||
// PUT /api/v1/admin/settings/rectifier
|
||||
func (h *SettingHandler) UpdateRectifierSettings(c *gin.Context) {
|
||||
var req UpdateRectifierSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 校验并清理自定义匹配关键词
|
||||
const maxPatterns = 50
|
||||
const maxPatternLen = 500
|
||||
if len(req.APIKeySignaturePatterns) > maxPatterns {
|
||||
response.BadRequest(c, "Too many signature patterns (max 50)")
|
||||
return
|
||||
}
|
||||
var cleanedPatterns []string
|
||||
for _, p := range req.APIKeySignaturePatterns {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if len(p) > maxPatternLen {
|
||||
response.BadRequest(c, "Signature pattern too long (max 500 characters)")
|
||||
return
|
||||
}
|
||||
cleanedPatterns = append(cleanedPatterns, p)
|
||||
}
|
||||
|
||||
settings := &service.RectifierSettings{
|
||||
Enabled: req.Enabled,
|
||||
ThinkingSignatureEnabled: req.ThinkingSignatureEnabled,
|
||||
ThinkingBudgetEnabled: req.ThinkingBudgetEnabled,
|
||||
APIKeySignatureEnabled: req.APIKeySignatureEnabled,
|
||||
APIKeySignaturePatterns: cleanedPatterns,
|
||||
}
|
||||
|
||||
if err := h.settingService.SetRectifierSettings(c.Request.Context(), settings); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 重新获取设置返回
|
||||
updatedSettings, err := h.settingService.GetRectifierSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
updatedPatterns := updatedSettings.APIKeySignaturePatterns
|
||||
if updatedPatterns == nil {
|
||||
updatedPatterns = []string{}
|
||||
}
|
||||
response.Success(c, dto.RectifierSettings{
|
||||
Enabled: updatedSettings.Enabled,
|
||||
ThinkingSignatureEnabled: updatedSettings.ThinkingSignatureEnabled,
|
||||
ThinkingBudgetEnabled: updatedSettings.ThinkingBudgetEnabled,
|
||||
APIKeySignatureEnabled: updatedSettings.APIKeySignatureEnabled,
|
||||
APIKeySignaturePatterns: updatedPatterns,
|
||||
})
|
||||
}
|
||||
|
||||
// GetBetaPolicySettings 获取 Beta 策略配置
|
||||
// GET /api/v1/admin/settings/beta-policy
|
||||
func (h *SettingHandler) GetBetaPolicySettings(c *gin.Context) {
|
||||
settings, err := h.settingService.GetBetaPolicySettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
rules := make([]dto.BetaPolicyRule, len(settings.Rules))
|
||||
for i, r := range settings.Rules {
|
||||
rules[i] = dto.BetaPolicyRule(r)
|
||||
}
|
||||
response.Success(c, dto.BetaPolicySettings{Rules: rules})
|
||||
}
|
||||
|
||||
// UpdateBetaPolicySettingsRequest 更新 Beta 策略配置请求
|
||||
type UpdateBetaPolicySettingsRequest struct {
|
||||
Rules []dto.BetaPolicyRule `json:"rules"`
|
||||
}
|
||||
|
||||
// UpdateBetaPolicySettings 更新 Beta 策略配置
|
||||
// PUT /api/v1/admin/settings/beta-policy
|
||||
func (h *SettingHandler) UpdateBetaPolicySettings(c *gin.Context) {
|
||||
var req UpdateBetaPolicySettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rules := make([]service.BetaPolicyRule, len(req.Rules))
|
||||
for i, r := range req.Rules {
|
||||
rules[i] = service.BetaPolicyRule(r)
|
||||
}
|
||||
|
||||
settings := &service.BetaPolicySettings{Rules: rules}
|
||||
if err := h.settingService.SetBetaPolicySettings(c.Request.Context(), settings); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Re-fetch to return updated settings
|
||||
updated, err := h.settingService.GetBetaPolicySettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
outRules := make([]dto.BetaPolicyRule, len(updated.Rules))
|
||||
for i, r := range updated.Rules {
|
||||
outRules[i] = dto.BetaPolicyRule(r)
|
||||
}
|
||||
response.Success(c, dto.BetaPolicySettings{Rules: outRules})
|
||||
}
|
||||
|
||||
// UpdateStreamTimeoutSettingsRequest 更新流超时配置请求
|
||||
type UpdateStreamTimeoutSettingsRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Action string `json:"action"`
|
||||
TempUnschedMinutes int `json:"temp_unsched_minutes"`
|
||||
ThresholdCount int `json:"threshold_count"`
|
||||
ThresholdWindowMinutes int `json:"threshold_window_minutes"`
|
||||
}
|
||||
|
||||
// UpdateStreamTimeoutSettings 更新流超时处理配置
|
||||
// PUT /api/v1/admin/settings/stream-timeout
|
||||
func (h *SettingHandler) UpdateStreamTimeoutSettings(c *gin.Context) {
|
||||
var req UpdateStreamTimeoutSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
settings := &service.StreamTimeoutSettings{
|
||||
Enabled: req.Enabled,
|
||||
Action: req.Action,
|
||||
TempUnschedMinutes: req.TempUnschedMinutes,
|
||||
ThresholdCount: req.ThresholdCount,
|
||||
ThresholdWindowMinutes: req.ThresholdWindowMinutes,
|
||||
}
|
||||
|
||||
if err := h.settingService.SetStreamTimeoutSettings(c.Request.Context(), settings); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 重新获取设置返回
|
||||
updatedSettings, err := h.settingService.GetStreamTimeoutSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, dto.StreamTimeoutSettings{
|
||||
Enabled: updatedSettings.Enabled,
|
||||
Action: updatedSettings.Action,
|
||||
TempUnschedMinutes: updatedSettings.TempUnschedMinutes,
|
||||
ThresholdCount: updatedSettings.ThresholdCount,
|
||||
ThresholdWindowMinutes: updatedSettings.ThresholdWindowMinutes,
|
||||
})
|
||||
}
|
||||
|
||||
// GetWebSearchEmulationConfig 获取 Web Search 模拟配置
|
||||
// GET /api/v1/admin/settings/web-search-emulation
|
||||
func (h *SettingHandler) GetWebSearchEmulationConfig(c *gin.Context) {
|
||||
cfg, err := h.settingService.GetWebSearchEmulationConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, service.PopulateWebSearchUsage(c.Request.Context(), cfg))
|
||||
}
|
||||
|
||||
// UpdateWebSearchEmulationConfig 更新 Web Search 模拟配置
|
||||
// PUT /api/v1/admin/settings/web-search-emulation
|
||||
func (h *SettingHandler) UpdateWebSearchEmulationConfig(c *gin.Context) {
|
||||
var cfg service.WebSearchEmulationConfig
|
||||
if err := c.ShouldBindJSON(&cfg); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.settingService.SaveWebSearchEmulationConfig(c.Request.Context(), &cfg); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Re-read (with sanitized api keys) to return current state
|
||||
updated, err := h.settingService.GetWebSearchEmulationConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, service.PopulateWebSearchUsage(c.Request.Context(), updated))
|
||||
}
|
||||
|
||||
// ResetWebSearchUsage 重置指定 provider 的配额用量
|
||||
// POST /api/v1/admin/settings/web-search-emulation/reset-usage
|
||||
func (h *SettingHandler) ResetWebSearchUsage(c *gin.Context) {
|
||||
var req struct {
|
||||
ProviderType string `json:"provider_type"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.ProviderType == "" {
|
||||
response.BadRequest(c, "provider_type is required")
|
||||
return
|
||||
}
|
||||
if err := service.ResetWebSearchUsage(c.Request.Context(), req.ProviderType); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, nil)
|
||||
}
|
||||
|
||||
// TestWebSearchEmulation 测试 Web Search 搜索
|
||||
// POST /api/v1/admin/settings/web-search-emulation/test
|
||||
func (h *SettingHandler) TestWebSearchEmulation(c *gin.Context) {
|
||||
var req struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Query) == "" {
|
||||
req.Query = "搜索今年世界大事件"
|
||||
}
|
||||
|
||||
result, err := service.TestWebSearch(c.Request.Context(), req.Query)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,628 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
// getPerformanceStats 获取 RPM 和 TPM(近5分钟平均值,可选按用户过滤)
|
||||
func (r *usageLogRepository) getPerformanceStats(ctx context.Context, userID int64) (rpm, tpm int64, err error) {
|
||||
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0) as token_count
|
||||
FROM usage_logs
|
||||
WHERE created_at >= $1`
|
||||
args := []any{fiveMinutesAgo}
|
||||
if userID > 0 {
|
||||
query += " AND user_id = $2"
|
||||
args = append(args, userID)
|
||||
}
|
||||
|
||||
var requestCount int64
|
||||
var tokenCount int64
|
||||
if err := scanSingleRow(ctx, r.sql, query, args, &requestCount, &tokenCount); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return requestCount / 5, tokenCount / 5, nil
|
||||
}
|
||||
|
||||
// UserStats 用户使用统计
|
||||
type UserStats struct {
|
||||
TotalRequests int64 `json:"total_requests"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) GetUserStats(ctx context.Context, userID int64, startTime, endTime time.Time) (*UserStats, error) {
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(*) as total_requests,
|
||||
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(actual_cost), 0) as total_cost,
|
||||
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as output_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens
|
||||
FROM usage_logs
|
||||
WHERE user_id = $1 AND created_at >= $2 AND created_at < $3
|
||||
`
|
||||
|
||||
stats := &UserStats{}
|
||||
if err := scanSingleRow(
|
||||
ctx,
|
||||
r.sql,
|
||||
query,
|
||||
[]any{userID, startTime, endTime},
|
||||
&stats.TotalRequests,
|
||||
&stats.TotalTokens,
|
||||
&stats.TotalCost,
|
||||
&stats.InputTokens,
|
||||
&stats.OutputTokens,
|
||||
&stats.CacheReadTokens,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// DashboardStats 仪表盘统计
|
||||
type DashboardStats = usagestats.DashboardStats
|
||||
|
||||
func (r *usageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
|
||||
stats := &DashboardStats{}
|
||||
now := timezone.Now()
|
||||
todayStart := timezone.Today()
|
||||
|
||||
if err := r.fillDashboardEntityStats(ctx, stats, todayStart, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.fillDashboardUsageStatsAggregated(ctx, stats, todayStart, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rpm, tpm, err := r.getPerformanceStats(ctx, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.Rpm = rpm
|
||||
stats.Tpm = tpm
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) GetDashboardStatsWithRange(ctx context.Context, start, end time.Time) (*DashboardStats, error) {
|
||||
startUTC := start.UTC()
|
||||
endUTC := end.UTC()
|
||||
if !endUTC.After(startUTC) {
|
||||
return nil, errors.New("统计时间范围无效")
|
||||
}
|
||||
|
||||
stats := &DashboardStats{}
|
||||
now := timezone.Now()
|
||||
todayStart := timezone.Today()
|
||||
|
||||
if err := r.fillDashboardEntityStats(ctx, stats, todayStart, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.fillDashboardUsageStatsFromUsageLogs(ctx, stats, startUTC, endUTC, todayStart, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rpm, tpm, err := r.getPerformanceStats(ctx, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.Rpm = rpm
|
||||
stats.Tpm = tpm
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) fillDashboardEntityStats(ctx context.Context, stats *DashboardStats, todayUTC, now time.Time) error {
|
||||
userStatsQuery := `
|
||||
SELECT
|
||||
COUNT(*) as total_users,
|
||||
COUNT(CASE WHEN created_at >= $1 THEN 1 END) as today_new_users
|
||||
FROM users
|
||||
WHERE deleted_at IS NULL
|
||||
`
|
||||
if err := scanSingleRow(
|
||||
ctx,
|
||||
r.sql,
|
||||
userStatsQuery,
|
||||
[]any{todayUTC},
|
||||
&stats.TotalUsers,
|
||||
&stats.TodayNewUsers,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiKeyStatsQuery := `
|
||||
SELECT
|
||||
COUNT(*) as total_api_keys,
|
||||
COUNT(CASE WHEN status = $1 THEN 1 END) as active_api_keys
|
||||
FROM api_keys
|
||||
WHERE deleted_at IS NULL
|
||||
`
|
||||
if err := scanSingleRow(
|
||||
ctx,
|
||||
r.sql,
|
||||
apiKeyStatsQuery,
|
||||
[]any{service.StatusActive},
|
||||
&stats.TotalAPIKeys,
|
||||
&stats.ActiveAPIKeys,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accountStatsQuery := `
|
||||
SELECT
|
||||
COUNT(*) as total_accounts,
|
||||
COUNT(CASE WHEN status = $1 AND schedulable = true THEN 1 END) as normal_accounts,
|
||||
COUNT(CASE WHEN status = $2 THEN 1 END) as error_accounts,
|
||||
COUNT(CASE WHEN rate_limited_at IS NOT NULL AND rate_limit_reset_at > $3 THEN 1 END) as ratelimit_accounts,
|
||||
COUNT(CASE WHEN overload_until IS NOT NULL AND overload_until > $4 THEN 1 END) as overload_accounts
|
||||
FROM accounts
|
||||
WHERE deleted_at IS NULL
|
||||
`
|
||||
if err := scanSingleRow(
|
||||
ctx,
|
||||
r.sql,
|
||||
accountStatsQuery,
|
||||
[]any{service.StatusActive, service.StatusError, now, now},
|
||||
&stats.TotalAccounts,
|
||||
&stats.NormalAccounts,
|
||||
&stats.ErrorAccounts,
|
||||
&stats.RateLimitAccounts,
|
||||
&stats.OverloadAccounts,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) fillDashboardUsageStatsAggregated(ctx context.Context, stats *DashboardStats, todayUTC, now time.Time) error {
|
||||
totalStatsQuery := `
|
||||
SELECT
|
||||
COALESCE(SUM(total_requests), 0) as total_requests,
|
||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens,
|
||||
COALESCE(SUM(total_cost), 0) as total_cost,
|
||||
COALESCE(SUM(actual_cost), 0) as total_actual_cost,
|
||||
COALESCE(SUM(account_cost), 0) as total_account_cost,
|
||||
COALESCE(SUM(total_duration_ms), 0) as total_duration_ms
|
||||
FROM usage_dashboard_daily
|
||||
`
|
||||
var totalDurationMs int64
|
||||
if err := scanSingleRow(
|
||||
ctx,
|
||||
r.sql,
|
||||
totalStatsQuery,
|
||||
nil,
|
||||
&stats.TotalRequests,
|
||||
&stats.TotalInputTokens,
|
||||
&stats.TotalOutputTokens,
|
||||
&stats.TotalCacheCreationTokens,
|
||||
&stats.TotalCacheReadTokens,
|
||||
&stats.TotalCost,
|
||||
&stats.TotalActualCost,
|
||||
&stats.TotalAccountCost,
|
||||
&totalDurationMs,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheCreationTokens + stats.TotalCacheReadTokens
|
||||
if stats.TotalRequests > 0 {
|
||||
stats.AverageDurationMs = float64(totalDurationMs) / float64(stats.TotalRequests)
|
||||
}
|
||||
|
||||
todayStatsQuery := `
|
||||
SELECT
|
||||
total_requests as today_requests,
|
||||
input_tokens as today_input_tokens,
|
||||
output_tokens as today_output_tokens,
|
||||
cache_creation_tokens as today_cache_creation_tokens,
|
||||
cache_read_tokens as today_cache_read_tokens,
|
||||
total_cost as today_cost,
|
||||
actual_cost as today_actual_cost,
|
||||
account_cost as today_account_cost,
|
||||
active_users as active_users
|
||||
FROM usage_dashboard_daily
|
||||
WHERE bucket_date = $1::date
|
||||
`
|
||||
if err := scanSingleRow(
|
||||
ctx,
|
||||
r.sql,
|
||||
todayStatsQuery,
|
||||
[]any{todayUTC},
|
||||
&stats.TodayRequests,
|
||||
&stats.TodayInputTokens,
|
||||
&stats.TodayOutputTokens,
|
||||
&stats.TodayCacheCreationTokens,
|
||||
&stats.TodayCacheReadTokens,
|
||||
&stats.TodayCost,
|
||||
&stats.TodayActualCost,
|
||||
&stats.TodayAccountCost,
|
||||
&stats.ActiveUsers,
|
||||
); err != nil {
|
||||
if err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
}
|
||||
stats.TodayTokens = stats.TodayInputTokens + stats.TodayOutputTokens + stats.TodayCacheCreationTokens + stats.TodayCacheReadTokens
|
||||
|
||||
hourlyActiveQuery := `
|
||||
SELECT active_users
|
||||
FROM usage_dashboard_hourly
|
||||
WHERE bucket_start = $1
|
||||
`
|
||||
hourStart := now.In(timezone.Location()).Truncate(time.Hour)
|
||||
if err := scanSingleRow(ctx, r.sql, hourlyActiveQuery, []any{hourStart}, &stats.HourlyActiveUsers); err != nil {
|
||||
if err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) fillDashboardUsageStatsFromUsageLogs(ctx context.Context, stats *DashboardStats, startUTC, endUTC, todayUTC, now time.Time) error {
|
||||
todayEnd := todayUTC.Add(24 * time.Hour)
|
||||
combinedStatsQuery := `
|
||||
WITH scoped AS (
|
||||
SELECT
|
||||
created_at,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_creation_tokens,
|
||||
cache_read_tokens,
|
||||
total_cost,
|
||||
actual_cost,
|
||||
COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1) AS account_cost,
|
||||
COALESCE(duration_ms, 0) AS duration_ms
|
||||
FROM usage_logs
|
||||
WHERE created_at >= LEAST($1::timestamptz, $3::timestamptz)
|
||||
AND created_at < GREATEST($2::timestamptz, $4::timestamptz)
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz) AS total_requests,
|
||||
COALESCE(SUM(input_tokens) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_input_tokens,
|
||||
COALESCE(SUM(output_tokens) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_cache_read_tokens,
|
||||
COALESCE(SUM(total_cost) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_cost,
|
||||
COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_actual_cost,
|
||||
COALESCE(SUM(account_cost) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_account_cost,
|
||||
COALESCE(SUM(duration_ms) FILTER (WHERE created_at >= $1::timestamptz AND created_at < $2::timestamptz), 0) AS total_duration_ms,
|
||||
COUNT(*) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz) AS today_requests,
|
||||
COALESCE(SUM(input_tokens) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_input_tokens,
|
||||
COALESCE(SUM(output_tokens) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_cache_read_tokens,
|
||||
COALESCE(SUM(total_cost) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_cost,
|
||||
COALESCE(SUM(actual_cost) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_actual_cost,
|
||||
COALESCE(SUM(account_cost) FILTER (WHERE created_at >= $3::timestamptz AND created_at < $4::timestamptz), 0) AS today_account_cost
|
||||
FROM scoped
|
||||
`
|
||||
var totalDurationMs int64
|
||||
if err := scanSingleRow(
|
||||
ctx,
|
||||
r.sql,
|
||||
combinedStatsQuery,
|
||||
[]any{startUTC, endUTC, todayUTC, todayEnd},
|
||||
&stats.TotalRequests,
|
||||
&stats.TotalInputTokens,
|
||||
&stats.TotalOutputTokens,
|
||||
&stats.TotalCacheCreationTokens,
|
||||
&stats.TotalCacheReadTokens,
|
||||
&stats.TotalCost,
|
||||
&stats.TotalActualCost,
|
||||
&stats.TotalAccountCost,
|
||||
&totalDurationMs,
|
||||
&stats.TodayRequests,
|
||||
&stats.TodayInputTokens,
|
||||
&stats.TodayOutputTokens,
|
||||
&stats.TodayCacheCreationTokens,
|
||||
&stats.TodayCacheReadTokens,
|
||||
&stats.TodayCost,
|
||||
&stats.TodayActualCost,
|
||||
&stats.TodayAccountCost,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheCreationTokens + stats.TotalCacheReadTokens
|
||||
if stats.TotalRequests > 0 {
|
||||
stats.AverageDurationMs = float64(totalDurationMs) / float64(stats.TotalRequests)
|
||||
}
|
||||
|
||||
stats.TodayTokens = stats.TodayInputTokens + stats.TodayOutputTokens + stats.TodayCacheCreationTokens + stats.TodayCacheReadTokens
|
||||
|
||||
hourStart := now.UTC().Truncate(time.Hour)
|
||||
hourEnd := hourStart.Add(time.Hour)
|
||||
activeUsersQuery := `
|
||||
WITH scoped AS (
|
||||
SELECT user_id, created_at
|
||||
FROM usage_logs
|
||||
WHERE created_at >= LEAST($1::timestamptz, $3::timestamptz)
|
||||
AND created_at < GREATEST($2::timestamptz, $4::timestamptz)
|
||||
)
|
||||
SELECT
|
||||
COUNT(DISTINCT CASE WHEN created_at >= $1::timestamptz AND created_at < $2::timestamptz THEN user_id END) AS active_users,
|
||||
COUNT(DISTINCT CASE WHEN created_at >= $3::timestamptz AND created_at < $4::timestamptz THEN user_id END) AS hourly_active_users
|
||||
FROM scoped
|
||||
`
|
||||
if err := scanSingleRow(ctx, r.sql, activeUsersQuery, []any{todayUTC, todayEnd, hourStart, hourEnd}, &stats.ActiveUsers, &stats.HourlyActiveUsers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserDashboardStats 用户仪表盘统计
|
||||
type UserDashboardStats = usagestats.UserDashboardStats
|
||||
|
||||
// PlatformDashboardStats 单平台用量明细
|
||||
type PlatformDashboardStats = usagestats.PlatformDashboardStats
|
||||
|
||||
// GetUserDashboardStats 获取用户专属的仪表盘统计
|
||||
func (r *usageLogRepository) GetUserDashboardStats(ctx context.Context, userID int64) (*UserDashboardStats, error) {
|
||||
stats := &UserDashboardStats{}
|
||||
today := timezone.Today()
|
||||
|
||||
// API Key 统计
|
||||
if err := scanSingleRow(
|
||||
ctx,
|
||||
r.sql,
|
||||
"SELECT COUNT(*) FROM api_keys WHERE user_id = $1 AND deleted_at IS NULL",
|
||||
[]any{userID},
|
||||
&stats.TotalAPIKeys,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := scanSingleRow(
|
||||
ctx,
|
||||
r.sql,
|
||||
"SELECT COUNT(*) FROM api_keys WHERE user_id = $1 AND status = $2 AND deleted_at IS NULL",
|
||||
[]any{userID, service.StatusActive},
|
||||
&stats.ActiveAPIKeys,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 累计 Token 统计
|
||||
totalStatsQuery := `
|
||||
SELECT
|
||||
COUNT(*) as total_requests,
|
||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens,
|
||||
COALESCE(SUM(total_cost), 0) as total_cost,
|
||||
COALESCE(SUM(actual_cost), 0) as total_actual_cost,
|
||||
COALESCE(AVG(duration_ms), 0) as avg_duration_ms
|
||||
FROM usage_logs
|
||||
WHERE user_id = $1
|
||||
`
|
||||
if err := scanSingleRow(
|
||||
ctx,
|
||||
r.sql,
|
||||
totalStatsQuery,
|
||||
[]any{userID},
|
||||
&stats.TotalRequests,
|
||||
&stats.TotalInputTokens,
|
||||
&stats.TotalOutputTokens,
|
||||
&stats.TotalCacheCreationTokens,
|
||||
&stats.TotalCacheReadTokens,
|
||||
&stats.TotalCost,
|
||||
&stats.TotalActualCost,
|
||||
&stats.AverageDurationMs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheCreationTokens + stats.TotalCacheReadTokens
|
||||
|
||||
// 今日 Token 统计
|
||||
todayStatsQuery := `
|
||||
SELECT
|
||||
COUNT(*) as today_requests,
|
||||
COALESCE(SUM(input_tokens), 0) as today_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as today_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as today_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as today_cache_read_tokens,
|
||||
COALESCE(SUM(total_cost), 0) as today_cost,
|
||||
COALESCE(SUM(actual_cost), 0) as today_actual_cost
|
||||
FROM usage_logs
|
||||
WHERE user_id = $1 AND created_at >= $2
|
||||
`
|
||||
if err := scanSingleRow(
|
||||
ctx,
|
||||
r.sql,
|
||||
todayStatsQuery,
|
||||
[]any{userID, today},
|
||||
&stats.TodayRequests,
|
||||
&stats.TodayInputTokens,
|
||||
&stats.TodayOutputTokens,
|
||||
&stats.TodayCacheCreationTokens,
|
||||
&stats.TodayCacheReadTokens,
|
||||
&stats.TodayCost,
|
||||
&stats.TodayActualCost,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.TodayTokens = stats.TodayInputTokens + stats.TodayOutputTokens + stats.TodayCacheCreationTokens + stats.TodayCacheReadTokens
|
||||
|
||||
// 性能指标:RPM 和 TPM(最近1分钟,仅统计该用户的请求)
|
||||
rpm, tpm, err := r.getPerformanceStats(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.Rpm = rpm
|
||||
stats.Tpm = tpm
|
||||
|
||||
// 按"有效平台"维度拆分(group.platform 优先,否则 account.platform)。
|
||||
// 与 ops 路径口径一致;HAVING 过滤掉无法确定平台的行(避免出现空字符串平台)。
|
||||
// 与上面 totalStatsQuery/todayStatsQuery 的总值可能略微差异,原因有二:
|
||||
// 1) 无平台归属的极少数行(group/account 都没 platform)会被 HAVING 排除;
|
||||
// 2) usageLogSuccessFilterUL 会把 actual_cost = 0 的失败 placeholder 行排除,
|
||||
// 而 totalStatsQuery/todayStatsQuery 没有这层过滤、会把这些行的 request 计数算进去。
|
||||
platformQuery := `
|
||||
SELECT
|
||||
` + usageLogEffectivePlatformExpr + ` as platform,
|
||||
COUNT(*) as total_requests,
|
||||
COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(ul.actual_cost), 0) as total_actual_cost,
|
||||
COUNT(*) FILTER (WHERE ul.created_at >= $2) as today_requests,
|
||||
COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens) FILTER (WHERE ul.created_at >= $2), 0) as today_tokens,
|
||||
COALESCE(SUM(ul.actual_cost) FILTER (WHERE ul.created_at >= $2), 0) as today_actual_cost
|
||||
FROM usage_logs ul
|
||||
LEFT JOIN groups g ON g.id = ul.group_id
|
||||
LEFT JOIN accounts a ON a.id = ul.account_id
|
||||
WHERE ul.user_id = $1
|
||||
AND ` + usageLogSuccessFilterUL + `
|
||||
GROUP BY ` + usageLogEffectivePlatformExpr + `
|
||||
HAVING ` + usageLogEffectivePlatformExpr + ` IS NOT NULL AND ` + usageLogEffectivePlatformExpr + ` <> ''
|
||||
ORDER BY total_actual_cost DESC
|
||||
`
|
||||
rows, err := r.sql.QueryContext(ctx, platformQuery, userID, today)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var p PlatformDashboardStats
|
||||
if err := rows.Scan(
|
||||
&p.Platform,
|
||||
&p.TotalRequests,
|
||||
&p.TotalTokens,
|
||||
&p.TotalActualCost,
|
||||
&p.TodayRequests,
|
||||
&p.TodayTokens,
|
||||
&p.TodayActualCost,
|
||||
); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
stats.ByPlatform = append(stats.ByPlatform, p)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// getPerformanceStatsByAPIKey 获取指定 API Key 的 RPM 和 TPM(近5分钟平均值)
|
||||
func (r *usageLogRepository) getPerformanceStatsByAPIKey(ctx context.Context, apiKeyID int64) (rpm, tpm int64, err error) {
|
||||
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as token_count
|
||||
FROM usage_logs
|
||||
WHERE created_at >= $1 AND api_key_id = $2`
|
||||
args := []any{fiveMinutesAgo, apiKeyID}
|
||||
|
||||
var requestCount int64
|
||||
var tokenCount int64
|
||||
if err := scanSingleRow(ctx, r.sql, query, args, &requestCount, &tokenCount); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return requestCount / 5, tokenCount / 5, nil
|
||||
}
|
||||
|
||||
// GetAPIKeyDashboardStats 获取指定 API Key 的仪表盘统计(按 api_key_id 过滤)
|
||||
func (r *usageLogRepository) GetAPIKeyDashboardStats(ctx context.Context, apiKeyID int64) (*UserDashboardStats, error) {
|
||||
stats := &UserDashboardStats{}
|
||||
today := timezone.Today()
|
||||
|
||||
// API Key 维度不需要统计 key 数量,设为 1
|
||||
stats.TotalAPIKeys = 1
|
||||
stats.ActiveAPIKeys = 1
|
||||
|
||||
// 累计 Token 统计
|
||||
totalStatsQuery := `
|
||||
SELECT
|
||||
COUNT(*) as total_requests,
|
||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens,
|
||||
COALESCE(SUM(total_cost), 0) as total_cost,
|
||||
COALESCE(SUM(actual_cost), 0) as total_actual_cost,
|
||||
COALESCE(AVG(duration_ms), 0) as avg_duration_ms
|
||||
FROM usage_logs
|
||||
WHERE api_key_id = $1
|
||||
`
|
||||
if err := scanSingleRow(
|
||||
ctx,
|
||||
r.sql,
|
||||
totalStatsQuery,
|
||||
[]any{apiKeyID},
|
||||
&stats.TotalRequests,
|
||||
&stats.TotalInputTokens,
|
||||
&stats.TotalOutputTokens,
|
||||
&stats.TotalCacheCreationTokens,
|
||||
&stats.TotalCacheReadTokens,
|
||||
&stats.TotalCost,
|
||||
&stats.TotalActualCost,
|
||||
&stats.AverageDurationMs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheCreationTokens + stats.TotalCacheReadTokens
|
||||
|
||||
// 今日 Token 统计
|
||||
todayStatsQuery := `
|
||||
SELECT
|
||||
COUNT(*) as today_requests,
|
||||
COALESCE(SUM(input_tokens), 0) as today_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as today_output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as today_cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as today_cache_read_tokens,
|
||||
COALESCE(SUM(total_cost), 0) as today_cost,
|
||||
COALESCE(SUM(actual_cost), 0) as today_actual_cost
|
||||
FROM usage_logs
|
||||
WHERE api_key_id = $1 AND created_at >= $2
|
||||
`
|
||||
if err := scanSingleRow(
|
||||
ctx,
|
||||
r.sql,
|
||||
todayStatsQuery,
|
||||
[]any{apiKeyID, today},
|
||||
&stats.TodayRequests,
|
||||
&stats.TodayInputTokens,
|
||||
&stats.TodayOutputTokens,
|
||||
&stats.TodayCacheCreationTokens,
|
||||
&stats.TodayCacheReadTokens,
|
||||
&stats.TodayCost,
|
||||
&stats.TodayActualCost,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.TodayTokens = stats.TodayInputTokens + stats.TodayOutputTokens + stats.TodayCacheCreationTokens + stats.TodayCacheReadTokens
|
||||
|
||||
// 性能指标:RPM 和 TPM(最近5分钟,按 API Key 过滤)
|
||||
rpm, tpm, err := r.getPerformanceStatsByAPIKey(ctx, apiKeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.Rpm = rpm
|
||||
stats.Tpm = tpm
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,712 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dbaccount "github.com/Wei-Shaw/sub2api/ent/account"
|
||||
dbapikey "github.com/Wei-Shaw/sub2api/ent/apikey"
|
||||
dbgroup "github.com/Wei-Shaw/sub2api/ent/group"
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
||||
dbusersub "github.com/Wei-Shaw/sub2api/ent/usersubscription"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at"
|
||||
|
||||
func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) {
|
||||
query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1"
|
||||
rows, err := r.sql.QueryContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
// 保持主错误优先;仅在无错误时回传 Close 失败。
|
||||
// 同时清空返回值,避免误用不完整结果。
|
||||
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
log = nil
|
||||
}
|
||||
}()
|
||||
if !rows.Next() {
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, service.ErrUsageLogNotFound
|
||||
}
|
||||
log, err = scanUsageLog(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return log, nil
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) ListByUser(ctx context.Context, userID int64, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
||||
return r.listUsageLogsWithPagination(ctx, "WHERE user_id = $1", []any{userID}, params)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) ListByAPIKey(ctx context.Context, apiKeyID int64, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
||||
return r.listUsageLogsWithPagination(ctx, "WHERE api_key_id = $1", []any{apiKeyID}, params)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) ListByAccount(ctx context.Context, accountID int64, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
||||
return r.listUsageLogsWithPagination(ctx, "WHERE account_id = $1", []any{accountID}, params)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) ListByUserAndTimeRange(ctx context.Context, userID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
||||
query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE user_id = $1 AND created_at >= $2 AND created_at < $3 ORDER BY id DESC LIMIT 10000"
|
||||
logs, err := r.queryUsageLogs(ctx, query, userID, startTime, endTime)
|
||||
return logs, nil, err
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) ListByAPIKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
||||
query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE api_key_id = $1 AND created_at >= $2 AND created_at < $3 ORDER BY id DESC LIMIT 10000"
|
||||
logs, err := r.queryUsageLogs(ctx, query, apiKeyID, startTime, endTime)
|
||||
return logs, nil, err
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) ListByAccountAndTimeRange(ctx context.Context, accountID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
||||
query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE account_id = $1 AND created_at >= $2 AND created_at < $3 ORDER BY id DESC LIMIT 10000"
|
||||
logs, err := r.queryUsageLogs(ctx, query, accountID, startTime, endTime)
|
||||
return logs, nil, err
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) ListByModelAndTimeRange(ctx context.Context, modelName string, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
||||
query := fmt.Sprintf("SELECT %s FROM usage_logs WHERE %s = $1 AND created_at >= $2 AND created_at < $3 ORDER BY id DESC LIMIT 10000", usageLogSelectColumns, rawUsageLogModelColumn)
|
||||
logs, err := r.queryUsageLogs(ctx, query, modelName, startTime, endTime)
|
||||
return logs, nil, err
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) Delete(ctx context.Context, id int64) error {
|
||||
_, err := r.sql.ExecContext(ctx, "DELETE FROM usage_logs WHERE id = $1", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UsageLogFilters represents filters for usage log queries
|
||||
type UsageLogFilters = usagestats.UsageLogFilters
|
||||
|
||||
// ListWithFilters lists usage logs with optional filters (for admin)
|
||||
func (r *usageLogRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
||||
conditions := make([]string, 0, 9)
|
||||
args := make([]any, 0, 9)
|
||||
|
||||
if filters.UserID > 0 {
|
||||
conditions = append(conditions, fmt.Sprintf("user_id = $%d", len(args)+1))
|
||||
args = append(args, filters.UserID)
|
||||
}
|
||||
if filters.APIKeyID > 0 {
|
||||
conditions = append(conditions, fmt.Sprintf("api_key_id = $%d", len(args)+1))
|
||||
args = append(args, filters.APIKeyID)
|
||||
}
|
||||
if filters.AccountID > 0 {
|
||||
conditions = append(conditions, fmt.Sprintf("account_id = $%d", len(args)+1))
|
||||
args = append(args, filters.AccountID)
|
||||
}
|
||||
if filters.GroupID > 0 {
|
||||
conditions = append(conditions, fmt.Sprintf("group_id = $%d", len(args)+1))
|
||||
args = append(args, filters.GroupID)
|
||||
}
|
||||
conditions, args = appendUsageLogModelWhereCondition(conditions, args, filters.Model, filters.ModelFilterSource)
|
||||
conditions, args = appendRequestTypeOrStreamWhereCondition(conditions, args, filters.RequestType, filters.Stream)
|
||||
if filters.BillingType != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1))
|
||||
args = append(args, int16(*filters.BillingType))
|
||||
}
|
||||
conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode)
|
||||
if filters.StartTime != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1))
|
||||
args = append(args, *filters.StartTime)
|
||||
}
|
||||
if filters.EndTime != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("created_at < $%d", len(args)+1))
|
||||
args = append(args, *filters.EndTime)
|
||||
}
|
||||
|
||||
whereClause := buildWhere(conditions)
|
||||
var (
|
||||
logs []service.UsageLog
|
||||
page *pagination.PaginationResult
|
||||
err error
|
||||
)
|
||||
if shouldUseFastUsageLogTotal(filters) {
|
||||
logs, page, err = r.listUsageLogsWithFastPagination(ctx, whereClause, args, params)
|
||||
} else {
|
||||
logs, page, err = r.listUsageLogsWithPagination(ctx, whereClause, args, params)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err := r.hydrateUsageLogAssociations(ctx, logs); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return logs, page, nil
|
||||
}
|
||||
|
||||
func shouldUseFastUsageLogTotal(filters UsageLogFilters) bool {
|
||||
if filters.ExactTotal {
|
||||
return false
|
||||
}
|
||||
// 强选择过滤下记录集通常较小,保留精确总数。
|
||||
return filters.UserID == 0 && filters.APIKeyID == 0 && filters.AccountID == 0
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) listUsageLogsWithPagination(ctx context.Context, whereClause string, args []any, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
||||
countQuery := "SELECT COUNT(*) FROM usage_logs " + whereClause
|
||||
var total int64
|
||||
if err := scanSingleRow(ctx, r.sql, countQuery, args, &total); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
limitPos := len(args) + 1
|
||||
offsetPos := len(args) + 2
|
||||
listArgs := append(append([]any{}, args...), params.Limit(), params.Offset())
|
||||
query := fmt.Sprintf("SELECT %s FROM usage_logs %s ORDER BY %s LIMIT $%d OFFSET $%d", usageLogSelectColumns, whereClause, usageLogOrderBy(params), limitPos, offsetPos)
|
||||
logs, err := r.queryUsageLogs(ctx, query, listArgs...)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return logs, paginationResultFromTotal(total, params), nil
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) listUsageLogsWithFastPagination(ctx context.Context, whereClause string, args []any, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
||||
limit := params.Limit()
|
||||
offset := params.Offset()
|
||||
|
||||
limitPos := len(args) + 1
|
||||
offsetPos := len(args) + 2
|
||||
listArgs := append(append([]any{}, args...), limit+1, offset)
|
||||
query := fmt.Sprintf("SELECT %s FROM usage_logs %s ORDER BY %s LIMIT $%d OFFSET $%d", usageLogSelectColumns, whereClause, usageLogOrderBy(params), limitPos, offsetPos)
|
||||
|
||||
logs, err := r.queryUsageLogs(ctx, query, listArgs...)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
hasMore := false
|
||||
if len(logs) > limit {
|
||||
hasMore = true
|
||||
logs = logs[:limit]
|
||||
}
|
||||
|
||||
total := int64(offset) + int64(len(logs))
|
||||
if hasMore {
|
||||
// 只保证“还有下一页”,避免对超大表做全量 COUNT(*)。
|
||||
total = int64(offset) + int64(limit) + 1
|
||||
}
|
||||
|
||||
return logs, paginationResultFromTotal(total, params), nil
|
||||
}
|
||||
|
||||
func usageLogOrderBy(params pagination.PaginationParams) string {
|
||||
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
|
||||
sortOrder := strings.ToUpper(params.NormalizedSortOrder(pagination.SortOrderDesc))
|
||||
|
||||
var column string
|
||||
switch sortBy {
|
||||
case "model":
|
||||
column = "COALESCE(NULLIF(TRIM(requested_model), ''), model)"
|
||||
case "created_at":
|
||||
column = "created_at"
|
||||
default:
|
||||
column = "id"
|
||||
}
|
||||
|
||||
if column == "id" {
|
||||
return fmt.Sprintf("id %s", sortOrder)
|
||||
}
|
||||
return fmt.Sprintf("%s %s, id %s", column, sortOrder, sortOrder)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) queryUsageLogs(ctx context.Context, query string, args ...any) (logs []service.UsageLog, err error) {
|
||||
rows, err := r.sql.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
// 保持主错误优先;仅在无错误时回传 Close 失败。
|
||||
// 同时清空返回值,避免误用不完整结果。
|
||||
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
logs = nil
|
||||
}
|
||||
}()
|
||||
|
||||
logs = make([]service.UsageLog, 0)
|
||||
for rows.Next() {
|
||||
var log *service.UsageLog
|
||||
log, err = scanUsageLog(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logs = append(logs, *log)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) hydrateUsageLogAssociations(ctx context.Context, logs []service.UsageLog) error {
|
||||
// 关联数据使用 Ent 批量加载,避免把复杂 SQL 继续膨胀。
|
||||
if len(logs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := collectUsageLogIDs(logs)
|
||||
users, err := r.loadUsers(ctx, ids.userIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apiKeys, err := r.loadAPIKeys(ctx, ids.apiKeyIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
accounts, err := r.loadAccounts(ctx, ids.accountIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groups, err := r.loadGroups(ctx, ids.groupIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subs, err := r.loadSubscriptions(ctx, ids.subscriptionIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range logs {
|
||||
if user, ok := users[logs[i].UserID]; ok {
|
||||
logs[i].User = user
|
||||
}
|
||||
if key, ok := apiKeys[logs[i].APIKeyID]; ok {
|
||||
logs[i].APIKey = key
|
||||
}
|
||||
if acc, ok := accounts[logs[i].AccountID]; ok {
|
||||
logs[i].Account = acc
|
||||
}
|
||||
if logs[i].GroupID != nil {
|
||||
if group, ok := groups[*logs[i].GroupID]; ok {
|
||||
logs[i].Group = group
|
||||
}
|
||||
}
|
||||
if logs[i].SubscriptionID != nil {
|
||||
if sub, ok := subs[*logs[i].SubscriptionID]; ok {
|
||||
logs[i].Subscription = sub
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type usageLogIDs struct {
|
||||
userIDs []int64
|
||||
apiKeyIDs []int64
|
||||
accountIDs []int64
|
||||
groupIDs []int64
|
||||
subscriptionIDs []int64
|
||||
}
|
||||
|
||||
func collectUsageLogIDs(logs []service.UsageLog) usageLogIDs {
|
||||
idSet := func() map[int64]struct{} { return make(map[int64]struct{}) }
|
||||
|
||||
userIDs := idSet()
|
||||
apiKeyIDs := idSet()
|
||||
accountIDs := idSet()
|
||||
groupIDs := idSet()
|
||||
subscriptionIDs := idSet()
|
||||
|
||||
for i := range logs {
|
||||
userIDs[logs[i].UserID] = struct{}{}
|
||||
apiKeyIDs[logs[i].APIKeyID] = struct{}{}
|
||||
accountIDs[logs[i].AccountID] = struct{}{}
|
||||
if logs[i].GroupID != nil {
|
||||
groupIDs[*logs[i].GroupID] = struct{}{}
|
||||
}
|
||||
if logs[i].SubscriptionID != nil {
|
||||
subscriptionIDs[*logs[i].SubscriptionID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return usageLogIDs{
|
||||
userIDs: setToSlice(userIDs),
|
||||
apiKeyIDs: setToSlice(apiKeyIDs),
|
||||
accountIDs: setToSlice(accountIDs),
|
||||
groupIDs: setToSlice(groupIDs),
|
||||
subscriptionIDs: setToSlice(subscriptionIDs),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) loadUsers(ctx context.Context, ids []int64) (map[int64]*service.User, error) {
|
||||
out := make(map[int64]*service.User)
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
// 无条件穿透软删除:ids 来自调用方已按 user_id 筛选的日志行;普通用户路径强制 UserID=本人(本人必为活跃用户),不会借此解析他人已删身份;仅 admin 路径可借此显示已删用户。
|
||||
models, err := r.client.User.Query().Where(dbuser.IDIn(ids...)).All(mixins.SkipSoftDelete(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range models {
|
||||
out[m.ID] = userEntityToService(m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) loadAPIKeys(ctx context.Context, ids []int64) (map[int64]*service.APIKey, error) {
|
||||
out := make(map[int64]*service.APIKey)
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
models, err := r.client.APIKey.Query().Where(dbapikey.IDIn(ids...)).All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range models {
|
||||
out[m.ID] = apiKeyEntityToService(m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) loadAccounts(ctx context.Context, ids []int64) (map[int64]*service.Account, error) {
|
||||
out := make(map[int64]*service.Account)
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
models, err := r.client.Account.Query().Where(dbaccount.IDIn(ids...)).All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range models {
|
||||
out[m.ID] = accountEntityToService(m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) loadGroups(ctx context.Context, ids []int64) (map[int64]*service.Group, error) {
|
||||
out := make(map[int64]*service.Group)
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
models, err := r.client.Group.Query().Where(dbgroup.IDIn(ids...)).All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range models {
|
||||
out[m.ID] = groupEntityToService(m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) loadSubscriptions(ctx context.Context, ids []int64) (map[int64]*service.UserSubscription, error) {
|
||||
out := make(map[int64]*service.UserSubscription)
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
models, err := r.client.UserSubscription.Query().Where(dbusersub.IDIn(ids...)).All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range models {
|
||||
out[m.ID] = userSubscriptionEntityToService(m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, error) {
|
||||
var (
|
||||
id int64
|
||||
userID int64
|
||||
apiKeyID int64
|
||||
accountID int64
|
||||
requestID sql.NullString
|
||||
model string
|
||||
requestedModel sql.NullString
|
||||
upstreamModel sql.NullString
|
||||
groupID sql.NullInt64
|
||||
subscriptionID sql.NullInt64
|
||||
inputTokens int
|
||||
outputTokens int
|
||||
cacheCreationTokens int
|
||||
cacheReadTokens int
|
||||
cacheCreation5m int
|
||||
cacheCreation1h int
|
||||
imageOutputTokens int
|
||||
imageOutputCost float64
|
||||
inputCost float64
|
||||
outputCost float64
|
||||
cacheCreationCost float64
|
||||
cacheReadCost float64
|
||||
totalCost float64
|
||||
actualCost float64
|
||||
rateMultiplier float64
|
||||
accountRateMultiplier sql.NullFloat64
|
||||
billingType int16
|
||||
requestTypeRaw int16
|
||||
stream bool
|
||||
openaiWSMode bool
|
||||
durationMs sql.NullInt64
|
||||
firstTokenMs sql.NullInt64
|
||||
userAgent sql.NullString
|
||||
ipAddress sql.NullString
|
||||
imageCount int
|
||||
imageSize sql.NullString
|
||||
imageInputSize sql.NullString
|
||||
imageOutputSize sql.NullString
|
||||
imageSizeSource sql.NullString
|
||||
imageSizeBreakdown sql.NullString
|
||||
serviceTier sql.NullString
|
||||
reasoningEffort sql.NullString
|
||||
inboundEndpoint sql.NullString
|
||||
upstreamEndpoint sql.NullString
|
||||
cacheTTLOverridden bool
|
||||
channelID sql.NullInt64
|
||||
modelMappingChain sql.NullString
|
||||
billingTier sql.NullString
|
||||
billingMode sql.NullString
|
||||
accountStatsCost sql.NullFloat64
|
||||
createdAt time.Time
|
||||
)
|
||||
|
||||
if err := scanner.Scan(
|
||||
&id,
|
||||
&userID,
|
||||
&apiKeyID,
|
||||
&accountID,
|
||||
&requestID,
|
||||
&model,
|
||||
&requestedModel,
|
||||
&upstreamModel,
|
||||
&groupID,
|
||||
&subscriptionID,
|
||||
&inputTokens,
|
||||
&outputTokens,
|
||||
&cacheCreationTokens,
|
||||
&cacheReadTokens,
|
||||
&cacheCreation5m,
|
||||
&cacheCreation1h,
|
||||
&imageOutputTokens,
|
||||
&imageOutputCost,
|
||||
&inputCost,
|
||||
&outputCost,
|
||||
&cacheCreationCost,
|
||||
&cacheReadCost,
|
||||
&totalCost,
|
||||
&actualCost,
|
||||
&rateMultiplier,
|
||||
&accountRateMultiplier,
|
||||
&billingType,
|
||||
&requestTypeRaw,
|
||||
&stream,
|
||||
&openaiWSMode,
|
||||
&durationMs,
|
||||
&firstTokenMs,
|
||||
&userAgent,
|
||||
&ipAddress,
|
||||
&imageCount,
|
||||
&imageSize,
|
||||
&imageInputSize,
|
||||
&imageOutputSize,
|
||||
&imageSizeSource,
|
||||
&imageSizeBreakdown,
|
||||
&serviceTier,
|
||||
&reasoningEffort,
|
||||
&inboundEndpoint,
|
||||
&upstreamEndpoint,
|
||||
&cacheTTLOverridden,
|
||||
&channelID,
|
||||
&modelMappingChain,
|
||||
&billingTier,
|
||||
&billingMode,
|
||||
&accountStatsCost,
|
||||
&createdAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log := &service.UsageLog{
|
||||
ID: id,
|
||||
UserID: userID,
|
||||
APIKeyID: apiKeyID,
|
||||
AccountID: accountID,
|
||||
Model: model,
|
||||
RequestedModel: coalesceTrimmedString(requestedModel, model),
|
||||
InputTokens: inputTokens,
|
||||
OutputTokens: outputTokens,
|
||||
CacheCreationTokens: cacheCreationTokens,
|
||||
CacheReadTokens: cacheReadTokens,
|
||||
CacheCreation5mTokens: cacheCreation5m,
|
||||
CacheCreation1hTokens: cacheCreation1h,
|
||||
ImageOutputTokens: imageOutputTokens,
|
||||
ImageOutputCost: imageOutputCost,
|
||||
InputCost: inputCost,
|
||||
OutputCost: outputCost,
|
||||
CacheCreationCost: cacheCreationCost,
|
||||
CacheReadCost: cacheReadCost,
|
||||
TotalCost: totalCost,
|
||||
ActualCost: actualCost,
|
||||
RateMultiplier: rateMultiplier,
|
||||
AccountRateMultiplier: nullFloat64Ptr(accountRateMultiplier),
|
||||
BillingType: int8(billingType),
|
||||
RequestType: service.RequestTypeFromInt16(requestTypeRaw),
|
||||
ImageCount: imageCount,
|
||||
CacheTTLOverridden: cacheTTLOverridden,
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
// 先回填 legacy 字段,再基于 legacy + request_type 计算最终请求类型,保证历史数据兼容。
|
||||
log.Stream = stream
|
||||
log.OpenAIWSMode = openaiWSMode
|
||||
log.RequestType = log.EffectiveRequestType()
|
||||
log.Stream, log.OpenAIWSMode = service.ApplyLegacyRequestFields(log.RequestType, stream, openaiWSMode)
|
||||
|
||||
if requestID.Valid {
|
||||
log.RequestID = requestID.String
|
||||
}
|
||||
if groupID.Valid {
|
||||
value := groupID.Int64
|
||||
log.GroupID = &value
|
||||
}
|
||||
if subscriptionID.Valid {
|
||||
value := subscriptionID.Int64
|
||||
log.SubscriptionID = &value
|
||||
}
|
||||
if durationMs.Valid {
|
||||
value := int(durationMs.Int64)
|
||||
log.DurationMs = &value
|
||||
}
|
||||
if firstTokenMs.Valid {
|
||||
value := int(firstTokenMs.Int64)
|
||||
log.FirstTokenMs = &value
|
||||
}
|
||||
if userAgent.Valid {
|
||||
log.UserAgent = &userAgent.String
|
||||
}
|
||||
if ipAddress.Valid {
|
||||
log.IPAddress = &ipAddress.String
|
||||
}
|
||||
if imageSize.Valid {
|
||||
log.ImageSize = &imageSize.String
|
||||
}
|
||||
if imageInputSize.Valid {
|
||||
log.ImageInputSize = &imageInputSize.String
|
||||
}
|
||||
if imageOutputSize.Valid {
|
||||
log.ImageOutputSize = &imageOutputSize.String
|
||||
}
|
||||
if imageSizeSource.Valid {
|
||||
log.ImageSizeSource = &imageSizeSource.String
|
||||
}
|
||||
log.ImageSizeBreakdown = stringIntMapFromNullJSON(imageSizeBreakdown)
|
||||
if serviceTier.Valid {
|
||||
log.ServiceTier = &serviceTier.String
|
||||
}
|
||||
if reasoningEffort.Valid {
|
||||
log.ReasoningEffort = &reasoningEffort.String
|
||||
}
|
||||
if inboundEndpoint.Valid {
|
||||
log.InboundEndpoint = &inboundEndpoint.String
|
||||
}
|
||||
if upstreamEndpoint.Valid {
|
||||
log.UpstreamEndpoint = &upstreamEndpoint.String
|
||||
}
|
||||
if upstreamModel.Valid {
|
||||
log.UpstreamModel = &upstreamModel.String
|
||||
}
|
||||
if channelID.Valid {
|
||||
value := channelID.Int64
|
||||
log.ChannelID = &value
|
||||
}
|
||||
if modelMappingChain.Valid {
|
||||
log.ModelMappingChain = &modelMappingChain.String
|
||||
}
|
||||
if billingTier.Valid {
|
||||
log.BillingTier = &billingTier.String
|
||||
}
|
||||
if billingMode.Valid {
|
||||
log.BillingMode = &billingMode.String
|
||||
}
|
||||
if accountStatsCost.Valid {
|
||||
log.AccountStatsCost = &accountStatsCost.Float64
|
||||
}
|
||||
|
||||
return log, nil
|
||||
}
|
||||
|
||||
func nullInt64(v *int64) sql.NullInt64 {
|
||||
if v == nil {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
return sql.NullInt64{Int64: *v, Valid: true}
|
||||
}
|
||||
|
||||
func nullInt(v *int) sql.NullInt64 {
|
||||
if v == nil {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
return sql.NullInt64{Int64: int64(*v), Valid: true}
|
||||
}
|
||||
|
||||
func nullFloat64Ptr(v sql.NullFloat64) *float64 {
|
||||
if !v.Valid {
|
||||
return nil
|
||||
}
|
||||
out := v.Float64
|
||||
return &out
|
||||
}
|
||||
|
||||
func nullString(v *string) sql.NullString {
|
||||
if v == nil || *v == "" {
|
||||
return sql.NullString{}
|
||||
}
|
||||
return sql.NullString{String: *v, Valid: true}
|
||||
}
|
||||
|
||||
func nullStringIntMapJSON(v map[string]int) any {
|
||||
if len(v) == 0 {
|
||||
return nil
|
||||
}
|
||||
payload, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return string(payload)
|
||||
}
|
||||
|
||||
func stringIntMapFromNullJSON(v sql.NullString) map[string]int {
|
||||
if !v.Valid || strings.TrimSpace(v.String) == "" {
|
||||
return nil
|
||||
}
|
||||
var out map[string]int
|
||||
if err := json.Unmarshal([]byte(v.String), &out); err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func coalesceTrimmedString(v sql.NullString, fallback string) string {
|
||||
if v.Valid && strings.TrimSpace(v.String) != "" {
|
||||
return v.String
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func setToSlice(set map[int64]struct{}) []int64 {
|
||||
out := make([]int64, 0, len(set))
|
||||
for id := range set {
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,799 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||
)
|
||||
|
||||
// TrendDataPoint represents a single point in trend data
|
||||
type TrendDataPoint = usagestats.TrendDataPoint
|
||||
|
||||
// ModelStat represents usage statistics for a single model
|
||||
type ModelStat = usagestats.ModelStat
|
||||
|
||||
// UserUsageTrendPoint represents user usage trend data point
|
||||
type UserUsageTrendPoint = usagestats.UserUsageTrendPoint
|
||||
|
||||
// UserSpendingRankingItem represents a user spending ranking row.
|
||||
type UserSpendingRankingItem = usagestats.UserSpendingRankingItem
|
||||
type UserSpendingRankingResponse = usagestats.UserSpendingRankingResponse
|
||||
|
||||
// APIKeyUsageTrendPoint represents API key usage trend data point
|
||||
type APIKeyUsageTrendPoint = usagestats.APIKeyUsageTrendPoint
|
||||
|
||||
// GetAPIKeyUsageTrend returns usage trend data grouped by API key and date
|
||||
func (r *usageLogRepository) GetAPIKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) (results []APIKeyUsageTrendPoint, err error) {
|
||||
dateFormat := safeDateFormat(granularity)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
WITH top_keys AS (
|
||||
SELECT api_key_id
|
||||
FROM usage_logs
|
||||
WHERE created_at >= $1 AND created_at < $2
|
||||
GROUP BY api_key_id
|
||||
ORDER BY SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) DESC
|
||||
LIMIT $3
|
||||
)
|
||||
SELECT
|
||||
TO_CHAR(u.created_at, '%s') as date,
|
||||
u.api_key_id,
|
||||
COALESCE(k.name, '') as key_name,
|
||||
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 api_keys k ON u.api_key_id = k.id
|
||||
WHERE u.api_key_id IN (SELECT api_key_id FROM top_keys)
|
||||
AND u.created_at >= $4 AND u.created_at < $5
|
||||
GROUP BY date, u.api_key_id, k.name
|
||||
ORDER BY date ASC, tokens DESC
|
||||
`, dateFormat)
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, startTime, endTime, limit, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
// 保持主错误优先;仅在无错误时回传 Close 失败。
|
||||
// 同时清空返回值,避免误用不完整结果。
|
||||
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
results = nil
|
||||
}
|
||||
}()
|
||||
|
||||
results = make([]APIKeyUsageTrendPoint, 0)
|
||||
for rows.Next() {
|
||||
var row APIKeyUsageTrendPoint
|
||||
if err = rows.Scan(&row.Date, &row.APIKeyID, &row.KeyName, &row.Requests, &row.Tokens); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetUserUsageTrend returns usage trend data grouped by user and date
|
||||
func (r *usageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) (results []UserUsageTrendPoint, err error) {
|
||||
dateFormat := safeDateFormat(granularity)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
WITH top_users AS (
|
||||
SELECT user_id
|
||||
FROM usage_logs
|
||||
WHERE created_at >= $1 AND created_at < $2
|
||||
GROUP BY user_id
|
||||
ORDER BY SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) DESC
|
||||
LIMIT $3
|
||||
)
|
||||
SELECT
|
||||
TO_CHAR(u.created_at, '%s') as date,
|
||||
u.user_id,
|
||||
COALESCE(us.email, '') as email,
|
||||
COALESCE(us.username, '') as username,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(u.input_tokens + u.output_tokens + u.cache_creation_tokens + u.cache_read_tokens), 0) as tokens,
|
||||
COALESCE(SUM(u.total_cost), 0) as cost,
|
||||
COALESCE(SUM(u.actual_cost), 0) as actual_cost
|
||||
FROM usage_logs u
|
||||
LEFT JOIN users us ON u.user_id = us.id
|
||||
WHERE u.user_id IN (SELECT user_id FROM top_users)
|
||||
AND u.created_at >= $4 AND u.created_at < $5
|
||||
GROUP BY date, u.user_id, us.email, us.username
|
||||
ORDER BY date ASC, tokens DESC
|
||||
`, dateFormat)
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, startTime, endTime, limit, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
// 保持主错误优先;仅在无错误时回传 Close 失败。
|
||||
// 同时清空返回值,避免误用不完整结果。
|
||||
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
results = nil
|
||||
}
|
||||
}()
|
||||
|
||||
results = make([]UserUsageTrendPoint, 0)
|
||||
for rows.Next() {
|
||||
var row UserUsageTrendPoint
|
||||
if err = rows.Scan(&row.Date, &row.UserID, &row.Email, &row.Username, &row.Requests, &row.Tokens, &row.Cost, &row.ActualCost); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetUserSpendingRanking returns user spending ranking aggregated within the time range.
|
||||
func (r *usageLogRepository) GetUserSpendingRanking(ctx context.Context, startTime, endTime time.Time, limit int) (result *UserSpendingRankingResponse, err error) {
|
||||
if limit <= 0 {
|
||||
limit = 12
|
||||
}
|
||||
|
||||
query := `
|
||||
WITH user_spend AS (
|
||||
SELECT
|
||||
u.user_id,
|
||||
COALESCE(us.email, '') as email,
|
||||
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
|
||||
),
|
||||
ranked AS (
|
||||
SELECT
|
||||
user_id,
|
||||
email,
|
||||
actual_cost,
|
||||
requests,
|
||||
tokens,
|
||||
COALESCE(SUM(actual_cost) OVER (), 0) as total_actual_cost,
|
||||
COALESCE(SUM(requests) OVER (), 0) as total_requests,
|
||||
COALESCE(SUM(tokens) OVER (), 0) as total_tokens
|
||||
FROM user_spend
|
||||
ORDER BY actual_cost DESC, tokens DESC, user_id ASC
|
||||
LIMIT $3
|
||||
)
|
||||
SELECT
|
||||
user_id,
|
||||
email,
|
||||
actual_cost,
|
||||
requests,
|
||||
tokens,
|
||||
total_actual_cost,
|
||||
total_requests,
|
||||
total_tokens
|
||||
FROM ranked
|
||||
ORDER BY actual_cost DESC, tokens DESC, user_id ASC
|
||||
`
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, startTime, endTime, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
result = nil
|
||||
}
|
||||
}()
|
||||
|
||||
ranking := make([]UserSpendingRankingItem, 0)
|
||||
totalActualCost := 0.0
|
||||
totalRequests := int64(0)
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
ranking = append(ranking, row)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UserSpendingRankingResponse{
|
||||
Ranking: ranking,
|
||||
TotalActualCost: totalActualCost,
|
||||
TotalRequests: totalRequests,
|
||||
TotalTokens: totalTokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUserUsageTrendByUserID 获取指定用户的使用趋势
|
||||
func (r *usageLogRepository) GetUserUsageTrendByUserID(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) (results []TrendDataPoint, err error) {
|
||||
dateFormat := safeDateFormat(granularity)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
TO_CHAR(created_at, '%s') as date,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens,
|
||||
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(total_cost), 0) as cost,
|
||||
COALESCE(SUM(actual_cost), 0) as actual_cost
|
||||
FROM usage_logs
|
||||
WHERE user_id = $1 AND created_at >= $2 AND created_at < $3
|
||||
GROUP BY date
|
||||
ORDER BY date ASC
|
||||
`, dateFormat)
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, userID, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
// 保持主错误优先;仅在无错误时回传 Close 失败。
|
||||
// 同时清空返回值,避免误用不完整结果。
|
||||
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
results = nil
|
||||
}
|
||||
}()
|
||||
|
||||
results, err = scanTrendRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetUserModelStats 获取指定用户的模型统计
|
||||
func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) (results []ModelStat, err error) {
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "")
|
||||
}
|
||||
|
||||
// GetUsageTrendWithFilters returns usage trend data with optional filters
|
||||
func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) (results []TrendDataPoint, err error) {
|
||||
return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "")
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters UsageLogFilters) (results []TrendDataPoint, err error) {
|
||||
return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []TrendDataPoint, err error) {
|
||||
if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode) {
|
||||
aggregated, aggregatedErr := r.getUsageTrendFromAggregates(ctx, startTime, endTime, granularity)
|
||||
if aggregatedErr == nil && len(aggregated) > 0 {
|
||||
return aggregated, nil
|
||||
}
|
||||
}
|
||||
|
||||
dateFormat := safeDateFormat(granularity)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
TO_CHAR(created_at, '%s') as date,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens,
|
||||
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(total_cost), 0) as cost,
|
||||
COALESCE(SUM(actual_cost), 0) as actual_cost
|
||||
FROM usage_logs
|
||||
WHERE created_at >= $1 AND created_at < $2
|
||||
`, dateFormat)
|
||||
|
||||
args := []any{startTime, endTime}
|
||||
if userID > 0 {
|
||||
query += fmt.Sprintf(" AND user_id = $%d", len(args)+1)
|
||||
args = append(args, userID)
|
||||
}
|
||||
if apiKeyID > 0 {
|
||||
query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1)
|
||||
args = append(args, apiKeyID)
|
||||
}
|
||||
if accountID > 0 {
|
||||
query += fmt.Sprintf(" AND account_id = $%d", len(args)+1)
|
||||
args = append(args, accountID)
|
||||
}
|
||||
if groupID > 0 {
|
||||
query += fmt.Sprintf(" AND group_id = $%d", len(args)+1)
|
||||
args = append(args, groupID)
|
||||
}
|
||||
query, args = appendUsageLogModelQueryFilter(query, args, model, modelSource)
|
||||
query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream)
|
||||
if billingType != nil {
|
||||
query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1)
|
||||
args = append(args, int16(*billingType))
|
||||
}
|
||||
query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "")
|
||||
query += " GROUP BY date ORDER BY date ASC"
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
// 保持主错误优先;仅在无错误时回传 Close 失败。
|
||||
// 同时清空返回值,避免误用不完整结果。
|
||||
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
results = nil
|
||||
}
|
||||
}()
|
||||
|
||||
results, err = scanTrendRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) bool {
|
||||
if granularity != "day" && granularity != "hour" {
|
||||
return false
|
||||
}
|
||||
return userID == 0 &&
|
||||
apiKeyID == 0 &&
|
||||
accountID == 0 &&
|
||||
groupID == 0 &&
|
||||
model == "" &&
|
||||
requestType == nil &&
|
||||
stream == nil &&
|
||||
billingType == nil &&
|
||||
billingMode == ""
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, startTime, endTime time.Time, granularity string) (results []TrendDataPoint, err error) {
|
||||
dateFormat := safeDateFormat(granularity)
|
||||
query := ""
|
||||
args := []any{startTime, endTime}
|
||||
|
||||
switch granularity {
|
||||
case "hour":
|
||||
query = fmt.Sprintf(`
|
||||
SELECT
|
||||
TO_CHAR(bucket_start, '%s') as date,
|
||||
total_requests as requests,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_creation_tokens,
|
||||
cache_read_tokens,
|
||||
(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) as total_tokens,
|
||||
total_cost as cost,
|
||||
actual_cost
|
||||
FROM usage_dashboard_hourly
|
||||
WHERE bucket_start >= $1 AND bucket_start < $2
|
||||
ORDER BY bucket_start ASC
|
||||
`, dateFormat)
|
||||
case "day":
|
||||
query = fmt.Sprintf(`
|
||||
SELECT
|
||||
TO_CHAR(bucket_date::timestamp, '%s') as date,
|
||||
total_requests as requests,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_creation_tokens,
|
||||
cache_read_tokens,
|
||||
(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) as total_tokens,
|
||||
total_cost as cost,
|
||||
actual_cost
|
||||
FROM usage_dashboard_daily
|
||||
WHERE bucket_date >= $1::date AND bucket_date < $2::date
|
||||
ORDER BY bucket_date ASC
|
||||
`, dateFormat)
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
results = nil
|
||||
}
|
||||
}()
|
||||
|
||||
results, err = scanTrendRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetModelStatsWithFilters returns model statistics with optional filters
|
||||
func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []ModelStat, err error) {
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "")
|
||||
}
|
||||
|
||||
// GetModelStatsWithFiltersBySource returns model statistics with optional filters and model source dimension.
|
||||
// source: requested | upstream | mapping.
|
||||
func (r *usageLogRepository) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) (results []ModelStat, err error) {
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "")
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters, source string) (results []ModelStat, err error) {
|
||||
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string) (results []ModelStat, err error) {
|
||||
actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost"
|
||||
// 当仅按 account_id 聚合时,实际费用使用账号倍率(total_cost * account_rate_multiplier)。
|
||||
if accountID > 0 && userID == 0 && apiKeyID == 0 {
|
||||
actualCostExpr = "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost"
|
||||
}
|
||||
accountCostExpr := "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as account_cost"
|
||||
modelExpr := resolveModelDimensionExpression(source)
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
%s as model,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as output_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as cache_read_tokens,
|
||||
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(total_cost), 0) as cost,
|
||||
%s,
|
||||
%s
|
||||
FROM usage_logs
|
||||
WHERE created_at >= $1 AND created_at < $2
|
||||
`, modelExpr, actualCostExpr, accountCostExpr)
|
||||
|
||||
args := []any{startTime, endTime}
|
||||
if userID > 0 {
|
||||
query += fmt.Sprintf(" AND user_id = $%d", len(args)+1)
|
||||
args = append(args, userID)
|
||||
}
|
||||
if apiKeyID > 0 {
|
||||
query += fmt.Sprintf(" AND api_key_id = $%d", len(args)+1)
|
||||
args = append(args, apiKeyID)
|
||||
}
|
||||
if accountID > 0 {
|
||||
query += fmt.Sprintf(" AND account_id = $%d", len(args)+1)
|
||||
args = append(args, accountID)
|
||||
}
|
||||
if groupID > 0 {
|
||||
query += fmt.Sprintf(" AND group_id = $%d", len(args)+1)
|
||||
args = append(args, groupID)
|
||||
}
|
||||
if strings.TrimSpace(model) != "" {
|
||||
query += fmt.Sprintf(" AND %s = $%d", modelExpr, len(args)+1)
|
||||
args = append(args, model)
|
||||
}
|
||||
query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream)
|
||||
if billingType != nil {
|
||||
query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1)
|
||||
args = append(args, int16(*billingType))
|
||||
}
|
||||
query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "")
|
||||
query += fmt.Sprintf(" GROUP BY %s ORDER BY total_tokens DESC", modelExpr)
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
// 保持主错误优先;仅在无错误时回传 Close 失败。
|
||||
// 同时清空返回值,避免误用不完整结果。
|
||||
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
results = nil
|
||||
}
|
||||
}()
|
||||
|
||||
results, err = scanModelStatsRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetGroupStatsWithFilters returns group usage statistics with optional filters
|
||||
func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []usagestats.GroupStat, err error) {
|
||||
return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "")
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters) (results []usagestats.GroupStat, err error) {
|
||||
return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode)
|
||||
}
|
||||
|
||||
func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []usagestats.GroupStat, err error) {
|
||||
query := `
|
||||
SELECT
|
||||
COALESCE(ul.group_id, 0) as group_id,
|
||||
COALESCE(g.name, '') as group_name,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(ul.total_cost), 0) as cost,
|
||||
COALESCE(SUM(ul.actual_cost), 0) as actual_cost,
|
||||
COALESCE(SUM(COALESCE(ul.account_stats_cost, ul.total_cost) * COALESCE(ul.account_rate_multiplier, 1)), 0) as account_cost
|
||||
FROM usage_logs ul
|
||||
LEFT JOIN groups g ON g.id = ul.group_id
|
||||
WHERE ul.created_at >= $1 AND ul.created_at < $2
|
||||
`
|
||||
|
||||
args := []any{startTime, endTime}
|
||||
if userID > 0 {
|
||||
query += fmt.Sprintf(" AND ul.user_id = $%d", len(args)+1)
|
||||
args = append(args, userID)
|
||||
}
|
||||
if apiKeyID > 0 {
|
||||
query += fmt.Sprintf(" AND ul.api_key_id = $%d", len(args)+1)
|
||||
args = append(args, apiKeyID)
|
||||
}
|
||||
if accountID > 0 {
|
||||
query += fmt.Sprintf(" AND ul.account_id = $%d", len(args)+1)
|
||||
args = append(args, accountID)
|
||||
}
|
||||
if groupID > 0 {
|
||||
query += fmt.Sprintf(" AND ul.group_id = $%d", len(args)+1)
|
||||
args = append(args, groupID)
|
||||
}
|
||||
if strings.TrimSpace(model) != "" {
|
||||
modelExpr := resolveModelDimensionExpressionWithAlias(usagestats.ModelSourceRequested, "ul")
|
||||
query += fmt.Sprintf(" AND %s = $%d", modelExpr, len(args)+1)
|
||||
args = append(args, model)
|
||||
}
|
||||
query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream)
|
||||
if billingType != nil {
|
||||
query += fmt.Sprintf(" AND ul.billing_type = $%d", len(args)+1)
|
||||
args = append(args, int16(*billingType))
|
||||
}
|
||||
query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "ul")
|
||||
query += " GROUP BY ul.group_id, g.name ORDER BY total_tokens DESC"
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
results = nil
|
||||
}
|
||||
}()
|
||||
|
||||
results = make([]usagestats.GroupStat, 0)
|
||||
for rows.Next() {
|
||||
var row usagestats.GroupStat
|
||||
if err := rows.Scan(
|
||||
&row.GroupID,
|
||||
&row.GroupName,
|
||||
&row.Requests,
|
||||
&row.TotalTokens,
|
||||
&row.Cost,
|
||||
&row.ActualCost,
|
||||
&row.AccountCost,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetUserBreakdownStats returns per-user usage breakdown within a specific dimension.
|
||||
func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTime, endTime time.Time, dim usagestats.UserBreakdownDimension, limit int) (results []usagestats.UserBreakdownItem, err error) {
|
||||
query := `
|
||||
SELECT
|
||||
COALESCE(ul.user_id, 0) as user_id,
|
||||
COALESCE(u.email, '') as email,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(ul.input_tokens + ul.output_tokens + ul.cache_creation_tokens + ul.cache_read_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(ul.total_cost), 0) as cost,
|
||||
COALESCE(SUM(ul.actual_cost), 0) as actual_cost,
|
||||
COALESCE(SUM(COALESCE(ul.account_stats_cost, ul.total_cost) * COALESCE(ul.account_rate_multiplier, 1)), 0) as account_cost
|
||||
FROM usage_logs ul
|
||||
LEFT JOIN users u ON u.id = ul.user_id
|
||||
WHERE ul.created_at >= $1 AND ul.created_at < $2
|
||||
`
|
||||
args := []any{startTime, endTime}
|
||||
|
||||
if dim.GroupID > 0 {
|
||||
query += fmt.Sprintf(" AND ul.group_id = $%d", len(args)+1)
|
||||
args = append(args, dim.GroupID)
|
||||
}
|
||||
if dim.Model != "" {
|
||||
query += fmt.Sprintf(" AND %s = $%d", resolveModelDimensionExpression(dim.ModelType), len(args)+1)
|
||||
args = append(args, dim.Model)
|
||||
}
|
||||
if dim.Endpoint != "" {
|
||||
col := resolveEndpointColumn(dim.EndpointType)
|
||||
query += fmt.Sprintf(" AND %s = $%d", col, len(args)+1)
|
||||
args = append(args, dim.Endpoint)
|
||||
}
|
||||
if dim.UserID > 0 {
|
||||
query += fmt.Sprintf(" AND ul.user_id = $%d", len(args)+1)
|
||||
args = append(args, dim.UserID)
|
||||
}
|
||||
if dim.APIKeyID > 0 {
|
||||
query += fmt.Sprintf(" AND ul.api_key_id = $%d", len(args)+1)
|
||||
args = append(args, dim.APIKeyID)
|
||||
}
|
||||
if dim.AccountID > 0 {
|
||||
query += fmt.Sprintf(" AND ul.account_id = $%d", len(args)+1)
|
||||
args = append(args, dim.AccountID)
|
||||
}
|
||||
if dim.RequestType != nil {
|
||||
query += fmt.Sprintf(" AND ul.request_type = $%d", len(args)+1)
|
||||
args = append(args, *dim.RequestType)
|
||||
}
|
||||
if dim.Stream != nil {
|
||||
query += fmt.Sprintf(" AND ul.stream = $%d", len(args)+1)
|
||||
args = append(args, *dim.Stream)
|
||||
}
|
||||
if dim.BillingType != nil {
|
||||
query += fmt.Sprintf(" AND ul.billing_type = $%d", len(args)+1)
|
||||
args = append(args, *dim.BillingType)
|
||||
}
|
||||
|
||||
query += " GROUP BY ul.user_id, u.email ORDER BY actual_cost DESC"
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
}
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
results = nil
|
||||
}
|
||||
}()
|
||||
|
||||
results = make([]usagestats.UserBreakdownItem, 0)
|
||||
for rows.Next() {
|
||||
var row usagestats.UserBreakdownItem
|
||||
if err := rows.Scan(
|
||||
&row.UserID,
|
||||
&row.Email,
|
||||
&row.Requests,
|
||||
&row.TotalTokens,
|
||||
&row.Cost,
|
||||
&row.ActualCost,
|
||||
&row.AccountCost,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetAllGroupUsageSummary returns today's and cumulative actual_cost for every group.
|
||||
// todayStart is the start-of-day in the caller's timezone (UTC-based).
|
||||
// TODO(perf): This query scans ALL usage_logs rows for total_cost aggregation.
|
||||
// When usage_logs exceeds ~1M rows, consider adding a short-lived cache (30s)
|
||||
// or a materialized view / pre-aggregation table for cumulative costs.
|
||||
func (r *usageLogRepository) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) {
|
||||
query := `
|
||||
SELECT
|
||||
g.id AS group_id,
|
||||
COALESCE(SUM(ul.actual_cost), 0) AS total_cost,
|
||||
COALESCE(SUM(CASE WHEN ul.created_at >= $1 THEN ul.actual_cost ELSE 0 END), 0) AS today_cost
|
||||
FROM groups g
|
||||
LEFT JOIN usage_logs ul ON ul.group_id = g.id
|
||||
GROUP BY g.id
|
||||
`
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, query, todayStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var results []usagestats.GroupUsageSummary
|
||||
for rows.Next() {
|
||||
var row usagestats.GroupUsageSummary
|
||||
if err := rows.Scan(&row.GroupID, &row.TotalCost, &row.TodayCost); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// resolveModelDimensionExpression maps model source type to a safe SQL expression.
|
||||
func resolveModelDimensionExpression(modelType string) string {
|
||||
return resolveModelDimensionExpressionWithAlias(modelType, "")
|
||||
}
|
||||
|
||||
func resolveModelDimensionExpressionWithAlias(modelType, alias string) string {
|
||||
column := func(name string) string {
|
||||
if alias == "" {
|
||||
return name
|
||||
}
|
||||
return alias + "." + name
|
||||
}
|
||||
requestedExpr := fmt.Sprintf("COALESCE(NULLIF(TRIM(%s), ''), %s)", column("requested_model"), column("model"))
|
||||
switch usagestats.NormalizeModelSource(modelType) {
|
||||
case usagestats.ModelSourceUpstream:
|
||||
return fmt.Sprintf("COALESCE(NULLIF(TRIM(%s), ''), %s)", column("upstream_model"), requestedExpr)
|
||||
case usagestats.ModelSourceMapping:
|
||||
return fmt.Sprintf("(%s || ' -> ' || COALESCE(NULLIF(TRIM(%s), ''), %s))", requestedExpr, column("upstream_model"), requestedExpr)
|
||||
default:
|
||||
return requestedExpr
|
||||
}
|
||||
}
|
||||
|
||||
func scanTrendRows(rows *sql.Rows) ([]TrendDataPoint, error) {
|
||||
results := make([]TrendDataPoint, 0)
|
||||
for rows.Next() {
|
||||
var row TrendDataPoint
|
||||
if err := rows.Scan(
|
||||
&row.Date,
|
||||
&row.Requests,
|
||||
&row.InputTokens,
|
||||
&row.OutputTokens,
|
||||
&row.CacheCreationTokens,
|
||||
&row.CacheReadTokens,
|
||||
&row.TotalTokens,
|
||||
&row.Cost,
|
||||
&row.ActualCost,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func scanModelStatsRows(rows *sql.Rows) ([]ModelStat, error) {
|
||||
results := make([]ModelStat, 0)
|
||||
for rows.Next() {
|
||||
var row ModelStat
|
||||
if err := rows.Scan(
|
||||
&row.Model,
|
||||
&row.Requests,
|
||||
&row.InputTokens,
|
||||
&row.OutputTokens,
|
||||
&row.CacheCreationTokens,
|
||||
&row.CacheReadTokens,
|
||||
&row.TotalTokens,
|
||||
&row.Cost,
|
||||
&row.ActualCost,
|
||||
&row.AccountCost,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,965 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
|
||||
)
|
||||
|
||||
// Group management implementations
|
||||
func (s *adminServiceImpl) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error) {
|
||||
params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder}
|
||||
groups, result, err := s.groupRepo.ListWithFilters(ctx, params, platform, status, search, isExclusive)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return groups, result.Total, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetAllGroups(ctx context.Context) ([]Group, error) {
|
||||
return s.groupRepo.ListActive(ctx)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetAllGroupsByPlatform(ctx context.Context, platform string) ([]Group, error) {
|
||||
return s.groupRepo.ListActiveByPlatform(ctx, platform)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetAllGroupsIncludingInactive(ctx context.Context) ([]Group, error) {
|
||||
// ListWithFilters with empty status = no status filter, so active + disabled groups are returned.
|
||||
// PageSize 10000 is intentionally large; group count is O(dozens) in practice.
|
||||
groups, _, err := s.groupRepo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 10000}, "", "", "", nil)
|
||||
return groups, err
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetGroup(ctx context.Context, id int64) (*Group, error) {
|
||||
return s.groupRepo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetGroupModelsListCandidates(ctx context.Context, id int64, platform string) ([]string, error) {
|
||||
platform = strings.TrimSpace(platform)
|
||||
if id > 0 {
|
||||
group, err := s.groupRepo.GetByIDLite(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if platform == "" {
|
||||
platform = group.Platform
|
||||
}
|
||||
}
|
||||
if platform == "" {
|
||||
platform = PlatformAnthropic
|
||||
}
|
||||
|
||||
candidates := defaultModelsListCandidateIDs(platform)
|
||||
if id <= 0 || s.accountRepo == nil {
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
for _, model := range candidates {
|
||||
seen[model] = struct{}{}
|
||||
}
|
||||
for _, acc := range accounts {
|
||||
if acc.Platform != platform {
|
||||
continue
|
||||
}
|
||||
for model := range acc.GetModelMapping() {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[model]; ok {
|
||||
continue
|
||||
}
|
||||
seen[model] = struct{}{}
|
||||
candidates = append(candidates, model)
|
||||
}
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func defaultModelsListCandidateIDs(platform string) []string {
|
||||
switch platform {
|
||||
case PlatformOpenAI:
|
||||
return openai.DefaultModelIDs()
|
||||
case PlatformGemini:
|
||||
ids := make([]string, 0, len(geminicli.DefaultModels))
|
||||
for _, model := range geminicli.DefaultModels {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
case PlatformAntigravity:
|
||||
models := antigravity.DefaultModels()
|
||||
ids := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
case PlatformGrok:
|
||||
return xai.DefaultModelIDs()
|
||||
default:
|
||||
ids := make([]string, 0, len(claude.DefaultModels))
|
||||
for _, model := range claude.DefaultModels {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
}
|
||||
|
||||
func defaultAllowImageGenerationForPlatform(platform string) bool {
|
||||
// Grok image and video generation routes share the legacy image-generation gate.
|
||||
// Older clients send the false zero value, so Grok groups must default enabled.
|
||||
return platform == PlatformGrok
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) {
|
||||
if input.RateMultiplier <= 0 {
|
||||
return nil, errors.New("rate_multiplier must be > 0")
|
||||
}
|
||||
|
||||
platform := input.Platform
|
||||
if platform == "" {
|
||||
platform = PlatformAnthropic
|
||||
}
|
||||
|
||||
subscriptionType := input.SubscriptionType
|
||||
if subscriptionType == "" {
|
||||
subscriptionType = SubscriptionTypeStandard
|
||||
}
|
||||
|
||||
// 限额字段:nil/负数 表示"无限制",0 表示"不允许用量",正数表示具体限额
|
||||
dailyLimit := normalizeLimit(input.DailyLimitUSD)
|
||||
weeklyLimit := normalizeLimit(input.WeeklyLimitUSD)
|
||||
monthlyLimit := normalizeLimit(input.MonthlyLimitUSD)
|
||||
|
||||
// 图片价格:负数表示清除(使用默认价格),0 保留(表示免费)
|
||||
imagePrice1K := normalizePrice(input.ImagePrice1K)
|
||||
imagePrice2K := normalizePrice(input.ImagePrice2K)
|
||||
imagePrice4K := normalizePrice(input.ImagePrice4K)
|
||||
imageRateMultiplier := 1.0
|
||||
if input.ImageRateMultiplier != nil {
|
||||
if *input.ImageRateMultiplier < 0 {
|
||||
return nil, errors.New("image_rate_multiplier must be >= 0")
|
||||
}
|
||||
imageRateMultiplier = *input.ImageRateMultiplier
|
||||
}
|
||||
batchImageDiscountMultiplier := defaultBatchImageDiscountMultiplier
|
||||
if input.BatchImageDiscountMultiplier != nil {
|
||||
if *input.BatchImageDiscountMultiplier < 0 {
|
||||
return nil, errors.New("batch_image_discount_multiplier must be >= 0")
|
||||
}
|
||||
batchImageDiscountMultiplier = *input.BatchImageDiscountMultiplier
|
||||
}
|
||||
batchImageHoldMultiplier := defaultBatchImageHoldMultiplier
|
||||
if input.BatchImageHoldMultiplier != nil {
|
||||
if *input.BatchImageHoldMultiplier < 0 {
|
||||
return nil, errors.New("batch_image_hold_multiplier must be >= 0")
|
||||
}
|
||||
batchImageHoldMultiplier = *input.BatchImageHoldMultiplier
|
||||
}
|
||||
// 不变式:hold 比例 >= discount 比例。否则批量任务成功率足够高时
|
||||
// 实际成本会超过冻结额,结算永远失败、用户冻结余额无法解冻。
|
||||
if batchImageHoldMultiplier < batchImageDiscountMultiplier {
|
||||
return nil, errors.New("batch_image_hold_multiplier must be >= batch_image_discount_multiplier")
|
||||
}
|
||||
|
||||
peakRateMultiplier := 1.0
|
||||
if input.PeakRateMultiplier != nil {
|
||||
peakRateMultiplier = *input.PeakRateMultiplier
|
||||
}
|
||||
// 先归一化(非订阅分组清空高峰配置、清洗停用状态下的脏字段)再校验,与 UpdateGroup 同一收口。
|
||||
peakRateEnabled, peakStart, peakEnd, peakRateMultiplier := NormalizePeakRateConfig(subscriptionType, input.PeakRateEnabled, input.PeakStart, input.PeakEnd, peakRateMultiplier)
|
||||
if err := ValidatePeakRateConfig(subscriptionType, peakRateEnabled, peakStart, peakEnd, peakRateMultiplier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 校验降级分组
|
||||
if input.FallbackGroupID != nil {
|
||||
if err := s.validateFallbackGroup(ctx, 0, *input.FallbackGroupID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
fallbackOnInvalidRequest := input.FallbackGroupIDOnInvalidRequest
|
||||
if fallbackOnInvalidRequest != nil && *fallbackOnInvalidRequest <= 0 {
|
||||
fallbackOnInvalidRequest = nil
|
||||
}
|
||||
// 校验无效请求兜底分组
|
||||
if fallbackOnInvalidRequest != nil {
|
||||
if err := s.validateFallbackGroupOnInvalidRequest(ctx, 0, platform, subscriptionType, *fallbackOnInvalidRequest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// MCPXMLInject:默认为 true,仅当显式传入 false 时关闭
|
||||
mcpXMLInject := true
|
||||
if input.MCPXMLInject != nil {
|
||||
mcpXMLInject = *input.MCPXMLInject
|
||||
}
|
||||
|
||||
allowImageGeneration := input.AllowImageGeneration || defaultAllowImageGenerationForPlatform(platform)
|
||||
allowBatchImageGeneration := input.AllowBatchImageGeneration && allowImageGeneration && platform == PlatformGemini
|
||||
|
||||
// 如果指定了复制账号的源分组,先获取账号 ID 列表
|
||||
var accountIDsToCopy []int64
|
||||
if len(input.CopyAccountsFromGroupIDs) > 0 {
|
||||
// 去重源分组 IDs
|
||||
seen := make(map[int64]struct{})
|
||||
uniqueSourceGroupIDs := make([]int64, 0, len(input.CopyAccountsFromGroupIDs))
|
||||
for _, srcGroupID := range input.CopyAccountsFromGroupIDs {
|
||||
if _, exists := seen[srcGroupID]; !exists {
|
||||
seen[srcGroupID] = struct{}{}
|
||||
uniqueSourceGroupIDs = append(uniqueSourceGroupIDs, srcGroupID)
|
||||
}
|
||||
}
|
||||
|
||||
// 校验源分组的平台是否与新分组一致
|
||||
for _, srcGroupID := range uniqueSourceGroupIDs {
|
||||
srcGroup, err := s.groupRepo.GetByIDLite(ctx, srcGroupID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("source group %d not found: %w", srcGroupID, err)
|
||||
}
|
||||
if srcGroup.Platform != platform {
|
||||
return nil, fmt.Errorf("source group %d platform mismatch: expected %s, got %s", srcGroupID, platform, srcGroup.Platform)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有源分组的账号(去重)
|
||||
var err error
|
||||
accountIDsToCopy, err = s.groupRepo.GetAccountIDsByGroupIDs(ctx, uniqueSourceGroupIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get accounts from source groups: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
group := &Group{
|
||||
Name: input.Name,
|
||||
Description: input.Description,
|
||||
Platform: platform,
|
||||
RateMultiplier: input.RateMultiplier,
|
||||
IsExclusive: input.IsExclusive,
|
||||
Status: StatusActive,
|
||||
SubscriptionType: subscriptionType,
|
||||
DailyLimitUSD: dailyLimit,
|
||||
WeeklyLimitUSD: weeklyLimit,
|
||||
MonthlyLimitUSD: monthlyLimit,
|
||||
AllowImageGeneration: allowImageGeneration,
|
||||
AllowBatchImageGeneration: allowBatchImageGeneration,
|
||||
ImageRateIndependent: input.ImageRateIndependent,
|
||||
ImageRateMultiplier: imageRateMultiplier,
|
||||
BatchImageDiscountMultiplier: batchImageDiscountMultiplier,
|
||||
BatchImageHoldMultiplier: batchImageHoldMultiplier,
|
||||
PeakRateEnabled: peakRateEnabled,
|
||||
PeakStart: peakStart,
|
||||
PeakEnd: peakEnd,
|
||||
PeakRateMultiplier: peakRateMultiplier,
|
||||
ImagePrice1K: imagePrice1K,
|
||||
ImagePrice2K: imagePrice2K,
|
||||
ImagePrice4K: imagePrice4K,
|
||||
ClaudeCodeOnly: input.ClaudeCodeOnly,
|
||||
FallbackGroupID: input.FallbackGroupID,
|
||||
FallbackGroupIDOnInvalidRequest: fallbackOnInvalidRequest,
|
||||
ModelRouting: input.ModelRouting,
|
||||
MCPXMLInject: mcpXMLInject,
|
||||
SupportedModelScopes: input.SupportedModelScopes,
|
||||
AllowMessagesDispatch: input.AllowMessagesDispatch,
|
||||
RequireOAuthOnly: input.RequireOAuthOnly,
|
||||
RequirePrivacySet: input.RequirePrivacySet,
|
||||
DefaultMappedModel: input.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: normalizeOpenAIMessagesDispatchModelConfig(input.MessagesDispatchModelConfig),
|
||||
ModelsListConfig: normalizeGroupModelsListConfig(input.ModelsListConfig),
|
||||
RPMLimit: input.RPMLimit,
|
||||
}
|
||||
sanitizeGroupMessagesDispatchFields(group)
|
||||
if err := s.groupRepo.Create(ctx, group); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// require_oauth_only: 过滤掉 apikey 类型账号
|
||||
if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini || group.Platform == PlatformGrok) && len(accountIDsToCopy) > 0 {
|
||||
accounts, err := s.accountRepo.GetByIDs(ctx, accountIDsToCopy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch accounts for oauth filter: %w", err)
|
||||
}
|
||||
oauthIDs := make(map[int64]struct{}, len(accounts))
|
||||
for _, acc := range accounts {
|
||||
if acc.Type != AccountTypeAPIKey {
|
||||
oauthIDs[acc.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
var filtered []int64
|
||||
for _, aid := range accountIDsToCopy {
|
||||
if _, ok := oauthIDs[aid]; ok {
|
||||
filtered = append(filtered, aid)
|
||||
}
|
||||
}
|
||||
accountIDsToCopy = filtered
|
||||
}
|
||||
|
||||
// 如果有需要复制的账号,绑定到新分组
|
||||
if len(accountIDsToCopy) > 0 {
|
||||
if err := s.groupRepo.BindAccountsToGroup(ctx, group.ID, accountIDsToCopy); err != nil {
|
||||
return nil, fmt.Errorf("failed to bind accounts to new group: %w", err)
|
||||
}
|
||||
group.AccountCount = int64(len(accountIDsToCopy))
|
||||
}
|
||||
|
||||
return group, nil
|
||||
}
|
||||
|
||||
// normalizeLimit 将负数转换为 nil(表示无限制),0 保留(表示限额为零)
|
||||
func normalizeLimit(limit *float64) *float64 {
|
||||
if limit == nil || *limit < 0 {
|
||||
return nil
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// normalizePrice 将负数转换为 nil(表示使用默认价格),0 保留(表示免费)
|
||||
func normalizePrice(price *float64) *float64 {
|
||||
if price == nil || *price < 0 {
|
||||
return nil
|
||||
}
|
||||
return price
|
||||
}
|
||||
|
||||
// validateFallbackGroup 校验降级分组的有效性
|
||||
// currentGroupID: 当前分组 ID(新建时为 0)
|
||||
// fallbackGroupID: 降级分组 ID
|
||||
func (s *adminServiceImpl) validateFallbackGroup(ctx context.Context, currentGroupID, fallbackGroupID int64) error {
|
||||
// 不能将自己设置为降级分组
|
||||
if currentGroupID > 0 && currentGroupID == fallbackGroupID {
|
||||
return fmt.Errorf("cannot set self as fallback group")
|
||||
}
|
||||
|
||||
visited := map[int64]struct{}{}
|
||||
nextID := fallbackGroupID
|
||||
for {
|
||||
if _, seen := visited[nextID]; seen {
|
||||
return fmt.Errorf("fallback group cycle detected")
|
||||
}
|
||||
visited[nextID] = struct{}{}
|
||||
if currentGroupID > 0 && nextID == currentGroupID {
|
||||
return fmt.Errorf("fallback group cycle detected")
|
||||
}
|
||||
|
||||
// 检查降级分组是否存在
|
||||
fallbackGroup, err := s.groupRepo.GetByIDLite(ctx, nextID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fallback group not found: %w", err)
|
||||
}
|
||||
|
||||
// 降级分组不能启用 claude_code_only,否则会造成死循环
|
||||
if nextID == fallbackGroupID && fallbackGroup.ClaudeCodeOnly {
|
||||
return fmt.Errorf("fallback group cannot have claude_code_only enabled")
|
||||
}
|
||||
|
||||
if fallbackGroup.FallbackGroupID == nil {
|
||||
return nil
|
||||
}
|
||||
nextID = *fallbackGroup.FallbackGroupID
|
||||
}
|
||||
}
|
||||
|
||||
// validateFallbackGroupOnInvalidRequest 校验无效请求兜底分组的有效性
|
||||
// currentGroupID: 当前分组 ID(新建时为 0)
|
||||
// platform/subscriptionType: 当前分组的有效平台/订阅类型
|
||||
// fallbackGroupID: 兜底分组 ID
|
||||
func (s *adminServiceImpl) validateFallbackGroupOnInvalidRequest(ctx context.Context, currentGroupID int64, platform, subscriptionType string, fallbackGroupID int64) error {
|
||||
if platform != PlatformAnthropic && platform != PlatformAntigravity {
|
||||
return fmt.Errorf("invalid request fallback only supported for anthropic or antigravity groups")
|
||||
}
|
||||
if subscriptionType == SubscriptionTypeSubscription {
|
||||
return fmt.Errorf("subscription groups cannot set invalid request fallback")
|
||||
}
|
||||
if currentGroupID > 0 && currentGroupID == fallbackGroupID {
|
||||
return fmt.Errorf("cannot set self as invalid request fallback group")
|
||||
}
|
||||
|
||||
fallbackGroup, err := s.groupRepo.GetByIDLite(ctx, fallbackGroupID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fallback group not found: %w", err)
|
||||
}
|
||||
if fallbackGroup.Platform != PlatformAnthropic {
|
||||
return fmt.Errorf("fallback group must be anthropic platform")
|
||||
}
|
||||
if fallbackGroup.SubscriptionType == SubscriptionTypeSubscription {
|
||||
return fmt.Errorf("fallback group cannot be subscription type")
|
||||
}
|
||||
if fallbackGroup.FallbackGroupIDOnInvalidRequest != nil {
|
||||
return fmt.Errorf("fallback group cannot have invalid request fallback configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error) {
|
||||
group, err := s.groupRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if input.Name != "" {
|
||||
group.Name = input.Name
|
||||
}
|
||||
if input.Description != nil {
|
||||
group.Description = *input.Description
|
||||
}
|
||||
if input.Platform != "" {
|
||||
group.Platform = input.Platform
|
||||
}
|
||||
if input.RateMultiplier != nil {
|
||||
if *input.RateMultiplier <= 0 {
|
||||
return nil, errors.New("rate_multiplier must be > 0")
|
||||
}
|
||||
group.RateMultiplier = *input.RateMultiplier
|
||||
}
|
||||
if input.IsExclusive != nil {
|
||||
group.IsExclusive = *input.IsExclusive
|
||||
}
|
||||
if input.Status != "" {
|
||||
group.Status = input.Status
|
||||
}
|
||||
|
||||
// 订阅相关字段
|
||||
if input.SubscriptionType != "" {
|
||||
group.SubscriptionType = input.SubscriptionType
|
||||
}
|
||||
// 限额字段:nil/负数 表示"无限制",0 表示"不允许用量",正数表示具体限额
|
||||
// 前端始终发送这三个字段,无需 nil 守卫
|
||||
group.DailyLimitUSD = normalizeLimit(input.DailyLimitUSD)
|
||||
group.WeeklyLimitUSD = normalizeLimit(input.WeeklyLimitUSD)
|
||||
group.MonthlyLimitUSD = normalizeLimit(input.MonthlyLimitUSD)
|
||||
// 图片生成计费配置:负数表示清除(使用默认价格)
|
||||
if input.AllowImageGeneration != nil {
|
||||
group.AllowImageGeneration = *input.AllowImageGeneration
|
||||
}
|
||||
if input.AllowBatchImageGeneration != nil {
|
||||
group.AllowBatchImageGeneration = *input.AllowBatchImageGeneration
|
||||
}
|
||||
if !group.AllowImageGeneration || group.Platform != PlatformGemini {
|
||||
group.AllowBatchImageGeneration = false
|
||||
}
|
||||
if input.ImageRateIndependent != nil {
|
||||
group.ImageRateIndependent = *input.ImageRateIndependent
|
||||
}
|
||||
if input.ImageRateMultiplier != nil {
|
||||
if *input.ImageRateMultiplier < 0 {
|
||||
return nil, errors.New("image_rate_multiplier must be >= 0")
|
||||
}
|
||||
group.ImageRateMultiplier = *input.ImageRateMultiplier
|
||||
}
|
||||
if input.BatchImageDiscountMultiplier != nil {
|
||||
if *input.BatchImageDiscountMultiplier < 0 {
|
||||
return nil, errors.New("batch_image_discount_multiplier must be >= 0")
|
||||
}
|
||||
group.BatchImageDiscountMultiplier = *input.BatchImageDiscountMultiplier
|
||||
}
|
||||
if input.BatchImageHoldMultiplier != nil {
|
||||
if *input.BatchImageHoldMultiplier < 0 {
|
||||
return nil, errors.New("batch_image_hold_multiplier must be >= 0")
|
||||
}
|
||||
group.BatchImageHoldMultiplier = *input.BatchImageHoldMultiplier
|
||||
}
|
||||
// 仅在本次更新显式触碰任一比例时校验合并后的不变式(hold >= discount),
|
||||
// 避免存量脏数据阻塞其他字段的正常更新(提交侧另有钳制兜底)。
|
||||
if (input.BatchImageDiscountMultiplier != nil || input.BatchImageHoldMultiplier != nil) &&
|
||||
group.BatchImageHoldMultiplier < group.BatchImageDiscountMultiplier {
|
||||
return nil, errors.New("batch_image_hold_multiplier must be >= batch_image_discount_multiplier")
|
||||
}
|
||||
if input.PeakRateEnabled != nil {
|
||||
group.PeakRateEnabled = *input.PeakRateEnabled
|
||||
}
|
||||
if input.PeakStart != nil {
|
||||
group.PeakStart = *input.PeakStart
|
||||
}
|
||||
if input.PeakEnd != nil {
|
||||
group.PeakEnd = *input.PeakEnd
|
||||
}
|
||||
if input.PeakRateMultiplier != nil {
|
||||
group.PeakRateMultiplier = *input.PeakRateMultiplier
|
||||
}
|
||||
// 先归一化(非订阅分组——含本次更新转为非订阅——静默清空高峰配置,清洗停用状态下的脏字段),
|
||||
// 再收敛校验:Update 可能只传部分 peak 字段,需对合并后的最终配置统一校验,
|
||||
// 防止单独修改 start/end 导致最终 start>=end 等非法配置入库。与 CreateGroup 同一收口。
|
||||
group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier = NormalizePeakRateConfig(group.SubscriptionType, group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier)
|
||||
if err := ValidatePeakRateConfig(group.SubscriptionType, group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.ImagePrice1K != nil {
|
||||
group.ImagePrice1K = normalizePrice(input.ImagePrice1K)
|
||||
}
|
||||
if input.ImagePrice2K != nil {
|
||||
group.ImagePrice2K = normalizePrice(input.ImagePrice2K)
|
||||
}
|
||||
if input.ImagePrice4K != nil {
|
||||
group.ImagePrice4K = normalizePrice(input.ImagePrice4K)
|
||||
}
|
||||
|
||||
// Claude Code 客户端限制
|
||||
if input.ClaudeCodeOnly != nil {
|
||||
group.ClaudeCodeOnly = *input.ClaudeCodeOnly
|
||||
}
|
||||
if input.FallbackGroupID != nil {
|
||||
// 校验降级分组
|
||||
if *input.FallbackGroupID > 0 {
|
||||
if err := s.validateFallbackGroup(ctx, id, *input.FallbackGroupID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
group.FallbackGroupID = input.FallbackGroupID
|
||||
} else {
|
||||
// 传入 0 或负数表示清除降级分组
|
||||
group.FallbackGroupID = nil
|
||||
}
|
||||
}
|
||||
fallbackOnInvalidRequest := group.FallbackGroupIDOnInvalidRequest
|
||||
if input.FallbackGroupIDOnInvalidRequest != nil {
|
||||
if *input.FallbackGroupIDOnInvalidRequest > 0 {
|
||||
fallbackOnInvalidRequest = input.FallbackGroupIDOnInvalidRequest
|
||||
} else {
|
||||
fallbackOnInvalidRequest = nil
|
||||
}
|
||||
}
|
||||
if fallbackOnInvalidRequest != nil {
|
||||
if err := s.validateFallbackGroupOnInvalidRequest(ctx, id, group.Platform, group.SubscriptionType, *fallbackOnInvalidRequest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
group.FallbackGroupIDOnInvalidRequest = fallbackOnInvalidRequest
|
||||
|
||||
// 模型路由配置
|
||||
if input.ModelRouting != nil {
|
||||
group.ModelRouting = input.ModelRouting
|
||||
}
|
||||
if input.ModelRoutingEnabled != nil {
|
||||
group.ModelRoutingEnabled = *input.ModelRoutingEnabled
|
||||
}
|
||||
if input.MCPXMLInject != nil {
|
||||
group.MCPXMLInject = *input.MCPXMLInject
|
||||
}
|
||||
|
||||
// 支持的模型系列(仅 antigravity 平台使用)
|
||||
if input.SupportedModelScopes != nil {
|
||||
group.SupportedModelScopes = *input.SupportedModelScopes
|
||||
}
|
||||
|
||||
// OpenAI Messages 调度配置
|
||||
if input.AllowMessagesDispatch != nil {
|
||||
group.AllowMessagesDispatch = *input.AllowMessagesDispatch
|
||||
}
|
||||
if input.RequireOAuthOnly != nil {
|
||||
group.RequireOAuthOnly = *input.RequireOAuthOnly
|
||||
}
|
||||
if input.RequirePrivacySet != nil {
|
||||
group.RequirePrivacySet = *input.RequirePrivacySet
|
||||
}
|
||||
if input.DefaultMappedModel != nil {
|
||||
group.DefaultMappedModel = *input.DefaultMappedModel
|
||||
}
|
||||
if input.MessagesDispatchModelConfig != nil {
|
||||
group.MessagesDispatchModelConfig = normalizeOpenAIMessagesDispatchModelConfig(*input.MessagesDispatchModelConfig)
|
||||
}
|
||||
if input.ModelsListConfig != nil {
|
||||
group.ModelsListConfig = normalizeGroupModelsListConfig(*input.ModelsListConfig)
|
||||
}
|
||||
if input.RPMLimit != nil {
|
||||
group.RPMLimit = *input.RPMLimit
|
||||
}
|
||||
sanitizeGroupMessagesDispatchFields(group)
|
||||
|
||||
if err := s.groupRepo.Update(ctx, group); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.authCacheInvalidator != nil {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, id)
|
||||
}
|
||||
|
||||
// 如果指定了复制账号的源分组,同步绑定(替换当前分组的账号)
|
||||
if len(input.CopyAccountsFromGroupIDs) > 0 {
|
||||
// 去重源分组 IDs
|
||||
seen := make(map[int64]struct{})
|
||||
uniqueSourceGroupIDs := make([]int64, 0, len(input.CopyAccountsFromGroupIDs))
|
||||
for _, srcGroupID := range input.CopyAccountsFromGroupIDs {
|
||||
// 校验:源分组不能是自身
|
||||
if srcGroupID == id {
|
||||
return nil, fmt.Errorf("cannot copy accounts from self")
|
||||
}
|
||||
// 去重
|
||||
if _, exists := seen[srcGroupID]; !exists {
|
||||
seen[srcGroupID] = struct{}{}
|
||||
uniqueSourceGroupIDs = append(uniqueSourceGroupIDs, srcGroupID)
|
||||
}
|
||||
}
|
||||
|
||||
// 校验源分组的平台是否与当前分组一致
|
||||
for _, srcGroupID := range uniqueSourceGroupIDs {
|
||||
srcGroup, err := s.groupRepo.GetByIDLite(ctx, srcGroupID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("source group %d not found: %w", srcGroupID, err)
|
||||
}
|
||||
if srcGroup.Platform != group.Platform {
|
||||
return nil, fmt.Errorf("source group %d platform mismatch: expected %s, got %s", srcGroupID, group.Platform, srcGroup.Platform)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有源分组的账号(去重)
|
||||
accountIDsToCopy, err := s.groupRepo.GetAccountIDsByGroupIDs(ctx, uniqueSourceGroupIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get accounts from source groups: %w", err)
|
||||
}
|
||||
|
||||
// 先清空当前分组的所有账号绑定
|
||||
if _, err := s.groupRepo.DeleteAccountGroupsByGroupID(ctx, id); err != nil {
|
||||
return nil, fmt.Errorf("failed to clear existing account bindings: %w", err)
|
||||
}
|
||||
|
||||
// require_oauth_only: 过滤掉 apikey 类型账号
|
||||
if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini || group.Platform == PlatformGrok) && len(accountIDsToCopy) > 0 {
|
||||
accounts, err := s.accountRepo.GetByIDs(ctx, accountIDsToCopy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch accounts for oauth filter: %w", err)
|
||||
}
|
||||
oauthIDs := make(map[int64]struct{}, len(accounts))
|
||||
for _, acc := range accounts {
|
||||
if acc.Type != AccountTypeAPIKey {
|
||||
oauthIDs[acc.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
var filtered []int64
|
||||
for _, aid := range accountIDsToCopy {
|
||||
if _, ok := oauthIDs[aid]; ok {
|
||||
filtered = append(filtered, aid)
|
||||
}
|
||||
}
|
||||
accountIDsToCopy = filtered
|
||||
}
|
||||
|
||||
// 再绑定源分组的账号
|
||||
if len(accountIDsToCopy) > 0 {
|
||||
if err := s.groupRepo.BindAccountsToGroup(ctx, id, accountIDsToCopy); err != nil {
|
||||
return nil, fmt.Errorf("failed to bind accounts to group: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return group, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) DeleteGroup(ctx context.Context, id int64) error {
|
||||
var groupKeys []string
|
||||
if s.authCacheInvalidator != nil {
|
||||
keys, err := s.apiKeyRepo.ListKeysByGroupID(ctx, id)
|
||||
if err == nil {
|
||||
groupKeys = keys
|
||||
}
|
||||
}
|
||||
|
||||
affectedUserIDs, err := s.groupRepo.DeleteCascade(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 注意:user_group_rate_multipliers 表通过外键 ON DELETE CASCADE 自动清理
|
||||
|
||||
// 事务成功后,异步失效受影响用户的订阅缓存
|
||||
if len(affectedUserIDs) > 0 && s.billingCacheService != nil {
|
||||
groupID := id
|
||||
go func() {
|
||||
cacheCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
for _, userID := range affectedUserIDs {
|
||||
if err := s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID); err != nil {
|
||||
logger.LegacyPrintf("service.admin", "invalidate subscription cache failed: user_id=%d group_id=%d err=%v", userID, groupID, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
if s.authCacheInvalidator != nil {
|
||||
for _, key := range groupKeys {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, key)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error) {
|
||||
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
||||
keys, result, err := s.apiKeyRepo.ListByGroupID(ctx, groupID, params)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return keys, result.Total, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error) {
|
||||
if s.userGroupRateRepo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.userGroupRateRepo.GetByGroupID(ctx, groupID)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) ClearGroupRateMultipliers(ctx context.Context, groupID int64) error {
|
||||
if s.userGroupRateRepo == nil {
|
||||
return nil
|
||||
}
|
||||
return s.userGroupRateRepo.DeleteByGroupID(ctx, groupID)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error {
|
||||
if s.userGroupRateRepo == nil {
|
||||
return nil
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.RateMultiplier <= 0 {
|
||||
return fmt.Errorf("rate_multiplier must be > 0 (user_id=%d)", e.UserID)
|
||||
}
|
||||
}
|
||||
return s.userGroupRateRepo.SyncGroupRateMultipliers(ctx, groupID, entries)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) ClearGroupRPMOverrides(ctx context.Context, groupID int64) error {
|
||||
if s.userGroupRateRepo == nil {
|
||||
return nil
|
||||
}
|
||||
if err := s.userGroupRateRepo.ClearGroupRPMOverrides(ctx, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
// RPM override 已嵌入 auth cache snapshot (v7),变更后必须失效相关缓存。
|
||||
if s.authCacheInvalidator != nil {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, groupID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) BatchSetGroupRPMOverrides(ctx context.Context, groupID int64, entries []GroupRPMOverrideInput) error {
|
||||
if s.userGroupRateRepo == nil {
|
||||
return nil
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.RPMOverride != nil && *e.RPMOverride < 0 {
|
||||
return infraerrors.BadRequest("INVALID_RPM_OVERRIDE", fmt.Sprintf("rpm_override must be >= 0 (user_id=%d)", e.UserID))
|
||||
}
|
||||
}
|
||||
if err := s.userGroupRateRepo.SyncGroupRPMOverrides(ctx, groupID, entries); err != nil {
|
||||
return err
|
||||
}
|
||||
// RPM override 已嵌入 auth cache snapshot (v7),变更后必须失效相关缓存。
|
||||
if s.authCacheInvalidator != nil {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, groupID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error {
|
||||
return s.groupRepo.UpdateSortOrders(ctx, updates)
|
||||
}
|
||||
|
||||
// AdminUpdateAPIKeyGroupID 管理员修改 API Key 分组绑定
|
||||
// groupID: nil=不修改, 指向0=解绑, 指向正整数=绑定到目标分组
|
||||
func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*AdminUpdateAPIKeyGroupIDResult, error) {
|
||||
apiKey, err := s.apiKeyRepo.GetByID(ctx, keyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if groupID == nil {
|
||||
// nil 表示不修改,直接返回
|
||||
return &AdminUpdateAPIKeyGroupIDResult{APIKey: apiKey}, nil
|
||||
}
|
||||
|
||||
if *groupID < 0 {
|
||||
return nil, infraerrors.BadRequest("INVALID_GROUP_ID", "group_id must be non-negative")
|
||||
}
|
||||
|
||||
result := &AdminUpdateAPIKeyGroupIDResult{}
|
||||
|
||||
if *groupID == 0 {
|
||||
// 0 表示解绑分组(不修改 user_allowed_groups,避免影响用户其他 Key)
|
||||
apiKey.GroupID = nil
|
||||
apiKey.Group = nil
|
||||
} else {
|
||||
// 验证目标分组存在且状态为 active
|
||||
group, err := s.groupRepo.GetByID(ctx, *groupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if group.Status != StatusActive {
|
||||
return nil, infraerrors.BadRequest("GROUP_NOT_ACTIVE", "target group is not active")
|
||||
}
|
||||
// 订阅类型分组:用户须持有该分组的有效订阅才可绑定
|
||||
if group.IsSubscriptionType() {
|
||||
if s.userSubRepo == nil {
|
||||
return nil, infraerrors.InternalServer("SUBSCRIPTION_REPOSITORY_UNAVAILABLE", "subscription repository is not configured")
|
||||
}
|
||||
if _, err := s.userSubRepo.GetActiveByUserIDAndGroupID(ctx, apiKey.UserID, *groupID); err != nil {
|
||||
if errors.Is(err, ErrSubscriptionNotFound) {
|
||||
return nil, infraerrors.BadRequest("SUBSCRIPTION_REQUIRED", "user does not have an active subscription for this group")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
gid := *groupID
|
||||
apiKey.GroupID = &gid
|
||||
apiKey.Group = group
|
||||
|
||||
// 专属标准分组:使用事务保证「添加分组权限」与「更新 API Key」的原子性
|
||||
if group.IsExclusive && !group.IsSubscriptionType() {
|
||||
opCtx := ctx
|
||||
var tx *dbent.Tx
|
||||
if s.entClient == nil {
|
||||
logger.LegacyPrintf("service.admin", "Warning: entClient is nil, skipping transaction protection for exclusive group binding")
|
||||
} else {
|
||||
var txErr error
|
||||
tx, txErr = s.entClient.Tx(ctx)
|
||||
if txErr != nil {
|
||||
return nil, fmt.Errorf("begin transaction: %w", txErr)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
opCtx = dbent.NewTxContext(ctx, tx)
|
||||
}
|
||||
|
||||
if addErr := s.userRepo.AddGroupToAllowedGroups(opCtx, apiKey.UserID, gid); addErr != nil {
|
||||
return nil, fmt.Errorf("add group to user allowed groups: %w", addErr)
|
||||
}
|
||||
if err := s.apiKeyRepo.Update(opCtx, apiKey); err != nil {
|
||||
return nil, fmt.Errorf("update api key: %w", err)
|
||||
}
|
||||
if tx != nil {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
result.AutoGrantedGroupAccess = true
|
||||
result.GrantedGroupID = &gid
|
||||
result.GrantedGroupName = group.Name
|
||||
|
||||
// 失效认证缓存(在事务提交后执行)
|
||||
if s.authCacheInvalidator != nil {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key)
|
||||
}
|
||||
|
||||
result.APIKey = apiKey
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 非专属分组 / 解绑:无需事务,单步更新即可
|
||||
if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil {
|
||||
return nil, fmt.Errorf("update api key: %w", err)
|
||||
}
|
||||
|
||||
// 失效认证缓存
|
||||
if s.authCacheInvalidator != nil {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key)
|
||||
}
|
||||
|
||||
result.APIKey = apiKey
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AdminResetAPIKeyRateLimitUsage resets all API key rate-limit usage windows.
|
||||
func (s *adminServiceImpl) AdminResetAPIKeyRateLimitUsage(ctx context.Context, keyID int64) (*APIKey, error) {
|
||||
apiKey, err := s.apiKeyRepo.GetByID(ctx, keyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiKey.Usage5h = 0
|
||||
apiKey.Usage1d = 0
|
||||
apiKey.Usage7d = 0
|
||||
apiKey.Window5hStart = nil
|
||||
apiKey.Window1dStart = nil
|
||||
apiKey.Window7dStart = nil
|
||||
if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil {
|
||||
return nil, fmt.Errorf("reset api key rate limit usage: %w", err)
|
||||
}
|
||||
if s.authCacheInvalidator != nil {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key)
|
||||
}
|
||||
if s.billingCacheService != nil {
|
||||
_ = s.billingCacheService.InvalidateAPIKeyRateLimit(ctx, apiKey.ID)
|
||||
}
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
// ReplaceUserGroup 替换用户的专属分组
|
||||
func (s *adminServiceImpl) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*ReplaceUserGroupResult, error) {
|
||||
if oldGroupID == newGroupID {
|
||||
return nil, infraerrors.BadRequest("SAME_GROUP", "old and new group must be different")
|
||||
}
|
||||
|
||||
// 验证新分组存在且为活跃的专属标准分组
|
||||
newGroup, err := s.groupRepo.GetByID(ctx, newGroupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if newGroup.Status != StatusActive {
|
||||
return nil, infraerrors.BadRequest("GROUP_NOT_ACTIVE", "target group is not active")
|
||||
}
|
||||
if !newGroup.IsExclusive {
|
||||
return nil, infraerrors.BadRequest("GROUP_NOT_EXCLUSIVE", "target group is not exclusive")
|
||||
}
|
||||
if newGroup.IsSubscriptionType() {
|
||||
return nil, infraerrors.BadRequest("GROUP_IS_SUBSCRIPTION", "subscription groups are not supported for replacement")
|
||||
}
|
||||
|
||||
// 事务保证原子性
|
||||
if s.entClient == nil {
|
||||
return nil, fmt.Errorf("entClient is nil, cannot perform group replacement")
|
||||
}
|
||||
tx, err := s.entClient.Tx(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
opCtx := dbent.NewTxContext(ctx, tx)
|
||||
|
||||
// 1. 授予新分组权限
|
||||
if err := s.userRepo.AddGroupToAllowedGroups(opCtx, userID, newGroupID); err != nil {
|
||||
return nil, fmt.Errorf("add new group to allowed groups: %w", err)
|
||||
}
|
||||
|
||||
// 2. 迁移绑定旧分组的 Key 到新分组
|
||||
migrated, err := s.apiKeyRepo.UpdateGroupIDByUserAndGroup(opCtx, userID, oldGroupID, newGroupID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migrate api keys: %w", err)
|
||||
}
|
||||
|
||||
// 3. 移除旧分组权限
|
||||
if err := s.userRepo.RemoveGroupFromUserAllowedGroups(opCtx, userID, oldGroupID); err != nil {
|
||||
return nil, fmt.Errorf("remove old group from allowed groups: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
|
||||
// 失效该用户所有 Key 的认证缓存
|
||||
if s.authCacheInvalidator != nil {
|
||||
keys, keyErr := s.apiKeyRepo.ListKeysByUserID(ctx, userID)
|
||||
if keyErr == nil {
|
||||
for _, k := range keys {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &ReplaceUserGroupResult{MigratedKeys: migrated}, nil
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/httputil"
|
||||
)
|
||||
|
||||
// Proxy management implementations
|
||||
func (s *adminServiceImpl) ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]Proxy, int64, error) {
|
||||
params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder}
|
||||
proxies, result, err := s.proxyRepo.ListWithFilters(ctx, params, protocol, status, search)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return proxies, result.Total, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) ListProxiesWithAccountCount(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]ProxyWithAccountCount, int64, error) {
|
||||
params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder}
|
||||
proxies, result, err := s.proxyRepo.ListWithFiltersAndAccountCount(ctx, params, protocol, status, search)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
s.attachProxyLatency(ctx, proxies)
|
||||
return proxies, result.Total, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetAllProxies(ctx context.Context) ([]Proxy, error) {
|
||||
return s.proxyRepo.ListActive(ctx)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetAllProxiesWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error) {
|
||||
proxies, err := s.proxyRepo.ListActiveWithAccountCount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.attachProxyLatency(ctx, proxies)
|
||||
return proxies, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetProxy(ctx context.Context, id int64) (*Proxy, error) {
|
||||
return s.proxyRepo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetProxiesByIDs(ctx context.Context, ids []int64) ([]Proxy, error) {
|
||||
return s.proxyRepo.ListByIDs(ctx, ids)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) CreateProxy(ctx context.Context, input *CreateProxyInput) (*Proxy, error) {
|
||||
// 规范化 fallback_mode
|
||||
mode := input.FallbackMode
|
||||
if mode == "" {
|
||||
mode = FallbackModeNone
|
||||
}
|
||||
// 校验:mode=proxy 必须有 backup
|
||||
if mode == FallbackModeProxy && input.BackupProxyID == nil {
|
||||
return nil, infraerrors.BadRequest("PROXY_BACKUP_REQUIRED", "backup proxy required when fallback_mode=proxy")
|
||||
}
|
||||
if input.ExpiryWarnDays < 0 {
|
||||
return nil, infraerrors.BadRequest("PROXY_WARN_DAYS_INVALID", "expiry_warn_days must be >= 0")
|
||||
}
|
||||
|
||||
proxy := &Proxy{
|
||||
Name: input.Name,
|
||||
Protocol: input.Protocol,
|
||||
Host: input.Host,
|
||||
Port: input.Port,
|
||||
Username: input.Username,
|
||||
Password: input.Password,
|
||||
Status: StatusActive,
|
||||
ExpiresAt: input.ExpiresAt,
|
||||
FallbackMode: mode,
|
||||
BackupProxyID: input.BackupProxyID,
|
||||
ExpiryWarnDays: input.ExpiryWarnDays,
|
||||
}
|
||||
if err := s.proxyRepo.Create(ctx, proxy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Probe latency asynchronously so creation isn't blocked by network timeout.
|
||||
go s.probeProxyLatency(context.Background(), proxy)
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) UpdateProxy(ctx context.Context, id int64, input *UpdateProxyInput) (*Proxy, error) {
|
||||
// 校验:backup_proxy_id 不能是自身
|
||||
if input.BackupProxyID != nil && *input.BackupProxyID == id {
|
||||
return nil, infraerrors.BadRequest("PROXY_BACKUP_SELF", "backup proxy cannot be itself")
|
||||
}
|
||||
// 规范化 fallback_mode
|
||||
mode := input.FallbackMode
|
||||
if mode == "" {
|
||||
mode = FallbackModeNone
|
||||
}
|
||||
// 校验:mode=proxy 必须有 backup
|
||||
if mode == FallbackModeProxy && input.BackupProxyID == nil {
|
||||
return nil, infraerrors.BadRequest("PROXY_BACKUP_REQUIRED", "backup proxy required when fallback_mode=proxy")
|
||||
}
|
||||
if input.ExpiryWarnDays < 0 {
|
||||
return nil, infraerrors.BadRequest("PROXY_WARN_DAYS_INVALID", "expiry_warn_days must be >= 0")
|
||||
}
|
||||
|
||||
proxy, err := s.proxyRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if input.Name != "" {
|
||||
proxy.Name = input.Name
|
||||
}
|
||||
if input.Protocol != "" {
|
||||
proxy.Protocol = input.Protocol
|
||||
}
|
||||
if input.Host != "" {
|
||||
proxy.Host = input.Host
|
||||
}
|
||||
if input.Port != 0 {
|
||||
proxy.Port = input.Port
|
||||
}
|
||||
if input.Username != "" {
|
||||
proxy.Username = input.Username
|
||||
}
|
||||
if input.Password != "" {
|
||||
proxy.Password = input.Password
|
||||
}
|
||||
if input.Status != "" {
|
||||
proxy.Status = input.Status
|
||||
}
|
||||
// 透传有效期与回退字段
|
||||
proxy.ExpiresAt = input.ExpiresAt
|
||||
proxy.FallbackMode = mode
|
||||
proxy.BackupProxyID = input.BackupProxyID
|
||||
proxy.ExpiryWarnDays = input.ExpiryWarnDays
|
||||
|
||||
if err := s.proxyRepo.Update(ctx, proxy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) DeleteProxy(ctx context.Context, id int64) error {
|
||||
count, err := s.proxyRepo.CountAccountsByProxyID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return ErrProxyInUse
|
||||
}
|
||||
return s.proxyRepo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) BatchDeleteProxies(ctx context.Context, ids []int64) (*ProxyBatchDeleteResult, error) {
|
||||
result := &ProxyBatchDeleteResult{}
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
count, err := s.proxyRepo.CountAccountsByProxyID(ctx, id)
|
||||
if err != nil {
|
||||
result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{
|
||||
ID: id,
|
||||
Reason: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if count > 0 {
|
||||
result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{
|
||||
ID: id,
|
||||
Reason: ErrProxyInUse.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if err := s.proxyRepo.Delete(ctx, id); err != nil {
|
||||
result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{
|
||||
ID: id,
|
||||
Reason: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
result.DeletedIDs = append(result.DeletedIDs, id)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetProxyAccounts(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error) {
|
||||
return s.proxyRepo.ListAccountSummariesByProxyID(ctx, proxyID)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) CheckProxyExists(ctx context.Context, host string, port int, username, password string) (bool, error) {
|
||||
return s.proxyRepo.ExistsByHostPortAuth(ctx, host, port, username, password)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) TestProxy(ctx context.Context, id int64) (*ProxyTestResult, error) {
|
||||
proxy, err := s.proxyRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxyURL := proxy.URL()
|
||||
exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxyURL)
|
||||
if err != nil {
|
||||
s.saveProxyLatency(ctx, id, &ProxyLatencyInfo{
|
||||
Success: false,
|
||||
Message: err.Error(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
return &ProxyTestResult{
|
||||
Success: false,
|
||||
Message: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
latency := latencyMs
|
||||
s.saveProxyLatency(ctx, id, &ProxyLatencyInfo{
|
||||
Success: true,
|
||||
LatencyMs: &latency,
|
||||
Message: "Proxy is accessible",
|
||||
IPAddress: exitInfo.IP,
|
||||
Country: exitInfo.Country,
|
||||
CountryCode: exitInfo.CountryCode,
|
||||
Region: exitInfo.Region,
|
||||
City: exitInfo.City,
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
return &ProxyTestResult{
|
||||
Success: true,
|
||||
Message: "Proxy is accessible",
|
||||
LatencyMs: latencyMs,
|
||||
IPAddress: exitInfo.IP,
|
||||
City: exitInfo.City,
|
||||
Region: exitInfo.Region,
|
||||
Country: exitInfo.Country,
|
||||
CountryCode: exitInfo.CountryCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) CheckProxyQuality(ctx context.Context, id int64) (*ProxyQualityCheckResult, error) {
|
||||
proxy, err := s.proxyRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &ProxyQualityCheckResult{
|
||||
ProxyID: id,
|
||||
Score: 100,
|
||||
Grade: "A",
|
||||
CheckedAt: time.Now().Unix(),
|
||||
Items: make([]ProxyQualityCheckItem, 0, len(proxyQualityTargets)+1),
|
||||
}
|
||||
|
||||
proxyURL := proxy.URL()
|
||||
if s.proxyProber == nil {
|
||||
result.Items = append(result.Items, ProxyQualityCheckItem{
|
||||
Target: "base_connectivity",
|
||||
Status: "fail",
|
||||
Message: "代理探测服务未配置",
|
||||
})
|
||||
result.FailedCount++
|
||||
finalizeProxyQualityResult(result)
|
||||
s.saveProxyQualitySnapshot(ctx, id, result, nil)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxyURL)
|
||||
if err != nil {
|
||||
result.Items = append(result.Items, ProxyQualityCheckItem{
|
||||
Target: "base_connectivity",
|
||||
Status: "fail",
|
||||
LatencyMs: latencyMs,
|
||||
Message: err.Error(),
|
||||
})
|
||||
result.FailedCount++
|
||||
finalizeProxyQualityResult(result)
|
||||
s.saveProxyQualitySnapshot(ctx, id, result, nil)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.ExitIP = exitInfo.IP
|
||||
result.Country = exitInfo.Country
|
||||
result.CountryCode = exitInfo.CountryCode
|
||||
result.BaseLatencyMs = latencyMs
|
||||
result.Items = append(result.Items, ProxyQualityCheckItem{
|
||||
Target: "base_connectivity",
|
||||
Status: "pass",
|
||||
LatencyMs: latencyMs,
|
||||
Message: "代理出口连通正常",
|
||||
})
|
||||
result.PassedCount++
|
||||
|
||||
client, err := httpclient.GetClient(httpclient.Options{
|
||||
ProxyURL: proxyURL,
|
||||
Timeout: proxyQualityRequestTimeout,
|
||||
ResponseHeaderTimeout: proxyQualityResponseHeaderTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
result.Items = append(result.Items, ProxyQualityCheckItem{
|
||||
Target: "http_client",
|
||||
Status: "fail",
|
||||
Message: fmt.Sprintf("创建检测客户端失败: %v", err),
|
||||
})
|
||||
result.FailedCount++
|
||||
finalizeProxyQualityResult(result)
|
||||
s.saveProxyQualitySnapshot(ctx, id, result, exitInfo)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
for _, target := range proxyQualityTargets {
|
||||
item := runProxyQualityTarget(ctx, client, target)
|
||||
result.Items = append(result.Items, item)
|
||||
switch item.Status {
|
||||
case "pass":
|
||||
result.PassedCount++
|
||||
case "warn":
|
||||
result.WarnCount++
|
||||
case "challenge":
|
||||
result.ChallengeCount++
|
||||
default:
|
||||
result.FailedCount++
|
||||
}
|
||||
}
|
||||
|
||||
finalizeProxyQualityResult(result)
|
||||
s.saveProxyQualitySnapshot(ctx, id, result, exitInfo)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func runProxyQualityTarget(ctx context.Context, client *http.Client, target proxyQualityTarget) ProxyQualityCheckItem {
|
||||
item := ProxyQualityCheckItem{
|
||||
Target: target.Target,
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, target.Method, target.URL, nil)
|
||||
if err != nil {
|
||||
item.Status = "fail"
|
||||
item.Message = fmt.Sprintf("构建请求失败: %v", err)
|
||||
return item
|
||||
}
|
||||
req.Header.Set("Accept", "application/json,text/html,*/*")
|
||||
req.Header.Set("User-Agent", proxyQualityClientUserAgent)
|
||||
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
item.Status = "fail"
|
||||
item.LatencyMs = time.Since(start).Milliseconds()
|
||||
item.Message = fmt.Sprintf("请求失败: %v", err)
|
||||
return item
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
item.LatencyMs = time.Since(start).Milliseconds()
|
||||
item.HTTPStatus = resp.StatusCode
|
||||
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, proxyQualityMaxBodyBytes+1))
|
||||
if readErr != nil {
|
||||
item.Status = "fail"
|
||||
item.Message = fmt.Sprintf("读取响应失败: %v", readErr)
|
||||
return item
|
||||
}
|
||||
if int64(len(body)) > proxyQualityMaxBodyBytes {
|
||||
body = body[:proxyQualityMaxBodyBytes]
|
||||
}
|
||||
|
||||
// Cloudflare challenge 检测
|
||||
if httputil.IsCloudflareChallengeResponse(resp.StatusCode, resp.Header, body) {
|
||||
item.Status = "challenge"
|
||||
item.CFRay = httputil.ExtractCloudflareRayID(resp.Header, body)
|
||||
item.Message = "命中 Cloudflare challenge"
|
||||
return item
|
||||
}
|
||||
|
||||
if _, ok := target.AllowedStatuses[resp.StatusCode]; ok {
|
||||
// 白名单内的状态码均代表目标可达:2xx 表示接口直接可用,
|
||||
// 401/405 等是无鉴权探测的预期结果,同样视为连通正常,不再扣分。
|
||||
item.Status = "pass"
|
||||
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
|
||||
item.Message = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
} else {
|
||||
item.Message = fmt.Sprintf("HTTP %d(目标可达)", resp.StatusCode)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
item.Status = "warn"
|
||||
item.Message = "目标返回 429,可能存在频控"
|
||||
return item
|
||||
}
|
||||
|
||||
item.Status = "fail"
|
||||
item.Message = fmt.Sprintf("非预期状态码: %d", resp.StatusCode)
|
||||
return item
|
||||
}
|
||||
|
||||
func finalizeProxyQualityResult(result *ProxyQualityCheckResult) {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
score := 100 - result.WarnCount*10 - result.FailedCount*22 - result.ChallengeCount*30
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
result.Score = score
|
||||
result.Grade = proxyQualityGrade(score)
|
||||
result.Summary = fmt.Sprintf(
|
||||
"通过 %d 项,告警 %d 项,失败 %d 项,挑战 %d 项",
|
||||
result.PassedCount,
|
||||
result.WarnCount,
|
||||
result.FailedCount,
|
||||
result.ChallengeCount,
|
||||
)
|
||||
}
|
||||
|
||||
func proxyQualityGrade(score int) string {
|
||||
switch {
|
||||
case score >= 90:
|
||||
return "A"
|
||||
case score >= 75:
|
||||
return "B"
|
||||
case score >= 60:
|
||||
return "C"
|
||||
case score >= 40:
|
||||
return "D"
|
||||
default:
|
||||
return "F"
|
||||
}
|
||||
}
|
||||
|
||||
func proxyQualityOverallStatus(result *ProxyQualityCheckResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
if result.ChallengeCount > 0 {
|
||||
return "challenge"
|
||||
}
|
||||
if result.FailedCount > 0 {
|
||||
return "failed"
|
||||
}
|
||||
if result.WarnCount > 0 {
|
||||
return "warn"
|
||||
}
|
||||
if result.PassedCount > 0 {
|
||||
return "healthy"
|
||||
}
|
||||
return "failed"
|
||||
}
|
||||
|
||||
func proxyQualityFirstCFRay(result *ProxyQualityCheckResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
for _, item := range result.Items {
|
||||
if item.CFRay != "" {
|
||||
return item.CFRay
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func proxyQualityBaseConnectivityPass(result *ProxyQualityCheckResult) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
for _, item := range result.Items {
|
||||
if item.Target == "base_connectivity" {
|
||||
return item.Status == "pass"
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) saveProxyQualitySnapshot(ctx context.Context, proxyID int64, result *ProxyQualityCheckResult, exitInfo *ProxyExitInfo) {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
score := result.Score
|
||||
checkedAt := result.CheckedAt
|
||||
info := &ProxyLatencyInfo{
|
||||
Success: proxyQualityBaseConnectivityPass(result),
|
||||
Message: result.Summary,
|
||||
QualityStatus: proxyQualityOverallStatus(result),
|
||||
QualityScore: &score,
|
||||
QualityGrade: result.Grade,
|
||||
QualitySummary: result.Summary,
|
||||
QualityCheckedAt: &checkedAt,
|
||||
QualityCFRay: proxyQualityFirstCFRay(result),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if result.BaseLatencyMs > 0 {
|
||||
latency := result.BaseLatencyMs
|
||||
info.LatencyMs = &latency
|
||||
}
|
||||
if exitInfo != nil {
|
||||
info.IPAddress = exitInfo.IP
|
||||
info.Country = exitInfo.Country
|
||||
info.CountryCode = exitInfo.CountryCode
|
||||
info.Region = exitInfo.Region
|
||||
info.City = exitInfo.City
|
||||
}
|
||||
s.saveProxyLatency(ctx, proxyID, info)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) probeProxyLatency(ctx context.Context, proxy *Proxy) {
|
||||
if s.proxyProber == nil || proxy == nil {
|
||||
return
|
||||
}
|
||||
exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxy.URL())
|
||||
if err != nil {
|
||||
s.saveProxyLatency(ctx, proxy.ID, &ProxyLatencyInfo{
|
||||
Success: false,
|
||||
Message: err.Error(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
latency := latencyMs
|
||||
s.saveProxyLatency(ctx, proxy.ID, &ProxyLatencyInfo{
|
||||
Success: true,
|
||||
LatencyMs: &latency,
|
||||
Message: "Proxy is accessible",
|
||||
IPAddress: exitInfo.IP,
|
||||
Country: exitInfo.Country,
|
||||
CountryCode: exitInfo.CountryCode,
|
||||
Region: exitInfo.Region,
|
||||
City: exitInfo.City,
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) attachProxyLatency(ctx context.Context, proxies []ProxyWithAccountCount) {
|
||||
if s.proxyLatencyCache == nil || len(proxies) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(proxies))
|
||||
for i := range proxies {
|
||||
ids = append(ids, proxies[i].ID)
|
||||
}
|
||||
|
||||
latencies, err := s.proxyLatencyCache.GetProxyLatencies(ctx, ids)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.admin", "Warning: load proxy latency cache failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for i := range proxies {
|
||||
info := latencies[proxies[i].ID]
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
if info.Success {
|
||||
proxies[i].LatencyStatus = "success"
|
||||
proxies[i].LatencyMs = info.LatencyMs
|
||||
} else {
|
||||
proxies[i].LatencyStatus = "failed"
|
||||
}
|
||||
proxies[i].LatencyMessage = info.Message
|
||||
proxies[i].IPAddress = info.IPAddress
|
||||
proxies[i].Country = info.Country
|
||||
proxies[i].CountryCode = info.CountryCode
|
||||
proxies[i].Region = info.Region
|
||||
proxies[i].City = info.City
|
||||
proxies[i].QualityStatus = info.QualityStatus
|
||||
proxies[i].QualityScore = info.QualityScore
|
||||
proxies[i].QualityGrade = info.QualityGrade
|
||||
proxies[i].QualitySummary = info.QualitySummary
|
||||
proxies[i].QualityChecked = info.QualityCheckedAt
|
||||
}
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) saveProxyLatency(ctx context.Context, proxyID int64, info *ProxyLatencyInfo) {
|
||||
if s.proxyLatencyCache == nil || info == nil {
|
||||
return
|
||||
}
|
||||
|
||||
merged := *info
|
||||
if latencies, err := s.proxyLatencyCache.GetProxyLatencies(ctx, []int64{proxyID}); err == nil {
|
||||
if existing := latencies[proxyID]; existing != nil {
|
||||
if merged.QualityCheckedAt == nil &&
|
||||
merged.QualityScore == nil &&
|
||||
merged.QualityGrade == "" &&
|
||||
merged.QualityStatus == "" &&
|
||||
merged.QualitySummary == "" &&
|
||||
merged.QualityCFRay == "" {
|
||||
merged.QualityStatus = existing.QualityStatus
|
||||
merged.QualityScore = existing.QualityScore
|
||||
merged.QualityGrade = existing.QualityGrade
|
||||
merged.QualitySummary = existing.QualitySummary
|
||||
merged.QualityCheckedAt = existing.QualityCheckedAt
|
||||
merged.QualityCFRay = existing.QualityCFRay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.proxyLatencyCache.SetProxyLatency(ctx, proxyID, &merged); err != nil {
|
||||
logger.LegacyPrintf("service.admin", "Warning: store proxy latency cache failed: %v", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,754 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Forward 转发 Claude 协议请求(Claude → Gemini 转换)
|
||||
//
|
||||
// 限流处理流程:
|
||||
//
|
||||
// 请求 → antigravityRetryLoop → 预检查(remaining>0? → 切换账号) → 发送上游
|
||||
// ├─ 成功 → 正常返回
|
||||
// └─ 429/503 → handleSmartRetry
|
||||
// ├─ retryDelay >= 7s → 设置模型限流 + 清除粘性绑定 → 切换账号
|
||||
// └─ retryDelay < 7s → 等待后重试 1 次
|
||||
// ├─ 成功 → 正常返回
|
||||
// └─ 失败 → 设置模型限流 + 清除粘性绑定 → 切换账号
|
||||
func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte, isStickySession bool) (*ForwardResult, error) {
|
||||
// 上游透传账号直接转发,不走 OAuth token 刷新
|
||||
if account.Type == AccountTypeUpstream {
|
||||
return s.ForwardUpstream(ctx, c, account, body)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
sessionID := getSessionID(c)
|
||||
prefix := logPrefix(sessionID, account.Name)
|
||||
|
||||
// 解析 Claude 请求
|
||||
var claudeReq antigravity.ClaudeRequest
|
||||
if err := json.Unmarshal(body, &claudeReq); err != nil {
|
||||
return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Invalid request body")
|
||||
}
|
||||
if strings.TrimSpace(claudeReq.Model) == "" {
|
||||
return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Missing model")
|
||||
}
|
||||
|
||||
originalModel := claudeReq.Model
|
||||
mappedModel := s.getMappedModel(account, claudeReq.Model)
|
||||
if mappedModel == "" {
|
||||
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
|
||||
return nil, s.writeClaudeError(c, http.StatusForbidden, "permission_error", fmt.Sprintf("model %s not in whitelist", claudeReq.Model))
|
||||
}
|
||||
// 应用 thinking 模式自动后缀:如果 thinking 开启且目标是 claude-sonnet-4-5,自动改为 thinking 版本
|
||||
thinkingEnabled := claudeReq.Thinking != nil && (claudeReq.Thinking.Type == "enabled" || claudeReq.Thinking.Type == "adaptive")
|
||||
mappedModel = applyThinkingModelSuffix(mappedModel, thinkingEnabled)
|
||||
billingModel := mappedModel
|
||||
|
||||
// 获取 access_token
|
||||
if s.tokenProvider == nil {
|
||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "api_error", "Antigravity token provider not configured")
|
||||
}
|
||||
accessToken, err := s.tokenProvider.GetAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
ResponseBody: []byte(`{"error":{"type":"authentication_error","message":"Failed to get upstream access token"},"type":"error"}`),
|
||||
}
|
||||
}
|
||||
|
||||
projectID, err := resolveAntigravityProjectID(account)
|
||||
if err != nil {
|
||||
_ = s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 代理 URL
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
// 获取转换选项
|
||||
// Antigravity 上游要求必须包含身份提示词,否则会返回 429
|
||||
transformOpts := s.getClaudeTransformOptions(ctx)
|
||||
transformOpts.EnableIdentityPatch = true // 强制启用,Antigravity 上游必需
|
||||
|
||||
// 转换 Claude 请求为 Gemini 格式
|
||||
geminiBody, err := antigravity.TransformClaudeToGeminiWithOptions(&claudeReq, projectID, mappedModel, transformOpts)
|
||||
if err != nil {
|
||||
return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", "Invalid request")
|
||||
}
|
||||
|
||||
// Antigravity 上游只支持流式请求,统一使用 streamGenerateContent
|
||||
// 如果客户端请求非流式,在响应处理阶段会收集完整流式响应后转换返回
|
||||
action := "streamGenerateContent"
|
||||
|
||||
// 执行带重试的请求
|
||||
result, err := s.antigravityRetryLoop(antigravityRetryLoopParams{
|
||||
ctx: ctx,
|
||||
prefix: prefix,
|
||||
account: account,
|
||||
proxyURL: proxyURL,
|
||||
accessToken: accessToken,
|
||||
action: action,
|
||||
body: geminiBody,
|
||||
c: c,
|
||||
httpUpstream: s.httpUpstream,
|
||||
settingService: s.settingService,
|
||||
accountRepo: s.accountRepo,
|
||||
handleError: s.handleUpstreamError,
|
||||
requestedModel: originalModel,
|
||||
isStickySession: isStickySession, // Forward 由上层判断粘性会话
|
||||
groupID: 0, // Forward 方法没有 groupID,由上层处理粘性会话清除
|
||||
sessionHash: "", // Forward 方法没有 sessionHash,由上层处理粘性会话清除
|
||||
})
|
||||
if err != nil {
|
||||
// 检查是否是账号切换信号,转换为 UpstreamFailoverError 让 Handler 切换账号
|
||||
if switchErr, ok := IsAntigravityAccountSwitchError(err); ok {
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
ForceCacheBilling: switchErr.IsStickySession,
|
||||
}
|
||||
}
|
||||
// 区分客户端取消和真正的上游失败,返回更准确的错误消息
|
||||
if c.Request.Context().Err() != nil {
|
||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "client_disconnected", "Client disconnected before upstream response")
|
||||
}
|
||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed after retries")
|
||||
}
|
||||
resp := result.resp
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
respBody := s.readUpstreamErrorBody(resp)
|
||||
|
||||
// 优先检测 thinking block 的 signature 相关错误(400)并重试一次:
|
||||
// Antigravity /v1internal 链路在部分场景会对 thought/thinking signature 做严格校验,
|
||||
// 当历史消息携带的 signature 不合法时会直接 400;去除 thinking 后可继续完成请求。
|
||||
if resp.StatusCode == http.StatusBadRequest && isSignatureRelatedError(respBody) && s.settingService.IsSignatureRectifierEnabled(ctx) {
|
||||
upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody))
|
||||
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
||||
logBody, maxBytes := s.getLogConfig()
|
||||
upstreamDetail := s.getUpstreamErrorDetail(respBody)
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
Kind: "signature_error",
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
|
||||
// Conservative two-stage fallback:
|
||||
// 1) Disable top-level thinking + thinking->text
|
||||
// 2) Only if still signature-related 400: also downgrade tool_use/tool_result to text.
|
||||
|
||||
retryStages := []struct {
|
||||
name string
|
||||
strip func(*antigravity.ClaudeRequest) (bool, error)
|
||||
}{
|
||||
{name: "thinking-only", strip: stripThinkingFromClaudeRequest},
|
||||
{name: "thinking+tools", strip: stripSignatureSensitiveBlocksFromClaudeRequest},
|
||||
}
|
||||
|
||||
for _, stage := range retryStages {
|
||||
retryClaudeReq := claudeReq
|
||||
retryClaudeReq.Messages = append([]antigravity.ClaudeMessage(nil), claudeReq.Messages...)
|
||||
|
||||
stripped, stripErr := stage.strip(&retryClaudeReq)
|
||||
if stripErr != nil || !stripped {
|
||||
continue
|
||||
}
|
||||
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: detected signature-related 400, retrying once (%s)", account.ID, stage.name)
|
||||
|
||||
retryGeminiBody, txErr := antigravity.TransformClaudeToGeminiWithOptions(&retryClaudeReq, projectID, mappedModel, s.getClaudeTransformOptions(ctx))
|
||||
if txErr != nil {
|
||||
continue
|
||||
}
|
||||
retryResult, retryErr := s.antigravityRetryLoop(antigravityRetryLoopParams{
|
||||
ctx: ctx,
|
||||
prefix: prefix,
|
||||
account: account,
|
||||
proxyURL: proxyURL,
|
||||
accessToken: accessToken,
|
||||
action: action,
|
||||
body: retryGeminiBody,
|
||||
c: c,
|
||||
httpUpstream: s.httpUpstream,
|
||||
settingService: s.settingService,
|
||||
accountRepo: s.accountRepo,
|
||||
handleError: s.handleUpstreamError,
|
||||
requestedModel: originalModel,
|
||||
isStickySession: isStickySession,
|
||||
groupID: 0, // Forward 方法没有 groupID,由上层处理粘性会话清除
|
||||
sessionHash: "", // Forward 方法没有 sessionHash,由上层处理粘性会话清除
|
||||
})
|
||||
if retryErr != nil {
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: 0,
|
||||
Kind: "signature_retry_request_error",
|
||||
Message: sanitizeUpstreamErrorMessage(retryErr.Error()),
|
||||
})
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: signature retry request failed (%s): %v", account.ID, stage.name, retryErr)
|
||||
continue
|
||||
}
|
||||
|
||||
retryResp := retryResult.resp
|
||||
if retryResp.StatusCode < 400 {
|
||||
_ = resp.Body.Close()
|
||||
resp = retryResp
|
||||
respBody = nil
|
||||
break
|
||||
}
|
||||
|
||||
retryBody, _ := io.ReadAll(io.LimitReader(retryResp.Body, 8<<10))
|
||||
_ = retryResp.Body.Close()
|
||||
if retryResp.StatusCode == http.StatusTooManyRequests {
|
||||
retryBaseURL := ""
|
||||
if retryResp.Request != nil && retryResp.Request.URL != nil {
|
||||
retryBaseURL = retryResp.Request.URL.Scheme + "://" + retryResp.Request.URL.Host
|
||||
}
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=429 rate_limited base_url=%s retry_stage=%s body=%s", prefix, retryBaseURL, stage.name, truncateForLog(retryBody, 200))
|
||||
}
|
||||
kind := "signature_retry"
|
||||
if strings.TrimSpace(stage.name) != "" {
|
||||
kind = "signature_retry_" + strings.ReplaceAll(stage.name, "+", "_")
|
||||
}
|
||||
retryUpstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(retryBody))
|
||||
retryUpstreamMsg = sanitizeUpstreamErrorMessage(retryUpstreamMsg)
|
||||
retryUpstreamDetail := ""
|
||||
if logBody {
|
||||
retryUpstreamDetail = truncateString(string(retryBody), maxBytes)
|
||||
}
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: retryResp.StatusCode,
|
||||
UpstreamRequestID: retryResp.Header.Get("x-request-id"),
|
||||
Kind: kind,
|
||||
Message: retryUpstreamMsg,
|
||||
Detail: retryUpstreamDetail,
|
||||
})
|
||||
|
||||
// If this stage fixed the signature issue, we stop; otherwise we may try the next stage.
|
||||
if retryResp.StatusCode != http.StatusBadRequest || !isSignatureRelatedError(retryBody) {
|
||||
respBody = retryBody
|
||||
resp = &http.Response{
|
||||
StatusCode: retryResp.StatusCode,
|
||||
Header: retryResp.Header.Clone(),
|
||||
Body: io.NopCloser(bytes.NewReader(retryBody)),
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Still signature-related; capture context and allow next stage.
|
||||
respBody = retryBody
|
||||
resp = &http.Response{
|
||||
StatusCode: retryResp.StatusCode,
|
||||
Header: retryResp.Header.Clone(),
|
||||
Body: io.NopCloser(bytes.NewReader(retryBody)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Budget 整流:检测 budget_tokens 约束错误并自动修正重试
|
||||
if resp.StatusCode == http.StatusBadRequest && respBody != nil && !isSignatureRelatedError(respBody) {
|
||||
errMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody))
|
||||
if isThinkingBudgetConstraintError(errMsg) && s.settingService.IsBudgetRectifierEnabled(ctx) {
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
Kind: "budget_constraint_error",
|
||||
Message: errMsg,
|
||||
Detail: s.getUpstreamErrorDetail(respBody),
|
||||
})
|
||||
|
||||
// 修正 claudeReq 的 thinking 参数(adaptive 模式不修正)
|
||||
if claudeReq.Thinking == nil || claudeReq.Thinking.Type != "adaptive" {
|
||||
retryClaudeReq := claudeReq
|
||||
retryClaudeReq.Messages = append([]antigravity.ClaudeMessage(nil), claudeReq.Messages...)
|
||||
// 创建新的 ThinkingConfig 避免修改原始 claudeReq.Thinking 指针
|
||||
retryClaudeReq.Thinking = &antigravity.ThinkingConfig{
|
||||
Type: "enabled",
|
||||
BudgetTokens: BudgetRectifyBudgetTokens,
|
||||
}
|
||||
if retryClaudeReq.MaxTokens < BudgetRectifyMinMaxTokens {
|
||||
retryClaudeReq.MaxTokens = BudgetRectifyMaxTokens
|
||||
}
|
||||
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: detected budget_tokens constraint error, retrying with rectified budget (budget_tokens=%d, max_tokens=%d)", account.ID, BudgetRectifyBudgetTokens, BudgetRectifyMaxTokens)
|
||||
|
||||
retryGeminiBody, txErr := antigravity.TransformClaudeToGeminiWithOptions(&retryClaudeReq, projectID, mappedModel, transformOpts)
|
||||
if txErr == nil {
|
||||
retryResult, retryErr := s.antigravityRetryLoop(antigravityRetryLoopParams{
|
||||
ctx: ctx,
|
||||
prefix: prefix,
|
||||
account: account,
|
||||
proxyURL: proxyURL,
|
||||
accessToken: accessToken,
|
||||
action: action,
|
||||
body: retryGeminiBody,
|
||||
c: c,
|
||||
httpUpstream: s.httpUpstream,
|
||||
settingService: s.settingService,
|
||||
accountRepo: s.accountRepo,
|
||||
handleError: s.handleUpstreamError,
|
||||
requestedModel: originalModel,
|
||||
isStickySession: isStickySession,
|
||||
groupID: 0,
|
||||
sessionHash: "",
|
||||
})
|
||||
if retryErr == nil {
|
||||
retryResp := retryResult.resp
|
||||
if retryResp.StatusCode < 400 {
|
||||
_ = resp.Body.Close()
|
||||
resp = retryResp
|
||||
respBody = nil
|
||||
} else {
|
||||
retryBody := s.readUpstreamErrorBody(retryResp)
|
||||
_ = retryResp.Body.Close()
|
||||
respBody = retryBody
|
||||
resp = &http.Response{
|
||||
StatusCode: retryResp.StatusCode,
|
||||
Header: retryResp.Header.Clone(),
|
||||
Body: io.NopCloser(bytes.NewReader(retryBody)),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Antigravity account %d: budget rectifier retry failed: %v", account.ID, retryErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理错误响应(重试后仍失败或不触发重试)
|
||||
if resp.StatusCode >= 400 {
|
||||
// 检测 prompt too long 错误,返回特殊错误类型供上层 fallback
|
||||
if resp.StatusCode == http.StatusBadRequest && isPromptTooLongError(respBody) {
|
||||
upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody))
|
||||
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
||||
upstreamDetail := s.getUpstreamErrorDetail(respBody)
|
||||
logBody, maxBytes := s.getLogConfig()
|
||||
if logBody {
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=400 prompt_too_long=true upstream_message=%q request_id=%s body=%s", prefix, upstreamMsg, resp.Header.Get("x-request-id"), truncateForLog(respBody, maxBytes))
|
||||
}
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
Kind: "prompt_too_long",
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
return nil, &PromptTooLongError{
|
||||
StatusCode: resp.StatusCode,
|
||||
RequestID: resp.Header.Get("x-request-id"),
|
||||
Body: respBody,
|
||||
}
|
||||
}
|
||||
|
||||
s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, originalModel, 0, "", isStickySession)
|
||||
|
||||
// 精确匹配服务端配置类 400 错误,触发同账号重试 + failover
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
msg := strings.ToLower(strings.TrimSpace(extractAntigravityErrorMessage(respBody)))
|
||||
if isGoogleProjectConfigError(msg) {
|
||||
upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractAntigravityErrorMessage(respBody)))
|
||||
upstreamDetail := s.getUpstreamErrorDetail(respBody)
|
||||
log.Printf("%s status=400 google_config_error failover=true upstream_message=%q account=%d", prefix, upstreamMsg, account.ID)
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
Kind: "failover",
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: true}
|
||||
}
|
||||
}
|
||||
|
||||
if s.shouldFailoverUpstreamError(resp.StatusCode) {
|
||||
upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(respBody))
|
||||
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
||||
upstreamDetail := s.getUpstreamErrorDetail(respBody)
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
Kind: "failover",
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody}
|
||||
}
|
||||
|
||||
return nil, s.writeMappedClaudeError(c, account, resp.StatusCode, resp.Header.Get("x-request-id"), respBody)
|
||||
}
|
||||
}
|
||||
|
||||
requestID := resp.Header.Get("x-request-id")
|
||||
if requestID != "" {
|
||||
c.Header("x-request-id", requestID)
|
||||
}
|
||||
|
||||
var usage *ClaudeUsage
|
||||
var firstTokenMs *int
|
||||
var clientDisconnect bool
|
||||
if claudeReq.Stream {
|
||||
// 客户端要求流式,直接透传转换
|
||||
streamRes, err := s.handleClaudeStreamingResponse(c, resp, startTime, originalModel)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_error error=%v", prefix, err)
|
||||
return nil, err
|
||||
}
|
||||
usage = streamRes.usage
|
||||
firstTokenMs = streamRes.firstTokenMs
|
||||
clientDisconnect = streamRes.clientDisconnect
|
||||
} else {
|
||||
// 客户端要求非流式,收集流式响应后转换返回
|
||||
streamRes, err := s.handleClaudeStreamToNonStreaming(c, resp, startTime, originalModel)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_collect_error error=%v", prefix, err)
|
||||
return nil, err
|
||||
}
|
||||
usage = streamRes.usage
|
||||
firstTokenMs = streamRes.firstTokenMs
|
||||
}
|
||||
|
||||
return &ForwardResult{
|
||||
RequestID: requestID,
|
||||
Usage: *usage,
|
||||
Model: originalModel,
|
||||
UpstreamModel: billingModel,
|
||||
Stream: claudeReq.Stream,
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
ClientDisconnect: clientDisconnect,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isSignatureRelatedError(respBody []byte) bool {
|
||||
msg := strings.ToLower(strings.TrimSpace(extractAntigravityErrorMessage(respBody)))
|
||||
if msg == "" {
|
||||
// Fallback: best-effort scan of the raw payload.
|
||||
msg = strings.ToLower(string(respBody))
|
||||
}
|
||||
|
||||
// Keep this intentionally broad: different upstreams may use "signature" or "thought_signature".
|
||||
if strings.Contains(msg, "thought_signature") || strings.Contains(msg, "signature") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Also detect thinking block structural errors:
|
||||
// "Expected `thinking` or `redacted_thinking`, but found `text`"
|
||||
if strings.Contains(msg, "expected") && (strings.Contains(msg, "thinking") || strings.Contains(msg, "redacted_thinking")) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isPromptTooLongError 检测是否为 prompt too long 错误
|
||||
func isPromptTooLongError(respBody []byte) bool {
|
||||
msg := strings.ToLower(strings.TrimSpace(extractAntigravityErrorMessage(respBody)))
|
||||
if msg == "" {
|
||||
msg = strings.ToLower(string(respBody))
|
||||
}
|
||||
return strings.Contains(msg, "prompt is too long") ||
|
||||
strings.Contains(msg, "request is too long") ||
|
||||
strings.Contains(msg, "context length exceeded") ||
|
||||
strings.Contains(msg, "max_tokens")
|
||||
}
|
||||
|
||||
// isPassthroughErrorMessage 检查错误消息是否在透传白名单中
|
||||
func isPassthroughErrorMessage(msg string) bool {
|
||||
lower := strings.ToLower(msg)
|
||||
for _, pattern := range antigravityPassthroughErrorMessages {
|
||||
if strings.Contains(lower, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getPassthroughOrDefault 若消息在白名单内则返回原始消息,否则返回默认消息
|
||||
func getPassthroughOrDefault(upstreamMsg, defaultMsg string) string {
|
||||
if isPassthroughErrorMessage(upstreamMsg) {
|
||||
return upstreamMsg
|
||||
}
|
||||
return defaultMsg
|
||||
}
|
||||
|
||||
func extractAntigravityErrorMessage(body []byte) string {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Google-style: {"error": {"message": "..."}}
|
||||
if errObj, ok := payload["error"].(map[string]any); ok {
|
||||
if msg, ok := errObj["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: top-level message
|
||||
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||
return msg
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// stripThinkingFromClaudeRequest converts thinking blocks to text blocks in a Claude Messages request.
|
||||
// This preserves the thinking content while avoiding signature validation errors.
|
||||
// Note: redacted_thinking blocks are removed because they cannot be converted to text.
|
||||
// It also disables top-level `thinking` to avoid upstream structural constraints for thinking mode.
|
||||
func stripThinkingFromClaudeRequest(req *antigravity.ClaudeRequest) (bool, error) {
|
||||
if req == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
changed := false
|
||||
if req.Thinking != nil {
|
||||
req.Thinking = nil
|
||||
changed = true
|
||||
}
|
||||
|
||||
for i := range req.Messages {
|
||||
raw := req.Messages[i].Content
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// If content is a string, nothing to strip.
|
||||
var str string
|
||||
if json.Unmarshal(raw, &str) == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Otherwise treat as an array of blocks and convert thinking blocks to text.
|
||||
var blocks []map[string]any
|
||||
if err := json.Unmarshal(raw, &blocks); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
filtered := make([]map[string]any, 0, len(blocks))
|
||||
modifiedAny := false
|
||||
for _, block := range blocks {
|
||||
t, _ := block["type"].(string)
|
||||
switch t {
|
||||
case "thinking":
|
||||
thinkingText, _ := block["thinking"].(string)
|
||||
if thinkingText != "" {
|
||||
filtered = append(filtered, map[string]any{
|
||||
"type": "text",
|
||||
"text": thinkingText,
|
||||
})
|
||||
}
|
||||
modifiedAny = true
|
||||
case "redacted_thinking":
|
||||
modifiedAny = true
|
||||
case "":
|
||||
if thinkingText, hasThinking := block["thinking"].(string); hasThinking {
|
||||
if thinkingText != "" {
|
||||
filtered = append(filtered, map[string]any{
|
||||
"type": "text",
|
||||
"text": thinkingText,
|
||||
})
|
||||
}
|
||||
modifiedAny = true
|
||||
} else {
|
||||
filtered = append(filtered, block)
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, block)
|
||||
}
|
||||
}
|
||||
|
||||
if !modifiedAny {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
filtered = append(filtered, map[string]any{
|
||||
"type": "text",
|
||||
"text": "(content removed)",
|
||||
})
|
||||
}
|
||||
|
||||
newRaw, err := json.Marshal(filtered)
|
||||
if err != nil {
|
||||
return changed, err
|
||||
}
|
||||
req.Messages[i].Content = newRaw
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// stripSignatureSensitiveBlocksFromClaudeRequest is a stronger retry degradation that additionally converts
|
||||
// tool blocks to plain text. Use this only after a thinking-only retry still fails with signature errors.
|
||||
func stripSignatureSensitiveBlocksFromClaudeRequest(req *antigravity.ClaudeRequest) (bool, error) {
|
||||
if req == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
changed := false
|
||||
if req.Thinking != nil {
|
||||
req.Thinking = nil
|
||||
changed = true
|
||||
}
|
||||
|
||||
for i := range req.Messages {
|
||||
raw := req.Messages[i].Content
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// If content is a string, nothing to strip.
|
||||
var str string
|
||||
if json.Unmarshal(raw, &str) == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Otherwise treat as an array of blocks and convert signature-sensitive blocks to text.
|
||||
var blocks []map[string]any
|
||||
if err := json.Unmarshal(raw, &blocks); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
filtered := make([]map[string]any, 0, len(blocks))
|
||||
modifiedAny := false
|
||||
for _, block := range blocks {
|
||||
t, _ := block["type"].(string)
|
||||
switch t {
|
||||
case "thinking":
|
||||
// Convert thinking to text, skip if empty
|
||||
thinkingText, _ := block["thinking"].(string)
|
||||
if thinkingText != "" {
|
||||
filtered = append(filtered, map[string]any{
|
||||
"type": "text",
|
||||
"text": thinkingText,
|
||||
})
|
||||
}
|
||||
modifiedAny = true
|
||||
case "redacted_thinking":
|
||||
// Remove redacted_thinking (cannot convert encrypted content)
|
||||
modifiedAny = true
|
||||
case "tool_use":
|
||||
// Convert tool_use to text to avoid upstream signature/thought_signature validation errors.
|
||||
// This is a retry-only degradation path, so we prioritise request validity over tool semantics.
|
||||
name, _ := block["name"].(string)
|
||||
id, _ := block["id"].(string)
|
||||
input := block["input"]
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
text := "(tool_use)"
|
||||
if name != "" {
|
||||
text += " name=" + name
|
||||
}
|
||||
if id != "" {
|
||||
text += " id=" + id
|
||||
}
|
||||
if len(inputJSON) > 0 && string(inputJSON) != "null" {
|
||||
text += " input=" + string(inputJSON)
|
||||
}
|
||||
filtered = append(filtered, map[string]any{
|
||||
"type": "text",
|
||||
"text": text,
|
||||
})
|
||||
modifiedAny = true
|
||||
case "tool_result":
|
||||
// Convert tool_result to text so it stays consistent when tool_use is downgraded.
|
||||
toolUseID, _ := block["tool_use_id"].(string)
|
||||
isError, _ := block["is_error"].(bool)
|
||||
content := block["content"]
|
||||
contentJSON, _ := json.Marshal(content)
|
||||
text := "(tool_result)"
|
||||
if toolUseID != "" {
|
||||
text += " tool_use_id=" + toolUseID
|
||||
}
|
||||
if isError {
|
||||
text += " is_error=true"
|
||||
}
|
||||
if len(contentJSON) > 0 && string(contentJSON) != "null" {
|
||||
text += "\n" + string(contentJSON)
|
||||
}
|
||||
filtered = append(filtered, map[string]any{
|
||||
"type": "text",
|
||||
"text": text,
|
||||
})
|
||||
modifiedAny = true
|
||||
case "":
|
||||
// Handle untyped block with "thinking" field
|
||||
if thinkingText, hasThinking := block["thinking"].(string); hasThinking {
|
||||
if thinkingText != "" {
|
||||
filtered = append(filtered, map[string]any{
|
||||
"type": "text",
|
||||
"text": thinkingText,
|
||||
})
|
||||
}
|
||||
modifiedAny = true
|
||||
} else {
|
||||
filtered = append(filtered, block)
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, block)
|
||||
}
|
||||
}
|
||||
|
||||
if !modifiedAny {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
// Keep request valid: upstream rejects empty content arrays.
|
||||
filtered = append(filtered, map[string]any{
|
||||
"type": "text",
|
||||
"text": "(content removed)",
|
||||
})
|
||||
}
|
||||
|
||||
newRaw, err := json.Marshal(filtered)
|
||||
if err != nil {
|
||||
return changed, err
|
||||
}
|
||||
req.Messages[i].Content = newRaw
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed, nil
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ForwardGemini 转发 Gemini 协议请求
|
||||
//
|
||||
// 限流处理流程:
|
||||
//
|
||||
// 请求 → antigravityRetryLoop → 预检查(remaining>0? → 切换账号) → 发送上游
|
||||
// ├─ 成功 → 正常返回
|
||||
// └─ 429/503 → handleSmartRetry
|
||||
// ├─ retryDelay >= 7s → 设置模型限流 + 清除粘性绑定 → 切换账号
|
||||
// └─ retryDelay < 7s → 等待后重试 1 次
|
||||
// ├─ 成功 → 正常返回
|
||||
// └─ 失败 → 设置模型限流 + 清除粘性绑定 → 切换账号
|
||||
type ForwardGeminiOption func(*forwardGeminiOptions)
|
||||
|
||||
type forwardGeminiOptions struct {
|
||||
groupID int64
|
||||
sessionHash string
|
||||
}
|
||||
|
||||
func WithForwardGeminiSession(groupID int64, sessionHash string) ForwardGeminiOption {
|
||||
return func(opts *forwardGeminiOptions) {
|
||||
opts.groupID = groupID
|
||||
opts.sessionHash = sessionHash
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Context, account *Account, originalModel string, action string, stream bool, body []byte, isStickySession bool, options ...ForwardGeminiOption) (*ForwardResult, error) {
|
||||
startTime := time.Now()
|
||||
forwardOpts := forwardGeminiOptions{}
|
||||
for _, apply := range options {
|
||||
if apply != nil {
|
||||
apply(&forwardOpts)
|
||||
}
|
||||
}
|
||||
|
||||
sessionID := getSessionID(c)
|
||||
prefix := logPrefix(sessionID, account.Name)
|
||||
|
||||
if strings.TrimSpace(originalModel) == "" {
|
||||
return nil, s.writeGoogleError(c, http.StatusBadRequest, "Missing model in URL")
|
||||
}
|
||||
if strings.TrimSpace(action) == "" {
|
||||
return nil, s.writeGoogleError(c, http.StatusBadRequest, "Missing action in URL")
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil, s.writeGoogleError(c, http.StatusBadRequest, "Request body is empty")
|
||||
}
|
||||
|
||||
// 解析请求以获取 image_size(用于图片计费)
|
||||
imageInputSize := s.extractImageInputSize(body)
|
||||
imageSize := normalizeOpenAIImageSizeTier(imageInputSize)
|
||||
|
||||
switch action {
|
||||
case "generateContent", "streamGenerateContent":
|
||||
// ok
|
||||
case "countTokens":
|
||||
// 直接返回空值,不透传上游
|
||||
c.JSON(http.StatusOK, map[string]any{"totalTokens": 0})
|
||||
return &ForwardResult{
|
||||
RequestID: "",
|
||||
Usage: ClaudeUsage{},
|
||||
Model: originalModel,
|
||||
Stream: false,
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: nil,
|
||||
}, nil
|
||||
default:
|
||||
return nil, s.writeGoogleError(c, http.StatusNotFound, "Unsupported action: "+action)
|
||||
}
|
||||
|
||||
mappedModel := s.getMappedModel(account, originalModel)
|
||||
if mappedModel == "" {
|
||||
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
|
||||
return nil, s.writeGoogleError(c, http.StatusForbidden, fmt.Sprintf("model %s not in whitelist", originalModel))
|
||||
}
|
||||
billingModel := mappedModel
|
||||
|
||||
// 获取 access_token
|
||||
if s.tokenProvider == nil {
|
||||
return nil, s.writeGoogleError(c, http.StatusBadGateway, "Antigravity token provider not configured")
|
||||
}
|
||||
accessToken, err := s.tokenProvider.GetAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
ResponseBody: []byte(`{"error":{"message":"Failed to get upstream access token","status":"UNAVAILABLE"}}`),
|
||||
}
|
||||
}
|
||||
|
||||
projectID, err := resolveAntigravityProjectID(account)
|
||||
if err != nil {
|
||||
_ = s.writeGoogleError(c, http.StatusBadRequest, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 代理 URL
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
// Antigravity 上游要求必须包含身份提示词,注入到请求中
|
||||
injectedBody, err := injectIdentityPatchToGeminiRequest(body)
|
||||
if err != nil {
|
||||
return nil, s.writeGoogleError(c, http.StatusBadRequest, "Invalid request body")
|
||||
}
|
||||
|
||||
// 清理 Schema
|
||||
if cleanedBody, err := cleanGeminiRequest(injectedBody); err == nil {
|
||||
injectedBody = cleanedBody
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Cleaned request schema in forwarded request for account %s", account.Name)
|
||||
} else {
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Failed to clean schema: %v", err)
|
||||
}
|
||||
|
||||
// 包装请求
|
||||
wrappedBody, err := s.wrapV1InternalRequest(projectID, mappedModel, injectedBody)
|
||||
if err != nil {
|
||||
return nil, s.writeGoogleError(c, http.StatusInternalServerError, "Failed to build upstream request")
|
||||
}
|
||||
|
||||
// Antigravity 上游只支持流式请求,统一使用 streamGenerateContent
|
||||
// 如果客户端请求非流式,在响应处理阶段会收集完整流式响应后返回
|
||||
upstreamAction := "streamGenerateContent"
|
||||
|
||||
// 执行带重试的请求
|
||||
result, err := s.antigravityRetryLoop(antigravityRetryLoopParams{
|
||||
ctx: ctx,
|
||||
prefix: prefix,
|
||||
account: account,
|
||||
proxyURL: proxyURL,
|
||||
accessToken: accessToken,
|
||||
action: upstreamAction,
|
||||
body: wrappedBody,
|
||||
c: c,
|
||||
httpUpstream: s.httpUpstream,
|
||||
settingService: s.settingService,
|
||||
accountRepo: s.accountRepo,
|
||||
handleError: s.handleUpstreamError,
|
||||
requestedModel: originalModel,
|
||||
isStickySession: isStickySession, // ForwardGemini 由上层判断粘性会话
|
||||
groupID: forwardOpts.groupID,
|
||||
sessionHash: forwardOpts.sessionHash,
|
||||
})
|
||||
if err != nil {
|
||||
// 检查是否是账号切换信号,转换为 UpstreamFailoverError 让 Handler 切换账号
|
||||
if switchErr, ok := IsAntigravityAccountSwitchError(err); ok {
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
ForceCacheBilling: switchErr.IsStickySession,
|
||||
}
|
||||
}
|
||||
// 区分客户端取消和真正的上游失败,返回更准确的错误消息
|
||||
if c.Request.Context().Err() != nil {
|
||||
return nil, s.writeGoogleError(c, http.StatusBadGateway, "Client disconnected before upstream response")
|
||||
}
|
||||
return nil, s.writeGoogleError(c, http.StatusBadGateway, "Upstream request failed after retries")
|
||||
}
|
||||
resp := result.resp
|
||||
defer func() {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// 处理错误响应
|
||||
if resp.StatusCode >= 400 {
|
||||
respBody := s.readUpstreamErrorBody(resp)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
// 尽早关闭原始响应体,释放连接;后续逻辑仍可能需要读取 body,因此用内存副本重新包装。
|
||||
_ = resp.Body.Close()
|
||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
||||
|
||||
// 模型兜底:模型不存在且开启 fallback 时,自动用 fallback 模型重试一次
|
||||
if s.settingService != nil && s.settingService.IsModelFallbackEnabled(ctx) &&
|
||||
isModelNotFoundError(resp.StatusCode, respBody) {
|
||||
fallbackModel := s.settingService.GetFallbackModel(ctx, PlatformAntigravity)
|
||||
if fallbackModel != "" && fallbackModel != mappedModel {
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[Antigravity] Model not found (%s), retrying with fallback model %s (account: %s)", mappedModel, fallbackModel, account.Name)
|
||||
|
||||
fallbackWrapped, err := s.wrapV1InternalRequest(projectID, fallbackModel, injectedBody)
|
||||
if err == nil {
|
||||
fallbackReq, err := antigravity.NewAPIRequest(ctx, upstreamAction, accessToken, fallbackWrapped)
|
||||
if err == nil {
|
||||
fallbackResp, err := s.httpUpstream.Do(fallbackReq, proxyURL, account.ID, account.Concurrency)
|
||||
if err == nil && fallbackResp.StatusCode < 400 {
|
||||
_ = resp.Body.Close()
|
||||
resp = fallbackResp
|
||||
} else if fallbackResp != nil {
|
||||
_ = fallbackResp.Body.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini 原生请求中的 thoughtSignature 可能来自旧上下文/旧账号,触发上游严格校验后返回
|
||||
// "Corrupted thought signature."。检测到此类 400 时,将 thoughtSignature 清理为 dummy 值后重试一次。
|
||||
signatureCheckBody := respBody
|
||||
if unwrapped, unwrapErr := s.unwrapV1InternalResponse(respBody); unwrapErr == nil && len(unwrapped) > 0 {
|
||||
signatureCheckBody = unwrapped
|
||||
}
|
||||
if resp.StatusCode == http.StatusBadRequest &&
|
||||
s.settingService != nil &&
|
||||
s.settingService.IsSignatureRectifierEnabled(ctx) &&
|
||||
isSignatureRelatedError(signatureCheckBody) &&
|
||||
bytes.Contains(injectedBody, []byte(`"thoughtSignature"`)) {
|
||||
upstreamMsg := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractAntigravityErrorMessage(signatureCheckBody)))
|
||||
upstreamDetail := s.getUpstreamErrorDetail(signatureCheckBody)
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
Kind: "signature_error",
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Antigravity Gemini account %d: detected signature-related 400, retrying with cleaned thought signatures", account.ID)
|
||||
|
||||
cleanedInjectedBody := CleanGeminiNativeThoughtSignatures(injectedBody)
|
||||
retryWrappedBody, wrapErr := s.wrapV1InternalRequest(projectID, mappedModel, cleanedInjectedBody)
|
||||
if wrapErr == nil {
|
||||
retryResult, retryErr := s.antigravityRetryLoop(antigravityRetryLoopParams{
|
||||
ctx: ctx,
|
||||
prefix: prefix,
|
||||
account: account,
|
||||
proxyURL: proxyURL,
|
||||
accessToken: accessToken,
|
||||
action: upstreamAction,
|
||||
body: retryWrappedBody,
|
||||
c: c,
|
||||
httpUpstream: s.httpUpstream,
|
||||
settingService: s.settingService,
|
||||
accountRepo: s.accountRepo,
|
||||
handleError: s.handleUpstreamError,
|
||||
requestedModel: originalModel,
|
||||
isStickySession: isStickySession,
|
||||
groupID: forwardOpts.groupID,
|
||||
sessionHash: forwardOpts.sessionHash,
|
||||
})
|
||||
if retryErr == nil {
|
||||
retryResp := retryResult.resp
|
||||
if retryResp.StatusCode < 400 {
|
||||
resp = retryResp
|
||||
} else {
|
||||
retryRespBody := s.readUpstreamErrorBody(retryResp)
|
||||
_ = retryResp.Body.Close()
|
||||
retryOpsBody := retryRespBody
|
||||
if retryUnwrapped, unwrapErr := s.unwrapV1InternalResponse(retryRespBody); unwrapErr == nil && len(retryUnwrapped) > 0 {
|
||||
retryOpsBody = retryUnwrapped
|
||||
}
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: retryResp.StatusCode,
|
||||
UpstreamRequestID: retryResp.Header.Get("x-request-id"),
|
||||
Kind: "signature_retry",
|
||||
Message: sanitizeUpstreamErrorMessage(strings.TrimSpace(extractAntigravityErrorMessage(retryOpsBody))),
|
||||
Detail: s.getUpstreamErrorDetail(retryOpsBody),
|
||||
})
|
||||
respBody = retryRespBody
|
||||
resp = &http.Response{
|
||||
StatusCode: retryResp.StatusCode,
|
||||
Header: retryResp.Header.Clone(),
|
||||
Body: io.NopCloser(bytes.NewReader(retryRespBody)),
|
||||
}
|
||||
contentType = resp.Header.Get("Content-Type")
|
||||
}
|
||||
} else {
|
||||
if switchErr, ok := IsAntigravityAccountSwitchError(retryErr); ok {
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: http.StatusServiceUnavailable,
|
||||
Kind: "failover",
|
||||
Message: sanitizeUpstreamErrorMessage(retryErr.Error()),
|
||||
})
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
ForceCacheBilling: switchErr.IsStickySession,
|
||||
}
|
||||
}
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: 0,
|
||||
Kind: "signature_retry_request_error",
|
||||
Message: sanitizeUpstreamErrorMessage(retryErr.Error()),
|
||||
})
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Antigravity Gemini account %d: signature retry request failed: %v", account.ID, retryErr)
|
||||
}
|
||||
} else {
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Antigravity Gemini account %d: signature retry wrap failed: %v", account.ID, wrapErr)
|
||||
}
|
||||
}
|
||||
|
||||
// fallback 成功:继续按正常响应处理
|
||||
if resp.StatusCode < 400 {
|
||||
goto handleSuccess
|
||||
}
|
||||
|
||||
requestID := resp.Header.Get("x-request-id")
|
||||
if requestID != "" {
|
||||
c.Header("x-request-id", requestID)
|
||||
}
|
||||
|
||||
unwrapped, unwrapErr := s.unwrapV1InternalResponse(respBody)
|
||||
unwrappedForOps := unwrapped
|
||||
if unwrapErr != nil || len(unwrappedForOps) == 0 {
|
||||
unwrappedForOps = respBody
|
||||
}
|
||||
s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, originalModel, forwardOpts.groupID, forwardOpts.sessionHash, isStickySession)
|
||||
upstreamMsg := strings.TrimSpace(extractAntigravityErrorMessage(unwrappedForOps))
|
||||
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
||||
upstreamDetail := s.getUpstreamErrorDetail(unwrappedForOps)
|
||||
|
||||
// Always record upstream context for Ops error logs, even when we will failover.
|
||||
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
|
||||
|
||||
// 精确匹配服务端配置类 400 错误,触发同账号重试 + failover
|
||||
if resp.StatusCode == http.StatusBadRequest && isGoogleProjectConfigError(strings.ToLower(upstreamMsg)) {
|
||||
log.Printf("%s status=400 google_config_error failover=true upstream_message=%q account=%d", prefix, upstreamMsg, account.ID)
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: requestID,
|
||||
Kind: "failover",
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: unwrappedForOps, RetryableOnSameAccount: true}
|
||||
}
|
||||
|
||||
if s.shouldFailoverUpstreamError(resp.StatusCode) {
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: requestID,
|
||||
Kind: "failover",
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: unwrappedForOps}
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: requestID,
|
||||
Kind: "http_error",
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] upstream error status=%d body=%s", resp.StatusCode, truncateForLog(unwrappedForOps, 500))
|
||||
MarkResponseCommitted(c)
|
||||
c.Data(resp.StatusCode, contentType, unwrappedForOps)
|
||||
return nil, fmt.Errorf("antigravity upstream error: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
handleSuccess:
|
||||
requestID := resp.Header.Get("x-request-id")
|
||||
if requestID != "" {
|
||||
c.Header("x-request-id", requestID)
|
||||
}
|
||||
|
||||
var usage *ClaudeUsage
|
||||
var firstTokenMs *int
|
||||
var clientDisconnect bool
|
||||
|
||||
if stream {
|
||||
// 客户端要求流式,直接透传
|
||||
streamRes, err := s.handleGeminiStreamingResponse(c, resp, startTime)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_error error=%v", prefix, err)
|
||||
return nil, err
|
||||
}
|
||||
usage = streamRes.usage
|
||||
firstTokenMs = streamRes.firstTokenMs
|
||||
clientDisconnect = streamRes.clientDisconnect
|
||||
} else {
|
||||
// 客户端要求非流式,收集流式响应后返回
|
||||
streamRes, err := s.handleGeminiStreamToNonStreaming(c, resp, startTime)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=stream_collect_error error=%v", prefix, err)
|
||||
return nil, err
|
||||
}
|
||||
usage = streamRes.usage
|
||||
firstTokenMs = streamRes.firstTokenMs
|
||||
}
|
||||
|
||||
if usage == nil {
|
||||
usage = &ClaudeUsage{}
|
||||
}
|
||||
|
||||
// 判断是否为图片生成模型
|
||||
imageCount := 0
|
||||
if isImageGenerationModel(mappedModel) {
|
||||
// Gemini 图片生成 API 每次请求只生成一张图片(API 限制)
|
||||
imageCount = 1
|
||||
}
|
||||
|
||||
return &ForwardResult{
|
||||
RequestID: requestID,
|
||||
Usage: *usage,
|
||||
Model: originalModel,
|
||||
UpstreamModel: billingModel,
|
||||
Stream: stream,
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
ClientDisconnect: clientDisconnect,
|
||||
ImageCount: imageCount,
|
||||
ImageSize: imageSize,
|
||||
ImageInputSize: imageInputSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// cleanGeminiRequest 清理 Gemini 请求体中的 Schema
|
||||
func cleanGeminiRequest(body []byte) ([]byte, error) {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
modified := false
|
||||
|
||||
// 1. 清理 Tools
|
||||
if tools, ok := payload["tools"].([]any); ok && len(tools) > 0 {
|
||||
for _, t := range tools {
|
||||
toolMap, ok := t.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// function_declarations (snake_case) or functionDeclarations (camelCase)
|
||||
var funcs []any
|
||||
if f, ok := toolMap["functionDeclarations"].([]any); ok {
|
||||
funcs = f
|
||||
} else if f, ok := toolMap["function_declarations"].([]any); ok {
|
||||
funcs = f
|
||||
}
|
||||
|
||||
if len(funcs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range funcs {
|
||||
funcMap, ok := f.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if params, ok := funcMap["parameters"].(map[string]any); ok {
|
||||
antigravity.DeepCleanUndefined(params)
|
||||
cleaned := antigravity.CleanJSONSchema(params)
|
||||
funcMap["parameters"] = cleaned
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !modified {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
// filterEmptyPartsFromGeminiRequest 过滤掉 parts 为空的消息
|
||||
// Gemini API 不接受空 parts,需要在请求前过滤
|
||||
func filterEmptyPartsFromGeminiRequest(body []byte) ([]byte, error) {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contents, ok := payload["contents"].([]any)
|
||||
if !ok || len(contents) == 0 {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
filtered := make([]any, 0, len(contents))
|
||||
modified := false
|
||||
|
||||
for _, c := range contents {
|
||||
contentMap, ok := c.(map[string]any)
|
||||
if !ok {
|
||||
filtered = append(filtered, c)
|
||||
continue
|
||||
}
|
||||
|
||||
parts, hasParts := contentMap["parts"]
|
||||
if !hasParts {
|
||||
filtered = append(filtered, c)
|
||||
continue
|
||||
}
|
||||
|
||||
partsSlice, ok := parts.([]any)
|
||||
if !ok {
|
||||
filtered = append(filtered, c)
|
||||
continue
|
||||
}
|
||||
|
||||
// 跳过 parts 为空数组的消息
|
||||
if len(partsSlice) == 0 {
|
||||
modified = true
|
||||
continue
|
||||
}
|
||||
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
|
||||
if !modified {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
payload["contents"] = filtered
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,375 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ForwardUpstream 使用 base_url + /v1/messages + 双 header 认证透传上游 Claude 请求
|
||||
func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
|
||||
startTime := time.Now()
|
||||
sessionID := getSessionID(c)
|
||||
prefix := logPrefix(sessionID, account.Name)
|
||||
|
||||
// 获取上游配置
|
||||
baseURL := strings.TrimSpace(account.GetCredential("base_url"))
|
||||
apiKey := strings.TrimSpace(account.GetCredential("api_key"))
|
||||
if baseURL == "" || apiKey == "" {
|
||||
return nil, fmt.Errorf("upstream account missing base_url or api_key")
|
||||
}
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
|
||||
// 解析请求获取模型信息
|
||||
var claudeReq antigravity.ClaudeRequest
|
||||
if err := json.Unmarshal(body, &claudeReq); err != nil {
|
||||
return nil, fmt.Errorf("parse claude request: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(claudeReq.Model) == "" {
|
||||
return nil, fmt.Errorf("missing model")
|
||||
}
|
||||
originalModel := claudeReq.Model
|
||||
|
||||
// 构建上游请求 URL
|
||||
upstreamURL := baseURL + "/v1/messages"
|
||||
|
||||
// 能力维度 sanitize:Anthropic-compatible 上游透传路径也需要保证 body↔beta header
|
||||
// 对称。客户端 anthropic-beta header 不含 context-management-2025-06-27 但 body 带
|
||||
// context_management 时 strip,与 Anthropic 直连 / Bedrock / Vertex 路径保持一致。
|
||||
clientBeta := c.GetHeader("anthropic-beta")
|
||||
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed {
|
||||
body = sanitized
|
||||
}
|
||||
|
||||
// 创建请求
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create upstream request: %w", err)
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("x-api-key", apiKey) // Claude API 兼容
|
||||
|
||||
// 透传 Claude 相关 headers
|
||||
if v := c.GetHeader("anthropic-version"); v != "" {
|
||||
req.Header.Set("anthropic-version", v)
|
||||
}
|
||||
if v := clientBeta; v != "" {
|
||||
req.Header.Set("anthropic-beta", v)
|
||||
}
|
||||
|
||||
// 代理 URL
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s upstream request failed: %v", prefix, err)
|
||||
return nil, fmt.Errorf("upstream request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 处理错误响应
|
||||
if resp.StatusCode >= 400 {
|
||||
respBody := s.readUpstreamErrorBody(resp)
|
||||
|
||||
// 429 错误时标记账号限流
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
s.handleUpstreamError(ctx, prefix, account, resp.StatusCode, resp.Header, respBody, originalModel, 0, "", false)
|
||||
}
|
||||
|
||||
// 透传上游错误
|
||||
c.Header("Content-Type", resp.Header.Get("Content-Type"))
|
||||
c.Status(resp.StatusCode)
|
||||
_, _ = c.Writer.Write(respBody)
|
||||
|
||||
return &ForwardResult{
|
||||
Model: originalModel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 处理成功响应(流式/非流式)
|
||||
var usage *ClaudeUsage
|
||||
var firstTokenMs *int
|
||||
var clientDisconnect bool
|
||||
|
||||
if claudeReq.Stream {
|
||||
// 流式响应:透传
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
streamRes := s.streamUpstreamResponse(c, resp, startTime)
|
||||
usage = streamRes.usage
|
||||
firstTokenMs = streamRes.firstTokenMs
|
||||
clientDisconnect = streamRes.clientDisconnect
|
||||
} else {
|
||||
// 非流式响应:直接透传
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read upstream response: %w", err)
|
||||
}
|
||||
|
||||
// 提取 usage
|
||||
usage = s.extractClaudeUsage(respBody)
|
||||
|
||||
c.Header("Content-Type", resp.Header.Get("Content-Type"))
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = c.Writer.Write(respBody)
|
||||
}
|
||||
|
||||
// 构建计费结果
|
||||
duration := time.Since(startTime)
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "%s status=success duration_ms=%d", prefix, duration.Milliseconds())
|
||||
|
||||
return &ForwardResult{
|
||||
Model: originalModel,
|
||||
Stream: claudeReq.Stream,
|
||||
Duration: duration,
|
||||
FirstTokenMs: firstTokenMs,
|
||||
ClientDisconnect: clientDisconnect,
|
||||
Usage: ClaudeUsage{
|
||||
InputTokens: usage.InputTokens,
|
||||
OutputTokens: usage.OutputTokens,
|
||||
CacheReadInputTokens: usage.CacheReadInputTokens,
|
||||
CacheCreationInputTokens: usage.CacheCreationInputTokens,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// streamUpstreamResponse 透传上游 SSE 流并提取 Claude usage
|
||||
func (s *AntigravityGatewayService) streamUpstreamResponse(c *gin.Context, resp *http.Response, startTime time.Time) *antigravityStreamResult {
|
||||
usage := &ClaudeUsage{}
|
||||
var firstTokenMs *int
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
maxLineSize := defaultMaxLineSize
|
||||
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 {
|
||||
maxLineSize = s.settingService.cfg.Gateway.MaxLineSize
|
||||
}
|
||||
scanner.Buffer(make([]byte, 64*1024), maxLineSize)
|
||||
|
||||
type scanEvent struct {
|
||||
line string
|
||||
err error
|
||||
}
|
||||
events := make(chan scanEvent, 16)
|
||||
done := make(chan struct{})
|
||||
sendEvent := func(ev scanEvent) bool {
|
||||
select {
|
||||
case events <- ev:
|
||||
return true
|
||||
case <-done:
|
||||
return false
|
||||
}
|
||||
}
|
||||
var lastReadAt int64
|
||||
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
||||
go func() {
|
||||
defer close(events)
|
||||
for scanner.Scan() {
|
||||
atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
|
||||
if !sendEvent(scanEvent{line: scanner.Text()}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
_ = sendEvent(scanEvent{err: err})
|
||||
}
|
||||
}()
|
||||
defer close(done)
|
||||
|
||||
streamInterval := time.Duration(0)
|
||||
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamDataIntervalTimeout > 0 {
|
||||
streamInterval = time.Duration(s.settingService.cfg.Gateway.StreamDataIntervalTimeout) * time.Second
|
||||
}
|
||||
var intervalTicker *time.Ticker
|
||||
if streamInterval > 0 {
|
||||
intervalTicker = time.NewTicker(streamInterval)
|
||||
defer intervalTicker.Stop()
|
||||
}
|
||||
var intervalCh <-chan time.Time
|
||||
if intervalTicker != nil {
|
||||
intervalCh = intervalTicker.C
|
||||
}
|
||||
|
||||
// 下游 keepalive:防止代理/Cloudflare Tunnel 因连接空闲而断开
|
||||
keepaliveInterval := time.Duration(0)
|
||||
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamKeepaliveInterval > 0 {
|
||||
keepaliveInterval = time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second
|
||||
}
|
||||
var keepaliveTicker *time.Ticker
|
||||
if keepaliveInterval > 0 {
|
||||
keepaliveTicker = time.NewTicker(keepaliveInterval)
|
||||
defer keepaliveTicker.Stop()
|
||||
}
|
||||
var keepaliveCh <-chan time.Time
|
||||
if keepaliveTicker != nil {
|
||||
keepaliveCh = keepaliveTicker.C
|
||||
}
|
||||
lastDataAt := time.Now()
|
||||
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
cw := newAntigravityClientWriter(c.Writer, flusher, "antigravity upstream")
|
||||
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-events:
|
||||
if !ok {
|
||||
return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: cw.Disconnected()}
|
||||
}
|
||||
if ev.err != nil {
|
||||
if disconnect, handled := handleStreamReadError(ev.err, cw.Disconnected(), "antigravity upstream"); handled {
|
||||
return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: disconnect}
|
||||
}
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Stream read error (antigravity upstream): %v", ev.err)
|
||||
return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}
|
||||
}
|
||||
|
||||
lastDataAt = time.Now()
|
||||
|
||||
line := ev.line
|
||||
|
||||
// 记录首 token 时间
|
||||
if firstTokenMs == nil && len(line) > 0 {
|
||||
ms := int(time.Since(startTime).Milliseconds())
|
||||
firstTokenMs = &ms
|
||||
}
|
||||
|
||||
// 尝试从 message_delta 或 message_stop 事件提取 usage
|
||||
s.extractSSEUsage(line, usage)
|
||||
|
||||
// 透传行
|
||||
cw.Fprintf("%s\n", line)
|
||||
|
||||
case <-intervalCh:
|
||||
lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
|
||||
if time.Since(lastRead) < streamInterval {
|
||||
continue
|
||||
}
|
||||
if cw.Disconnected() {
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Upstream timeout after client disconnect (antigravity upstream), returning collected usage")
|
||||
return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}
|
||||
}
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (antigravity upstream)")
|
||||
return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}
|
||||
|
||||
case <-keepaliveCh:
|
||||
if cw.Disconnected() {
|
||||
continue
|
||||
}
|
||||
if time.Since(lastDataAt) < keepaliveInterval {
|
||||
continue
|
||||
}
|
||||
// SSE ping 事件:Anthropic 原生格式,客户端会正确处理,
|
||||
// 同时保持连接活跃防止 Cloudflare Tunnel 等代理断开
|
||||
if !cw.Fprintf("event: ping\ndata: {\"type\": \"ping\"}\n\n") {
|
||||
logger.LegacyPrintf("service.antigravity_gateway", "Client disconnected during keepalive ping (antigravity upstream), continuing to drain upstream for billing")
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractSSEUsage 从 SSE data 行中提取 Claude usage(用于流式透传场景)
|
||||
//
|
||||
// Anthropic streaming 的 usage 字段分布在两类事件中:
|
||||
// - message_start:嵌套在 event.message.usage(input_tokens、cache_creation_input_tokens、
|
||||
// cache_read_input_tokens 等输入侧字段)
|
||||
// - message_delta:位于顶层 event.usage(流结束时的最终 output_tokens)
|
||||
//
|
||||
// 仅读取顶层 event.usage 会漏掉 message_start 的输入侧字段,导致流式透传请求落库的
|
||||
// usage_logs 记录 input_tokens=0。
|
||||
func (s *AntigravityGatewayService) extractSSEUsage(line string, usage *ClaudeUsage) {
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
return
|
||||
}
|
||||
dataStr := strings.TrimPrefix(line, "data: ")
|
||||
var event map[string]any
|
||||
if json.Unmarshal([]byte(dataStr), &event) != nil {
|
||||
return
|
||||
}
|
||||
var u map[string]any
|
||||
if eventType, _ := event["type"].(string); eventType == "message_start" {
|
||||
if msg, ok := event["message"].(map[string]any); ok {
|
||||
u, _ = msg["usage"].(map[string]any)
|
||||
}
|
||||
} else {
|
||||
u, _ = event["usage"].(map[string]any)
|
||||
}
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
if v, ok := u["input_tokens"].(float64); ok && int(v) > 0 {
|
||||
usage.InputTokens = int(v)
|
||||
}
|
||||
if v, ok := u["output_tokens"].(float64); ok && int(v) > 0 {
|
||||
usage.OutputTokens = int(v)
|
||||
}
|
||||
if v, ok := u["cache_read_input_tokens"].(float64); ok && int(v) > 0 {
|
||||
usage.CacheReadInputTokens = int(v)
|
||||
}
|
||||
if v, ok := u["cache_creation_input_tokens"].(float64); ok && int(v) > 0 {
|
||||
usage.CacheCreationInputTokens = int(v)
|
||||
}
|
||||
// 解析嵌套的 cache_creation 对象中的 5m/1h 明细
|
||||
if cc, ok := u["cache_creation"].(map[string]any); ok {
|
||||
if v, ok := cc["ephemeral_5m_input_tokens"].(float64); ok {
|
||||
usage.CacheCreation5mTokens = int(v)
|
||||
}
|
||||
if v, ok := cc["ephemeral_1h_input_tokens"].(float64); ok {
|
||||
usage.CacheCreation1hTokens = int(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractClaudeUsage 从非流式 Claude 响应提取 usage
|
||||
func (s *AntigravityGatewayService) extractClaudeUsage(body []byte) *ClaudeUsage {
|
||||
usage := &ClaudeUsage{}
|
||||
var resp map[string]any
|
||||
if json.Unmarshal(body, &resp) != nil {
|
||||
return usage
|
||||
}
|
||||
if u, ok := resp["usage"].(map[string]any); ok {
|
||||
if v, ok := u["input_tokens"].(float64); ok {
|
||||
usage.InputTokens = int(v)
|
||||
}
|
||||
if v, ok := u["output_tokens"].(float64); ok {
|
||||
usage.OutputTokens = int(v)
|
||||
}
|
||||
if v, ok := u["cache_read_input_tokens"].(float64); ok {
|
||||
usage.CacheReadInputTokens = int(v)
|
||||
}
|
||||
if v, ok := u["cache_creation_input_tokens"].(float64); ok {
|
||||
usage.CacheCreationInputTokens = int(v)
|
||||
}
|
||||
// 解析嵌套的 cache_creation 对象中的 5m/1h 明细
|
||||
if cc, ok := u["cache_creation"].(map[string]any); ok {
|
||||
if v, ok := cc["ephemeral_5m_input_tokens"].(float64); ok {
|
||||
usage.CacheCreation5mTokens = int(v)
|
||||
}
|
||||
if v, ok := cc["ephemeral_1h_input_tokens"].(float64); ok {
|
||||
usage.CacheCreation1hTokens = int(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return usage
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,606 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ForwardCountTokens 转发 count_tokens 请求到上游 API
|
||||
// 特点:不记录使用量、仅支持非流式响应
|
||||
func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) error {
|
||||
if parsed == nil {
|
||||
s.countTokensError(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
|
||||
return fmt.Errorf("parse request: empty request")
|
||||
}
|
||||
|
||||
if account != nil && account.IsAnthropicAPIKeyPassthroughEnabled() {
|
||||
passthroughBody := parsed.Body.Bytes()
|
||||
if reqModel := parsed.Model; reqModel != "" {
|
||||
if mappedModel := account.GetMappedModel(reqModel); mappedModel != reqModel {
|
||||
passthroughBody = s.replaceModelInBody(passthroughBody, mappedModel)
|
||||
logger.LegacyPrintf("service.gateway", "CountTokens passthrough model mapping: %s -> %s (account: %s)", reqModel, mappedModel, account.Name)
|
||||
}
|
||||
}
|
||||
return s.forwardCountTokensAnthropicAPIKeyPassthrough(ctx, c, account, passthroughBody)
|
||||
}
|
||||
|
||||
// Bedrock 不支持 count_tokens 端点
|
||||
if account != nil && account.IsBedrock() {
|
||||
s.countTokensError(c, http.StatusNotFound, "not_found_error", "count_tokens endpoint is not supported for Bedrock")
|
||||
return nil
|
||||
}
|
||||
|
||||
body := parsed.Body.Bytes()
|
||||
replaceBody := func(next []byte) error {
|
||||
if err := parsed.ReplaceBody(next); err != nil {
|
||||
return fmt.Errorf("rewrite count_tokens body: %w", err)
|
||||
}
|
||||
body = parsed.Body.Bytes()
|
||||
return nil
|
||||
}
|
||||
reqModel := parsed.Model
|
||||
|
||||
// Pre-filter: strip empty text blocks to prevent upstream 400.
|
||||
if err := replaceBody(StripEmptyTextBlocks(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isClaudeCodeCT := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID)
|
||||
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCodeCT
|
||||
|
||||
if shouldMimicClaudeCode {
|
||||
normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true}
|
||||
var normalizedBody []byte
|
||||
normalizedBody, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts)
|
||||
if err := replaceBody(normalizedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := replaceBody(s.rewriteMessageCacheControlIfEnabled(ctx, body)); err != nil {
|
||||
return err
|
||||
}
|
||||
if rw := buildToolNameRewriteFromBody(body); rw != nil {
|
||||
if err := replaceBody(applyToolNameRewriteToBody(body, rw)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := replaceBody(applyToolsLastCacheBreakpoint(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Antigravity 账户不支持 count_tokens,返回 404 让客户端 fallback 到本地估算。
|
||||
// 返回 nil 避免 handler 层记录为错误,也不设置 ops 上游错误上下文。
|
||||
if account.Platform == PlatformAntigravity {
|
||||
s.countTokensError(c, http.StatusNotFound, "not_found_error", "count_tokens endpoint is not supported for this platform")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 应用模型映射:
|
||||
// - APIKey 账号:使用账号级别的显式映射(如果配置),否则透传原始模型名
|
||||
// - OAuth/SetupToken 账号:使用 Anthropic 标准映射(短ID → 长ID)
|
||||
if reqModel != "" {
|
||||
mappedModel := reqModel
|
||||
mappingSource := ""
|
||||
if account.Type == AccountTypeAPIKey {
|
||||
mappedModel = account.GetMappedModel(reqModel)
|
||||
if mappedModel != reqModel {
|
||||
mappingSource = "account"
|
||||
}
|
||||
}
|
||||
if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey {
|
||||
normalized := claude.NormalizeModelID(reqModel)
|
||||
if normalized != reqModel {
|
||||
mappedModel = normalized
|
||||
mappingSource = "prefix"
|
||||
}
|
||||
}
|
||||
if mappedModel != reqModel {
|
||||
originalReqModel := reqModel
|
||||
if err := replaceBody(s.replaceModelInBody(body, mappedModel)); err != nil {
|
||||
return err
|
||||
}
|
||||
reqModel = mappedModel
|
||||
parsed.Model = mappedModel
|
||||
logger.LegacyPrintf("service.gateway", "CountTokens model mapping applied: %s -> %s (account: %s, source=%s)", originalReqModel, mappedModel, account.Name, mappingSource)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取凭证
|
||||
token, tokenType, err := s.GetAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to get access token")
|
||||
return err
|
||||
}
|
||||
|
||||
// 构建上游请求
|
||||
upstreamReq, wireBody, err := s.buildCountTokensRequest(ctx, c, account, body, token, tokenType, reqModel, shouldMimicClaudeCode)
|
||||
if err != nil {
|
||||
s.countTokensError(c, http.StatusInternalServerError, "api_error", "Failed to build request")
|
||||
return err
|
||||
}
|
||||
// 先记录首发 wire body;如果后面进入 400 retry,retry 会基于未签名的逻辑 body 重新构建。
|
||||
acceptedWireBody := wireBody
|
||||
|
||||
// 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递)
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "")
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed")
|
||||
return fmt.Errorf("upstream request failed: %w", err)
|
||||
}
|
||||
|
||||
// 读取响应体
|
||||
countTokensTooLarge := func(c *gin.Context) {
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Upstream response too large")
|
||||
}
|
||||
respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, countTokensTooLarge)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 检测 thinking block 签名错误(400)并重试一次(过滤 thinking blocks)
|
||||
if resp.StatusCode == 400 && s.shouldRectifySignatureError(ctx, account, respBody, reqModel) {
|
||||
logger.LegacyPrintf("service.gateway", "Account %d: detected thinking block signature error on count_tokens, retrying with filtered thinking blocks", account.ID)
|
||||
|
||||
filteredBody := FilterThinkingBlocksForRetry(body, reqModel)
|
||||
retryReq, retryWireBody, buildErr := s.buildCountTokensRequest(ctx, c, account, filteredBody, token, tokenType, reqModel, shouldMimicClaudeCode)
|
||||
if buildErr == nil {
|
||||
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if retryErr == nil {
|
||||
if retryResp.StatusCode < 400 {
|
||||
// count_tokens 签名重试成功后记录最终 wire body,错误响应仍保留原 body 便于后续处理。
|
||||
acceptedWireBody = retryWireBody
|
||||
}
|
||||
resp = retryResp
|
||||
respBody, err = ReadUpstreamResponseBody(resp.Body, s.cfg, c, countTokensTooLarge)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response")
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resp.StatusCode < 400 && !bytes.Equal(acceptedWireBody, body) {
|
||||
// count_tokens 成功后再同步最终 wire body,避免 retry 从已签名 body 派生。
|
||||
if err := replaceBody(acceptedWireBody); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 处理错误响应
|
||||
if resp.StatusCode >= 400 {
|
||||
// 标记账号状态(429/529等)
|
||||
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
|
||||
|
||||
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
|
||||
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
||||
upstreamDetail := ""
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 2048
|
||||
}
|
||||
upstreamDetail = truncateString(string(respBody), maxBytes)
|
||||
}
|
||||
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
|
||||
|
||||
// 记录上游错误摘要便于排障(不回显请求内容)
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
logger.LegacyPrintf("service.gateway",
|
||||
"count_tokens upstream error %d (account=%d platform=%s type=%s): %s",
|
||||
resp.StatusCode,
|
||||
account.ID,
|
||||
account.Platform,
|
||||
account.Type,
|
||||
truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes),
|
||||
)
|
||||
}
|
||||
|
||||
// 返回简化的错误响应
|
||||
errMsg := "Upstream request failed"
|
||||
switch resp.StatusCode {
|
||||
case 429:
|
||||
errMsg = "Rate limit exceeded"
|
||||
case 529:
|
||||
errMsg = "Service overloaded"
|
||||
}
|
||||
s.countTokensError(c, resp.StatusCode, "upstream_error", errMsg)
|
||||
if upstreamMsg == "" {
|
||||
return fmt.Errorf("upstream error: %d", resp.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg)
|
||||
}
|
||||
|
||||
// 透传成功响应
|
||||
c.Data(resp.StatusCode, "application/json", respBody)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx context.Context, c *gin.Context, account *Account, body []byte) error {
|
||||
token, tokenType, err := s.GetAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to get access token")
|
||||
return err
|
||||
}
|
||||
if tokenType != "apikey" {
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Invalid account token type")
|
||||
return fmt.Errorf("anthropic api key passthrough requires apikey token, got: %s", tokenType)
|
||||
}
|
||||
|
||||
upstreamReq, err := s.buildCountTokensRequestAnthropicAPIKeyPassthrough(ctx, c, account, body, token)
|
||||
if err != nil {
|
||||
s.countTokensError(c, http.StatusInternalServerError, "api_error", "Failed to build request")
|
||||
return err
|
||||
}
|
||||
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "")
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: 0,
|
||||
UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()),
|
||||
Passthrough: true,
|
||||
Kind: "request_error",
|
||||
Message: sanitizeUpstreamErrorMessage(err.Error()),
|
||||
})
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed")
|
||||
return fmt.Errorf("upstream request failed: %w", err)
|
||||
}
|
||||
|
||||
countTokensTooLarge := func(c *gin.Context) {
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Upstream response too large")
|
||||
}
|
||||
respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, countTokensTooLarge)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
if s.rateLimitService != nil {
|
||||
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
|
||||
}
|
||||
|
||||
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
|
||||
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
||||
|
||||
// 中转站不支持 count_tokens 端点时(404),返回 404 让客户端 fallback 到本地估算。
|
||||
// 仅在错误消息明确指向 count_tokens endpoint 不存在时生效,避免误吞其他 404(如错误 base_url)。
|
||||
// 返回 nil 避免 handler 层记录为错误,也不设置 ops 上游错误上下文。
|
||||
if isCountTokensUnsupported404(resp.StatusCode, respBody) {
|
||||
logger.LegacyPrintf("service.gateway",
|
||||
"[count_tokens] Upstream does not support count_tokens (404), returning 404: account=%d name=%s msg=%s",
|
||||
account.ID, account.Name, truncateString(upstreamMsg, 512))
|
||||
s.countTokensError(c, http.StatusNotFound, "not_found_error", "count_tokens endpoint is not supported by upstream")
|
||||
return nil
|
||||
}
|
||||
|
||||
upstreamDetail := ""
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 2048
|
||||
}
|
||||
upstreamDetail = truncateString(string(respBody), maxBytes)
|
||||
}
|
||||
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()),
|
||||
Passthrough: true,
|
||||
Kind: "http_error",
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
|
||||
errMsg := "Upstream request failed"
|
||||
switch resp.StatusCode {
|
||||
case 429:
|
||||
errMsg = "Rate limit exceeded"
|
||||
case 529:
|
||||
errMsg = "Service overloaded"
|
||||
}
|
||||
s.countTokensError(c, resp.StatusCode, "upstream_error", errMsg)
|
||||
if upstreamMsg == "" {
|
||||
return fmt.Errorf("upstream error: %d", resp.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg)
|
||||
}
|
||||
|
||||
writeAnthropicPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||
contentType := strings.TrimSpace(resp.Header.Get("Content-Type"))
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
c.Data(resp.StatusCode, contentType, respBody)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
|
||||
ctx context.Context,
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
body []byte,
|
||||
token string,
|
||||
) (*http.Request, error) {
|
||||
targetURL := claudeAPICountTokensURL
|
||||
baseURL := account.GetBaseURL()
|
||||
if baseURL != "" {
|
||||
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetURL = validatedURL + "/v1/messages/count_tokens?beta=true"
|
||||
}
|
||||
body = sanitizeCountTokensRequestBody(body)
|
||||
|
||||
// 同 buildUpstreamRequestAnthropicAPIKeyPassthrough:能力维度 sanitize。
|
||||
clientBeta := ""
|
||||
if c != nil && c.Request != nil {
|
||||
clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta")
|
||||
}
|
||||
// 账号覆写了 anthropic-beta 时,覆写值即最终上游值:净化以覆写值为准
|
||||
if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
|
||||
clientBeta = beta
|
||||
}
|
||||
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, clientBeta); changed {
|
||||
body = sanitized
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c != nil && c.Request != nil {
|
||||
for key, values := range c.Request.Header {
|
||||
lowerKey := strings.ToLower(strings.TrimSpace(key))
|
||||
if !allowedHeaders[lowerKey] {
|
||||
continue
|
||||
}
|
||||
wireKey := resolveWireCasing(key)
|
||||
for _, v := range values {
|
||||
addHeaderRaw(req.Header, wireKey, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Del("authorization")
|
||||
req.Header.Del("x-api-key")
|
||||
req.Header.Del("x-goog-api-key")
|
||||
req.Header.Del("cookie")
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, token)
|
||||
|
||||
if req.Header.Get("content-type") == "" {
|
||||
req.Header.Set("content-type", "application/json")
|
||||
}
|
||||
if req.Header.Get("anthropic-version") == "" {
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
}
|
||||
|
||||
// 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头)
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// buildCountTokensRequest 构建 count_tokens 上游请求
|
||||
func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, mimicClaudeCode bool) (*http.Request, []byte, error) {
|
||||
// 确定目标 URL
|
||||
targetURL := claudeAPICountTokensURL
|
||||
if account.Type == AccountTypeAPIKey {
|
||||
baseURL := account.GetBaseURL()
|
||||
if baseURL != "" {
|
||||
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
targetURL = validatedURL + "/v1/messages/count_tokens?beta=true"
|
||||
}
|
||||
} else if account.IsCustomBaseURLEnabled() {
|
||||
customURL := account.GetCustomBaseURL()
|
||||
if customURL == "" {
|
||||
return nil, nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID)
|
||||
}
|
||||
validatedURL, err := s.validateUpstreamBaseURL(customURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages/count_tokens", account)
|
||||
}
|
||||
|
||||
clientHeaders := http.Header{}
|
||||
if c != nil && c.Request != nil {
|
||||
clientHeaders = c.Request.Header
|
||||
}
|
||||
|
||||
// OAuth 账号:应用统一指纹和重写 userID(受设置开关控制)
|
||||
// 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
|
||||
ctEnableFP, ctEnableMPT := true, false
|
||||
if s.settingService != nil {
|
||||
ctEnableFP, ctEnableMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx)
|
||||
}
|
||||
var ctFingerprint *Fingerprint
|
||||
if account.IsOAuth() && s.identityService != nil {
|
||||
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
|
||||
if err == nil {
|
||||
ctFingerprint = fp
|
||||
if !ctEnableMPT {
|
||||
accountUUID := account.GetExtraString("account_uuid")
|
||||
if accountUUID != "" && fp.ClientID != "" {
|
||||
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
|
||||
body = newBody
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 同步 billing header cc_version 与实际发送的 User-Agent 版本
|
||||
if ctFingerprint != nil && ctEnableFP {
|
||||
body = syncBillingHeaderVersion(body, ctFingerprint.UserAgent)
|
||||
}
|
||||
|
||||
// === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)===
|
||||
// 顺序约束同 buildUpstreamRequest。
|
||||
ctEffectiveDropSet := mergeDropSets(s.getBetaPolicyFilterSet(ctx, c, account, modelID))
|
||||
finalBetaHeader, finalBetaShouldSet := s.computeFinalCountTokensAnthropicBeta(
|
||||
tokenType, mimicClaudeCode, modelID, clientHeaders, body, ctEffectiveDropSet,
|
||||
)
|
||||
|
||||
// 账号覆写了 anthropic-beta 时,覆写值即最终上游值:净化以覆写值为准
|
||||
if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
|
||||
finalBetaHeader, finalBetaShouldSet = beta, true
|
||||
}
|
||||
|
||||
// 能力维度 body sanitize:与最终 anthropic-beta header 对称
|
||||
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed {
|
||||
body = sanitized
|
||||
}
|
||||
|
||||
body = sanitizeCountTokensRequestBody(body)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 设置认证头(保持原始大小写)
|
||||
if tokenType == "oauth" {
|
||||
setHeaderRaw(req.Header, "authorization", "Bearer "+token)
|
||||
} else {
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, token)
|
||||
}
|
||||
|
||||
// 白名单透传 headers(恢复真实 wire casing)
|
||||
for key, values := range clientHeaders {
|
||||
lowerKey := strings.ToLower(key)
|
||||
if allowedHeaders[lowerKey] {
|
||||
wireKey := resolveWireCasing(key)
|
||||
for _, v := range values {
|
||||
addHeaderRaw(req.Header, wireKey, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth 账号:应用指纹到请求头(受设置开关控制)
|
||||
if ctEnableFP && ctFingerprint != nil {
|
||||
s.identityService.ApplyFingerprint(req, ctFingerprint)
|
||||
}
|
||||
|
||||
// 确保必要的 headers 存在(保持原始大小写)
|
||||
if getHeaderRaw(req.Header, "content-type") == "" {
|
||||
setHeaderRaw(req.Header, "content-type", "application/json")
|
||||
}
|
||||
if getHeaderRaw(req.Header, "anthropic-version") == "" {
|
||||
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
|
||||
}
|
||||
if tokenType == "oauth" {
|
||||
applyClaudeOAuthHeaderDefaults(req)
|
||||
}
|
||||
|
||||
// OAuth + mimic Claude Code:强制注入 CLI 指纹 header
|
||||
if tokenType == "oauth" && mimicClaudeCode {
|
||||
applyClaudeCodeMimicHeaders(req, false)
|
||||
}
|
||||
|
||||
// 写入最终 anthropic-beta header(Del 一次避免白名单透传值残留)
|
||||
deleteHeaderAllForms(req.Header, "anthropic-beta")
|
||||
if finalBetaShouldSet {
|
||||
setHeaderRaw(req.Header, "anthropic-beta", finalBetaHeader)
|
||||
}
|
||||
|
||||
// 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖
|
||||
if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" {
|
||||
if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
|
||||
if parsed := ParseMetadataUserID(uid); parsed != nil {
|
||||
setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 账号级请求头覆写(仅 anthropic/openai api_key 账号启用时生效;OAuth 路径 no-op)
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
|
||||
if c != nil && tokenType == "oauth" {
|
||||
c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode))
|
||||
}
|
||||
if s.debugClaudeMimicEnabled() {
|
||||
logClaudeMimicDebug(req, body, account, tokenType, mimicClaudeCode)
|
||||
}
|
||||
|
||||
return req, body, nil
|
||||
}
|
||||
|
||||
func sanitizeCountTokensRequestBody(body []byte) []byte {
|
||||
out := body
|
||||
for _, path := range []string{
|
||||
"temperature",
|
||||
"top_p",
|
||||
"top_k",
|
||||
"stream",
|
||||
"stop_sequences",
|
||||
"stop",
|
||||
} {
|
||||
if gjson.GetBytes(out, path).Exists() {
|
||||
if next, ok := deleteJSONPathBytes(out, path); ok {
|
||||
out = next
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// countTokensError 返回 count_tokens 错误响应
|
||||
func (s *GatewayService) countTokensError(c *gin.Context, status int, errType, message string) {
|
||||
c.JSON(status, gin.H{
|
||||
"type": "error",
|
||||
"error": gin.H{
|
||||
"type": errType,
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,959 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 重试相关常量
|
||||
const (
|
||||
// 最大尝试次数(包含首次请求)。过多重试会导致请求堆积与资源耗尽。
|
||||
maxRetryAttempts = 5
|
||||
|
||||
// 指数退避:第 N 次失败后的等待 = retryBaseDelay * 2^(N-1),并且上限为 retryMaxDelay。
|
||||
retryBaseDelay = 300 * time.Millisecond
|
||||
retryMaxDelay = 3 * time.Second
|
||||
|
||||
// 最大重试耗时(包含请求本身耗时 + 退避等待时间)。
|
||||
// 用于防止极端情况下 goroutine 长时间堆积导致资源耗尽。
|
||||
maxRetryElapsed = 10 * time.Second
|
||||
)
|
||||
|
||||
func (s *GatewayService) shouldRetryUpstreamError(account *Account, statusCode int) bool {
|
||||
// OAuth/Setup Token 账号:仅 403 重试
|
||||
if account.IsOAuth() {
|
||||
return statusCode == 403
|
||||
}
|
||||
|
||||
// API Key 账号:未配置的错误码重试
|
||||
return !account.ShouldHandleErrorCode(statusCode)
|
||||
}
|
||||
|
||||
// shouldFailoverUpstreamError determines whether an upstream error should trigger account failover.
|
||||
func (s *GatewayService) shouldFailoverUpstreamError(statusCode int) bool {
|
||||
switch statusCode {
|
||||
case 401, 403, 429, 529:
|
||||
return true
|
||||
default:
|
||||
return statusCode >= 500
|
||||
}
|
||||
}
|
||||
|
||||
func retryBackoffDelay(attempt int) time.Duration {
|
||||
// attempt 从 1 开始,表示第 attempt 次请求刚失败,需要等待后进行第 attempt+1 次请求。
|
||||
if attempt <= 0 {
|
||||
return retryBaseDelay
|
||||
}
|
||||
delay := retryBaseDelay * time.Duration(1<<(attempt-1))
|
||||
if delay > retryMaxDelay {
|
||||
return retryMaxDelay
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) error {
|
||||
if d <= 0 {
|
||||
return nil
|
||||
}
|
||||
timer := time.NewTimer(d)
|
||||
defer func() {
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Forward 转发请求到Claude API
|
||||
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) {
|
||||
startTime := time.Now()
|
||||
if parsed == nil {
|
||||
return nil, fmt.Errorf("parse request: empty request")
|
||||
}
|
||||
|
||||
// Web Search 模拟:纯 web_search 请求时,直接调用搜索 API 构造响应
|
||||
if account != nil && s.shouldEmulateWebSearch(ctx, account, parsed.GroupID, parsed.Body.Bytes()) {
|
||||
return s.handleWebSearchEmulation(ctx, c, account, parsed)
|
||||
}
|
||||
|
||||
if account != nil && account.IsAnthropicAPIKeyPassthroughEnabled() {
|
||||
passthroughBody := parsed.Body.Bytes()
|
||||
passthroughModel := parsed.Model
|
||||
if passthroughModel != "" {
|
||||
if mappedModel := account.GetMappedModel(passthroughModel); mappedModel != passthroughModel {
|
||||
passthroughBody = s.replaceModelInBody(passthroughBody, mappedModel)
|
||||
logger.LegacyPrintf("service.gateway", "Passthrough model mapping: %s -> %s (account: %s)", parsed.Model, mappedModel, account.Name)
|
||||
passthroughModel = mappedModel
|
||||
}
|
||||
}
|
||||
return s.forwardAnthropicAPIKeyPassthroughWithInput(ctx, c, account, anthropicPassthroughForwardInput{
|
||||
Body: passthroughBody,
|
||||
Parsed: parsed,
|
||||
RequestModel: passthroughModel,
|
||||
OriginalModel: parsed.Model,
|
||||
RequestStream: parsed.Stream,
|
||||
StartTime: startTime,
|
||||
})
|
||||
}
|
||||
|
||||
if account != nil && account.IsBedrock() {
|
||||
return s.forwardBedrock(ctx, c, account, parsed, startTime)
|
||||
}
|
||||
|
||||
// Beta policy: evaluate once; block check + cache filter set for buildUpstreamRequest.
|
||||
// Always overwrite the cache to prevent stale values from a previous retry with a different account.
|
||||
if account.Platform == PlatformAnthropic && c != nil {
|
||||
policy := s.evaluateBetaPolicy(ctx, c.GetHeader("anthropic-beta"), account, parsed.Model)
|
||||
if policy.blockErr != nil {
|
||||
return nil, policy.blockErr
|
||||
}
|
||||
filterSet := policy.filterSet
|
||||
if filterSet == nil {
|
||||
filterSet = map[string]struct{}{}
|
||||
}
|
||||
c.Set(betaPolicyFilterSetKey, filterSet)
|
||||
}
|
||||
|
||||
body := parsed.Body.Bytes()
|
||||
replaceBody := func(next []byte) error {
|
||||
if err := parsed.ReplaceBody(next); err != nil {
|
||||
return fmt.Errorf("rewrite request body: %w", err)
|
||||
}
|
||||
body = parsed.Body.Bytes()
|
||||
return nil
|
||||
}
|
||||
reqModel := parsed.Model
|
||||
reqStream := parsed.Stream
|
||||
originalModel := reqModel
|
||||
|
||||
// === DEBUG: 打印客户端原始请求(headers + body 摘要)===
|
||||
if c != nil {
|
||||
s.debugLogGatewaySnapshot("CLIENT_ORIGINAL", c.Request.Header, body, map[string]string{
|
||||
"account": fmt.Sprintf("%d(%s)", account.ID, account.Name),
|
||||
"account_type": string(account.Type),
|
||||
"model": reqModel,
|
||||
"stream": strconv.FormatBool(reqStream),
|
||||
})
|
||||
}
|
||||
|
||||
// Claude Code 客户端判定:UA 匹配 claude-cli/* 且携带 metadata.user_id。
|
||||
// 真正的 Claude Code 客户端自带完整的 system prompt、cache_control 断点和 header,
|
||||
// 不需要代理做任何 body 级别的 mimicry;强行替换反而会破坏客户端的缓存策略
|
||||
// (长 system prompt 被替换为 ~45 tokens 的短 prompt,低于 Anthropic 1024 token
|
||||
// 最低缓存门槛,导致系统级缓存失效)。
|
||||
//
|
||||
// 对于非 Claude Code 的第三方客户端(opencode 等),仍然走完整 mimicry。
|
||||
isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID)
|
||||
shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
|
||||
|
||||
if shouldMimicClaudeCode {
|
||||
// 与 Parrot 对齐:OAuth 账号无条件重写 system(即使客户端已发了 Claude Code
|
||||
// 风格的 system prompt)。原因:第三方工具(opencode 等)会发 "You are Claude
|
||||
// Code..." system prompt 但缺少 billing attribution block,导致 Anthropic
|
||||
// 检测到"有 CC prompt 但无 billing block"的不一致而判为 third-party。
|
||||
// Parrot 的 transform_request 从不检查客户端 system 内容,直接覆盖。
|
||||
systemRewritten := false
|
||||
if !strings.Contains(strings.ToLower(reqModel), "haiku") {
|
||||
systemRaw, _ := parsed.SystemValue()
|
||||
systemPromptInjectionEnabled, systemPrompt, systemPromptBlocks := s.claudeOAuthSystemPromptInjectionSettings(ctx)
|
||||
if systemPromptInjectionEnabled {
|
||||
if err := replaceBody(rewriteSystemForNonClaudeCodeWithPromptBlocks(body, systemRaw, systemPrompt, systemPromptBlocks)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
systemRewritten = true
|
||||
}
|
||||
}
|
||||
|
||||
// system 被重写时保留 CC prompt 的 cache_control: ephemeral(匹配真实 Claude Code 行为);
|
||||
// 未重写时(haiku / 注入开关关闭)剥离客户端 cache_control,与原有行为一致。
|
||||
// 两种情况下 enforceCacheControlLimit 都会兜底处理上限。
|
||||
normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten}
|
||||
if s.identityService != nil {
|
||||
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
|
||||
if err == nil && fp != nil {
|
||||
// metadata 透传开启时跳过 metadata 注入
|
||||
_, mimicMPT, _ := s.settingService.GetGatewayForwardingSettings(ctx)
|
||||
if !mimicMPT {
|
||||
if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" {
|
||||
normalizeOpts.injectMetadata = true
|
||||
normalizeOpts.metadataUserID = metadataUserID
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var normalizedBody []byte
|
||||
normalizedBody, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts)
|
||||
if err := replaceBody(normalizedBody); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// D/E/F: 可选 messages cache 策略 + 工具名混淆 + tools[-1] 断点
|
||||
// 与 forward_as_chat_completions / forward_as_responses 路径对齐,
|
||||
// 原生 /v1/messages 路径也走同一套可配置字段级改写。
|
||||
if err := replaceBody(s.rewriteMessageCacheControlIfEnabled(ctx, body)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rw := buildToolNameRewriteFromBody(body); rw != nil {
|
||||
if err := replaceBody(applyToolNameRewriteToBody(body, rw)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Set(toolNameRewriteKey, rw)
|
||||
} else {
|
||||
if err := replaceBody(applyToolsLastCacheBreakpoint(body)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 客户端 dateline 归一化:仅对 Anthropic OAuth/SetupToken 账号生效。
|
||||
// 抹除 "Today's date is …" 语句里可能被注入的隐写指纹(4 种撇号 × 2 种日期
|
||||
// 分隔符),还原为 ASCII 撇号 + "-" 分隔符。运行在 mimicry 分支之外,
|
||||
// 保证真实 Claude Code 客户端注入的指纹同样被清洗。
|
||||
if next, ok := s.normalizeClientDatelineIfEnabled(ctx, account, body); ok {
|
||||
if err := replaceBody(next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 强制执行 cache_control 块数量限制(最多 4 个)
|
||||
if err := replaceBody(enforceCacheControlLimit(body)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 应用模型映射:
|
||||
// - APIKey 账号:使用账号级别的显式映射(如果配置),否则透传原始模型名
|
||||
// - OAuth/SetupToken 账号:使用 Anthropic 标准映射(短ID → 长ID)
|
||||
mappedModel := reqModel
|
||||
mappingSource := ""
|
||||
if account.Type == AccountTypeAPIKey {
|
||||
mappedModel = account.GetMappedModel(reqModel)
|
||||
if mappedModel != reqModel {
|
||||
mappingSource = "account"
|
||||
}
|
||||
}
|
||||
if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount {
|
||||
if candidate, matched := account.ResolveMappedModel(reqModel); matched {
|
||||
mappedModel = candidate
|
||||
mappingSource = "account"
|
||||
} else {
|
||||
normalized := normalizeVertexAnthropicModelID(claude.NormalizeModelID(reqModel))
|
||||
if normalized != reqModel {
|
||||
mappedModel = normalized
|
||||
mappingSource = "vertex"
|
||||
}
|
||||
}
|
||||
}
|
||||
if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey {
|
||||
normalized := claude.NormalizeModelID(reqModel)
|
||||
if normalized != reqModel {
|
||||
mappedModel = normalized
|
||||
mappingSource = "prefix"
|
||||
}
|
||||
}
|
||||
if mappedModel != reqModel {
|
||||
// 替换请求体中的模型名
|
||||
if err := replaceBody(s.replaceModelInBody(body, mappedModel)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqModel = mappedModel
|
||||
parsed.Model = mappedModel
|
||||
logger.LegacyPrintf("service.gateway", "Model mapping applied: %s -> %s (account: %s, source=%s)", originalModel, mappedModel, account.Name, mappingSource)
|
||||
}
|
||||
|
||||
if s.shouldInjectAnthropicCacheTTL1h(ctx, account) {
|
||||
if err := replaceBody(injectAnthropicCacheControlTTL1h(body)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 获取凭证
|
||||
token, tokenType, err := s.GetAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递)
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 TLS 指纹 profile(同一请求生命周期内不变,避免重试循环中重复解析)
|
||||
tlsProfile := s.tlsFPProfileService.ResolveTLSProfile(account)
|
||||
|
||||
// 调试日志:记录即将转发的账号信息
|
||||
logger.LegacyPrintf("service.gateway", "[Forward] Using account: ID=%d Name=%s Platform=%s Type=%s TLSFingerprint=%v Proxy=%s",
|
||||
account.ID, account.Name, account.Platform, account.Type, tlsProfile, proxyURL)
|
||||
// Pre-filter: strip empty text blocks (including nested in tool_result) to prevent upstream 400.
|
||||
if err := replaceBody(StripEmptyTextBlocks(body)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Pre-filter: strip web-search history blocks the upstream cannot accept
|
||||
// (emulation-synthesized server_tool_use / web_search_tool_result always;
|
||||
// genuine ones additionally for passback-required upstreams). See
|
||||
// FilterWebSearchHistoryBlocks. reqModel 此时已是映射后的模型 ID。
|
||||
if err := replaceBody(FilterWebSearchHistoryBlocks(body, reqModel)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Pre-filter: remove thinking blocks with missing/invalid signatures before forwarding.
|
||||
// Clients (e.g. Claude Code) sometimes send multi-turn conversations where a historical
|
||||
// assistant message contains a thinking block that is missing the required "signature" field,
|
||||
// causing upstream to reject the request with 400 "thinking.signature: Field required".
|
||||
// FilterThinkingBlocks removes only the invalid blocks; thinking blocks with valid signatures
|
||||
// are preserved. This avoids relying solely on the post-error retry path, which can time out
|
||||
// (maxRetryElapsed = 10s) for long conversations before the retry budget is exhausted.
|
||||
//
|
||||
// 仅 anthropic-strict 模型族执行此过滤;passback-required 上游 (DeepSeek/Kimi/GLM 等)
|
||||
// 要求历史 thinking block 原样回传,过滤反而制造 400。reqModel 此时已是映射后的模型 ID。
|
||||
if err := replaceBody(FilterThinkingBlocks(body, reqModel)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Chinese LLM thinking.type 协议差异补正(如 MiniMax 只接受 adaptive;Anthropic-SDK
|
||||
// 客户端默认发 enabled)。仅对 passback-required 上游生效(claude-* 不会进来)。
|
||||
if ResolveThinkingProtocol(reqModel) == ThinkingProtocolPassbackRequired {
|
||||
if rewritten, applied := NormalizeChineseLLMThinking(body, reqModel); applied {
|
||||
if err := replaceBody(rewritten); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.LegacyPrintf("service.gateway", "Account %d: rewrote thinking.type for %s (Anthropic-SDK default 'enabled' -> vendor-specific)", account.ID, reqModel)
|
||||
}
|
||||
}
|
||||
|
||||
// 重试循环
|
||||
var resp *http.Response
|
||||
lastWireBody := body
|
||||
retryStart := time.Now()
|
||||
for attempt := 1; attempt <= maxRetryAttempts; attempt++ {
|
||||
// 构建上游请求(每次重试需要重新构建,因为请求体需要重新读取)
|
||||
upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream)
|
||||
upstreamReq, wireBody, err := s.buildUpstreamRequest(upstreamCtx, c, account, body, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
|
||||
releaseUpstreamCtx()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 记录本次实际发送的 wire body;只有请求成功后才写回 ParsedRequest,避免 400 retry 基于已签名 CCH 再改写。
|
||||
lastWireBody = wireBody
|
||||
|
||||
// 发送请求
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
// Ensure the client receives an error response (handlers assume Forward writes on non-failover errors).
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
setOpsUpstreamError(c, 0, safeErr, "")
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: 0,
|
||||
UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()),
|
||||
Kind: "request_error",
|
||||
Message: safeErr,
|
||||
})
|
||||
c.JSON(http.StatusBadGateway, gin.H{
|
||||
"type": "error",
|
||||
"error": gin.H{
|
||||
"type": "upstream_error",
|
||||
"message": "Upstream request failed",
|
||||
},
|
||||
})
|
||||
return nil, fmt.Errorf("upstream request failed: %s", safeErr)
|
||||
}
|
||||
|
||||
// 优先检测thinking block签名错误(400)并重试一次
|
||||
if resp.StatusCode == 400 {
|
||||
respBody, readErr := s.readUpstreamErrorBody(resp)
|
||||
if readErr == nil {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if s.shouldRectifySignatureError(ctx, account, respBody, reqModel) {
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()),
|
||||
Kind: "signature_error",
|
||||
Message: extractUpstreamErrorMessage(respBody),
|
||||
Detail: func() string {
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
})
|
||||
|
||||
looksLikeToolSignatureError := func(msg string) bool {
|
||||
m := strings.ToLower(msg)
|
||||
return strings.Contains(m, "tool_use") ||
|
||||
strings.Contains(m, "tool_result") ||
|
||||
strings.Contains(m, "functioncall") ||
|
||||
strings.Contains(m, "function_call") ||
|
||||
strings.Contains(m, "functionresponse") ||
|
||||
strings.Contains(m, "function_response")
|
||||
}
|
||||
|
||||
// 避免在重试预算已耗尽时再发起额外请求
|
||||
if time.Since(retryStart) >= maxRetryElapsed {
|
||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
||||
break
|
||||
}
|
||||
logger.LegacyPrintf("service.gateway", "[warn] Account %d: thinking blocks have invalid signature, retrying with filtered blocks", account.ID)
|
||||
|
||||
// Conservative two-stage fallback:
|
||||
// 1) Disable thinking + thinking->text (preserve content)
|
||||
// 2) Only if upstream still errors AND error message points to tool/function signature issues:
|
||||
// also downgrade tool_use/tool_result blocks to text.
|
||||
|
||||
filteredBody := FilterThinkingBlocksForRetry(body, reqModel)
|
||||
retryCtx, releaseRetryCtx := detachStreamUpstreamContext(ctx, reqStream)
|
||||
retryReq, retryWireBody, buildErr := s.buildUpstreamRequest(retryCtx, c, account, filteredBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
|
||||
releaseRetryCtx()
|
||||
if buildErr == nil {
|
||||
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
if retryErr == nil {
|
||||
if retryResp.StatusCode < 400 {
|
||||
// 重试请求被上游接受后同步 ParsedRequest,保证 usage/日志看到真实请求体。
|
||||
lastWireBody = retryWireBody
|
||||
if err := replaceBody(retryWireBody); err != nil {
|
||||
_ = retryResp.Body.Close()
|
||||
return nil, err
|
||||
}
|
||||
logger.LegacyPrintf("service.gateway", "Account %d: thinking block retry succeeded (blocks downgraded)", account.ID)
|
||||
resp = retryResp
|
||||
break
|
||||
}
|
||||
|
||||
retryRespBody, retryReadErr := s.readUpstreamErrorBody(retryResp)
|
||||
_ = retryResp.Body.Close()
|
||||
if retryReadErr == nil && retryResp.StatusCode == 400 && s.isSignatureErrorPattern(ctx, account, retryRespBody) {
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: retryResp.StatusCode,
|
||||
UpstreamRequestID: retryResp.Header.Get("x-request-id"),
|
||||
UpstreamURL: safeUpstreamURL(retryReq.URL.String()),
|
||||
Kind: "signature_retry_thinking",
|
||||
Message: extractUpstreamErrorMessage(retryRespBody),
|
||||
Detail: func() string {
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
return truncateString(string(retryRespBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
})
|
||||
msg2 := extractUpstreamErrorMessage(retryRespBody)
|
||||
if looksLikeToolSignatureError(msg2) && time.Since(retryStart) < maxRetryElapsed {
|
||||
logger.LegacyPrintf("service.gateway", "Account %d: signature retry still failing and looks tool-related, retrying with tool blocks downgraded", account.ID)
|
||||
filteredBody2 := FilterSignatureSensitiveBlocksForRetry(body, reqModel)
|
||||
retryCtx2, releaseRetryCtx2 := detachStreamUpstreamContext(ctx, reqStream)
|
||||
retryReq2, retryWireBody2, buildErr2 := s.buildUpstreamRequest(retryCtx2, c, account, filteredBody2, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
|
||||
releaseRetryCtx2()
|
||||
if buildErr2 == nil {
|
||||
retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
if retryErr2 == nil {
|
||||
if retryResp2.StatusCode < 400 {
|
||||
// 二阶段工具块降级成功时也必须更新当前 body。
|
||||
lastWireBody = retryWireBody2
|
||||
if err := replaceBody(retryWireBody2); err != nil {
|
||||
_ = retryResp2.Body.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
resp = retryResp2
|
||||
break
|
||||
}
|
||||
if retryResp2 != nil && retryResp2.Body != nil {
|
||||
_ = retryResp2.Body.Close()
|
||||
}
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: 0,
|
||||
UpstreamURL: safeUpstreamURL(retryReq2.URL.String()),
|
||||
Kind: "signature_retry_tools_request_error",
|
||||
Message: sanitizeUpstreamErrorMessage(retryErr2.Error()),
|
||||
})
|
||||
logger.LegacyPrintf("service.gateway", "Account %d: tool-downgrade signature retry failed: %v", account.ID, retryErr2)
|
||||
} else {
|
||||
logger.LegacyPrintf("service.gateway", "Account %d: tool-downgrade signature retry build failed: %v", account.ID, buildErr2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the original retry response context.
|
||||
resp = &http.Response{
|
||||
StatusCode: retryResp.StatusCode,
|
||||
Header: retryResp.Header.Clone(),
|
||||
Body: io.NopCloser(bytes.NewReader(retryRespBody)),
|
||||
}
|
||||
break
|
||||
}
|
||||
if retryResp != nil && retryResp.Body != nil {
|
||||
_ = retryResp.Body.Close()
|
||||
}
|
||||
logger.LegacyPrintf("service.gateway", "Account %d: signature error retry failed: %v", account.ID, retryErr)
|
||||
} else {
|
||||
logger.LegacyPrintf("service.gateway", "Account %d: signature error retry build request failed: %v", account.ID, buildErr)
|
||||
}
|
||||
|
||||
// Retry failed: restore original response body and continue handling.
|
||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
||||
break
|
||||
}
|
||||
// 不是签名错误(或整流器已关闭),继续检查 budget 约束
|
||||
errMsg := extractUpstreamErrorMessage(respBody)
|
||||
if isThinkingBudgetConstraintError(errMsg) && s.settingService.IsBudgetRectifierEnabled(ctx) {
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()),
|
||||
Kind: "budget_constraint_error",
|
||||
Message: errMsg,
|
||||
Detail: func() string {
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
})
|
||||
|
||||
rectifiedBody, applied := RectifyThinkingBudget(body)
|
||||
if applied && time.Since(retryStart) < maxRetryElapsed {
|
||||
logger.LegacyPrintf("service.gateway", "Account %d: detected budget_tokens constraint error, retrying with rectified budget (budget_tokens=%d, max_tokens=%d)", account.ID, BudgetRectifyBudgetTokens, BudgetRectifyMaxTokens)
|
||||
budgetRetryCtx, releaseBudgetRetryCtx := detachStreamUpstreamContext(ctx, reqStream)
|
||||
budgetRetryReq, budgetWireBody, buildErr := s.buildUpstreamRequest(budgetRetryCtx, c, account, rectifiedBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
|
||||
releaseBudgetRetryCtx()
|
||||
if buildErr == nil {
|
||||
budgetRetryResp, retryErr := s.httpUpstream.DoWithTLS(budgetRetryReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
if retryErr == nil {
|
||||
if budgetRetryResp.StatusCode < 400 {
|
||||
// budget 修正请求成功后,ParsedRequest 也要描述被接受的修正版。
|
||||
lastWireBody = budgetWireBody
|
||||
if err := replaceBody(budgetWireBody); err != nil {
|
||||
_ = budgetRetryResp.Body.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
resp = budgetRetryResp
|
||||
break
|
||||
}
|
||||
if budgetRetryResp != nil && budgetRetryResp.Body != nil {
|
||||
_ = budgetRetryResp.Body.Close()
|
||||
}
|
||||
logger.LegacyPrintf("service.gateway", "Account %d: budget rectifier retry failed: %v", account.ID, retryErr)
|
||||
} else {
|
||||
logger.LegacyPrintf("service.gateway", "Account %d: budget rectifier retry build failed: %v", account.ID, buildErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要通用重试(排除400,因为400已经在上面特殊处理过了)
|
||||
if resp.StatusCode >= 400 && resp.StatusCode != 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) {
|
||||
if attempt < maxRetryAttempts {
|
||||
elapsed := time.Since(retryStart)
|
||||
if elapsed >= maxRetryElapsed {
|
||||
break
|
||||
}
|
||||
|
||||
delay := retryBackoffDelay(attempt)
|
||||
remaining := maxRetryElapsed - elapsed
|
||||
if delay > remaining {
|
||||
delay = remaining
|
||||
}
|
||||
if delay <= 0 {
|
||||
break
|
||||
}
|
||||
|
||||
respBody, _ := s.readUpstreamErrorBody(resp)
|
||||
_ = resp.Body.Close()
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
UpstreamURL: safeUpstreamURL(upstreamReq.URL.String()),
|
||||
Kind: "retry",
|
||||
Message: extractUpstreamErrorMessage(respBody),
|
||||
Detail: func() string {
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
})
|
||||
logger.LegacyPrintf("service.gateway", "Account %d: upstream error %d, retry %d/%d after %v (elapsed=%v/%v)",
|
||||
account.ID, resp.StatusCode, attempt, maxRetryAttempts, delay, elapsed, maxRetryElapsed)
|
||||
if err := sleepWithContext(ctx, delay); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 最后一次尝试也失败,跳出循环处理重试耗尽
|
||||
break
|
||||
}
|
||||
|
||||
// 不需要重试(成功或不可重试的错误),跳出循环
|
||||
// DEBUG: 输出响应 headers(用于检测 rate limit 信息)
|
||||
if account.Platform == PlatformGemini && resp.StatusCode < 400 && s.cfg != nil && s.cfg.Gateway.GeminiDebugResponseHeaders {
|
||||
logger.LegacyPrintf("service.gateway", "[DEBUG] Gemini API Response Headers for account %d:", account.ID)
|
||||
for k, v := range resp.Header {
|
||||
logger.LegacyPrintf("service.gateway", "[DEBUG] %s: %v", k, v)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
if resp == nil || resp.Body == nil {
|
||||
return nil, errors.New("upstream request failed: empty response")
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 处理重试耗尽的情况
|
||||
if resp.StatusCode >= 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) {
|
||||
if s.shouldFailoverUpstreamError(resp.StatusCode) {
|
||||
respBody, _ := s.readUpstreamErrorBody(resp)
|
||||
_ = resp.Body.Close()
|
||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
||||
|
||||
// 调试日志:打印重试耗尽后的错误响应
|
||||
logger.LegacyPrintf("service.gateway", "[Forward] Upstream error (retry exhausted, failover): Account=%d(%s) Status=%d RequestID=%s Body=%s",
|
||||
account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000))
|
||||
|
||||
s.handleRetryExhaustedSideEffects(ctx, resp, account)
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
Kind: "retry_exhausted_failover",
|
||||
Message: extractUpstreamErrorMessage(respBody),
|
||||
Detail: func() string {
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
})
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: respBody,
|
||||
RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||||
}
|
||||
}
|
||||
return s.handleRetryExhaustedError(ctx, resp, c, account)
|
||||
}
|
||||
|
||||
// 处理可切换账号的错误
|
||||
if resp.StatusCode >= 400 && s.shouldFailoverUpstreamError(resp.StatusCode) {
|
||||
respBody, _ := s.readUpstreamErrorBody(resp)
|
||||
_ = resp.Body.Close()
|
||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
||||
|
||||
// 调试日志:打印上游错误响应
|
||||
logger.LegacyPrintf("service.gateway", "[Forward] Upstream error (failover): Account=%d(%s) Status=%d RequestID=%s Body=%s",
|
||||
account.ID, account.Name, resp.StatusCode, resp.Header.Get("x-request-id"), truncateString(string(respBody), 1000))
|
||||
|
||||
s.handleFailoverSideEffects(ctx, resp, account, reqModel)
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
Kind: "failover",
|
||||
Message: extractUpstreamErrorMessage(respBody),
|
||||
Detail: func() string {
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
return truncateString(string(respBody), s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
})
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: respBody,
|
||||
RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||||
}
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
// 可选:对部分 400 触发 failover(默认关闭以保持语义)
|
||||
if resp.StatusCode == 400 && s.cfg != nil && s.cfg.Gateway.FailoverOn400 {
|
||||
respBody, readErr := s.readUpstreamErrorBody(resp)
|
||||
if readErr != nil {
|
||||
// ReadAll failed, fall back to normal error handling without consuming the stream
|
||||
return s.handleErrorResponse(ctx, resp, c, account, reqModel)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
||||
|
||||
if s.shouldFailoverOn400(respBody) {
|
||||
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
|
||||
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
||||
upstreamDetail := ""
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 2048
|
||||
}
|
||||
upstreamDetail = truncateString(string(respBody), maxBytes)
|
||||
}
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
Kind: "failover_on_400",
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
|
||||
if s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
logger.LegacyPrintf("service.gateway",
|
||||
"Account %d: 400 error, attempting failover: %s",
|
||||
account.ID,
|
||||
truncateForLog(respBody, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes),
|
||||
)
|
||||
} else {
|
||||
logger.LegacyPrintf("service.gateway", "Account %d: 400 error, attempting failover", account.ID)
|
||||
}
|
||||
s.handleFailoverSideEffects(ctx, resp, account, reqModel)
|
||||
return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody}
|
||||
}
|
||||
}
|
||||
return s.handleErrorResponse(ctx, resp, c, account, reqModel)
|
||||
}
|
||||
|
||||
// 处理正常响应
|
||||
|
||||
if !bytes.Equal(lastWireBody, body) {
|
||||
// 成功后再同步最终 wire body,避免失败重试从已签名 CCH 的 body 继续派生。
|
||||
if err := replaceBody(lastWireBody); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 触发上游接受回调(提前释放串行锁,不等流完成)
|
||||
if parsed.OnUpstreamAccepted != nil {
|
||||
parsed.OnUpstreamAccepted()
|
||||
}
|
||||
|
||||
var usage *ClaudeUsage
|
||||
var firstTokenMs *int
|
||||
var clientDisconnect bool
|
||||
if reqStream {
|
||||
streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel, shouldMimicClaudeCode)
|
||||
if err != nil {
|
||||
var sseErr *sseStreamErrorEventError
|
||||
if errors.As(err, &sseErr) {
|
||||
// 上游 HTTP 200 + SSE 流体内出现 event:error 帧。
|
||||
// 保留 StatusCode=403 以兼容既有 failover/客户端响应语义,
|
||||
// 但补全 ResponseBody 与 ops 上下文,让运维日志能反映上游真实错误。
|
||||
body := []byte(sseErr.RawData)
|
||||
|
||||
upstreamMsg := sanitizeUpstreamErrorMessage(
|
||||
strings.TrimSpace(extractUpstreamErrorMessage(body)),
|
||||
)
|
||||
|
||||
upstreamDetail := ""
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 2048
|
||||
}
|
||||
upstreamDetail = truncateString(sseErr.RawData, maxBytes)
|
||||
}
|
||||
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: 403,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
Kind: "stream_error",
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
|
||||
logger.LegacyPrintf("service.gateway",
|
||||
"[Forward] SSE error event in stream: Account=%d(%s) RequestID=%s Body=%s",
|
||||
account.ID, account.Name, resp.Header.Get("x-request-id"),
|
||||
truncateString(sseErr.RawData, 1000),
|
||||
)
|
||||
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: 403,
|
||||
ResponseBody: body,
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
usage = streamResult.usage
|
||||
firstTokenMs = streamResult.firstTokenMs
|
||||
clientDisconnect = streamResult.clientDisconnect
|
||||
} else {
|
||||
usage, err = s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, reqModel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &ForwardResult{
|
||||
RequestID: resp.Header.Get("x-request-id"),
|
||||
Usage: *usage,
|
||||
Model: originalModel, // 使用原始模型用于计费和日志
|
||||
UpstreamModel: mappedModel,
|
||||
Stream: reqStream,
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
ClientDisconnect: clientDisconnect,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveChannelMapping 委托渠道服务解析模型映射
|
||||
func (s *GatewayService) ResolveChannelMapping(ctx context.Context, groupID int64, model string) ChannelMappingResult {
|
||||
if s.channelService == nil {
|
||||
return ChannelMappingResult{MappedModel: model}
|
||||
}
|
||||
return s.channelService.ResolveChannelMapping(ctx, groupID, model)
|
||||
}
|
||||
|
||||
// ReplaceModelInBody 替换请求体中的模型名(导出供 handler 使用)
|
||||
func (s *GatewayService) ReplaceModelInBody(body []byte, newModel string) []byte {
|
||||
return ReplaceModelInBody(body, newModel)
|
||||
}
|
||||
|
||||
// IsModelRestricted 检查模型是否被渠道限制
|
||||
func (s *GatewayService) IsModelRestricted(ctx context.Context, groupID int64, model string) bool {
|
||||
if s.channelService == nil {
|
||||
return false
|
||||
}
|
||||
return s.channelService.IsModelRestricted(ctx, groupID, model)
|
||||
}
|
||||
|
||||
// ResolveChannelMappingAndRestrict 解析渠道映射。
|
||||
// 模型限制检查已移至调度阶段(checkChannelPricingRestriction),restricted 始终返回 false。
|
||||
func (s *GatewayService) ResolveChannelMappingAndRestrict(ctx context.Context, groupID *int64, model string) (ChannelMappingResult, bool) {
|
||||
if s.channelService == nil {
|
||||
return ChannelMappingResult{MappedModel: model}, false
|
||||
}
|
||||
return s.channelService.ResolveChannelMappingAndRestrict(ctx, groupID, model)
|
||||
}
|
||||
|
||||
// checkChannelPricingRestriction 根据渠道计费基准检查模型是否受定价列表限制。
|
||||
// 供调度阶段预检查(requested / channel_mapped)。
|
||||
// upstream 需逐账号检查,此处返回 false。
|
||||
func (s *GatewayService) checkChannelPricingRestriction(ctx context.Context, groupID *int64, requestedModel string) bool {
|
||||
if groupID == nil || s.channelService == nil || requestedModel == "" {
|
||||
return false
|
||||
}
|
||||
mapping := s.channelService.ResolveChannelMapping(ctx, *groupID, requestedModel)
|
||||
billingModel := billingModelForRestriction(mapping.BillingModelSource, requestedModel, mapping.MappedModel)
|
||||
if billingModel == "" {
|
||||
return false
|
||||
}
|
||||
return s.channelService.IsModelRestricted(ctx, *groupID, billingModel)
|
||||
}
|
||||
|
||||
// billingModelForRestriction 根据计费基准确定限制检查使用的模型。
|
||||
// upstream 返回空(需逐账号检查)。
|
||||
func billingModelForRestriction(source, requestedModel, channelMappedModel string) string {
|
||||
switch source {
|
||||
case BillingModelSourceRequested:
|
||||
return requestedModel
|
||||
case BillingModelSourceUpstream:
|
||||
return ""
|
||||
case BillingModelSourceChannelMapped:
|
||||
return channelMappedModel
|
||||
default:
|
||||
return channelMappedModel
|
||||
}
|
||||
}
|
||||
|
||||
// isUpstreamModelRestrictedByChannel 检查账号映射后的上游模型是否受渠道定价限制。
|
||||
// 仅在 BillingModelSource="upstream" 且 RestrictModels=true 时由调度循环调用。
|
||||
func (s *GatewayService) isUpstreamModelRestrictedByChannel(ctx context.Context, groupID int64, account *Account, requestedModel string) bool {
|
||||
if s.channelService == nil {
|
||||
return false
|
||||
}
|
||||
upstreamModel := resolveAccountUpstreamModel(account, requestedModel)
|
||||
if upstreamModel == "" {
|
||||
return false
|
||||
}
|
||||
return s.channelService.IsModelRestricted(ctx, groupID, upstreamModel)
|
||||
}
|
||||
|
||||
// resolveAccountUpstreamModel 确定账号将请求模型映射为什么上游模型。
|
||||
func resolveAccountUpstreamModel(account *Account, requestedModel string) string {
|
||||
if account.Platform == PlatformAntigravity {
|
||||
return mapAntigravityModel(account, requestedModel)
|
||||
}
|
||||
return account.GetMappedModel(requestedModel)
|
||||
}
|
||||
|
||||
// needsUpstreamChannelRestrictionCheck 判断是否需要在调度循环中逐账号检查上游模型的渠道限制。
|
||||
func (s *GatewayService) needsUpstreamChannelRestrictionCheck(ctx context.Context, groupID *int64) bool {
|
||||
if groupID == nil || s.channelService == nil {
|
||||
return false
|
||||
}
|
||||
ch, err := s.channelService.GetChannelForGroup(ctx, *groupID)
|
||||
if err != nil {
|
||||
slog.Warn("failed to check channel upstream restriction", "group_id", *groupID, "error", err)
|
||||
return false
|
||||
}
|
||||
if ch == nil || !ch.RestrictModels {
|
||||
return false
|
||||
}
|
||||
return ch.BillingModelSource == BillingModelSourceUpstream
|
||||
}
|
||||
|
||||
// isStickyAccountUpstreamRestricted 检查粘性会话命中的账号是否受 upstream 渠道限制。
|
||||
// 合并 needsUpstreamChannelRestrictionCheck + isUpstreamModelRestrictedByChannel 两步调用,
|
||||
// 供 sticky session 条件链使用,避免内联多个函数调用导致行过长。
|
||||
func (s *GatewayService) isStickyAccountUpstreamRestricted(ctx context.Context, groupID *int64, account *Account, requestedModel string) bool {
|
||||
if groupID == nil {
|
||||
return false
|
||||
}
|
||||
if !s.needsUpstreamChannelRestrictionCheck(ctx, groupID) {
|
||||
return false
|
||||
}
|
||||
return s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, requestedModel)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,923 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||
"github.com/google/uuid"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, reqStream bool, mimicClaudeCode bool) (*http.Request, []byte, error) {
|
||||
if account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount {
|
||||
req, err := s.buildUpstreamRequestAnthropicVertex(ctx, c, account, body, token, modelID, reqStream)
|
||||
return req, body, err
|
||||
}
|
||||
|
||||
// 确定目标URL
|
||||
targetURL := claudeAPIURL
|
||||
if account.Type == AccountTypeAPIKey {
|
||||
baseURL := account.GetBaseURL()
|
||||
if baseURL != "" {
|
||||
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
targetURL = validatedURL + "/v1/messages?beta=true"
|
||||
}
|
||||
} else if account.IsCustomBaseURLEnabled() {
|
||||
customURL := account.GetCustomBaseURL()
|
||||
if customURL == "" {
|
||||
return nil, nil, fmt.Errorf("custom_base_url is enabled but not configured for account %d", account.ID)
|
||||
}
|
||||
validatedURL, err := s.validateUpstreamBaseURL(customURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages", account)
|
||||
}
|
||||
|
||||
clientHeaders := http.Header{}
|
||||
if c != nil && c.Request != nil {
|
||||
clientHeaders = c.Request.Header
|
||||
}
|
||||
|
||||
// OAuth账号:应用统一指纹和metadata重写(受设置开关控制)
|
||||
var fingerprint *Fingerprint
|
||||
enableFP, enableMPT := true, false
|
||||
if s.settingService != nil {
|
||||
enableFP, enableMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx)
|
||||
}
|
||||
if account.IsOAuth() && s.identityService != nil {
|
||||
// 1. 获取或创建指纹(包含随机生成的ClientID)
|
||||
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.gateway", "Warning: failed to get fingerprint for account %d: %v", account.ID, err)
|
||||
// 失败时降级为透传原始headers
|
||||
} else {
|
||||
if enableFP {
|
||||
fingerprint = fp
|
||||
}
|
||||
|
||||
// 2. 重写metadata.user_id(需要指纹中的ClientID和账号的account_uuid)
|
||||
// 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
|
||||
// 当 metadata 透传开启时跳过重写
|
||||
if !enableMPT {
|
||||
accountUUID := account.GetExtraString("account_uuid")
|
||||
if accountUUID != "" && fp.ClientID != "" {
|
||||
if newBody, err := s.identityService.RewriteUserIDWithMasking(ctx, body, account, accountUUID, fp.ClientID, fp.UserAgent); err == nil && len(newBody) > 0 {
|
||||
body = newBody
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 同步 billing header cc_version 与实际发送的 User-Agent 版本
|
||||
if fingerprint != nil {
|
||||
body = syncBillingHeaderVersion(body, fingerprint.UserAgent)
|
||||
}
|
||||
|
||||
// === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)===
|
||||
//
|
||||
// 顺序约束:
|
||||
// 1) 算 finalBeta(纯函数,不依赖 req.Header;mimicry 路径会忽略客户端 beta,
|
||||
// 与原“OAuth + mimicClaudeCode 跳过白名单透传”行为对齐)
|
||||
// 2) 按 finalBeta 做能力维度 body sanitize(如 context-management beta 缺失 →
|
||||
// strip body.context_management,与 Bedrock 路径对称)
|
||||
// 3) CCH 签名(必须使用 strip 后的 body,否则 hash 与最终 body 不一致 →
|
||||
// 被 Anthropic 判 third-party)
|
||||
// 4) NewRequest(body 至此最终敲定)
|
||||
// 5) 透传白名单 / fingerprint / mimic header / 写入 finalBeta
|
||||
policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account, modelID)
|
||||
effectiveDropSet := mergeDropSets(policyFilterSet)
|
||||
finalBetaHeader, finalBetaShouldSet := s.computeFinalAnthropicBeta(
|
||||
tokenType, mimicClaudeCode, modelID, clientHeaders, body, effectiveDropSet,
|
||||
)
|
||||
|
||||
// 账号覆写了 anthropic-beta 时,覆写值即最终上游值(由下方 ApplyHeaderOverrides 写入):
|
||||
// body 能力净化必须以覆写值为准,否则 header/body 不对称会被上游 400。
|
||||
if beta, ok := account.HeaderOverrideValue("anthropic-beta"); ok {
|
||||
finalBetaHeader, finalBetaShouldSet = beta, true
|
||||
}
|
||||
|
||||
// 能力维度 body sanitize:与最终 anthropic-beta header 对称
|
||||
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(body, finalBetaHeader); changed {
|
||||
body = sanitized
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 设置认证头(保持原始大小写)
|
||||
if tokenType == "oauth" {
|
||||
setHeaderRaw(req.Header, "authorization", "Bearer "+token)
|
||||
} else {
|
||||
setAnthropicAPIKeyAuthHeader(req.Header, account, token)
|
||||
}
|
||||
|
||||
// 白名单透传 headers
|
||||
// OAuth mimicry 路径:跳过客户端 header 透传,与 Parrot 对齐。
|
||||
// Parrot 的 build_upstream_headers 只发 9 个精确 header,不透传任何客户端 header。
|
||||
// 透传客户端 header 会引入不一致的 x-stainless-* / anthropic-beta / user-agent /
|
||||
// x-claude-code-session-id 等值,和我们注入的伪装 header 冲突,被 Anthropic 判 third-party。
|
||||
if tokenType != "oauth" || !mimicClaudeCode {
|
||||
for key, values := range clientHeaders {
|
||||
lowerKey := strings.ToLower(key)
|
||||
if allowedHeaders[lowerKey] {
|
||||
wireKey := resolveWireCasing(key)
|
||||
for _, v := range values {
|
||||
addHeaderRaw(req.Header, wireKey, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth账号:应用缓存的指纹到请求头(覆盖白名单透传的头)
|
||||
if fingerprint != nil {
|
||||
s.identityService.ApplyFingerprint(req, fingerprint)
|
||||
}
|
||||
|
||||
// 确保必要的headers存在(保持原始大小写)
|
||||
if getHeaderRaw(req.Header, "content-type") == "" {
|
||||
setHeaderRaw(req.Header, "content-type", "application/json")
|
||||
}
|
||||
if getHeaderRaw(req.Header, "anthropic-version") == "" {
|
||||
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
|
||||
}
|
||||
if tokenType == "oauth" {
|
||||
applyClaudeOAuthHeaderDefaults(req)
|
||||
}
|
||||
|
||||
// OAuth + mimic Claude Code:强制注入 CLI 指纹相关 header
|
||||
// (user-agent/x-stainless-*/x-app/Accept/x-stainless-helper-method/x-client-request-id)
|
||||
if tokenType == "oauth" && mimicClaudeCode {
|
||||
applyClaudeCodeMimicHeaders(req, reqStream)
|
||||
}
|
||||
|
||||
// 写入最终 anthropic-beta header
|
||||
// 注:透传分支白名单可能写入了客户端 anthropic-beta,无条件 Del 一次再按 finalBeta
|
||||
// 决定是否 set,确保 dropSet 过滤后的结果一定覆盖客户端原始值。
|
||||
deleteHeaderAllForms(req.Header, "anthropic-beta")
|
||||
if finalBetaShouldSet {
|
||||
setHeaderRaw(req.Header, "anthropic-beta", finalBetaHeader)
|
||||
}
|
||||
|
||||
// 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖
|
||||
if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" {
|
||||
if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
|
||||
if parsed := ParseMetadataUserID(uid); parsed != nil {
|
||||
setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 账号级请求头覆写(仅 anthropic/openai api_key 账号启用时生效;OAuth 路径 no-op)。
|
||||
// 放在所有 header 逻辑之后,确保配置值对同名头拥有最终决定权。
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
|
||||
// === DEBUG: 打印上游转发请求(headers + body 摘要),与 CLIENT_ORIGINAL 对比 ===
|
||||
s.debugLogGatewaySnapshot("UPSTREAM_FORWARD", req.Header, body, map[string]string{
|
||||
"url": req.URL.String(),
|
||||
"token_type": tokenType,
|
||||
"mimic_claude_code": strconv.FormatBool(mimicClaudeCode),
|
||||
"fingerprint_applied": strconv.FormatBool(fingerprint != nil),
|
||||
"enable_fp": strconv.FormatBool(enableFP),
|
||||
"enable_mpt": strconv.FormatBool(enableMPT),
|
||||
})
|
||||
|
||||
// Always capture a compact fingerprint line for later error diagnostics.
|
||||
// We only print it when needed (or when the explicit debug flag is enabled).
|
||||
if c != nil && tokenType == "oauth" {
|
||||
c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode))
|
||||
}
|
||||
if s.debugClaudeMimicEnabled() {
|
||||
logClaudeMimicDebug(req, body, account, tokenType, mimicClaudeCode)
|
||||
}
|
||||
|
||||
return req, body, nil
|
||||
}
|
||||
|
||||
// vertexSupportedBetaTokens 是 Vertex AI 的 Anthropic 端点接受的 anthropic-beta
|
||||
// 白名单。Vertex 对任何未知 token 直接 HTTP 400,故采用白名单(与 Bedrock 的
|
||||
// bedrockSupportedBetaTokens 同思路)而非黑名单:未来 Claude Code 新增的、Vertex 尚未
|
||||
// 支持的 token 天然被剥离。当 Vertex 新增支持某 beta 时在此补充。
|
||||
//
|
||||
// 明确排除(issue #3358 中 Vertex 报 400 的 token):advisor-tool-2026-03-01、
|
||||
// prompt-caching-scope-2026-01-05、redact-thinking-2026-02-12、
|
||||
// thinking-token-count-2026-05-13;以及 claude-code-20250219 / oauth-2025-04-20 等
|
||||
// 客户端身份 beta——Vertex service_account 走 Bearer 鉴权,不需要它们。
|
||||
var vertexSupportedBetaTokens = map[string]bool{
|
||||
"context-1m-2025-08-07": true,
|
||||
"context-management-2025-06-27": true,
|
||||
"fine-grained-tool-streaming-2025-05-14": true,
|
||||
"interleaved-thinking-2025-05-14": true,
|
||||
}
|
||||
|
||||
// filterVertexBetaTokens 解析 client 的 anthropic-beta header,先剔除 drop 集合中的
|
||||
// token(BetaPolicy filter + 默认 drop),再只保留 Vertex 支持的 token,去重后逗号拼接。
|
||||
// 返回最终 header(可能为空字符串)。
|
||||
func filterVertexBetaTokens(header string, drop map[string]struct{}) string {
|
||||
tokens := parseAnthropicBetaHeader(header)
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := make([]string, 0, len(tokens))
|
||||
seen := make(map[string]bool, len(tokens))
|
||||
for _, t := range tokens {
|
||||
if _, dropped := drop[t]; dropped {
|
||||
continue
|
||||
}
|
||||
if !vertexSupportedBetaTokens[t] {
|
||||
continue
|
||||
}
|
||||
if seen[t] {
|
||||
continue
|
||||
}
|
||||
seen[t] = true
|
||||
out = append(out, t)
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
func (s *GatewayService) buildUpstreamRequestAnthropicVertex(
|
||||
ctx context.Context,
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
body []byte,
|
||||
token string,
|
||||
modelID string,
|
||||
reqStream bool,
|
||||
) (*http.Request, error) {
|
||||
vertexBody, err := buildVertexAnthropicRequestBody(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 计算最终 outgoing anthropic-beta。Vertex AI 的 Anthropic 端点只接受一小撮
|
||||
// beta token,未知 token 会直接 HTTP 400——近期 Claude Code CLI 透传的
|
||||
// advisor-tool-2026-03-01 / prompt-caching-scope-2026-01-05 /
|
||||
// redact-thinking-2026-02-12 / thinking-token-count-2026-05-13 都不被 Vertex 接受
|
||||
// (issue #3358)。这里复用 BetaPolicy 的 block 检查(与 Bedrock 的
|
||||
// resolveBedrockBetaTokensForRequest 对称),再按 vertexSupportedBetaTokens 白名单
|
||||
// 剥离其余 token,使该路径与 Anthropic 直连 / Bedrock 路径行为一致。
|
||||
clientBeta := ""
|
||||
if c != nil && c.Request != nil {
|
||||
clientBeta = getHeaderRaw(c.Request.Header, "anthropic-beta")
|
||||
}
|
||||
policy := s.evaluateBetaPolicy(ctx, clientBeta, account, modelID)
|
||||
if policy.blockErr != nil {
|
||||
return nil, policy.blockErr
|
||||
}
|
||||
finalBeta := filterVertexBetaTokens(clientBeta, mergeDropSets(policy.filterSet))
|
||||
|
||||
// 能力维度 sanitize:基于最终 beta(而非原始 client 值)决定是否保留 body 中的
|
||||
// context_management,与 Anthropic 直连 / Bedrock 路径对称。
|
||||
if sanitized, changed := sanitizeAnthropicBodyForBetaTokens(vertexBody, finalBeta); changed {
|
||||
vertexBody = sanitized
|
||||
}
|
||||
fullURL, err := buildVertexAnthropicURL(account.VertexProjectID(), account.VertexLocation(modelID), modelID, reqStream)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(vertexBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c != nil && c.Request != nil {
|
||||
for key, values := range c.Request.Header {
|
||||
lowerKey := strings.ToLower(strings.TrimSpace(key))
|
||||
if !allowedHeaders[lowerKey] || lowerKey == "anthropic-version" {
|
||||
continue
|
||||
}
|
||||
wireKey := resolveWireCasing(key)
|
||||
for _, v := range values {
|
||||
addHeaderRaw(req.Header, wireKey, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Del("authorization")
|
||||
req.Header.Del("x-api-key")
|
||||
req.Header.Del("x-goog-api-key")
|
||||
req.Header.Del("cookie")
|
||||
req.Header.Del("anthropic-version")
|
||||
setHeaderRaw(req.Header, "authorization", "Bearer "+token)
|
||||
setHeaderRaw(req.Header, "content-type", "application/json")
|
||||
|
||||
// 覆盖上面白名单 loop 写入的原始 client anthropic-beta,使用过滤后的最终值。
|
||||
// finalBeta 为空(全部被剥离)时不下发该 header,与 Vertex 无 beta 请求一致。
|
||||
deleteHeaderAllForms(req.Header, "anthropic-beta")
|
||||
if finalBeta != "" {
|
||||
setHeaderRaw(req.Header, "anthropic-beta", finalBeta)
|
||||
}
|
||||
|
||||
s.debugLogGatewaySnapshot("UPSTREAM_FORWARD_VERTEX_ANTHROPIC", req.Header, vertexBody, map[string]string{
|
||||
"url": req.URL.String(),
|
||||
"token_type": "service_account",
|
||||
"model": modelID,
|
||||
"stream": strconv.FormatBool(reqStream),
|
||||
})
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// getBetaHeader 处理anthropic-beta header
|
||||
// 对于OAuth账号,需要确保包含oauth-2025-04-20
|
||||
func (s *GatewayService) getBetaHeader(modelID string, clientBetaHeader string) string {
|
||||
// 如果客户端传了anthropic-beta
|
||||
if clientBetaHeader != "" {
|
||||
// 已包含oauth beta则直接返回
|
||||
if strings.Contains(clientBetaHeader, claude.BetaOAuth) {
|
||||
return clientBetaHeader
|
||||
}
|
||||
|
||||
// 需要添加oauth beta
|
||||
parts := strings.Split(clientBetaHeader, ",")
|
||||
for i, p := range parts {
|
||||
parts[i] = strings.TrimSpace(p)
|
||||
}
|
||||
|
||||
// 在claude-code-20250219后面插入oauth beta
|
||||
claudeCodeIdx := -1
|
||||
for i, p := range parts {
|
||||
if p == claude.BetaClaudeCode {
|
||||
claudeCodeIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if claudeCodeIdx >= 0 {
|
||||
// 在claude-code后面插入
|
||||
newParts := make([]string, 0, len(parts)+1)
|
||||
newParts = append(newParts, parts[:claudeCodeIdx+1]...)
|
||||
newParts = append(newParts, claude.BetaOAuth)
|
||||
newParts = append(newParts, parts[claudeCodeIdx+1:]...)
|
||||
return strings.Join(newParts, ",")
|
||||
}
|
||||
|
||||
// 没有claude-code,放在第一位
|
||||
return claude.BetaOAuth + "," + clientBetaHeader
|
||||
}
|
||||
|
||||
// 客户端没传,根据模型生成
|
||||
// haiku 模型不需要 claude-code beta
|
||||
if strings.Contains(strings.ToLower(modelID), "haiku") {
|
||||
return claude.HaikuBetaHeader
|
||||
}
|
||||
|
||||
return claude.DefaultBetaHeader
|
||||
}
|
||||
|
||||
func requestNeedsBetaFeatures(body []byte) bool {
|
||||
tools := gjson.GetBytes(body, "tools")
|
||||
if tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 {
|
||||
return true
|
||||
}
|
||||
thinkingType := gjson.GetBytes(body, "thinking.type").String()
|
||||
if strings.EqualFold(thinkingType, "enabled") || strings.EqualFold(thinkingType, "adaptive") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func defaultAPIKeyBetaHeader(body []byte) string {
|
||||
modelID := gjson.GetBytes(body, "model").String()
|
||||
if strings.Contains(strings.ToLower(modelID), "haiku") {
|
||||
return claude.APIKeyHaikuBetaHeader
|
||||
}
|
||||
return claude.APIKeyBetaHeader
|
||||
}
|
||||
|
||||
func applyClaudeOAuthHeaderDefaults(req *http.Request) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
if getHeaderRaw(req.Header, "Accept") == "" {
|
||||
setHeaderRaw(req.Header, "Accept", "application/json")
|
||||
}
|
||||
for key, value := range claude.DefaultHeaders {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if getHeaderRaw(req.Header, key) == "" {
|
||||
setHeaderRaw(req.Header, resolveWireCasing(key), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mergeAnthropicBeta(required []string, incoming string) string {
|
||||
seen := make(map[string]struct{}, len(required)+8)
|
||||
out := make([]string, 0, len(required)+8)
|
||||
|
||||
add := func(v string) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
return
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
|
||||
for _, r := range required {
|
||||
add(r)
|
||||
}
|
||||
for _, p := range strings.Split(incoming, ",") {
|
||||
add(p)
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
func mergeAnthropicBetaDropping(required []string, incoming string, drop map[string]struct{}) string {
|
||||
merged := mergeAnthropicBeta(required, incoming)
|
||||
if merged == "" || len(drop) == 0 {
|
||||
return merged
|
||||
}
|
||||
out := make([]string, 0, 8)
|
||||
for _, p := range strings.Split(merged, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := drop[p]; ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
// computeFinalAnthropicBeta 计算发往上游的最终 anthropic-beta header 值。
|
||||
//
|
||||
// 设计动机:将原本在 buildUpstreamRequest 内联在一起、依赖 req.Header 的
|
||||
// anthropic-beta 计算逻辑抽成纯函数。这样调用方可以在 NewRequest 之前
|
||||
// 就提前拿到最终 beta header,进而能按它对 body 做能力维度 sanitize 后再做
|
||||
// CCH 签名——一举修复了以下之前由顺序依赖导致的能力维度 sanitize
|
||||
// 无法部署的问题(签名与最终 body 不一致可以被判 third-party)。
|
||||
//
|
||||
// 返回 (value, shouldSet):
|
||||
// - shouldSet=false 意为“不主动设置 anthropic-beta header”,与原代码“
|
||||
// API-key 账号 + 客户端未传 anthropic-beta + InjectBetaForAPIKey 未开启或
|
||||
// requestNeedsBetaFeatures=false”的行为对齐。
|
||||
// - shouldSet=true 时 value 可能为空字符串(例如客户端透传的 beta 被 dropSet
|
||||
// 全部过滤掉),这与原代码中 setHeaderRaw 的结果一致。
|
||||
//
|
||||
// clientHeaders 是客户端原始 HTTP header(通常为 c.Request.Header);nil 时按“客户端
|
||||
// 未传”处理。body 是已经 metadata 重写 / billing version sync 之后但未 sanitize 上游
|
||||
// 不兼容字段之前的版本。
|
||||
func (s *GatewayService) computeFinalAnthropicBeta(
|
||||
tokenType string,
|
||||
mimicClaudeCode bool,
|
||||
modelID string,
|
||||
clientHeaders http.Header,
|
||||
body []byte,
|
||||
effectiveDropSet map[string]struct{},
|
||||
) (string, bool) {
|
||||
clientBeta := ""
|
||||
if clientHeaders != nil {
|
||||
clientBeta = getHeaderRaw(clientHeaders, "anthropic-beta")
|
||||
}
|
||||
|
||||
if tokenType == "oauth" {
|
||||
if mimicClaudeCode {
|
||||
// mimic 路径:原代码跳过白名单透传,incomingBeta 总是空字符串。
|
||||
// 这里传空 string 以严格对齐原行为。
|
||||
requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking}
|
||||
if !strings.Contains(strings.ToLower(modelID), "haiku") {
|
||||
requiredBetas = claude.FullClaudeCodeMimicryBetas()
|
||||
}
|
||||
return mergeAnthropicBetaDropping(requiredBetas, "", effectiveDropSet), true
|
||||
}
|
||||
// 真 Claude Code 客户端透传路径
|
||||
return stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBeta), effectiveDropSet), true
|
||||
}
|
||||
|
||||
// API-key accounts
|
||||
if clientBeta != "" {
|
||||
return stripBetaTokensWithSet(clientBeta, effectiveDropSet), true
|
||||
}
|
||||
if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
|
||||
if requestNeedsBetaFeatures(body) {
|
||||
if beta := defaultAPIKeyBetaHeader(body); beta != "" {
|
||||
return beta, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// computeFinalCountTokensAnthropicBeta 是 count_tokens 路径上 anthropic-beta header 的
|
||||
// 计算纯函数。语义与 computeFinalAnthropicBeta 对齐,但备份了 count_tokens 独有的
|
||||
// 两条特殊规则:
|
||||
//
|
||||
// - OAuth mimic:requiredBetas 为 FullClaudeCodeMimicryBetas + BetaTokenCounting
|
||||
// (与 messages 不同的是:不按 haiku 排除;count_tokens 始终携带 token-counting beta)
|
||||
// - OAuth 透传 + 客户端未传 anthropic-beta:补齐 CountTokensBetaHeader
|
||||
// - OAuth 透传 + 客户端传了:补齐 BetaTokenCounting(如果未含)
|
||||
//
|
||||
// 返回语义同 computeFinalAnthropicBeta。
|
||||
func (s *GatewayService) computeFinalCountTokensAnthropicBeta(
|
||||
tokenType string,
|
||||
mimicClaudeCode bool,
|
||||
modelID string,
|
||||
clientHeaders http.Header,
|
||||
body []byte,
|
||||
effectiveDropSet map[string]struct{},
|
||||
) (string, bool) {
|
||||
clientBeta := ""
|
||||
if clientHeaders != nil {
|
||||
clientBeta = getHeaderRaw(clientHeaders, "anthropic-beta")
|
||||
}
|
||||
|
||||
if tokenType == "oauth" {
|
||||
if mimicClaudeCode {
|
||||
// 与原代码严格等价:original buildCountTokensRequest 在 count_tokens mimic
|
||||
// 分支上**不**会跳过白名单透传(与 messages mimic 路径不同),所以
|
||||
// incomingBeta = req.Header[anthropic-beta] = 客户端透传过来的 client beta。
|
||||
// 重构后直接从 clientHeaders 拿同一个值,保持行为一致。
|
||||
requiredBetas := append(claude.FullClaudeCodeMimicryBetas(), claude.BetaTokenCounting)
|
||||
return mergeAnthropicBetaDropping(requiredBetas, clientBeta, effectiveDropSet), true
|
||||
}
|
||||
if clientBeta == "" {
|
||||
return claude.CountTokensBetaHeader, true
|
||||
}
|
||||
beta := s.getBetaHeader(modelID, clientBeta)
|
||||
if !strings.Contains(beta, claude.BetaTokenCounting) {
|
||||
beta = beta + "," + claude.BetaTokenCounting
|
||||
}
|
||||
return stripBetaTokensWithSet(beta, effectiveDropSet), true
|
||||
}
|
||||
|
||||
// API-key accounts
|
||||
if clientBeta != "" {
|
||||
return stripBetaTokensWithSet(clientBeta, effectiveDropSet), true
|
||||
}
|
||||
if s.cfg != nil && s.cfg.Gateway.InjectBetaForAPIKey {
|
||||
if requestNeedsBetaFeatures(body) {
|
||||
if beta := defaultAPIKeyBetaHeader(body); beta != "" {
|
||||
return beta, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// stripBetaTokens removes the given beta tokens from a comma-separated header value.
|
||||
func stripBetaTokens(header string, tokens []string) string {
|
||||
if header == "" || len(tokens) == 0 {
|
||||
return header
|
||||
}
|
||||
return stripBetaTokensWithSet(header, buildBetaTokenSet(tokens))
|
||||
}
|
||||
|
||||
func stripBetaTokensWithSet(header string, drop map[string]struct{}) string {
|
||||
if header == "" || len(drop) == 0 {
|
||||
return header
|
||||
}
|
||||
parts := strings.Split(header, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := drop[p]; ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
if len(out) == len(parts) {
|
||||
return header // no change, avoid allocation
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
// BetaBlockedError indicates a request was blocked by a beta policy rule.
|
||||
type BetaBlockedError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *BetaBlockedError) Error() string { return e.Message }
|
||||
|
||||
// betaPolicyResult holds the evaluated result of beta policy rules for a single request.
|
||||
type betaPolicyResult struct {
|
||||
blockErr *BetaBlockedError // non-nil if a block rule matched
|
||||
filterSet map[string]struct{} // tokens to filter (may be nil)
|
||||
}
|
||||
|
||||
// evaluateBetaPolicy loads settings once and evaluates all rules against the given request.
|
||||
func (s *GatewayService) evaluateBetaPolicy(ctx context.Context, betaHeader string, account *Account, model string) betaPolicyResult {
|
||||
if s.settingService == nil {
|
||||
return betaPolicyResult{}
|
||||
}
|
||||
settings, err := s.settingService.GetBetaPolicySettings(ctx)
|
||||
if err != nil || settings == nil {
|
||||
return betaPolicyResult{}
|
||||
}
|
||||
isOAuth := account.IsOAuth()
|
||||
isBedrock := account.IsBedrock()
|
||||
var result betaPolicyResult
|
||||
for _, rule := range settings.Rules {
|
||||
if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) {
|
||||
continue
|
||||
}
|
||||
effectiveAction, effectiveErrMsg := resolveRuleAction(rule, model)
|
||||
switch effectiveAction {
|
||||
case BetaPolicyActionBlock:
|
||||
if result.blockErr == nil && betaHeader != "" && containsBetaToken(betaHeader, rule.BetaToken) {
|
||||
msg := effectiveErrMsg
|
||||
if msg == "" {
|
||||
msg = "beta feature " + rule.BetaToken + " is not allowed"
|
||||
}
|
||||
result.blockErr = &BetaBlockedError{Message: msg}
|
||||
}
|
||||
case BetaPolicyActionFilter:
|
||||
if result.filterSet == nil {
|
||||
result.filterSet = make(map[string]struct{})
|
||||
}
|
||||
result.filterSet[rule.BetaToken] = struct{}{}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// mergeDropSets merges the static defaultDroppedBetasSet with dynamic policy filter tokens.
|
||||
// Returns defaultDroppedBetasSet directly when policySet is empty (zero allocation).
|
||||
func mergeDropSets(policySet map[string]struct{}, extra ...string) map[string]struct{} {
|
||||
if len(policySet) == 0 && len(extra) == 0 {
|
||||
return defaultDroppedBetasSet
|
||||
}
|
||||
m := make(map[string]struct{}, len(defaultDroppedBetasSet)+len(policySet)+len(extra))
|
||||
for t := range defaultDroppedBetasSet {
|
||||
m[t] = struct{}{}
|
||||
}
|
||||
for t := range policySet {
|
||||
m[t] = struct{}{}
|
||||
}
|
||||
for _, t := range extra {
|
||||
m[t] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// betaPolicyFilterSetKey is the gin.Context key for caching the policy filter set within a request.
|
||||
const betaPolicyFilterSetKey = "betaPolicyFilterSet"
|
||||
|
||||
// getBetaPolicyFilterSet returns the beta policy filter set, using the gin context cache if available.
|
||||
// In the /v1/messages path, Forward() evaluates the policy first and caches the result;
|
||||
// buildUpstreamRequest reuses it (zero extra DB calls). In the count_tokens path, this
|
||||
// evaluates on demand (one DB call).
|
||||
func (s *GatewayService) getBetaPolicyFilterSet(ctx context.Context, c *gin.Context, account *Account, model string) map[string]struct{} {
|
||||
if c != nil {
|
||||
if v, ok := c.Get(betaPolicyFilterSetKey); ok {
|
||||
if fs, ok := v.(map[string]struct{}); ok {
|
||||
return fs
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.evaluateBetaPolicy(ctx, "", account, model).filterSet
|
||||
}
|
||||
|
||||
// betaPolicyScopeMatches checks whether a rule's scope matches the current account type.
|
||||
func betaPolicyScopeMatches(scope string, isOAuth bool, isBedrock bool) bool {
|
||||
switch scope {
|
||||
case BetaPolicyScopeAll:
|
||||
return true
|
||||
case BetaPolicyScopeOAuth:
|
||||
return isOAuth
|
||||
case BetaPolicyScopeAPIKey:
|
||||
return !isOAuth && !isBedrock
|
||||
case BetaPolicyScopeBedrock:
|
||||
return isBedrock
|
||||
default:
|
||||
return true // unknown scope → match all (fail-open)
|
||||
}
|
||||
}
|
||||
|
||||
// matchModelWhitelist checks if a model matches any pattern in the whitelist.
|
||||
// Reuses matchModelPattern from group.go which supports exact and wildcard prefix matching.
|
||||
func matchModelWhitelist(model string, whitelist []string) bool {
|
||||
for _, pattern := range whitelist {
|
||||
if matchModelPattern(pattern, model) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveRuleAction determines the effective action and error message for a rule given the request model.
|
||||
// When ModelWhitelist is empty, the rule's primary Action/ErrorMessage applies unconditionally.
|
||||
// When non-empty, Action applies to matching models; FallbackAction/FallbackErrorMessage applies to others.
|
||||
func resolveRuleAction(rule BetaPolicyRule, model string) (action, errorMessage string) {
|
||||
if len(rule.ModelWhitelist) == 0 {
|
||||
return rule.Action, rule.ErrorMessage
|
||||
}
|
||||
if matchModelWhitelist(model, rule.ModelWhitelist) {
|
||||
return rule.Action, rule.ErrorMessage
|
||||
}
|
||||
if rule.FallbackAction != "" {
|
||||
return rule.FallbackAction, rule.FallbackErrorMessage
|
||||
}
|
||||
return BetaPolicyActionPass, "" // default fallback: pass (fail-open)
|
||||
}
|
||||
|
||||
// droppedBetaSet returns claude.DroppedBetas as a set, with optional extra tokens.
|
||||
func droppedBetaSet(extra ...string) map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(defaultDroppedBetasSet)+len(extra))
|
||||
for t := range defaultDroppedBetasSet {
|
||||
m[t] = struct{}{}
|
||||
}
|
||||
for _, t := range extra {
|
||||
m[t] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// containsBetaToken checks if a comma-separated header value contains the given token.
|
||||
func containsBetaToken(header, token string) bool {
|
||||
if header == "" || token == "" {
|
||||
return false
|
||||
}
|
||||
for _, p := range strings.Split(header, ",") {
|
||||
if strings.TrimSpace(p) == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func filterBetaTokens(tokens []string, filterSet map[string]struct{}) []string {
|
||||
if len(tokens) == 0 || len(filterSet) == 0 {
|
||||
return tokens
|
||||
}
|
||||
kept := make([]string, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
if _, filtered := filterSet[token]; !filtered {
|
||||
kept = append(kept, token)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
func (s *GatewayService) resolveBedrockBetaTokensForRequest(
|
||||
ctx context.Context,
|
||||
account *Account,
|
||||
betaHeader string,
|
||||
body []byte,
|
||||
modelID string,
|
||||
) ([]string, error) {
|
||||
// 1. 对原始 header 中的 beta token 做 block 检查(快速失败)
|
||||
policy := s.evaluateBetaPolicy(ctx, betaHeader, account, modelID)
|
||||
if policy.blockErr != nil {
|
||||
return nil, policy.blockErr
|
||||
}
|
||||
|
||||
// 2. 解析 header + body 自动注入 + Bedrock 转换/过滤
|
||||
betaTokens := ResolveBedrockBetaTokens(betaHeader, body, modelID)
|
||||
|
||||
// 3. 对最终 token 列表再做 block 检查,捕获通过 body 自动注入绕过 header block 的情况。
|
||||
// 例如:管理员 block 了 interleaved-thinking,客户端不在 header 中带该 token,
|
||||
// 但请求体中包含 thinking 字段 → autoInjectBedrockBetaTokens 会自动补齐 →
|
||||
// 如果不做此检查,block 规则会被绕过。
|
||||
if blockErr := s.checkBetaPolicyBlockForTokens(ctx, betaTokens, account, modelID); blockErr != nil {
|
||||
return nil, blockErr
|
||||
}
|
||||
|
||||
return filterBetaTokens(betaTokens, policy.filterSet), nil
|
||||
}
|
||||
|
||||
// checkBetaPolicyBlockForTokens 检查 token 列表中是否有被管理员 block 规则命中的 token。
|
||||
// 用于补充 evaluateBetaPolicy 对 header 的检查,覆盖 body 自动注入的 token。
|
||||
func (s *GatewayService) checkBetaPolicyBlockForTokens(ctx context.Context, tokens []string, account *Account, model string) *BetaBlockedError {
|
||||
if s.settingService == nil || len(tokens) == 0 {
|
||||
return nil
|
||||
}
|
||||
settings, err := s.settingService.GetBetaPolicySettings(ctx)
|
||||
if err != nil || settings == nil {
|
||||
return nil
|
||||
}
|
||||
isOAuth := account.IsOAuth()
|
||||
isBedrock := account.IsBedrock()
|
||||
tokenSet := buildBetaTokenSet(tokens)
|
||||
for _, rule := range settings.Rules {
|
||||
effectiveAction, effectiveErrMsg := resolveRuleAction(rule, model)
|
||||
if effectiveAction != BetaPolicyActionBlock {
|
||||
continue
|
||||
}
|
||||
if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) {
|
||||
continue
|
||||
}
|
||||
if _, present := tokenSet[rule.BetaToken]; present {
|
||||
msg := effectiveErrMsg
|
||||
if msg == "" {
|
||||
msg = "beta feature " + rule.BetaToken + " is not allowed"
|
||||
}
|
||||
return &BetaBlockedError{Message: msg}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildBetaTokenSet(tokens []string) map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(tokens))
|
||||
for _, t := range tokens {
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
m[t] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
var defaultDroppedBetasSet = buildBetaTokenSet(claude.DroppedBetas)
|
||||
|
||||
// applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers.
|
||||
// This mirrors opencode-anthropic-auth behavior: do not trust downstream
|
||||
// headers when using Claude Code-scoped OAuth credentials.
|
||||
func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
// Start with the standard defaults (fill missing).
|
||||
applyClaudeOAuthHeaderDefaults(req)
|
||||
// Then force key headers to match Claude Code fingerprint regardless of what the client sent.
|
||||
// 使用 resolveWireCasing 确保 key 与真实 wire format 一致(如 "x-app" 而非 "X-App")
|
||||
for key, value := range claude.DefaultHeaders {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
setHeaderRaw(req.Header, resolveWireCasing(key), value)
|
||||
}
|
||||
// Real Claude CLI uses Accept: application/json (even for streaming).
|
||||
setHeaderRaw(req.Header, "Accept", "application/json")
|
||||
if isStream {
|
||||
setHeaderRaw(req.Header, "x-stainless-helper-method", "stream")
|
||||
}
|
||||
// Real Claude CLI 每个请求都会生成一个新的 UUID 放在 x-client-request-id。
|
||||
// 上游会以此作为会话/请求指纹的一部分,缺失或重复都可能触发第三方判定。
|
||||
if getHeaderRaw(req.Header, "x-client-request-id") == "" {
|
||||
setHeaderRaw(req.Header, "x-client-request-id", uuid.NewString())
|
||||
}
|
||||
}
|
||||
|
||||
func truncateForLog(b []byte, maxBytes int) string {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 2048
|
||||
}
|
||||
if len(b) > maxBytes {
|
||||
b = b[:maxBytes]
|
||||
}
|
||||
s := string(b)
|
||||
// 保持一行,避免污染日志格式
|
||||
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\\r")
|
||||
return s
|
||||
}
|
||||
|
||||
// buildCustomRelayURL 构建自定义中继转发 URL
|
||||
// 在 path 后附加 beta=true 和可选的 proxy 查询参数
|
||||
func (s *GatewayService) buildCustomRelayURL(baseURL, path string, account *Account) string {
|
||||
u := strings.TrimRight(baseURL, "/") + path + "?beta=true"
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL := account.Proxy.URL()
|
||||
if proxyURL != "" {
|
||||
u += "&proxy=" + url.QueryEscape(proxyURL)
|
||||
}
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (s *GatewayService) validateUpstreamBaseURL(raw string) (string, error) {
|
||||
if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
|
||||
normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid base_url: %w", err)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
|
||||
AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
|
||||
RequireAllowlist: true,
|
||||
AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid base_url: %w", err)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,978 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
)
|
||||
|
||||
func (s *GatewayService) getUserGroupRateMultiplier(ctx context.Context, userID, groupID int64, groupDefaultMultiplier float64) float64 {
|
||||
if s == nil {
|
||||
return groupDefaultMultiplier
|
||||
}
|
||||
resolver := s.userGroupRateResolver
|
||||
if resolver == nil {
|
||||
resolver = newUserGroupRateResolver(
|
||||
s.userGroupRateRepo,
|
||||
s.userGroupRateCache,
|
||||
resolveUserGroupRateCacheTTL(s.cfg),
|
||||
&s.userGroupRateSF,
|
||||
"service.gateway",
|
||||
)
|
||||
}
|
||||
return resolver.Resolve(ctx, userID, groupID, groupDefaultMultiplier)
|
||||
}
|
||||
|
||||
// RecordUsageInput 记录使用量的输入参数。
|
||||
// 异步 worker 只接收计费所需快照,不能持有 ParsedRequest/RequestBodyRef 这类大请求体引用。
|
||||
type RecordUsageInput struct {
|
||||
Result *ForwardResult
|
||||
APIKey *APIKey
|
||||
User *User
|
||||
Account *Account
|
||||
Subscription *UserSubscription // 可选:订阅信息
|
||||
InboundEndpoint string // 入站端点(客户端请求路径)
|
||||
UpstreamEndpoint string // 上游端点(标准化后的上游路径)
|
||||
UserAgent string // 请求的 User-Agent
|
||||
IPAddress string // 请求的客户端 IP 地址
|
||||
RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险
|
||||
ForceCacheBilling bool // 强制缓存计费:将 input_tokens 转为 cache_read 计费(用于粘性会话切换)
|
||||
APIKeyService APIKeyQuotaUpdater // 可选:用于更新API Key配额
|
||||
QuotaPlatform string // user×platform 配额计量平台:handler 在请求 ctx 内经 QuotaPlatform() 算定后传入(后扣运行在 worker 池 background ctx 上,取不到 ForcePlatform)
|
||||
|
||||
ChannelUsageFields // 渠道映射信息(由 handler 在 Forward 前解析)
|
||||
}
|
||||
|
||||
// APIKeyQuotaUpdater defines the interface for updating API Key quota and rate limit usage
|
||||
type APIKeyQuotaUpdater interface {
|
||||
UpdateQuotaUsed(ctx context.Context, apiKeyID int64, cost float64) error
|
||||
UpdateRateLimitUsage(ctx context.Context, apiKeyID int64, cost float64) error
|
||||
}
|
||||
|
||||
type apiKeyAuthCacheInvalidator interface {
|
||||
InvalidateAuthCacheByKey(ctx context.Context, key string)
|
||||
}
|
||||
|
||||
type usageLogBestEffortWriter interface {
|
||||
CreateBestEffort(ctx context.Context, log *UsageLog) error
|
||||
}
|
||||
|
||||
// postUsageBillingParams 统一扣费所需的参数
|
||||
type postUsageBillingParams struct {
|
||||
Cost *CostBreakdown
|
||||
User *User
|
||||
APIKey *APIKey
|
||||
Account *Account
|
||||
Subscription *UserSubscription
|
||||
RequestPayloadHash string
|
||||
IsSubscriptionBill bool
|
||||
AccountRateMultiplier float64
|
||||
APIKeyService APIKeyQuotaUpdater
|
||||
Platform string // 来自 APIKey 关联 Group 的平台标识
|
||||
}
|
||||
|
||||
// PlatformFromAPIKey 从 APIKey 关联的 Group 推导 platform 名称。
|
||||
// apiKey 为 nil 或 Group 信息缺失时返回空串(调用方据此 short-circuit quota 累加)。
|
||||
// 导出供 handler 层调用。
|
||||
func PlatformFromAPIKey(apiKey *APIKey) string {
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return ""
|
||||
}
|
||||
return apiKey.Group.Platform
|
||||
}
|
||||
|
||||
// QuotaPlatform 返回 user×platform 配额计量使用的平台标识。
|
||||
// 强制平台路由(如 /antigravity)优先按 ctx 中的 ForcePlatform 计量,否则回退到
|
||||
// APIKey 关联 Group 的平台。
|
||||
//
|
||||
// 注意:必须用带 ForcePlatform 的请求 context 调用(如 handler 的 c.Request.Context())。
|
||||
// 后扣运行在 worker 池的 background ctx 上没有 ForcePlatform,因此后扣平台由 handler
|
||||
// 预先算定、经 RecordUsageInput.QuotaPlatform 传入,不要在后扣链路用 worker ctx 调用本函数。
|
||||
func QuotaPlatform(ctx context.Context, apiKey *APIKey) string {
|
||||
if fp, ok := ctx.Value(ctxkey.ForcePlatform).(string); ok && fp != "" {
|
||||
return fp
|
||||
}
|
||||
return PlatformFromAPIKey(apiKey)
|
||||
}
|
||||
|
||||
func (p *postUsageBillingParams) shouldDeductAPIKeyQuota() bool {
|
||||
return p.Cost.ActualCost > 0 && p.APIKey.Quota > 0 && p.APIKeyService != nil
|
||||
}
|
||||
|
||||
func (p *postUsageBillingParams) shouldUpdateRateLimits() bool {
|
||||
return p.Cost.ActualCost > 0 && p.APIKey.HasRateLimits() && p.APIKeyService != nil
|
||||
}
|
||||
|
||||
func (p *postUsageBillingParams) shouldUpdateAccountQuota() bool {
|
||||
return p.Cost.TotalCost > 0 && p.Account.IsAPIKeyOrBedrock() && p.Account.HasAnyQuotaLimit()
|
||||
}
|
||||
|
||||
// postUsageBilling is the legacy fallback billing path used when the unified
|
||||
// billing repo is unavailable (nil). Production uses applyUsageBilling → repo.Apply
|
||||
// for atomic billing. This path only runs in tests or degraded mode.
|
||||
func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *billingDeps) {
|
||||
billingCtx, cancel := detachedBillingContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
cost := p.Cost
|
||||
|
||||
if p.IsSubscriptionBill {
|
||||
// Subscription usage tracked by ActualCost so group rate multiplier
|
||||
// consumes the quota at the expected speed.
|
||||
if cost.ActualCost > 0 {
|
||||
if err := deps.userSubRepo.IncrementUsage(billingCtx, p.Subscription.ID, cost.ActualCost); err != nil {
|
||||
slog.Error("increment subscription usage failed", "subscription_id", p.Subscription.ID, "error", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if cost.ActualCost > 0 {
|
||||
if err := deps.userRepo.DeductBalance(billingCtx, p.User.ID, cost.ActualCost); err != nil {
|
||||
slog.Error("deduct balance failed", "user_id", p.User.ID, "error", err)
|
||||
} else if deps.billingCacheService != nil {
|
||||
if err := deps.billingCacheService.InvalidateUserBalance(billingCtx, p.User.ID); err != nil {
|
||||
slog.Warn("invalidate balance cache after legacy deduction failed", "user_id", p.User.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if p.shouldDeductAPIKeyQuota() {
|
||||
if err := p.APIKeyService.UpdateQuotaUsed(billingCtx, p.APIKey.ID, cost.ActualCost); err != nil {
|
||||
slog.Error("update api key quota failed", "api_key_id", p.APIKey.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if p.shouldUpdateRateLimits() {
|
||||
if err := p.APIKeyService.UpdateRateLimitUsage(billingCtx, p.APIKey.ID, cost.ActualCost); err != nil {
|
||||
slog.Error("update api key rate limit usage failed", "api_key_id", p.APIKey.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if p.shouldUpdateAccountQuota() {
|
||||
accountCost := cost.TotalCost * p.AccountRateMultiplier
|
||||
if err := deps.accountRepo.IncrementQuotaUsed(billingCtx, p.Account.ID, accountCost); err != nil {
|
||||
slog.Error("increment account quota used failed", "account_id", p.Account.ID, "cost", accountCost, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Platform quota 累加(legacy 兜底路径):仅对 standard(余额)模式生效;订阅模式豁免;仅对有 limit 的用户写
|
||||
// - HasUserPlatformQuotaLimit 守卫:与正常路径对齐,无 limit 公司跳过
|
||||
// - 新增 Redis 同步写:enforcement 走 Redis,legacy 路径也必须同步写,否则 preflight 看不到消费
|
||||
// - flusher_enabled=false(降级):保留原有同步直写 DB
|
||||
// - flusher_enabled=true:跳过直写 DB,由 flusher 异步批量刷(markDirty 在 IncrementUserPlatformQuotaUsage 内部完成)
|
||||
// - 失败仅记 ALERT log + counter,不阻断主扣费流程
|
||||
if !p.IsSubscriptionBill && p.Platform != "" && cost.ActualCost > 0 && p.User != nil && deps.userPlatformQuotaRepo != nil {
|
||||
if deps.billingCacheService.HasUserPlatformQuotaLimit(billingCtx, p.User.ID, p.Platform) {
|
||||
deps.billingCacheService.IncrementUserPlatformQuotaUsage(p.User.ID, p.Platform, cost.ActualCost)
|
||||
if deps.cfg == nil || !deps.cfg.Database.UserPlatformQuotaFlusherEnabled {
|
||||
// 降级路径:flusher 未启用时保留原有同步直写 DB
|
||||
if err := deps.userPlatformQuotaRepo.IncrementUsageWithReset(billingCtx, p.User.ID, p.Platform, cost.ActualCost, time.Now().UTC()); err != nil {
|
||||
userPlatformQuotaDBIncrLegacyErrorTotal.Add(1)
|
||||
logger.LegacyPrintf("service.gateway", "ALERT: legacy incr user platform quota DB failed user=%d platform=%s cost=%f: %v", p.User.ID, p.Platform, cost.ActualCost, err)
|
||||
}
|
||||
}
|
||||
// flusher_enabled=true:不直写 DB,flusher 异步批量刷
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: finalizePostUsageBilling is NOT called here to avoid double-queuing
|
||||
// cache updates. The legacy path does DB writes directly; the finalize path
|
||||
// does cache queue + notifications. Notifications are dispatched separately
|
||||
// by the caller after recording the usage log.
|
||||
}
|
||||
|
||||
func resolveUsageBillingRequestID(ctx context.Context, upstreamRequestID string) string {
|
||||
if ctx != nil {
|
||||
if clientRequestID, _ := ctx.Value(ctxkey.ClientRequestID).(string); strings.TrimSpace(clientRequestID) != "" {
|
||||
return "client:" + strings.TrimSpace(clientRequestID)
|
||||
}
|
||||
if requestID, _ := ctx.Value(ctxkey.RequestID).(string); strings.TrimSpace(requestID) != "" {
|
||||
return "local:" + strings.TrimSpace(requestID)
|
||||
}
|
||||
}
|
||||
if requestID := strings.TrimSpace(upstreamRequestID); requestID != "" {
|
||||
return requestID
|
||||
}
|
||||
return "generated:" + generateRequestID()
|
||||
}
|
||||
|
||||
func resolveUsageBillingPayloadFingerprint(ctx context.Context, requestPayloadHash string) string {
|
||||
if payloadHash := strings.TrimSpace(requestPayloadHash); payloadHash != "" {
|
||||
return payloadHash
|
||||
}
|
||||
if ctx != nil {
|
||||
if clientRequestID, _ := ctx.Value(ctxkey.ClientRequestID).(string); strings.TrimSpace(clientRequestID) != "" {
|
||||
return "client:" + strings.TrimSpace(clientRequestID)
|
||||
}
|
||||
if requestID, _ := ctx.Value(ctxkey.RequestID).(string); strings.TrimSpace(requestID) != "" {
|
||||
return "local:" + strings.TrimSpace(requestID)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildUsageBillingCommand(requestID string, usageLog *UsageLog, p *postUsageBillingParams) *UsageBillingCommand {
|
||||
if p == nil || p.Cost == nil || p.APIKey == nil || p.User == nil || p.Account == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := &UsageBillingCommand{
|
||||
RequestID: requestID,
|
||||
APIKeyID: p.APIKey.ID,
|
||||
UserID: p.User.ID,
|
||||
AccountID: p.Account.ID,
|
||||
AccountType: p.Account.Type,
|
||||
RequestPayloadHash: strings.TrimSpace(p.RequestPayloadHash),
|
||||
}
|
||||
if usageLog != nil {
|
||||
cmd.Model = usageLog.Model
|
||||
cmd.BillingType = usageLog.BillingType
|
||||
cmd.InputTokens = usageLog.InputTokens
|
||||
cmd.OutputTokens = usageLog.OutputTokens
|
||||
cmd.CacheCreationTokens = usageLog.CacheCreationTokens
|
||||
cmd.CacheReadTokens = usageLog.CacheReadTokens
|
||||
cmd.ImageCount = usageLog.ImageCount
|
||||
if usageLog.ServiceTier != nil {
|
||||
cmd.ServiceTier = *usageLog.ServiceTier
|
||||
}
|
||||
if usageLog.ReasoningEffort != nil {
|
||||
cmd.ReasoningEffort = *usageLog.ReasoningEffort
|
||||
}
|
||||
if usageLog.SubscriptionID != nil {
|
||||
cmd.SubscriptionID = usageLog.SubscriptionID
|
||||
}
|
||||
}
|
||||
|
||||
// Record subscription / balance cost using ActualCost so the group (and any
|
||||
// user-specific) rate multiplier consumes subscription quota at the expected
|
||||
// speed. TotalCost remains the raw (pre-multiplier) value; downstream guards
|
||||
// on "> 0" still correctly skip free subscriptions (RateMultiplier == 0).
|
||||
if p.IsSubscriptionBill && p.Subscription != nil && p.Cost.TotalCost > 0 {
|
||||
cmd.SubscriptionID = &p.Subscription.ID
|
||||
cmd.SubscriptionCost = p.Cost.ActualCost
|
||||
} else if p.Cost.ActualCost > 0 {
|
||||
cmd.BalanceCost = p.Cost.ActualCost
|
||||
}
|
||||
|
||||
if p.shouldDeductAPIKeyQuota() {
|
||||
cmd.APIKeyQuotaCost = p.Cost.ActualCost
|
||||
}
|
||||
if p.shouldUpdateRateLimits() {
|
||||
cmd.APIKeyRateLimitCost = p.Cost.ActualCost
|
||||
}
|
||||
if p.shouldUpdateAccountQuota() {
|
||||
cmd.AccountQuotaCost = p.Cost.TotalCost * p.AccountRateMultiplier
|
||||
}
|
||||
|
||||
cmd.Normalize()
|
||||
return cmd
|
||||
}
|
||||
|
||||
func applyUsageBilling(ctx context.Context, requestID string, usageLog *UsageLog, p *postUsageBillingParams, deps *billingDeps, repo UsageBillingRepository) (bool, error) {
|
||||
if p == nil || deps == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cmd := buildUsageBillingCommand(requestID, usageLog, p)
|
||||
if cmd == nil || cmd.RequestID == "" || repo == nil {
|
||||
postUsageBilling(ctx, p, deps)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
billingCtx, cancel := detachedBillingContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
result, err := repo.Apply(billingCtx, cmd)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if result == nil || !result.Applied {
|
||||
deps.deferredService.ScheduleLastUsedUpdate(p.Account.ID)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if result.APIKeyQuotaExhausted {
|
||||
if invalidator, ok := p.APIKeyService.(apiKeyAuthCacheInvalidator); ok && p.APIKey != nil && p.APIKey.Key != "" {
|
||||
invalidator.InvalidateAuthCacheByKey(billingCtx, p.APIKey.Key)
|
||||
}
|
||||
}
|
||||
|
||||
finalizePostUsageBilling(billingCtx, p, deps, result)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func finalizePostUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *billingDeps, result *UsageBillingApplyResult) {
|
||||
if p == nil || p.Cost == nil || deps == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if p.IsSubscriptionBill {
|
||||
if p.Cost.ActualCost > 0 && p.User != nil && p.APIKey != nil && p.APIKey.GroupID != nil {
|
||||
deps.billingCacheService.QueueUpdateSubscriptionUsage(p.User.ID, *p.APIKey.GroupID, p.Cost.ActualCost)
|
||||
}
|
||||
} else if p.Cost.ActualCost > 0 && p.User != nil {
|
||||
syncBalanceCacheAfterDeduction(ctx, p, deps, result)
|
||||
}
|
||||
|
||||
if p.Cost.ActualCost > 0 && p.APIKey != nil && p.APIKey.HasRateLimits() {
|
||||
deps.billingCacheService.QueueUpdateAPIKeyRateLimitUsage(p.APIKey.ID, p.Cost.ActualCost)
|
||||
}
|
||||
|
||||
deps.deferredService.ScheduleLastUsedUpdate(p.Account.ID)
|
||||
|
||||
// Platform quota 累加:仅在 standard(余额)模式生效;订阅模式豁免;仅对有 limit 的用户写
|
||||
// Redis 同步写 + DB 异步持久化(flag=false 降级)或 flusher 异步刷(flag=true):
|
||||
// - HasUserPlatformQuotaLimit 守卫:无 limit 的公司跳过,避免无效写入 + 浪费 Redis 容量
|
||||
// - Redis 同步:确保下次 preflight 立即看到最新 usage,把 TOCTOU 超支窗口
|
||||
// 限制在并发 in-flight 请求数量内(旧实现的异步入队会让超支无限累积直到 worker 处理)
|
||||
// - DB 异步(flusher_enabled=false):在独立 goroutine 中走 detached context,失败用 ALERT log 触发 oncall 对账
|
||||
// - flusher_enabled=true:不直写 DB,由 flusher 异步批量刷(markDirty 已在 IncrementUserPlatformQuotaUsage 内部完成)
|
||||
if !p.IsSubscriptionBill && p.Platform != "" && p.Cost.ActualCost > 0 && p.User != nil && deps.userPlatformQuotaRepo != nil {
|
||||
if deps.billingCacheService.HasUserPlatformQuotaLimit(ctx, p.User.ID, p.Platform) {
|
||||
deps.billingCacheService.IncrementUserPlatformQuotaUsage(p.User.ID, p.Platform, p.Cost.ActualCost)
|
||||
if deps.cfg == nil || !deps.cfg.Database.UserPlatformQuotaFlusherEnabled {
|
||||
// 降级路径:flusher 未启用时保留原有异步直写 DB
|
||||
dbCtx, dbCancel := detachUpstreamContext(ctx)
|
||||
userID, platform, cost := p.User.ID, p.Platform, p.Cost.ActualCost
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.LegacyPrintf("service.gateway", "ALERT: panic in user platform quota incr goroutine user=%d platform=%s: %v", userID, platform, r)
|
||||
}
|
||||
}()
|
||||
defer dbCancel()
|
||||
if err := deps.userPlatformQuotaRepo.IncrementUsageWithReset(dbCtx, userID, platform, cost, time.Now().UTC()); err != nil {
|
||||
// 失败计数器:暴露给 GatewayUserPlatformQuotaIncrStats(),由 ops 面板做斜率告警。
|
||||
userPlatformQuotaDBIncrErrorTotal.Add(1)
|
||||
// ALERT 级别:DB 持久化失败意味着 Redis cache 失效后该笔 cost 永久丢失,
|
||||
// 用户配额视图与实际消费会偏差,oncall 需要据此对账或人工补录。
|
||||
logger.LegacyPrintf("service.gateway", "ALERT: incr user platform quota DB failed user=%d platform=%s cost=%f: %v", userID, platform, cost, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
// flusher_enabled=true:不直写 DB,flusher 异步批量刷
|
||||
}
|
||||
}
|
||||
|
||||
// Notification checks run async — all parameters are already captured,
|
||||
// no dependency on the request context or upstream connection.
|
||||
go notifyBalanceLow(p, deps, result)
|
||||
go notifyAccountQuota(p, deps, result)
|
||||
}
|
||||
|
||||
func syncBalanceCacheAfterDeduction(ctx context.Context, p *postUsageBillingParams, deps *billingDeps, result *UsageBillingApplyResult) {
|
||||
if p == nil || p.Cost == nil || p.User == nil || deps == nil || deps.billingCacheService == nil {
|
||||
return
|
||||
}
|
||||
if result != nil && result.NewBalance != nil && deps.billingCacheService.balanceBelowEligibilityThreshold(*result.NewBalance) {
|
||||
if err := deps.billingCacheService.InvalidateUserBalance(ctx, p.User.ID); err != nil {
|
||||
slog.Warn("invalidate balance cache after exhausted deduction failed",
|
||||
"user_id", p.User.ID,
|
||||
"new_balance", *result.NewBalance,
|
||||
"balance_overdrafted", result.BalanceOverdrafted,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
deps.billingCacheService.QueueDeductBalance(p.User.ID, p.Cost.ActualCost)
|
||||
}
|
||||
|
||||
// notifyBalanceLow sends balance low notification after deduction.
|
||||
// When result.NewBalance is available (from DB transaction RETURNING), it is used directly
|
||||
// to reconstruct oldBalance, avoiding stale Redis reads and concurrent-deduction races.
|
||||
func notifyBalanceLow(p *postUsageBillingParams, deps *billingDeps, result *UsageBillingApplyResult) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("panic in notifyBalanceLow", "recover", r)
|
||||
}
|
||||
}()
|
||||
if p.IsSubscriptionBill || p.Cost.ActualCost <= 0 || p.User == nil || deps.balanceNotifyService == nil {
|
||||
slog.Debug("notifyBalanceLow: skipped",
|
||||
"is_subscription", p.IsSubscriptionBill,
|
||||
"actual_cost", p.Cost.ActualCost,
|
||||
"user_nil", p.User == nil,
|
||||
"service_nil", deps.balanceNotifyService == nil,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
oldBalance := resolveOldBalance(p, result)
|
||||
slog.Debug("notifyBalanceLow: calling CheckBalanceAfterDeduction",
|
||||
"user_id", p.User.ID,
|
||||
"old_balance", oldBalance,
|
||||
"cost", p.Cost.ActualCost,
|
||||
"notify_enabled", p.User.BalanceNotifyEnabled,
|
||||
"threshold", p.User.BalanceNotifyThreshold,
|
||||
"result_has_new_balance", result != nil && result.NewBalance != nil,
|
||||
)
|
||||
deps.balanceNotifyService.CheckBalanceAfterDeduction(context.Background(), p.User, oldBalance, p.Cost.ActualCost)
|
||||
}
|
||||
|
||||
// resolveOldBalance returns the pre-deduction balance.
|
||||
// Prefers the DB transaction result (newBalance + cost) over snapshot.
|
||||
func resolveOldBalance(p *postUsageBillingParams, result *UsageBillingApplyResult) float64 {
|
||||
if result != nil && result.NewBalance != nil {
|
||||
return *result.NewBalance + p.Cost.ActualCost
|
||||
}
|
||||
// Legacy fallback: snapshot balance from request context
|
||||
return p.User.Balance
|
||||
}
|
||||
|
||||
// notifyAccountQuota sends account quota threshold notification after increment.
|
||||
// When result.QuotaState is available (from DB transaction RETURNING), it is passed directly
|
||||
// to avoid a separate DB read that may see stale or concurrently-modified data.
|
||||
func notifyAccountQuota(p *postUsageBillingParams, deps *billingDeps, result *UsageBillingApplyResult) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("panic in notifyAccountQuota", "recover", r)
|
||||
}
|
||||
}()
|
||||
if p.Cost.TotalCost <= 0 || p.Account == nil || !p.Account.IsAPIKeyOrBedrock() || deps.balanceNotifyService == nil {
|
||||
slog.Debug("notifyAccountQuota: skipped",
|
||||
"total_cost", p.Cost.TotalCost,
|
||||
"account_nil", p.Account == nil,
|
||||
"is_apikey_or_bedrock", p.Account != nil && p.Account.IsAPIKeyOrBedrock(),
|
||||
"service_nil", deps.balanceNotifyService == nil,
|
||||
)
|
||||
return
|
||||
}
|
||||
accountCost := p.Cost.TotalCost * p.AccountRateMultiplier
|
||||
var quotaState *AccountQuotaState
|
||||
if result != nil {
|
||||
quotaState = result.QuotaState
|
||||
}
|
||||
slog.Debug("notifyAccountQuota: calling CheckAccountQuotaAfterIncrement",
|
||||
"account_id", p.Account.ID,
|
||||
"account_cost", accountCost,
|
||||
"has_quota_state", quotaState != nil,
|
||||
)
|
||||
deps.balanceNotifyService.CheckAccountQuotaAfterIncrement(context.Background(), p.Account, accountCost, quotaState)
|
||||
}
|
||||
|
||||
func detachedBillingContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
base := context.Background()
|
||||
if ctx != nil {
|
||||
base = context.WithoutCancel(ctx)
|
||||
}
|
||||
return context.WithTimeout(base, postUsageBillingTimeout)
|
||||
}
|
||||
|
||||
func detachStreamUpstreamContext(ctx context.Context, stream bool) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
return context.Background(), func() {}
|
||||
}
|
||||
if !stream {
|
||||
return ctx, func() {}
|
||||
}
|
||||
return context.WithoutCancel(ctx), func() {}
|
||||
}
|
||||
|
||||
func detachUpstreamContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
return context.Background(), func() {}
|
||||
}
|
||||
return context.WithoutCancel(ctx), func() {}
|
||||
}
|
||||
|
||||
// billingDeps 扣费逻辑依赖的服务(由各 gateway service 提供)
|
||||
type billingDeps struct {
|
||||
accountRepo AccountRepository
|
||||
userRepo UserRepository
|
||||
userSubRepo UserSubscriptionRepository
|
||||
billingCacheService *BillingCacheService
|
||||
deferredService *DeferredService
|
||||
balanceNotifyService *BalanceNotifyService
|
||||
userPlatformQuotaRepo UserPlatformQuotaRepository
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func (s *GatewayService) billingDeps() *billingDeps {
|
||||
return &billingDeps{
|
||||
accountRepo: s.accountRepo,
|
||||
userRepo: s.userRepo,
|
||||
userSubRepo: s.userSubRepo,
|
||||
billingCacheService: s.billingCacheService,
|
||||
deferredService: s.deferredService,
|
||||
balanceNotifyService: s.balanceNotifyService,
|
||||
userPlatformQuotaRepo: s.userPlatformQuotaRepo,
|
||||
cfg: s.cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func writeUsageLogBestEffort(ctx context.Context, repo UsageLogRepository, usageLog *UsageLog, logKey string) {
|
||||
if repo == nil || usageLog == nil {
|
||||
return
|
||||
}
|
||||
usageCtx, cancel := detachedBillingContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
if writer, ok := repo.(usageLogBestEffortWriter); ok {
|
||||
if err := writer.CreateBestEffort(usageCtx, usageLog); err != nil {
|
||||
logger.LegacyPrintf(logKey, "Create usage log failed: %v", err)
|
||||
// 计费已在此前完成,日志必须落库:dropped(批处理队列超时)同样走同步兜底,
|
||||
// 否则会出现“已扣费但无 usage_log”的对账缺口(issue #3656)。
|
||||
// 重复写入由 usage_logs 的 ON CONFLICT (request_id, api_key_id) DO NOTHING 防护。
|
||||
fallbackCtx := usageCtx
|
||||
if usageCtx.Err() != nil {
|
||||
// usageCtx 已耗尽(best-effort 入队阻塞到期限):换新的 detached 窗口,避免兜底必然失败。
|
||||
var fallbackCancel context.CancelFunc
|
||||
fallbackCtx, fallbackCancel = detachedBillingContext(context.Background())
|
||||
defer fallbackCancel()
|
||||
}
|
||||
if _, syncErr := repo.Create(fallbackCtx, usageLog); syncErr != nil {
|
||||
logger.LegacyPrintf(logKey, "Create usage log sync fallback failed: %v", syncErr)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := repo.Create(usageCtx, usageLog); err != nil {
|
||||
logger.LegacyPrintf(logKey, "Create usage log failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// recordUsageOpts 内部选项,参数化普通计费与长上下文计费的差异点。
|
||||
type recordUsageOpts struct {
|
||||
// 长上下文计费(仅 Gemini 路径需要)
|
||||
LongContextThreshold int
|
||||
LongContextMultiplier float64
|
||||
}
|
||||
|
||||
// RecordUsage 记录使用量并扣费(或更新订阅用量)
|
||||
func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInput) error {
|
||||
return s.recordUsageCore(ctx, &recordUsageCoreInput{
|
||||
Result: input.Result,
|
||||
APIKey: input.APIKey,
|
||||
User: input.User,
|
||||
Account: input.Account,
|
||||
Subscription: input.Subscription,
|
||||
InboundEndpoint: input.InboundEndpoint,
|
||||
UpstreamEndpoint: input.UpstreamEndpoint,
|
||||
UserAgent: input.UserAgent,
|
||||
IPAddress: input.IPAddress,
|
||||
RequestPayloadHash: input.RequestPayloadHash,
|
||||
ForceCacheBilling: input.ForceCacheBilling,
|
||||
APIKeyService: input.APIKeyService,
|
||||
QuotaPlatform: input.QuotaPlatform,
|
||||
ChannelUsageFields: input.ChannelUsageFields,
|
||||
}, &recordUsageOpts{})
|
||||
}
|
||||
|
||||
// RecordUsageLongContextInput 记录使用量的输入参数(支持长上下文双倍计费)
|
||||
type RecordUsageLongContextInput struct {
|
||||
Result *ForwardResult
|
||||
APIKey *APIKey
|
||||
User *User
|
||||
Account *Account
|
||||
Subscription *UserSubscription // 可选:订阅信息
|
||||
InboundEndpoint string // 入站端点(客户端请求路径)
|
||||
UpstreamEndpoint string // 上游端点(标准化后的上游路径)
|
||||
UserAgent string // 请求的 User-Agent
|
||||
IPAddress string // 请求的客户端 IP 地址
|
||||
RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险
|
||||
LongContextThreshold int // 长上下文阈值(如 200000)
|
||||
LongContextMultiplier float64 // 超出阈值部分的倍率(如 2.0)
|
||||
ForceCacheBilling bool // 强制缓存计费:将 input_tokens 转为 cache_read 计费(用于粘性会话切换)
|
||||
APIKeyService APIKeyQuotaUpdater // API Key 配额服务(可选)
|
||||
QuotaPlatform string // user×platform 配额计量平台:handler 在请求 ctx 内经 QuotaPlatform() 算定后传入(后扣运行在 worker 池 background ctx 上,取不到 ForcePlatform)
|
||||
|
||||
ChannelUsageFields // 渠道映射信息(由 handler 在 Forward 前解析)
|
||||
}
|
||||
|
||||
// RecordUsageWithLongContext 记录使用量并扣费,支持长上下文双倍计费(用于 Gemini)
|
||||
func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *RecordUsageLongContextInput) error {
|
||||
return s.recordUsageCore(ctx, &recordUsageCoreInput{
|
||||
Result: input.Result,
|
||||
APIKey: input.APIKey,
|
||||
User: input.User,
|
||||
Account: input.Account,
|
||||
Subscription: input.Subscription,
|
||||
InboundEndpoint: input.InboundEndpoint,
|
||||
UpstreamEndpoint: input.UpstreamEndpoint,
|
||||
UserAgent: input.UserAgent,
|
||||
IPAddress: input.IPAddress,
|
||||
RequestPayloadHash: input.RequestPayloadHash,
|
||||
ForceCacheBilling: input.ForceCacheBilling,
|
||||
APIKeyService: input.APIKeyService,
|
||||
QuotaPlatform: input.QuotaPlatform,
|
||||
ChannelUsageFields: input.ChannelUsageFields,
|
||||
}, &recordUsageOpts{
|
||||
LongContextThreshold: input.LongContextThreshold,
|
||||
LongContextMultiplier: input.LongContextMultiplier,
|
||||
})
|
||||
}
|
||||
|
||||
// recordUsageCoreInput 是 recordUsageCore 的公共输入字段,从两种输入结构体中提取。
|
||||
type recordUsageCoreInput struct {
|
||||
Result *ForwardResult
|
||||
APIKey *APIKey
|
||||
User *User
|
||||
Account *Account
|
||||
Subscription *UserSubscription
|
||||
InboundEndpoint string
|
||||
UpstreamEndpoint string
|
||||
UserAgent string
|
||||
IPAddress string
|
||||
RequestPayloadHash string
|
||||
ForceCacheBilling bool
|
||||
APIKeyService APIKeyQuotaUpdater
|
||||
QuotaPlatform string
|
||||
ChannelUsageFields
|
||||
}
|
||||
|
||||
// recordUsageCore 是 RecordUsage 和 RecordUsageWithLongContext 的统一实现。
|
||||
// LongContextThreshold > 0 时 Token 计费回退走 CalculateCostWithLongContext。
|
||||
func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsageCoreInput, opts *recordUsageOpts) error {
|
||||
result := input.Result
|
||||
apiKey := input.APIKey
|
||||
user := input.User
|
||||
account := input.Account
|
||||
subscription := input.Subscription
|
||||
ApplyForwardImageBillingResolution(result)
|
||||
|
||||
// 强制缓存计费:将 input_tokens 转为 cache_read_input_tokens
|
||||
// 用于粘性会话切换时的特殊计费处理
|
||||
if input.ForceCacheBilling && result.Usage.InputTokens > 0 {
|
||||
logger.LegacyPrintf("service.gateway", "force_cache_billing: %d input_tokens → cache_read_input_tokens (account=%d)",
|
||||
result.Usage.InputTokens, account.ID)
|
||||
result.Usage.CacheReadInputTokens += result.Usage.InputTokens
|
||||
result.Usage.InputTokens = 0
|
||||
}
|
||||
|
||||
// Cache TTL Override: 确保计费时 token 分类与账号设置一致。
|
||||
// 账号级设置优先;全局 1h 请求注入开启时,默认把 usage 计费归回 5m。
|
||||
cacheTTLOverridden := false
|
||||
if overrideTarget, ok := s.resolveCacheTTLUsageOverrideTarget(ctx, account); ok {
|
||||
applyCacheTTLOverride(&result.Usage, overrideTarget)
|
||||
cacheTTLOverridden = (result.Usage.CacheCreation5mTokens + result.Usage.CacheCreation1hTokens) > 0
|
||||
}
|
||||
|
||||
// 获取费率倍数(优先级:用户专属 > 分组默认 > 系统默认)
|
||||
multiplier := 1.0
|
||||
if s.cfg != nil {
|
||||
multiplier = s.cfg.Default.RateMultiplier
|
||||
}
|
||||
if apiKey.GroupID != nil && apiKey.Group != nil {
|
||||
groupDefault := apiKey.Group.RateMultiplier
|
||||
multiplier = s.getUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, groupDefault)
|
||||
}
|
||||
// token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算,
|
||||
// 不并入上面的 getUserGroupRateMultiplier,以免污染 user:group 倍率缓存。
|
||||
multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, multiplier, timezone.Now())
|
||||
|
||||
// 确定计费模型
|
||||
billingModel := forwardResultBillingModel(result.Model, result.UpstreamModel)
|
||||
if input.BillingModelSource == BillingModelSourceChannelMapped && input.ChannelMappedModel != "" {
|
||||
billingModel = input.ChannelMappedModel
|
||||
}
|
||||
if input.BillingModelSource == BillingModelSourceRequested && input.OriginalModel != "" {
|
||||
billingModel = input.OriginalModel
|
||||
}
|
||||
|
||||
// 确定 RequestedModel(渠道映射前的原始模型)
|
||||
requestedModel := result.Model
|
||||
if input.OriginalModel != "" {
|
||||
requestedModel = input.OriginalModel
|
||||
}
|
||||
|
||||
// 计算费用
|
||||
cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, opts)
|
||||
|
||||
// 判断计费方式:订阅模式 vs 余额模式
|
||||
isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
|
||||
billingType := BillingTypeBalance
|
||||
if isSubscriptionBilling {
|
||||
billingType = BillingTypeSubscription
|
||||
}
|
||||
|
||||
// 创建使用日志
|
||||
accountRateMultiplier := account.BillingRateMultiplier()
|
||||
usageLog := s.buildRecordUsageLog(ctx, input, result, apiKey, user, account, subscription,
|
||||
requestedModel, multiplier, imageMultiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost, opts)
|
||||
|
||||
// 计算账号统计定价费用(使用最终上游模型匹配自定义规则)
|
||||
if apiKey.GroupID != nil {
|
||||
applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService,
|
||||
account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model,
|
||||
// Anthropic's input_tokens excludes cache_read and cache_creation (billed separately);
|
||||
// OpenAI gateway uses actualInputTokens which also excludes cache_read for the same reason.
|
||||
UsageTokens{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
||||
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
||||
ImageOutputTokens: result.Usage.ImageOutputTokens,
|
||||
},
|
||||
cost.TotalCost,
|
||||
)
|
||||
}
|
||||
|
||||
if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple {
|
||||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
|
||||
logger.LegacyPrintf("service.gateway", "[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens())
|
||||
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 配额平台由 handler 在请求 ctx 内经 QuotaPlatform() 算定并通过 input 传入;
|
||||
// 后扣运行在 worker 池的 background ctx 上,无法再从 ctx 取 ForcePlatform。
|
||||
// 缺省(未设置)时回退到分组平台,保持对其它调用方的兼容。
|
||||
quotaPlatform := input.QuotaPlatform
|
||||
if quotaPlatform == "" {
|
||||
quotaPlatform = PlatformFromAPIKey(apiKey)
|
||||
}
|
||||
requestID := usageLog.RequestID
|
||||
_, billingErr := applyUsageBilling(ctx, requestID, usageLog, &postUsageBillingParams{
|
||||
Cost: cost,
|
||||
User: user,
|
||||
APIKey: apiKey,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
RequestPayloadHash: resolveUsageBillingPayloadFingerprint(ctx, input.RequestPayloadHash),
|
||||
IsSubscriptionBill: isSubscriptionBilling,
|
||||
AccountRateMultiplier: accountRateMultiplier,
|
||||
APIKeyService: input.APIKeyService,
|
||||
Platform: quotaPlatform,
|
||||
}, s.billingDeps(), s.usageBillingRepo)
|
||||
|
||||
if billingErr != nil {
|
||||
return billingErr
|
||||
}
|
||||
writeUsageLogBestEffort(ctx, s.usageLogRepo, usageLog, "service.gateway")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// calculateRecordUsageCost 根据请求类型和选项计算费用。
|
||||
func (s *GatewayService) calculateRecordUsageCost(
|
||||
ctx context.Context,
|
||||
result *ForwardResult,
|
||||
apiKey *APIKey,
|
||||
billingModel string,
|
||||
multiplier float64,
|
||||
imageMultiplier float64,
|
||||
opts *recordUsageOpts,
|
||||
) *CostBreakdown {
|
||||
// 图片生成:渠道定价为 token 计费时走 token 路径,否则走图片计费
|
||||
if result.ImageCount > 0 {
|
||||
if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil && resolved.Mode == BillingModeToken {
|
||||
return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, opts)
|
||||
}
|
||||
return s.calculateImageCost(ctx, result, apiKey, billingModel, imageMultiplier)
|
||||
}
|
||||
|
||||
// Token 计费
|
||||
return s.calculateTokenCost(ctx, result, apiKey, billingModel, multiplier, opts)
|
||||
}
|
||||
|
||||
// resolveChannelPricing 检查指定模型是否存在渠道级别定价。
|
||||
// 返回非 nil 的 ResolvedPricing 表示有渠道定价,nil 表示走默认定价路径。
|
||||
func (s *GatewayService) resolveChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing {
|
||||
if s.resolver == nil || apiKey.Group == nil {
|
||||
return nil
|
||||
}
|
||||
gid := apiKey.Group.ID
|
||||
resolved := s.resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: &gid})
|
||||
if resolved.Source == PricingSourceChannel {
|
||||
return resolved
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// calculateImageCost 计算图片生成费用:渠道级别定价优先,否则走按次计费。
|
||||
func (s *GatewayService) calculateImageCost(
|
||||
ctx context.Context,
|
||||
result *ForwardResult,
|
||||
apiKey *APIKey,
|
||||
billingModel string,
|
||||
multiplier float64,
|
||||
) *CostBreakdown {
|
||||
sizeTier := NormalizeImageBillingTierOrDefault(result.ImageSize)
|
||||
if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil {
|
||||
tokens := UsageTokens{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
ImageOutputTokens: result.Usage.ImageOutputTokens,
|
||||
}
|
||||
gid := apiKey.Group.ID
|
||||
cost, err := s.billingService.CalculateCostUnified(CostInput{
|
||||
Ctx: ctx,
|
||||
Model: billingModel,
|
||||
GroupID: &gid,
|
||||
Tokens: tokens,
|
||||
RequestCount: result.ImageCount,
|
||||
SizeTier: sizeTier,
|
||||
RateMultiplier: multiplier,
|
||||
Resolver: s.resolver,
|
||||
Resolved: resolved,
|
||||
})
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.gateway", "Calculate image token cost failed: %v", err)
|
||||
return &CostBreakdown{ActualCost: 0}
|
||||
}
|
||||
return cost
|
||||
}
|
||||
|
||||
var groupConfig *ImagePriceConfig
|
||||
if apiKey.Group != nil {
|
||||
groupConfig = &ImagePriceConfig{
|
||||
Price1K: apiKey.Group.ImagePrice1K,
|
||||
Price2K: apiKey.Group.ImagePrice2K,
|
||||
Price4K: apiKey.Group.ImagePrice4K,
|
||||
}
|
||||
}
|
||||
return s.billingService.CalculateImageCost(billingModel, sizeTier, result.ImageCount, groupConfig, multiplier)
|
||||
}
|
||||
|
||||
// calculateTokenCost 计算 Token 计费:根据 opts 决定走普通/长上下文/渠道统一计费。
|
||||
func (s *GatewayService) calculateTokenCost(
|
||||
ctx context.Context,
|
||||
result *ForwardResult,
|
||||
apiKey *APIKey,
|
||||
billingModel string,
|
||||
multiplier float64,
|
||||
opts *recordUsageOpts,
|
||||
) *CostBreakdown {
|
||||
tokens := UsageTokens{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
||||
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
||||
CacheCreation5mTokens: result.Usage.CacheCreation5mTokens,
|
||||
CacheCreation1hTokens: result.Usage.CacheCreation1hTokens,
|
||||
ImageOutputTokens: result.Usage.ImageOutputTokens,
|
||||
}
|
||||
|
||||
var cost *CostBreakdown
|
||||
var err error
|
||||
|
||||
// 优先尝试渠道定价 → CalculateCostUnified
|
||||
if resolved := s.resolveChannelPricing(ctx, billingModel, apiKey); resolved != nil {
|
||||
gid := apiKey.Group.ID
|
||||
cost, err = s.billingService.CalculateCostUnified(CostInput{
|
||||
Ctx: ctx,
|
||||
Model: billingModel,
|
||||
GroupID: &gid,
|
||||
Tokens: tokens,
|
||||
RequestCount: 1,
|
||||
RateMultiplier: multiplier,
|
||||
Resolver: s.resolver,
|
||||
Resolved: resolved,
|
||||
})
|
||||
} else if opts.LongContextThreshold > 0 {
|
||||
// 长上下文双倍计费(如 Gemini 200K 阈值)
|
||||
cost, err = s.billingService.CalculateCostWithLongContext(billingModel, tokens, multiplier, opts.LongContextThreshold, opts.LongContextMultiplier)
|
||||
} else {
|
||||
cost, err = s.billingService.CalculateCost(billingModel, tokens, multiplier)
|
||||
}
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err)
|
||||
return &CostBreakdown{ActualCost: 0}
|
||||
}
|
||||
return cost
|
||||
}
|
||||
|
||||
// buildRecordUsageLog 构建使用日志并设置计费模式。
|
||||
func (s *GatewayService) buildRecordUsageLog(
|
||||
ctx context.Context,
|
||||
input *recordUsageCoreInput,
|
||||
result *ForwardResult,
|
||||
apiKey *APIKey,
|
||||
user *User,
|
||||
account *Account,
|
||||
subscription *UserSubscription,
|
||||
requestedModel string,
|
||||
multiplier float64,
|
||||
imageMultiplier float64,
|
||||
accountRateMultiplier float64,
|
||||
billingType int8,
|
||||
cacheTTLOverridden bool,
|
||||
cost *CostBreakdown,
|
||||
opts *recordUsageOpts,
|
||||
) *UsageLog {
|
||||
durationMs := int(result.Duration.Milliseconds())
|
||||
requestID := resolveUsageBillingRequestID(ctx, result.RequestID)
|
||||
usageLog := &UsageLog{
|
||||
UserID: user.ID,
|
||||
APIKeyID: apiKey.ID,
|
||||
AccountID: account.ID,
|
||||
RequestID: requestID,
|
||||
Model: result.Model,
|
||||
RequestedModel: requestedModel,
|
||||
UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, result.Model),
|
||||
ReasoningEffort: result.ReasoningEffort,
|
||||
InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint),
|
||||
UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint),
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
|
||||
CacheReadTokens: result.Usage.CacheReadInputTokens,
|
||||
CacheCreation5mTokens: result.Usage.CacheCreation5mTokens,
|
||||
CacheCreation1hTokens: result.Usage.CacheCreation1hTokens,
|
||||
ImageOutputTokens: result.Usage.ImageOutputTokens,
|
||||
RateMultiplier: multiplier,
|
||||
AccountRateMultiplier: &accountRateMultiplier,
|
||||
BillingType: billingType,
|
||||
BillingMode: resolveBillingMode(result, cost),
|
||||
Stream: result.Stream,
|
||||
DurationMs: &durationMs,
|
||||
FirstTokenMs: result.FirstTokenMs,
|
||||
ImageCount: result.ImageCount,
|
||||
ImageSize: optionalTrimmedStringPtr(result.ImageSize),
|
||||
ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize),
|
||||
ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize),
|
||||
ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource),
|
||||
ImageSizeBreakdown: result.ImageSizeBreakdown,
|
||||
CacheTTLOverridden: cacheTTLOverridden,
|
||||
ChannelID: optionalInt64Ptr(input.ChannelID),
|
||||
ModelMappingChain: optionalTrimmedStringPtr(input.ModelMappingChain),
|
||||
UserAgent: optionalTrimmedStringPtr(input.UserAgent),
|
||||
IPAddress: optionalTrimmedStringPtr(input.IPAddress),
|
||||
GroupID: apiKey.GroupID,
|
||||
SubscriptionID: optionalSubscriptionID(subscription),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if result.ImageCount > 0 && (cost == nil || cost.BillingMode != string(BillingModeToken)) {
|
||||
usageLog.RateMultiplier = imageMultiplier
|
||||
}
|
||||
if cost != nil {
|
||||
usageLog.InputCost = cost.InputCost
|
||||
usageLog.OutputCost = cost.OutputCost
|
||||
usageLog.ImageOutputCost = cost.ImageOutputCost
|
||||
usageLog.CacheCreationCost = cost.CacheCreationCost
|
||||
usageLog.CacheReadCost = cost.CacheReadCost
|
||||
usageLog.TotalCost = cost.TotalCost
|
||||
usageLog.ActualCost = cost.ActualCost
|
||||
}
|
||||
|
||||
return usageLog
|
||||
}
|
||||
|
||||
// resolveBillingMode 根据计费结果和请求类型确定计费模式。
|
||||
func resolveBillingMode(result *ForwardResult, cost *CostBreakdown) *string {
|
||||
var mode string
|
||||
switch {
|
||||
case cost != nil && cost.BillingMode != "":
|
||||
mode = cost.BillingMode
|
||||
case result.ImageCount > 0:
|
||||
mode = string(BillingModeImage)
|
||||
default:
|
||||
mode = string(BillingModeToken)
|
||||
}
|
||||
return &mode
|
||||
}
|
||||
|
||||
func optionalSubscriptionID(subscription *UserSubscription) *int64 {
|
||||
if subscription != nil {
|
||||
return &subscription.ID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,956 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// Forward forwards request to OpenAI API
|
||||
func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
restrictionResult := s.detectCodexClientRestriction(c, account, body)
|
||||
apiKeyID := getAPIKeyIDFromContext(c)
|
||||
logCodexCLIOnlyDetection(ctx, c, account, apiKeyID, restrictionResult, body)
|
||||
if restrictionResult.Enabled && !restrictionResult.Matched {
|
||||
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "forbidden_error",
|
||||
"message": CodexClientRestrictionMessage(restrictionResult),
|
||||
},
|
||||
})
|
||||
return nil, errors.New("codex_cli_only restriction: only codex official clients are allowed")
|
||||
}
|
||||
|
||||
originalBody := body
|
||||
requestView := newOpenAIRequestView(body)
|
||||
reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey
|
||||
originalModel := reqModel
|
||||
|
||||
if account.Platform == PlatformGrok {
|
||||
_ = promptCacheKey
|
||||
return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime)
|
||||
}
|
||||
|
||||
if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) {
|
||||
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
|
||||
}
|
||||
|
||||
compatMessagesBridge := isOpenAICompatMessagesBridgeBody(body)
|
||||
setOpenAICompatMessagesBridgeContext(c, compatMessagesBridge)
|
||||
|
||||
isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI)
|
||||
wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account)
|
||||
clientTransport := GetOpenAIClientTransport(c)
|
||||
// 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。
|
||||
wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, clientTransport)
|
||||
if c != nil {
|
||||
c.Set("openai_ws_transport_decision", string(wsDecision.Transport))
|
||||
c.Set("openai_ws_transport_reason", wsDecision.Reason)
|
||||
}
|
||||
if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocketV2 {
|
||||
logOpenAIWSModeDebug(
|
||||
"selected account_id=%d account_type=%s transport=%s reason=%s model=%s stream=%v",
|
||||
account.ID,
|
||||
account.Type,
|
||||
normalizeOpenAIWSLogValue(string(wsDecision.Transport)),
|
||||
normalizeOpenAIWSLogValue(wsDecision.Reason),
|
||||
reqModel,
|
||||
reqStream,
|
||||
)
|
||||
}
|
||||
// 当前仅支持 WSv2;WSv1 命中时直接返回错误,避免出现“配置可开但行为不确定”。
|
||||
if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocket {
|
||||
if c != nil {
|
||||
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "invalid_request_error",
|
||||
"message": "OpenAI WSv1 is temporarily unsupported. Please enable responses_websockets_v2.",
|
||||
},
|
||||
})
|
||||
}
|
||||
return nil, errors.New("openai ws v1 is temporarily unsupported; use ws v2")
|
||||
}
|
||||
passthroughEnabled := account.IsOpenAIPassthroughEnabled()
|
||||
if passthroughEnabled {
|
||||
// 透传分支只需要轻量提取字段,避免热路径全量 Unmarshal。
|
||||
reasoningEffort := extractOpenAIReasoningEffortFromBody(body, reqModel)
|
||||
// 国产模型默认 effort 补充:也要用 mappedModel 判定是否是 passback-required 上游。
|
||||
reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, account.GetMappedModel(reqModel))
|
||||
return s.forwardOpenAIPassthrough(ctx, c, account, originalBody, reqModel, reasoningEffort, reqStream, startTime)
|
||||
}
|
||||
|
||||
bodyModified := false
|
||||
var reqBody map[string]any
|
||||
ensureReqBody := func() (map[string]any, error) {
|
||||
if requestView.HasPatches() {
|
||||
patchedBody, patchErr := requestView.ApplyPatches()
|
||||
if patchErr != nil {
|
||||
return nil, patchErr
|
||||
}
|
||||
body = patchedBody
|
||||
requestView = newOpenAIRequestView(body)
|
||||
reqBody = nil
|
||||
bodyModified = false
|
||||
}
|
||||
if reqBody != nil {
|
||||
return reqBody, nil
|
||||
}
|
||||
decoded, decodeErr := requestView.Decode(c)
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
reqBody = decoded
|
||||
return reqBody, nil
|
||||
}
|
||||
markPatchSet := func(path string, value any) {
|
||||
bodyModified = true
|
||||
if requestView.patchesDisabled {
|
||||
if reqBody != nil {
|
||||
setOpenAIRequestMapPath(reqBody, path, value)
|
||||
}
|
||||
return
|
||||
}
|
||||
requestView.MarkPatchSet(path, value)
|
||||
}
|
||||
markPatchDelete := func(path string) {
|
||||
bodyModified = true
|
||||
if requestView.patchesDisabled {
|
||||
if reqBody != nil {
|
||||
deleteOpenAIRequestMapPath(reqBody, path)
|
||||
}
|
||||
return
|
||||
}
|
||||
requestView.MarkPatchDelete(path)
|
||||
}
|
||||
disablePatch := func() {
|
||||
requestView.DisablePatches()
|
||||
}
|
||||
markDecodedModified := func() {
|
||||
bodyModified = true
|
||||
disablePatch()
|
||||
}
|
||||
|
||||
apiKey := getAPIKeyFromContext(c)
|
||||
imageGenerationAllowed := GroupAllowsImageGeneration(nil)
|
||||
if apiKey != nil {
|
||||
imageGenerationAllowed = GroupAllowsImageGeneration(apiKey.Group)
|
||||
}
|
||||
codexImageGenerationExplicitToolPolicy := codexImageGenerationExplicitToolPolicyAllow
|
||||
if isCodexCLI {
|
||||
codexImageGenerationExplicitToolPolicy = account.CodexImageGenerationExplicitToolPolicy()
|
||||
}
|
||||
codexImageGenerationBridgeEnabled := isCodexCLI && imageGenerationAllowed && codexImageGenerationExplicitToolPolicy != codexImageGenerationExplicitToolPolicyStrip && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey)
|
||||
var imageIntent bool
|
||||
if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip {
|
||||
decoded, decodeErr := ensureReqBody()
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
if stripOpenAIImageGenerationTools(decoded) {
|
||||
markDecodedModified()
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Stripped /responses image_generation tool for Codex client by account policy")
|
||||
}
|
||||
imageIntent = IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, decoded)
|
||||
} else {
|
||||
imageIntent = IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body)
|
||||
}
|
||||
if imageIntent && !imageGenerationAllowed {
|
||||
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": gin.H{"type": "permission_error", "message": ImageGenerationPermissionMessage()}})
|
||||
return nil, errors.New("image generation disabled for group")
|
||||
}
|
||||
|
||||
instructions := gjson.GetBytes(body, "instructions")
|
||||
instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == ""
|
||||
if instructionsEmpty && !compatMessagesBridge {
|
||||
markPatchSet("instructions", defaultCodexSynthInstructions(reqModel))
|
||||
}
|
||||
|
||||
billingModel := account.GetMappedModel(reqModel)
|
||||
if billingModel != reqModel {
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", reqModel, billingModel, account.Name, isCodexCLI)
|
||||
reqModel = billingModel
|
||||
markPatchSet("model", billingModel)
|
||||
}
|
||||
upstreamModel := billingModel
|
||||
isCompactRequest := isOpenAIResponsesCompactPath(c)
|
||||
compactMapped := false
|
||||
if isCompactRequest {
|
||||
compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel)
|
||||
if compactMappedModel != "" && compactMappedModel != billingModel {
|
||||
compactMapped = true
|
||||
upstreamModel = compactMappedModel
|
||||
reqModel = compactMappedModel
|
||||
markPatchSet("model", compactMappedModel)
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Compact model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", billingModel, compactMappedModel, account.Name, isCodexCLI)
|
||||
}
|
||||
}
|
||||
if !compactMapped {
|
||||
modelForNormalize := reqModel
|
||||
if modelForNormalize == "" {
|
||||
modelForNormalize = requestView.Model
|
||||
}
|
||||
upstreamModel = normalizeOpenAIModelForUpstream(account, modelForNormalize)
|
||||
if upstreamModel != "" && upstreamModel != modelForNormalize {
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Upstream model resolved: %s -> %s (account: %s, type: %s, isCodexCLI: %v)", modelForNormalize, upstreamModel, account.Name, account.Type, isCodexCLI)
|
||||
reqModel = upstreamModel
|
||||
markPatchSet("model", upstreamModel)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) == "minimal" {
|
||||
markPatchSet("reasoning.effort", "none")
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized reasoning.effort: minimal -> none (account: %s)", account.Name)
|
||||
}
|
||||
|
||||
imageIntent = imageIntent || IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, nil) || isOpenAIImageGenerationModel(upstreamModel)
|
||||
if imageIntent && !imageGenerationAllowed {
|
||||
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": gin.H{"type": "permission_error", "message": ImageGenerationPermissionMessage()}})
|
||||
return nil, errors.New("image generation disabled for group")
|
||||
}
|
||||
|
||||
// /responses/compact 是会话压缩请求:上游不接受 tool_choice(400 unknown_parameter),
|
||||
// 注入 image_generation 工具也没有意义,整块豁免。
|
||||
if imageGenerationAllowed && !isCompactRequest && (codexImageGenerationBridgeEnabled || isOpenAIImageGenerationModel(requestView.Model) || openAIRequestBodyImageGenerationToolNeedsNormalization(body) || isOpenAIImageGenerationModel(upstreamModel)) {
|
||||
decoded, decodeErr := ensureReqBody()
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationTool(decoded) {
|
||||
markDecodedModified()
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Injected /responses image_generation tool for Codex client")
|
||||
}
|
||||
if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationToolChoiceAuto(decoded) {
|
||||
markDecodedModified()
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Set /responses image_generation tool_choice=auto for Codex client")
|
||||
}
|
||||
if normalizeOpenAIResponsesImageGenerationTools(decoded) {
|
||||
markDecodedModified()
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image_generation tool payload")
|
||||
}
|
||||
if normalizeOpenAIResponsesImageOnlyModel(decoded) {
|
||||
markDecodedModified()
|
||||
if model, ok := decoded["model"].(string); ok {
|
||||
upstreamModel = strings.TrimSpace(model)
|
||||
}
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image-only model request inbound_model=%s image_model=%s upstream_model=%s", requestView.Model, billingModel, upstreamModel)
|
||||
}
|
||||
if err := validateOpenAIResponsesImageModel(decoded, upstreamModel); err != nil {
|
||||
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": err.Error(), "param": "model"}})
|
||||
return nil, err
|
||||
}
|
||||
if hasOpenAIImageGenerationTool(decoded) {
|
||||
imageIntent = true
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] /responses image_generation request inbound_model=%s mapped_model=%s account_type=%s", requestView.Model, upstreamModel, account.Type)
|
||||
}
|
||||
if codexImageGenerationBridgeEnabled && applyCodexImageGenerationBridgeInstructions(decoded) {
|
||||
markDecodedModified()
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Added Codex image_generation bridge instructions")
|
||||
}
|
||||
} else if imageGenerationAllowed && imageIntent && openAIRequestBodyHasImageGenerationTool(body) {
|
||||
// 完整 image_generation tool 只做 raw 计费读取,校验/桥接/旧字段迁移命中时才展开大 input map。
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] /responses image_generation request inbound_model=%s mapped_model=%s account_type=%s", requestView.Model, upstreamModel, account.Type)
|
||||
}
|
||||
|
||||
if isCodexSparkModel(upstreamModel) && openAIRequestBodyMayContainImageInput(body) {
|
||||
decoded, decodeErr := ensureReqBody()
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
if err := validateCodexSparkInput(decoded, upstreamModel); err != nil {
|
||||
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": err.Error(), "param": "input"}})
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// gpt-5.3-codex-spark also rejects the image_generation tool (HTTP 400,
|
||||
// param=tools). Strip it here so both APIKey and OAuth /responses paths are
|
||||
// covered regardless of the image-generation feature gate.
|
||||
if isCodexSparkModel(upstreamModel) && openAIRequestBodyHasImageGenerationTool(body) {
|
||||
decoded, decodeErr := ensureReqBody()
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
if stripCodexSparkImageGenerationTools(decoded) {
|
||||
markDecodedModified()
|
||||
}
|
||||
}
|
||||
|
||||
if account.Type == AccountTypeOAuth {
|
||||
decoded, decodeErr := ensureReqBody()
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
codexResult := codexTransformResult{}
|
||||
if compatMessagesBridge {
|
||||
codexResult = applyCodexOAuthTransformWithOptions(decoded, codexOAuthTransformOptions{IsCodexCLI: isCodexCLI, IsCompact: isCompactRequest, SkipDefaultInstructions: true, PreserveToolCallIDs: true})
|
||||
ensureCodexOAuthInstructionsField(decoded)
|
||||
markDecodedModified()
|
||||
} else {
|
||||
codexResult = applyCodexOAuthTransform(decoded, isCodexCLI, isCompactRequest)
|
||||
}
|
||||
if codexResult.Modified {
|
||||
markDecodedModified()
|
||||
}
|
||||
// 带真实 device_id 时补齐 client_metadata 安装标识,与真实 Codex 对齐(compact 形态不同,跳过)。
|
||||
if !isCompactRequest && applyCodexClientMetadata(decoded, account) {
|
||||
markDecodedModified()
|
||||
}
|
||||
if codexResult.NormalizedModel != "" {
|
||||
upstreamModel = codexResult.NormalizedModel
|
||||
}
|
||||
if codexResult.PromptCacheKey != "" {
|
||||
promptCacheKey = codexResult.PromptCacheKey
|
||||
}
|
||||
}
|
||||
|
||||
if !SupportsVerbosity(upstreamModel) && gjson.GetBytes(body, "text.verbosity").Exists() {
|
||||
markPatchDelete("text.verbosity")
|
||||
}
|
||||
|
||||
if !isCodexCLI {
|
||||
maxOutputTokens := gjson.GetBytes(body, "max_output_tokens")
|
||||
if maxOutputTokens.Exists() {
|
||||
switch account.Platform {
|
||||
case PlatformOpenAI:
|
||||
if account.Type == AccountTypeAPIKey {
|
||||
markPatchDelete("max_output_tokens")
|
||||
}
|
||||
case PlatformAnthropic:
|
||||
decoded, decodeErr := ensureReqBody()
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
delete(decoded, "max_output_tokens")
|
||||
if _, hasMaxTokens := decoded["max_tokens"]; !hasMaxTokens {
|
||||
decoded["max_tokens"] = maxOutputTokens.Value()
|
||||
}
|
||||
markDecodedModified()
|
||||
case PlatformGemini:
|
||||
markPatchDelete("max_output_tokens")
|
||||
default:
|
||||
markPatchDelete("max_output_tokens")
|
||||
}
|
||||
}
|
||||
if gjson.GetBytes(body, "max_completion_tokens").Exists() && (account.Type == AccountTypeAPIKey || account.Platform != PlatformOpenAI) {
|
||||
markPatchDelete("max_completion_tokens")
|
||||
}
|
||||
for _, unsupportedField := range []string{"prompt_cache_retention", "safety_identifier"} {
|
||||
if gjson.GetBytes(body, unsupportedField).Exists() {
|
||||
markPatchDelete(unsupportedField)
|
||||
}
|
||||
}
|
||||
}
|
||||
if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 && gjson.GetBytes(body, "previous_response_id").Exists() {
|
||||
markPatchDelete("previous_response_id")
|
||||
}
|
||||
if openAIRequestBodyMayContainEmptyBase64InputImage(body) {
|
||||
decoded, decodeErr := ensureReqBody()
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
if sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(decoded) {
|
||||
markDecodedModified()
|
||||
}
|
||||
}
|
||||
|
||||
if rawTier := requestView.ServiceTier; rawTier != "" {
|
||||
if normTier := normalizedOpenAIServiceTierValue(rawTier); normTier != "" {
|
||||
action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, upstreamModel, normTier)
|
||||
switch action {
|
||||
case BetaPolicyActionBlock:
|
||||
msg := errMsg
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, upstreamModel)
|
||||
}
|
||||
blocked := &OpenAIFastBlockedError{Message: msg}
|
||||
writeOpenAIFastPolicyBlockedResponse(c, blocked)
|
||||
return nil, blocked
|
||||
case BetaPolicyActionFilter:
|
||||
markPatchDelete("service_tier")
|
||||
case OpenAIFastPolicyActionForcePriority:
|
||||
if rawTier != OpenAIFastTierPriority {
|
||||
markPatchSet("service_tier", OpenAIFastTierPriority)
|
||||
}
|
||||
default:
|
||||
if normTier != rawTier {
|
||||
markPatchSet("service_tier", normTier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bodyModified {
|
||||
if requestView.HasPatches() {
|
||||
if patchedBody, patchErr := requestView.ApplyPatches(); patchErr == nil {
|
||||
body = patchedBody
|
||||
requestView = newOpenAIRequestView(body)
|
||||
reqBody = nil
|
||||
bodyModified = false
|
||||
}
|
||||
}
|
||||
if bodyModified {
|
||||
decoded, decodeErr := ensureReqBody()
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
var marshalErr error
|
||||
body, marshalErr = marshalOpenAIUpstreamJSON(decoded)
|
||||
if marshalErr != nil {
|
||||
return nil, fmt.Errorf("serialize request body: %w", marshalErr)
|
||||
}
|
||||
requestView = newOpenAIRequestView(body)
|
||||
}
|
||||
}
|
||||
imageBillingModel := ""
|
||||
imageSizeTier := ""
|
||||
imageInputSize := ""
|
||||
if imageIntent {
|
||||
var imageCfg OpenAIResponsesImageBillingConfig
|
||||
var imageCfgErr error
|
||||
if reqBody != nil {
|
||||
imageCfg, imageCfgErr = resolveOpenAIResponsesImageBillingConfigDetailed(reqBody, billingModel)
|
||||
} else {
|
||||
imageCfg, imageCfgErr = resolveOpenAIResponsesImageBillingConfigDetailedFromBody(body, billingModel)
|
||||
}
|
||||
if imageCfgErr != nil {
|
||||
setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": imageCfgErr.Error(), "param": "size"}})
|
||||
return nil, imageCfgErr
|
||||
}
|
||||
imageBillingModel = imageCfg.Model
|
||||
imageSizeTier = imageCfg.SizeTier
|
||||
imageInputSize = imageCfg.InputSize
|
||||
}
|
||||
|
||||
// Get access token
|
||||
token, _, err := s.GetAccessToken(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 命中 WS 时仅走 WebSocket Mode;不再自动回退 HTTP。
|
||||
if wsDecision.Transport == OpenAIUpstreamTransportResponsesWebsocketV2 {
|
||||
// WS 分支需要结构化 payload 与重连恢复,命中后再触发 full-map decode。
|
||||
wsReqBody, err := ensureReqBody()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, hasPreviousResponseID := wsReqBody["previous_response_id"]
|
||||
logOpenAIWSModeDebug(
|
||||
"forward_start account_id=%d account_type=%s model=%s stream=%v has_previous_response_id=%v",
|
||||
account.ID,
|
||||
account.Type,
|
||||
upstreamModel,
|
||||
reqStream,
|
||||
hasPreviousResponseID,
|
||||
)
|
||||
maxAttempts := openAIWSReconnectRetryLimit + 1
|
||||
wsAttempts := 0
|
||||
var wsResult *OpenAIForwardResult
|
||||
var wsErr error
|
||||
wsLastFailureReason := ""
|
||||
wsPrevResponseRecoveryTried := false
|
||||
wsInvalidEncryptedContentRecoveryTried := false
|
||||
recoverPrevResponseNotFound := func(attempt int) bool {
|
||||
if wsPrevResponseRecoveryTried {
|
||||
return false
|
||||
}
|
||||
previousResponseID := openAIWSPayloadString(wsReqBody, "previous_response_id")
|
||||
if previousResponseID == "" {
|
||||
logOpenAIWSModeInfo(
|
||||
"reconnect_prev_response_recovery_skip account_id=%d attempt=%d reason=missing_previous_response_id previous_response_id_present=false",
|
||||
account.ID,
|
||||
attempt,
|
||||
)
|
||||
return false
|
||||
}
|
||||
if HasFunctionCallOutput(wsReqBody) {
|
||||
logOpenAIWSModeInfo(
|
||||
"reconnect_prev_response_recovery_skip account_id=%d attempt=%d reason=has_function_call_output previous_response_id_present=true",
|
||||
account.ID,
|
||||
attempt,
|
||||
)
|
||||
return false
|
||||
}
|
||||
delete(wsReqBody, "previous_response_id")
|
||||
wsPrevResponseRecoveryTried = true
|
||||
logOpenAIWSModeInfo(
|
||||
"reconnect_prev_response_recovery account_id=%d attempt=%d action=drop_previous_response_id retry=1 previous_response_id=%s previous_response_id_kind=%s",
|
||||
account.ID,
|
||||
attempt,
|
||||
truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen),
|
||||
normalizeOpenAIWSLogValue(ClassifyOpenAIPreviousResponseIDKind(previousResponseID)),
|
||||
)
|
||||
return true
|
||||
}
|
||||
recoverInvalidEncryptedContent := func(attempt int) bool {
|
||||
if wsInvalidEncryptedContentRecoveryTried {
|
||||
return false
|
||||
}
|
||||
removedReasoningItems := trimOpenAIEncryptedReasoningItems(wsReqBody)
|
||||
if !removedReasoningItems {
|
||||
logOpenAIWSModeInfo(
|
||||
"reconnect_invalid_encrypted_content_recovery_skip account_id=%d attempt=%d reason=missing_encrypted_reasoning_items",
|
||||
account.ID,
|
||||
attempt,
|
||||
)
|
||||
return false
|
||||
}
|
||||
previousResponseID := openAIWSPayloadString(wsReqBody, "previous_response_id")
|
||||
hasFunctionCallOutput := HasFunctionCallOutput(wsReqBody)
|
||||
if previousResponseID != "" && !hasFunctionCallOutput {
|
||||
delete(wsReqBody, "previous_response_id")
|
||||
}
|
||||
wsInvalidEncryptedContentRecoveryTried = true
|
||||
logOpenAIWSModeInfo(
|
||||
"reconnect_invalid_encrypted_content_recovery account_id=%d attempt=%d action=drop_encrypted_reasoning_items retry=1 previous_response_id_present=%v previous_response_id=%s previous_response_id_kind=%s has_function_call_output=%v dropped_previous_response_id=%v",
|
||||
account.ID,
|
||||
attempt,
|
||||
previousResponseID != "",
|
||||
truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen),
|
||||
normalizeOpenAIWSLogValue(ClassifyOpenAIPreviousResponseIDKind(previousResponseID)),
|
||||
hasFunctionCallOutput,
|
||||
previousResponseID != "" && !hasFunctionCallOutput,
|
||||
)
|
||||
return true
|
||||
}
|
||||
retryBudget := s.openAIWSRetryTotalBudget()
|
||||
retryStartedAt := time.Now()
|
||||
wsRetryLoop:
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
wsAttempts = attempt
|
||||
wsResult, wsErr = s.forwardOpenAIWSV2(
|
||||
ctx,
|
||||
c,
|
||||
account,
|
||||
wsReqBody,
|
||||
token,
|
||||
wsDecision,
|
||||
isCodexCLI,
|
||||
reqStream,
|
||||
originalModel,
|
||||
upstreamModel,
|
||||
startTime,
|
||||
attempt,
|
||||
wsLastFailureReason,
|
||||
)
|
||||
if wsErr == nil {
|
||||
break
|
||||
}
|
||||
if c != nil && c.Writer != nil && c.Writer.Written() {
|
||||
break
|
||||
}
|
||||
|
||||
reason, retryable := classifyOpenAIWSReconnectReason(wsErr)
|
||||
if reason != "" {
|
||||
wsLastFailureReason = reason
|
||||
}
|
||||
// previous_response_not_found 说明续链锚点不可用:
|
||||
// 对非 function_call_output 场景,允许一次“去掉 previous_response_id 后重放”。
|
||||
if reason == "previous_response_not_found" && recoverPrevResponseNotFound(attempt) {
|
||||
continue
|
||||
}
|
||||
if reason == "invalid_encrypted_content" && recoverInvalidEncryptedContent(attempt) {
|
||||
continue
|
||||
}
|
||||
if retryable && attempt < maxAttempts {
|
||||
backoff := s.openAIWSRetryBackoff(attempt)
|
||||
if retryBudget > 0 && time.Since(retryStartedAt)+backoff > retryBudget {
|
||||
s.recordOpenAIWSRetryExhausted()
|
||||
logOpenAIWSModeInfo(
|
||||
"reconnect_budget_exhausted account_id=%d attempts=%d max_retries=%d reason=%s elapsed_ms=%d budget_ms=%d",
|
||||
account.ID,
|
||||
attempt,
|
||||
openAIWSReconnectRetryLimit,
|
||||
normalizeOpenAIWSLogValue(reason),
|
||||
time.Since(retryStartedAt).Milliseconds(),
|
||||
retryBudget.Milliseconds(),
|
||||
)
|
||||
break
|
||||
}
|
||||
s.recordOpenAIWSRetryAttempt(backoff)
|
||||
logOpenAIWSModeInfo(
|
||||
"reconnect_retry account_id=%d retry=%d max_retries=%d reason=%s backoff_ms=%d",
|
||||
account.ID,
|
||||
attempt,
|
||||
openAIWSReconnectRetryLimit,
|
||||
normalizeOpenAIWSLogValue(reason),
|
||||
backoff.Milliseconds(),
|
||||
)
|
||||
if backoff > 0 {
|
||||
timer := time.NewTimer(backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if !timer.Stop() {
|
||||
<-timer.C
|
||||
}
|
||||
wsErr = wrapOpenAIWSFallback("retry_backoff_canceled", ctx.Err())
|
||||
break wsRetryLoop
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if retryable {
|
||||
s.recordOpenAIWSRetryExhausted()
|
||||
logOpenAIWSModeInfo(
|
||||
"reconnect_exhausted account_id=%d attempts=%d max_retries=%d reason=%s",
|
||||
account.ID,
|
||||
attempt,
|
||||
openAIWSReconnectRetryLimit,
|
||||
normalizeOpenAIWSLogValue(reason),
|
||||
)
|
||||
} else if reason != "" {
|
||||
s.recordOpenAIWSNonRetryableFastFallback()
|
||||
logOpenAIWSModeInfo(
|
||||
"reconnect_stop account_id=%d attempt=%d reason=%s",
|
||||
account.ID,
|
||||
attempt,
|
||||
normalizeOpenAIWSLogValue(reason),
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
if wsErr == nil {
|
||||
firstTokenMs := int64(0)
|
||||
hasFirstTokenMs := wsResult != nil && wsResult.FirstTokenMs != nil
|
||||
if hasFirstTokenMs {
|
||||
firstTokenMs = int64(*wsResult.FirstTokenMs)
|
||||
}
|
||||
requestID := ""
|
||||
if wsResult != nil {
|
||||
requestID = strings.TrimSpace(wsResult.RequestID)
|
||||
}
|
||||
logOpenAIWSModeDebug(
|
||||
"forward_succeeded account_id=%d request_id=%s stream=%v has_first_token_ms=%v first_token_ms=%d ws_attempts=%d",
|
||||
account.ID,
|
||||
requestID,
|
||||
reqStream,
|
||||
hasFirstTokenMs,
|
||||
firstTokenMs,
|
||||
wsAttempts,
|
||||
)
|
||||
wsResult.UpstreamModel = upstreamModel
|
||||
if wsResult.BillingModel == "" {
|
||||
wsResult.BillingModel = billingModel
|
||||
}
|
||||
if wsResult.ImageCount > 0 {
|
||||
wsResult.ImageSize = imageSizeTier
|
||||
wsResult.ImageInputSize = imageInputSize
|
||||
wsResult.BillingModel = imageBillingModel
|
||||
}
|
||||
return wsResult, nil
|
||||
}
|
||||
s.writeOpenAIWSFallbackErrorResponse(c, account, wsErr)
|
||||
return nil, wsErr
|
||||
}
|
||||
|
||||
httpInvalidEncryptedContentRetryTried := false
|
||||
for {
|
||||
// Build upstream request
|
||||
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
|
||||
upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, body, token, reqStream, promptCacheKey, isCodexCLI)
|
||||
releaseUpstreamCtx()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get proxy URL
|
||||
proxyURL := ""
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
// Send request
|
||||
upstreamStart := time.Now()
|
||||
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
|
||||
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
|
||||
if err != nil {
|
||||
// Transport-level failure (proxy/DNS/TCP/TLS — no HTTP response). Convert to
|
||||
// a failover so the handler switches to a healthy account, and temporarily
|
||||
// unschedule the account on durable faults (e.g. rejected proxy credentials).
|
||||
return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false)
|
||||
}
|
||||
|
||||
// Handle error response
|
||||
if resp.StatusCode >= 400 {
|
||||
respBody := s.readUpstreamErrorBody(resp)
|
||||
_ = resp.Body.Close()
|
||||
resp.Body = io.NopCloser(bytes.NewReader(respBody))
|
||||
|
||||
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
|
||||
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
||||
upstreamCode := extractUpstreamErrorCode(respBody)
|
||||
if !httpInvalidEncryptedContentRetryTried && resp.StatusCode == http.StatusBadRequest && upstreamCode == "invalid_encrypted_content" {
|
||||
decoded, decodeErr := ensureReqBody()
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
if trimOpenAIEncryptedReasoningItems(decoded) {
|
||||
body, err = marshalOpenAIUpstreamJSON(decoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("serialize invalid_encrypted_content retry body: %w", err)
|
||||
}
|
||||
httpInvalidEncryptedContentRetryTried = true
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request once after invalid_encrypted_content (account: %s)", account.Name)
|
||||
continue
|
||||
}
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Skip non-WSv2 invalid_encrypted_content retry because encrypted reasoning items are missing (account: %s)", account.Name)
|
||||
}
|
||||
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) {
|
||||
upstreamDetail := ""
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 2048
|
||||
}
|
||||
upstreamDetail = truncateString(string(respBody), maxBytes)
|
||||
}
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
Kind: "failover",
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
|
||||
s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel)
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: respBody,
|
||||
RetryableOnSameAccount: account.IsPoolMode() && (account.IsPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
|
||||
}
|
||||
}
|
||||
return s.handleErrorResponse(ctx, resp, c, account, body, billingModel)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
reasoningEffort := extractOpenAIReasoningEffortFromBody(body, originalModel)
|
||||
// 国产模型默认 effort 补充:此处 reqModel 已被 mapping 重写为 billingModel(见
|
||||
// line 2510-2515 的 GetMappedModel + reqModel 赋值),可直接作为 mappedModel。
|
||||
reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, reqModel)
|
||||
serviceTier := extractOpenAIServiceTierFromBody(body)
|
||||
// 上游接受后只保留计费需要的标量,避免响应处理期间继续保活完整 input/tools map。
|
||||
reqBody = nil
|
||||
|
||||
// Handle normal response
|
||||
var usage *OpenAIUsage
|
||||
var firstTokenMs *int
|
||||
responseID := ""
|
||||
imageCount := 0
|
||||
var imageOutputSizes []string
|
||||
if reqStream {
|
||||
streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, upstreamModel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usage = streamResult.usage
|
||||
firstTokenMs = streamResult.firstTokenMs
|
||||
responseID = strings.TrimSpace(streamResult.responseID)
|
||||
imageCount = streamResult.imageCount
|
||||
imageOutputSizes = streamResult.imageOutputSizes
|
||||
} else {
|
||||
nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usage = nonStreamResult.usage
|
||||
responseID = strings.TrimSpace(nonStreamResult.responseID)
|
||||
imageCount = nonStreamResult.imageCount
|
||||
imageOutputSizes = nonStreamResult.imageOutputSizes
|
||||
}
|
||||
s.bindHTTPResponseAccount(ctx, c, account, responseID)
|
||||
|
||||
// Extract and save Codex usage snapshot from response headers (for OAuth accounts).
|
||||
// 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。
|
||||
if account.Type == AccountTypeOAuth && !account.IsShadow() {
|
||||
if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil {
|
||||
s.updateCodexUsageSnapshot(ctx, account.ID, snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
if usage == nil {
|
||||
usage = &OpenAIUsage{}
|
||||
}
|
||||
|
||||
forwardResult := &OpenAIForwardResult{
|
||||
RequestID: resp.Header.Get("x-request-id"),
|
||||
ResponseID: responseID,
|
||||
Usage: *usage,
|
||||
Model: originalModel,
|
||||
BillingModel: billingModel,
|
||||
UpstreamModel: upstreamModel,
|
||||
ServiceTier: serviceTier,
|
||||
ReasoningEffort: reasoningEffort,
|
||||
Stream: reqStream,
|
||||
OpenAIWSMode: false,
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
}
|
||||
if imageCount > 0 {
|
||||
forwardResult.ImageCount = imageCount
|
||||
forwardResult.ImageSize = imageSizeTier
|
||||
forwardResult.ImageInputSize = imageInputSize
|
||||
forwardResult.ImageOutputSizes = imageOutputSizes
|
||||
forwardResult.BillingModel = imageBillingModel
|
||||
}
|
||||
return forwardResult, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string, isStream bool, promptCacheKey string, isCodexCLI bool) (*http.Request, error) {
|
||||
// Determine target URL based on account type
|
||||
var targetURL string
|
||||
switch account.Type {
|
||||
case AccountTypeOAuth:
|
||||
// OAuth accounts use ChatGPT internal API
|
||||
targetURL = chatgptCodexURL
|
||||
case AccountTypeAPIKey:
|
||||
// API Key accounts use Platform API or custom base URL
|
||||
baseURL := account.GetOpenAIBaseURL()
|
||||
if baseURL == "" {
|
||||
targetURL = openaiPlatformAPIURL
|
||||
} else {
|
||||
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetURL = buildOpenAIResponsesURL(validatedURL)
|
||||
}
|
||||
default:
|
||||
targetURL = openaiPlatformAPIURL
|
||||
}
|
||||
targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
|
||||
|
||||
// Set authentication header
|
||||
req.Header.Set("authorization", "Bearer "+token)
|
||||
|
||||
// Set headers specific to OAuth accounts (ChatGPT internal API)
|
||||
if account.Type == AccountTypeOAuth {
|
||||
// Required: set Host for ChatGPT API (must use req.Host, not Header.Set)
|
||||
req.Host = "chatgpt.com"
|
||||
if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil {
|
||||
return nil, fmt.Errorf("resolve chatgpt account headers: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Whitelist passthrough headers
|
||||
for key, values := range c.Request.Header {
|
||||
lowerKey := strings.ToLower(key)
|
||||
if openaiAllowedHeaders[lowerKey] {
|
||||
for _, v := range values {
|
||||
req.Header.Add(key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if account.Type == AccountTypeOAuth {
|
||||
compatMessagesBridge := isOpenAICompatMessagesBridgeContext(c) || isOpenAICompatMessagesBridgeBody(body)
|
||||
// 清除客户端透传的 session 头,后续用隔离后的值重新设置,防止跨用户会话碰撞。
|
||||
clientConversationID := strings.TrimSpace(req.Header.Get("conversation_id"))
|
||||
req.Header.Del("conversation_id")
|
||||
req.Header.Del("session_id")
|
||||
|
||||
if compatMessagesBridge {
|
||||
req.Header.Del("OpenAI-Beta")
|
||||
req.Header.Del("originator")
|
||||
} else {
|
||||
req.Header.Set("OpenAI-Beta", "responses=experimental")
|
||||
req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI))
|
||||
}
|
||||
apiKeyID := getAPIKeyIDFromContext(c)
|
||||
if isOpenAIResponsesCompactPath(c) {
|
||||
req.Header.Set("accept", "application/json")
|
||||
if req.Header.Get("version") == "" {
|
||||
req.Header.Set("version", codexCLIVersion)
|
||||
}
|
||||
compactSession := resolveOpenAICompactSessionID(c)
|
||||
req.Header.Set("session_id", isolateOpenAISessionID(apiKeyID, compactSession))
|
||||
} else {
|
||||
req.Header.Set("accept", "text/event-stream")
|
||||
}
|
||||
if promptCacheKey != "" {
|
||||
isolated := isolateOpenAISessionID(apiKeyID, promptCacheKey)
|
||||
req.Header.Set("session_id", isolated)
|
||||
if !compatMessagesBridge || clientConversationID != "" {
|
||||
req.Header.Set("conversation_id", isolated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply custom User-Agent if configured
|
||||
customUA := account.GetOpenAIUserAgent()
|
||||
if customUA != "" {
|
||||
req.Header.Set("user-agent", customUA)
|
||||
}
|
||||
|
||||
// 若开启 ForceCodexCLI,则强制将上游 User-Agent 伪装为 Codex CLI。
|
||||
// 用于网关未透传/改写 User-Agent 时,仍能命中 Codex 侧识别逻辑。
|
||||
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)
|
||||
|
||||
// Ensure required headers exist
|
||||
if req.Header.Get("content-type") == "" {
|
||||
req.Header.Set("content-type", "application/json")
|
||||
}
|
||||
|
||||
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
|
||||
account.ApplyHeaderOverrides(req.Header)
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,597 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func logOpenAIInstructionsRequiredDebug(
|
||||
ctx context.Context,
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
upstreamStatusCode int,
|
||||
upstreamMsg string,
|
||||
requestBody []byte,
|
||||
upstreamBody []byte,
|
||||
) {
|
||||
msg := strings.TrimSpace(upstreamMsg)
|
||||
if !isOpenAIInstructionsRequiredError(upstreamStatusCode, msg, upstreamBody) {
|
||||
return
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
accountID := int64(0)
|
||||
accountName := ""
|
||||
if account != nil {
|
||||
accountID = account.ID
|
||||
accountName = strings.TrimSpace(account.Name)
|
||||
}
|
||||
|
||||
userAgent := ""
|
||||
originator := ""
|
||||
if c != nil {
|
||||
userAgent = strings.TrimSpace(c.GetHeader("User-Agent"))
|
||||
originator = strings.TrimSpace(c.GetHeader("originator"))
|
||||
}
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.String("component", "service.openai_gateway"),
|
||||
zap.Int64("account_id", accountID),
|
||||
zap.String("account_name", accountName),
|
||||
zap.Int("upstream_status_code", upstreamStatusCode),
|
||||
zap.String("upstream_error_message", msg),
|
||||
zap.String("request_user_agent", userAgent),
|
||||
zap.Bool("codex_official_client_match", openai.IsCodexOfficialClientByHeaders(userAgent, originator)),
|
||||
}
|
||||
fields = appendCodexCLIOnlyRejectedRequestFields(fields, c, requestBody)
|
||||
|
||||
logger.FromContext(ctx).With(fields...).Warn("OpenAI 上游返回 Instructions are required,已记录请求详情用于排查")
|
||||
}
|
||||
|
||||
func isOpenAIInstructionsRequiredError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool {
|
||||
if upstreamStatusCode != http.StatusBadRequest {
|
||||
return false
|
||||
}
|
||||
|
||||
hasInstructionRequired := func(text string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if lower == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(lower, "instructions are required") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "required parameter: 'instructions'") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "required parameter: instructions") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "missing required parameter") && strings.Contains(lower, "instructions") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(lower, "instruction") && strings.Contains(lower, "required")
|
||||
}
|
||||
|
||||
if hasInstructionRequired(upstreamMsg) {
|
||||
return true
|
||||
}
|
||||
if len(upstreamBody) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
errMsg := gjson.GetBytes(upstreamBody, "error.message").String()
|
||||
errMsgLower := strings.ToLower(strings.TrimSpace(errMsg))
|
||||
errCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.code").String()))
|
||||
errParam := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.param").String()))
|
||||
errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.type").String()))
|
||||
|
||||
if errParam == "instructions" {
|
||||
return true
|
||||
}
|
||||
if hasInstructionRequired(errMsg) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(errCode, "missing_required_parameter") && strings.Contains(errMsgLower, "instructions") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(errType, "invalid_request") && strings.Contains(errMsgLower, "instructions") && strings.Contains(errMsgLower, "required") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool {
|
||||
if upstreamStatusCode != http.StatusBadRequest && upstreamStatusCode != http.StatusServiceUnavailable {
|
||||
return false
|
||||
}
|
||||
|
||||
hasOpenAIServerOverloadedCode := func(payload []byte) bool {
|
||||
code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String()))
|
||||
if code == "" {
|
||||
code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String()))
|
||||
}
|
||||
return code == "server_is_overloaded" || code == "slow_down"
|
||||
}
|
||||
|
||||
if len(upstreamBody) > 0 && hasOpenAIServerOverloadedCode(upstreamBody) {
|
||||
return true
|
||||
}
|
||||
if upstreamStatusCode != http.StatusBadRequest {
|
||||
return false
|
||||
}
|
||||
|
||||
match := func(text string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if lower == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(lower, "an error occurred while processing your request") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "selected model is at capacity") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(lower, "you can retry your request") &&
|
||||
strings.Contains(lower, "help.openai.com") &&
|
||||
strings.Contains(lower, "request id")
|
||||
}
|
||||
|
||||
if match(upstreamMsg) {
|
||||
return true
|
||||
}
|
||||
if len(upstreamBody) == 0 {
|
||||
return false
|
||||
}
|
||||
if match(gjson.GetBytes(upstreamBody, "error.message").String()) {
|
||||
return true
|
||||
}
|
||||
return match(string(upstreamBody))
|
||||
}
|
||||
|
||||
func isOpenAIContextWindowError(upstreamMsg string, upstreamBody []byte) bool {
|
||||
match := func(text string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if lower == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(lower, "context_too_large") || strings.Contains(lower, "context_length_exceeded") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "maximum context length") || strings.Contains(lower, "max context length") {
|
||||
return true
|
||||
}
|
||||
hasExceeded := strings.Contains(lower, "exceed") || strings.Contains(lower, "too large") || strings.Contains(lower, "too long")
|
||||
if strings.Contains(lower, "context window") && hasExceeded {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "context length") && hasExceeded {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(lower, "token limit") &&
|
||||
strings.Contains(lower, "context") &&
|
||||
hasExceeded
|
||||
}
|
||||
|
||||
if match(upstreamMsg) {
|
||||
return true
|
||||
}
|
||||
if len(upstreamBody) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, path := range []string{
|
||||
"error.message",
|
||||
"response.error.message",
|
||||
"message",
|
||||
"error.code",
|
||||
"response.error.code",
|
||||
"code",
|
||||
} {
|
||||
if match(gjson.GetBytes(upstreamBody, path).String()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return match(string(upstreamBody))
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool {
|
||||
switch statusCode {
|
||||
case 401, 402, 403, 429, 529:
|
||||
return true
|
||||
default:
|
||||
return statusCode >= 500
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode int, upstreamMsg string, upstreamBody []byte) bool {
|
||||
if isOpenAIContextWindowError(upstreamMsg, upstreamBody) {
|
||||
return false
|
||||
}
|
||||
if s.shouldFailoverUpstreamError(statusCode) {
|
||||
return true
|
||||
}
|
||||
return isOpenAITransientProcessingError(statusCode, upstreamMsg, upstreamBody)
|
||||
}
|
||||
|
||||
func marshalOpenAIUpstreamJSON(v any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := buf.Bytes()
|
||||
if len(out) > 0 && out[len(out)-1] == '\n' {
|
||||
out = out[:len(out)-1]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func openAIUpstreamErrorBodyReadLimitForConfig(cfg *config.Config) int64 {
|
||||
limit := openAIUpstreamErrorBodyReadLimit
|
||||
if cfg != nil && cfg.Gateway.LogUpstreamErrorBody && cfg.Gateway.LogUpstreamErrorBodyMaxBytes > int(limit) {
|
||||
limit = int64(cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) readUpstreamErrorBody(resp *http.Response) []byte {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return nil
|
||||
}
|
||||
cfg := (*config.Config)(nil)
|
||||
if s != nil {
|
||||
cfg = s.cfg
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, openAIUpstreamErrorBodyReadLimitForConfig(cfg)))
|
||||
return body
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account, responseBody []byte, requestedModel ...string) {
|
||||
if len(requestedModel) > 0 {
|
||||
s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody, requestedModel[0])
|
||||
return
|
||||
}
|
||||
s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) handleErrorResponse(
|
||||
ctx context.Context,
|
||||
resp *http.Response,
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
requestBody []byte,
|
||||
requestedModel ...string,
|
||||
) (*OpenAIForwardResult, error) {
|
||||
body := s.readUpstreamErrorBody(resp)
|
||||
|
||||
// cyber_policy 硬阻断:透传上游原始错误体给客户端(不重包成通用 502),不冷却账号。
|
||||
// 当前请求恒透传(需求1);标记供 handler 事后写风控/邮件。400 cyber 不可 failover
|
||||
// (shouldFailoverUpstreamError(400)=false),故走到此处即可安全早返回。
|
||||
if hit, code, cyberMsg := detectOpenAICyberPolicy(body); hit {
|
||||
MarkOpsCyberPolicy(c, CyberPolicyMark{
|
||||
Code: code,
|
||||
Message: cyberMsg,
|
||||
Body: truncateString(string(body), 4096),
|
||||
UpstreamStatus: resp.StatusCode,
|
||||
})
|
||||
setOpsUpstreamError(c, resp.StatusCode, cyberMsg, truncateString(string(body), 2048))
|
||||
writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
c.Data(resp.StatusCode, contentType, body)
|
||||
if cyberMsg == "" {
|
||||
return nil, fmt.Errorf("openai cyber_policy: %d", resp.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("openai cyber_policy: %s", cyberMsg)
|
||||
}
|
||||
|
||||
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
|
||||
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
||||
upstreamDetail := ""
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 2048
|
||||
}
|
||||
upstreamDetail = truncateString(string(body), maxBytes)
|
||||
}
|
||||
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
|
||||
logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body)
|
||||
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
logger.LegacyPrintf("service.openai_gateway",
|
||||
"OpenAI upstream error %d (account=%d platform=%s type=%s): %s",
|
||||
resp.StatusCode,
|
||||
account.ID,
|
||||
account.Platform,
|
||||
account.Type,
|
||||
truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes),
|
||||
)
|
||||
}
|
||||
|
||||
if status, errType, errMsg, matched := applyErrorPassthroughRule(
|
||||
c,
|
||||
PlatformOpenAI,
|
||||
resp.StatusCode,
|
||||
body,
|
||||
http.StatusBadGateway,
|
||||
"upstream_error",
|
||||
"Upstream request failed",
|
||||
); matched {
|
||||
MarkResponseCommitted(c)
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"type": errType,
|
||||
"message": errMsg,
|
||||
},
|
||||
})
|
||||
if upstreamMsg == "" {
|
||||
upstreamMsg = errMsg
|
||||
}
|
||||
if upstreamMsg == "" {
|
||||
return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg)
|
||||
}
|
||||
|
||||
// Check custom error codes
|
||||
if !account.ShouldHandleErrorCode(resp.StatusCode) {
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
Kind: "http_error",
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
MarkResponseCommitted(c)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "upstream_error",
|
||||
"message": "Upstream gateway error",
|
||||
},
|
||||
})
|
||||
if upstreamMsg == "" {
|
||||
return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("upstream error: %d (not in custom error codes) message=%s", resp.StatusCode, upstreamMsg)
|
||||
}
|
||||
|
||||
// Handle upstream error (mark account status)
|
||||
var reqModel string
|
||||
if len(requestedModel) > 0 {
|
||||
reqModel = strings.TrimSpace(requestedModel[0])
|
||||
}
|
||||
if reqModel == "" {
|
||||
reqModel, _, _ = extractOpenAIRequestMetaFromBody(requestBody)
|
||||
}
|
||||
shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, reqModel)
|
||||
kind := "http_error"
|
||||
if shouldDisable {
|
||||
kind = "failover"
|
||||
}
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
Kind: kind,
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
if shouldDisable {
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: body,
|
||||
RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||||
}
|
||||
}
|
||||
|
||||
MarkResponseCommitted(c)
|
||||
|
||||
// Return appropriate error response
|
||||
var errType, errMsg string
|
||||
var statusCode int
|
||||
|
||||
switch resp.StatusCode {
|
||||
case 401:
|
||||
statusCode = http.StatusBadGateway
|
||||
errType = "upstream_error"
|
||||
errMsg = "Upstream authentication failed, please contact administrator"
|
||||
case 402:
|
||||
statusCode = http.StatusBadGateway
|
||||
errType = "upstream_error"
|
||||
errMsg = "Upstream payment required: insufficient balance or billing issue"
|
||||
case 403:
|
||||
statusCode = http.StatusBadGateway
|
||||
errType = "upstream_error"
|
||||
errMsg = "Upstream access forbidden, please contact administrator"
|
||||
case 429:
|
||||
statusCode = http.StatusTooManyRequests
|
||||
errType = "rate_limit_error"
|
||||
errMsg = "Upstream rate limit exceeded, please retry later"
|
||||
default:
|
||||
statusCode = http.StatusBadGateway
|
||||
errType = "upstream_error"
|
||||
errMsg = "Upstream request failed"
|
||||
}
|
||||
if isOpenAIContextWindowError(upstreamMsg, body) && upstreamMsg != "" {
|
||||
errMsg = upstreamMsg
|
||||
}
|
||||
|
||||
c.JSON(statusCode, gin.H{
|
||||
"error": gin.H{
|
||||
"type": errType,
|
||||
"message": errMsg,
|
||||
},
|
||||
})
|
||||
|
||||
if upstreamMsg == "" {
|
||||
return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg)
|
||||
}
|
||||
|
||||
// compatErrorWriter is the signature for format-specific error writers used by
|
||||
// the compat paths (Chat Completions and Anthropic Messages).
|
||||
type compatErrorWriter func(c *gin.Context, statusCode int, errType, message string)
|
||||
|
||||
// handleCompatErrorResponse is the shared non-failover error handler for the
|
||||
// Chat Completions and Anthropic Messages compat paths. It mirrors the logic of
|
||||
// handleErrorResponse (passthrough rules, ShouldHandleErrorCode, rate-limit
|
||||
// tracking, secondary failover) but delegates the final error write to the
|
||||
// format-specific writer function.
|
||||
func (s *OpenAIGatewayService) handleCompatErrorResponse(
|
||||
resp *http.Response,
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
writeError compatErrorWriter,
|
||||
requestedModel ...string,
|
||||
) (*OpenAIForwardResult, error) {
|
||||
body := s.readUpstreamErrorBody(resp)
|
||||
|
||||
// cyber_policy:兼容路径(Chat Completions / Anthropic)以各自格式回写错误,
|
||||
// 不原样透传 responses 格式的 cyber body(否则对下游格式不合法)。cyber 是上游网络
|
||||
// 安全策略拦截,不冷却账号,故标记后直接以兼容格式回写错误并返回,跳过下方
|
||||
// handleOpenAIAccountUpstreamError(避免自定义 temp-unschedulable 规则误冷却)。
|
||||
if hit, code, cyberMsg := detectOpenAICyberPolicy(body); hit {
|
||||
MarkOpsCyberPolicy(c, CyberPolicyMark{
|
||||
Code: code,
|
||||
Message: cyberMsg,
|
||||
Body: truncateString(string(body), 4096),
|
||||
UpstreamStatus: resp.StatusCode,
|
||||
})
|
||||
setOpsUpstreamError(c, resp.StatusCode, cyberMsg, truncateString(string(body), 2048))
|
||||
clientMsg := cyberMsg
|
||||
if clientMsg == "" {
|
||||
clientMsg = "Request blocked by upstream cyber-security policy"
|
||||
}
|
||||
writeError(c, resp.StatusCode, "invalid_request_error", clientMsg)
|
||||
if cyberMsg == "" {
|
||||
return nil, fmt.Errorf("openai cyber_policy: %d", resp.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("openai cyber_policy: %s", cyberMsg)
|
||||
}
|
||||
|
||||
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
|
||||
if upstreamMsg == "" {
|
||||
upstreamMsg = fmt.Sprintf("Upstream error: %d", resp.StatusCode)
|
||||
}
|
||||
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
|
||||
|
||||
upstreamDetail := ""
|
||||
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 2048
|
||||
}
|
||||
upstreamDetail = truncateString(string(body), maxBytes)
|
||||
}
|
||||
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
|
||||
|
||||
// Apply error passthrough rules
|
||||
if status, errType, errMsg, matched := applyErrorPassthroughRule(
|
||||
c, account.Platform, resp.StatusCode, body,
|
||||
http.StatusBadGateway, "api_error", "Upstream request failed",
|
||||
); matched {
|
||||
MarkResponseCommitted(c)
|
||||
writeError(c, status, errType, errMsg)
|
||||
if upstreamMsg == "" {
|
||||
upstreamMsg = errMsg
|
||||
}
|
||||
if upstreamMsg == "" {
|
||||
return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg)
|
||||
}
|
||||
|
||||
// Check custom error codes — if the account does not handle this status,
|
||||
// return a generic error without exposing upstream details.
|
||||
if !account.ShouldHandleErrorCode(resp.StatusCode) {
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
Kind: "http_error",
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
MarkResponseCommitted(c)
|
||||
writeError(c, http.StatusInternalServerError, "api_error", "Upstream gateway error")
|
||||
if upstreamMsg == "" {
|
||||
return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("upstream error: %d (not in custom error codes) message=%s", resp.StatusCode, upstreamMsg)
|
||||
}
|
||||
|
||||
// Track rate limits and decide whether to trigger secondary failover.
|
||||
var modelForCooldown string
|
||||
if len(requestedModel) > 0 {
|
||||
modelForCooldown = requestedModel[0]
|
||||
}
|
||||
shouldDisable := s.handleOpenAIAccountUpstreamError(
|
||||
c.Request.Context(), account, resp.StatusCode, resp.Header, body, modelForCooldown,
|
||||
)
|
||||
kind := "http_error"
|
||||
if shouldDisable {
|
||||
kind = "failover"
|
||||
}
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
Platform: account.Platform,
|
||||
AccountID: account.ID,
|
||||
AccountName: account.Name,
|
||||
UpstreamStatusCode: resp.StatusCode,
|
||||
UpstreamRequestID: resp.Header.Get("x-request-id"),
|
||||
Kind: kind,
|
||||
Message: upstreamMsg,
|
||||
Detail: upstreamDetail,
|
||||
})
|
||||
if shouldDisable {
|
||||
return nil, &UpstreamFailoverError{
|
||||
StatusCode: resp.StatusCode,
|
||||
ResponseBody: body,
|
||||
RetryableOnSameAccount: account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||||
}
|
||||
}
|
||||
|
||||
MarkResponseCommitted(c)
|
||||
|
||||
// Map status code to error type and write response
|
||||
errType := "api_error"
|
||||
switch {
|
||||
case resp.StatusCode == 400:
|
||||
errType = "invalid_request_error"
|
||||
case resp.StatusCode == 404:
|
||||
errType = "not_found_error"
|
||||
case resp.StatusCode == 429:
|
||||
errType = "rate_limit_error"
|
||||
case resp.StatusCode >= 500:
|
||||
errType = "api_error"
|
||||
}
|
||||
|
||||
writeError(c, resp.StatusCode, errType, upstreamMsg)
|
||||
return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,690 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
coderws "github.com/coder/websocket"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func normalizeOpenAIWSLogValue(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return "-"
|
||||
}
|
||||
return openAIWSLogValueReplacer.Replace(trimmed)
|
||||
}
|
||||
|
||||
func truncateOpenAIWSLogValue(value string, maxLen int) string {
|
||||
normalized := normalizeOpenAIWSLogValue(value)
|
||||
if normalized == "-" || maxLen <= 0 {
|
||||
return normalized
|
||||
}
|
||||
if len(normalized) <= maxLen {
|
||||
return normalized
|
||||
}
|
||||
return normalized[:maxLen] + "..."
|
||||
}
|
||||
|
||||
func openAIWSHeaderValueForLog(headers http.Header, key string) string {
|
||||
if headers == nil {
|
||||
return "-"
|
||||
}
|
||||
return truncateOpenAIWSLogValue(headers.Get(key), openAIWSHeaderValueMaxLen)
|
||||
}
|
||||
|
||||
func hasOpenAIWSHeader(headers http.Header, key string) bool {
|
||||
if headers == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(headers.Get(key)) != ""
|
||||
}
|
||||
|
||||
type openAIWSSessionHeaderResolution struct {
|
||||
SessionID string
|
||||
ConversationID string
|
||||
SessionSource string
|
||||
ConversationSource string
|
||||
}
|
||||
|
||||
func resolveOpenAIWSSessionHeaders(c *gin.Context, promptCacheKey string) openAIWSSessionHeaderResolution {
|
||||
resolution := openAIWSSessionHeaderResolution{
|
||||
SessionSource: "none",
|
||||
ConversationSource: "none",
|
||||
}
|
||||
if c != nil && c.Request != nil {
|
||||
if sessionID := strings.TrimSpace(c.Request.Header.Get("session_id")); sessionID != "" {
|
||||
resolution.SessionID = sessionID
|
||||
resolution.SessionSource = "header_session_id"
|
||||
}
|
||||
if conversationID := strings.TrimSpace(c.Request.Header.Get("conversation_id")); conversationID != "" {
|
||||
resolution.ConversationID = conversationID
|
||||
resolution.ConversationSource = "header_conversation_id"
|
||||
if resolution.SessionID == "" {
|
||||
resolution.SessionID = conversationID
|
||||
resolution.SessionSource = "header_conversation_id"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cacheKey := strings.TrimSpace(promptCacheKey)
|
||||
if cacheKey != "" {
|
||||
if resolution.SessionID == "" {
|
||||
resolution.SessionID = cacheKey
|
||||
resolution.SessionSource = "prompt_cache_key"
|
||||
}
|
||||
}
|
||||
return resolution
|
||||
}
|
||||
|
||||
func shouldLogOpenAIWSEvent(idx int, eventType string) bool {
|
||||
if idx <= openAIWSEventLogHeadLimit {
|
||||
return true
|
||||
}
|
||||
if openAIWSEventLogEveryN > 0 && idx%openAIWSEventLogEveryN == 0 {
|
||||
return true
|
||||
}
|
||||
if eventType == "error" || isOpenAIWSTerminalEvent(eventType) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shouldLogOpenAIWSBufferedEvent(idx int) bool {
|
||||
if idx <= openAIWSBufferLogHeadLimit {
|
||||
return true
|
||||
}
|
||||
if openAIWSBufferLogEveryN > 0 && idx%openAIWSBufferLogEveryN == 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func openAIWSEventMayContainModel(eventType string) bool {
|
||||
switch eventType {
|
||||
case "response.created",
|
||||
"response.in_progress",
|
||||
"response.completed",
|
||||
"response.done",
|
||||
"response.failed",
|
||||
"response.incomplete",
|
||||
"response.cancelled",
|
||||
"response.canceled":
|
||||
return true
|
||||
default:
|
||||
trimmed := strings.TrimSpace(eventType)
|
||||
if trimmed == eventType {
|
||||
return false
|
||||
}
|
||||
switch trimmed {
|
||||
case "response.created",
|
||||
"response.in_progress",
|
||||
"response.completed",
|
||||
"response.done",
|
||||
"response.failed",
|
||||
"response.incomplete",
|
||||
"response.cancelled",
|
||||
"response.canceled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openAIWSEventMayContainToolCalls(eventType string) bool {
|
||||
eventType = strings.TrimSpace(eventType)
|
||||
if eventType == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(eventType, "function_call") || strings.Contains(eventType, "tool_call") {
|
||||
return true
|
||||
}
|
||||
switch eventType {
|
||||
case "response.output_item.added", "response.output_item.done", "response.completed", "response.done":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func openAIWSEventShouldParseUsage(eventType string) bool {
|
||||
switch strings.TrimSpace(eventType) {
|
||||
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseOpenAIWSEventEnvelope(message []byte) (eventType string, responseID string, response gjson.Result) {
|
||||
if len(message) == 0 {
|
||||
return "", "", gjson.Result{}
|
||||
}
|
||||
values := gjson.GetManyBytes(message, "type", "response.id", "id", "response")
|
||||
eventType = strings.TrimSpace(values[0].String())
|
||||
if id := strings.TrimSpace(values[1].String()); id != "" {
|
||||
responseID = id
|
||||
} else {
|
||||
responseID = strings.TrimSpace(values[2].String())
|
||||
}
|
||||
return eventType, responseID, values[3]
|
||||
}
|
||||
|
||||
func openAIWSMessageLikelyContainsToolCalls(message []byte) bool {
|
||||
if len(message) == 0 {
|
||||
return false
|
||||
}
|
||||
return bytes.Contains(message, []byte(`"tool_calls"`)) ||
|
||||
bytes.Contains(message, []byte(`"tool_call"`)) ||
|
||||
bytes.Contains(message, []byte(`"function_call"`))
|
||||
}
|
||||
|
||||
func parseOpenAIWSResponseUsageFromCompletedEvent(message []byte, usage *OpenAIUsage) {
|
||||
if usage == nil || len(message) == 0 {
|
||||
return
|
||||
}
|
||||
if parsedUsage, ok := extractOpenAIUsageFromJSONBytes(message); ok {
|
||||
*usage = parsedUsage
|
||||
}
|
||||
}
|
||||
|
||||
func parseOpenAIWSErrorEventFields(message []byte) (code string, errType string, errMessage string) {
|
||||
if len(message) == 0 {
|
||||
return "", "", ""
|
||||
}
|
||||
values := gjson.GetManyBytes(message, "error.code", "error.type", "error.message")
|
||||
return strings.TrimSpace(values[0].String()), strings.TrimSpace(values[1].String()), strings.TrimSpace(values[2].String())
|
||||
}
|
||||
|
||||
func summarizeOpenAIWSErrorEventFieldsFromRaw(codeRaw, errTypeRaw, errMessageRaw string) (code string, errType string, errMessage string) {
|
||||
code = truncateOpenAIWSLogValue(codeRaw, openAIWSLogValueMaxLen)
|
||||
errType = truncateOpenAIWSLogValue(errTypeRaw, openAIWSLogValueMaxLen)
|
||||
errMessage = truncateOpenAIWSLogValue(errMessageRaw, openAIWSLogValueMaxLen)
|
||||
return code, errType, errMessage
|
||||
}
|
||||
|
||||
func summarizeOpenAIWSErrorEventFields(message []byte) (code string, errType string, errMessage string) {
|
||||
if len(message) == 0 {
|
||||
return "-", "-", "-"
|
||||
}
|
||||
return summarizeOpenAIWSErrorEventFieldsFromRaw(parseOpenAIWSErrorEventFields(message))
|
||||
}
|
||||
|
||||
func summarizeOpenAIWSPayloadKeySizes(payload map[string]any, topN int) string {
|
||||
if len(payload) == 0 {
|
||||
return "-"
|
||||
}
|
||||
type keySize struct {
|
||||
Key string
|
||||
Size int
|
||||
}
|
||||
sizes := make([]keySize, 0, len(payload))
|
||||
for key, value := range payload {
|
||||
size := estimateOpenAIWSPayloadValueSize(value, openAIWSPayloadSizeEstimateDepth)
|
||||
sizes = append(sizes, keySize{Key: key, Size: size})
|
||||
}
|
||||
sort.Slice(sizes, func(i, j int) bool {
|
||||
if sizes[i].Size == sizes[j].Size {
|
||||
return sizes[i].Key < sizes[j].Key
|
||||
}
|
||||
return sizes[i].Size > sizes[j].Size
|
||||
})
|
||||
|
||||
if topN <= 0 || topN > len(sizes) {
|
||||
topN = len(sizes)
|
||||
}
|
||||
parts := make([]string, 0, topN)
|
||||
for idx := 0; idx < topN; idx++ {
|
||||
item := sizes[idx]
|
||||
parts = append(parts, fmt.Sprintf("%s:%d", item.Key, item.Size))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func estimateOpenAIWSPayloadValueSize(value any, depth int) int {
|
||||
if depth <= 0 {
|
||||
return -1
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return 0
|
||||
case string:
|
||||
return len(v)
|
||||
case []byte:
|
||||
return len(v)
|
||||
case bool:
|
||||
return 1
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
return 8
|
||||
case float32, float64:
|
||||
return 8
|
||||
case map[string]any:
|
||||
if len(v) == 0 {
|
||||
return 2
|
||||
}
|
||||
total := 2
|
||||
count := 0
|
||||
for key, item := range v {
|
||||
count++
|
||||
if count > openAIWSPayloadSizeEstimateMaxItems {
|
||||
return -1
|
||||
}
|
||||
itemSize := estimateOpenAIWSPayloadValueSize(item, depth-1)
|
||||
if itemSize < 0 {
|
||||
return -1
|
||||
}
|
||||
total += len(key) + itemSize + 3
|
||||
if total > openAIWSPayloadSizeEstimateMaxBytes {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
return total
|
||||
case []any:
|
||||
if len(v) == 0 {
|
||||
return 2
|
||||
}
|
||||
total := 2
|
||||
limit := len(v)
|
||||
if limit > openAIWSPayloadSizeEstimateMaxItems {
|
||||
return -1
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
itemSize := estimateOpenAIWSPayloadValueSize(v[i], depth-1)
|
||||
if itemSize < 0 {
|
||||
return -1
|
||||
}
|
||||
total += itemSize + 1
|
||||
if total > openAIWSPayloadSizeEstimateMaxBytes {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
return total
|
||||
default:
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
if len(raw) > openAIWSPayloadSizeEstimateMaxBytes {
|
||||
return -1
|
||||
}
|
||||
return len(raw)
|
||||
}
|
||||
}
|
||||
|
||||
func openAIWSPayloadString(payload map[string]any, key string) string {
|
||||
if len(payload) == 0 {
|
||||
return ""
|
||||
}
|
||||
raw, ok := payload[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
case []byte:
|
||||
return strings.TrimSpace(string(v))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func openAIWSPayloadStringFromRaw(payload []byte, key string) string {
|
||||
if len(payload) == 0 || strings.TrimSpace(key) == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(gjson.GetBytes(payload, key).String())
|
||||
}
|
||||
|
||||
func openAIWSPayloadBoolFromRaw(payload []byte, key string, defaultValue bool) bool {
|
||||
if len(payload) == 0 || strings.TrimSpace(key) == "" {
|
||||
return defaultValue
|
||||
}
|
||||
value := gjson.GetBytes(payload, key)
|
||||
if !value.Exists() {
|
||||
return defaultValue
|
||||
}
|
||||
if value.Type != gjson.True && value.Type != gjson.False {
|
||||
return defaultValue
|
||||
}
|
||||
return value.Bool()
|
||||
}
|
||||
|
||||
func openAIWSSessionHashesFromID(sessionID string) (string, string) {
|
||||
return deriveOpenAISessionHashes(sessionID)
|
||||
}
|
||||
|
||||
func extractOpenAIWSImageURL(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
case map[string]any:
|
||||
if raw, ok := v["url"].(string); ok {
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func summarizeOpenAIWSInput(input any) string {
|
||||
items, ok := input.([]any)
|
||||
if !ok || len(items) == 0 {
|
||||
return "-"
|
||||
}
|
||||
|
||||
itemCount := len(items)
|
||||
textChars := 0
|
||||
imageDataURLs := 0
|
||||
imageDataURLChars := 0
|
||||
imageRemoteURLs := 0
|
||||
|
||||
handleContentItem := func(contentItem map[string]any) {
|
||||
contentType, _ := contentItem["type"].(string)
|
||||
switch strings.TrimSpace(contentType) {
|
||||
case "input_text", "output_text", "text":
|
||||
if text, ok := contentItem["text"].(string); ok {
|
||||
textChars += len(text)
|
||||
}
|
||||
case "input_image":
|
||||
imageURL := extractOpenAIWSImageURL(contentItem["image_url"])
|
||||
if imageURL == "" {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(imageURL), "data:image/") {
|
||||
imageDataURLs++
|
||||
imageDataURLChars += len(imageURL)
|
||||
return
|
||||
}
|
||||
imageRemoteURLs++
|
||||
}
|
||||
}
|
||||
|
||||
handleInputItem := func(inputItem map[string]any) {
|
||||
if content, ok := inputItem["content"].([]any); ok {
|
||||
for _, rawContent := range content {
|
||||
contentItem, ok := rawContent.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
handleContentItem(contentItem)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
itemType, _ := inputItem["type"].(string)
|
||||
switch strings.TrimSpace(itemType) {
|
||||
case "input_text", "output_text", "text":
|
||||
if text, ok := inputItem["text"].(string); ok {
|
||||
textChars += len(text)
|
||||
}
|
||||
case "input_image":
|
||||
imageURL := extractOpenAIWSImageURL(inputItem["image_url"])
|
||||
if imageURL == "" {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(imageURL), "data:image/") {
|
||||
imageDataURLs++
|
||||
imageDataURLChars += len(imageURL)
|
||||
return
|
||||
}
|
||||
imageRemoteURLs++
|
||||
}
|
||||
}
|
||||
|
||||
for _, rawItem := range items {
|
||||
inputItem, ok := rawItem.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
handleInputItem(inputItem)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"items=%d,text_chars=%d,image_data_urls=%d,image_data_url_chars=%d,image_remote_urls=%d",
|
||||
itemCount,
|
||||
textChars,
|
||||
imageDataURLs,
|
||||
imageDataURLChars,
|
||||
imageRemoteURLs,
|
||||
)
|
||||
}
|
||||
|
||||
func dropOpenAIWSPayloadKey(payload map[string]any, key string, removed *[]string) {
|
||||
if len(payload) == 0 || strings.TrimSpace(key) == "" {
|
||||
return
|
||||
}
|
||||
if _, exists := payload[key]; !exists {
|
||||
return
|
||||
}
|
||||
delete(payload, key)
|
||||
*removed = append(*removed, key)
|
||||
}
|
||||
|
||||
// applyOpenAIWSRetryPayloadStrategy 在 WS 连续失败时仅移除无语义字段,
|
||||
// 避免重试成功却改变原始请求语义。
|
||||
// 注意:prompt_cache_key 不应在重试中移除;它常用于会话稳定标识(session_id 兜底)。
|
||||
func applyOpenAIWSRetryPayloadStrategy(payload map[string]any, attempt int) (strategy string, removedKeys []string) {
|
||||
if len(payload) == 0 {
|
||||
return "empty", nil
|
||||
}
|
||||
if attempt <= 1 {
|
||||
return "full", nil
|
||||
}
|
||||
|
||||
removed := make([]string, 0, 2)
|
||||
if attempt >= 2 {
|
||||
dropOpenAIWSPayloadKey(payload, "include", &removed)
|
||||
}
|
||||
|
||||
if len(removed) == 0 {
|
||||
return "full", nil
|
||||
}
|
||||
sort.Strings(removed)
|
||||
return "trim_optional_fields", removed
|
||||
}
|
||||
|
||||
func logOpenAIWSModeInfo(format string, args ...any) {
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI WS Mode][openai_ws_mode=true] "+format, args...)
|
||||
}
|
||||
|
||||
func isOpenAIWSModeDebugEnabled() bool {
|
||||
return logger.L().Core().Enabled(zap.DebugLevel)
|
||||
}
|
||||
|
||||
func logOpenAIWSModeDebug(format string, args ...any) {
|
||||
if !isOpenAIWSModeDebugEnabled() {
|
||||
return
|
||||
}
|
||||
logger.LegacyPrintf("service.openai_gateway", "[debug] [OpenAI WS Mode][openai_ws_mode=true] "+format, args...)
|
||||
}
|
||||
|
||||
func logOpenAIWSBindResponseAccountWarn(groupID, accountID int64, responseID string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
logger.L().Warn(
|
||||
"openai.ws_bind_response_account_failed",
|
||||
zap.Int64("group_id", groupID),
|
||||
zap.Int64("account_id", accountID),
|
||||
zap.String("response_id", truncateOpenAIWSLogValue(responseID, openAIWSIDValueMaxLen)),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
|
||||
func summarizeOpenAIWSReadCloseError(err error) (status string, reason string) {
|
||||
if err == nil {
|
||||
return "-", "-"
|
||||
}
|
||||
statusCode := coderws.CloseStatus(err)
|
||||
if statusCode == -1 {
|
||||
return "-", "-"
|
||||
}
|
||||
closeStatus := fmt.Sprintf("%d(%s)", int(statusCode), statusCode.String())
|
||||
closeReason := "-"
|
||||
var closeErr coderws.CloseError
|
||||
if errors.As(err, &closeErr) {
|
||||
reasonText := strings.TrimSpace(closeErr.Reason)
|
||||
if reasonText != "" {
|
||||
closeReason = normalizeOpenAIWSLogValue(reasonText)
|
||||
}
|
||||
}
|
||||
return normalizeOpenAIWSLogValue(closeStatus), closeReason
|
||||
}
|
||||
|
||||
func unwrapOpenAIWSDialBaseError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var dialErr *openAIWSDialError
|
||||
if errors.As(err, &dialErr) && dialErr != nil && dialErr.Err != nil {
|
||||
return dialErr.Err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func openAIWSDialRespHeaderForLog(err error, key string) string {
|
||||
var dialErr *openAIWSDialError
|
||||
if !errors.As(err, &dialErr) || dialErr == nil || dialErr.ResponseHeaders == nil {
|
||||
return "-"
|
||||
}
|
||||
return truncateOpenAIWSLogValue(dialErr.ResponseHeaders.Get(key), openAIWSHeaderValueMaxLen)
|
||||
}
|
||||
|
||||
func classifyOpenAIWSDialError(err error) string {
|
||||
if err == nil {
|
||||
return "-"
|
||||
}
|
||||
baseErr := unwrapOpenAIWSDialBaseError(err)
|
||||
if baseErr == nil {
|
||||
return "-"
|
||||
}
|
||||
if errors.Is(baseErr, context.DeadlineExceeded) {
|
||||
return "ctx_deadline_exceeded"
|
||||
}
|
||||
if errors.Is(baseErr, context.Canceled) {
|
||||
return "ctx_canceled"
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(baseErr, &netErr) && netErr.Timeout() {
|
||||
return "net_timeout"
|
||||
}
|
||||
if status := coderws.CloseStatus(baseErr); status != -1 {
|
||||
return normalizeOpenAIWSLogValue(fmt.Sprintf("ws_close_%d", int(status)))
|
||||
}
|
||||
message := strings.ToLower(strings.TrimSpace(baseErr.Error()))
|
||||
switch {
|
||||
case strings.Contains(message, "handshake not finished"):
|
||||
return "handshake_not_finished"
|
||||
case strings.Contains(message, "bad handshake"):
|
||||
return "bad_handshake"
|
||||
case strings.Contains(message, "connection refused"):
|
||||
return "connection_refused"
|
||||
case strings.Contains(message, "no such host"):
|
||||
return "dns_not_found"
|
||||
case strings.Contains(message, "tls"):
|
||||
return "tls_error"
|
||||
case strings.Contains(message, "i/o timeout"):
|
||||
return "io_timeout"
|
||||
case strings.Contains(message, "context deadline exceeded"):
|
||||
return "ctx_deadline_exceeded"
|
||||
default:
|
||||
return "dial_error"
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeOpenAIWSDialError(err error) (
|
||||
statusCode int,
|
||||
dialClass string,
|
||||
closeStatus string,
|
||||
closeReason string,
|
||||
respServer string,
|
||||
respVia string,
|
||||
respCFRay string,
|
||||
respRequestID string,
|
||||
) {
|
||||
dialClass = "-"
|
||||
closeStatus = "-"
|
||||
closeReason = "-"
|
||||
respServer = "-"
|
||||
respVia = "-"
|
||||
respCFRay = "-"
|
||||
respRequestID = "-"
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
var dialErr *openAIWSDialError
|
||||
if errors.As(err, &dialErr) && dialErr != nil {
|
||||
statusCode = dialErr.StatusCode
|
||||
respServer = openAIWSDialRespHeaderForLog(err, "server")
|
||||
respVia = openAIWSDialRespHeaderForLog(err, "via")
|
||||
respCFRay = openAIWSDialRespHeaderForLog(err, "cf-ray")
|
||||
respRequestID = openAIWSDialRespHeaderForLog(err, "x-request-id")
|
||||
}
|
||||
dialClass = normalizeOpenAIWSLogValue(classifyOpenAIWSDialError(err))
|
||||
closeStatus, closeReason = summarizeOpenAIWSReadCloseError(unwrapOpenAIWSDialBaseError(err))
|
||||
return
|
||||
}
|
||||
|
||||
func isOpenAIWSClientDisconnectError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) || errors.Is(err, context.Canceled) {
|
||||
return true
|
||||
}
|
||||
switch coderws.CloseStatus(err) {
|
||||
case coderws.StatusNormalClosure, coderws.StatusGoingAway, coderws.StatusNoStatusRcvd, coderws.StatusAbnormalClosure:
|
||||
return true
|
||||
}
|
||||
message := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
if message == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(message, "failed to read frame header: eof") ||
|
||||
strings.Contains(message, "unexpected eof") ||
|
||||
strings.Contains(message, "use of closed network connection") ||
|
||||
strings.Contains(message, "connection reset by peer") ||
|
||||
strings.Contains(message, "broken pipe") ||
|
||||
strings.Contains(message, "an established connection was aborted")
|
||||
}
|
||||
|
||||
func classifyOpenAIWSReadFallbackReason(err error) string {
|
||||
if err == nil {
|
||||
return "read_event"
|
||||
}
|
||||
switch coderws.CloseStatus(err) {
|
||||
case coderws.StatusPolicyViolation:
|
||||
return "policy_violation"
|
||||
case coderws.StatusMessageTooBig:
|
||||
return "message_too_big"
|
||||
default:
|
||||
return "read_event"
|
||||
}
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
func (s *OpenAIGatewayService) buildOpenAIResponsesWSURL(account *Account) (string, error) {
|
||||
if account == nil {
|
||||
return "", errors.New("account is nil")
|
||||
}
|
||||
var targetURL string
|
||||
switch account.Type {
|
||||
case AccountTypeOAuth:
|
||||
targetURL = chatgptCodexURL
|
||||
case AccountTypeAPIKey:
|
||||
baseURL := account.GetOpenAIBaseURL()
|
||||
if baseURL == "" {
|
||||
targetURL = openaiPlatformAPIURL
|
||||
} else {
|
||||
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
targetURL = buildOpenAIResponsesURL(validatedURL)
|
||||
}
|
||||
default:
|
||||
targetURL = openaiPlatformAPIURL
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(strings.TrimSpace(targetURL))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid target url: %w", err)
|
||||
}
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "https":
|
||||
parsed.Scheme = "wss"
|
||||
case "http":
|
||||
parsed.Scheme = "ws"
|
||||
case "wss", "ws":
|
||||
// 保持不变
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported scheme for ws: %s", parsed.Scheme)
|
||||
}
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) buildOpenAIWSHeaders(
|
||||
ctx context.Context,
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
token string,
|
||||
decision OpenAIWSProtocolDecision,
|
||||
isCodexCLI bool,
|
||||
turnState string,
|
||||
turnMetadata string,
|
||||
promptCacheKey string,
|
||||
) (http.Header, openAIWSSessionHeaderResolution, error) {
|
||||
headers := make(http.Header)
|
||||
headers.Set("authorization", "Bearer "+token)
|
||||
|
||||
sessionResolution := resolveOpenAIWSSessionHeaders(c, promptCacheKey)
|
||||
if c != nil && c.Request != nil {
|
||||
if v := strings.TrimSpace(c.Request.Header.Get("accept-language")); v != "" {
|
||||
headers.Set("accept-language", v)
|
||||
}
|
||||
}
|
||||
// OAuth 账号:将 apiKeyID 混入 session 标识符,防止跨用户会话碰撞。
|
||||
if account != nil && account.Type == AccountTypeOAuth {
|
||||
apiKeyID := getAPIKeyIDFromContext(c)
|
||||
if sessionResolution.SessionID != "" {
|
||||
headers.Set("session_id", isolateOpenAISessionID(apiKeyID, sessionResolution.SessionID))
|
||||
}
|
||||
if sessionResolution.ConversationID != "" {
|
||||
headers.Set("conversation_id", isolateOpenAISessionID(apiKeyID, sessionResolution.ConversationID))
|
||||
}
|
||||
} else {
|
||||
if sessionResolution.SessionID != "" {
|
||||
headers.Set("session_id", sessionResolution.SessionID)
|
||||
}
|
||||
if sessionResolution.ConversationID != "" {
|
||||
headers.Set("conversation_id", sessionResolution.ConversationID)
|
||||
}
|
||||
}
|
||||
if state := strings.TrimSpace(turnState); state != "" {
|
||||
headers.Set(openAIWSTurnStateHeader, state)
|
||||
}
|
||||
if metadata := strings.TrimSpace(turnMetadata); metadata != "" {
|
||||
headers.Set(openAIWSTurnMetadataHeader, metadata)
|
||||
}
|
||||
|
||||
if account != nil && account.Type == AccountTypeOAuth {
|
||||
if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil {
|
||||
return nil, sessionResolution, fmt.Errorf("resolve chatgpt account headers: %w", err)
|
||||
}
|
||||
headers.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI))
|
||||
}
|
||||
|
||||
betaValue := openAIWSBetaV2Value
|
||||
if decision.Transport == OpenAIUpstreamTransportResponsesWebsocket {
|
||||
betaValue = openAIWSBetaV1Value
|
||||
}
|
||||
headers.Set("OpenAI-Beta", betaValue)
|
||||
|
||||
customUA := ""
|
||||
if account != nil {
|
||||
customUA = account.GetOpenAIUserAgent()
|
||||
}
|
||||
if strings.TrimSpace(customUA) != "" {
|
||||
headers.Set("user-agent", customUA)
|
||||
} else if c != nil {
|
||||
if ua := strings.TrimSpace(c.GetHeader("User-Agent")); ua != "" {
|
||||
headers.Set("user-agent", ua)
|
||||
}
|
||||
}
|
||||
if s != nil && s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
|
||||
headers.Set("user-agent", codexCLIUserAgent)
|
||||
}
|
||||
if account != nil && account.Type == AccountTypeOAuth && !openai.IsCodexCLIRequest(headers.Get("user-agent")) {
|
||||
headers.Set("user-agent", codexCLIUserAgent)
|
||||
}
|
||||
|
||||
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)。
|
||||
// 覆盖所有 WS 模式(ctx_pool/dedicated/passthrough)的握手头。
|
||||
account.ApplyHeaderOverrides(headers)
|
||||
|
||||
return headers, sessionResolution, nil
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) buildOpenAIWSCreatePayload(reqBody map[string]any, account *Account) map[string]any {
|
||||
// OpenAI WS Mode 协议:response.create 字段与 HTTP /responses 基本一致。
|
||||
// 保留 stream 字段(与 Codex CLI 一致),仅移除 background。
|
||||
payload := make(map[string]any, len(reqBody)+1)
|
||||
for k, v := range reqBody {
|
||||
payload[k] = v
|
||||
}
|
||||
|
||||
delete(payload, "background")
|
||||
if _, exists := payload["stream"]; !exists {
|
||||
payload["stream"] = true
|
||||
}
|
||||
payload["type"] = "response.create"
|
||||
|
||||
// OAuth 默认保持 store=false,避免误依赖服务端历史。
|
||||
if account != nil && account.Type == AccountTypeOAuth && !s.isOpenAIWSStoreRecoveryAllowed(account) {
|
||||
payload["store"] = false
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func setOpenAIWSTurnMetadata(payload map[string]any, turnMetadata string) {
|
||||
if len(payload) == 0 {
|
||||
return
|
||||
}
|
||||
metadata := strings.TrimSpace(turnMetadata)
|
||||
if metadata == "" {
|
||||
return
|
||||
}
|
||||
|
||||
switch existing := payload["client_metadata"].(type) {
|
||||
case map[string]any:
|
||||
existing[openAIWSTurnMetadataHeader] = metadata
|
||||
payload["client_metadata"] = existing
|
||||
case map[string]string:
|
||||
next := make(map[string]any, len(existing)+1)
|
||||
for k, v := range existing {
|
||||
next[k] = v
|
||||
}
|
||||
next[openAIWSTurnMetadataHeader] = metadata
|
||||
payload["client_metadata"] = next
|
||||
default:
|
||||
payload["client_metadata"] = map[string]any{
|
||||
openAIWSTurnMetadataHeader: metadata,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) isOpenAIWSStoreRecoveryAllowed(account *Account) bool {
|
||||
if account != nil && account.IsOpenAIWSAllowStoreRecoveryEnabled() {
|
||||
return true
|
||||
}
|
||||
if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.AllowStoreRecovery {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) isOpenAIWSStoreDisabledInRequest(reqBody map[string]any, account *Account) bool {
|
||||
if account != nil && account.Type == AccountTypeOAuth && !s.isOpenAIWSStoreRecoveryAllowed(account) {
|
||||
return true
|
||||
}
|
||||
if len(reqBody) == 0 {
|
||||
return false
|
||||
}
|
||||
rawStore, ok := reqBody["store"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
storeEnabled, ok := rawStore.(bool)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return !storeEnabled
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) isOpenAIWSStoreDisabledInRequestRaw(reqBody []byte, account *Account) bool {
|
||||
if account != nil && account.Type == AccountTypeOAuth && !s.isOpenAIWSStoreRecoveryAllowed(account) {
|
||||
return true
|
||||
}
|
||||
if len(reqBody) == 0 {
|
||||
return false
|
||||
}
|
||||
storeValue := gjson.GetBytes(reqBody, "store")
|
||||
if !storeValue.Exists() {
|
||||
return false
|
||||
}
|
||||
if storeValue.Type != gjson.True && storeValue.Type != gjson.False {
|
||||
return false
|
||||
}
|
||||
return !storeValue.Bool()
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) openAIWSStoreDisabledConnMode() string {
|
||||
if s == nil || s.cfg == nil {
|
||||
return openAIWSStoreDisabledConnModeStrict
|
||||
}
|
||||
mode := strings.ToLower(strings.TrimSpace(s.cfg.Gateway.OpenAIWS.StoreDisabledConnMode))
|
||||
switch mode {
|
||||
case openAIWSStoreDisabledConnModeStrict, openAIWSStoreDisabledConnModeAdaptive, openAIWSStoreDisabledConnModeOff:
|
||||
return mode
|
||||
case "":
|
||||
// 兼容旧配置:仅配置了布尔开关时按旧语义推导。
|
||||
if s.cfg.Gateway.OpenAIWS.StoreDisabledForceNewConn {
|
||||
return openAIWSStoreDisabledConnModeStrict
|
||||
}
|
||||
return openAIWSStoreDisabledConnModeOff
|
||||
default:
|
||||
return openAIWSStoreDisabledConnModeStrict
|
||||
}
|
||||
}
|
||||
|
||||
func shouldForceNewConnOnStoreDisabled(mode, lastFailureReason string) bool {
|
||||
switch mode {
|
||||
case openAIWSStoreDisabledConnModeOff:
|
||||
return false
|
||||
case openAIWSStoreDisabledConnModeAdaptive:
|
||||
reason := strings.TrimPrefix(strings.TrimSpace(lastFailureReason), "prewarm_")
|
||||
switch reason {
|
||||
case "policy_violation", "message_too_big", "auth_failed", "write_request", "write":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func dropPreviousResponseIDFromRawPayload(payload []byte) ([]byte, bool, error) {
|
||||
return dropPreviousResponseIDFromRawPayloadWithDeleteFn(payload, sjson.DeleteBytes)
|
||||
}
|
||||
|
||||
func dropPreviousResponseIDFromRawPayloadWithDeleteFn(
|
||||
payload []byte,
|
||||
deleteFn func([]byte, string) ([]byte, error),
|
||||
) ([]byte, bool, error) {
|
||||
if len(payload) == 0 {
|
||||
return payload, false, nil
|
||||
}
|
||||
if !gjson.GetBytes(payload, "previous_response_id").Exists() {
|
||||
return payload, false, nil
|
||||
}
|
||||
if deleteFn == nil {
|
||||
deleteFn = sjson.DeleteBytes
|
||||
}
|
||||
|
||||
updated := payload
|
||||
for i := 0; i < openAIWSMaxPrevResponseIDDeletePasses &&
|
||||
gjson.GetBytes(updated, "previous_response_id").Exists(); i++ {
|
||||
next, err := deleteFn(updated, "previous_response_id")
|
||||
if err != nil {
|
||||
return payload, false, err
|
||||
}
|
||||
updated = next
|
||||
}
|
||||
return updated, !gjson.GetBytes(updated, "previous_response_id").Exists(), nil
|
||||
}
|
||||
|
||||
func setPreviousResponseIDToRawPayload(payload []byte, previousResponseID string) ([]byte, error) {
|
||||
normalizedPrevID := strings.TrimSpace(previousResponseID)
|
||||
if len(payload) == 0 || normalizedPrevID == "" {
|
||||
return payload, nil
|
||||
}
|
||||
updated, err := sjson.SetBytes(payload, "previous_response_id", normalizedPrevID)
|
||||
if err == nil {
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
var reqBody map[string]any
|
||||
if unmarshalErr := json.Unmarshal(payload, &reqBody); unmarshalErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody["previous_response_id"] = normalizedPrevID
|
||||
rebuilt, marshalErr := json.Marshal(reqBody)
|
||||
if marshalErr != nil {
|
||||
return nil, marshalErr
|
||||
}
|
||||
return rebuilt, nil
|
||||
}
|
||||
|
||||
func shouldInferIngressFunctionCallOutputPreviousResponseID(
|
||||
storeDisabled bool,
|
||||
turn int,
|
||||
signals ToolContinuationSignals,
|
||||
currentPreviousResponseID string,
|
||||
expectedPreviousResponseID string,
|
||||
) bool {
|
||||
if !storeDisabled || turn <= 1 || !signals.HasFunctionCallOutput {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(currentPreviousResponseID) != "" {
|
||||
return false
|
||||
}
|
||||
if signals.HasFunctionCallOutputMissingCallID {
|
||||
return false
|
||||
}
|
||||
// If the client already sent the actual tool-call context, treat this as
|
||||
// a full replay / self-contained continuation payload rather than
|
||||
// downgrading it into an inferred delta continuation. item_reference alone
|
||||
// is not enough on the store=false WS path: it still needs a valid prior
|
||||
// response anchor so upstream can resolve the referenced function_call.
|
||||
if signals.HasToolCallContext {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(expectedPreviousResponseID) != ""
|
||||
}
|
||||
|
||||
func alignStoreDisabledPreviousResponseID(
|
||||
payload []byte,
|
||||
expectedPreviousResponseID string,
|
||||
) ([]byte, bool, error) {
|
||||
if len(payload) == 0 {
|
||||
return payload, false, nil
|
||||
}
|
||||
expected := strings.TrimSpace(expectedPreviousResponseID)
|
||||
if expected == "" {
|
||||
return payload, false, nil
|
||||
}
|
||||
current := openAIWSPayloadStringFromRaw(payload, "previous_response_id")
|
||||
if current == "" || current == expected {
|
||||
return payload, false, nil
|
||||
}
|
||||
|
||||
withoutPrev, removed, dropErr := dropPreviousResponseIDFromRawPayload(payload)
|
||||
if dropErr != nil {
|
||||
return payload, false, dropErr
|
||||
}
|
||||
if !removed {
|
||||
return payload, false, nil
|
||||
}
|
||||
updated, setErr := setPreviousResponseIDToRawPayload(withoutPrev, expected)
|
||||
if setErr != nil {
|
||||
return payload, false, setErr
|
||||
}
|
||||
return updated, true, nil
|
||||
}
|
||||
|
||||
func cloneOpenAIWSPayloadBytes(payload []byte) []byte {
|
||||
if len(payload) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]byte, len(payload))
|
||||
copy(cloned, payload)
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneOpenAIWSRawMessages(items []json.RawMessage) []json.RawMessage {
|
||||
if items == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]json.RawMessage, 0, len(items))
|
||||
for idx := range items {
|
||||
cloned = append(cloned, json.RawMessage(cloneOpenAIWSPayloadBytes(items[idx])))
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func normalizeOpenAIWSJSONForCompare(raw []byte) ([]byte, error) {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) == 0 {
|
||||
return nil, errors.New("json is empty")
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal(trimmed, &decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(decoded)
|
||||
}
|
||||
|
||||
func normalizeOpenAIWSJSONForCompareOrRaw(raw []byte) []byte {
|
||||
normalized, err := normalizeOpenAIWSJSONForCompare(raw)
|
||||
if err != nil {
|
||||
return bytes.TrimSpace(raw)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload []byte) ([]byte, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, errors.New("payload is empty")
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delete(decoded, "input")
|
||||
delete(decoded, "previous_response_id")
|
||||
return json.Marshal(decoded)
|
||||
}
|
||||
|
||||
func openAIWSExtractNormalizedInputSequence(payload []byte) ([]json.RawMessage, bool, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
inputValue := gjson.GetBytes(payload, "input")
|
||||
if !inputValue.Exists() {
|
||||
return nil, false, nil
|
||||
}
|
||||
if inputValue.Type == gjson.JSON {
|
||||
raw := strings.TrimSpace(inputValue.Raw)
|
||||
if strings.HasPrefix(raw, "[") {
|
||||
var items []json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return items, true, nil
|
||||
}
|
||||
return []json.RawMessage{json.RawMessage(raw)}, true, nil
|
||||
}
|
||||
if inputValue.Type == gjson.String {
|
||||
encoded, _ := json.Marshal(inputValue.String())
|
||||
return []json.RawMessage{encoded}, true, nil
|
||||
}
|
||||
return []json.RawMessage{json.RawMessage(inputValue.Raw)}, true, nil
|
||||
}
|
||||
|
||||
func openAIWSInputIsPrefixExtended(previousPayload, currentPayload []byte) (bool, error) {
|
||||
previousItems, previousExists, prevErr := openAIWSExtractNormalizedInputSequence(previousPayload)
|
||||
if prevErr != nil {
|
||||
return false, prevErr
|
||||
}
|
||||
currentItems, currentExists, currentErr := openAIWSExtractNormalizedInputSequence(currentPayload)
|
||||
if currentErr != nil {
|
||||
return false, currentErr
|
||||
}
|
||||
if !previousExists && !currentExists {
|
||||
return true, nil
|
||||
}
|
||||
if !previousExists {
|
||||
return len(currentItems) == 0, nil
|
||||
}
|
||||
if !currentExists {
|
||||
return len(previousItems) == 0, nil
|
||||
}
|
||||
if len(currentItems) < len(previousItems) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
for idx := range previousItems {
|
||||
previousNormalized := normalizeOpenAIWSJSONForCompareOrRaw(previousItems[idx])
|
||||
currentNormalized := normalizeOpenAIWSJSONForCompareOrRaw(currentItems[idx])
|
||||
if !bytes.Equal(previousNormalized, currentNormalized) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func openAIWSRawItemsHasPrefix(items []json.RawMessage, prefix []json.RawMessage) bool {
|
||||
if len(prefix) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(items) < len(prefix) {
|
||||
return false
|
||||
}
|
||||
for idx := range prefix {
|
||||
previousNormalized := normalizeOpenAIWSJSONForCompareOrRaw(prefix[idx])
|
||||
currentNormalized := normalizeOpenAIWSJSONForCompareOrRaw(items[idx])
|
||||
if !bytes.Equal(previousNormalized, currentNormalized) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func openAIWSRawItemsHasFunctionCallOutput(items []json.RawMessage) bool {
|
||||
for _, item := range items {
|
||||
if isCodexToolCallOutputItemType(gjson.GetBytes(item, "type").String()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func openAIWSRawItemsHaveToolCallContextForOutputs(items []json.RawMessage) bool {
|
||||
if len(items) == 0 {
|
||||
return false
|
||||
}
|
||||
contextCallIDs := make(map[string]struct{})
|
||||
outputCallIDs := make(map[string]struct{})
|
||||
for _, item := range items {
|
||||
itemType := gjson.GetBytes(item, "type").String()
|
||||
callID := strings.TrimSpace(gjson.GetBytes(item, "call_id").String())
|
||||
switch {
|
||||
case isCodexToolCallContextItemType(itemType):
|
||||
if callID != "" {
|
||||
contextCallIDs[callID] = struct{}{}
|
||||
}
|
||||
case isCodexToolCallOutputItemType(itemType):
|
||||
if callID == "" {
|
||||
return false
|
||||
}
|
||||
outputCallIDs[callID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(outputCallIDs) == 0 || len(contextCallIDs) == 0 {
|
||||
return false
|
||||
}
|
||||
for callID := range outputCallIDs {
|
||||
if _, ok := contextCallIDs[callID]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func openAIWSRawPayloadHasToolCallOutput(payload []byte) bool {
|
||||
if len(payload) == 0 {
|
||||
return false
|
||||
}
|
||||
input := gjson.GetBytes(payload, "input")
|
||||
if !input.Exists() {
|
||||
return false
|
||||
}
|
||||
if input.IsArray() {
|
||||
for _, item := range input.Array() {
|
||||
if isCodexToolCallOutputItemType(item.Get("type").String()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if input.Type == gjson.JSON {
|
||||
return isCodexToolCallOutputItemType(input.Get("type").String())
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildOpenAIWSReplayInputSequence(
|
||||
previousFullInput []json.RawMessage,
|
||||
previousFullInputExists bool,
|
||||
currentPayload []byte,
|
||||
hasPreviousResponseID bool,
|
||||
) ([]json.RawMessage, bool, error) {
|
||||
currentItems, currentExists, currentErr := openAIWSExtractNormalizedInputSequence(currentPayload)
|
||||
if currentErr != nil {
|
||||
return nil, false, currentErr
|
||||
}
|
||||
if !hasPreviousResponseID {
|
||||
return cloneOpenAIWSRawMessages(currentItems), currentExists, nil
|
||||
}
|
||||
if !previousFullInputExists {
|
||||
return cloneOpenAIWSRawMessages(currentItems), currentExists, nil
|
||||
}
|
||||
if !currentExists || len(currentItems) == 0 {
|
||||
return cloneOpenAIWSRawMessages(previousFullInput), true, nil
|
||||
}
|
||||
if openAIWSRawItemsHasPrefix(currentItems, previousFullInput) {
|
||||
return cloneOpenAIWSRawMessages(currentItems), true, nil
|
||||
}
|
||||
merged := make([]json.RawMessage, 0, len(previousFullInput)+len(currentItems))
|
||||
merged = append(merged, cloneOpenAIWSRawMessages(previousFullInput)...)
|
||||
merged = append(merged, cloneOpenAIWSRawMessages(currentItems)...)
|
||||
return merged, true, nil
|
||||
}
|
||||
|
||||
func setOpenAIWSPayloadInputSequence(
|
||||
payload []byte,
|
||||
fullInput []json.RawMessage,
|
||||
fullInputExists bool,
|
||||
) ([]byte, error) {
|
||||
if !fullInputExists {
|
||||
return payload, nil
|
||||
}
|
||||
// Preserve [] vs null semantics when input exists but is empty.
|
||||
inputForMarshal := fullInput
|
||||
if inputForMarshal == nil {
|
||||
inputForMarshal = []json.RawMessage{}
|
||||
}
|
||||
inputRaw, marshalErr := json.Marshal(inputForMarshal)
|
||||
if marshalErr != nil {
|
||||
return nil, marshalErr
|
||||
}
|
||||
return sjson.SetRawBytes(payload, "input", inputRaw)
|
||||
}
|
||||
|
||||
func shouldKeepIngressPreviousResponseID(
|
||||
previousPayload []byte,
|
||||
currentPayload []byte,
|
||||
lastTurnResponseID string,
|
||||
hasFunctionCallOutput bool,
|
||||
) (bool, string, error) {
|
||||
if hasFunctionCallOutput {
|
||||
return true, "has_function_call_output", nil
|
||||
}
|
||||
currentPreviousResponseID := strings.TrimSpace(openAIWSPayloadStringFromRaw(currentPayload, "previous_response_id"))
|
||||
if currentPreviousResponseID == "" {
|
||||
return false, "missing_previous_response_id", nil
|
||||
}
|
||||
expectedPreviousResponseID := strings.TrimSpace(lastTurnResponseID)
|
||||
if expectedPreviousResponseID == "" {
|
||||
return false, "missing_last_turn_response_id", nil
|
||||
}
|
||||
if currentPreviousResponseID != expectedPreviousResponseID {
|
||||
return false, "previous_response_id_mismatch", nil
|
||||
}
|
||||
if len(previousPayload) == 0 {
|
||||
return false, "missing_previous_turn_payload", nil
|
||||
}
|
||||
|
||||
previousComparable, previousComparableErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(previousPayload)
|
||||
if previousComparableErr != nil {
|
||||
return false, "non_input_compare_error", previousComparableErr
|
||||
}
|
||||
currentComparable, currentComparableErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(currentPayload)
|
||||
if currentComparableErr != nil {
|
||||
return false, "non_input_compare_error", currentComparableErr
|
||||
}
|
||||
if !bytes.Equal(previousComparable, currentComparable) {
|
||||
return false, "non_input_changed", nil
|
||||
}
|
||||
return true, "strict_incremental_ok", nil
|
||||
}
|
||||
|
||||
type openAIWSIngressPreviousTurnStrictState struct {
|
||||
nonInputComparable []byte
|
||||
}
|
||||
|
||||
func buildOpenAIWSIngressPreviousTurnStrictState(payload []byte) (*openAIWSIngressPreviousTurnStrictState, error) {
|
||||
if len(payload) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
nonInputComparable, nonInputErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload)
|
||||
if nonInputErr != nil {
|
||||
return nil, nonInputErr
|
||||
}
|
||||
return &openAIWSIngressPreviousTurnStrictState{
|
||||
nonInputComparable: nonInputComparable,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func shouldKeepIngressPreviousResponseIDWithStrictState(
|
||||
previousState *openAIWSIngressPreviousTurnStrictState,
|
||||
currentPayload []byte,
|
||||
lastTurnResponseID string,
|
||||
hasFunctionCallOutput bool,
|
||||
) (bool, string, error) {
|
||||
if hasFunctionCallOutput {
|
||||
return true, "has_function_call_output", nil
|
||||
}
|
||||
currentPreviousResponseID := strings.TrimSpace(openAIWSPayloadStringFromRaw(currentPayload, "previous_response_id"))
|
||||
if currentPreviousResponseID == "" {
|
||||
return false, "missing_previous_response_id", nil
|
||||
}
|
||||
expectedPreviousResponseID := strings.TrimSpace(lastTurnResponseID)
|
||||
if expectedPreviousResponseID == "" {
|
||||
return false, "missing_last_turn_response_id", nil
|
||||
}
|
||||
if currentPreviousResponseID != expectedPreviousResponseID {
|
||||
return false, "previous_response_id_mismatch", nil
|
||||
}
|
||||
if previousState == nil {
|
||||
return false, "missing_previous_turn_payload", nil
|
||||
}
|
||||
|
||||
currentComparable, currentComparableErr := normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(currentPayload)
|
||||
if currentComparableErr != nil {
|
||||
return false, "non_input_compare_error", currentComparableErr
|
||||
}
|
||||
if !bytes.Equal(previousState.nonInputComparable, currentComparable) {
|
||||
return false, "non_input_changed", nil
|
||||
}
|
||||
return true, "strict_incremental_ok", nil
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
func (s *OpenAIGatewayService) isOpenAIWSGeneratePrewarmEnabled() bool {
|
||||
return s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.PrewarmGenerateEnabled
|
||||
}
|
||||
|
||||
// performOpenAIWSGeneratePrewarm 在 WSv2 下执行可选的 generate=false 预热。
|
||||
// 预热默认关闭,仅在配置开启后生效;失败时按可恢复错误回退到 HTTP。
|
||||
func (s *OpenAIGatewayService) performOpenAIWSGeneratePrewarm(
|
||||
ctx context.Context,
|
||||
lease *openAIWSConnLease,
|
||||
decision OpenAIWSProtocolDecision,
|
||||
payload map[string]any,
|
||||
previousResponseID string,
|
||||
reqBody map[string]any,
|
||||
account *Account,
|
||||
stateStore OpenAIWSStateStore,
|
||||
groupID int64,
|
||||
) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if lease == nil || account == nil {
|
||||
logOpenAIWSModeInfo("prewarm_skip reason=invalid_state has_lease=%v has_account=%v", lease != nil, account != nil)
|
||||
return nil
|
||||
}
|
||||
connID := strings.TrimSpace(lease.ConnID())
|
||||
if !s.isOpenAIWSGeneratePrewarmEnabled() {
|
||||
return nil
|
||||
}
|
||||
if decision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 {
|
||||
logOpenAIWSModeInfo(
|
||||
"prewarm_skip account_id=%d conn_id=%s reason=transport_not_v2 transport=%s",
|
||||
account.ID,
|
||||
connID,
|
||||
normalizeOpenAIWSLogValue(string(decision.Transport)),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(previousResponseID) != "" {
|
||||
logOpenAIWSModeInfo(
|
||||
"prewarm_skip account_id=%d conn_id=%s reason=has_previous_response_id previous_response_id=%s",
|
||||
account.ID,
|
||||
connID,
|
||||
truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if lease.IsPrewarmed() {
|
||||
logOpenAIWSModeInfo("prewarm_skip account_id=%d conn_id=%s reason=already_prewarmed", account.ID, connID)
|
||||
return nil
|
||||
}
|
||||
if NeedsToolContinuation(reqBody) {
|
||||
logOpenAIWSModeInfo("prewarm_skip account_id=%d conn_id=%s reason=tool_continuation", account.ID, connID)
|
||||
return nil
|
||||
}
|
||||
prewarmStart := time.Now()
|
||||
logOpenAIWSModeInfo("prewarm_start account_id=%d conn_id=%s", account.ID, connID)
|
||||
|
||||
prewarmPayload := make(map[string]any, len(payload)+1)
|
||||
for k, v := range payload {
|
||||
prewarmPayload[k] = v
|
||||
}
|
||||
prewarmPayload["generate"] = false
|
||||
prewarmPayloadJSON := payloadAsJSONBytes(prewarmPayload)
|
||||
|
||||
if err := lease.WriteJSONWithContextTimeout(ctx, prewarmPayload, s.openAIWSWriteTimeout()); err != nil {
|
||||
lease.MarkBroken()
|
||||
logOpenAIWSModeInfo(
|
||||
"prewarm_write_fail account_id=%d conn_id=%s cause=%s",
|
||||
account.ID,
|
||||
connID,
|
||||
truncateOpenAIWSLogValue(err.Error(), openAIWSLogValueMaxLen),
|
||||
)
|
||||
return wrapOpenAIWSFallback("prewarm_write", err)
|
||||
}
|
||||
logOpenAIWSModeInfo("prewarm_write_sent account_id=%d conn_id=%s payload_bytes=%d", account.ID, connID, len(prewarmPayloadJSON))
|
||||
|
||||
prewarmResponseID := ""
|
||||
prewarmEventCount := 0
|
||||
prewarmTerminalCount := 0
|
||||
for {
|
||||
message, readErr := lease.ReadMessageWithContextTimeout(ctx, s.openAIWSReadTimeout())
|
||||
if readErr != nil {
|
||||
lease.MarkBroken()
|
||||
closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr)
|
||||
logOpenAIWSModeInfo(
|
||||
"prewarm_read_fail account_id=%d conn_id=%s close_status=%s close_reason=%s cause=%s events=%d",
|
||||
account.ID,
|
||||
connID,
|
||||
closeStatus,
|
||||
closeReason,
|
||||
truncateOpenAIWSLogValue(readErr.Error(), openAIWSLogValueMaxLen),
|
||||
prewarmEventCount,
|
||||
)
|
||||
return wrapOpenAIWSFallback("prewarm_"+classifyOpenAIWSReadFallbackReason(readErr), readErr)
|
||||
}
|
||||
|
||||
eventType, eventResponseID, _ := parseOpenAIWSEventEnvelope(message)
|
||||
if eventType == "" {
|
||||
continue
|
||||
}
|
||||
prewarmEventCount++
|
||||
if prewarmResponseID == "" && eventResponseID != "" {
|
||||
prewarmResponseID = eventResponseID
|
||||
}
|
||||
if prewarmEventCount <= openAIWSPrewarmEventLogHead || eventType == "error" || isOpenAIWSTerminalEvent(eventType) {
|
||||
logOpenAIWSModeInfo(
|
||||
"prewarm_event account_id=%d conn_id=%s idx=%d type=%s bytes=%d",
|
||||
account.ID,
|
||||
connID,
|
||||
prewarmEventCount,
|
||||
truncateOpenAIWSLogValue(eventType, openAIWSLogValueMaxLen),
|
||||
len(message),
|
||||
)
|
||||
}
|
||||
|
||||
if eventType == "error" {
|
||||
errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(message)
|
||||
s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), message, errCodeRaw, errTypeRaw, errMsgRaw)
|
||||
errMsg := strings.TrimSpace(errMsgRaw)
|
||||
if errMsg == "" {
|
||||
errMsg = "OpenAI websocket prewarm error"
|
||||
}
|
||||
fallbackReason, canFallback := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMsgRaw)
|
||||
errCode, errType, errMessage := summarizeOpenAIWSErrorEventFieldsFromRaw(errCodeRaw, errTypeRaw, errMsgRaw)
|
||||
logOpenAIWSModeInfo(
|
||||
"prewarm_error_event account_id=%d conn_id=%s idx=%d fallback_reason=%s can_fallback=%v err_code=%s err_type=%s err_message=%s",
|
||||
account.ID,
|
||||
connID,
|
||||
prewarmEventCount,
|
||||
truncateOpenAIWSLogValue(fallbackReason, openAIWSLogValueMaxLen),
|
||||
canFallback,
|
||||
errCode,
|
||||
errType,
|
||||
errMessage,
|
||||
)
|
||||
lease.MarkBroken()
|
||||
if canFallback {
|
||||
return wrapOpenAIWSFallback("prewarm_"+fallbackReason, errors.New(errMsg))
|
||||
}
|
||||
return wrapOpenAIWSFallback("prewarm_error_event", errors.New(errMsg))
|
||||
}
|
||||
|
||||
if isOpenAIWSTerminalEvent(eventType) {
|
||||
prewarmTerminalCount++
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
lease.MarkPrewarmed()
|
||||
if prewarmResponseID != "" && stateStore != nil {
|
||||
ttl := s.openAIWSResponseStickyTTL()
|
||||
logOpenAIWSBindResponseAccountWarn(groupID, account.ID, prewarmResponseID, stateStore.BindResponseAccount(ctx, groupID, prewarmResponseID, account.ID, ttl))
|
||||
stateStore.BindResponseConn(prewarmResponseID, lease.ConnID(), ttl)
|
||||
}
|
||||
logOpenAIWSModeInfo(
|
||||
"prewarm_done account_id=%d conn_id=%s response_id=%s events=%d terminal_events=%d duration_ms=%d",
|
||||
account.ID,
|
||||
connID,
|
||||
truncateOpenAIWSLogValue(prewarmResponseID, openAIWSIDValueMaxLen),
|
||||
prewarmEventCount,
|
||||
prewarmTerminalCount,
|
||||
time.Since(prewarmStart).Milliseconds(),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func payloadAsJSON(payload map[string]any) string {
|
||||
return string(payloadAsJSONBytes(payload))
|
||||
}
|
||||
|
||||
func payloadAsJSONBytes(payload map[string]any) []byte {
|
||||
if len(payload) == 0 {
|
||||
return []byte("{}")
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return []byte("{}")
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func isOpenAIWSTerminalEvent(eventType string) bool {
|
||||
switch strings.TrimSpace(eventType) {
|
||||
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isOpenAIWSTokenEvent(eventType string) bool {
|
||||
eventType = strings.TrimSpace(eventType)
|
||||
if eventType == "" {
|
||||
return false
|
||||
}
|
||||
switch eventType {
|
||||
case "response.created", "response.in_progress", "response.output_item.added", "response.output_item.done":
|
||||
return false
|
||||
}
|
||||
if strings.Contains(eventType, ".delta") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(eventType, "response.output_text") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(eventType, "response.output") {
|
||||
return true
|
||||
}
|
||||
// 终止事件(response.completed/done/failed/...)由 isOpenAIWSTerminalEvent 单独处理。
|
||||
// 不能把它们当作 token event,否则当上游没有可识别的 delta 时,
|
||||
// firstTokenMs 会被填到终止时刻,等于把"总耗时"误报为"首 token 延迟"。
|
||||
return false
|
||||
}
|
||||
|
||||
func replaceOpenAIWSMessageModel(message []byte, fromModel, toModel string) []byte {
|
||||
if len(message) == 0 {
|
||||
return message
|
||||
}
|
||||
if strings.TrimSpace(fromModel) == "" || strings.TrimSpace(toModel) == "" || fromModel == toModel {
|
||||
return message
|
||||
}
|
||||
if !bytes.Contains(message, []byte(`"model"`)) || !bytes.Contains(message, []byte(fromModel)) {
|
||||
return message
|
||||
}
|
||||
modelValues := gjson.GetManyBytes(message, "model", "response.model")
|
||||
replaceModel := modelValues[0].Exists() && modelValues[0].Str == fromModel
|
||||
replaceResponseModel := modelValues[1].Exists() && modelValues[1].Str == fromModel
|
||||
if !replaceModel && !replaceResponseModel {
|
||||
return message
|
||||
}
|
||||
updated := message
|
||||
if replaceModel {
|
||||
if next, err := sjson.SetBytes(updated, "model", toModel); err == nil {
|
||||
updated = next
|
||||
}
|
||||
}
|
||||
if replaceResponseModel {
|
||||
if next, err := sjson.SetBytes(updated, "response.model", toModel); err == nil {
|
||||
updated = next
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func populateOpenAIUsageFromResponseJSON(body []byte, usage *OpenAIUsage) {
|
||||
if usage == nil || len(body) == 0 {
|
||||
return
|
||||
}
|
||||
values := gjson.GetManyBytes(
|
||||
body,
|
||||
"usage.input_tokens",
|
||||
"usage.output_tokens",
|
||||
"usage.input_tokens_details.cached_tokens",
|
||||
)
|
||||
usage.InputTokens = int(values[0].Int())
|
||||
usage.OutputTokens = int(values[1].Int())
|
||||
usage.CacheReadInputTokens = int(values[2].Int())
|
||||
}
|
||||
|
||||
func getOpenAIGroupIDFromContext(c *gin.Context) int64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
value, exists := c.Get("api_key")
|
||||
if !exists {
|
||||
return 0
|
||||
}
|
||||
apiKey, ok := value.(*APIKey)
|
||||
if !ok || apiKey == nil || apiKey.GroupID == nil {
|
||||
return 0
|
||||
}
|
||||
return *apiKey.GroupID
|
||||
}
|
||||
|
||||
// SelectAccountByPreviousResponseID 按 previous_response_id 命中账号粘连。
|
||||
// 未命中或账号不可用时返回 (nil, nil),由调用方继续走常规调度。
|
||||
func (s *OpenAIGatewayService) SelectAccountByPreviousResponseID(
|
||||
ctx context.Context,
|
||||
groupID *int64,
|
||||
previousResponseID string,
|
||||
requestedModel string,
|
||||
excludedIDs map[int64]struct{},
|
||||
requireCompact bool,
|
||||
) (*AccountSelectionResult, error) {
|
||||
return s.selectAccountByPreviousResponseIDForCapability(ctx, groupID, previousResponseID, requestedModel, excludedIDs, "", requireCompact)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) selectAccountByPreviousResponseIDForCapability(
|
||||
ctx context.Context,
|
||||
groupID *int64,
|
||||
previousResponseID string,
|
||||
requestedModel string,
|
||||
excludedIDs map[int64]struct{},
|
||||
requiredCapability OpenAIEndpointCapability,
|
||||
requireCompact bool,
|
||||
) (*AccountSelectionResult, error) {
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
accountID, account, responseID, store := s.resolveAccountByPreviousResponseIDForCapability(ctx, groupID, previousResponseID, requestedModel, excludedIDs, requiredCapability, requireCompact)
|
||||
if accountID <= 0 || account == nil || store == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result, acquireErr := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency)
|
||||
if acquireErr == nil && result.Acquired {
|
||||
logOpenAIWSBindResponseAccountWarn(
|
||||
derefGroupID(groupID),
|
||||
accountID,
|
||||
responseID,
|
||||
store.BindResponseAccount(ctx, derefGroupID(groupID), responseID, accountID, s.openAIWSResponseStickyTTL()),
|
||||
)
|
||||
return &AccountSelectionResult{
|
||||
Account: account,
|
||||
Acquired: true,
|
||||
ReleaseFunc: result.ReleaseFunc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
cfg := s.schedulingConfig()
|
||||
if s.concurrencyService != nil {
|
||||
return &AccountSelectionResult{
|
||||
Account: account,
|
||||
WaitPlan: &AccountWaitPlan{
|
||||
AccountID: accountID,
|
||||
MaxConcurrency: account.Concurrency,
|
||||
Timeout: cfg.StickySessionWaitTimeout,
|
||||
MaxWaiting: cfg.StickySessionMaxWaiting,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) ResolveAccountIDByPreviousResponseIDForScheduler(
|
||||
ctx context.Context,
|
||||
groupID *int64,
|
||||
previousResponseID string,
|
||||
requestedModel string,
|
||||
excludedIDs map[int64]struct{},
|
||||
requiredCapability OpenAIEndpointCapability,
|
||||
requireCompact bool,
|
||||
) int64 {
|
||||
accountID, _, _, _ := s.resolveAccountByPreviousResponseIDForCapability(ctx, groupID, previousResponseID, requestedModel, excludedIDs, requiredCapability, requireCompact)
|
||||
return accountID
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) resolveAccountByPreviousResponseIDForCapability(
|
||||
ctx context.Context,
|
||||
groupID *int64,
|
||||
previousResponseID string,
|
||||
requestedModel string,
|
||||
excludedIDs map[int64]struct{},
|
||||
requiredCapability OpenAIEndpointCapability,
|
||||
requireCompact bool,
|
||||
) (int64, *Account, string, OpenAIWSStateStore) {
|
||||
if s == nil {
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
responseID := strings.TrimSpace(previousResponseID)
|
||||
if responseID == "" {
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
store := s.getOpenAIWSStateStore()
|
||||
if store == nil {
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
|
||||
accountID, err := store.GetResponseAccount(ctx, derefGroupID(groupID), responseID)
|
||||
if err != nil || accountID <= 0 {
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
if excludedIDs != nil {
|
||||
if _, excluded := excludedIDs[accountID]; excluded {
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
}
|
||||
|
||||
account, err := s.getSchedulableAccount(ctx, accountID)
|
||||
if err != nil || account == nil {
|
||||
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
// 非 WSv2 场景(如 force_http/全局关闭)不应使用 previous_response_id 粘连,
|
||||
// 以保持“回滚到 HTTP”后的历史行为一致性。
|
||||
if s.getOpenAIWSProtocolResolver().Resolve(account).Transport != OpenAIUpstreamTransportResponsesWebsocketV2 {
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
if shouldClearStickySession(account, requestedModel) || !account.IsOpenAI() || !account.IsSchedulable() {
|
||||
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
if !parentHealthyForShadow(account, s.parentAccountLookup(ctx)) {
|
||||
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
if requestedModel != "" && !account.IsModelSupported(requestedModel) {
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
if !account.SupportsOpenAIEndpointCapability(requiredCapability) {
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
// Quota auto-pause must also gate the previous_response_id sticky path; otherwise an
|
||||
// account over its 5h/7d threshold keeps serving the same response chain even though
|
||||
// normal scheduling skips it. Pause is transient, so fall through to normal scheduling
|
||||
// without deleting the binding (the window may reset before the next turn).
|
||||
if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, account); paused {
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
if s.schedulerSnapshot != nil && s.accountRepo != nil {
|
||||
latest, latestErr := s.accountRepo.GetByID(ctx, account.ID)
|
||||
if latestErr != nil || latest == nil {
|
||||
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
if shouldClearStickySession(latest, requestedModel) || !latest.IsOpenAI() || !latest.IsSchedulable() {
|
||||
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
if !parentHealthyForShadow(latest, s.parentAccountLookup(ctx)) {
|
||||
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
if requestedModel != "" && !latest.IsModelSupported(requestedModel) {
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
if !latest.SupportsOpenAIEndpointCapability(requiredCapability) {
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, latest); paused {
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
if s.isOpenAIAccountRuntimeBlocked(latest) {
|
||||
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
account = latest
|
||||
}
|
||||
if requireCompact && openAICompactSupportTier(account) == 0 {
|
||||
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
|
||||
return 0, nil, "", nil
|
||||
}
|
||||
return accountID, account, responseID, store
|
||||
}
|
||||
|
||||
func classifyOpenAIWSAcquireError(err error) string {
|
||||
if err == nil {
|
||||
return "acquire_conn"
|
||||
}
|
||||
var dialErr *openAIWSDialError
|
||||
if errors.As(err, &dialErr) {
|
||||
switch dialErr.StatusCode {
|
||||
case 426:
|
||||
return "upgrade_required"
|
||||
case 401, 403:
|
||||
return "auth_failed"
|
||||
case 429:
|
||||
return "upstream_rate_limited"
|
||||
}
|
||||
if dialErr.StatusCode >= 500 {
|
||||
return "upstream_5xx"
|
||||
}
|
||||
return "dial_failed"
|
||||
}
|
||||
if errors.Is(err, errOpenAIWSConnQueueFull) {
|
||||
return "conn_queue_full"
|
||||
}
|
||||
if errors.Is(err, errOpenAIWSPreferredConnUnavailable) {
|
||||
return "preferred_conn_unavailable"
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "acquire_timeout"
|
||||
}
|
||||
return "acquire_conn"
|
||||
}
|
||||
|
||||
func isOpenAIWSRateLimitError(codeRaw, errTypeRaw, msgRaw string) bool {
|
||||
code := strings.ToLower(strings.TrimSpace(codeRaw))
|
||||
errType := strings.ToLower(strings.TrimSpace(errTypeRaw))
|
||||
msg := strings.ToLower(strings.TrimSpace(msgRaw))
|
||||
|
||||
if strings.Contains(errType, "rate_limit") || strings.Contains(errType, "usage_limit") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(code, "rate_limit") || strings.Contains(code, "usage_limit") || strings.Contains(code, "insufficient_quota") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(msg, "usage limit") && strings.Contains(msg, "reached") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(msg, "rate limit") && (strings.Contains(msg, "reached") || strings.Contains(msg, "exceeded")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) persistOpenAIWSRateLimitSignal(ctx context.Context, account *Account, headers http.Header, responseBody []byte, codeRaw, errTypeRaw, msgRaw string) {
|
||||
if s == nil || s.rateLimitService == nil || account == nil || account.Platform != PlatformOpenAI {
|
||||
return
|
||||
}
|
||||
if !isOpenAIWSRateLimitError(codeRaw, errTypeRaw, msgRaw) {
|
||||
return
|
||||
}
|
||||
s.handleOpenAIAccountUpstreamError(ctx, account, http.StatusTooManyRequests, headers, responseBody)
|
||||
}
|
||||
|
||||
func classifyOpenAIWSErrorEventFromRaw(codeRaw, errTypeRaw, msgRaw string) (string, bool) {
|
||||
code := strings.ToLower(strings.TrimSpace(codeRaw))
|
||||
errType := strings.ToLower(strings.TrimSpace(errTypeRaw))
|
||||
msg := strings.ToLower(strings.TrimSpace(msgRaw))
|
||||
|
||||
switch code {
|
||||
case "upgrade_required":
|
||||
return "upgrade_required", true
|
||||
case "websocket_not_supported", "websocket_unsupported":
|
||||
return "ws_unsupported", true
|
||||
case "websocket_connection_limit_reached":
|
||||
return "ws_connection_limit_reached", true
|
||||
case "invalid_encrypted_content":
|
||||
return "invalid_encrypted_content", true
|
||||
case "previous_response_not_found":
|
||||
return "previous_response_not_found", true
|
||||
}
|
||||
if isOpenAIWSRateLimitError(codeRaw, errTypeRaw, msgRaw) {
|
||||
return "upstream_rate_limited", false
|
||||
}
|
||||
if strings.Contains(msg, "upgrade required") || strings.Contains(msg, "status 426") {
|
||||
return "upgrade_required", true
|
||||
}
|
||||
if strings.Contains(errType, "upgrade") {
|
||||
return "upgrade_required", true
|
||||
}
|
||||
if strings.Contains(msg, "websocket") && strings.Contains(msg, "unsupported") {
|
||||
return "ws_unsupported", true
|
||||
}
|
||||
if strings.Contains(msg, "connection limit") && strings.Contains(msg, "websocket") {
|
||||
return "ws_connection_limit_reached", true
|
||||
}
|
||||
if strings.Contains(msg, "invalid_encrypted_content") ||
|
||||
(strings.Contains(msg, "encrypted content") && strings.Contains(msg, "could not be verified")) {
|
||||
return "invalid_encrypted_content", true
|
||||
}
|
||||
if strings.Contains(msg, "previous_response_not_found") ||
|
||||
(strings.Contains(msg, "previous response") && strings.Contains(msg, "not found")) {
|
||||
return "previous_response_not_found", true
|
||||
}
|
||||
if strings.Contains(errType, "server_error") || strings.Contains(code, "server_error") {
|
||||
return "upstream_error_event", true
|
||||
}
|
||||
return "event_error", false
|
||||
}
|
||||
|
||||
func classifyOpenAIWSErrorEvent(message []byte) (string, bool) {
|
||||
if len(message) == 0 {
|
||||
return "event_error", false
|
||||
}
|
||||
return classifyOpenAIWSErrorEventFromRaw(parseOpenAIWSErrorEventFields(message))
|
||||
}
|
||||
|
||||
func openAIWSErrorHTTPStatusFromRaw(codeRaw, errTypeRaw string) int {
|
||||
code := strings.ToLower(strings.TrimSpace(codeRaw))
|
||||
errType := strings.ToLower(strings.TrimSpace(errTypeRaw))
|
||||
switch {
|
||||
case strings.Contains(errType, "invalid_request"),
|
||||
strings.Contains(code, "invalid_request"),
|
||||
strings.Contains(code, "bad_request"),
|
||||
code == "invalid_encrypted_content",
|
||||
code == "previous_response_not_found":
|
||||
return http.StatusBadRequest
|
||||
case strings.Contains(errType, "authentication"),
|
||||
strings.Contains(code, "invalid_api_key"),
|
||||
strings.Contains(code, "unauthorized"):
|
||||
return http.StatusUnauthorized
|
||||
case strings.Contains(errType, "permission"),
|
||||
strings.Contains(code, "forbidden"):
|
||||
return http.StatusForbidden
|
||||
case isOpenAIWSRateLimitError(codeRaw, errTypeRaw, ""):
|
||||
return http.StatusTooManyRequests
|
||||
default:
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
}
|
||||
|
||||
func openAIWSErrorHTTPStatus(message []byte) int {
|
||||
if len(message) == 0 {
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
codeRaw, errTypeRaw, _ := parseOpenAIWSErrorEventFields(message)
|
||||
return openAIWSErrorHTTPStatusFromRaw(codeRaw, errTypeRaw)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) openAIWSFallbackCooldown() time.Duration {
|
||||
if s == nil || s.cfg == nil {
|
||||
return 30 * time.Second
|
||||
}
|
||||
seconds := s.cfg.Gateway.OpenAIWS.FallbackCooldownSeconds
|
||||
if seconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) isOpenAIWSFallbackCooling(accountID int64) bool {
|
||||
if s == nil || accountID <= 0 {
|
||||
return false
|
||||
}
|
||||
cooldown := s.openAIWSFallbackCooldown()
|
||||
if cooldown <= 0 {
|
||||
return false
|
||||
}
|
||||
rawUntil, ok := s.openaiWSFallbackUntil.Load(accountID)
|
||||
if !ok || rawUntil == nil {
|
||||
return false
|
||||
}
|
||||
until, ok := rawUntil.(time.Time)
|
||||
if !ok || until.IsZero() {
|
||||
s.openaiWSFallbackUntil.Delete(accountID)
|
||||
return false
|
||||
}
|
||||
if time.Now().Before(until) {
|
||||
return true
|
||||
}
|
||||
s.openaiWSFallbackUntil.Delete(accountID)
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) markOpenAIWSFallbackCooling(accountID int64, _ string) {
|
||||
if s == nil || accountID <= 0 {
|
||||
return
|
||||
}
|
||||
cooldown := s.openAIWSFallbackCooldown()
|
||||
if cooldown <= 0 {
|
||||
return
|
||||
}
|
||||
s.openaiWSFallbackUntil.Store(accountID, time.Now().Add(cooldown))
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) clearOpenAIWSFallbackCooling(accountID int64) {
|
||||
if s == nil || accountID <= 0 {
|
||||
return
|
||||
}
|
||||
s.openaiWSFallbackUntil.Delete(accountID)
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func (s *OpenAIGatewayService) forwardOpenAIWSV2(
|
||||
ctx context.Context,
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
reqBody map[string]any,
|
||||
token string,
|
||||
decision OpenAIWSProtocolDecision,
|
||||
isCodexCLI bool,
|
||||
reqStream bool,
|
||||
originalModel string,
|
||||
mappedModel string,
|
||||
startTime time.Time,
|
||||
attempt int,
|
||||
lastFailureReason string,
|
||||
) (*OpenAIForwardResult, error) {
|
||||
if s == nil || account == nil {
|
||||
return nil, wrapOpenAIWSFallback("invalid_state", errors.New("service or account is nil"))
|
||||
}
|
||||
|
||||
wsURL, err := s.buildOpenAIResponsesWSURL(account)
|
||||
if err != nil {
|
||||
return nil, wrapOpenAIWSFallback("build_ws_url", err)
|
||||
}
|
||||
wsHost := "-"
|
||||
wsPath := "-"
|
||||
if parsed, parseErr := url.Parse(wsURL); parseErr == nil && parsed != nil {
|
||||
if h := strings.TrimSpace(parsed.Host); h != "" {
|
||||
wsHost = normalizeOpenAIWSLogValue(h)
|
||||
}
|
||||
if p := strings.TrimSpace(parsed.Path); p != "" {
|
||||
wsPath = normalizeOpenAIWSLogValue(p)
|
||||
}
|
||||
}
|
||||
logOpenAIWSModeDebug(
|
||||
"dial_target account_id=%d account_type=%s ws_host=%s ws_path=%s",
|
||||
account.ID,
|
||||
account.Type,
|
||||
wsHost,
|
||||
wsPath,
|
||||
)
|
||||
|
||||
payload := s.buildOpenAIWSCreatePayload(reqBody, account)
|
||||
payloadStrategy, removedKeys := applyOpenAIWSRetryPayloadStrategy(payload, attempt)
|
||||
previousResponseID := openAIWSPayloadString(payload, "previous_response_id")
|
||||
previousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(previousResponseID)
|
||||
promptCacheKey := openAIWSPayloadString(payload, "prompt_cache_key")
|
||||
_, hasTools := payload["tools"]
|
||||
debugEnabled := isOpenAIWSModeDebugEnabled()
|
||||
payloadBytes := -1
|
||||
resolvePayloadBytes := func() int {
|
||||
if payloadBytes >= 0 {
|
||||
return payloadBytes
|
||||
}
|
||||
payloadBytes = len(payloadAsJSONBytes(payload))
|
||||
return payloadBytes
|
||||
}
|
||||
streamValue := "-"
|
||||
if raw, ok := payload["stream"]; ok {
|
||||
streamValue = normalizeOpenAIWSLogValue(strings.TrimSpace(fmt.Sprintf("%v", raw)))
|
||||
}
|
||||
turnState := ""
|
||||
turnMetadata := ""
|
||||
if c != nil && c.Request != nil {
|
||||
turnState = strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader))
|
||||
turnMetadata = strings.TrimSpace(c.GetHeader(openAIWSTurnMetadataHeader))
|
||||
}
|
||||
setOpenAIWSTurnMetadata(payload, turnMetadata)
|
||||
payloadEventType := openAIWSPayloadString(payload, "type")
|
||||
if payloadEventType == "" {
|
||||
payloadEventType = "response.create"
|
||||
}
|
||||
if s.shouldEmitOpenAIWSPayloadSchema(attempt) {
|
||||
logOpenAIWSModeInfo(
|
||||
"[debug] payload_schema account_id=%d attempt=%d event=%s payload_keys=%s payload_bytes=%d payload_key_sizes=%s input_summary=%s stream=%s payload_strategy=%s removed_keys=%s has_previous_response_id=%v has_prompt_cache_key=%v has_tools=%v",
|
||||
account.ID,
|
||||
attempt,
|
||||
payloadEventType,
|
||||
normalizeOpenAIWSLogValue(strings.Join(sortedKeys(payload), ",")),
|
||||
resolvePayloadBytes(),
|
||||
normalizeOpenAIWSLogValue(summarizeOpenAIWSPayloadKeySizes(payload, openAIWSPayloadKeySizeTopN)),
|
||||
normalizeOpenAIWSLogValue(summarizeOpenAIWSInput(payload["input"])),
|
||||
streamValue,
|
||||
normalizeOpenAIWSLogValue(payloadStrategy),
|
||||
normalizeOpenAIWSLogValue(strings.Join(removedKeys, ",")),
|
||||
previousResponseID != "",
|
||||
promptCacheKey != "",
|
||||
hasTools,
|
||||
)
|
||||
}
|
||||
|
||||
stateStore := s.getOpenAIWSStateStore()
|
||||
groupID := getOpenAIGroupIDFromContext(c)
|
||||
sessionHash := s.GenerateSessionHash(c, nil)
|
||||
if sessionHash == "" {
|
||||
var legacySessionHash string
|
||||
sessionHash, legacySessionHash = openAIWSSessionHashesFromID(promptCacheKey)
|
||||
attachOpenAILegacySessionHashToGin(c, legacySessionHash)
|
||||
}
|
||||
if turnState == "" && stateStore != nil && sessionHash != "" {
|
||||
if savedTurnState, ok := stateStore.GetSessionTurnState(groupID, sessionHash); ok {
|
||||
turnState = savedTurnState
|
||||
}
|
||||
}
|
||||
preferredConnID := ""
|
||||
if stateStore != nil && previousResponseID != "" {
|
||||
if connID, ok := stateStore.GetResponseConn(previousResponseID); ok {
|
||||
preferredConnID = connID
|
||||
}
|
||||
}
|
||||
storeDisabled := s.isOpenAIWSStoreDisabledInRequest(reqBody, account)
|
||||
if stateStore != nil && storeDisabled && previousResponseID == "" && sessionHash != "" {
|
||||
if connID, ok := stateStore.GetSessionConn(groupID, sessionHash); ok {
|
||||
preferredConnID = connID
|
||||
}
|
||||
}
|
||||
storeDisabledConnMode := s.openAIWSStoreDisabledConnMode()
|
||||
forceNewConnByPolicy := shouldForceNewConnOnStoreDisabled(storeDisabledConnMode, lastFailureReason)
|
||||
forceNewConn := forceNewConnByPolicy && storeDisabled && previousResponseID == "" && sessionHash != "" && preferredConnID == ""
|
||||
wsHeaders, sessionResolution, buildHdrErr := s.buildOpenAIWSHeaders(ctx, c, account, token, decision, isCodexCLI, turnState, turnMetadata, promptCacheKey)
|
||||
if buildHdrErr != nil {
|
||||
return nil, fmt.Errorf("build ws headers: %w", buildHdrErr)
|
||||
}
|
||||
logOpenAIWSModeDebug(
|
||||
"acquire_start account_id=%d account_type=%s transport=%s preferred_conn_id=%s has_previous_response_id=%v session_hash=%s has_turn_state=%v turn_state_len=%d has_turn_metadata=%v turn_metadata_len=%d store_disabled=%v store_disabled_conn_mode=%s retry_last_reason=%s force_new_conn=%v header_user_agent=%s header_openai_beta=%s header_originator=%s header_accept_language=%s header_session_id=%s header_conversation_id=%s session_id_source=%s conversation_id_source=%s has_prompt_cache_key=%v has_chatgpt_account_id=%v has_authorization=%v has_session_id=%v has_conversation_id=%v proxy_enabled=%v",
|
||||
account.ID,
|
||||
account.Type,
|
||||
normalizeOpenAIWSLogValue(string(decision.Transport)),
|
||||
truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen),
|
||||
previousResponseID != "",
|
||||
truncateOpenAIWSLogValue(sessionHash, 12),
|
||||
turnState != "",
|
||||
len(turnState),
|
||||
turnMetadata != "",
|
||||
len(turnMetadata),
|
||||
storeDisabled,
|
||||
normalizeOpenAIWSLogValue(storeDisabledConnMode),
|
||||
truncateOpenAIWSLogValue(lastFailureReason, openAIWSLogValueMaxLen),
|
||||
forceNewConn,
|
||||
openAIWSHeaderValueForLog(wsHeaders, "user-agent"),
|
||||
openAIWSHeaderValueForLog(wsHeaders, "openai-beta"),
|
||||
openAIWSHeaderValueForLog(wsHeaders, "originator"),
|
||||
openAIWSHeaderValueForLog(wsHeaders, "accept-language"),
|
||||
openAIWSHeaderValueForLog(wsHeaders, "session_id"),
|
||||
openAIWSHeaderValueForLog(wsHeaders, "conversation_id"),
|
||||
normalizeOpenAIWSLogValue(sessionResolution.SessionSource),
|
||||
normalizeOpenAIWSLogValue(sessionResolution.ConversationSource),
|
||||
promptCacheKey != "",
|
||||
hasOpenAIWSHeader(wsHeaders, "chatgpt-account-id"),
|
||||
hasOpenAIWSHeader(wsHeaders, "authorization"),
|
||||
hasOpenAIWSHeader(wsHeaders, "session_id"),
|
||||
hasOpenAIWSHeader(wsHeaders, "conversation_id"),
|
||||
account.ProxyID != nil && account.Proxy != nil,
|
||||
)
|
||||
|
||||
acquireCtx, acquireCancel := context.WithTimeout(ctx, s.openAIWSAcquireTimeout())
|
||||
defer acquireCancel()
|
||||
|
||||
lease, err := s.getOpenAIWSConnPool().Acquire(acquireCtx, openAIWSAcquireRequest{
|
||||
Account: account,
|
||||
WSURL: wsURL,
|
||||
Headers: wsHeaders,
|
||||
PreferredConnID: preferredConnID,
|
||||
ForceNewConn: forceNewConn,
|
||||
ProxyURL: func() string {
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
return account.Proxy.URL()
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
})
|
||||
if err != nil {
|
||||
dialStatus, dialClass, dialCloseStatus, dialCloseReason, dialRespServer, dialRespVia, dialRespCFRay, dialRespReqID := summarizeOpenAIWSDialError(err)
|
||||
logOpenAIWSModeInfo(
|
||||
"acquire_fail account_id=%d account_type=%s transport=%s reason=%s dial_status=%d dial_class=%s dial_close_status=%s dial_close_reason=%s dial_resp_server=%s dial_resp_via=%s dial_resp_cf_ray=%s dial_resp_x_request_id=%s cause=%s preferred_conn_id=%s force_new_conn=%v ws_host=%s ws_path=%s proxy_enabled=%v",
|
||||
account.ID,
|
||||
account.Type,
|
||||
normalizeOpenAIWSLogValue(string(decision.Transport)),
|
||||
normalizeOpenAIWSLogValue(classifyOpenAIWSAcquireError(err)),
|
||||
dialStatus,
|
||||
dialClass,
|
||||
dialCloseStatus,
|
||||
truncateOpenAIWSLogValue(dialCloseReason, openAIWSHeaderValueMaxLen),
|
||||
dialRespServer,
|
||||
dialRespVia,
|
||||
dialRespCFRay,
|
||||
dialRespReqID,
|
||||
truncateOpenAIWSLogValue(err.Error(), openAIWSLogValueMaxLen),
|
||||
truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen),
|
||||
forceNewConn,
|
||||
wsHost,
|
||||
wsPath,
|
||||
account.ProxyID != nil && account.Proxy != nil,
|
||||
)
|
||||
var dialErr *openAIWSDialError
|
||||
if errors.As(err, &dialErr) && dialErr != nil && dialErr.StatusCode == http.StatusTooManyRequests {
|
||||
s.persistOpenAIWSRateLimitSignal(ctx, account, dialErr.ResponseHeaders, nil, "rate_limit_exceeded", "rate_limit_error", strings.TrimSpace(err.Error()))
|
||||
}
|
||||
return nil, wrapOpenAIWSFallback(classifyOpenAIWSAcquireError(err), err)
|
||||
}
|
||||
// cleanExit 标记正常终端事件退出,此时上游不会再发送帧,连接可安全归还复用。
|
||||
// 所有异常路径(读写错误、error 事件等)已在各自分支中提前调用 MarkBroken,
|
||||
// 因此 defer 中只需处理正常退出时不 MarkBroken 即可。
|
||||
cleanExit := false
|
||||
defer func() {
|
||||
if !cleanExit {
|
||||
lease.MarkBroken()
|
||||
}
|
||||
lease.Release()
|
||||
}()
|
||||
connID := strings.TrimSpace(lease.ConnID())
|
||||
logOpenAIWSModeDebug(
|
||||
"connected account_id=%d account_type=%s transport=%s conn_id=%s conn_reused=%v conn_pick_ms=%d queue_wait_ms=%d has_previous_response_id=%v",
|
||||
account.ID,
|
||||
account.Type,
|
||||
normalizeOpenAIWSLogValue(string(decision.Transport)),
|
||||
connID,
|
||||
lease.Reused(),
|
||||
lease.ConnPickDuration().Milliseconds(),
|
||||
lease.QueueWaitDuration().Milliseconds(),
|
||||
previousResponseID != "",
|
||||
)
|
||||
if previousResponseID != "" {
|
||||
logOpenAIWSModeInfo(
|
||||
"continuation_probe account_id=%d account_type=%s conn_id=%s previous_response_id=%s previous_response_id_kind=%s preferred_conn_id=%s conn_reused=%v store_disabled=%v session_hash=%s header_session_id=%s header_conversation_id=%s session_id_source=%s conversation_id_source=%s has_turn_state=%v turn_state_len=%d has_prompt_cache_key=%v",
|
||||
account.ID,
|
||||
account.Type,
|
||||
truncateOpenAIWSLogValue(connID, openAIWSIDValueMaxLen),
|
||||
truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen),
|
||||
normalizeOpenAIWSLogValue(previousResponseIDKind),
|
||||
truncateOpenAIWSLogValue(preferredConnID, openAIWSIDValueMaxLen),
|
||||
lease.Reused(),
|
||||
storeDisabled,
|
||||
truncateOpenAIWSLogValue(sessionHash, 12),
|
||||
openAIWSHeaderValueForLog(wsHeaders, "session_id"),
|
||||
openAIWSHeaderValueForLog(wsHeaders, "conversation_id"),
|
||||
normalizeOpenAIWSLogValue(sessionResolution.SessionSource),
|
||||
normalizeOpenAIWSLogValue(sessionResolution.ConversationSource),
|
||||
turnState != "",
|
||||
len(turnState),
|
||||
promptCacheKey != "",
|
||||
)
|
||||
}
|
||||
if c != nil {
|
||||
SetOpsLatencyMs(c, OpsOpenAIWSConnPickMsKey, lease.ConnPickDuration().Milliseconds())
|
||||
SetOpsLatencyMs(c, OpsOpenAIWSQueueWaitMsKey, lease.QueueWaitDuration().Milliseconds())
|
||||
c.Set(OpsOpenAIWSConnReusedKey, lease.Reused())
|
||||
if connID != "" {
|
||||
c.Set(OpsOpenAIWSConnIDKey, connID)
|
||||
}
|
||||
}
|
||||
|
||||
handshakeTurnState := strings.TrimSpace(lease.HandshakeHeader(openAIWSTurnStateHeader))
|
||||
logOpenAIWSModeDebug(
|
||||
"handshake account_id=%d conn_id=%s has_turn_state=%v turn_state_len=%d",
|
||||
account.ID,
|
||||
connID,
|
||||
handshakeTurnState != "",
|
||||
len(handshakeTurnState),
|
||||
)
|
||||
if handshakeTurnState != "" {
|
||||
if stateStore != nil && sessionHash != "" {
|
||||
stateStore.BindSessionTurnState(groupID, sessionHash, handshakeTurnState, s.openAIWSSessionStickyTTL())
|
||||
}
|
||||
if c != nil {
|
||||
c.Header(http.CanonicalHeaderKey(openAIWSTurnStateHeader), handshakeTurnState)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.performOpenAIWSGeneratePrewarm(
|
||||
ctx,
|
||||
lease,
|
||||
decision,
|
||||
payload,
|
||||
previousResponseID,
|
||||
reqBody,
|
||||
account,
|
||||
stateStore,
|
||||
groupID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := lease.WriteJSONWithContextTimeout(ctx, payload, s.openAIWSWriteTimeout()); err != nil {
|
||||
lease.MarkBroken()
|
||||
logOpenAIWSModeInfo(
|
||||
"write_request_fail account_id=%d conn_id=%s cause=%s payload_bytes=%d",
|
||||
account.ID,
|
||||
connID,
|
||||
truncateOpenAIWSLogValue(err.Error(), openAIWSLogValueMaxLen),
|
||||
resolvePayloadBytes(),
|
||||
)
|
||||
return nil, wrapOpenAIWSFallback("write_request", err)
|
||||
}
|
||||
if debugEnabled {
|
||||
logOpenAIWSModeDebug(
|
||||
"write_request_sent account_id=%d conn_id=%s stream=%v payload_bytes=%d previous_response_id=%s",
|
||||
account.ID,
|
||||
connID,
|
||||
reqStream,
|
||||
resolvePayloadBytes(),
|
||||
truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen),
|
||||
)
|
||||
}
|
||||
|
||||
usage := &OpenAIUsage{}
|
||||
imageCounter := newOpenAIImageOutputCounter()
|
||||
var firstTokenMs *int
|
||||
responseID := ""
|
||||
var finalResponse []byte
|
||||
wroteDownstream := false
|
||||
needModelReplace := originalModel != mappedModel
|
||||
var mappedModelBytes []byte
|
||||
if needModelReplace && mappedModel != "" {
|
||||
mappedModelBytes = []byte(mappedModel)
|
||||
}
|
||||
bufferedStreamEvents := make([][]byte, 0, 4)
|
||||
eventCount := 0
|
||||
tokenEventCount := 0
|
||||
terminalEventCount := 0
|
||||
bufferedEventCount := 0
|
||||
flushedBufferedEventCount := 0
|
||||
firstEventType := ""
|
||||
lastEventType := ""
|
||||
|
||||
var flusher http.Flusher
|
||||
if reqStream {
|
||||
if s.responseHeaderFilter != nil {
|
||||
responseheaders.WriteFilteredHeaders(c.Writer.Header(), http.Header{}, s.responseHeaderFilter)
|
||||
}
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
f, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
lease.MarkBroken()
|
||||
return nil, wrapOpenAIWSFallback("streaming_not_supported", errors.New("streaming not supported"))
|
||||
}
|
||||
flusher = f
|
||||
}
|
||||
|
||||
clientDisconnected := false
|
||||
flushBatchSize := s.openAIWSEventFlushBatchSize()
|
||||
flushInterval := s.openAIWSEventFlushInterval()
|
||||
pendingFlushEvents := 0
|
||||
lastFlushAt := time.Now()
|
||||
flushStreamWriter := func(force bool) {
|
||||
if clientDisconnected || flusher == nil || pendingFlushEvents <= 0 {
|
||||
return
|
||||
}
|
||||
if !force && flushBatchSize > 1 && pendingFlushEvents < flushBatchSize {
|
||||
if flushInterval <= 0 || time.Since(lastFlushAt) < flushInterval {
|
||||
return
|
||||
}
|
||||
}
|
||||
flusher.Flush()
|
||||
pendingFlushEvents = 0
|
||||
lastFlushAt = time.Now()
|
||||
}
|
||||
emitStreamMessage := func(message []byte, forceFlush bool) {
|
||||
if clientDisconnected {
|
||||
return
|
||||
}
|
||||
frame := make([]byte, 0, len(message)+8)
|
||||
frame = append(frame, "data: "...)
|
||||
frame = append(frame, message...)
|
||||
frame = append(frame, '\n', '\n')
|
||||
_, wErr := c.Writer.Write(frame)
|
||||
if wErr == nil {
|
||||
wroteDownstream = true
|
||||
pendingFlushEvents++
|
||||
flushStreamWriter(forceFlush)
|
||||
return
|
||||
}
|
||||
clientDisconnected = true
|
||||
logger.LegacyPrintf("service.openai_gateway", "[OpenAI WS Mode] client disconnected, continue draining upstream: account=%d", account.ID)
|
||||
}
|
||||
flushBufferedStreamEvents := func(reason string) {
|
||||
if len(bufferedStreamEvents) == 0 {
|
||||
return
|
||||
}
|
||||
flushed := len(bufferedStreamEvents)
|
||||
for _, buffered := range bufferedStreamEvents {
|
||||
emitStreamMessage(buffered, false)
|
||||
}
|
||||
bufferedStreamEvents = bufferedStreamEvents[:0]
|
||||
flushStreamWriter(true)
|
||||
flushedBufferedEventCount += flushed
|
||||
if debugEnabled {
|
||||
logOpenAIWSModeDebug(
|
||||
"buffer_flush account_id=%d conn_id=%s reason=%s flushed=%d total_flushed=%d client_disconnected=%v",
|
||||
account.ID,
|
||||
connID,
|
||||
truncateOpenAIWSLogValue(reason, openAIWSLogValueMaxLen),
|
||||
flushed,
|
||||
flushedBufferedEventCount,
|
||||
clientDisconnected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
readTimeout := s.openAIWSReadTimeout()
|
||||
|
||||
for {
|
||||
message, readErr := lease.ReadMessageWithContextTimeout(ctx, readTimeout)
|
||||
if readErr != nil {
|
||||
lease.MarkBroken()
|
||||
closeStatus, closeReason := summarizeOpenAIWSReadCloseError(readErr)
|
||||
logOpenAIWSModeInfo(
|
||||
"read_fail account_id=%d conn_id=%s wrote_downstream=%v close_status=%s close_reason=%s cause=%s events=%d token_events=%d terminal_events=%d buffered_pending=%d buffered_flushed=%d first_event=%s last_event=%s",
|
||||
account.ID,
|
||||
connID,
|
||||
wroteDownstream,
|
||||
closeStatus,
|
||||
closeReason,
|
||||
truncateOpenAIWSLogValue(readErr.Error(), openAIWSLogValueMaxLen),
|
||||
eventCount,
|
||||
tokenEventCount,
|
||||
terminalEventCount,
|
||||
len(bufferedStreamEvents),
|
||||
flushedBufferedEventCount,
|
||||
truncateOpenAIWSLogValue(firstEventType, openAIWSLogValueMaxLen),
|
||||
truncateOpenAIWSLogValue(lastEventType, openAIWSLogValueMaxLen),
|
||||
)
|
||||
if !wroteDownstream {
|
||||
return nil, wrapOpenAIWSFallback(classifyOpenAIWSReadFallbackReason(readErr), readErr)
|
||||
}
|
||||
if clientDisconnected {
|
||||
break
|
||||
}
|
||||
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(readErr.Error()), "")
|
||||
return nil, fmt.Errorf("openai ws read event: %w", readErr)
|
||||
}
|
||||
|
||||
eventType, eventResponseID, responseField := parseOpenAIWSEventEnvelope(message)
|
||||
if eventType == "" {
|
||||
continue
|
||||
}
|
||||
eventCount++
|
||||
if firstEventType == "" {
|
||||
firstEventType = eventType
|
||||
}
|
||||
lastEventType = eventType
|
||||
|
||||
if responseID == "" && eventResponseID != "" {
|
||||
responseID = eventResponseID
|
||||
}
|
||||
|
||||
isTokenEvent := isOpenAIWSTokenEvent(eventType)
|
||||
if isTokenEvent {
|
||||
tokenEventCount++
|
||||
}
|
||||
isTerminalEvent := isOpenAIWSTerminalEvent(eventType)
|
||||
if isTerminalEvent {
|
||||
terminalEventCount++
|
||||
}
|
||||
if firstTokenMs == nil && isTokenEvent {
|
||||
ms := int(time.Since(startTime).Milliseconds())
|
||||
firstTokenMs = &ms
|
||||
}
|
||||
if debugEnabled && shouldLogOpenAIWSEvent(eventCount, eventType) {
|
||||
logOpenAIWSModeDebug(
|
||||
"event_received account_id=%d conn_id=%s idx=%d type=%s bytes=%d token=%v terminal=%v buffered_pending=%d",
|
||||
account.ID,
|
||||
connID,
|
||||
eventCount,
|
||||
truncateOpenAIWSLogValue(eventType, openAIWSLogValueMaxLen),
|
||||
len(message),
|
||||
isTokenEvent,
|
||||
isTerminalEvent,
|
||||
len(bufferedStreamEvents),
|
||||
)
|
||||
}
|
||||
|
||||
if !clientDisconnected {
|
||||
if needModelReplace && len(mappedModelBytes) > 0 && openAIWSEventMayContainModel(eventType) && bytes.Contains(message, mappedModelBytes) {
|
||||
message = replaceOpenAIWSMessageModel(message, mappedModel, originalModel)
|
||||
}
|
||||
if openAIWSEventMayContainToolCalls(eventType) && openAIWSMessageLikelyContainsToolCalls(message) {
|
||||
if corrected, changed := s.toolCorrector.CorrectToolCallsInSSEBytes(message); changed {
|
||||
message = corrected
|
||||
}
|
||||
}
|
||||
}
|
||||
if openAIWSEventShouldParseUsage(eventType) {
|
||||
parseOpenAIWSResponseUsageFromCompletedEvent(message, usage)
|
||||
}
|
||||
imageCounter.AddSSEData(message)
|
||||
|
||||
if eventType == "response.failed" {
|
||||
if hit, code, msg := detectOpenAICyberPolicy(message); hit {
|
||||
MarkOpsCyberPolicy(c, CyberPolicyMark{
|
||||
Code: code,
|
||||
Message: msg,
|
||||
Body: truncateString(string(message), 4096),
|
||||
UpstreamStatus: http.StatusOK,
|
||||
UpstreamInTok: usage.InputTokens,
|
||||
UpstreamOutTok: usage.OutputTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if eventType == "error" {
|
||||
errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(message)
|
||||
s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), message, errCodeRaw, errTypeRaw, errMsgRaw)
|
||||
errMsg := strings.TrimSpace(errMsgRaw)
|
||||
if errMsg == "" {
|
||||
errMsg = "Upstream websocket error"
|
||||
}
|
||||
fallbackReason, canFallback := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMsgRaw)
|
||||
errCode, errType, errMessage := summarizeOpenAIWSErrorEventFieldsFromRaw(errCodeRaw, errTypeRaw, errMsgRaw)
|
||||
logOpenAIWSModeInfo(
|
||||
"error_event account_id=%d conn_id=%s idx=%d fallback_reason=%s can_fallback=%v err_code=%s err_type=%s err_message=%s",
|
||||
account.ID,
|
||||
connID,
|
||||
eventCount,
|
||||
truncateOpenAIWSLogValue(fallbackReason, openAIWSLogValueMaxLen),
|
||||
canFallback,
|
||||
errCode,
|
||||
errType,
|
||||
errMessage,
|
||||
)
|
||||
if fallbackReason == "previous_response_not_found" {
|
||||
logOpenAIWSModeInfo(
|
||||
"previous_response_not_found_diag account_id=%d account_type=%s conn_id=%s previous_response_id=%s previous_response_id_kind=%s response_id=%s event_idx=%d req_stream=%v store_disabled=%v conn_reused=%v session_hash=%s header_session_id=%s header_conversation_id=%s session_id_source=%s conversation_id_source=%s has_turn_state=%v turn_state_len=%d has_prompt_cache_key=%v err_code=%s err_type=%s err_message=%s",
|
||||
account.ID,
|
||||
account.Type,
|
||||
connID,
|
||||
truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen),
|
||||
normalizeOpenAIWSLogValue(previousResponseIDKind),
|
||||
truncateOpenAIWSLogValue(responseID, openAIWSIDValueMaxLen),
|
||||
eventCount,
|
||||
reqStream,
|
||||
storeDisabled,
|
||||
lease.Reused(),
|
||||
truncateOpenAIWSLogValue(sessionHash, 12),
|
||||
openAIWSHeaderValueForLog(wsHeaders, "session_id"),
|
||||
openAIWSHeaderValueForLog(wsHeaders, "conversation_id"),
|
||||
normalizeOpenAIWSLogValue(sessionResolution.SessionSource),
|
||||
normalizeOpenAIWSLogValue(sessionResolution.ConversationSource),
|
||||
turnState != "",
|
||||
len(turnState),
|
||||
promptCacheKey != "",
|
||||
errCode,
|
||||
errType,
|
||||
errMessage,
|
||||
)
|
||||
}
|
||||
// error 事件后连接不再可复用,避免回池后污染下一请求。
|
||||
lease.MarkBroken()
|
||||
if !wroteDownstream && canFallback {
|
||||
return nil, wrapOpenAIWSFallback(fallbackReason, errors.New(errMsg))
|
||||
}
|
||||
statusCode := openAIWSErrorHTTPStatusFromRaw(errCodeRaw, errTypeRaw)
|
||||
setOpsUpstreamError(c, statusCode, errMsg, "")
|
||||
if reqStream && !clientDisconnected {
|
||||
flushBufferedStreamEvents("error_event")
|
||||
emitStreamMessage(message, true)
|
||||
}
|
||||
if !reqStream {
|
||||
c.JSON(statusCode, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "upstream_error",
|
||||
"message": errMsg,
|
||||
},
|
||||
})
|
||||
}
|
||||
return nil, fmt.Errorf("openai ws error event: %s", errMsg)
|
||||
}
|
||||
|
||||
if reqStream {
|
||||
// 在首个 token 前先缓冲事件(如 response.created),
|
||||
// 以便上游早期断连时仍可安全回退到 HTTP,不给下游发送半截流。
|
||||
shouldBuffer := firstTokenMs == nil && !isTokenEvent && !isTerminalEvent
|
||||
if shouldBuffer {
|
||||
buffered := make([]byte, len(message))
|
||||
copy(buffered, message)
|
||||
bufferedStreamEvents = append(bufferedStreamEvents, buffered)
|
||||
bufferedEventCount++
|
||||
if debugEnabled && shouldLogOpenAIWSBufferedEvent(bufferedEventCount) {
|
||||
logOpenAIWSModeDebug(
|
||||
"buffer_enqueue account_id=%d conn_id=%s idx=%d event_idx=%d event_type=%s buffer_size=%d",
|
||||
account.ID,
|
||||
connID,
|
||||
bufferedEventCount,
|
||||
eventCount,
|
||||
truncateOpenAIWSLogValue(eventType, openAIWSLogValueMaxLen),
|
||||
len(bufferedStreamEvents),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
flushBufferedStreamEvents(eventType)
|
||||
emitStreamMessage(message, isTerminalEvent)
|
||||
}
|
||||
} else {
|
||||
if responseField.Exists() && responseField.Type == gjson.JSON {
|
||||
finalResponse = []byte(responseField.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
if isTerminalEvent {
|
||||
cleanExit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !reqStream {
|
||||
if len(finalResponse) == 0 {
|
||||
logOpenAIWSModeInfo(
|
||||
"missing_final_response account_id=%d conn_id=%s events=%d token_events=%d terminal_events=%d wrote_downstream=%v",
|
||||
account.ID,
|
||||
connID,
|
||||
eventCount,
|
||||
tokenEventCount,
|
||||
terminalEventCount,
|
||||
wroteDownstream,
|
||||
)
|
||||
if !wroteDownstream {
|
||||
return nil, wrapOpenAIWSFallback("missing_final_response", errors.New("no terminal response payload"))
|
||||
}
|
||||
return nil, errors.New("ws finished without final response")
|
||||
}
|
||||
|
||||
if needModelReplace {
|
||||
finalResponse = s.replaceModelInResponseBody(finalResponse, mappedModel, originalModel)
|
||||
}
|
||||
finalResponse = s.correctToolCallsInResponseBody(finalResponse)
|
||||
populateOpenAIUsageFromResponseJSON(finalResponse, usage)
|
||||
if responseID == "" {
|
||||
responseID = strings.TrimSpace(gjson.GetBytes(finalResponse, "id").String())
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "application/json", finalResponse)
|
||||
} else {
|
||||
flushStreamWriter(true)
|
||||
}
|
||||
|
||||
if responseID != "" && stateStore != nil {
|
||||
ttl := s.openAIWSResponseStickyTTL()
|
||||
logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, stateStore.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl))
|
||||
stateStore.BindResponseConn(responseID, lease.ConnID(), ttl)
|
||||
}
|
||||
if stateStore != nil && storeDisabled && sessionHash != "" {
|
||||
stateStore.BindSessionConn(groupID, sessionHash, lease.ConnID(), s.openAIWSSessionStickyTTL())
|
||||
}
|
||||
firstTokenMsValue := -1
|
||||
if firstTokenMs != nil {
|
||||
firstTokenMsValue = *firstTokenMs
|
||||
}
|
||||
logOpenAIWSModeDebug(
|
||||
"completed account_id=%d conn_id=%s response_id=%s stream=%v duration_ms=%d events=%d token_events=%d terminal_events=%d buffered_events=%d buffered_flushed=%d first_event=%s last_event=%s first_token_ms=%d wrote_downstream=%v client_disconnected=%v",
|
||||
account.ID,
|
||||
connID,
|
||||
truncateOpenAIWSLogValue(strings.TrimSpace(responseID), openAIWSIDValueMaxLen),
|
||||
reqStream,
|
||||
time.Since(startTime).Milliseconds(),
|
||||
eventCount,
|
||||
tokenEventCount,
|
||||
terminalEventCount,
|
||||
bufferedEventCount,
|
||||
flushedBufferedEventCount,
|
||||
truncateOpenAIWSLogValue(firstEventType, openAIWSLogValueMaxLen),
|
||||
truncateOpenAIWSLogValue(lastEventType, openAIWSLogValueMaxLen),
|
||||
firstTokenMsValue,
|
||||
wroteDownstream,
|
||||
clientDisconnected,
|
||||
)
|
||||
|
||||
return &OpenAIForwardResult{
|
||||
RequestID: responseID,
|
||||
Usage: *usage,
|
||||
Model: originalModel,
|
||||
UpstreamModel: mappedModel,
|
||||
ImageCount: imageCounter.Count(),
|
||||
ImageOutputSizes: imageCounter.Sizes(),
|
||||
ServiceTier: extractOpenAIServiceTier(reqBody),
|
||||
ReasoningEffort: extractOpenAIReasoningEffort(reqBody, originalModel),
|
||||
Stream: reqStream,
|
||||
OpenAIWSMode: true,
|
||||
ResponseHeaders: lease.HandshakeHeaders(),
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: firstTokenMs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ProxyResponsesWebSocketFromClient 处理客户端入站 WebSocket(OpenAI Responses WS Mode)并转发到上游。
|
||||
// 当前实现按“单请求 -> 终止事件 -> 下一请求”的顺序代理,适配 Codex CLI 的 turn 模式。
|
||||
// stripCodexSparkImageGenerationToolFromRawPayload removes the image_generation
|
||||
// tool from a raw /responses payload when the upstream model is gpt-5.3-codex-spark.
|
||||
// Spark rejects that tool upstream with HTTP 400 (invalid_request_error, param=tools);
|
||||
// Codex clients advertise it by default. Returns the (possibly unchanged) payload,
|
||||
// whether it changed, and any JSON decode error.
|
||||
func stripCodexSparkImageGenerationToolFromRawPayload(payload []byte, model string) ([]byte, bool, error) {
|
||||
if !isCodexSparkModel(model) || !openAIRequestBodyHasImageGenerationTool(payload) {
|
||||
return payload, false, nil
|
||||
}
|
||||
return stripOpenAIImageGenerationToolFromRawPayload(payload)
|
||||
}
|
||||
|
||||
func stripOpenAIImageGenerationToolFromRawPayload(payload []byte) ([]byte, bool, error) {
|
||||
payloadMap := make(map[string]any)
|
||||
if err := json.Unmarshal(payload, &payloadMap); err != nil {
|
||||
return payload, false, err
|
||||
}
|
||||
if !stripOpenAIImageGenerationTools(payloadMap) {
|
||||
return payload, false, nil
|
||||
}
|
||||
rebuilt, err := json.Marshal(payloadMap)
|
||||
if err != nil {
|
||||
return payload, false, err
|
||||
}
|
||||
return rebuilt, true, nil
|
||||
}
|
||||
@@ -0,0 +1,912 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IsRegistrationEnabled 检查是否开放注册
|
||||
func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
|
||||
if err != nil {
|
||||
// 安全默认:如果设置不存在或查询出错,默认关闭注册
|
||||
return false
|
||||
}
|
||||
return value == "true"
|
||||
}
|
||||
|
||||
// IsEmailVerifyEnabled 检查是否开启邮件验证
|
||||
func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return value == "true"
|
||||
}
|
||||
|
||||
// GetRegistrationEmailSuffixWhitelist returns normalized registration email suffix whitelist.
|
||||
func (s *SettingService) GetRegistrationEmailSuffixWhitelist(ctx context.Context) []string {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailSuffixWhitelist)
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return ParseRegistrationEmailSuffixWhitelist(value)
|
||||
}
|
||||
|
||||
// IsPromoCodeEnabled 检查是否启用优惠码功能
|
||||
func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyPromoCodeEnabled)
|
||||
if err != nil {
|
||||
return true // 默认启用
|
||||
}
|
||||
return value != "false"
|
||||
}
|
||||
|
||||
// IsInvitationCodeEnabled 检查是否启用邀请码注册功能
|
||||
func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyInvitationCodeEnabled)
|
||||
if err != nil {
|
||||
return false // 默认关闭
|
||||
}
|
||||
return value == "true"
|
||||
}
|
||||
|
||||
// GetCustomMenuItemsRaw returns the raw JSON string of custom_menu_items setting.
|
||||
func (s *SettingService) GetCustomMenuItemsRaw(ctx context.Context) string {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyCustomMenuItems)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// IsAffiliateEnabled 检查是否启用邀请返利功能(总开关)
|
||||
func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled)
|
||||
if err != nil {
|
||||
return false // 默认关闭
|
||||
}
|
||||
return value == "true"
|
||||
}
|
||||
|
||||
// GetAffiliateRebateRatePercent 读取并 clamp 全局返利比例。
|
||||
// 解析失败、缺失或越界都回退到 AffiliateRebateRateDefault — 该比例从不抛错,
|
||||
// 调用方只关心一个可用的数值。
|
||||
func (s *SettingService) GetAffiliateRebateRatePercent(ctx context.Context) float64 {
|
||||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateRate)
|
||||
if err != nil {
|
||||
return AffiliateRebateRateDefault
|
||||
}
|
||||
rate, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||
if err != nil || math.IsNaN(rate) || math.IsInf(rate, 0) {
|
||||
return AffiliateRebateRateDefault
|
||||
}
|
||||
return clampAffiliateRebateRate(rate)
|
||||
}
|
||||
|
||||
// GetAffiliateRebateFreezeHours 返回返利冻结期(小时)。
|
||||
// 返回 0 表示不冻结(向后兼容)。
|
||||
func (s *SettingService) GetAffiliateRebateFreezeHours(ctx context.Context) int {
|
||||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateFreezeHours)
|
||||
if err != nil {
|
||||
return AffiliateRebateFreezeHoursDefault
|
||||
}
|
||||
hours, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || hours < 0 {
|
||||
return AffiliateRebateFreezeHoursDefault
|
||||
}
|
||||
if hours > AffiliateRebateFreezeHoursMax {
|
||||
return AffiliateRebateFreezeHoursMax
|
||||
}
|
||||
return hours
|
||||
}
|
||||
|
||||
// GetAffiliateRebateDurationDays 返回返利有效期(天)。
|
||||
// 返回 0 表示永久有效。
|
||||
func (s *SettingService) GetAffiliateRebateDurationDays(ctx context.Context) int {
|
||||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateDurationDays)
|
||||
if err != nil {
|
||||
return AffiliateRebateDurationDaysDefault
|
||||
}
|
||||
days, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || days < 0 {
|
||||
return AffiliateRebateDurationDaysDefault
|
||||
}
|
||||
if days > AffiliateRebateDurationDaysMax {
|
||||
return AffiliateRebateDurationDaysMax
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
// GetAffiliateRebatePerInviteeCap 返回单人返利上限。
|
||||
// 返回 0 表示无上限。
|
||||
func (s *SettingService) GetAffiliateRebatePerInviteeCap(ctx context.Context) float64 {
|
||||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebatePerInviteeCap)
|
||||
if err != nil {
|
||||
return AffiliateRebatePerInviteeCapDefault
|
||||
}
|
||||
cap, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||
if err != nil || cap < 0 || math.IsNaN(cap) || math.IsInf(cap, 0) {
|
||||
return AffiliateRebatePerInviteeCapDefault
|
||||
}
|
||||
return cap
|
||||
}
|
||||
|
||||
// IsPasswordResetEnabled 检查是否启用密码重置功能
|
||||
// 要求:必须同时开启邮件验证
|
||||
func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool {
|
||||
// Password reset requires email verification to be enabled
|
||||
if !s.IsEmailVerifyEnabled(ctx) {
|
||||
return false
|
||||
}
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyPasswordResetEnabled)
|
||||
if err != nil {
|
||||
return false // 默认关闭
|
||||
}
|
||||
return value == "true"
|
||||
}
|
||||
|
||||
// IsTotpEnabled 检查是否启用 TOTP 双因素认证功能
|
||||
func (s *SettingService) IsTotpEnabled(ctx context.Context) bool {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyTotpEnabled)
|
||||
if err != nil {
|
||||
return false // 默认关闭
|
||||
}
|
||||
return value == "true"
|
||||
}
|
||||
|
||||
// IsTotpEncryptionKeyConfigured 检查 TOTP 加密密钥是否已手动配置
|
||||
// 只有手动配置了密钥才允许在管理后台启用 TOTP 功能
|
||||
func (s *SettingService) IsTotpEncryptionKeyConfigured() bool {
|
||||
return s.cfg.Totp.EncryptionKeyConfigured
|
||||
}
|
||||
|
||||
// GetSiteName 获取网站名称
|
||||
func (s *SettingService) GetSiteName(ctx context.Context) string {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeySiteName)
|
||||
if err != nil || value == "" {
|
||||
return "Sub2API"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// GetDefaultConcurrency 获取默认并发量
|
||||
func (s *SettingService) GetDefaultConcurrency(ctx context.Context) int {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultConcurrency)
|
||||
if err != nil {
|
||||
return s.cfg.Default.UserConcurrency
|
||||
}
|
||||
if v, err := strconv.Atoi(value); err == nil && v > 0 {
|
||||
return v
|
||||
}
|
||||
return s.cfg.Default.UserConcurrency
|
||||
}
|
||||
|
||||
// GetDefaultBalance 获取默认余额
|
||||
func (s *SettingService) GetDefaultBalance(ctx context.Context) float64 {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultBalance)
|
||||
if err != nil {
|
||||
return s.cfg.Default.UserBalance
|
||||
}
|
||||
if v, err := strconv.ParseFloat(value, 64); err == nil && v >= 0 {
|
||||
return v
|
||||
}
|
||||
return s.cfg.Default.UserBalance
|
||||
}
|
||||
|
||||
// GetDefaultUserRPMLimit 获取新用户默认 RPM 限制(0 = 不限制)。未配置则返回 0。
|
||||
func (s *SettingService) GetDefaultUserRPMLimit(ctx context.Context) int {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultUserRPMLimit)
|
||||
if err != nil || value == "" {
|
||||
return 0
|
||||
}
|
||||
if v, err := strconv.Atoi(value); err == nil && v >= 0 {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetDefaultSubscriptions 获取新用户默认订阅配置列表。
|
||||
func (s *SettingService) GetDefaultSubscriptions(ctx context.Context) []DefaultSubscriptionSetting {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultSubscriptions)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parseDefaultSubscriptions(value)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*AuthSourceDefaultSettings, error) {
|
||||
keys := []string{
|
||||
SettingKeyAuthSourceDefaultEmailBalance,
|
||||
SettingKeyAuthSourceDefaultEmailConcurrency,
|
||||
SettingKeyAuthSourceDefaultEmailSubscriptions,
|
||||
SettingKeyAuthSourceDefaultEmailGrantOnSignup,
|
||||
SettingKeyAuthSourceDefaultEmailGrantOnFirstBind,
|
||||
SettingKeyAuthSourceDefaultLinuxDoBalance,
|
||||
SettingKeyAuthSourceDefaultLinuxDoConcurrency,
|
||||
SettingKeyAuthSourceDefaultLinuxDoSubscriptions,
|
||||
SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup,
|
||||
SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind,
|
||||
SettingKeyAuthSourceDefaultOIDCBalance,
|
||||
SettingKeyAuthSourceDefaultOIDCConcurrency,
|
||||
SettingKeyAuthSourceDefaultOIDCSubscriptions,
|
||||
SettingKeyAuthSourceDefaultOIDCGrantOnSignup,
|
||||
SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind,
|
||||
SettingKeyAuthSourceDefaultWeChatBalance,
|
||||
SettingKeyAuthSourceDefaultWeChatConcurrency,
|
||||
SettingKeyAuthSourceDefaultWeChatSubscriptions,
|
||||
SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
|
||||
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
|
||||
SettingKeyAuthSourceDefaultGitHubBalance,
|
||||
SettingKeyAuthSourceDefaultGitHubConcurrency,
|
||||
SettingKeyAuthSourceDefaultGitHubSubscriptions,
|
||||
SettingKeyAuthSourceDefaultGitHubGrantOnSignup,
|
||||
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind,
|
||||
SettingKeyAuthSourceDefaultGoogleBalance,
|
||||
SettingKeyAuthSourceDefaultGoogleConcurrency,
|
||||
SettingKeyAuthSourceDefaultGoogleSubscriptions,
|
||||
SettingKeyAuthSourceDefaultGoogleGrantOnSignup,
|
||||
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind,
|
||||
SettingKeyAuthSourceDefaultDingTalkBalance,
|
||||
SettingKeyAuthSourceDefaultDingTalkConcurrency,
|
||||
SettingKeyAuthSourceDefaultDingTalkSubscriptions,
|
||||
SettingKeyAuthSourceDefaultDingTalkGrantOnSignup,
|
||||
SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind,
|
||||
SettingKeyAuthSourcePlatformQuotas("email"),
|
||||
SettingKeyAuthSourcePlatformQuotas("linuxdo"),
|
||||
SettingKeyAuthSourcePlatformQuotas("oidc"),
|
||||
SettingKeyAuthSourcePlatformQuotas("wechat"),
|
||||
SettingKeyAuthSourcePlatformQuotas("github"),
|
||||
SettingKeyAuthSourcePlatformQuotas("google"),
|
||||
SettingKeyAuthSourcePlatformQuotas("dingtalk"),
|
||||
SettingKeyForceEmailOnThirdPartySignup,
|
||||
}
|
||||
|
||||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get auth source default settings: %w", err)
|
||||
}
|
||||
|
||||
return &AuthSourceDefaultSettings{
|
||||
Email: parseProviderDefaultGrantSettings(settings, emailAuthSourceDefaultKeys),
|
||||
LinuxDo: parseProviderDefaultGrantSettings(settings, linuxDoAuthSourceDefaultKeys),
|
||||
OIDC: parseProviderDefaultGrantSettings(settings, oidcAuthSourceDefaultKeys),
|
||||
WeChat: parseProviderDefaultGrantSettings(settings, weChatAuthSourceDefaultKeys),
|
||||
GitHub: parseProviderDefaultGrantSettings(settings, gitHubAuthSourceDefaultKeys),
|
||||
Google: parseProviderDefaultGrantSettings(settings, googleAuthSourceDefaultKeys),
|
||||
DingTalk: parseProviderDefaultGrantSettings(settings, dingTalkAuthSourceDefaultKeys),
|
||||
ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SettingService) ResolveAuthSourceGrantSettings(ctx context.Context, signupSource string, firstBind bool) (ProviderDefaultGrantSettings, bool, error) {
|
||||
result := ProviderDefaultGrantSettings{
|
||||
Balance: s.GetDefaultBalance(ctx),
|
||||
Concurrency: s.GetDefaultConcurrency(ctx),
|
||||
Subscriptions: s.GetDefaultSubscriptions(ctx),
|
||||
}
|
||||
|
||||
defaults, err := s.GetAuthSourceDefaultSettings(ctx)
|
||||
if err != nil {
|
||||
return result, false, err
|
||||
}
|
||||
|
||||
providerDefaults, ok := authSourceSignupSettings(defaults, signupSource)
|
||||
if !ok {
|
||||
return result, false, nil
|
||||
}
|
||||
|
||||
enabled := providerDefaults.GrantOnSignup
|
||||
if firstBind {
|
||||
enabled = providerDefaults.GrantOnFirstBind
|
||||
}
|
||||
if !enabled {
|
||||
return result, false, nil
|
||||
}
|
||||
|
||||
return mergeProviderDefaultGrantSettings(result, providerDefaults), true, nil
|
||||
}
|
||||
|
||||
func (s *SettingService) UpdateAuthSourceDefaultSettings(ctx context.Context, settings *AuthSourceDefaultSettings) error {
|
||||
updates, err := s.buildAuthSourceDefaultUpdates(ctx, settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.settingRepo.SetMultiple(ctx, updates); err != nil {
|
||||
return fmt.Errorf("update auth source default settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsTurnstileEnabled 检查是否启用 Turnstile 验证
|
||||
func (s *SettingService) IsTurnstileEnabled(ctx context.Context) bool {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileEnabled)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return value == "true"
|
||||
}
|
||||
|
||||
// GetTurnstileSecretKey 获取 Turnstile Secret Key
|
||||
func (s *SettingService) GetTurnstileSecretKey(ctx context.Context) string {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileSecretKey)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// IsIdentityPatchEnabled 检查是否启用身份补丁(Claude -> Gemini systemInstruction 注入)
|
||||
func (s *SettingService) IsIdentityPatchEnabled(ctx context.Context) bool {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableIdentityPatch)
|
||||
if err != nil {
|
||||
// 默认开启,保持兼容
|
||||
return true
|
||||
}
|
||||
return value == "true"
|
||||
}
|
||||
|
||||
// GetIdentityPatchPrompt 获取自定义身份补丁提示词(为空表示使用内置默认模板)
|
||||
func (s *SettingService) GetIdentityPatchPrompt(ctx context.Context) string {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyIdentityPatchPrompt)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// GenerateAdminAPIKey 生成新的管理员 API Key
|
||||
func (s *SettingService) GenerateAdminAPIKey(ctx context.Context) (string, error) {
|
||||
// 生成 32 字节随机数 = 64 位十六进制字符
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("generate random bytes: %w", err)
|
||||
}
|
||||
|
||||
key := AdminAPIKeyPrefix + hex.EncodeToString(bytes)
|
||||
|
||||
// 存储到 settings 表
|
||||
if err := s.settingRepo.Set(ctx, SettingKeyAdminAPIKey, key); err != nil {
|
||||
return "", fmt.Errorf("save admin api key: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// GetAdminAPIKeyStatus 获取管理员 API Key 状态
|
||||
// 返回脱敏的 key、是否存在、错误
|
||||
func (s *SettingService) GetAdminAPIKeyStatus(ctx context.Context) (maskedKey string, exists bool, err error) {
|
||||
key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
if key == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// 脱敏:显示前 10 位和后 4 位
|
||||
if len(key) > 14 {
|
||||
maskedKey = key[:10] + "..." + key[len(key)-4:]
|
||||
} else {
|
||||
maskedKey = key
|
||||
}
|
||||
|
||||
return maskedKey, true, nil
|
||||
}
|
||||
|
||||
// GetAdminAPIKey 获取完整的管理员 API Key(仅供内部验证使用)
|
||||
// 如果未配置返回空字符串和 nil 错误,只有数据库错误时才返回 error
|
||||
func (s *SettingService) GetAdminAPIKey(ctx context.Context) (string, error) {
|
||||
key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
return "", nil // 未配置,返回空字符串
|
||||
}
|
||||
return "", err // 数据库错误
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// DeleteAdminAPIKey 删除管理员 API Key
|
||||
func (s *SettingService) DeleteAdminAPIKey(ctx context.Context) error {
|
||||
return s.settingRepo.Delete(ctx, SettingKeyAdminAPIKey)
|
||||
}
|
||||
|
||||
// IsModelFallbackEnabled 检查是否启用模型兜底机制
|
||||
func (s *SettingService) IsModelFallbackEnabled(ctx context.Context) bool {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableModelFallback)
|
||||
if err != nil {
|
||||
return false // Default: disabled
|
||||
}
|
||||
return value == "true"
|
||||
}
|
||||
|
||||
// GetFallbackModel 获取指定平台的兜底模型
|
||||
func (s *SettingService) GetFallbackModel(ctx context.Context, platform string) string {
|
||||
var key string
|
||||
var defaultModel string
|
||||
|
||||
switch platform {
|
||||
case PlatformAnthropic:
|
||||
key = SettingKeyFallbackModelAnthropic
|
||||
defaultModel = "claude-3-5-sonnet-20241022"
|
||||
case PlatformOpenAI:
|
||||
key = SettingKeyFallbackModelOpenAI
|
||||
defaultModel = "gpt-4o"
|
||||
case PlatformGemini:
|
||||
key = SettingKeyFallbackModelGemini
|
||||
defaultModel = "gemini-2.5-pro"
|
||||
case PlatformAntigravity:
|
||||
key = SettingKeyFallbackModelAntigravity
|
||||
defaultModel = "gemini-2.5-pro"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
value, err := s.settingRepo.GetValue(ctx, key)
|
||||
if err != nil || value == "" {
|
||||
return defaultModel
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// GetOverloadCooldownSettings 获取529过载冷却配置
|
||||
func (s *SettingService) GetOverloadCooldownSettings(ctx context.Context) (*OverloadCooldownSettings, error) {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyOverloadCooldownSettings)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
return DefaultOverloadCooldownSettings(), nil
|
||||
}
|
||||
return nil, fmt.Errorf("get overload cooldown settings: %w", err)
|
||||
}
|
||||
if value == "" {
|
||||
return DefaultOverloadCooldownSettings(), nil
|
||||
}
|
||||
|
||||
var settings OverloadCooldownSettings
|
||||
if err := json.Unmarshal([]byte(value), &settings); err != nil {
|
||||
return DefaultOverloadCooldownSettings(), nil
|
||||
}
|
||||
|
||||
// 修正配置值范围
|
||||
if settings.CooldownMinutes < 1 {
|
||||
settings.CooldownMinutes = 1
|
||||
}
|
||||
if settings.CooldownMinutes > 120 {
|
||||
settings.CooldownMinutes = 120
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// SetOverloadCooldownSettings 设置529过载冷却配置
|
||||
func (s *SettingService) SetOverloadCooldownSettings(ctx context.Context, settings *OverloadCooldownSettings) error {
|
||||
if settings == nil {
|
||||
return fmt.Errorf("settings cannot be nil")
|
||||
}
|
||||
|
||||
// 禁用时修正为合法值即可,不拒绝请求
|
||||
if settings.CooldownMinutes < 1 || settings.CooldownMinutes > 120 {
|
||||
if settings.Enabled {
|
||||
return fmt.Errorf("cooldown_minutes must be between 1-120")
|
||||
}
|
||||
settings.CooldownMinutes = 10 // 禁用状态下归一化为默认值
|
||||
}
|
||||
|
||||
data, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal overload cooldown settings: %w", err)
|
||||
}
|
||||
|
||||
return s.settingRepo.Set(ctx, SettingKeyOverloadCooldownSettings, string(data))
|
||||
}
|
||||
|
||||
// GetRateLimit429CooldownSettings 获取429默认回避配置
|
||||
func (s *SettingService) GetRateLimit429CooldownSettings(ctx context.Context) (*RateLimit429CooldownSettings, error) {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyRateLimit429CooldownSettings)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
return DefaultRateLimit429CooldownSettings(), nil
|
||||
}
|
||||
return nil, fmt.Errorf("get 429 cooldown settings: %w", err)
|
||||
}
|
||||
if value == "" {
|
||||
return DefaultRateLimit429CooldownSettings(), nil
|
||||
}
|
||||
|
||||
var settings RateLimit429CooldownSettings
|
||||
if err := json.Unmarshal([]byte(value), &settings); err != nil {
|
||||
return DefaultRateLimit429CooldownSettings(), nil
|
||||
}
|
||||
|
||||
if settings.CooldownSeconds < 1 {
|
||||
settings.CooldownSeconds = 1
|
||||
}
|
||||
if settings.CooldownSeconds > 7200 {
|
||||
settings.CooldownSeconds = 7200
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// SetRateLimit429CooldownSettings 设置429默认回避配置
|
||||
func (s *SettingService) SetRateLimit429CooldownSettings(ctx context.Context, settings *RateLimit429CooldownSettings) error {
|
||||
if settings == nil {
|
||||
return fmt.Errorf("settings cannot be nil")
|
||||
}
|
||||
|
||||
if settings.CooldownSeconds < 1 || settings.CooldownSeconds > 7200 {
|
||||
if settings.Enabled {
|
||||
return fmt.Errorf("cooldown_seconds must be between 1-7200")
|
||||
}
|
||||
settings.CooldownSeconds = 5
|
||||
}
|
||||
|
||||
data, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal 429 cooldown settings: %w", err)
|
||||
}
|
||||
|
||||
return s.settingRepo.Set(ctx, SettingKeyRateLimit429CooldownSettings, string(data))
|
||||
}
|
||||
|
||||
// GetStreamTimeoutSettings 获取流超时处理配置
|
||||
func (s *SettingService) GetStreamTimeoutSettings(ctx context.Context) (*StreamTimeoutSettings, error) {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyStreamTimeoutSettings)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
return DefaultStreamTimeoutSettings(), nil
|
||||
}
|
||||
return nil, fmt.Errorf("get stream timeout settings: %w", err)
|
||||
}
|
||||
if value == "" {
|
||||
return DefaultStreamTimeoutSettings(), nil
|
||||
}
|
||||
|
||||
var settings StreamTimeoutSettings
|
||||
if err := json.Unmarshal([]byte(value), &settings); err != nil {
|
||||
return DefaultStreamTimeoutSettings(), nil
|
||||
}
|
||||
|
||||
// 验证并修正配置值
|
||||
if settings.TempUnschedMinutes < 1 {
|
||||
settings.TempUnschedMinutes = 1
|
||||
}
|
||||
if settings.TempUnschedMinutes > 60 {
|
||||
settings.TempUnschedMinutes = 60
|
||||
}
|
||||
if settings.ThresholdCount < 1 {
|
||||
settings.ThresholdCount = 1
|
||||
}
|
||||
if settings.ThresholdCount > 10 {
|
||||
settings.ThresholdCount = 10
|
||||
}
|
||||
if settings.ThresholdWindowMinutes < 1 {
|
||||
settings.ThresholdWindowMinutes = 1
|
||||
}
|
||||
if settings.ThresholdWindowMinutes > 60 {
|
||||
settings.ThresholdWindowMinutes = 60
|
||||
}
|
||||
|
||||
// 验证 action
|
||||
switch settings.Action {
|
||||
case StreamTimeoutActionTempUnsched, StreamTimeoutActionError, StreamTimeoutActionNone:
|
||||
// valid
|
||||
default:
|
||||
settings.Action = StreamTimeoutActionTempUnsched
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// IsUngroupedKeySchedulingAllowed 查询是否允许未分组 Key 调度
|
||||
func (s *SettingService) IsUngroupedKeySchedulingAllowed(ctx context.Context) bool {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyAllowUngroupedKeyScheduling)
|
||||
if err != nil {
|
||||
return false // fail-closed: 查询失败时默认不允许
|
||||
}
|
||||
return value == "true"
|
||||
}
|
||||
|
||||
// GetRectifierSettings 获取请求整流器配置
|
||||
func (s *SettingService) GetRectifierSettings(ctx context.Context) (*RectifierSettings, error) {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyRectifierSettings)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
return DefaultRectifierSettings(), nil
|
||||
}
|
||||
return nil, fmt.Errorf("get rectifier settings: %w", err)
|
||||
}
|
||||
if value == "" {
|
||||
return DefaultRectifierSettings(), nil
|
||||
}
|
||||
|
||||
var settings RectifierSettings
|
||||
if err := json.Unmarshal([]byte(value), &settings); err != nil {
|
||||
return DefaultRectifierSettings(), nil
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// SetRectifierSettings 设置请求整流器配置
|
||||
func (s *SettingService) SetRectifierSettings(ctx context.Context, settings *RectifierSettings) error {
|
||||
if settings == nil {
|
||||
return fmt.Errorf("settings cannot be nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal rectifier settings: %w", err)
|
||||
}
|
||||
|
||||
return s.settingRepo.Set(ctx, SettingKeyRectifierSettings, string(data))
|
||||
}
|
||||
|
||||
// IsSignatureRectifierEnabled 判断签名整流是否启用(总开关 && 签名子开关)
|
||||
func (s *SettingService) IsSignatureRectifierEnabled(ctx context.Context) bool {
|
||||
settings, err := s.GetRectifierSettings(ctx)
|
||||
if err != nil {
|
||||
return true // fail-open: 查询失败时默认启用
|
||||
}
|
||||
return settings.Enabled && settings.ThinkingSignatureEnabled
|
||||
}
|
||||
|
||||
// IsBudgetRectifierEnabled 判断 Budget 整流是否启用(总开关 && Budget 子开关)
|
||||
func (s *SettingService) IsBudgetRectifierEnabled(ctx context.Context) bool {
|
||||
settings, err := s.GetRectifierSettings(ctx)
|
||||
if err != nil {
|
||||
return true // fail-open: 查询失败时默认启用
|
||||
}
|
||||
return settings.Enabled && settings.ThinkingBudgetEnabled
|
||||
}
|
||||
|
||||
// GetBetaPolicySettings 获取 Beta 策略配置
|
||||
func (s *SettingService) GetBetaPolicySettings(ctx context.Context) (*BetaPolicySettings, error) {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyBetaPolicySettings)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
return DefaultBetaPolicySettings(), nil
|
||||
}
|
||||
return nil, fmt.Errorf("get beta policy settings: %w", err)
|
||||
}
|
||||
if value == "" {
|
||||
return DefaultBetaPolicySettings(), nil
|
||||
}
|
||||
|
||||
var settings BetaPolicySettings
|
||||
if err := json.Unmarshal([]byte(value), &settings); err != nil {
|
||||
return DefaultBetaPolicySettings(), nil
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// SetBetaPolicySettings 设置 Beta 策略配置
|
||||
func (s *SettingService) SetBetaPolicySettings(ctx context.Context, settings *BetaPolicySettings) error {
|
||||
if settings == nil {
|
||||
return fmt.Errorf("settings cannot be nil")
|
||||
}
|
||||
|
||||
validActions := map[string]bool{
|
||||
BetaPolicyActionPass: true, BetaPolicyActionFilter: true, BetaPolicyActionBlock: true,
|
||||
}
|
||||
validScopes := map[string]bool{
|
||||
BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true,
|
||||
}
|
||||
|
||||
for i, rule := range settings.Rules {
|
||||
if rule.BetaToken == "" {
|
||||
return fmt.Errorf("rule[%d]: beta_token cannot be empty", i)
|
||||
}
|
||||
if !validActions[rule.Action] {
|
||||
return fmt.Errorf("rule[%d]: invalid action %q", i, rule.Action)
|
||||
}
|
||||
if !validScopes[rule.Scope] {
|
||||
return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope)
|
||||
}
|
||||
// Validate model_whitelist patterns
|
||||
for j, pattern := range rule.ModelWhitelist {
|
||||
trimmed := strings.TrimSpace(pattern)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("rule[%d]: model_whitelist[%d] cannot be empty", i, j)
|
||||
}
|
||||
settings.Rules[i].ModelWhitelist[j] = trimmed
|
||||
}
|
||||
// Validate fallback_action
|
||||
if rule.FallbackAction != "" && !validActions[rule.FallbackAction] {
|
||||
return fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction)
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal beta policy settings: %w", err)
|
||||
}
|
||||
|
||||
return s.settingRepo.Set(ctx, SettingKeyBetaPolicySettings, string(data))
|
||||
}
|
||||
|
||||
// GetOpenAIFastPolicySettings 获取 OpenAI fast 策略配置
|
||||
func (s *SettingService) GetOpenAIFastPolicySettings(ctx context.Context) (*OpenAIFastPolicySettings, error) {
|
||||
value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAIFastPolicySettings)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
return DefaultOpenAIFastPolicySettings(), nil
|
||||
}
|
||||
return nil, fmt.Errorf("get openai fast policy settings: %w", err)
|
||||
}
|
||||
if value == "" {
|
||||
return DefaultOpenAIFastPolicySettings(), nil
|
||||
}
|
||||
|
||||
var settings OpenAIFastPolicySettings
|
||||
if err := json.Unmarshal([]byte(value), &settings); err != nil {
|
||||
// JSON 损坏时静默 fallback 到默认配置会让策略意外失效(管理员配
|
||||
// 置的 block/filter 规则被忽略)。记录 Warn 让运维能在出现异常
|
||||
// 行为时定位到 settings 表里的脏数据。
|
||||
slog.Warn("failed to unmarshal openai fast policy settings, falling back to defaults",
|
||||
"error", err,
|
||||
"key", SettingKeyOpenAIFastPolicySettings)
|
||||
return DefaultOpenAIFastPolicySettings(), nil
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// SetOpenAIFastPolicySettings 设置 OpenAI fast 策略配置
|
||||
func (s *SettingService) SetOpenAIFastPolicySettings(ctx context.Context, settings *OpenAIFastPolicySettings) error {
|
||||
if settings == nil {
|
||||
return fmt.Errorf("settings cannot be nil")
|
||||
}
|
||||
|
||||
validActions := map[string]bool{
|
||||
BetaPolicyActionPass: true, BetaPolicyActionFilter: true, BetaPolicyActionBlock: true,
|
||||
OpenAIFastPolicyActionForcePriority: true,
|
||||
}
|
||||
validScopes := map[string]bool{
|
||||
BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true,
|
||||
}
|
||||
validTiers := map[string]bool{
|
||||
OpenAIFastTierAny: true, OpenAIFastTierPriority: true, OpenAIFastTierFlex: true,
|
||||
}
|
||||
|
||||
for i, rule := range settings.Rules {
|
||||
tier := strings.ToLower(strings.TrimSpace(rule.ServiceTier))
|
||||
if tier == "" {
|
||||
tier = OpenAIFastTierAny
|
||||
}
|
||||
if !validTiers[tier] {
|
||||
return fmt.Errorf("rule[%d]: invalid service_tier %q", i, rule.ServiceTier)
|
||||
}
|
||||
settings.Rules[i].ServiceTier = tier
|
||||
if !validActions[rule.Action] {
|
||||
return fmt.Errorf("rule[%d]: invalid action %q", i, rule.Action)
|
||||
}
|
||||
if !validScopes[rule.Scope] {
|
||||
return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope)
|
||||
}
|
||||
for j, pattern := range rule.ModelWhitelist {
|
||||
trimmed := strings.TrimSpace(pattern)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("rule[%d]: model_whitelist[%d] cannot be empty", i, j)
|
||||
}
|
||||
settings.Rules[i].ModelWhitelist[j] = trimmed
|
||||
}
|
||||
if rule.FallbackAction != "" && !validActions[rule.FallbackAction] {
|
||||
return fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction)
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal openai fast policy settings: %w", err)
|
||||
}
|
||||
|
||||
return s.settingRepo.Set(ctx, SettingKeyOpenAIFastPolicySettings, string(data))
|
||||
}
|
||||
|
||||
// SetStreamTimeoutSettings 设置流超时处理配置
|
||||
func (s *SettingService) SetStreamTimeoutSettings(ctx context.Context, settings *StreamTimeoutSettings) error {
|
||||
if settings == nil {
|
||||
return fmt.Errorf("settings cannot be nil")
|
||||
}
|
||||
|
||||
// 验证配置值
|
||||
if settings.TempUnschedMinutes < 1 || settings.TempUnschedMinutes > 60 {
|
||||
return fmt.Errorf("temp_unsched_minutes must be between 1-60")
|
||||
}
|
||||
if settings.ThresholdCount < 1 || settings.ThresholdCount > 10 {
|
||||
return fmt.Errorf("threshold_count must be between 1-10")
|
||||
}
|
||||
if settings.ThresholdWindowMinutes < 1 || settings.ThresholdWindowMinutes > 60 {
|
||||
return fmt.Errorf("threshold_window_minutes must be between 1-60")
|
||||
}
|
||||
|
||||
switch settings.Action {
|
||||
case StreamTimeoutActionTempUnsched, StreamTimeoutActionError, StreamTimeoutActionNone:
|
||||
// valid
|
||||
default:
|
||||
return fmt.Errorf("invalid action: %s", settings.Action)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal stream timeout settings: %w", err)
|
||||
}
|
||||
|
||||
return s.settingRepo.Set(ctx, SettingKeyStreamTimeoutSettings, string(data))
|
||||
}
|
||||
|
||||
// GetDefaultPlatformQuotas 读取系统全局 platform quota JSON key,返回全部允许平台 x 3 window 的设置。
|
||||
// 永远返回包含全部允许 platform key 的 map(值可能为零值/nil 字段,表示"上层未配置 = 不限制")。
|
||||
//
|
||||
// 使用单个 JSON key(default_platform_quotas),一次 DB roundtrip,消除旧 12-KV 格式的 N+1 问题。
|
||||
// 容错语义:取值失败或 unmarshal 失败 → 返回补齐全部允许平台 key 的空 map(fail-open,注册不被阻断)。
|
||||
func (s *SettingService) GetDefaultPlatformQuotas(ctx context.Context) (map[string]*DefaultPlatformQuotaSetting, error) {
|
||||
out := make(map[string]*DefaultPlatformQuotaSetting, len(AllowedQuotaPlatforms))
|
||||
for _, platform := range AllowedQuotaPlatforms {
|
||||
out[platform] = &DefaultPlatformQuotaSetting{}
|
||||
}
|
||||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultPlatformQuotas)
|
||||
if err != nil || raw == "" {
|
||||
return out, nil // 无配置 = 全部不限制
|
||||
}
|
||||
parsed := map[string]*DefaultPlatformQuotaSetting{}
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
slog.Warn("[Setting] unmarshal default_platform_quotas failed (fail-open)", "error", err)
|
||||
return out, nil
|
||||
}
|
||||
for _, platform := range AllowedQuotaPlatforms {
|
||||
if v := parsed[platform]; v != nil {
|
||||
out[platform] = v
|
||||
}
|
||||
}
|
||||
return out, nil // 补齐全部允许 platform key,保持与旧实现一致的下游契约
|
||||
}
|
||||
|
||||
// GetAuthSourcePlatformQuotas 读取指定 auth source 的 platform quota 覆盖(仅返回有配置的平台,override 语义)。
|
||||
func (s *SettingService) GetAuthSourcePlatformQuotas(ctx context.Context, source string) map[string]*DefaultPlatformQuotaSetting {
|
||||
out := map[string]*DefaultPlatformQuotaSetting{}
|
||||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAuthSourcePlatformQuotas(source))
|
||||
if err != nil || raw == "" {
|
||||
return out // 无 override
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
slog.Warn("[Setting] unmarshal auth source platform quotas failed (fail-open)", "source", source, "error", err)
|
||||
return map[string]*DefaultPlatformQuotaSetting{}
|
||||
}
|
||||
return out // 仅含已配置平台,保持 override 语义
|
||||
}
|
||||
|
||||
// mergePlatformQuotaDefaults 按字段级 patch:src 中非 nil 字段覆盖 dst。
|
||||
// 区分 nil("未配置",保留 dst)vs &0.0("显式禁用",覆盖 dst 为 0)
|
||||
func mergePlatformQuotaDefaults(dst, src *DefaultPlatformQuotaSetting) {
|
||||
if src == nil || dst == nil {
|
||||
return
|
||||
}
|
||||
if src.DailyLimitUSD != nil {
|
||||
dst.DailyLimitUSD = src.DailyLimitUSD
|
||||
}
|
||||
if src.WeeklyLimitUSD != nil {
|
||||
dst.WeeklyLimitUSD = src.WeeklyLimitUSD
|
||||
}
|
||||
if src.MonthlyLimitUSD != nil {
|
||||
dst.MonthlyLimitUSD = src.MonthlyLimitUSD
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,892 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// cachedVersionBounds 缓存 Claude Code 版本号上下限(进程内缓存,60s TTL)
|
||||
type cachedVersionBounds struct {
|
||||
min string // 空字符串 = 不检查
|
||||
max string // 空字符串 = 不检查
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
// versionBoundsCache 版本号上下限进程内缓存
|
||||
var versionBoundsCache atomic.Value // *cachedVersionBounds
|
||||
|
||||
// versionBoundsSF 防止缓存过期时 thundering herd
|
||||
var versionBoundsSF singleflight.Group
|
||||
|
||||
// versionBoundsCacheTTL 缓存有效期
|
||||
const versionBoundsCacheTTL = 60 * time.Second
|
||||
|
||||
// versionBoundsErrorTTL DB 错误时的短缓存,快速重试
|
||||
const versionBoundsErrorTTL = 5 * time.Second
|
||||
|
||||
// versionBoundsDBTimeout singleflight 内 DB 查询超时,独立于请求 context
|
||||
const versionBoundsDBTimeout = 5 * time.Second
|
||||
|
||||
// cachedBackendMode Backend Mode cache (in-process, 60s TTL)
|
||||
type cachedBackendMode struct {
|
||||
value bool
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
var backendModeCache atomic.Value // *cachedBackendMode
|
||||
var backendModeSF singleflight.Group
|
||||
|
||||
const backendModeCacheTTL = 60 * time.Second
|
||||
const backendModeErrorTTL = 5 * time.Second
|
||||
const backendModeDBTimeout = 5 * time.Second
|
||||
|
||||
// cachedGatewayForwardingSettings 缓存网关转发行为设置(进程内缓存,60s TTL)
|
||||
type cachedGatewayForwardingSettings struct {
|
||||
fingerprintUnification bool
|
||||
metadataPassthrough bool
|
||||
cchSigning bool
|
||||
claudeOAuthSystemPromptInjection bool
|
||||
claudeOAuthSystemPrompt string
|
||||
claudeOAuthSystemPromptBlocks string
|
||||
anthropicCacheTTL1hInjection bool
|
||||
rewriteMessageCacheControl bool
|
||||
clientDatelineNormalization bool
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
var gatewayForwardingCache atomic.Value // *cachedGatewayForwardingSettings
|
||||
var gatewayForwardingSF singleflight.Group
|
||||
|
||||
const gatewayForwardingCacheTTL = 60 * time.Second
|
||||
const gatewayForwardingErrorTTL = 5 * time.Second
|
||||
const gatewayForwardingDBTimeout = 5 * time.Second
|
||||
|
||||
// cachedAntigravityUserAgentVersion 缓存 Antigravity UA 版本号(进程内缓存,60s TTL)
|
||||
type cachedAntigravityUserAgentVersion struct {
|
||||
version string
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
const antigravityUserAgentVersionCacheTTL = 60 * time.Second
|
||||
const antigravityUserAgentVersionErrorTTL = 5 * time.Second
|
||||
const antigravityUserAgentVersionDBTimeout = 5 * time.Second
|
||||
|
||||
// DefaultOpenAICodexUserAgent OpenAI Codex 默认 User-Agent(用于规避 Cloudflare 对浏览器 UA 的质询)
|
||||
const DefaultOpenAICodexUserAgent = "codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)"
|
||||
|
||||
// cachedOpenAICodexUserAgent 缓存 OpenAI Codex UA(进程内缓存,60s TTL)
|
||||
type cachedOpenAICodexUserAgent struct {
|
||||
value string
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
type cachedOpenAIQuotaAutoPauseSettings struct {
|
||||
settings OpsOpenAIAccountQuotaAutoPauseSettings
|
||||
expiresAt int64
|
||||
}
|
||||
|
||||
const openAICodexUserAgentCacheTTL = 60 * time.Second
|
||||
const openAICodexUserAgentErrorTTL = 5 * time.Second
|
||||
const openAICodexUserAgentDBTimeout = 5 * time.Second
|
||||
|
||||
const codexRestrictionPolicyCacheTTL = 60 * time.Second
|
||||
const codexRestrictionPolicyDBTimeout = 5 * time.Second
|
||||
|
||||
// cachedCodexRestrictionPolicy codex_cli_only 全局加固策略缓存(进程内,60s TTL)。
|
||||
// GetCodexRestrictionPolicy 在每个 codex_cli_only 账号的网关请求热路径上被调用,避免每次访问 DB。
|
||||
type cachedCodexRestrictionPolicy struct {
|
||||
value CodexRestrictionPolicy
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
// cachedCyberSessionBlockRuntime cyber 会话屏蔽开关+TTL 进程内缓存(60s TTL)。
|
||||
// GetCyberSessionBlockRuntime 在网关请求热路径上被调用,避免每次访问 DB。
|
||||
type cachedCyberSessionBlockRuntime struct {
|
||||
enabled bool
|
||||
ttl time.Duration
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
const cyberSessionBlockRuntimeCacheTTL = 60 * time.Second
|
||||
const cyberSessionBlockRuntimeErrorTTL = 5 * time.Second
|
||||
const cyberSessionBlockRuntimeDBTimeout = 5 * time.Second
|
||||
|
||||
const openAIQuotaAutoPauseSettingsCacheTTL = 60 * time.Second
|
||||
const openAIQuotaAutoPauseSettingsErrorTTL = 5 * time.Second
|
||||
const openAIQuotaAutoPauseSettingsDBTimeout = 5 * time.Second
|
||||
|
||||
const openAIQuotaAutoPauseSettingsRefreshKey = "openai_quota_auto_pause_settings"
|
||||
|
||||
// GetCyberSessionBlockRuntime 返回 (开关, TTL),进程内缓存 ~60s,
|
||||
// 供网关热路径读取时避免 DB 往返。
|
||||
// 两个 setting key 在单次 singleflight 里一起读取,减少 DB 往返。
|
||||
// 默认值:开关 false,TTL 1h(与粘性会话对齐)。
|
||||
func (s *SettingService) GetCyberSessionBlockRuntime(ctx context.Context) (bool, time.Duration) {
|
||||
if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.enabled, cached.ttl
|
||||
}
|
||||
}
|
||||
result, _, _ := s.cyberSessionBlockRuntimeSF.Do("cyber_session_block_runtime", func() (any, error) {
|
||||
if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cyberSessionBlockRuntimeDBTimeout)
|
||||
defer cancel()
|
||||
|
||||
enabledVal, enabledErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockEnabled)
|
||||
ttlVal, ttlErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockTTLSeconds)
|
||||
|
||||
if enabledErr != nil && !errors.Is(enabledErr, ErrSettingNotFound) {
|
||||
slog.Warn("failed to get cyber_session_block_enabled setting", "error", enabledErr)
|
||||
entry := &cachedCyberSessionBlockRuntime{
|
||||
enabled: false,
|
||||
ttl: time.Hour,
|
||||
expiresAt: time.Now().Add(cyberSessionBlockRuntimeErrorTTL).UnixNano(),
|
||||
}
|
||||
s.cyberSessionBlockRuntimeCache.Store(entry)
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
enabled := enabledErr == nil && strings.TrimSpace(enabledVal) == "true"
|
||||
|
||||
ttl := time.Hour
|
||||
if ttlErr == nil {
|
||||
if n, perr := strconv.Atoi(strings.TrimSpace(ttlVal)); perr == nil && n > 0 {
|
||||
ttl = time.Duration(n) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
entry := &cachedCyberSessionBlockRuntime{
|
||||
enabled: enabled,
|
||||
ttl: ttl,
|
||||
expiresAt: time.Now().Add(cyberSessionBlockRuntimeCacheTTL).UnixNano(),
|
||||
}
|
||||
s.cyberSessionBlockRuntimeCache.Store(entry)
|
||||
return entry, nil
|
||||
})
|
||||
if entry, ok := result.(*cachedCyberSessionBlockRuntime); ok && entry != nil {
|
||||
return entry.enabled, entry.ttl
|
||||
}
|
||||
return false, time.Hour
|
||||
}
|
||||
|
||||
// GetAntigravityUserAgentVersion 返回 Antigravity 上游请求使用的版本号。
|
||||
// 后台设置优先;为空、缺失或非法时回退到 ANTIGRAVITY_USER_AGENT_VERSION / 内置默认值。
|
||||
func (s *SettingService) GetAntigravityUserAgentVersion(ctx context.Context) string {
|
||||
fallback := antigravity.GetDefaultUserAgentVersion()
|
||||
if s == nil || s.settingRepo == nil {
|
||||
return fallback
|
||||
}
|
||||
if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.version
|
||||
}
|
||||
}
|
||||
|
||||
result, _, _ := s.antigravityUAVersionSF.Do("antigravity_user_agent_version", func() (any, error) {
|
||||
if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.version, nil
|
||||
}
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), antigravityUserAgentVersionDBTimeout)
|
||||
defer cancel()
|
||||
value, err := s.settingRepo.GetValue(dbCtx, SettingKeyAntigravityUserAgentVersion)
|
||||
if err != nil && !errors.Is(err, ErrSettingNotFound) {
|
||||
slog.Warn("failed to get antigravity user agent version setting", "error", err)
|
||||
s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{
|
||||
version: fallback,
|
||||
expiresAt: time.Now().Add(antigravityUserAgentVersionErrorTTL).UnixNano(),
|
||||
})
|
||||
return fallback, nil
|
||||
}
|
||||
version := antigravity.NormalizeUserAgentVersion(value)
|
||||
if version == "" {
|
||||
version = fallback
|
||||
}
|
||||
s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{
|
||||
version: version,
|
||||
expiresAt: time.Now().Add(antigravityUserAgentVersionCacheTTL).UnixNano(),
|
||||
})
|
||||
return version, nil
|
||||
})
|
||||
if version, ok := result.(string); ok && version != "" {
|
||||
return version
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// GetOpenAICodexUserAgent 返回 OpenAI Codex 上游请求使用的 User-Agent。
|
||||
// 后台设置优先;为空时回退到内置默认值。
|
||||
func (s *SettingService) GetOpenAICodexUserAgent(ctx context.Context) string {
|
||||
fallback := DefaultOpenAICodexUserAgent
|
||||
if s == nil || s.settingRepo == nil {
|
||||
return fallback
|
||||
}
|
||||
if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.value
|
||||
}
|
||||
}
|
||||
|
||||
result, _, _ := s.openAICodexUASF.Do("openai_codex_user_agent", func() (any, error) {
|
||||
if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.value, nil
|
||||
}
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAICodexUserAgentDBTimeout)
|
||||
defer cancel()
|
||||
value, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAICodexUserAgent)
|
||||
if err != nil && !errors.Is(err, ErrSettingNotFound) {
|
||||
slog.Warn("failed to get openai codex user agent setting", "error", err)
|
||||
s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{
|
||||
value: fallback,
|
||||
expiresAt: time.Now().Add(openAICodexUserAgentErrorTTL).UnixNano(),
|
||||
})
|
||||
return fallback, nil
|
||||
}
|
||||
ua := strings.TrimSpace(value)
|
||||
if ua == "" {
|
||||
ua = fallback
|
||||
}
|
||||
s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{
|
||||
value: ua,
|
||||
expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(),
|
||||
})
|
||||
return ua, nil
|
||||
})
|
||||
if ua, ok := result.(string); ok && ua != "" {
|
||||
return ua
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
var legacyClaudeCodeCodexWhitelistEntry = openai.AllowedClientEntry{
|
||||
Originator: "Claude Code",
|
||||
UAContains: []string{"Claude Code/"},
|
||||
}
|
||||
|
||||
// MigrateOpenAIAllowClaudeCodeCodexPluginSetting folds the deprecated global Claude Code
|
||||
// plugin allow switch into codex_cli_only_whitelist. The app-server identity model is the
|
||||
// same originator + UA marker pair, so runtime checks no longer need a separate flag.
|
||||
func (s *SettingService) MigrateOpenAIAllowClaudeCodeCodexPluginSetting(ctx context.Context) error {
|
||||
if s == nil || s.settingRepo == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout)
|
||||
defer cancel()
|
||||
|
||||
legacyValue, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAIAllowClaudeCodeCodexPlugin)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("get deprecated %s setting: %w", SettingKeyOpenAIAllowClaudeCodeCodexPlugin, err)
|
||||
}
|
||||
if strings.TrimSpace(legacyValue) != "true" {
|
||||
return nil
|
||||
}
|
||||
|
||||
rawWhitelist, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyWhitelist)
|
||||
if err != nil && !errors.Is(err, ErrSettingNotFound) {
|
||||
return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err)
|
||||
}
|
||||
|
||||
var entries []openai.AllowedClientEntry
|
||||
if strings.TrimSpace(rawWhitelist) != "" {
|
||||
if err := json.Unmarshal([]byte(rawWhitelist), &entries); err != nil {
|
||||
return fmt.Errorf("parse %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err)
|
||||
}
|
||||
}
|
||||
if codexClientEntriesContain(entries, legacyClaudeCodeCodexWhitelistEntry) {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries = append(entries, legacyClaudeCodeCodexWhitelistEntry)
|
||||
encoded, err := json.Marshal(entries)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err)
|
||||
}
|
||||
if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyWhitelist, string(encoded)); err != nil {
|
||||
return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err)
|
||||
}
|
||||
s.codexRestrictionPolicySF.Forget("codex_restriction_policy")
|
||||
s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0})
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateCodexBodyFingerprintToSignals 把已废弃的 codex_cli_only_allow_body_engine_fingerprint
|
||||
// 开关并入引擎指纹信号列表。幂等:信号键已存在(非空)则不动;缺失时写默认种子,
|
||||
// 并把 body 路径行的 Required 设为旧 body 开关的值(旧 true ⇒ 勾上 body 行)。
|
||||
func (s *SettingService) MigrateCodexBodyFingerprintToSignals(ctx context.Context) error {
|
||||
if s == nil || s.settingRepo == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout)
|
||||
defer cancel()
|
||||
|
||||
if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals); err == nil && strings.TrimSpace(v) != "" {
|
||||
return nil // 已配置/已迁移
|
||||
} else if err != nil && !errors.Is(err, ErrSettingNotFound) {
|
||||
return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err)
|
||||
}
|
||||
|
||||
bodyOn := false
|
||||
if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint); err == nil {
|
||||
bodyOn = strings.TrimSpace(v) == "true"
|
||||
} else if !errors.Is(err, ErrSettingNotFound) {
|
||||
return fmt.Errorf("get deprecated %s setting: %w", SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint, err)
|
||||
}
|
||||
|
||||
seed := make([]openai.EngineFingerprintSignal, len(openai.DefaultEngineFingerprintSignals))
|
||||
copy(seed, openai.DefaultEngineFingerprintSignals)
|
||||
if bodyOn {
|
||||
for i := range seed {
|
||||
if seed[i].Type == openai.FingerprintSignalBodyPath {
|
||||
seed[i].Required = true
|
||||
}
|
||||
}
|
||||
}
|
||||
encoded, err := json.Marshal(seed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err)
|
||||
}
|
||||
if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals, string(encoded)); err != nil {
|
||||
return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err)
|
||||
}
|
||||
s.codexRestrictionPolicySF.Forget("codex_restriction_policy")
|
||||
s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0})
|
||||
return nil
|
||||
}
|
||||
|
||||
func codexClientEntriesContain(entries []openai.AllowedClientEntry, want openai.AllowedClientEntry) bool {
|
||||
wantOriginator := strings.TrimSpace(want.Originator)
|
||||
if wantOriginator == "" {
|
||||
return false
|
||||
}
|
||||
wantMarkers := normalizedCodexClientMarkers(want.UAContains)
|
||||
if len(wantMarkers) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !strings.EqualFold(strings.TrimSpace(entry.Originator), wantOriginator) {
|
||||
continue
|
||||
}
|
||||
gotMarkers := normalizedCodexClientMarkers(entry.UAContains)
|
||||
if len(gotMarkers) != len(wantMarkers) {
|
||||
continue
|
||||
}
|
||||
matched := true
|
||||
for marker := range wantMarkers {
|
||||
if _, ok := gotMarkers[marker]; !ok {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizedCodexClientMarkers(markers []string) map[string]struct{} {
|
||||
normalized := make(map[string]struct{}, len(markers))
|
||||
for _, marker := range markers {
|
||||
marker = strings.TrimSpace(marker)
|
||||
if marker == "" {
|
||||
continue
|
||||
}
|
||||
normalized[strings.ToLower(marker)] = struct{}{}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// GetCodexRestrictionPolicy 读取 codex_cli_only 全局加固策略(黑/白名单、最低版本、引擎指纹门)。
|
||||
// 仅在调用方已确认账号 codex_cli_only 开启时读取;进程内 atomic.Value 缓存(60s TTL)避免热路径访问 DB。
|
||||
// 任意键缺失/解析失败 → 安全默认:空名单、空版本、默认种子指纹信号。
|
||||
func (s *SettingService) GetCodexRestrictionPolicy(ctx context.Context) CodexRestrictionPolicy {
|
||||
if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.value
|
||||
}
|
||||
}
|
||||
result, _, _ := s.codexRestrictionPolicySF.Do("codex_restriction_policy", func() (any, error) {
|
||||
if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.value, nil
|
||||
}
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout)
|
||||
defer cancel()
|
||||
|
||||
pol := CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals} // 安全默认:默认种子指纹信号
|
||||
if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMinCodexVersion); err == nil {
|
||||
pol.MinCodexVersion = strings.TrimSpace(v)
|
||||
}
|
||||
if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMaxCodexVersion); err == nil {
|
||||
pol.MaxCodexVersion = strings.TrimSpace(v)
|
||||
}
|
||||
if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowAppServerClients); err == nil {
|
||||
pol.AllowAppServerClients = strings.TrimSpace(v) == "true" // 仅显式 "true" 开启
|
||||
}
|
||||
pol.EngineFingerprintSignals = s.loadEngineFingerprintSignals(dbCtx)
|
||||
pol.Whitelist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyWhitelist)
|
||||
pol.Blacklist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyBlacklist)
|
||||
|
||||
s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{
|
||||
value: pol,
|
||||
expiresAt: time.Now().Add(codexRestrictionPolicyCacheTTL).UnixNano(),
|
||||
})
|
||||
return pol, nil
|
||||
})
|
||||
if pol, ok := result.(CodexRestrictionPolicy); ok {
|
||||
return pol
|
||||
}
|
||||
return CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals}
|
||||
}
|
||||
|
||||
// loadCodexClientEntries 读取并解析 []openai.AllowedClientEntry JSON 设置;缺失/空/非法 → nil(安全忽略)。
|
||||
func (s *SettingService) loadCodexClientEntries(ctx context.Context, key string) []openai.AllowedClientEntry {
|
||||
v, err := s.settingRepo.GetValue(ctx, key)
|
||||
if err != nil || strings.TrimSpace(v) == "" {
|
||||
return nil
|
||||
}
|
||||
var entries []openai.AllowedClientEntry
|
||||
if json.Unmarshal([]byte(v), &entries) != nil {
|
||||
return nil
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// loadEngineFingerprintSignals 读取引擎指纹信号列表;缺失/空/非法 → 默认种子。
|
||||
func (s *SettingService) loadEngineFingerprintSignals(ctx context.Context) []openai.EngineFingerprintSignal {
|
||||
v, err := s.settingRepo.GetValue(ctx, SettingKeyCodexCLIOnlyEngineFingerprintSignals)
|
||||
if err != nil || strings.TrimSpace(v) == "" {
|
||||
return openai.DefaultEngineFingerprintSignals
|
||||
}
|
||||
sigs, ok := openai.ParseEngineFingerprintSignals(v)
|
||||
if !ok {
|
||||
return openai.DefaultEngineFingerprintSignals
|
||||
}
|
||||
return sigs
|
||||
}
|
||||
|
||||
// ValidateCodexClientEntriesJSON 校验 codex_cli_only 名单 JSON 配置(黑名单语义):
|
||||
// 空=合法(禁用);非空须为 []AllowedClientEntry 的 JSON 数组。黑名单是 OR 宽 deny,
|
||||
// 允许 originator-only 条目,故不校验 ua_contains。白名单请用 ValidateCodexWhitelistEntriesJSON。
|
||||
func ValidateCodexClientEntriesJSON(raw string) error {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
var entries []openai.AllowedClientEntry
|
||||
if err := json.Unmarshal([]byte(trimmed), &entries); err != nil {
|
||||
return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateCodexWhitelistEntriesJSON 在 ValidateCodexClientEntriesJSON 的数组结构校验之上,额外要求
|
||||
// 每条白名单条目「有可能命中」(openai.AllowedClientEntry.IsWhitelistable)。白名单是双因子 AND:
|
||||
// originator-only、空或含空白 ua_contains 的条目会在运行时静默失效——这里让管理员在写入时即收到反馈,
|
||||
// 而非存入永不命中的死规则。黑名单(OR 宽 deny)仍用 ValidateCodexClientEntriesJSON。
|
||||
func ValidateCodexWhitelistEntriesJSON(raw string) error {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
var entries []openai.AllowedClientEntry
|
||||
if err := json.Unmarshal([]byte(trimmed), &entries); err != nil {
|
||||
return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}")
|
||||
}
|
||||
for i, e := range entries {
|
||||
if !e.IsWhitelistable() {
|
||||
return fmt.Errorf("entry %d: whitelist requires a non-empty originator and at least one non-empty ua_contains (double-factor AND; otherwise the rule never matches)", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateEngineFingerprintSignalsJSON 服务层包装,复用 openai 校验逻辑。
|
||||
func ValidateEngineFingerprintSignalsJSON(raw string) error {
|
||||
return openai.ValidateEngineFingerprintSignalsJSON(raw)
|
||||
}
|
||||
|
||||
// IsBackendModeEnabled checks if backend mode is enabled
|
||||
// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path
|
||||
func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool {
|
||||
if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.value
|
||||
}
|
||||
}
|
||||
result, _, _ := backendModeSF.Do("backend_mode", func() (any, error) {
|
||||
if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.value, nil
|
||||
}
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), backendModeDBTimeout)
|
||||
defer cancel()
|
||||
value, err := s.settingRepo.GetValue(dbCtx, SettingKeyBackendModeEnabled)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
// Setting not yet created (fresh install) - default to disabled with full TTL
|
||||
backendModeCache.Store(&cachedBackendMode{
|
||||
value: false,
|
||||
expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(),
|
||||
})
|
||||
return false, nil
|
||||
}
|
||||
slog.Warn("failed to get backend_mode_enabled setting", "error", err)
|
||||
backendModeCache.Store(&cachedBackendMode{
|
||||
value: false,
|
||||
expiresAt: time.Now().Add(backendModeErrorTTL).UnixNano(),
|
||||
})
|
||||
return false, nil
|
||||
}
|
||||
enabled := value == "true"
|
||||
backendModeCache.Store(&cachedBackendMode{
|
||||
value: enabled,
|
||||
expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(),
|
||||
})
|
||||
return enabled, nil
|
||||
})
|
||||
if val, ok := result.(bool); ok {
|
||||
return val
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type gatewayForwardingSettingsResult struct {
|
||||
fp, mp, cch, claudeOAuthSystemPromptInjection, cacheTTL1h, rewriteMessageCacheControl bool
|
||||
clientDatelineNormalization bool
|
||||
claudeOAuthSystemPrompt, claudeOAuthSystemPromptBlocks string
|
||||
}
|
||||
|
||||
func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) gatewayForwardingSettingsResult {
|
||||
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return gatewayForwardingSettingsResult{
|
||||
fp: cached.fingerprintUnification,
|
||||
mp: cached.metadataPassthrough,
|
||||
cch: cached.cchSigning,
|
||||
claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection,
|
||||
claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt,
|
||||
claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks,
|
||||
cacheTTL1h: cached.anthropicCacheTTL1hInjection,
|
||||
rewriteMessageCacheControl: cached.rewriteMessageCacheControl,
|
||||
clientDatelineNormalization: cached.clientDatelineNormalization,
|
||||
}
|
||||
}
|
||||
}
|
||||
val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) {
|
||||
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return gatewayForwardingSettingsResult{
|
||||
fp: cached.fingerprintUnification,
|
||||
mp: cached.metadataPassthrough,
|
||||
cch: cached.cchSigning,
|
||||
claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection,
|
||||
claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt,
|
||||
claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks,
|
||||
cacheTTL1h: cached.anthropicCacheTTL1hInjection,
|
||||
rewriteMessageCacheControl: cached.rewriteMessageCacheControl,
|
||||
clientDatelineNormalization: cached.clientDatelineNormalization,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout)
|
||||
defer cancel()
|
||||
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
|
||||
SettingKeyEnableFingerprintUnification,
|
||||
SettingKeyEnableMetadataPassthrough,
|
||||
SettingKeyEnableCCHSigning,
|
||||
SettingKeyEnableClaudeOAuthSystemPromptInjection,
|
||||
SettingKeyClaudeOAuthSystemPrompt,
|
||||
SettingKeyClaudeOAuthSystemPromptBlocks,
|
||||
SettingKeyEnableAnthropicCacheTTL1hInjection,
|
||||
SettingKeyRewriteMessageCacheControl,
|
||||
SettingKeyEnableClientDatelineNormalization,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("failed to get gateway forwarding settings", "error", err)
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||||
fingerprintUnification: true,
|
||||
metadataPassthrough: false,
|
||||
cchSigning: false,
|
||||
claudeOAuthSystemPromptInjection: true,
|
||||
anthropicCacheTTL1hInjection: false,
|
||||
rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(),
|
||||
clientDatelineNormalization: true,
|
||||
expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
|
||||
})
|
||||
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), clientDatelineNormalization: true}, nil
|
||||
}
|
||||
fp := true
|
||||
if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
|
||||
fp = v == "true"
|
||||
}
|
||||
mp := values[SettingKeyEnableMetadataPassthrough] == "true"
|
||||
cch := values[SettingKeyEnableCCHSigning] == "true"
|
||||
systemPromptInjection := true
|
||||
if v, ok := values[SettingKeyEnableClaudeOAuthSystemPromptInjection]; ok && v != "" {
|
||||
systemPromptInjection = v == "true"
|
||||
}
|
||||
systemPrompt := values[SettingKeyClaudeOAuthSystemPrompt]
|
||||
systemPromptBlocks := values[SettingKeyClaudeOAuthSystemPromptBlocks]
|
||||
cacheTTL1h := values[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true"
|
||||
rewriteMessageCacheControl := s.defaultRewriteMessageCacheControl()
|
||||
if v, ok := values[SettingKeyRewriteMessageCacheControl]; ok && v != "" {
|
||||
rewriteMessageCacheControl = v == "true"
|
||||
}
|
||||
clientDatelineNormalization := true
|
||||
if v, ok := values[SettingKeyEnableClientDatelineNormalization]; ok && v != "" {
|
||||
clientDatelineNormalization = v == "true"
|
||||
}
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||||
fingerprintUnification: fp,
|
||||
metadataPassthrough: mp,
|
||||
cchSigning: cch,
|
||||
claudeOAuthSystemPromptInjection: systemPromptInjection,
|
||||
claudeOAuthSystemPrompt: systemPrompt,
|
||||
claudeOAuthSystemPromptBlocks: systemPromptBlocks,
|
||||
anthropicCacheTTL1hInjection: cacheTTL1h,
|
||||
rewriteMessageCacheControl: rewriteMessageCacheControl,
|
||||
clientDatelineNormalization: clientDatelineNormalization,
|
||||
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
||||
})
|
||||
return gatewayForwardingSettingsResult{
|
||||
fp: fp,
|
||||
mp: mp,
|
||||
cch: cch,
|
||||
claudeOAuthSystemPromptInjection: systemPromptInjection,
|
||||
claudeOAuthSystemPrompt: systemPrompt,
|
||||
claudeOAuthSystemPromptBlocks: systemPromptBlocks,
|
||||
cacheTTL1h: cacheTTL1h,
|
||||
rewriteMessageCacheControl: rewriteMessageCacheControl,
|
||||
clientDatelineNormalization: clientDatelineNormalization,
|
||||
}, nil
|
||||
})
|
||||
if r, ok := val.(gatewayForwardingSettingsResult); ok {
|
||||
return r
|
||||
}
|
||||
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, clientDatelineNormalization: true}
|
||||
}
|
||||
|
||||
// GetGatewayForwardingSettings returns cached gateway forwarding settings.
|
||||
// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path.
|
||||
// Returns (fingerprintUnification, metadataPassthrough, cchSigning).
|
||||
func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough, cchSigning bool) {
|
||||
result := s.getGatewayForwardingSettingsCached(ctx)
|
||||
return result.fp, result.mp, result.cch
|
||||
}
|
||||
|
||||
// IsAnthropicCacheTTL1hInjectionEnabled 检查是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl。
|
||||
func (s *SettingService) IsAnthropicCacheTTL1hInjectionEnabled(ctx context.Context) bool {
|
||||
return s.getGatewayForwardingSettingsCached(ctx).cacheTTL1h
|
||||
}
|
||||
|
||||
// IsRewriteMessageCacheControlEnabled 检查是否启用 messages cache_control 改写。
|
||||
func (s *SettingService) IsRewriteMessageCacheControlEnabled(ctx context.Context) bool {
|
||||
return s.getGatewayForwardingSettingsCached(ctx).rewriteMessageCacheControl
|
||||
}
|
||||
|
||||
// IsClientDatelineNormalizationEnabled 检查是否启用 Anthropic OAuth/SetupToken 请求体
|
||||
// 的客户端 dateline 归一化。默认开启。
|
||||
func (s *SettingService) IsClientDatelineNormalizationEnabled(ctx context.Context) bool {
|
||||
return s.getGatewayForwardingSettingsCached(ctx).clientDatelineNormalization
|
||||
}
|
||||
|
||||
// GetClaudeOAuthSystemPromptInjectionSettings returns the Claude OAuth mimic
|
||||
// system block switch, legacy custom expansion prompt, and configurable blocks JSON.
|
||||
// Empty values mean use the built-in Claude Code default blocks.
|
||||
func (s *SettingService) GetClaudeOAuthSystemPromptInjectionSettings(ctx context.Context) (enabled bool, prompt string, blocks string) {
|
||||
result := s.getGatewayForwardingSettingsCached(ctx)
|
||||
return result.claudeOAuthSystemPromptInjection, result.claudeOAuthSystemPrompt, result.claudeOAuthSystemPromptBlocks
|
||||
}
|
||||
|
||||
// GetClaudeCodeVersionBounds 获取 Claude Code 版本号上下限要求
|
||||
// 使用进程内 atomic.Value 缓存,60 秒 TTL,热路径零锁开销
|
||||
// singleflight 防止缓存过期时 thundering herd
|
||||
// 返回空字符串表示不做对应方向的版本检查
|
||||
func (s *SettingService) GetClaudeCodeVersionBounds(ctx context.Context) (min, max string) {
|
||||
if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.min, cached.max
|
||||
}
|
||||
}
|
||||
// singleflight: 同一时刻只有一个 goroutine 查询 DB,其余复用结果
|
||||
type bounds struct{ min, max string }
|
||||
result, err, _ := versionBoundsSF.Do("version_bounds", func() (any, error) {
|
||||
// 二次检查,避免排队的 goroutine 重复查询
|
||||
if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return bounds{cached.min, cached.max}, nil
|
||||
}
|
||||
}
|
||||
// 使用独立 context:断开请求取消链,避免客户端断连导致空值被长期缓存
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), versionBoundsDBTimeout)
|
||||
defer cancel()
|
||||
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
|
||||
SettingKeyMinClaudeCodeVersion,
|
||||
SettingKeyMaxClaudeCodeVersion,
|
||||
})
|
||||
if err != nil {
|
||||
// fail-open: DB 错误时不阻塞请求,但记录日志并使用短 TTL 快速重试
|
||||
slog.Warn("failed to get claude code version bounds setting, skipping version check", "error", err)
|
||||
versionBoundsCache.Store(&cachedVersionBounds{
|
||||
min: "",
|
||||
max: "",
|
||||
expiresAt: time.Now().Add(versionBoundsErrorTTL).UnixNano(),
|
||||
})
|
||||
return bounds{"", ""}, nil
|
||||
}
|
||||
b := bounds{
|
||||
min: values[SettingKeyMinClaudeCodeVersion],
|
||||
max: values[SettingKeyMaxClaudeCodeVersion],
|
||||
}
|
||||
versionBoundsCache.Store(&cachedVersionBounds{
|
||||
min: b.min,
|
||||
max: b.max,
|
||||
expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(),
|
||||
})
|
||||
return b, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
b, ok := result.(bounds)
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
return b.min, b.max
|
||||
}
|
||||
|
||||
// GetOpenAIQuotaAutoPauseSettings returns the current global default quota auto-pause
|
||||
// settings. It is invoked on the OpenAI scheduling hot path (once per request) and is
|
||||
// therefore designed to never block on the DB:
|
||||
//
|
||||
// - Fresh cached value → returned immediately.
|
||||
// - Stale or empty cache → the last known value is returned, and a background
|
||||
// goroutine refreshes the cache via singleflight (stale-while-revalidate).
|
||||
// - First call with no cache yet → zero defaults are returned and the same async
|
||||
// refresh is kicked off; the next call gets the freshly populated value.
|
||||
//
|
||||
// Callers that need the freshly persisted value synchronously (tests, post-update
|
||||
// confirmation, optional startup warm-up) should call WarmOpenAIQuotaAutoPauseSettings.
|
||||
func (s *SettingService) GetOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings {
|
||||
if s == nil {
|
||||
return OpsOpenAIAccountQuotaAutoPauseSettings{}
|
||||
}
|
||||
cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings)
|
||||
now := time.Now().UnixNano()
|
||||
if cached != nil && now < cached.expiresAt {
|
||||
return cached.settings
|
||||
}
|
||||
// Stale or unset: trigger background refresh without blocking this request.
|
||||
// singleflight.DoChan dedupes concurrent refreshes; we deliberately ignore the
|
||||
// returned channel — the result is observable via the atomic cache.
|
||||
s.openAIQuotaAutoPauseSettingsSF.DoChan(openAIQuotaAutoPauseSettingsRefreshKey, func() (any, error) {
|
||||
s.refreshOpenAIQuotaAutoPauseSettings(context.Background())
|
||||
return nil, nil
|
||||
})
|
||||
if cached != nil {
|
||||
return cached.settings // serve stale value while revalidating
|
||||
}
|
||||
return OpsOpenAIAccountQuotaAutoPauseSettings{}
|
||||
}
|
||||
|
||||
// WarmOpenAIQuotaAutoPauseSettings synchronously loads the quota auto-pause settings
|
||||
// into the in-memory cache. Useful for application startup (so the first request hits
|
||||
// a warm cache) and for tests that need deterministic reads immediately after
|
||||
// constructing the service.
|
||||
func (s *SettingService) WarmOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings {
|
||||
if s == nil {
|
||||
return OpsOpenAIAccountQuotaAutoPauseSettings{}
|
||||
}
|
||||
s.refreshOpenAIQuotaAutoPauseSettings(ctx)
|
||||
cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings)
|
||||
if cached == nil {
|
||||
return OpsOpenAIAccountQuotaAutoPauseSettings{}
|
||||
}
|
||||
return cached.settings
|
||||
}
|
||||
|
||||
// refreshOpenAIQuotaAutoPauseSettings reads the latest settings from the DB and stores
|
||||
// them into the in-memory cache. On error it stores the prior value (or zero defaults
|
||||
// if nothing is cached yet) with the shorter error TTL so the next refresh comes
|
||||
// sooner. Always uses its own timeout-bounded context to keep refresh latency
|
||||
// predictable regardless of the caller.
|
||||
func (s *SettingService) refreshOpenAIQuotaAutoPauseSettings(ctx context.Context) {
|
||||
if s == nil || s.settingRepo == nil {
|
||||
return
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIQuotaAutoPauseSettingsDBTimeout)
|
||||
defer cancel()
|
||||
|
||||
settings := OpsOpenAIAccountQuotaAutoPauseSettings{}
|
||||
ttl := openAIQuotaAutoPauseSettingsCacheTTL
|
||||
raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpsAdvancedSettings)
|
||||
if err == nil {
|
||||
cfg := defaultOpsAdvancedSettings()
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
if jsonErr := json.Unmarshal([]byte(raw), cfg); jsonErr == nil {
|
||||
normalizeOpsAdvancedSettings(cfg)
|
||||
}
|
||||
}
|
||||
settings = cfg.OpenAIAccountQuotaAutoPause
|
||||
} else if !errors.Is(err, ErrSettingNotFound) {
|
||||
// Real error: keep serving prior value but refresh sooner.
|
||||
if prior, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings); prior != nil {
|
||||
settings = prior.settings
|
||||
}
|
||||
ttl = openAIQuotaAutoPauseSettingsErrorTTL
|
||||
}
|
||||
|
||||
s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{
|
||||
settings: settings,
|
||||
expiresAt: time.Now().Add(ttl).UnixNano(),
|
||||
})
|
||||
}
|
||||
|
||||
// SetOpenAIQuotaAutoPauseSettings writes the given settings directly into the in-memory
|
||||
// cache. Called from settings-write code paths so that the next read reflects the new
|
||||
// value immediately, without waiting for the background refresh.
|
||||
func (s *SettingService) SetOpenAIQuotaAutoPauseSettings(settings OpsOpenAIAccountQuotaAutoPauseSettings) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{
|
||||
settings: settings,
|
||||
expiresAt: time.Now().Add(openAIQuotaAutoPauseSettingsCacheTTL).UnixNano(),
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,688 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
)
|
||||
|
||||
func normalizeLoginAgreementMode(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "checkbox":
|
||||
return "checkbox"
|
||||
default:
|
||||
return defaultLoginAgreementMode
|
||||
}
|
||||
}
|
||||
|
||||
func defaultLoginAgreementDocuments() []LoginAgreementDocument {
|
||||
return []LoginAgreementDocument{
|
||||
{
|
||||
ID: "terms",
|
||||
Title: "服务条款",
|
||||
ContentMD: "",
|
||||
},
|
||||
{
|
||||
ID: "usage-policy",
|
||||
Title: "使用政策",
|
||||
ContentMD: "",
|
||||
},
|
||||
{
|
||||
ID: "supported-regions",
|
||||
Title: "支持的国家和地区",
|
||||
ContentMD: "",
|
||||
},
|
||||
{
|
||||
ID: "service-specific-terms",
|
||||
Title: "服务特定条款",
|
||||
ContentMD: "",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLoginAgreementDocumentID(raw string) string {
|
||||
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||
var b strings.Builder
|
||||
lastSeparator := false
|
||||
for _, r := range raw {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
_, _ = b.WriteRune(r)
|
||||
lastSeparator = false
|
||||
continue
|
||||
}
|
||||
if r == '-' || r == '_' || r == ' ' || r == '.' || r == '/' {
|
||||
if !lastSeparator && b.Len() > 0 {
|
||||
if r == '_' {
|
||||
_, _ = b.WriteRune('_')
|
||||
} else {
|
||||
_, _ = b.WriteRune('-')
|
||||
}
|
||||
lastSeparator = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-_")
|
||||
}
|
||||
|
||||
func normalizeLoginAgreementDocuments(docs []LoginAgreementDocument) []LoginAgreementDocument {
|
||||
normalized := make([]LoginAgreementDocument, 0, len(docs))
|
||||
seen := make(map[string]int, len(docs))
|
||||
for i, doc := range docs {
|
||||
title := strings.TrimSpace(doc.Title)
|
||||
content := strings.TrimSpace(doc.ContentMD)
|
||||
if title == "" && content == "" {
|
||||
continue
|
||||
}
|
||||
id := normalizeLoginAgreementDocumentID(doc.ID)
|
||||
if id == "" {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%s:%s", i, title, content)))
|
||||
id = hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
baseID := id
|
||||
for suffix := 2; seen[id] > 0; suffix++ {
|
||||
id = fmt.Sprintf("%s-%d", baseID, suffix)
|
||||
}
|
||||
seen[id]++
|
||||
normalized = append(normalized, LoginAgreementDocument{
|
||||
ID: id,
|
||||
Title: title,
|
||||
ContentMD: content,
|
||||
})
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func parseLoginAgreementDocuments(raw string) []LoginAgreementDocument {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return defaultLoginAgreementDocuments()
|
||||
}
|
||||
var docs []LoginAgreementDocument
|
||||
if err := json.Unmarshal([]byte(raw), &docs); err != nil {
|
||||
return defaultLoginAgreementDocuments()
|
||||
}
|
||||
docs = normalizeLoginAgreementDocuments(docs)
|
||||
if len(docs) == 0 {
|
||||
return defaultLoginAgreementDocuments()
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
func marshalLoginAgreementDocuments(docs []LoginAgreementDocument) (string, error) {
|
||||
normalized := normalizeLoginAgreementDocuments(docs)
|
||||
if len(normalized) == 0 {
|
||||
normalized = defaultLoginAgreementDocuments()
|
||||
}
|
||||
b, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal login agreement documents: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func buildLoginAgreementRevision(updatedAt string, docs []LoginAgreementDocument) string {
|
||||
normalized := normalizeLoginAgreementDocuments(docs)
|
||||
payload, err := json.Marshal(struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Documents []LoginAgreementDocument `json:"documents"`
|
||||
}{
|
||||
UpdatedAt: strings.TrimSpace(updatedAt),
|
||||
Documents: normalized,
|
||||
})
|
||||
if err != nil {
|
||||
payload = []byte(strings.TrimSpace(updatedAt))
|
||||
}
|
||||
sum := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
|
||||
// GetFrontendURL 获取前端基础URL(数据库优先,fallback 到配置文件)
|
||||
func (s *SettingService) GetFrontendURL(ctx context.Context) string {
|
||||
val, err := s.settingRepo.GetValue(ctx, SettingKeyFrontendURL)
|
||||
if err == nil && strings.TrimSpace(val) != "" {
|
||||
return strings.TrimSpace(val)
|
||||
}
|
||||
return s.cfg.Server.FrontendURL
|
||||
}
|
||||
|
||||
// GetPublicSettings 获取公开设置(无需登录)
|
||||
func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings, error) {
|
||||
keys := []string{
|
||||
SettingKeyRegistrationEnabled,
|
||||
SettingKeyEmailVerifyEnabled,
|
||||
SettingKeyForceEmailOnThirdPartySignup,
|
||||
SettingKeyRegistrationEmailSuffixWhitelist,
|
||||
SettingKeyPromoCodeEnabled,
|
||||
SettingKeyPasswordResetEnabled,
|
||||
SettingKeyInvitationCodeEnabled,
|
||||
SettingKeyTotpEnabled,
|
||||
SettingKeyLoginAgreementEnabled,
|
||||
SettingKeyLoginAgreementMode,
|
||||
SettingKeyLoginAgreementUpdatedAt,
|
||||
SettingKeyLoginAgreementDocuments,
|
||||
SettingKeyTurnstileEnabled,
|
||||
SettingKeyTurnstileSiteKey,
|
||||
SettingKeyAPIKeyACLTrustForwardedIP,
|
||||
SettingKeySiteName,
|
||||
SettingKeySiteLogo,
|
||||
SettingKeySiteSubtitle,
|
||||
SettingKeyAPIBaseURL,
|
||||
SettingKeyContactInfo,
|
||||
SettingKeyDocURL,
|
||||
SettingKeyHomeContent,
|
||||
SettingKeyHideCcsImportButton,
|
||||
SettingKeyPurchaseSubscriptionEnabled,
|
||||
SettingKeyPurchaseSubscriptionURL,
|
||||
SettingKeyTableDefaultPageSize,
|
||||
SettingKeyTablePageSizeOptions,
|
||||
SettingKeyCustomMenuItems,
|
||||
SettingKeyCustomEndpoints,
|
||||
SettingKeyLinuxDoConnectEnabled,
|
||||
SettingKeyDingTalkConnectEnabled,
|
||||
SettingKeyWeChatConnectEnabled,
|
||||
SettingKeyWeChatConnectAppID,
|
||||
SettingKeyWeChatConnectAppSecret,
|
||||
SettingKeyWeChatConnectOpenAppID,
|
||||
SettingKeyWeChatConnectOpenAppSecret,
|
||||
SettingKeyWeChatConnectMPAppID,
|
||||
SettingKeyWeChatConnectMPAppSecret,
|
||||
SettingKeyWeChatConnectMobileAppID,
|
||||
SettingKeyWeChatConnectMobileAppSecret,
|
||||
SettingKeyWeChatConnectOpenEnabled,
|
||||
SettingKeyWeChatConnectMPEnabled,
|
||||
SettingKeyWeChatConnectMobileEnabled,
|
||||
SettingKeyWeChatConnectMode,
|
||||
SettingKeyWeChatConnectScopes,
|
||||
SettingKeyWeChatConnectRedirectURL,
|
||||
SettingKeyWeChatConnectFrontendRedirectURL,
|
||||
SettingKeyBackendModeEnabled,
|
||||
SettingPaymentEnabled,
|
||||
SettingKeyOIDCConnectEnabled,
|
||||
SettingKeyOIDCConnectProviderName,
|
||||
SettingKeyGitHubOAuthEnabled,
|
||||
SettingKeyGitHubOAuthClientID,
|
||||
SettingKeyGitHubOAuthClientSecret,
|
||||
SettingKeyGoogleOAuthEnabled,
|
||||
SettingKeyGoogleOAuthClientID,
|
||||
SettingKeyGoogleOAuthClientSecret,
|
||||
SettingKeyBalanceLowNotifyEnabled,
|
||||
SettingKeyBalanceLowNotifyThreshold,
|
||||
SettingKeyBalanceLowNotifyRechargeURL,
|
||||
SettingKeyAccountQuotaNotifyEnabled,
|
||||
SettingKeyChannelMonitorEnabled,
|
||||
SettingKeyChannelMonitorDefaultIntervalSeconds,
|
||||
SettingKeyAvailableChannelsEnabled,
|
||||
SettingKeyAffiliateEnabled,
|
||||
SettingKeyRiskControlEnabled,
|
||||
SettingKeyAllowUserViewErrorRequests,
|
||||
}
|
||||
|
||||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get public settings: %w", err)
|
||||
}
|
||||
|
||||
linuxDoEnabled := false
|
||||
if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok {
|
||||
linuxDoEnabled = raw == "true"
|
||||
} else {
|
||||
linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled
|
||||
}
|
||||
dingTalkEnabled := false
|
||||
if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok {
|
||||
dingTalkEnabled = raw == "true"
|
||||
} else {
|
||||
dingTalkEnabled = s.cfg != nil && s.cfg.DingTalk.Enabled
|
||||
}
|
||||
oidcEnabled := false
|
||||
if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok {
|
||||
oidcEnabled = raw == "true"
|
||||
} else {
|
||||
oidcEnabled = s.cfg != nil && s.cfg.OIDC.Enabled
|
||||
}
|
||||
oidcProviderName := strings.TrimSpace(settings[SettingKeyOIDCConnectProviderName])
|
||||
if oidcProviderName == "" && s.cfg != nil {
|
||||
oidcProviderName = strings.TrimSpace(s.cfg.OIDC.ProviderName)
|
||||
}
|
||||
if oidcProviderName == "" {
|
||||
oidcProviderName = "OIDC"
|
||||
}
|
||||
gitHubEnabled := s.emailOAuthPublicEnabled(settings, "github")
|
||||
googleEnabled := s.emailOAuthPublicEnabled(settings, "google")
|
||||
weChatEnabled, weChatOpenEnabled, weChatMPEnabled, weChatMobileEnabled := s.weChatOAuthCapabilitiesFromSettings(settings)
|
||||
|
||||
// Password reset requires email verification to be enabled
|
||||
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
|
||||
passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true"
|
||||
registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist(
|
||||
settings[SettingKeyRegistrationEmailSuffixWhitelist],
|
||||
)
|
||||
tableDefaultPageSize, tablePageSizeOptions := parseTablePreferences(
|
||||
settings[SettingKeyTableDefaultPageSize],
|
||||
settings[SettingKeyTablePageSizeOptions],
|
||||
)
|
||||
loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments])
|
||||
loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt])
|
||||
if loginAgreementUpdatedAt == "" {
|
||||
loginAgreementUpdatedAt = defaultLoginAgreementDate
|
||||
}
|
||||
|
||||
var balanceLowNotifyThreshold float64
|
||||
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
|
||||
balanceLowNotifyThreshold = v
|
||||
}
|
||||
|
||||
return &PublicSettings{
|
||||
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
||||
EmailVerifyEnabled: emailVerifyEnabled,
|
||||
ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
|
||||
RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist,
|
||||
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
||||
PasswordResetEnabled: passwordResetEnabled,
|
||||
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
||||
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
||||
LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0,
|
||||
LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]),
|
||||
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
|
||||
LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments),
|
||||
LoginAgreementDocuments: loginAgreementDocuments,
|
||||
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
||||
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
||||
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
|
||||
SiteLogo: settings[SettingKeySiteLogo],
|
||||
SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
|
||||
APIBaseURL: settings[SettingKeyAPIBaseURL],
|
||||
ContactInfo: settings[SettingKeyContactInfo],
|
||||
DocURL: settings[SettingKeyDocURL],
|
||||
HomeContent: settings[SettingKeyHomeContent],
|
||||
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
||||
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
||||
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
||||
TableDefaultPageSize: tableDefaultPageSize,
|
||||
TablePageSizeOptions: tablePageSizeOptions,
|
||||
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
||||
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
||||
LinuxDoOAuthEnabled: linuxDoEnabled,
|
||||
DingTalkOAuthEnabled: dingTalkEnabled,
|
||||
WeChatOAuthEnabled: weChatEnabled,
|
||||
WeChatOAuthOpenEnabled: weChatOpenEnabled,
|
||||
WeChatOAuthMPEnabled: weChatMPEnabled,
|
||||
WeChatOAuthMobileEnabled: weChatMobileEnabled,
|
||||
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
|
||||
PaymentEnabled: settings[SettingPaymentEnabled] == "true",
|
||||
OIDCOAuthEnabled: oidcEnabled,
|
||||
OIDCOAuthProviderName: oidcProviderName,
|
||||
GitHubOAuthEnabled: gitHubEnabled,
|
||||
GoogleOAuthEnabled: googleEnabled,
|
||||
BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
|
||||
AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
|
||||
BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
|
||||
BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL],
|
||||
|
||||
ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]),
|
||||
ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]),
|
||||
|
||||
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
|
||||
|
||||
AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
|
||||
|
||||
RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true",
|
||||
|
||||
AllowUserViewErrorRequests: settings[SettingKeyAllowUserViewErrorRequests] == "true",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// channelMonitorIntervalMin / channelMonitorIntervalMax bound the default interval
|
||||
// (mirrors the monitor-level constraint but lives here so setting_service stays decoupled).
|
||||
const (
|
||||
channelMonitorIntervalMin = 15
|
||||
channelMonitorIntervalMax = 3600
|
||||
channelMonitorIntervalFallback = 60
|
||||
)
|
||||
|
||||
// parseChannelMonitorInterval parses the stored string and clamps to [15, 3600].
|
||||
// Empty / invalid input falls back to channelMonitorIntervalFallback.
|
||||
func parseChannelMonitorInterval(raw string) int {
|
||||
v, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return channelMonitorIntervalFallback
|
||||
}
|
||||
return clampChannelMonitorInterval(v)
|
||||
}
|
||||
|
||||
// clampChannelMonitorInterval clamps v to the allowed range. 0 means "not provided".
|
||||
func clampChannelMonitorInterval(v int) int {
|
||||
if v <= 0 {
|
||||
return 0
|
||||
}
|
||||
if v < channelMonitorIntervalMin {
|
||||
return channelMonitorIntervalMin
|
||||
}
|
||||
if v > channelMonitorIntervalMax {
|
||||
return channelMonitorIntervalMax
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ChannelMonitorRuntime is the lightweight view of the channel monitor feature
|
||||
// consumed by the runner and user-facing handlers.
|
||||
type ChannelMonitorRuntime struct {
|
||||
Enabled bool
|
||||
DefaultIntervalSeconds int
|
||||
}
|
||||
|
||||
// GetChannelMonitorRuntime reads the channel monitor feature flags directly from
|
||||
// the settings store. Fail-open: on error returns Enabled=true with the default interval.
|
||||
func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime {
|
||||
vals, err := s.settingRepo.GetMultiple(ctx, []string{
|
||||
SettingKeyChannelMonitorEnabled,
|
||||
SettingKeyChannelMonitorDefaultIntervalSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
return ChannelMonitorRuntime{Enabled: true, DefaultIntervalSeconds: channelMonitorIntervalFallback}
|
||||
}
|
||||
return ChannelMonitorRuntime{
|
||||
Enabled: !isFalseSettingValue(vals[SettingKeyChannelMonitorEnabled]),
|
||||
DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]),
|
||||
}
|
||||
}
|
||||
|
||||
// AvailableChannelsRuntime is the lightweight view of the available-channels feature
|
||||
// switch consumed by the user-facing handler.
|
||||
type AvailableChannelsRuntime struct {
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// GetAvailableChannelsRuntime reads the available-channels feature switch directly
|
||||
// from the settings store. Fail-closed: on error returns Enabled=false, matching
|
||||
// the opt-in default (unknown ↔ disabled).
|
||||
func (s *SettingService) GetAvailableChannelsRuntime(ctx context.Context) AvailableChannelsRuntime {
|
||||
vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAvailableChannelsEnabled})
|
||||
if err != nil {
|
||||
return AvailableChannelsRuntime{Enabled: false}
|
||||
}
|
||||
return AvailableChannelsRuntime{
|
||||
Enabled: vals[SettingKeyAvailableChannelsEnabled] == "true",
|
||||
}
|
||||
}
|
||||
|
||||
// IsUserErrorViewAllowed reads the user-facing error-requests visibility switch
|
||||
// directly from the settings store. Fail-closed: on error returns false (opt-in default).
|
||||
func (s *SettingService) IsUserErrorViewAllowed(ctx context.Context) bool {
|
||||
vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAllowUserViewErrorRequests})
|
||||
if err != nil {
|
||||
slog.Warn("failed to get allow_user_view_error_requests setting, defaulting to false", "error", err)
|
||||
return false
|
||||
}
|
||||
return vals[SettingKeyAllowUserViewErrorRequests] == "true"
|
||||
}
|
||||
|
||||
// PublicSettingsInjectionPayload is the JSON shape embedded into HTML as
|
||||
// `window.__APP_CONFIG__` so the frontend can hydrate feature flags & site
|
||||
// config before the first XHR finishes.
|
||||
//
|
||||
// INVARIANT: every `json` tag here MUST also exist on handler/dto.PublicSettings.
|
||||
// If you forget a feature-flag field here, the frontend's
|
||||
// `cachedPublicSettings.xxx_enabled` will be `undefined` on refresh until the
|
||||
// async `/api/v1/settings/public` call returns — which causes opt-in menus
|
||||
// (strict `=== true`) to flicker off/on. See
|
||||
// frontend/src/utils/featureFlags.ts for the matching registry.
|
||||
//
|
||||
// A unit test diffs this struct's JSON keys against dto.PublicSettings to catch
|
||||
// drift automatically (see setting_service_injection_test.go).
|
||||
type PublicSettingsInjectionPayload struct {
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"`
|
||||
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
|
||||
LoginAgreementMode string `json:"login_agreement_mode"`
|
||||
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
|
||||
LoginAgreementRevision string `json:"login_agreement_revision"`
|
||||
LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
|
||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||
SiteName string `json:"site_name"`
|
||||
SiteLogo string `json:"site_logo"`
|
||||
SiteSubtitle string `json:"site_subtitle"`
|
||||
APIBaseURL string `json:"api_base_url"`
|
||||
ContactInfo string `json:"contact_info"`
|
||||
DocURL string `json:"doc_url"`
|
||||
HomeContent string `json:"home_content"`
|
||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
||||
TableDefaultPageSize int `json:"table_default_page_size"`
|
||||
TablePageSizeOptions []int `json:"table_page_size_options"`
|
||||
CustomMenuItems json.RawMessage `json:"custom_menu_items"`
|
||||
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
|
||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||
DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"`
|
||||
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
|
||||
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
|
||||
WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
|
||||
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
|
||||
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
||||
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
|
||||
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
|
||||
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
|
||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||
PaymentEnabled bool `json:"payment_enabled"`
|
||||
Version string `json:"version"`
|
||||
// 服务器全局时区(IANA 名称与当前 UTC 偏移),高峰时段等服务端本地时间窗口的展示标注用
|
||||
ServerTimezone string `json:"server_timezone"`
|
||||
ServerUTCOffset string `json:"server_utc_offset"`
|
||||
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
||||
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
||||
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
||||
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
|
||||
|
||||
// Feature flags — MUST match the opt-in/opt-out registry in
|
||||
// frontend/src/utils/featureFlags.ts. Missing a field here is the bug
|
||||
// that hid the "可用渠道" menu on page refresh.
|
||||
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
|
||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||
RiskControlEnabled bool `json:"risk_control_enabled"`
|
||||
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
|
||||
}
|
||||
|
||||
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection.
|
||||
// This implements the web.PublicSettingsProvider interface.
|
||||
func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any, error) {
|
||||
settings, err := s.GetPublicSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PublicSettingsInjectionPayload{
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||||
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
|
||||
PromoCodeEnabled: settings.PromoCodeEnabled,
|
||||
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||||
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||
TotpEnabled: settings.TotpEnabled,
|
||||
LoginAgreementEnabled: settings.LoginAgreementEnabled,
|
||||
LoginAgreementMode: settings.LoginAgreementMode,
|
||||
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
|
||||
LoginAgreementRevision: settings.LoginAgreementRevision,
|
||||
LoginAgreementDocuments: settings.LoginAgreementDocuments,
|
||||
TurnstileEnabled: settings.TurnstileEnabled,
|
||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||
SiteName: settings.SiteName,
|
||||
SiteLogo: settings.SiteLogo,
|
||||
SiteSubtitle: settings.SiteSubtitle,
|
||||
APIBaseURL: settings.APIBaseURL,
|
||||
ContactInfo: settings.ContactInfo,
|
||||
DocURL: settings.DocURL,
|
||||
HomeContent: settings.HomeContent,
|
||||
HideCcsImportButton: settings.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||
TableDefaultPageSize: settings.TableDefaultPageSize,
|
||||
TablePageSizeOptions: settings.TablePageSizeOptions,
|
||||
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
|
||||
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
|
||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||
DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled,
|
||||
WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
|
||||
WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,
|
||||
WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled,
|
||||
WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
|
||||
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
|
||||
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
|
||||
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
|
||||
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
|
||||
BackendModeEnabled: settings.BackendModeEnabled,
|
||||
PaymentEnabled: settings.PaymentEnabled,
|
||||
Version: s.version,
|
||||
ServerTimezone: timezone.Name(),
|
||||
ServerUTCOffset: timezone.UTCOffset(),
|
||||
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
||||
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
|
||||
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
|
||||
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
|
||||
|
||||
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
|
||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
RiskControlEnabled: settings.RiskControlEnabled,
|
||||
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON
|
||||
// array string, returning only items with visibility != "admin".
|
||||
func filterUserVisibleMenuItems(raw string) json.RawMessage {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
var items []struct {
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
|
||||
// Parse full items to preserve all fields
|
||||
var fullItems []json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &fullItems); err != nil {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
|
||||
var filtered []json.RawMessage
|
||||
for i, item := range items {
|
||||
if item.Visibility != "admin" {
|
||||
filtered = append(filtered, fullItems[i])
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
result, err := json.Marshal(filtered)
|
||||
if err != nil {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]".
|
||||
func safeRawJSONArray(raw string) json.RawMessage {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
if json.Valid([]byte(raw)) {
|
||||
return json.RawMessage(raw)
|
||||
}
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
|
||||
// GetFrameSrcOrigins returns deduplicated http(s) origins from home_content URL,
|
||||
// purchase_subscription_url, and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection.
|
||||
func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, error) {
|
||||
settings, err := s.GetPublicSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
var origins []string
|
||||
|
||||
addOrigin := func(rawURL string) {
|
||||
if origin := extractOriginFromURL(rawURL); origin != "" {
|
||||
if _, ok := seen[origin]; !ok {
|
||||
seen[origin] = struct{}{}
|
||||
origins = append(origins, origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// home content URL (when home_content is set to a URL for iframe embedding)
|
||||
addOrigin(settings.HomeContent)
|
||||
|
||||
// purchase subscription URL
|
||||
if settings.PurchaseSubscriptionEnabled {
|
||||
addOrigin(settings.PurchaseSubscriptionURL)
|
||||
}
|
||||
|
||||
// all custom menu items (including admin-only, since CSP must allow all iframes)
|
||||
for _, item := range parseCustomMenuItemURLs(settings.CustomMenuItems) {
|
||||
addOrigin(item)
|
||||
}
|
||||
|
||||
return origins, nil
|
||||
}
|
||||
|
||||
// extractOriginFromURL returns the scheme+host origin from rawURL.
|
||||
// Only http and https schemes are accepted.
|
||||
func extractOriginFromURL(rawURL string) string {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return ""
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
|
||||
// parseCustomMenuItemURLs extracts URLs from a raw JSON array of custom menu items.
|
||||
func parseCustomMenuItemURLs(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" {
|
||||
return nil
|
||||
}
|
||||
var items []struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return nil
|
||||
}
|
||||
urls := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.URL != "" {
|
||||
urls = append(urls, item.URL)
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,623 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
)
|
||||
|
||||
// UpdateSettings 更新系统设置
|
||||
func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error {
|
||||
updates, err := s.buildSystemSettingsUpdates(ctx, settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.settingRepo.SetMultiple(ctx, updates)
|
||||
if err == nil {
|
||||
s.refreshCachedSettings(settings)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateSettingsWithAuthSourceDefaults persists system settings and auth-source defaults in a single write.
|
||||
func (s *SettingService) UpdateSettingsWithAuthSourceDefaults(ctx context.Context, settings *SystemSettings, authDefaults *AuthSourceDefaultSettings) error {
|
||||
updates, err := s.buildSystemSettingsUpdates(ctx, settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authSourceUpdates, err := s.buildAuthSourceDefaultUpdates(ctx, authDefaults)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key, value := range authSourceUpdates {
|
||||
updates[key] = value
|
||||
}
|
||||
|
||||
err = s.settingRepo.SetMultiple(ctx, updates)
|
||||
if err == nil {
|
||||
s.refreshCachedSettings(settings)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, settings *SystemSettings) (map[string]string, error) {
|
||||
if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizedWhitelist, err := NormalizeRegistrationEmailSuffixWhitelist(settings.RegistrationEmailSuffixWhitelist)
|
||||
if err != nil {
|
||||
return nil, infraerrors.BadRequest("INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", err.Error())
|
||||
}
|
||||
if normalizedWhitelist == nil {
|
||||
normalizedWhitelist = []string{}
|
||||
}
|
||||
settings.RegistrationEmailSuffixWhitelist = normalizedWhitelist
|
||||
alipaySource, err := normalizeVisibleMethodSettingSource("alipay", settings.PaymentVisibleMethodAlipaySource, settings.PaymentVisibleMethodAlipayEnabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wxpaySource, err := normalizeVisibleMethodSettingSource("wxpay", settings.PaymentVisibleMethodWxpaySource, settings.PaymentVisibleMethodWxpayEnabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.normalizeOpenAIAdvancedSchedulerOverrides(settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings.PaymentVisibleMethodAlipaySource = alipaySource
|
||||
settings.PaymentVisibleMethodWxpaySource = wxpaySource
|
||||
settings.WeChatConnectAppID = strings.TrimSpace(settings.WeChatConnectAppID)
|
||||
settings.WeChatConnectAppSecret = strings.TrimSpace(settings.WeChatConnectAppSecret)
|
||||
settings.WeChatConnectOpenAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectOpenAppID, settings.WeChatConnectAppID))
|
||||
settings.WeChatConnectOpenAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectOpenAppSecret, settings.WeChatConnectAppSecret))
|
||||
settings.WeChatConnectMPAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMPAppID, settings.WeChatConnectAppID))
|
||||
settings.WeChatConnectMPAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMPAppSecret, settings.WeChatConnectAppSecret))
|
||||
settings.WeChatConnectMobileAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMobileAppID, settings.WeChatConnectAppID))
|
||||
settings.WeChatConnectMobileAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMobileAppSecret, settings.WeChatConnectAppSecret))
|
||||
settings.WeChatConnectMode = normalizeWeChatConnectStoredMode(
|
||||
settings.WeChatConnectOpenEnabled,
|
||||
settings.WeChatConnectMPEnabled,
|
||||
settings.WeChatConnectMobileEnabled,
|
||||
settings.WeChatConnectMode,
|
||||
)
|
||||
settings.WeChatConnectScopes = normalizeWeChatConnectScopeSetting(settings.WeChatConnectScopes, settings.WeChatConnectMode)
|
||||
settings.WeChatConnectRedirectURL = strings.TrimSpace(settings.WeChatConnectRedirectURL)
|
||||
settings.WeChatConnectFrontendRedirectURL = strings.TrimSpace(settings.WeChatConnectFrontendRedirectURL)
|
||||
if settings.WeChatConnectFrontendRedirectURL == "" {
|
||||
settings.WeChatConnectFrontendRedirectURL = defaultWeChatConnectFrontend
|
||||
}
|
||||
settings.GitHubOAuthRedirectURL = strings.TrimSpace(settings.GitHubOAuthRedirectURL)
|
||||
settings.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(settings.GitHubOAuthFrontendRedirectURL)
|
||||
if settings.GitHubOAuthFrontendRedirectURL == "" {
|
||||
settings.GitHubOAuthFrontendRedirectURL = defaultGitHubOAuthFrontend
|
||||
}
|
||||
settings.GoogleOAuthRedirectURL = strings.TrimSpace(settings.GoogleOAuthRedirectURL)
|
||||
settings.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(settings.GoogleOAuthFrontendRedirectURL)
|
||||
if settings.GoogleOAuthFrontendRedirectURL == "" {
|
||||
settings.GoogleOAuthFrontendRedirectURL = defaultGoogleOAuthFrontend
|
||||
}
|
||||
|
||||
updates := make(map[string]string)
|
||||
|
||||
// 注册设置
|
||||
updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled)
|
||||
updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled)
|
||||
registrationEmailSuffixWhitelistJSON, err := json.Marshal(settings.RegistrationEmailSuffixWhitelist)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal registration email suffix whitelist: %w", err)
|
||||
}
|
||||
updates[SettingKeyRegistrationEmailSuffixWhitelist] = string(registrationEmailSuffixWhitelistJSON)
|
||||
updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled)
|
||||
updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled)
|
||||
updates[SettingKeyFrontendURL] = settings.FrontendURL
|
||||
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
|
||||
updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled)
|
||||
settings.LoginAgreementMode = normalizeLoginAgreementMode(settings.LoginAgreementMode)
|
||||
settings.LoginAgreementUpdatedAt = strings.TrimSpace(settings.LoginAgreementUpdatedAt)
|
||||
if settings.LoginAgreementUpdatedAt == "" {
|
||||
settings.LoginAgreementUpdatedAt = defaultLoginAgreementDate
|
||||
}
|
||||
loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(settings.LoginAgreementDocuments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updates[SettingKeyLoginAgreementEnabled] = strconv.FormatBool(settings.LoginAgreementEnabled)
|
||||
updates[SettingKeyLoginAgreementMode] = settings.LoginAgreementMode
|
||||
updates[SettingKeyLoginAgreementUpdatedAt] = settings.LoginAgreementUpdatedAt
|
||||
updates[SettingKeyLoginAgreementDocuments] = loginAgreementDocumentsJSON
|
||||
|
||||
// 邮件服务设置(只有非空才更新密码)
|
||||
updates[SettingKeySMTPHost] = settings.SMTPHost
|
||||
updates[SettingKeySMTPPort] = strconv.Itoa(settings.SMTPPort)
|
||||
updates[SettingKeySMTPUsername] = settings.SMTPUsername
|
||||
if settings.SMTPPassword != "" {
|
||||
updates[SettingKeySMTPPassword] = settings.SMTPPassword
|
||||
}
|
||||
updates[SettingKeySMTPFrom] = settings.SMTPFrom
|
||||
updates[SettingKeySMTPFromName] = settings.SMTPFromName
|
||||
updates[SettingKeySMTPUseTLS] = strconv.FormatBool(settings.SMTPUseTLS)
|
||||
|
||||
// Cloudflare Turnstile 设置(只有非空才更新密钥)
|
||||
updates[SettingKeyTurnstileEnabled] = strconv.FormatBool(settings.TurnstileEnabled)
|
||||
updates[SettingKeyTurnstileSiteKey] = settings.TurnstileSiteKey
|
||||
if settings.TurnstileSecretKey != "" {
|
||||
updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey
|
||||
}
|
||||
updates[SettingKeyAPIKeyACLTrustForwardedIP] = strconv.FormatBool(settings.APIKeyACLTrustForwardedIP)
|
||||
|
||||
// LinuxDo Connect OAuth 登录
|
||||
updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled)
|
||||
updates[SettingKeyLinuxDoConnectClientID] = settings.LinuxDoConnectClientID
|
||||
updates[SettingKeyLinuxDoConnectRedirectURL] = settings.LinuxDoConnectRedirectURL
|
||||
if settings.LinuxDoConnectClientSecret != "" {
|
||||
updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret
|
||||
}
|
||||
|
||||
// DingTalk Connect OAuth 登录
|
||||
updates[SettingKeyDingTalkConnectEnabled] = strconv.FormatBool(settings.DingTalkConnectEnabled)
|
||||
updates[SettingKeyDingTalkConnectClientID] = settings.DingTalkConnectClientID
|
||||
updates[SettingKeyDingTalkConnectRedirectURL] = settings.DingTalkConnectRedirectURL
|
||||
if settings.DingTalkConnectClientSecret != "" {
|
||||
updates[SettingKeyDingTalkConnectClientSecret] = settings.DingTalkConnectClientSecret
|
||||
}
|
||||
updates[SettingKeyDingTalkConnectCorpRestrictionPolicy] = settings.DingTalkConnectCorpRestrictionPolicy
|
||||
updates[SettingKeyDingTalkConnectInternalCorpID] = settings.DingTalkConnectInternalCorpID
|
||||
updates[SettingKeyDingTalkConnectBypassRegistration] = strconv.FormatBool(settings.DingTalkConnectBypassRegistration)
|
||||
updates[SettingKeyDingTalkConnectSyncCorpEmail] = strconv.FormatBool(settings.DingTalkConnectSyncCorpEmail)
|
||||
updates[SettingKeyDingTalkConnectSyncDisplayName] = strconv.FormatBool(settings.DingTalkConnectSyncDisplayName)
|
||||
updates[SettingKeyDingTalkConnectSyncDept] = strconv.FormatBool(settings.DingTalkConnectSyncDept)
|
||||
updates[SettingKeyDingTalkConnectSyncCorpEmailAttrKey] = settings.DingTalkConnectSyncCorpEmailAttrKey
|
||||
updates[SettingKeyDingTalkConnectSyncDisplayNameAttrKey] = settings.DingTalkConnectSyncDisplayNameAttrKey
|
||||
updates[SettingKeyDingTalkConnectSyncDeptAttrKey] = settings.DingTalkConnectSyncDeptAttrKey
|
||||
updates[SettingKeyDingTalkConnectSyncCorpEmailAttrName] = settings.DingTalkConnectSyncCorpEmailAttrName
|
||||
updates[SettingKeyDingTalkConnectSyncDisplayNameAttrName] = settings.DingTalkConnectSyncDisplayNameAttrName
|
||||
updates[SettingKeyDingTalkConnectSyncDeptAttrName] = settings.DingTalkConnectSyncDeptAttrName
|
||||
|
||||
// Generic OIDC OAuth 登录
|
||||
updates[SettingKeyOIDCConnectEnabled] = strconv.FormatBool(settings.OIDCConnectEnabled)
|
||||
updates[SettingKeyOIDCConnectProviderName] = settings.OIDCConnectProviderName
|
||||
updates[SettingKeyOIDCConnectClientID] = settings.OIDCConnectClientID
|
||||
updates[SettingKeyOIDCConnectIssuerURL] = settings.OIDCConnectIssuerURL
|
||||
updates[SettingKeyOIDCConnectDiscoveryURL] = settings.OIDCConnectDiscoveryURL
|
||||
updates[SettingKeyOIDCConnectAuthorizeURL] = settings.OIDCConnectAuthorizeURL
|
||||
updates[SettingKeyOIDCConnectTokenURL] = settings.OIDCConnectTokenURL
|
||||
updates[SettingKeyOIDCConnectUserInfoURL] = settings.OIDCConnectUserInfoURL
|
||||
updates[SettingKeyOIDCConnectJWKSURL] = settings.OIDCConnectJWKSURL
|
||||
updates[SettingKeyOIDCConnectScopes] = settings.OIDCConnectScopes
|
||||
updates[SettingKeyOIDCConnectRedirectURL] = settings.OIDCConnectRedirectURL
|
||||
updates[SettingKeyOIDCConnectFrontendRedirectURL] = settings.OIDCConnectFrontendRedirectURL
|
||||
updates[SettingKeyOIDCConnectTokenAuthMethod] = settings.OIDCConnectTokenAuthMethod
|
||||
updates[SettingKeyOIDCConnectUsePKCE] = strconv.FormatBool(settings.OIDCConnectUsePKCE)
|
||||
updates[SettingKeyOIDCConnectValidateIDToken] = strconv.FormatBool(settings.OIDCConnectValidateIDToken)
|
||||
updates[SettingKeyOIDCConnectAllowedSigningAlgs] = settings.OIDCConnectAllowedSigningAlgs
|
||||
updates[SettingKeyOIDCConnectClockSkewSeconds] = strconv.Itoa(settings.OIDCConnectClockSkewSeconds)
|
||||
updates[SettingKeyOIDCConnectRequireEmailVerified] = strconv.FormatBool(settings.OIDCConnectRequireEmailVerified)
|
||||
updates[SettingKeyOIDCConnectUserInfoEmailPath] = settings.OIDCConnectUserInfoEmailPath
|
||||
updates[SettingKeyOIDCConnectUserInfoIDPath] = settings.OIDCConnectUserInfoIDPath
|
||||
updates[SettingKeyOIDCConnectUserInfoUsernamePath] = settings.OIDCConnectUserInfoUsernamePath
|
||||
if settings.OIDCConnectClientSecret != "" {
|
||||
updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret
|
||||
}
|
||||
|
||||
// GitHub / Google 邮箱快捷登录
|
||||
updates[SettingKeyGitHubOAuthEnabled] = strconv.FormatBool(settings.GitHubOAuthEnabled)
|
||||
updates[SettingKeyGitHubOAuthClientID] = strings.TrimSpace(settings.GitHubOAuthClientID)
|
||||
updates[SettingKeyGitHubOAuthRedirectURL] = settings.GitHubOAuthRedirectURL
|
||||
updates[SettingKeyGitHubOAuthFrontendRedirectURL] = settings.GitHubOAuthFrontendRedirectURL
|
||||
if settings.GitHubOAuthClientSecret != "" {
|
||||
updates[SettingKeyGitHubOAuthClientSecret] = strings.TrimSpace(settings.GitHubOAuthClientSecret)
|
||||
}
|
||||
updates[SettingKeyGoogleOAuthEnabled] = strconv.FormatBool(settings.GoogleOAuthEnabled)
|
||||
updates[SettingKeyGoogleOAuthClientID] = strings.TrimSpace(settings.GoogleOAuthClientID)
|
||||
updates[SettingKeyGoogleOAuthRedirectURL] = settings.GoogleOAuthRedirectURL
|
||||
updates[SettingKeyGoogleOAuthFrontendRedirectURL] = settings.GoogleOAuthFrontendRedirectURL
|
||||
if settings.GoogleOAuthClientSecret != "" {
|
||||
updates[SettingKeyGoogleOAuthClientSecret] = strings.TrimSpace(settings.GoogleOAuthClientSecret)
|
||||
}
|
||||
|
||||
// WeChat Connect OAuth 登录
|
||||
updates[SettingKeyWeChatConnectEnabled] = strconv.FormatBool(settings.WeChatConnectEnabled)
|
||||
updates[SettingKeyWeChatConnectAppID] = settings.WeChatConnectAppID
|
||||
updates[SettingKeyWeChatConnectOpenAppID] = settings.WeChatConnectOpenAppID
|
||||
updates[SettingKeyWeChatConnectMPAppID] = settings.WeChatConnectMPAppID
|
||||
updates[SettingKeyWeChatConnectMobileAppID] = settings.WeChatConnectMobileAppID
|
||||
updates[SettingKeyWeChatConnectOpenEnabled] = strconv.FormatBool(settings.WeChatConnectOpenEnabled)
|
||||
updates[SettingKeyWeChatConnectMPEnabled] = strconv.FormatBool(settings.WeChatConnectMPEnabled)
|
||||
updates[SettingKeyWeChatConnectMobileEnabled] = strconv.FormatBool(settings.WeChatConnectMobileEnabled)
|
||||
updates[SettingKeyWeChatConnectMode] = settings.WeChatConnectMode
|
||||
updates[SettingKeyWeChatConnectScopes] = settings.WeChatConnectScopes
|
||||
updates[SettingKeyWeChatConnectRedirectURL] = settings.WeChatConnectRedirectURL
|
||||
updates[SettingKeyWeChatConnectFrontendRedirectURL] = settings.WeChatConnectFrontendRedirectURL
|
||||
if settings.WeChatConnectAppSecret != "" {
|
||||
updates[SettingKeyWeChatConnectAppSecret] = settings.WeChatConnectAppSecret
|
||||
}
|
||||
if settings.WeChatConnectOpenAppSecret != "" {
|
||||
updates[SettingKeyWeChatConnectOpenAppSecret] = settings.WeChatConnectOpenAppSecret
|
||||
}
|
||||
if settings.WeChatConnectMPAppSecret != "" {
|
||||
updates[SettingKeyWeChatConnectMPAppSecret] = settings.WeChatConnectMPAppSecret
|
||||
}
|
||||
if settings.WeChatConnectMobileAppSecret != "" {
|
||||
updates[SettingKeyWeChatConnectMobileAppSecret] = settings.WeChatConnectMobileAppSecret
|
||||
}
|
||||
|
||||
// OEM设置
|
||||
updates[SettingKeySiteName] = settings.SiteName
|
||||
updates[SettingKeySiteLogo] = settings.SiteLogo
|
||||
updates[SettingKeySiteSubtitle] = settings.SiteSubtitle
|
||||
updates[SettingKeyAPIBaseURL] = settings.APIBaseURL
|
||||
updates[SettingKeyContactInfo] = settings.ContactInfo
|
||||
updates[SettingKeyDocURL] = settings.DocURL
|
||||
updates[SettingKeyHomeContent] = settings.HomeContent
|
||||
updates[SettingKeyHideCcsImportButton] = strconv.FormatBool(settings.HideCcsImportButton)
|
||||
updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled)
|
||||
updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL)
|
||||
tableDefaultPageSize, tablePageSizeOptions := normalizeTablePreferences(
|
||||
settings.TableDefaultPageSize,
|
||||
settings.TablePageSizeOptions,
|
||||
)
|
||||
updates[SettingKeyTableDefaultPageSize] = strconv.Itoa(tableDefaultPageSize)
|
||||
tablePageSizeOptionsJSON, err := json.Marshal(tablePageSizeOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal table page size options: %w", err)
|
||||
}
|
||||
updates[SettingKeyTablePageSizeOptions] = string(tablePageSizeOptionsJSON)
|
||||
updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems
|
||||
updates[SettingKeyCustomEndpoints] = settings.CustomEndpoints
|
||||
|
||||
// 默认配置
|
||||
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
|
||||
updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64)
|
||||
settings.AffiliateRebateRate = clampAffiliateRebateRate(settings.AffiliateRebateRate)
|
||||
updates[SettingKeyAffiliateRebateRate] = strconv.FormatFloat(settings.AffiliateRebateRate, 'f', 8, 64)
|
||||
if settings.AffiliateRebateFreezeHours < 0 {
|
||||
settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursDefault
|
||||
}
|
||||
if settings.AffiliateRebateFreezeHours > AffiliateRebateFreezeHoursMax {
|
||||
settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursMax
|
||||
}
|
||||
updates[SettingKeyAffiliateRebateFreezeHours] = strconv.Itoa(settings.AffiliateRebateFreezeHours)
|
||||
if settings.AffiliateRebateDurationDays < 0 {
|
||||
settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysDefault
|
||||
}
|
||||
if settings.AffiliateRebateDurationDays > AffiliateRebateDurationDaysMax {
|
||||
settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysMax
|
||||
}
|
||||
updates[SettingKeyAffiliateRebateDurationDays] = strconv.Itoa(settings.AffiliateRebateDurationDays)
|
||||
if settings.AffiliateRebatePerInviteeCap < 0 {
|
||||
settings.AffiliateRebatePerInviteeCap = AffiliateRebatePerInviteeCapDefault
|
||||
}
|
||||
updates[SettingKeyAffiliateRebatePerInviteeCap] = strconv.FormatFloat(settings.AffiliateRebatePerInviteeCap, 'f', 8, 64)
|
||||
updates[SettingKeyDefaultUserRPMLimit] = strconv.Itoa(settings.DefaultUserRPMLimit)
|
||||
defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal default subscriptions: %w", err)
|
||||
}
|
||||
updates[SettingKeyDefaultSubscriptions] = string(defaultSubsJSON)
|
||||
|
||||
// Model fallback configuration
|
||||
updates[SettingKeyEnableModelFallback] = strconv.FormatBool(settings.EnableModelFallback)
|
||||
updates[SettingKeyFallbackModelAnthropic] = settings.FallbackModelAnthropic
|
||||
updates[SettingKeyFallbackModelOpenAI] = settings.FallbackModelOpenAI
|
||||
updates[SettingKeyFallbackModelGemini] = settings.FallbackModelGemini
|
||||
updates[SettingKeyFallbackModelAntigravity] = settings.FallbackModelAntigravity
|
||||
|
||||
// Identity patch configuration (Claude -> Gemini)
|
||||
updates[SettingKeyEnableIdentityPatch] = strconv.FormatBool(settings.EnableIdentityPatch)
|
||||
updates[SettingKeyIdentityPatchPrompt] = settings.IdentityPatchPrompt
|
||||
|
||||
// Ops monitoring (vNext)
|
||||
updates[SettingKeyOpsMonitoringEnabled] = strconv.FormatBool(settings.OpsMonitoringEnabled)
|
||||
updates[SettingKeyOpsRealtimeMonitoringEnabled] = strconv.FormatBool(settings.OpsRealtimeMonitoringEnabled)
|
||||
updates[SettingKeyOpsQueryModeDefault] = string(ParseOpsQueryMode(settings.OpsQueryModeDefault))
|
||||
if settings.OpsMetricsIntervalSeconds > 0 {
|
||||
updates[SettingKeyOpsMetricsIntervalSeconds] = strconv.Itoa(settings.OpsMetricsIntervalSeconds)
|
||||
}
|
||||
|
||||
// Channel monitor feature switch
|
||||
updates[SettingKeyChannelMonitorEnabled] = strconv.FormatBool(settings.ChannelMonitorEnabled)
|
||||
if v := clampChannelMonitorInterval(settings.ChannelMonitorDefaultIntervalSeconds); v > 0 {
|
||||
updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v)
|
||||
}
|
||||
|
||||
// Available channels feature switch
|
||||
updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled)
|
||||
|
||||
// Affiliate (邀请返利) feature switch
|
||||
updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled)
|
||||
|
||||
// 风控中心功能开关
|
||||
updates[SettingKeyRiskControlEnabled] = strconv.FormatBool(settings.RiskControlEnabled)
|
||||
|
||||
// cyber 会话屏蔽开关 + TTL
|
||||
updates[SettingKeyCyberSessionBlockEnabled] = strconv.FormatBool(settings.CyberSessionBlockEnabled)
|
||||
if settings.CyberSessionBlockTTLSeconds > 0 {
|
||||
updates[SettingKeyCyberSessionBlockTTLSeconds] = strconv.Itoa(settings.CyberSessionBlockTTLSeconds)
|
||||
}
|
||||
|
||||
// Claude Code version check
|
||||
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
|
||||
updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion
|
||||
|
||||
// 分组隔离
|
||||
updates[SettingKeyAllowUngroupedKeyScheduling] = strconv.FormatBool(settings.AllowUngroupedKeyScheduling)
|
||||
|
||||
// Backend Mode
|
||||
updates[SettingKeyBackendModeEnabled] = strconv.FormatBool(settings.BackendModeEnabled)
|
||||
|
||||
// Gateway forwarding behavior
|
||||
updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification)
|
||||
updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough)
|
||||
updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning)
|
||||
updates[SettingKeyEnableClaudeOAuthSystemPromptInjection] = strconv.FormatBool(settings.EnableClaudeOAuthSystemPromptInjection)
|
||||
updates[SettingKeyClaudeOAuthSystemPrompt] = settings.ClaudeOAuthSystemPrompt
|
||||
if err := ValidateClaudeOAuthSystemPromptBlocksConfig(settings.ClaudeOAuthSystemPromptBlocks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updates[SettingKeyClaudeOAuthSystemPromptBlocks] = settings.ClaudeOAuthSystemPromptBlocks
|
||||
updates[SettingKeyEnableAnthropicCacheTTL1hInjection] = strconv.FormatBool(settings.EnableAnthropicCacheTTL1hInjection)
|
||||
updates[SettingKeyRewriteMessageCacheControl] = strconv.FormatBool(settings.RewriteMessageCacheControl)
|
||||
updates[SettingKeyEnableClientDatelineNormalization] = strconv.FormatBool(settings.EnableClientDatelineNormalization)
|
||||
updates[SettingKeyAntigravityUserAgentVersion] = antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion)
|
||||
updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent)
|
||||
// codex_cli_only 加固
|
||||
updates[SettingKeyMinCodexVersion] = strings.TrimSpace(settings.MinCodexVersion)
|
||||
updates[SettingKeyMaxCodexVersion] = strings.TrimSpace(settings.MaxCodexVersion)
|
||||
updates[SettingKeyCodexCLIOnlyBlacklist] = strings.TrimSpace(settings.CodexCLIOnlyBlacklist)
|
||||
updates[SettingKeyCodexCLIOnlyWhitelist] = strings.TrimSpace(settings.CodexCLIOnlyWhitelist)
|
||||
updates[SettingKeyCodexCLIOnlyAllowAppServerClients] = strconv.FormatBool(settings.CodexCLIOnlyAllowAppServerClients)
|
||||
updates[SettingKeyCodexCLIOnlyEngineFingerprintSignals] = strings.TrimSpace(settings.CodexCLIOnlyEngineFingerprintSignals)
|
||||
updates[SettingPaymentVisibleMethodAlipaySource] = settings.PaymentVisibleMethodAlipaySource
|
||||
updates[SettingPaymentVisibleMethodWxpaySource] = settings.PaymentVisibleMethodWxpaySource
|
||||
updates[SettingPaymentVisibleMethodAlipayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodAlipayEnabled)
|
||||
updates[SettingPaymentVisibleMethodWxpayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodWxpayEnabled)
|
||||
updates[openAIAdvancedSchedulerSettingKey] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerEnabled)
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerStickyWeightedEnabled)
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled)
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerLBTopK] = settings.OpenAIAdvancedSchedulerLBTopK
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightPriority] = settings.OpenAIAdvancedSchedulerWeightPriority
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightLoad] = settings.OpenAIAdvancedSchedulerWeightLoad
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightQueue] = settings.OpenAIAdvancedSchedulerWeightQueue
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightErrorRate] = settings.OpenAIAdvancedSchedulerWeightErrorRate
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightTTFT] = settings.OpenAIAdvancedSchedulerWeightTTFT
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightReset] = settings.OpenAIAdvancedSchedulerWeightReset
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom] = settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse] = settings.OpenAIAdvancedSchedulerWeightPreviousResponse
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky] = settings.OpenAIAdvancedSchedulerWeightSessionSticky
|
||||
|
||||
// 余额、订阅到期与账号限额通知
|
||||
updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled)
|
||||
updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64)
|
||||
updates[SettingKeyBalanceLowNotifyRechargeURL] = settings.BalanceLowNotifyRechargeURL
|
||||
updates[SettingKeySubscriptionExpiryNotifyEnabled] = strconv.FormatBool(settings.SubscriptionExpiryNotifyEnabled)
|
||||
updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled)
|
||||
updates[SettingKeyAccountQuotaNotifyEmails] = MarshalNotifyEmails(settings.AccountQuotaNotifyEmails)
|
||||
|
||||
// 系统全局 platform quota:整体替换语义(null/缺省 = 不限制)。
|
||||
if settings.DefaultPlatformQuotas != nil {
|
||||
if err := validateDefaultPlatformQuotaMap(settings.DefaultPlatformQuotas); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blob, err := json.Marshal(settings.DefaultPlatformQuotas)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal default platform quotas: %w", err)
|
||||
}
|
||||
updates[SettingKeyDefaultPlatformQuotas] = string(blob)
|
||||
}
|
||||
|
||||
updates[SettingKeyAllowUserViewErrorRequests] = strconv.FormatBool(settings.AllowUserViewErrorRequests)
|
||||
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
// validateDefaultPlatformQuotaMap 校验 platform quota map 的合法性:
|
||||
// 平台名须在 AllowedQuotaPlatforms 白名单内,每个非 nil 上限须 finite 且 >= 0。
|
||||
// 系统层和 auth-source 层共用此 helper。
|
||||
func validateDefaultPlatformQuotaMap(m map[string]*DefaultPlatformQuotaSetting) error {
|
||||
for platform, pq := range m {
|
||||
if !IsAllowedQuotaPlatform(platform) {
|
||||
return infraerrors.BadRequest("INVALID_DEFAULT_PLATFORM_QUOTA", fmt.Sprintf("unknown platform %q", platform))
|
||||
}
|
||||
if pq == nil {
|
||||
continue
|
||||
}
|
||||
for _, v := range []*float64{pq.DailyLimitUSD, pq.WeeklyLimitUSD, pq.MonthlyLimitUSD} {
|
||||
if v != nil && (*v < 0 || math.IsNaN(*v) || math.IsInf(*v, 0)) {
|
||||
return infraerrors.BadRequest("INVALID_DEFAULT_PLATFORM_QUOTA", "platform quota limit must be a finite non-negative number")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, settings *AuthSourceDefaultSettings) (map[string]string, error) {
|
||||
if settings == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
for _, subscriptions := range [][]DefaultSubscriptionSetting{
|
||||
settings.Email.Subscriptions,
|
||||
settings.LinuxDo.Subscriptions,
|
||||
settings.OIDC.Subscriptions,
|
||||
settings.WeChat.Subscriptions,
|
||||
settings.GitHub.Subscriptions,
|
||||
settings.Google.Subscriptions,
|
||||
settings.DingTalk.Subscriptions,
|
||||
} {
|
||||
if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 校验各 auth source 的 platform quota map(改动 C:对等系统层校验)
|
||||
for _, pgs := range []struct {
|
||||
name string
|
||||
pq map[string]*DefaultPlatformQuotaSetting
|
||||
}{
|
||||
{"email", settings.Email.PlatformQuotas},
|
||||
{"linuxdo", settings.LinuxDo.PlatformQuotas},
|
||||
{"oidc", settings.OIDC.PlatformQuotas},
|
||||
{"wechat", settings.WeChat.PlatformQuotas},
|
||||
{"github", settings.GitHub.PlatformQuotas},
|
||||
{"google", settings.Google.PlatformQuotas},
|
||||
{"dingtalk", settings.DingTalk.PlatformQuotas},
|
||||
} {
|
||||
if pgs.pq != nil {
|
||||
if err := validateDefaultPlatformQuotaMap(pgs.pq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updates := make(map[string]string, 36)
|
||||
writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email)
|
||||
writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo)
|
||||
writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC)
|
||||
writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat)
|
||||
writeProviderDefaultGrantUpdates(updates, gitHubAuthSourceDefaultKeys, settings.GitHub)
|
||||
writeProviderDefaultGrantUpdates(updates, googleAuthSourceDefaultKeys, settings.Google)
|
||||
writeProviderDefaultGrantUpdates(updates, dingTalkAuthSourceDefaultKeys, settings.DingTalk)
|
||||
updates[SettingKeyForceEmailOnThirdPartySignup] = strconv.FormatBool(settings.ForceEmailOnThirdPartySignup)
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func (s *SettingService) refreshCachedSettings(settings *SystemSettings) {
|
||||
if settings == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口
|
||||
versionBoundsSF.Forget("version_bounds")
|
||||
versionBoundsCache.Store(&cachedVersionBounds{
|
||||
min: settings.MinClaudeCodeVersion,
|
||||
max: settings.MaxClaudeCodeVersion,
|
||||
expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(),
|
||||
})
|
||||
backendModeSF.Forget("backend_mode")
|
||||
backendModeCache.Store(&cachedBackendMode{
|
||||
value: settings.BackendModeEnabled,
|
||||
expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(),
|
||||
})
|
||||
gatewayForwardingSF.Forget("gateway_forwarding")
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||||
fingerprintUnification: settings.EnableFingerprintUnification,
|
||||
metadataPassthrough: settings.EnableMetadataPassthrough,
|
||||
cchSigning: settings.EnableCCHSigning,
|
||||
claudeOAuthSystemPromptInjection: settings.EnableClaudeOAuthSystemPromptInjection,
|
||||
claudeOAuthSystemPrompt: settings.ClaudeOAuthSystemPrompt,
|
||||
claudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks,
|
||||
anthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
|
||||
rewriteMessageCacheControl: settings.RewriteMessageCacheControl,
|
||||
clientDatelineNormalization: settings.EnableClientDatelineNormalization,
|
||||
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
||||
})
|
||||
s.antigravityUAVersionSF.Forget("antigravity_user_agent_version")
|
||||
antigravityUserAgentVersion := antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion)
|
||||
if antigravityUserAgentVersion == "" {
|
||||
antigravityUserAgentVersion = antigravity.GetDefaultUserAgentVersion()
|
||||
}
|
||||
s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{
|
||||
version: antigravityUserAgentVersion,
|
||||
expiresAt: time.Now().Add(antigravityUserAgentVersionCacheTTL).UnixNano(),
|
||||
})
|
||||
s.openAICodexUASF.Forget("openai_codex_user_agent")
|
||||
codexUA := strings.TrimSpace(settings.OpenAICodexUserAgent)
|
||||
if codexUA == "" {
|
||||
codexUA = DefaultOpenAICodexUserAgent
|
||||
}
|
||||
s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{
|
||||
value: codexUA,
|
||||
expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(),
|
||||
})
|
||||
openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey)
|
||||
openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{
|
||||
enabled: settings.OpenAIAdvancedSchedulerEnabled,
|
||||
stickyWeightedEnabled: settings.OpenAIAdvancedSchedulerStickyWeightedEnabled,
|
||||
subscriptionPriorityEnabled: settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled,
|
||||
lbTopKOverride: parsePositiveIntOverride(settings.OpenAIAdvancedSchedulerLBTopK),
|
||||
weightOverrides: parseOpenAIAdvancedSchedulerWeightOverrides(map[string]string{
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightPriority: settings.OpenAIAdvancedSchedulerWeightPriority,
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightLoad: settings.OpenAIAdvancedSchedulerWeightLoad,
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightQueue: settings.OpenAIAdvancedSchedulerWeightQueue,
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightErrorRate: settings.OpenAIAdvancedSchedulerWeightErrorRate,
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightTTFT: settings.OpenAIAdvancedSchedulerWeightTTFT,
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightReset: settings.OpenAIAdvancedSchedulerWeightReset,
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom,
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse: settings.OpenAIAdvancedSchedulerWeightPreviousResponse,
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: settings.OpenAIAdvancedSchedulerWeightSessionSticky,
|
||||
}),
|
||||
expiresAt: time.Now().Add(openAIAdvancedSchedulerSettingCacheTTL).UnixNano(),
|
||||
})
|
||||
// Invalidate the quota auto-pause cache and let the next read trigger a fresh load.
|
||||
// We can't know from here whether ops_advanced_settings was also touched, so be
|
||||
// defensive: store an expired entry — GetOpenAIQuotaAutoPauseSettings will serve
|
||||
// stale and kick off an async refresh, never blocking the request that follows.
|
||||
s.openAIQuotaAutoPauseSettingsSF.Forget(openAIQuotaAutoPauseSettingsRefreshKey)
|
||||
if cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings); cached != nil {
|
||||
s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{
|
||||
settings: cached.settings,
|
||||
expiresAt: 0,
|
||||
})
|
||||
}
|
||||
if s.cfg != nil {
|
||||
s.cfg.SetTrustForwardedIPForAPIKeyACL(settings.APIKeyACLTrustForwardedIP)
|
||||
}
|
||||
// codex_cli_only 加固策略缓存:设置更新后强制下次重载(涉及 4 个键 + JSON 解析,直接置过期)。
|
||||
s.codexRestrictionPolicySF.Forget("codex_restriction_policy")
|
||||
s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0})
|
||||
if s.onUpdate != nil {
|
||||
s.onUpdate() // Invalidate cache after settings update
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SettingService) defaultRewriteMessageCacheControl() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *SettingService) validateDefaultSubscriptionGroups(ctx context.Context, items []DefaultSubscriptionSetting) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
checked := make(map[int64]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
if item.GroupID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := checked[item.GroupID]; ok {
|
||||
return ErrDefaultSubGroupDuplicate.WithMetadata(map[string]string{
|
||||
"group_id": strconv.FormatInt(item.GroupID, 10),
|
||||
})
|
||||
}
|
||||
checked[item.GroupID] = struct{}{}
|
||||
if s.defaultSubGroupReader == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
group, err := s.defaultSubGroupReader.GetByID(ctx, item.GroupID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrGroupNotFound) {
|
||||
return ErrDefaultSubGroupInvalid.WithMetadata(map[string]string{
|
||||
"group_id": strconv.FormatInt(item.GroupID, 10),
|
||||
})
|
||||
}
|
||||
return fmt.Errorf("get default subscription group %d: %w", item.GroupID, err)
|
||||
}
|
||||
if !group.IsSubscriptionType() {
|
||||
return ErrDefaultSubGroupInvalid.WithMetadata(map[string]string{
|
||||
"group_id": strconv.FormatInt(item.GroupID, 10),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import enAdminAccounts from '../locales/en/admin/accounts'
|
||||
import enAdminChannels from '../locales/en/admin/channels'
|
||||
import enAdminOps from '../locales/en/admin/ops'
|
||||
import enAdminOverview from '../locales/en/admin/overview'
|
||||
import enAdminResources from '../locales/en/admin/resources'
|
||||
import enAdminSettings from '../locales/en/admin/settings'
|
||||
import enCommon from '../locales/en/common'
|
||||
import enDashboard from '../locales/en/dashboard'
|
||||
import enLanding from '../locales/en/landing'
|
||||
import enMisc from '../locales/en/misc'
|
||||
import zhAdminAccounts from '../locales/zh/admin/accounts'
|
||||
import zhAdminChannels from '../locales/zh/admin/channels'
|
||||
import zhAdminOps from '../locales/zh/admin/ops'
|
||||
import zhAdminOverview from '../locales/zh/admin/overview'
|
||||
import zhAdminResources from '../locales/zh/admin/resources'
|
||||
import zhAdminSettings from '../locales/zh/admin/settings'
|
||||
import zhCommon from '../locales/zh/common'
|
||||
import zhDashboard from '../locales/zh/dashboard'
|
||||
import zhLanding from '../locales/zh/landing'
|
||||
import zhMisc from '../locales/zh/misc'
|
||||
|
||||
// locales/{zh,en}/index.ts 与 admin/index.ts 使用对象展开聚合各域模块,
|
||||
// 展开模块之间若出现同名顶层键会静默覆盖。本测试将该风险固化为显式失败。
|
||||
type Modules = Record<string, Record<string, unknown>>
|
||||
|
||||
function collisions(modules: Modules): string[] {
|
||||
const seen = new Map<string, string>()
|
||||
const out: string[] = []
|
||||
for (const [name, mod] of Object.entries(modules)) {
|
||||
for (const key of Object.keys(mod)) {
|
||||
const prev = seen.get(key)
|
||||
if (prev) {
|
||||
out.push(`"${key}" in both ${prev} and ${name}`)
|
||||
} else {
|
||||
seen.set(key, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const roots: Record<string, Modules> = {
|
||||
zh: { landing: zhLanding, common: zhCommon, dashboard: zhDashboard, misc: zhMisc },
|
||||
en: { landing: enLanding, common: enCommon, dashboard: enDashboard, misc: enMisc }
|
||||
}
|
||||
|
||||
const admins: Record<string, Modules> = {
|
||||
zh: {
|
||||
overview: zhAdminOverview,
|
||||
channels: zhAdminChannels,
|
||||
accounts: zhAdminAccounts,
|
||||
resources: zhAdminResources,
|
||||
ops: zhAdminOps,
|
||||
settings: zhAdminSettings
|
||||
},
|
||||
en: {
|
||||
overview: enAdminOverview,
|
||||
channels: enAdminChannels,
|
||||
accounts: enAdminAccounts,
|
||||
resources: enAdminResources,
|
||||
ops: enAdminOps,
|
||||
settings: enAdminSettings
|
||||
}
|
||||
}
|
||||
|
||||
describe.each(Object.keys(roots))('locale %s spread assembly', (locale) => {
|
||||
it('root modules have no overlapping top-level keys', () => {
|
||||
expect(collisions(roots[locale])).toEqual([])
|
||||
})
|
||||
|
||||
it('root modules do not shadow the explicit "admin" namespace', () => {
|
||||
for (const [name, mod] of Object.entries(roots[locale])) {
|
||||
expect(Object.keys(mod), `module ${name} must not define "admin"`).not.toContain('admin')
|
||||
}
|
||||
})
|
||||
|
||||
it('admin modules have no overlapping top-level keys', () => {
|
||||
expect(collisions(admins[locale])).toEqual([])
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,714 @@
|
||||
export default {
|
||||
availableChannels: {
|
||||
title: 'Available Channels',
|
||||
description: 'Aggregated view: each channel with its linked groups and supported models (wildcards expanded)',
|
||||
searchPlaceholder: 'Search channels or models...',
|
||||
columns: {
|
||||
name: 'Channel',
|
||||
status: 'Status',
|
||||
billingSource: 'Billing Model Source',
|
||||
groups: 'Linked Groups',
|
||||
supportedModels: 'Supported Models'
|
||||
},
|
||||
empty: 'No data',
|
||||
noGroups: 'No linked groups',
|
||||
noModels: 'No model mapping configured',
|
||||
noPricing: 'Pricing not configured',
|
||||
statusActive: 'Active',
|
||||
statusDisabled: 'Disabled',
|
||||
billingSource: {
|
||||
requested: 'Requested model',
|
||||
upstream: 'Upstream model',
|
||||
channel_mapped: 'Channel-mapped model'
|
||||
},
|
||||
pricing: {
|
||||
billingMode: 'Billing Mode',
|
||||
billingModeToken: 'Per Token',
|
||||
billingModePerRequest: 'Per Request',
|
||||
billingModeImage: 'Per Image',
|
||||
inputPrice: 'Input',
|
||||
outputPrice: 'Output',
|
||||
cacheWritePrice: 'Cache Write',
|
||||
cacheReadPrice: 'Cache Read',
|
||||
imageOutputPrice: 'Image Output',
|
||||
perRequestPrice: 'Per Request',
|
||||
intervals: 'Tiered Pricing',
|
||||
unitPerMillion: '/ 1M tokens',
|
||||
unitPerRequest: '/ request'
|
||||
}
|
||||
},
|
||||
|
||||
// Channel Management
|
||||
channels: {
|
||||
title: 'Channel Management',
|
||||
description: 'Manage channels and custom model pricing',
|
||||
searchChannels: 'Search channels...',
|
||||
createChannel: 'Create Channel',
|
||||
editChannel: 'Edit Channel',
|
||||
deleteChannel: 'Delete Channel',
|
||||
statusActive: 'Active',
|
||||
statusDisabled: 'Disabled',
|
||||
allStatus: 'All Status',
|
||||
groupsUnit: 'groups',
|
||||
pricingUnit: 'pricing rules',
|
||||
noChannelsYet: 'No Channels Yet',
|
||||
createFirstChannel: 'Create your first channel to manage model pricing',
|
||||
loadError: 'Failed to load channels',
|
||||
createSuccess: 'Channel created',
|
||||
updateSuccess: 'Channel updated',
|
||||
deleteSuccess: 'Channel deleted',
|
||||
createError: 'Failed to create channel',
|
||||
updateError: 'Failed to update channel',
|
||||
deleteError: 'Failed to delete channel',
|
||||
nameRequired: 'Please enter a channel name',
|
||||
duplicateModels: 'Model "{0}" appears in multiple pricing entries',
|
||||
modelConflict: "Model patterns '{model1}' and '{model2}' conflict: overlapping match range. Model names are matched case-insensitively, so an existing entry already covers all case variants — no need to add the variant separately.",
|
||||
mappingConflict: "Mapping source patterns '{model1}' and '{model2}' conflict: overlapping match range. Source patterns are matched case-insensitively, so an existing entry already covers all case variants.",
|
||||
intervalValidation: {
|
||||
negativeMin: 'Interval #{index}: minimum token count ({value}) cannot be negative',
|
||||
maxPositive: 'Interval #{index}: maximum token count ({value}) must be greater than 0',
|
||||
maxGreaterThanMin: 'Interval #{index}: maximum token count ({max}) must be greater than minimum token count ({min})',
|
||||
negativePrice: 'Interval #{index}: {field} cannot be negative',
|
||||
unboundedLast: 'Interval #{index}: an unbounded interval (empty maximum token count) must be last',
|
||||
overlap: 'Intervals #{previousIndex} and #{currentIndex} overlap: previous upper bound ({previousMax}) is greater than current lower bound ({currentMin})',
|
||||
price: {
|
||||
inputPrice: 'input price',
|
||||
outputPrice: 'output price',
|
||||
cacheWritePrice: 'cache write price',
|
||||
cacheReadPrice: 'cache read price',
|
||||
perRequestPrice: 'per-request price'
|
||||
}
|
||||
},
|
||||
deleteConfirm: 'Are you sure you want to delete channel "{name}"? This cannot be undone.',
|
||||
columns: {
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
status: 'Status',
|
||||
groups: 'Groups',
|
||||
pricing: 'Pricing',
|
||||
createdAt: 'Created',
|
||||
actions: 'Actions'
|
||||
},
|
||||
billingMode: {
|
||||
token: 'Token',
|
||||
perRequest: 'Per Request',
|
||||
image: 'Image (Per Request)'
|
||||
},
|
||||
form: {
|
||||
name: 'Name',
|
||||
namePlaceholder: 'Enter channel name',
|
||||
description: 'Description',
|
||||
descriptionPlaceholder: 'Optional description',
|
||||
status: 'Status',
|
||||
groups: 'Associated Groups',
|
||||
noGroupsAvailable: 'No groups available',
|
||||
inOtherChannel: 'In "{name}"',
|
||||
modelPricing: 'Model Pricing',
|
||||
models: 'Models',
|
||||
modelsPlaceholder: 'Type full model name and press Enter',
|
||||
modelInputHint: 'Press Enter to add, supports paste for batch import.',
|
||||
billingMode: 'Billing Mode',
|
||||
defaultPrices: 'Default prices (fallback when no interval matches)',
|
||||
inputPrice: 'Input',
|
||||
outputPrice: 'Output',
|
||||
cacheWritePrice: 'Cache Write',
|
||||
cacheReadPrice: 'Cache Read',
|
||||
cacheWritePriceShort: 'Cache W',
|
||||
cacheReadPriceShort: 'Cache R',
|
||||
imageTokenPrice: 'Image Output',
|
||||
imageOutputPrice: 'Image Output Price',
|
||||
pricePlaceholder: 'Default',
|
||||
intervals: 'Context Intervals (optional)',
|
||||
minTokens: 'Min',
|
||||
maxTokens: 'Max',
|
||||
inclusive: '(inclusive)',
|
||||
addInterval: 'Add Interval',
|
||||
requestTiers: 'Request Tiers',
|
||||
imageTiers: 'Image Tiers (Per Request)',
|
||||
addTier: 'Add Tier',
|
||||
noTiersYet: 'No tiers yet. Click add to configure per-request pricing.',
|
||||
noPricingRules: 'No pricing rules yet. Click "Add" to create one.',
|
||||
perRequestPrice: 'Price per Request',
|
||||
perRequestPriceRequired: 'Per-request price or billing tiers required for per-request/image billing mode',
|
||||
tierLabel: 'Tier',
|
||||
resolution: 'Resolution',
|
||||
modelMapping: 'Model Mapping',
|
||||
modelMappingHint: 'Map request model names to actual model names. Runs before account-level mapping.',
|
||||
noMappingRules: 'No mapping rules. Click "Add" to create one.',
|
||||
mappingSource: 'Source model',
|
||||
mappingTarget: 'Target model',
|
||||
billingModelSource: 'Billing Model',
|
||||
billingModelSourceChannelMapped: 'Bill by channel-mapped model',
|
||||
billingModelSourceRequested: 'Bill by requested model',
|
||||
billingModelSourceUpstream: 'Bill by final upstream model',
|
||||
billingModelSourceHint: 'Controls which model name is used for pricing lookup',
|
||||
selectedCount: '{count} selected',
|
||||
searchGroups: 'Search groups...',
|
||||
noGroupsMatch: 'No groups match your search',
|
||||
restrictModels: 'Restrict Models',
|
||||
restrictModelsHint: 'When enabled, only models in the pricing list are allowed. Others will be rejected.',
|
||||
defaultPerRequestPrice: 'Default per-request price (fallback when no tier matches)',
|
||||
defaultImagePrice: 'Default image price (fallback when no tier matches)',
|
||||
platformConfig: 'Platform Configuration',
|
||||
webSearchEmulation: 'Web Search Emulation',
|
||||
webSearchEmulationHint: '⚠️ When enabled, all accounts in this channel\'s Anthropic groups will intercept web_search requests. Use with caution.',
|
||||
webSearchEmulationGlobalDisabled: 'Please enable the global switch first in Settings → Gateway → Web Search Emulation',
|
||||
codexImageGenerationBridge: 'Codex Image Generation Bridge',
|
||||
codexImageGenerationBridgeHint: 'When enabled, Codex /responses text requests in OpenAI groups may be automatically given the image_generation tool. Keep off unless the routed accounts support image generation.',
|
||||
bedrockCCCompat: 'Bedrock CC Compatibility',
|
||||
bedrockCCCompatHint: '⚠️ When enabled, requests to Bedrock accounts in this channel will be transformed for Claude Code compatibility (thinking type conversion, tool_use ID sanitization).',
|
||||
basicSettings: 'Basic Settings',
|
||||
addPlatform: 'Add Platform',
|
||||
noPlatforms: 'Click "Add Platform" to start configuring the channel',
|
||||
mappingCount: 'mappings',
|
||||
pricingEntry: 'Pricing Entry',
|
||||
noModels: 'No models added',
|
||||
applyPricingToAccountStats: 'Apply Pricing to Account Stats',
|
||||
applyPricingToAccountStatsDesc: 'When enabled, requests not matched by custom rules will use standard model pricing for account stats calculation',
|
||||
accountStatsPricingRules: 'Custom Account Stats Pricing Rules',
|
||||
addRule: 'Add Rule',
|
||||
noRulesConfigured: 'No custom rules configured. Channel model pricing above will be used.',
|
||||
ruleName: 'Rule name (optional)',
|
||||
ruleGroups: 'Groups',
|
||||
ruleAccounts: 'Accounts',
|
||||
searchAccountPlaceholder: 'Search accounts...',
|
||||
ruleAccountsHint: 'Leave empty to match all accounts',
|
||||
ruleModelPricing: 'Model Pricing',
|
||||
noGroupsInChannel: 'No groups selected in platform tabs above',
|
||||
unnamed: 'Unnamed',
|
||||
syncLatestModels: 'Sync Latest Models',
|
||||
syncingModels: 'Syncing...',
|
||||
syncModelsSuccess: 'Synced {count} new model(s)',
|
||||
syncModelsAlreadyUpToDate: 'Models already up to date',
|
||||
syncModelsError: 'Failed to sync models'
|
||||
}
|
||||
},
|
||||
|
||||
riskControl: {
|
||||
title: 'Risk Control',
|
||||
description: 'Configure content moderation and review audit records',
|
||||
loadFailed: 'Failed to load risk control',
|
||||
saveFailed: 'Failed to save content moderation config',
|
||||
logsFailed: 'Failed to load audit records',
|
||||
saved: 'Content moderation config saved',
|
||||
refresh: 'Refresh',
|
||||
config: 'Content Moderation Config',
|
||||
configHint: 'Use OpenAI Moderations to score request content and handle threshold hits by mode.',
|
||||
openSettings: 'Moderation Settings',
|
||||
settingsTitle: 'Content Moderation Settings',
|
||||
refreshStatus: 'Refresh Status',
|
||||
records: 'Audit Records',
|
||||
recordsHint: 'Shows hits, blocks, errors, and sampled records.',
|
||||
saveConfig: 'Save Moderation Config',
|
||||
statusFailed: 'Failed to load runtime status',
|
||||
enabled: 'Enable Content Moderation',
|
||||
enabledHint: 'When off, gateway requests are not moderated even if the menu is enabled.',
|
||||
mode: 'Global Mode',
|
||||
modePreBlock: 'Pre-Block',
|
||||
modePreBlockDesc: 'Synchronously reviews the latest user input before every request and rejects hits immediately.',
|
||||
modeObserve: 'Observe Only',
|
||||
modeObserveDesc: 'Requests pass through while the latest user input is queued for async review; hits are recorded, notified, and counted.',
|
||||
modeOff: 'Off',
|
||||
modeOffDesc: 'Content moderation is disabled and no audit records are written.',
|
||||
baseUrl: 'OpenAI Base URL',
|
||||
model: 'Model',
|
||||
apiKey: 'OpenAI API Key',
|
||||
apiKeys: 'OpenAI API Keys',
|
||||
apiKeyCount: '{count} keys',
|
||||
apiKeyPlaceholder: 'Enter API Key',
|
||||
apiKeysPlaceholder: 'Add API Keys, one per line. They will be appended on save.',
|
||||
apiKeysPlaceholderReplace: 'Replace API Keys, one per line. Stored keys will be replaced on save.',
|
||||
apiKeysPlaceholderKeep: 'Add API Keys, one per line. They will be appended on save.',
|
||||
apiKeysHint: '{count} keys are currently stored. This input only adds keys; save appends and de-duplicates them.',
|
||||
apiKeysWriteMode: 'Write mode',
|
||||
apiKeysModeAppend: 'Add',
|
||||
apiKeysModeReplace: 'Replace',
|
||||
apiKeysModeAppendHint: 'Default: save appends input keys and keeps stored keys.',
|
||||
apiKeysModeReplaceHint: 'Replace mode: save replaces all stored keys with input keys.',
|
||||
apiKeysReplaceWarning: 'Replace mode',
|
||||
apiKeysReplaceNoInput: 'Replace mode requires at least 1 API Key',
|
||||
apiKeyPlaceholderKeep: 'Leave empty to keep current key',
|
||||
apiKeyWillClear: 'Configured key will be cleared on save',
|
||||
apiKeyConfigured: 'Configured',
|
||||
apiKeyTemporary: 'Pending',
|
||||
apiKeyPendingDelete: 'Pending delete',
|
||||
apiKeyPendingDeleteCount: '{count} keys pending deletion',
|
||||
deleteApiKey: 'Delete this key',
|
||||
undoDeleteApiKey: 'Undo delete',
|
||||
inputApiKeyCount: '{count} keys in input',
|
||||
storedApiKeyCount: '{count} stored keys',
|
||||
testInputApiKeys: 'Test input keys',
|
||||
testStoredApiKeys: 'Test stored keys',
|
||||
testContentWithStoredApiKey: 'Test content with stored key',
|
||||
testingApiKeys: 'Testing',
|
||||
apiKeyTestNoInput: 'Enter OpenAI API Keys to test first',
|
||||
apiKeyTestDone: 'Key test completed for {count} keys',
|
||||
apiKeyTestFailed: 'Failed to test OpenAI API Keys',
|
||||
apiKeyHealth: 'Key Availability',
|
||||
apiKeyFreezeRule: '400 does not freeze; 401/403 freeze for 10 minutes; 429/529 freeze for 1 minute; other HTTP errors freeze for 10 seconds.',
|
||||
apiKeyRows: '{count} keys',
|
||||
apiKeyRowsCollapsed: '{count} keys hidden',
|
||||
apiKeyRowsExpanded: 'Showing all {count} keys',
|
||||
expandApiKeyRows: 'Expand',
|
||||
collapseApiKeyRows: 'Collapse',
|
||||
apiKeyHealthEmpty: 'No key status yet',
|
||||
apiKeyHealthEmptyHint: 'Save keys or test input keys to see availability.',
|
||||
apiKeyStatusOk: 'Available',
|
||||
apiKeyStatusError: 'Error',
|
||||
apiKeyStatusFrozen: 'Frozen',
|
||||
apiKeyStatusUnknown: 'Untested',
|
||||
apiKeyFailureCount: '{count} failures',
|
||||
apiKeyLatency: '{ms} ms',
|
||||
apiKeyHTTPStatus: 'HTTP {status}',
|
||||
apiKeyFrozenUntil: 'Frozen until {time}',
|
||||
apiKeyLastChecked: 'Checked at {time}',
|
||||
apiKeyNotTested: 'Not tested',
|
||||
auditTestInput: 'Audit Test Input',
|
||||
auditTestInputHint: 'Enter a prompt and upload or paste images; images are sent as base64 and are not stored.',
|
||||
auditTestPromptPlaceholder: 'Enter a user prompt to test; leave empty to only test key availability.',
|
||||
auditTestImages: 'Test Images',
|
||||
auditTestImagesHint: 'Upload, drag, or paste images. Up to 1 image, 8MB each.',
|
||||
addAuditTestImage: 'Add image',
|
||||
clearAuditTest: 'Clear test',
|
||||
auditTestImageLimit: 'You can add up to {count} test images',
|
||||
auditTestImageTooLarge: 'Each test image must be 8MB or smaller',
|
||||
auditTestImageReadFailed: 'Failed to read test image',
|
||||
auditTestResult: 'Audit Test Result',
|
||||
auditTestHighest: 'Top category {category}, score {score}',
|
||||
auditTestComposite: 'Composite score',
|
||||
auditTestFlagged: 'Threshold hit',
|
||||
auditTestPassed: 'Pass',
|
||||
notConfigured: 'Not configured',
|
||||
clearApiKey: 'Clear stored key',
|
||||
keepApiKey: 'Keep stored key',
|
||||
timeoutMs: 'HTTP Timeout (ms)',
|
||||
retryCount: 'Retry Count',
|
||||
sampleRate: 'Sample Rate',
|
||||
recordNonHits: 'Record Non-Hits',
|
||||
recordNonHitsHint: 'When enabled, sampled non-hit request summaries are redacted before storage.',
|
||||
preHashCheck: 'Enable Pre-Hash Check',
|
||||
preHashCheckHint: 'Hashes from async hits are blocked before moderation; this does not send email or increment ban counters.',
|
||||
flaggedHashCount: 'Current hash collection size: {count}',
|
||||
flaggedHashHint: 'Hashes are stored permanently in Redis; paste a full 64-character hash to remove a false block, or clear all stored hashes.',
|
||||
flaggedHashPlaceholder: 'Paste full 64-character input hash',
|
||||
deleteFlaggedHash: 'Delete hash',
|
||||
clearFlaggedHashes: 'Clear all',
|
||||
clearFlaggedHashesConfirm: 'Clear all risk input hashes? This does not delete audit records, but removes all historical hash blocks.',
|
||||
flaggedHashDeleted: 'Risk hash deleted',
|
||||
flaggedHashNotFound: 'Risk hash not found',
|
||||
flaggedHashDeleteFailed: 'Failed to delete risk hash',
|
||||
flaggedHashesCleared: 'Cleared {count} risk hashes',
|
||||
flaggedHashesClearFailed: 'Failed to clear risk hashes',
|
||||
workerCount: 'Worker Count',
|
||||
queueSize: 'Async Queue Size',
|
||||
blockStatus: 'Block HTTP Status',
|
||||
blockMessage: 'Custom Block Message',
|
||||
defaultBlockMessage: 'Content audit matched a risk rule. Please adjust your input and try again.',
|
||||
emailOnHit: 'Email on Hit',
|
||||
emailOnHitHint: 'When enabled, send a risk-control email on every hit; auto-ban notices are always sent.',
|
||||
autoBan: 'Auto Ban User',
|
||||
autoBanHint: 'Disable the user, invalidate auth cache, and send a ban notice after the hit threshold is reached.',
|
||||
cyberPolicyExcludeBan: 'Exclude Cyber Policy Hits from Ban Count',
|
||||
cyberPolicyExcludeBanHint: 'When enabled, cyber_policy hits no longer count toward auto-ban violations: no ban judgment on the hit itself, and history rows are excluded from the rolling count. Logs and notice emails are unaffected.',
|
||||
violationNotCounted: 'Not counted',
|
||||
banThreshold: 'Ban Threshold',
|
||||
violationWindowHours: 'Count Window (hours)',
|
||||
hitRetentionDays: 'Hit Record Retention (days)',
|
||||
nonHitRetentionDays: 'Non-Hit Record Retention (days, max 3)',
|
||||
violationCount: '{count} hits',
|
||||
emailSent: 'Email sent',
|
||||
emailNotSent: 'No email',
|
||||
autoBanned: 'Banned',
|
||||
unbanUser: 'Unban',
|
||||
unbanSuccess: 'User has been unbanned',
|
||||
unbanFailed: 'Failed to unban user',
|
||||
inputDetailTitle: 'Input Summary Detail',
|
||||
inputDetailContent: 'Full Content',
|
||||
matchedKeyword: 'Matched Keyword',
|
||||
queueDelay: 'Queued {ms} ms',
|
||||
allGroups: 'All Groups',
|
||||
allGroupsHint: 'Auditing all groups',
|
||||
selectedGroupsHint: 'Auditing selected groups',
|
||||
groupScope: 'Audit Groups',
|
||||
groupScopeHint: 'Switch on for all groups, or turn off to choose specific groups.',
|
||||
selectedGroups: 'Selected Groups',
|
||||
searchGroups: 'Search group name or platform',
|
||||
noGroups: 'No groups available',
|
||||
modelFilter: 'Model scope',
|
||||
modelFilterHint: 'Moderate by the client-requested model name; channel model mappings do not change this match.',
|
||||
modelFilterAll: 'All models',
|
||||
modelFilterAllDesc: 'All model requests go through content moderation.',
|
||||
modelFilterInclude: 'Only selected',
|
||||
modelFilterIncludeDesc: 'Only listed models go through content moderation.',
|
||||
modelFilterExclude: 'Exclude selected',
|
||||
modelFilterExcludeDesc: 'Listed models skip content moderation; other models are moderated.',
|
||||
modelFilterModels: 'Model list',
|
||||
modelFilterModelCount: '{count} models configured',
|
||||
modelFilterModelsRequired: 'This model scope requires at least 1 model',
|
||||
modelFilterAllSummary: 'Applies to all models',
|
||||
modelFilterIncludeSummary: 'Applies to {count} models',
|
||||
modelFilterExcludeSummary: 'Excludes {count} models',
|
||||
emptyLogs: 'No audit records',
|
||||
preBlockSyncStatus: 'Pre-Block Sync Status',
|
||||
preBlockSyncHint: 'Live counters for the synchronous moderation path, excluding async record tasks.',
|
||||
preBlockActive: 'Sync Processing',
|
||||
preBlockActiveHint: 'Currently checking',
|
||||
preBlockChecked: 'Checked',
|
||||
preBlockCheckedHint: 'Entered pre-block path',
|
||||
preBlockAllowed: 'Allowed',
|
||||
preBlockAllowedHint: 'No block triggered',
|
||||
preBlockBlocked: 'Blocked',
|
||||
preBlockBlockedHint: 'Rejected after hit',
|
||||
preBlockErrors: 'Audit Errors',
|
||||
preBlockErrorsHint: 'Failed or no usable key',
|
||||
preBlockAvgLatency: 'Avg Latency',
|
||||
preBlockAvgLatencyHint: 'Synchronous path average',
|
||||
preBlockAPIKeyLoad: 'Audit Key Load',
|
||||
preBlockAPIKeyLoadHint: 'Synchronous pre-block checks round-robin usable audit keys directly.',
|
||||
preBlockAPIKeyLoadSummary: 'Sync active {active} / usable keys {available}, {total} total, worker: {workerActive} / {workerTotal}',
|
||||
preBlockAPIKeyTotals: 'Total {total}, success {success}, errors {errors}',
|
||||
preBlockAPIKeyLoadEmpty: 'No audit key load data yet',
|
||||
preBlockKeyActiveShort: 'Active',
|
||||
preBlockKeyTotalShort: 'Total',
|
||||
preBlockKeyAvgShort: 'Avg',
|
||||
preBlockKeyLastShort: 'Last',
|
||||
workerStatus: 'Worker Runtime',
|
||||
workerStatusHint: 'Queue and worker pool status for async audit tasks and pre-block record tasks, excluding synchronous pre-block checks.',
|
||||
workerPool: 'Worker Pool',
|
||||
workerPoolMeta: '{active} processing, {idle} idle and ready, {total} total',
|
||||
queueUsage: 'Queue Usage',
|
||||
activeWorkers: 'Processing',
|
||||
idleWorkers: 'Idle Ready',
|
||||
workerActive: 'Processing an async audit or record task',
|
||||
workerIdle: 'Started, idle and ready',
|
||||
workerDisabled: 'Risk control or content audit is disabled',
|
||||
processed: 'Processed',
|
||||
droppedErrors: 'Dropped / Errors',
|
||||
autoRefresh: 'Auto refresh every 15s',
|
||||
lastCleanup: 'Last cleanup: {time}',
|
||||
cleanupStats: 'Last cleanup deleted {hit} hits and {nonHit} non-hits',
|
||||
riskSwitchOff: 'System switch off',
|
||||
riskThresholds: 'Risk Thresholds',
|
||||
riskThresholdsHint: 'Adjust hit thresholds by OpenAI Moderations category. Scores greater than or equal to the threshold count as hits.',
|
||||
riskThresholdDefault: 'Default {value}',
|
||||
riskThresholdReset: 'Restore defaults',
|
||||
riskThresholdPercent: 'Threshold percentage',
|
||||
tabs: {
|
||||
basic: 'Basic',
|
||||
scope: 'Scope',
|
||||
runtime: 'Runtime',
|
||||
response: 'Hit Notice',
|
||||
riskThresholds: 'Risk Thresholds',
|
||||
keywords: 'Keyword Block',
|
||||
retention: 'Retention',
|
||||
},
|
||||
blockedKeywords: 'Blocked keywords',
|
||||
blockedKeywordsPlaceholder: 'One keyword per line, e.g.:\nbadword1\nbadword2',
|
||||
blockedKeywordsDescription: 'Matching is case-insensitive. Whether the upstream moderation API is invoked after a hit depends on the strategy below.',
|
||||
blockedKeywordsPreBlockHint: 'Keyword blocking only takes effect in "Pre-block" mode.',
|
||||
blockedKeywordsModeWarning: 'Current mode is "{mode}". Keyword blocking will not run until you switch to "Pre-block" mode.',
|
||||
blockedKeywordCount: '{count} keywords configured',
|
||||
blockedKeywordsLimit: 'Up to {max} keywords, each no longer than 200 characters. Duplicates are removed automatically.',
|
||||
keywordBlockingMode: 'Moderation strategy',
|
||||
keywordModeKeywordAndApi: 'Keyword + API',
|
||||
keywordModeKeywordAndApiDesc: 'Block on keyword hit; otherwise fall through to the upstream moderation API.',
|
||||
keywordModeKeywordOnly: 'Keyword only',
|
||||
keywordModeKeywordOnlyDesc: 'Decide using keywords only; misses are allowed without calling the API, saving upstream cost.',
|
||||
keywordModeKeywordOnlyNotice: 'Keyword-only strategy: requests that do not match any keyword are allowed without calling the upstream moderation API.',
|
||||
keywordModeApiOnly: 'API only',
|
||||
keywordModeApiOnlyDesc: 'Use the upstream moderation API only; the keyword list configured here is not consulted.',
|
||||
keywordModeApiOnlyNotice: 'API-only strategy: the keyword list is not consulted; all requests go through the upstream moderation API.',
|
||||
overview: {
|
||||
status: 'Status',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
apiKey: 'API Key',
|
||||
groupScope: 'Scope',
|
||||
logs: 'Audit Records',
|
||||
currentFilter: 'Current filter',
|
||||
},
|
||||
filters: {
|
||||
search: 'Search user/key/summary',
|
||||
from: 'From',
|
||||
to: 'To',
|
||||
allGroups: 'All Groups',
|
||||
allEndpoints: 'All Endpoints',
|
||||
},
|
||||
table: {
|
||||
time: 'Time',
|
||||
group: 'Group',
|
||||
user: 'User',
|
||||
apiKey: 'API Key',
|
||||
endpoint: 'Endpoint',
|
||||
result: 'Result',
|
||||
highest: 'Highest',
|
||||
actionMeta: 'Action',
|
||||
latency: 'Latency',
|
||||
input: 'Input Summary',
|
||||
},
|
||||
result: {
|
||||
all: 'All Results',
|
||||
hit: 'Hit',
|
||||
blocked: 'Blocked',
|
||||
pass: 'Pass',
|
||||
error: 'Error',
|
||||
},
|
||||
action: {
|
||||
block: 'Blocked',
|
||||
keywordBlock: 'Keyword Blocked',
|
||||
cyberPolicy: 'Cyber policy',
|
||||
error: 'Error',
|
||||
},
|
||||
},
|
||||
|
||||
// Channel Monitor
|
||||
channelMonitor: {
|
||||
title: 'Channel Monitor',
|
||||
description: 'Monitor channel availability, latency and status',
|
||||
searchPlaceholder: 'Search monitor name...',
|
||||
allProviders: 'All Providers',
|
||||
allStatus: 'All Status',
|
||||
enabledFilter: 'Enabled',
|
||||
onlyEnabled: 'Enabled only',
|
||||
onlyDisabled: 'Disabled only',
|
||||
createButton: 'Create Monitor',
|
||||
createTitle: 'Create Channel Monitor',
|
||||
editTitle: 'Edit Channel Monitor',
|
||||
runNow: 'Run Now',
|
||||
runSuccess: 'Check completed',
|
||||
runFailed: 'Check failed',
|
||||
apiKeyDecryptFailed: 'API Key decryption failed. Please re-edit this monitor with a fresh key.',
|
||||
createSuccess: 'Monitor created',
|
||||
updateSuccess: 'Monitor updated',
|
||||
deleteSuccess: 'Monitor deleted',
|
||||
loadError: 'Failed to load monitors',
|
||||
deleteConfirm: 'Are you sure you want to delete monitor "{name}"? This action cannot be undone.',
|
||||
nameRequired: 'Please enter a monitor name',
|
||||
primaryModelRequired: 'Please enter a primary model',
|
||||
columns: {
|
||||
name: 'Name',
|
||||
provider: 'Provider',
|
||||
primaryModel: 'Primary Model',
|
||||
availability7d: '7d Availability',
|
||||
latency: 'Latency (ms)',
|
||||
enabled: 'Enabled',
|
||||
actions: 'Actions'
|
||||
},
|
||||
form: {
|
||||
name: 'Name',
|
||||
namePlaceholder: 'Enter monitor name',
|
||||
provider: 'Platform',
|
||||
apiMode: 'OpenAI protocol',
|
||||
apiModeChatCompletions: 'OpenAI Compatible',
|
||||
apiModeChatCompletionsHint: 'Use /v1/chat/completions with messages; works for most compatible providers.',
|
||||
apiModeResponses: 'Responses API',
|
||||
apiModeResponsesHint: 'Use /v1/responses with default instructions + input; best for self-check/Codex paths.',
|
||||
endpoint: 'Endpoint',
|
||||
endpointPlaceholder: 'https://api.example.com',
|
||||
useCurrentDomain: 'Use current service',
|
||||
apiKey: 'API Key',
|
||||
apiKeyPlaceholder: 'Enter API Key',
|
||||
apiKeyEditPlaceholder: 'Leave blank to keep current key',
|
||||
useMyKey: 'Use my key',
|
||||
selectKeyTitle: 'Select my API Key',
|
||||
selectKeyHint: 'Only your active, non-expired keys are listed.',
|
||||
noActiveKey: 'No active API keys available',
|
||||
primaryModel: 'Primary Model',
|
||||
primaryModelPlaceholder: 'gpt-4o-mini',
|
||||
extraModels: 'Extra Models',
|
||||
extraModelsPlaceholder: 'Press Enter to add extra model',
|
||||
groupName: 'Group Name',
|
||||
groupNamePlaceholder: 'Optional, used to group rows in user view',
|
||||
intervalSeconds: 'Interval (seconds)',
|
||||
intervalSecondsHint: 'Range: 15 - 3600 seconds',
|
||||
jitterSeconds: 'Random Jitter (± seconds)',
|
||||
jitterSecondsHint: 'Each check fires at interval ± a random offset within this value; 0 means fixed interval. Interval minus jitter must be ≥ 15s',
|
||||
enabled: 'Enable monitor',
|
||||
kindRequired: 'Please select a provider'
|
||||
},
|
||||
runResultTitle: 'Check Result',
|
||||
noMonitorsYet: 'No monitors yet',
|
||||
createFirstMonitor: 'Create your first monitor to track channel availability',
|
||||
advanced: {
|
||||
section: 'Advanced (optional)',
|
||||
sectionHint: 'Customize request headers and body to bypass upstream client-detection (e.g. "only Claude Code clients allowed").',
|
||||
headers: 'Custom request headers',
|
||||
headersPlaceholder: 'User-Agent: claude-cli/1.0.83 (external, cli)\nx-app: cli\nanthropic-beta: claude-code-20250219',
|
||||
headerNamePlaceholder: 'Header name',
|
||||
headerValuePlaceholder: 'Value',
|
||||
headerAddRow: 'Add header',
|
||||
headerNameInvalid: 'Header name cannot contain whitespace or colon: {name}',
|
||||
headersHint: 'Merged on top of adapter defaults (user wins). Hop-by-hop headers (Host / Content-Length / ...) are ignored.',
|
||||
headersParseError: 'Cannot parse line: {line}',
|
||||
bodyMode: 'Body handling',
|
||||
bodyModeOff: 'Default',
|
||||
bodyModeMerge: 'Merge',
|
||||
bodyModeReplace: 'Replace',
|
||||
bodyModeHintOff: 'Use the adapter default body (includes challenge validation).',
|
||||
bodyModeHintMerge: 'Shallow-merge with the default body; user fields win but model / messages / contents are protected (use Replace to change those).',
|
||||
bodyModeHintReplace: 'Use the JSON below as the complete body. Challenge validation is skipped; HTTP 2xx + non-empty response text is treated as operational.',
|
||||
bodyJson: 'Body JSON',
|
||||
bodyJsonFormat: 'Format',
|
||||
bodyJsonHint: 'Parsed on blur. Empty means no override.',
|
||||
bodyJsonError: 'JSON parse failed',
|
||||
bodyJsonObjectError: 'Body must be a JSON object (no arrays or primitives)'
|
||||
},
|
||||
templateField: {
|
||||
label: 'Request template',
|
||||
none: 'No template',
|
||||
placeholder: 'Pick a template (filtered by current provider)',
|
||||
applyHint: 'Picking a template copies its headers and body to this monitor (snapshot). Later template edits are not auto-synced.'
|
||||
},
|
||||
template: {
|
||||
manageButton: 'Templates',
|
||||
managerTitle: 'Request template manager',
|
||||
createButton: 'New template',
|
||||
emptyState: 'No templates for this provider yet',
|
||||
missingName: 'Template name is required',
|
||||
createSuccess: 'Template created',
|
||||
updateSuccess: 'Template updated',
|
||||
deleteSuccess: 'Template deleted',
|
||||
applyButton: 'Apply to monitors',
|
||||
applyTooltip: 'Overwrite snapshot fields on associated monitors',
|
||||
applyTitle: 'Apply template',
|
||||
applyConfirm: 'Apply',
|
||||
applyConfirmMessage: 'Overwrite {n} associated monitor(s) with the current configuration of "{name}"? Any local customizations on those monitors will be discarded.',
|
||||
applySuccess: 'Applied to {n} monitor(s)',
|
||||
applyPickerTitle: 'Apply template "{name}"',
|
||||
applyPickerHint: 'Select which monitors to overwrite (all selected by default). Any local customizations will be discarded.',
|
||||
applyPickerEmpty: 'No monitors are currently associated to this template',
|
||||
applyPickerConfirm: 'Apply to {n} monitor(s)',
|
||||
selectNone: 'Select none',
|
||||
selectedCount: 'Selected {n} / {total}',
|
||||
deleteConfirm: 'Delete template "{name}"? {n} associated monitor(s) will be disassociated but keep their current snapshot and continue running.',
|
||||
associatedCount: '{n} associated monitor(s)',
|
||||
headersSummary: '{n} custom header(s)',
|
||||
form: {
|
||||
name: 'Template name',
|
||||
namePlaceholder: 'e.g. Claude Code mimicry',
|
||||
description: 'Description',
|
||||
descriptionPlaceholder: 'Optional: what this template is for, capture date, etc.'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Subscriptions
|
||||
subscriptions: {
|
||||
title: 'Subscription Management',
|
||||
description: 'Manage user subscriptions and quota limits',
|
||||
assignSubscription: 'Assign Subscription',
|
||||
adjustSubscription: 'Adjust Subscription',
|
||||
revokeSubscription: 'Revoke Subscription',
|
||||
restoreSubscription: 'Restore Subscription',
|
||||
allStatus: 'All Status',
|
||||
allGroups: 'All Groups',
|
||||
allPlatforms: 'All Platforms',
|
||||
daily: 'Daily',
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly',
|
||||
noLimits: 'No limits configured',
|
||||
unlimited: 'Unlimited',
|
||||
resetNow: 'Resetting soon',
|
||||
windowNotActive: 'Window not active',
|
||||
resetInMinutes: 'Resets in {minutes}m',
|
||||
resetInHoursMinutes: 'Resets in {hours}h {minutes}m',
|
||||
resetInDaysHours: 'Resets in {days}d {hours}h',
|
||||
quotaEndsInMinutes: 'Quota ends in {minutes}m',
|
||||
quotaEndsInHoursMinutes: 'Quota ends in {hours}h {minutes}m',
|
||||
quotaEndsInDaysHours: 'Quota ends in {days}d {hours}h',
|
||||
daysRemaining: 'days remaining',
|
||||
remainingDays: 'Remaining days',
|
||||
noExpiration: 'No expiration',
|
||||
status: {
|
||||
active: 'Active',
|
||||
expired: 'Expired',
|
||||
revoked: 'Revoked',
|
||||
suspended: 'Suspended'
|
||||
},
|
||||
columns: {
|
||||
user: 'User',
|
||||
group: 'Group',
|
||||
usage: 'Usage',
|
||||
expires: 'Expires',
|
||||
status: 'Status',
|
||||
actions: 'Actions'
|
||||
},
|
||||
form: {
|
||||
user: 'User',
|
||||
group: 'Subscription Group',
|
||||
validityDays: 'Validity (Days)',
|
||||
adjustDays: 'Adjust by (Days)'
|
||||
},
|
||||
selectUser: 'Select a user',
|
||||
selectGroup: 'Select a subscription group',
|
||||
groupHint: 'Only groups with subscription billing type are shown',
|
||||
validityHint: 'Number of days the subscription will be valid',
|
||||
adjustingFor: 'Adjusting subscription for',
|
||||
currentExpiration: 'Current expiration',
|
||||
adjustDaysPlaceholder: 'Positive to extend, negative to shorten',
|
||||
adjustHint: 'Enter positive number to extend, negative to shorten (remaining days must be > 0)',
|
||||
assign: 'Assign',
|
||||
assigning: 'Assigning...',
|
||||
adjust: 'Adjust',
|
||||
adjusting: 'Adjusting...',
|
||||
revoke: 'Revoke',
|
||||
restore: 'Restore',
|
||||
resetQuota: 'Reset Quota',
|
||||
resetQuotaTitle: 'Reset Usage Quota',
|
||||
resetQuotaConfirm: "Reset the daily, weekly, and monthly usage quota for '{user}'? Usage will be zeroed and windows restarted from today.",
|
||||
quotaResetSuccess: 'Quota reset successfully',
|
||||
failedToResetQuota: 'Failed to reset quota',
|
||||
noSubscriptionsYet: 'No subscriptions yet',
|
||||
assignFirstSubscription: 'Assign a subscription to get started.',
|
||||
subscriptionAssigned: 'Subscription assigned successfully',
|
||||
subscriptionAdjusted: 'Subscription adjusted successfully',
|
||||
subscriptionRevoked: 'Subscription revoked successfully',
|
||||
subscriptionRestored: 'Subscription restored successfully',
|
||||
failedToLoad: 'Failed to load subscriptions',
|
||||
failedToAssign: 'Failed to assign subscription',
|
||||
failedToAdjust: 'Failed to adjust subscription',
|
||||
failedToRevoke: 'Failed to revoke subscription',
|
||||
failedToRestore: 'Failed to restore subscription',
|
||||
adjustWouldExpire: 'Remaining days after adjustment must be greater than 0',
|
||||
adjustOutOfRange: 'Adjustment days must be between -36500 and 36500',
|
||||
pleaseSelectUser: 'Please select a user',
|
||||
pleaseSelectGroup: 'Please select a group',
|
||||
validityDaysRequired: 'Please enter a valid number of days (at least 1)',
|
||||
revokeConfirm:
|
||||
"Are you sure you want to revoke the subscription for '{user}'? You can restore it later from the revoked list.",
|
||||
restoreConfirm:
|
||||
"Restore the subscription for '{user}'? If the original subscription has expired, it will be restored as expired.",
|
||||
guide: {
|
||||
title: 'Subscription Management Guide',
|
||||
subtitle: 'Subscription mode lets you assign time-based usage quotas to users, with daily/weekly/monthly limits. Follow these steps to get started.',
|
||||
showGuide: 'Usage Guide',
|
||||
step1: {
|
||||
title: 'Create a Subscription Group',
|
||||
line1: 'Go to "Group Management" page, click "Create Group"',
|
||||
line2: 'Set billing type to "Subscription", configure daily/weekly/monthly quota limits',
|
||||
line3: 'Save the group and ensure its status is "Active"',
|
||||
link: 'Go to Group Management'
|
||||
},
|
||||
step2: {
|
||||
title: 'Assign Subscription to User',
|
||||
line1: 'Click the "Assign Subscription" button in the top right',
|
||||
line2: 'Search for a user by email and select them',
|
||||
line3: 'Choose a subscription group, set validity days, then click "Assign"'
|
||||
},
|
||||
step3: {
|
||||
title: 'Manage Existing Subscriptions'
|
||||
},
|
||||
actions: {
|
||||
adjust: 'Adjust',
|
||||
adjustDesc: 'Extend or shorten the subscription validity period',
|
||||
resetQuota: 'Reset Quota',
|
||||
resetQuotaDesc: 'Reset daily/weekly/monthly usage to zero',
|
||||
revoke: 'Revoke',
|
||||
revokeDesc: 'Immediately terminate the subscription (restorable from the revoked list)'
|
||||
},
|
||||
tip: 'Tip: Only groups with billing type "Subscription" and status "Active" appear in the group dropdown. If no options are available, create one in Group Management first.'
|
||||
}
|
||||
},
|
||||
|
||||
// Accounts
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import overview from './overview'
|
||||
import channels from './channels'
|
||||
import accounts from './accounts'
|
||||
import resources from './resources'
|
||||
import ops from './ops'
|
||||
import settings from './settings'
|
||||
|
||||
export default {
|
||||
...overview,
|
||||
...channels,
|
||||
...accounts,
|
||||
...resources,
|
||||
...ops,
|
||||
...settings,
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
export default {
|
||||
ops: {
|
||||
title: 'Ops Monitoring',
|
||||
description: 'Operational monitoring and troubleshooting',
|
||||
// Dashboard
|
||||
systemHealth: 'System Health',
|
||||
overview: 'Overview',
|
||||
noSystemMetrics: 'No system metrics collected yet.',
|
||||
collectedAt: 'Collected at:',
|
||||
window: 'window',
|
||||
memory: 'Memory',
|
||||
db: 'DB',
|
||||
goroutines: 'Goroutines',
|
||||
jobs: 'Jobs',
|
||||
jobsHelp: 'Click “Details” to view job heartbeats and recent errors',
|
||||
active: 'active',
|
||||
idle: 'idle',
|
||||
waiting: 'waiting',
|
||||
conns: 'conns',
|
||||
queue: 'queue',
|
||||
accountSwitches: 'Account switches',
|
||||
ok: 'ok',
|
||||
lastRun: 'last_run:',
|
||||
lastSuccess: 'last_success:',
|
||||
lastError: 'last_error:',
|
||||
noData: 'No data.',
|
||||
loadingText: 'loading',
|
||||
ready: 'ready',
|
||||
autoRefreshRemaining: 'Remaining {seconds}s',
|
||||
systemLogs: {
|
||||
title: 'System Logs',
|
||||
description: 'Newest logs are shown first. Filter, search, and clean up by condition.',
|
||||
queue: 'Queue',
|
||||
written: 'Written',
|
||||
dropped: 'Dropped',
|
||||
failed: 'Failed',
|
||||
runtimeConfig: 'Runtime Log Configuration (applies immediately)',
|
||||
all: 'All',
|
||||
level: 'Level',
|
||||
stacktraceThreshold: 'Stacktrace threshold',
|
||||
samplingInitial: 'Sampling initial',
|
||||
samplingThereafter: 'Sampling thereafter',
|
||||
retentionDays: 'Retention days',
|
||||
caller: 'caller',
|
||||
sampling: 'sampling',
|
||||
saveAndApply: 'Save and apply',
|
||||
resetDefaults: 'Reset defaults',
|
||||
latestWriteError: 'Latest write error:',
|
||||
timeRange: 'Time range',
|
||||
startTime: 'Start time (optional)',
|
||||
endTime: 'End time (optional)',
|
||||
component: 'Component',
|
||||
componentPlaceholder: 'e.g. http.access',
|
||||
keyId: 'KEY ID',
|
||||
platform: 'Platform',
|
||||
model: 'Model',
|
||||
keyword: 'Keyword',
|
||||
keywordPlaceholder: 'message/request_id',
|
||||
search: 'Search',
|
||||
cleanCurrentFilters: 'Clean current filters',
|
||||
refreshHealth: 'Refresh health',
|
||||
empty: 'No system logs',
|
||||
time: 'Time',
|
||||
logDetails: 'Log Details',
|
||||
loadFailed: 'Failed to load system logs',
|
||||
runtimeConfigActive: 'Runtime log configuration is active',
|
||||
runtimeConfigSaveFailed: 'Failed to save log configuration',
|
||||
resetRuntimeConfigConfirm: 'Reset to startup configuration (env/yaml) and apply immediately?',
|
||||
runtimeConfigReset: 'Reset to startup log configuration',
|
||||
runtimeConfigResetFailed: 'Failed to reset log configuration',
|
||||
cleanupConfirm: 'Clean up system logs matching the current filters? This cannot be undone.',
|
||||
cleanupSuccess: 'Cleanup complete. Deleted {count} log entries.',
|
||||
cleanupFailed: 'Failed to clean up system logs'
|
||||
},
|
||||
requestsTotal: 'Requests (total)',
|
||||
slaScope: 'SLA scope:',
|
||||
tokens: 'Tokens',
|
||||
tps: 'TPS:',
|
||||
current: 'current',
|
||||
peak: 'peak',
|
||||
average: 'average',
|
||||
totalRequests: 'Total Requests',
|
||||
avgQps: 'Avg QPS',
|
||||
avgTps: 'Avg TPS',
|
||||
avgLatency: 'Avg Request Duration',
|
||||
avgTtft: 'Avg TTFT',
|
||||
exceptions: 'Exceptions',
|
||||
requestErrors: 'Request Errors',
|
||||
errorCount: 'Error Count',
|
||||
upstreamErrors: 'Upstream Errors',
|
||||
errorCountExcl429529: 'Error Count (excl 429/529)',
|
||||
sla: 'SLA (excl business limits)',
|
||||
businessLimited: 'business_limited:',
|
||||
errors: 'Errors',
|
||||
errorRate: 'error_rate:',
|
||||
upstreamRate: 'upstream_rate:',
|
||||
latencyDuration: 'Request Duration',
|
||||
ttftLabel: 'TTFT (first_token_ms)',
|
||||
p50: 'p50:',
|
||||
p90: 'p90:',
|
||||
p95: 'p95:',
|
||||
p99: 'p99:',
|
||||
avg: 'avg:',
|
||||
max: 'max:',
|
||||
requests: 'Requests',
|
||||
requestsTitle: 'Requests',
|
||||
upstream: 'Upstream',
|
||||
client: 'Client',
|
||||
system: 'System',
|
||||
other: 'Other',
|
||||
errorsSla: 'Errors (SLA scope)',
|
||||
upstreamExcl429529: 'Upstream (excl 429/529)',
|
||||
failedToLoadData: 'Failed to load ops data.',
|
||||
failedToLoadOverview: 'Failed to load overview',
|
||||
failedToLoadThroughputTrend: 'Failed to load throughput trend',
|
||||
failedToLoadSwitchTrend: 'Failed to load avg account switches trend',
|
||||
failedToLoadLatencyHistogram: 'Failed to load request duration histogram',
|
||||
failedToLoadErrorTrend: 'Failed to load error trend',
|
||||
failedToLoadErrorDistribution: 'Failed to load error distribution',
|
||||
failedToLoadErrorDetail: 'Failed to load error detail',
|
||||
retryFailed: 'Retry failed',
|
||||
tpsK: 'TPS (K)',
|
||||
top: 'Top:',
|
||||
throughputTrend: 'Throughput Trend',
|
||||
switchRateTrend: 'Avg Account Switches',
|
||||
latencyHistogram: 'Request Duration Histogram',
|
||||
errorTrend: 'Error Trend',
|
||||
errorDistribution: 'Error Distribution',
|
||||
switchRate: 'Avg switches',
|
||||
// Health Score & Diagnosis
|
||||
health: 'Health',
|
||||
healthCondition: 'Health Condition',
|
||||
healthHelp: 'Overall system health score based on SLA, error rate, and resource usage',
|
||||
healthyStatus: 'Healthy',
|
||||
riskyStatus: 'At Risk',
|
||||
idleStatus: 'Idle',
|
||||
timeRange: {
|
||||
'5m': 'Last 5 minutes',
|
||||
'30m': 'Last 30 minutes',
|
||||
'1h': 'Last 1 hour',
|
||||
'1d': 'Last 1 day',
|
||||
'15d': 'Last 15 days',
|
||||
'6h': 'Last 6 hours',
|
||||
'24h': 'Last 24 hours',
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days'
|
||||
},
|
||||
openaiTokenStats: {
|
||||
title: 'OpenAI Token Request Stats',
|
||||
viewModeTopN: 'TopN',
|
||||
viewModePagination: 'Pagination',
|
||||
prevPage: 'Previous',
|
||||
nextPage: 'Next',
|
||||
pageInfo: 'Page {page}/{total}',
|
||||
totalModels: 'Total models: {total}',
|
||||
failedToLoad: 'Failed to load OpenAI token stats',
|
||||
empty: 'No OpenAI token stats for the current filters',
|
||||
table: {
|
||||
model: 'Model',
|
||||
requestCount: 'Requests',
|
||||
avgTokensPerSec: 'Avg Tokens/sec',
|
||||
avgFirstTokenMs: 'Avg First Token Latency (ms)',
|
||||
totalOutputTokens: 'Total Output Tokens',
|
||||
avgDurationMs: 'Avg Duration (ms)',
|
||||
requestsWithFirstToken: 'Requests With First Token'
|
||||
}
|
||||
},
|
||||
fullscreen: {
|
||||
enter: 'Enter Fullscreen'
|
||||
},
|
||||
diagnosis: {
|
||||
title: 'Smart Diagnosis',
|
||||
footer: 'Automated diagnostic suggestions based on current metrics',
|
||||
idle: 'System is currently idle',
|
||||
idleImpact: 'No active traffic',
|
||||
// Resource diagnostics
|
||||
dbDown: 'Database connection failed',
|
||||
dbDownImpact: 'All database operations will fail',
|
||||
dbDownAction: 'Check database service status, network connectivity, and connection configuration',
|
||||
redisDown: 'Redis connection failed',
|
||||
redisDownImpact: 'Cache functionality degraded, performance may decline',
|
||||
redisDownAction: 'Check Redis service status and network connectivity',
|
||||
cpuCritical: 'CPU usage critically high ({usage}%)',
|
||||
cpuCriticalImpact: 'System response slowing, may affect all requests',
|
||||
cpuCriticalAction: 'Check CPU-intensive tasks, consider scaling or code optimization',
|
||||
cpuHigh: 'CPU usage elevated ({usage}%)',
|
||||
cpuHighImpact: 'System load is high, needs attention',
|
||||
cpuHighAction: 'Monitor CPU trends, prepare scaling plan',
|
||||
memoryCritical: 'Memory usage critically high ({usage}%)',
|
||||
memoryCriticalImpact: 'May trigger OOM, system stability threatened',
|
||||
memoryCriticalAction: 'Check for memory leaks, consider increasing memory or optimizing usage',
|
||||
memoryHigh: 'Memory usage elevated ({usage}%)',
|
||||
memoryHighImpact: 'Memory pressure is high, needs attention',
|
||||
memoryHighAction: 'Monitor memory trends, check for memory leaks',
|
||||
ttftHigh: 'Time to first token elevated ({ttft}ms)',
|
||||
ttftHighImpact: 'User perceived latency increased',
|
||||
ttftHighAction: 'Optimize request processing flow, reduce pre-processing time',
|
||||
// Error rate diagnostics
|
||||
upstreamCritical: 'Upstream error rate critically high ({rate}%)',
|
||||
upstreamCriticalImpact: 'May affect many user requests',
|
||||
upstreamCriticalAction: 'Check upstream service health, enable fallback strategies',
|
||||
upstreamHigh: 'Upstream error rate elevated ({rate}%)',
|
||||
upstreamHighImpact: 'Recommend checking upstream service status',
|
||||
upstreamHighAction: 'Contact upstream service team, prepare fallback plan',
|
||||
errorHigh: 'Error rate too high ({rate}%)',
|
||||
errorHighImpact: 'Many requests failing',
|
||||
errorHighAction: 'Check error logs, identify root cause, urgent fix required',
|
||||
errorElevated: 'Error rate elevated ({rate}%)',
|
||||
errorElevatedImpact: 'Recommend checking error logs',
|
||||
errorElevatedAction: 'Analyze error types and distribution, create fix plan',
|
||||
// SLA diagnostics
|
||||
slaCritical: 'SLA critically below target ({sla}%)',
|
||||
slaCriticalImpact: 'User experience severely degraded',
|
||||
slaCriticalAction: 'Urgently investigate errors and latency, consider rate limiting',
|
||||
slaLow: 'SLA below target ({sla}%)',
|
||||
slaLowImpact: 'Service quality needs attention',
|
||||
slaLowAction: 'Analyze SLA decline causes, optimize system performance',
|
||||
// Health score diagnostics
|
||||
healthCritical: 'Overall health score critically low ({score})',
|
||||
healthCriticalImpact: 'Multiple metrics may be degraded; prioritize error rate and latency investigation',
|
||||
healthCriticalAction: 'Comprehensive system check, prioritize critical-level issues',
|
||||
healthLow: 'Overall health score low ({score})',
|
||||
healthLowImpact: 'May indicate minor instability; monitor SLA and error rates',
|
||||
healthLowAction: 'Monitor metric trends, prevent issue escalation',
|
||||
healthy: 'All system metrics normal',
|
||||
healthyImpact: 'Service running stable'
|
||||
},
|
||||
// Error Log
|
||||
errorLog: {
|
||||
timeId: 'Time / ID',
|
||||
commonErrors: {
|
||||
contextDeadlineExceeded: 'context deadline exceeded',
|
||||
connectionRefused: 'connection refused',
|
||||
rateLimit: 'rate limit'
|
||||
},
|
||||
time: 'Time',
|
||||
type: 'Type',
|
||||
context: 'Context',
|
||||
platform: 'Platform',
|
||||
model: 'Model',
|
||||
group: 'Group',
|
||||
user: 'User',
|
||||
userId: 'User ID',
|
||||
apiKey: 'API Key',
|
||||
keyDeletedBadge: 'Key Deleted',
|
||||
account: 'Account',
|
||||
accountId: 'Account ID',
|
||||
status: 'Status',
|
||||
message: 'Message',
|
||||
ip: 'IP',
|
||||
latency: 'Request Duration',
|
||||
action: 'Action',
|
||||
noErrors: 'No errors in this window.',
|
||||
grp: 'GRP:',
|
||||
acc: 'ACC:',
|
||||
details: 'Details',
|
||||
phase: 'Phase',
|
||||
id: 'ID:',
|
||||
typeUpstream: 'Upstream',
|
||||
typeRequest: 'Request',
|
||||
typeAuth: 'Auth',
|
||||
typeRouting: 'Routing',
|
||||
typeInternal: 'Internal',
|
||||
endpoint: 'Endpoint',
|
||||
requestType: 'Type',
|
||||
requestTypeSync: 'Sync',
|
||||
requestTypeStream: 'Stream',
|
||||
requestTypeWs: 'WS'
|
||||
},
|
||||
// Error Details Modal
|
||||
errorDetails: {
|
||||
upstreamErrors: 'Upstream Errors',
|
||||
requestErrors: 'Request Errors',
|
||||
unresolved: 'Unresolved',
|
||||
resolved: 'Resolved',
|
||||
viewErrors: 'Errors',
|
||||
viewExcluded: 'Excluded',
|
||||
statusCodeOther: 'Other',
|
||||
owner: {
|
||||
provider: 'Provider',
|
||||
client: 'Client',
|
||||
platform: 'Platform'
|
||||
},
|
||||
phase: {
|
||||
request: 'Request',
|
||||
auth: 'Auth',
|
||||
routing: 'Routing',
|
||||
upstream: 'Upstream',
|
||||
network: 'Network',
|
||||
internal: 'Internal'
|
||||
},
|
||||
total: 'Total:',
|
||||
searchPlaceholder: 'Search request_id / client_request_id / message',
|
||||
},
|
||||
// Error Detail Modal
|
||||
errorDetail: {
|
||||
title: 'Error Detail',
|
||||
titleWithId: 'Error #{id}',
|
||||
noErrorSelected: 'No error selected.',
|
||||
resolution: 'Resolved:',
|
||||
failedToUpdateResolvedStatus: 'Failed to update resolved status',
|
||||
classificationKeys: {
|
||||
phase: 'Phase',
|
||||
owner: 'Owner',
|
||||
source: 'Source',
|
||||
resolvedAt: 'Resolved At',
|
||||
resolvedBy: 'Resolved By'
|
||||
},
|
||||
source: {
|
||||
upstream_http: 'Upstream HTTP'
|
||||
},
|
||||
upstreamKeys: {
|
||||
status: 'Status',
|
||||
message: 'Message',
|
||||
detail: 'Detail',
|
||||
upstreamErrors: 'Upstream Errors'
|
||||
},
|
||||
upstreamEvent: {
|
||||
account: 'Account',
|
||||
status: 'Status',
|
||||
requestId: 'Request ID'
|
||||
},
|
||||
responsePreview: {
|
||||
expand: 'Response (click to expand)',
|
||||
collapse: 'Response (click to collapse)'
|
||||
},
|
||||
loading: 'Loading…',
|
||||
requestId: 'Request ID',
|
||||
time: 'Time',
|
||||
phase: 'Phase',
|
||||
status: 'Status',
|
||||
message: 'Message',
|
||||
basicInfo: 'Basic Info',
|
||||
platform: 'Platform',
|
||||
model: 'Model',
|
||||
group: 'Group',
|
||||
user: 'User',
|
||||
account: 'Account',
|
||||
latency: 'Request Duration',
|
||||
businessLimited: 'Business Limited',
|
||||
requestPath: 'Request Path',
|
||||
inboundEndpoint: 'Inbound Endpoint',
|
||||
upstreamEndpoint: 'Upstream Endpoint',
|
||||
requestedModel: 'Requested Model',
|
||||
upstreamModel: 'Upstream Model',
|
||||
requestType: 'Request Type',
|
||||
requestTypeUnknown: 'Unknown',
|
||||
requestTypeSync: 'Sync',
|
||||
requestTypeStream: 'Stream',
|
||||
requestTypeWs: 'WebSocket',
|
||||
modelMapping: 'Model Mapping',
|
||||
timings: 'Timings',
|
||||
auth: 'Auth',
|
||||
routing: 'Routing',
|
||||
upstream: 'Upstream',
|
||||
response: 'Response',
|
||||
classification: 'Classification',
|
||||
errorBody: 'Error Body',
|
||||
trimmed: 'trimmed',
|
||||
markResolved: 'Mark resolved',
|
||||
markUnresolved: 'Mark unresolved',
|
||||
tabOverview: 'Overview',
|
||||
tabRequest: 'Request',
|
||||
tabResponse: 'Response',
|
||||
responseBody: 'Response',
|
||||
compareA: 'Compare A',
|
||||
compareB: 'Compare B',
|
||||
suggestion: 'Suggestion',
|
||||
suggestUpstream: 'Upstream instability: check account status or consider switching accounts',
|
||||
suggestRequest: 'Client request error: ask customer to fix request parameters',
|
||||
suggestAuth: 'Auth failed: verify API key/credentials',
|
||||
suggestPlatform: 'Platform error: prioritize investigation and fix',
|
||||
suggestGeneric: 'See details for more context',
|
||||
apiKeyPrefix: 'Key Prefix',
|
||||
attemptedKeyPrefix: 'Attempted Key Prefix',
|
||||
deletedKeyOwner: 'Deleted Key Owner',
|
||||
keyDeletedBadge: 'Key Deleted'
|
||||
},
|
||||
requestDetails: {
|
||||
title: 'Request Details',
|
||||
details: 'Details',
|
||||
rangeLabel: 'Window: {range}',
|
||||
rangeMinutes: '{n} minutes',
|
||||
rangeHours: '{n} hours',
|
||||
empty: 'No requests in this window.',
|
||||
emptyHint: 'Try a different time range or remove filters.',
|
||||
failedToLoad: 'Failed to load request details',
|
||||
requestIdCopied: 'Request ID copied',
|
||||
copyFailed: 'Copy failed',
|
||||
copy: 'Copy',
|
||||
viewError: 'View Error',
|
||||
kind: {
|
||||
success: 'SUCCESS',
|
||||
error: 'ERROR'
|
||||
},
|
||||
table: {
|
||||
time: 'Time',
|
||||
kind: 'Kind',
|
||||
platform: 'Platform',
|
||||
model: 'Model',
|
||||
duration: 'Duration',
|
||||
status: 'Status',
|
||||
requestId: 'Request ID',
|
||||
actions: 'Actions'
|
||||
}
|
||||
},
|
||||
alertEvents: {
|
||||
title: 'Alert Events',
|
||||
description: 'Recent alert firing/resolution records (email-only)',
|
||||
loading: 'Loading...',
|
||||
empty: 'No alert events',
|
||||
loadFailed: 'Failed to load alert events',
|
||||
status: {
|
||||
firing: 'FIRING',
|
||||
resolved: 'RESOLVED',
|
||||
manualResolved: 'MANUAL RESOLVED'
|
||||
},
|
||||
detail: {
|
||||
title: 'Alert Detail',
|
||||
loading: 'Loading detail...',
|
||||
empty: 'No detail',
|
||||
loadFailed: 'Failed to load alert detail',
|
||||
manualResolve: 'Mark as Resolved',
|
||||
manualResolvedSuccess: 'Marked as manually resolved',
|
||||
manualResolvedFailed: 'Failed to mark as manually resolved',
|
||||
silence: 'Ignore Alert',
|
||||
silenceSuccess: 'Alert silenced',
|
||||
silenceFailed: 'Failed to silence alert',
|
||||
viewRule: 'View Rule',
|
||||
viewLogs: 'View Logs',
|
||||
firedAt: 'Fired At',
|
||||
resolvedAt: 'Resolved At',
|
||||
ruleId: 'Rule ID',
|
||||
dimensions: 'Dimensions',
|
||||
historyTitle: 'History',
|
||||
historyHint: 'Recent events with same rule + dimensions',
|
||||
historyLoading: 'Loading history...',
|
||||
historyEmpty: 'No history'
|
||||
},
|
||||
table: {
|
||||
time: 'Time',
|
||||
status: 'Status',
|
||||
severity: 'Severity',
|
||||
platform: 'Platform',
|
||||
ruleId: 'Rule ID',
|
||||
title: 'Title',
|
||||
duration: 'Duration',
|
||||
metric: 'Metric / Threshold',
|
||||
dimensions: 'Dimensions',
|
||||
email: 'Email Sent',
|
||||
emailSent: 'Sent',
|
||||
emailIgnored: 'Ignored'
|
||||
}
|
||||
},
|
||||
alertRules: {
|
||||
title: 'Alert Rules',
|
||||
description: 'Create and manage threshold-based system alerts (email-only)',
|
||||
loading: 'Loading...',
|
||||
empty: 'No alert rules',
|
||||
loadFailed: 'Failed to load alert rules',
|
||||
saveFailed: 'Failed to save alert rule',
|
||||
saveSuccess: 'Alert rule saved successfully',
|
||||
deleteFailed: 'Failed to delete alert rule',
|
||||
deleteSuccess: 'Alert rule deleted successfully',
|
||||
manage: 'Manage Alert Rules',
|
||||
create: 'Create Rule',
|
||||
createTitle: 'Create Alert Rule',
|
||||
editTitle: 'Edit Alert Rule',
|
||||
deleteConfirmTitle: 'Delete this rule?',
|
||||
deleteConfirmMessage: 'This will remove the rule and its related events. Continue?',
|
||||
metricGroups: {
|
||||
system: 'System Metrics',
|
||||
group: 'Group-level Metrics (requires group_id)',
|
||||
account: 'Account-level Metrics'
|
||||
},
|
||||
metrics: {
|
||||
successRate: 'Success Rate (%)',
|
||||
errorRate: 'Error Rate (%)',
|
||||
upstreamErrorRate: 'Upstream Error Rate (%)',
|
||||
p95: 'P95 Latency (ms)',
|
||||
p99: 'P99 Latency (ms)',
|
||||
cpu: 'CPU Usage (%)',
|
||||
memory: 'Memory Usage (%)',
|
||||
queueDepth: 'Concurrency Queue Depth',
|
||||
groupAvailableAccounts: 'Group Available Accounts',
|
||||
groupAvailableRatio: 'Group Available Ratio (%)',
|
||||
groupRateLimitRatio: 'Group Rate Limit Ratio (%)',
|
||||
accountRateLimitedCount: 'Rate-limited Accounts',
|
||||
accountErrorCount: 'Error Accounts (excluding temporarily unschedulable)',
|
||||
accountErrorRatio: 'Error Account Ratio (%)',
|
||||
accountTempUnscheduledCount: 'Temporarily Unschedulable Accounts',
|
||||
overloadAccountCount: 'Overloaded Accounts'
|
||||
},
|
||||
metricDescriptions: {
|
||||
successRate: 'Percentage of successful requests in the window (0-100).',
|
||||
errorRate: 'Percentage of failed requests in the window (0-100).',
|
||||
upstreamErrorRate: 'Percentage of upstream failures in the window (0-100).',
|
||||
p95: 'P95 request latency within the window (ms).',
|
||||
p99: 'P99 request latency within the window (ms).',
|
||||
cpu: 'Current instance CPU usage (0-100).',
|
||||
memory: 'Current instance memory usage (0-100).',
|
||||
queueDepth: 'Concurrency queue depth within the window (queued requests).',
|
||||
groupAvailableAccounts: 'Number of available accounts in the selected group (requires group_id).',
|
||||
groupAvailableRatio: 'Available account ratio in the selected group (0-100, requires group_id).',
|
||||
groupRateLimitRatio: 'Rate-limited account ratio in the selected group (0-100, requires group_id).',
|
||||
accountRateLimitedCount: 'Number of rate-limited accounts within the window.',
|
||||
accountErrorCount: 'Number of error accounts within the window (excluding temporarily unschedulable).',
|
||||
accountErrorRatio: 'Error account ratio within the window (0-100).',
|
||||
accountTempUnscheduledCount: 'Number of accounts currently temporarily unschedulable (e.g. proxy/credential failure auto-eviction).',
|
||||
overloadAccountCount: 'Number of overloaded accounts within the window.'
|
||||
},
|
||||
hints: {
|
||||
recommended: 'Recommended: operator {operator}, threshold {threshold}{unit}',
|
||||
groupRequired: 'This is a group-level metric; selecting a group (group_id) is required.',
|
||||
groupOptional: 'Optional: limit the rule to a specific group via group_id.'
|
||||
},
|
||||
table: {
|
||||
name: 'Name',
|
||||
metric: 'Metric',
|
||||
severity: 'Severity',
|
||||
enabled: 'Enabled',
|
||||
actions: 'Actions'
|
||||
},
|
||||
form: {
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
metric: 'Metric',
|
||||
operator: 'Operator',
|
||||
groupId: 'Group (group_id)',
|
||||
groupPlaceholder: 'Select a group',
|
||||
allGroups: 'All groups',
|
||||
threshold: 'Threshold',
|
||||
severity: 'Severity',
|
||||
window: 'Window (minutes)',
|
||||
sustained: 'Sustained (samples)',
|
||||
cooldown: 'Cooldown (minutes)',
|
||||
enabled: 'Enabled',
|
||||
notifyEmail: 'Send email notifications'
|
||||
},
|
||||
validation: {
|
||||
title: 'Please fix the following issues',
|
||||
invalid: 'Invalid rule',
|
||||
nameRequired: 'Name is required',
|
||||
metricRequired: 'Metric is required',
|
||||
groupIdRequired: 'group_id is required for group-level metrics',
|
||||
operatorRequired: 'Operator is required',
|
||||
thresholdRequired: 'Threshold must be a number',
|
||||
windowRange: 'Window must be one of: 1, 5, 60 minutes',
|
||||
sustainedRange: 'Sustained must be between 1 and 1440 samples',
|
||||
cooldownRange: 'Cooldown must be between 0 and 1440 minutes'
|
||||
}
|
||||
},
|
||||
runtime: {
|
||||
title: 'Ops Runtime Settings',
|
||||
description: 'Stored in database; changes take effect without editing config files.',
|
||||
loading: 'Loading...',
|
||||
noData: 'No runtime settings available',
|
||||
loadFailed: 'Failed to load runtime settings',
|
||||
saveSuccess: 'Runtime settings saved',
|
||||
saveFailed: 'Failed to save runtime settings',
|
||||
alertTitle: 'Alert Evaluator',
|
||||
groupAvailabilityTitle: 'Group Availability Monitor',
|
||||
evalIntervalSeconds: 'Evaluation Interval (seconds)',
|
||||
silencing: {
|
||||
title: 'Alert Silencing (Maintenance Mode)',
|
||||
enabled: 'Enable silencing',
|
||||
globalUntil: 'Silence until (RFC3339)',
|
||||
untilHint: 'Leave empty to only toggle silencing without an expiry (not recommended).',
|
||||
reason: 'Reason',
|
||||
reasonPlaceholder: 'e.g., planned maintenance',
|
||||
entries: {
|
||||
title: 'Advanced: targeted silencing',
|
||||
hint: 'Optional: silence only certain rules or severities. Leave fields empty to match all.',
|
||||
add: 'Add Entry',
|
||||
empty: 'No targeted entries',
|
||||
entryTitle: 'Entry #{n}',
|
||||
ruleId: 'Rule ID (optional)',
|
||||
ruleIdPlaceholder: 'e.g., 1',
|
||||
severities: 'Severities (optional)',
|
||||
severitiesPlaceholder: 'e.g., P0,P1 (empty = all)',
|
||||
until: 'Until (RFC3339)',
|
||||
reason: 'Reason',
|
||||
validation: {
|
||||
untilRequired: 'Entry until time is required',
|
||||
untilFormat: 'Entry until time must be a valid RFC3339 timestamp',
|
||||
ruleIdPositive: 'Entry rule_id must be a positive integer',
|
||||
severitiesFormat: 'Entry severities must be a comma-separated list of P0..P3'
|
||||
}
|
||||
},
|
||||
validation: {
|
||||
timeFormat: 'Silence time must be a valid RFC3339 timestamp'
|
||||
}
|
||||
},
|
||||
lockEnabled: 'Distributed Lock Enabled',
|
||||
lockKey: 'Distributed Lock Key',
|
||||
lockTTLSeconds: 'Distributed Lock TTL (seconds)',
|
||||
showAdvancedDeveloperSettings: 'Show advanced developer settings (Distributed Lock)',
|
||||
advancedSettingsSummary: 'Advanced settings (Distributed Lock)',
|
||||
evalIntervalHint: 'How often the evaluator runs. Keeping the default is recommended.',
|
||||
validation: {
|
||||
title: 'Please fix the following issues',
|
||||
invalid: 'Invalid settings',
|
||||
evalIntervalRange: 'Evaluation interval must be between 1 and 86400 seconds',
|
||||
lockKeyRequired: 'Distributed lock key is required when lock is enabled',
|
||||
lockKeyPrefix: 'Distributed lock key must start with "{prefix}"',
|
||||
lockKeyHint: 'Recommended: start with "{prefix}" to avoid conflicts',
|
||||
lockTtlRange: 'Distributed lock TTL must be between 1 and 86400 seconds',
|
||||
slaMinPercentRange: 'SLA minimum percentage must be between 0 and 100',
|
||||
ttftP99MaxRange: 'TTFT P99 maximum must be a number ≥ 0',
|
||||
requestErrorRateMaxRange: 'Request error rate maximum must be between 0 and 100',
|
||||
upstreamErrorRateMaxRange: 'Upstream error rate maximum must be between 0 and 100'
|
||||
}
|
||||
},
|
||||
email: {
|
||||
title: 'Email Notification',
|
||||
description: 'Configure alert/report email notifications (stored in database).',
|
||||
loading: 'Loading...',
|
||||
noData: 'No email notification config',
|
||||
loadFailed: 'Failed to load email notification config',
|
||||
saveSuccess: 'Email notification config saved',
|
||||
saveFailed: 'Failed to save email notification config',
|
||||
alertTitle: 'Alert Emails',
|
||||
reportTitle: 'Report Emails',
|
||||
recipients: 'Recipients',
|
||||
recipientsHint: 'If empty, the system may fallback to the first admin email.',
|
||||
minSeverity: 'Min Severity',
|
||||
minSeverityAll: 'All severities',
|
||||
rateLimitPerHour: 'Rate limit per hour',
|
||||
batchWindowSeconds: 'Batch window (seconds)',
|
||||
includeResolved: 'Include resolved alerts',
|
||||
dailySummary: 'Daily summary',
|
||||
weeklySummary: 'Weekly summary',
|
||||
errorDigest: 'Error digest',
|
||||
errorDigestMinCount: 'Min errors for digest',
|
||||
accountHealth: 'Account health',
|
||||
accountHealthThreshold: 'Error rate threshold (%)',
|
||||
cronPlaceholder: 'Cron expression',
|
||||
reportHint: 'Schedules use cron syntax; leave empty to use defaults.',
|
||||
validation: {
|
||||
title: 'Please fix the following issues',
|
||||
invalid: 'Invalid email notification config',
|
||||
alertRecipientsRequired: 'Alert emails are enabled but no recipients are configured',
|
||||
reportRecipientsRequired: 'Report emails are enabled but no recipients are configured',
|
||||
invalidRecipients: 'One or more recipient emails are invalid',
|
||||
rateLimitRange: 'Rate limit per hour must be a number ≥ 0',
|
||||
batchWindowRange: 'Batch window must be between 0 and 86400 seconds',
|
||||
cronRequired: 'A cron expression is required when schedule is enabled',
|
||||
cronFormat: 'Cron expression format looks invalid (expected at least 5 parts)',
|
||||
digestMinCountRange: 'Min errors for digest must be a number ≥ 0',
|
||||
accountHealthThresholdRange: 'Account health threshold must be between 0 and 100'
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
title: 'Ops Monitoring Settings',
|
||||
loadFailed: 'Failed to load settings',
|
||||
saveSuccess: 'Ops monitoring settings saved successfully',
|
||||
saveFailed: 'Failed to save settings',
|
||||
dataCollection: 'Data Collection',
|
||||
evaluationInterval: 'Evaluation Interval (seconds)',
|
||||
evaluationIntervalHint: 'Frequency of detection tasks, recommended to keep default',
|
||||
alertConfig: 'Alert Configuration',
|
||||
enableAlert: 'Enable Alerts',
|
||||
alertRecipients: 'Alert Recipient Emails',
|
||||
emailPlaceholder: 'Enter email address',
|
||||
recipientsHint: 'If empty, the system will use the first admin email as default recipient',
|
||||
minSeverity: 'Minimum Severity',
|
||||
reportConfig: 'Report Configuration',
|
||||
enableReport: 'Enable Reports',
|
||||
reportRecipients: 'Report Recipient Emails',
|
||||
dailySummary: 'Daily Summary',
|
||||
weeklySummary: 'Weekly Summary',
|
||||
metricThresholds: 'Metric Thresholds',
|
||||
metricThresholdsHint: 'Configure alert thresholds for metrics, values exceeding thresholds will be displayed in red',
|
||||
slaMinPercent: 'SLA Minimum Percentage',
|
||||
slaMinPercentHint: 'SLA below this value will be displayed in red (default: 99.5%)',
|
||||
ttftP99MaxMs: 'TTFT P99 Maximum (ms)',
|
||||
ttftP99MaxMsHint: 'TTFT P99 above this value will be displayed in red (default: 500ms)',
|
||||
requestErrorRateMaxPercent: 'Request Error Rate Maximum (%)',
|
||||
requestErrorRateMaxPercentHint: 'Request error rate above this value will be displayed in red (default: 5%)',
|
||||
upstreamErrorRateMaxPercent: 'Upstream Error Rate Maximum (%)',
|
||||
upstreamErrorRateMaxPercentHint: 'Upstream error rate above this value will be displayed in red (default: 5%)',
|
||||
advancedSettings: 'Advanced Settings',
|
||||
dataRetention: 'Data Retention Policy',
|
||||
enableCleanup: 'Enable Data Cleanup',
|
||||
cleanupSchedule: 'Cleanup Schedule (Cron)',
|
||||
cleanupScheduleHint: 'Example: 0 2 * * * means 2 AM daily',
|
||||
errorLogRetentionDays: 'Error Log Retention Days',
|
||||
minuteMetricsRetentionDays: 'Minute Metrics Retention Days',
|
||||
hourlyMetricsRetentionDays: 'Hourly Metrics Retention Days',
|
||||
retentionDaysHint: 'Recommended 7-90 days; longer periods consume more storage. Set to 0 to wipe all history on every scheduled cleanup',
|
||||
aggregation: 'Pre-aggregation Tasks',
|
||||
enableAggregation: 'Enable Pre-aggregation',
|
||||
aggregationHint: 'Pre-aggregation improves query performance for long time windows',
|
||||
openaiQuotaAutoPause: 'OpenAI Account Quota Auto-pause',
|
||||
openaiQuotaAutoPauseHint: 'When an OpenAI account reaches its 5h / 7d usage threshold, the scheduler skips it automatically and resumes once the window rolls over. Per-account thresholds take precedence over this global default.',
|
||||
openaiQuotaAutoPauseDefault5h: 'Default 5h usage threshold (%)',
|
||||
openaiQuotaAutoPauseDefault7d: 'Default 7d usage threshold (%)',
|
||||
openaiQuotaAutoPauseThresholdHint: 'Value 0-100; leave blank or 0 to disable the global default threshold.',
|
||||
errorFiltering: 'Error Filtering',
|
||||
ignoreCountTokensErrors: 'Ignore count_tokens errors',
|
||||
ignoreCountTokensErrorsHint: 'When enabled, errors from count_tokens requests will not be written to the error log.',
|
||||
ignoreContextCanceled: 'Ignore client disconnect errors',
|
||||
ignoreContextCanceledHint: 'When enabled, client disconnect (context canceled) errors will not be written to the error log.',
|
||||
ignoreNoAvailableAccounts: 'Ignore no available accounts errors',
|
||||
ignoreNoAvailableAccountsHint: 'When enabled, "No available accounts" errors will not be written to the error log (not recommended; usually a config issue).',
|
||||
ignoreInvalidApiKeyErrors: 'Ignore invalid API key errors',
|
||||
ignoreInvalidApiKeyErrorsHint: 'When enabled, invalid or missing API key errors (INVALID_API_KEY, API_KEY_REQUIRED) will not be written to the error log.',
|
||||
ignoreInsufficientBalanceErrors: 'Ignore Insufficient Balance Errors',
|
||||
ignoreInsufficientBalanceErrorsHint: 'When enabled, insufficient account balance errors will not be written to the error log.',
|
||||
autoRefresh: 'Auto Refresh',
|
||||
enableAutoRefresh: 'Enable auto refresh',
|
||||
enableAutoRefreshHint: 'Automatically refresh dashboard data at a fixed interval.',
|
||||
refreshInterval: 'Refresh Interval',
|
||||
refreshInterval15s: '15 seconds',
|
||||
refreshInterval30s: '30 seconds',
|
||||
refreshInterval60s: '60 seconds',
|
||||
dashboardCards: 'Dashboard Cards',
|
||||
displayAlertEvents: 'Display alert events',
|
||||
displayAlertEventsHint: 'Show or hide the recent alert events card on the ops dashboard. Enabled by default.',
|
||||
displayOpenAITokenStats: 'Display OpenAI token request stats',
|
||||
displayOpenAITokenStatsHint: 'Show or hide the OpenAI token request stats card on the ops dashboard. Hidden by default.',
|
||||
autoRefreshCountdown: 'Auto refresh: {seconds}s',
|
||||
validation: {
|
||||
title: 'Please fix the following issues',
|
||||
retentionDaysRange: 'Retention days must be between 0 and 365 (0 = wipe all on every cleanup)',
|
||||
slaMinPercentRange: 'SLA minimum percentage must be between 0 and 100',
|
||||
ttftP99MaxRange: 'TTFT P99 maximum must be a number ≥ 0',
|
||||
requestErrorRateMaxRange: 'Request error rate maximum must be between 0 and 100',
|
||||
upstreamErrorRateMaxRange: 'Upstream error rate maximum must be between 0 and 100',
|
||||
openaiQuotaAutoPauseRange: 'OpenAI quota auto-pause threshold must be between 0 and 100'
|
||||
}
|
||||
},
|
||||
concurrency: {
|
||||
title: 'Concurrency / Queue',
|
||||
byPlatform: 'By Platform',
|
||||
byGroup: 'By Group',
|
||||
byAccount: 'By Account',
|
||||
byUser: 'By User',
|
||||
showByUserTooltip: 'Switch to user view to see concurrency usage per user',
|
||||
switchToUser: 'Switch to user view',
|
||||
switchToPlatform: 'Switch to platform view',
|
||||
totalRows: '{count} rows',
|
||||
disabledHint: 'Realtime monitoring is disabled in settings.',
|
||||
empty: 'No data',
|
||||
queued: 'Queue {count}',
|
||||
rateLimited: 'Rate-limited {count}',
|
||||
errorAccounts: 'Errors {count}',
|
||||
loadFailed: 'Failed to load concurrency data'
|
||||
},
|
||||
realtime: {
|
||||
title: 'Realtime',
|
||||
connected: 'Realtime connected',
|
||||
connecting: 'Realtime connecting',
|
||||
reconnecting: 'Realtime reconnecting',
|
||||
offline: 'Realtime offline',
|
||||
closed: 'Realtime closed',
|
||||
reconnectIn: 'retry in {seconds}s'
|
||||
},
|
||||
queryMode: {
|
||||
auto: 'Auto',
|
||||
raw: 'Raw',
|
||||
preagg: 'Preagg'
|
||||
},
|
||||
accountAvailability: {
|
||||
available: 'Available',
|
||||
unavailable: 'Unavailable',
|
||||
accountError: 'Error'
|
||||
},
|
||||
tooltips: {
|
||||
totalRequests: 'Total number of requests (including both successful and failed requests) in the selected time window.',
|
||||
throughputTrend: 'Requests/QPS + Tokens/TPS in the selected window.',
|
||||
switchRateTrend: 'Trend of account switches / total requests over the last 5 hours (avg switches).',
|
||||
latencyHistogram: 'Request duration distribution (ms) for successful requests.',
|
||||
errorTrend: 'Error counts over time (SLA scope excludes business limits; upstream excludes 429/529).',
|
||||
errorDistribution: 'Error distribution by status code (SLA scope, excluding business limits).',
|
||||
goroutines:
|
||||
'Number of Go runtime goroutines (lightweight threads). There is no absolute "safe" number—use your historical baseline. Heuristic: <2k is common; 2k–8k watch; >8k plus rising queue/latency often suggests blocking/leaks.',
|
||||
cpu: 'CPU usage percentage, showing system processor load.',
|
||||
memory: 'Memory usage, including used and total available memory.',
|
||||
db: 'Database connection pool status, including active, idle, and waiting connections.',
|
||||
redis: 'Redis connection pool status, showing active and idle connections.',
|
||||
jobs: 'Background job execution status, including last run time, success time, and error information.',
|
||||
qps: 'Queries Per Second (QPS) and Tokens Per Second (TPS), real-time system throughput.',
|
||||
tokens: 'Total number of tokens processed in the current time window.',
|
||||
sla: 'Service Level Agreement success rate, excluding business limits (e.g., insufficient balance, quota exceeded).',
|
||||
errors: 'Error statistics, including total errors, error rate, and upstream error rate.',
|
||||
upstreamErrors: 'Upstream error statistics, excluding rate limit errors (429/529).',
|
||||
latency: 'Request duration statistics, including p50, p90, p95, p99 percentiles.',
|
||||
ttft: 'Time To First Token, measuring the speed of first token return in streaming responses.',
|
||||
health: 'System health score (0-100), considering SLA, error rate, and resource usage.'
|
||||
},
|
||||
charts: {
|
||||
emptyRequest: 'No requests in this window.',
|
||||
emptyError: 'No errors in this window.',
|
||||
resetZoom: 'Reset',
|
||||
resetZoomHint: 'Reset zoom (if enabled)',
|
||||
downloadChart: 'Download',
|
||||
downloadChartHint: 'Download chart as image'
|
||||
}
|
||||
},
|
||||
|
||||
// Settings
|
||||
}
|
||||
@@ -0,0 +1,949 @@
|
||||
export default {
|
||||
// Dashboard
|
||||
dashboard: {
|
||||
title: 'Admin Dashboard',
|
||||
description: 'System overview and real-time statistics',
|
||||
apiKeys: 'API Keys',
|
||||
accounts: 'Accounts',
|
||||
users: 'Users',
|
||||
todayRequests: 'Today Requests',
|
||||
newUsersToday: 'New Users Today',
|
||||
todayTokens: 'Today Tokens',
|
||||
totalTokens: 'Total Tokens',
|
||||
cacheToday: 'Cache (Today)',
|
||||
performance: 'Performance',
|
||||
avgResponse: 'Avg Response',
|
||||
active: 'active',
|
||||
ok: 'ok',
|
||||
err: 'err',
|
||||
activeUsers: 'active users',
|
||||
create: 'Create',
|
||||
timeRange: 'Time Range',
|
||||
granularity: 'Granularity',
|
||||
day: 'Day',
|
||||
hour: 'Hour',
|
||||
modelDistribution: 'Model Distribution',
|
||||
groupDistribution: 'Group Usage Distribution',
|
||||
metricTokens: 'By Tokens',
|
||||
metricActualCost: 'By Actual Cost',
|
||||
tokenUsageTrend: 'Token Usage Trend',
|
||||
userUsageTrend: 'User Usage Trend (Top 12)',
|
||||
model: 'Model',
|
||||
group: 'Group',
|
||||
noGroup: 'No Group',
|
||||
requests: 'Requests',
|
||||
tokens: 'Tokens',
|
||||
actual: 'Actual',
|
||||
standard: 'Standard',
|
||||
accountCost: 'Cost',
|
||||
noDataAvailable: 'No data available',
|
||||
recentUsage: 'Recent Usage',
|
||||
viewModelDistribution: 'Model Distribution',
|
||||
viewSpendingRanking: 'User Spending Ranking',
|
||||
spendingRankingTitle: 'User Spending Ranking',
|
||||
spendingRankingUser: 'User',
|
||||
spendingRankingRequests: 'Requests',
|
||||
spendingRankingTokens: 'Tokens',
|
||||
spendingRankingSpend: 'Spend',
|
||||
spendingRankingOther: 'Others',
|
||||
spendingRankingUsage: 'Usage',
|
||||
spendShort: 'Spend',
|
||||
requestsShort: 'Req',
|
||||
tokensShort: 'Tok',
|
||||
quickActions: 'Quick Actions',
|
||||
batchImage: 'Batch Image',
|
||||
batchImageDesc: 'Submit jobs and copy agent instructions',
|
||||
groupPricing: 'Group Pricing',
|
||||
groupPricingDesc: 'Configure batch discount and hold ratio',
|
||||
failedToLoad: 'Failed to load dashboard statistics'
|
||||
},
|
||||
|
||||
backup: {
|
||||
title: 'Database Backup',
|
||||
description: 'Full database backup to S3-compatible storage with scheduled backup and restore',
|
||||
s3: {
|
||||
title: 'S3 Storage Configuration',
|
||||
description: 'Configure S3-compatible storage (supports Cloudflare R2)',
|
||||
descriptionPrefix: 'Configure S3-compatible storage (supports',
|
||||
descriptionSuffix: ')',
|
||||
enabled: 'Enable S3 Storage',
|
||||
endpoint: 'Endpoint',
|
||||
region: 'Region',
|
||||
bucket: 'Bucket',
|
||||
prefix: 'Key Prefix',
|
||||
accessKeyId: 'Access Key ID',
|
||||
secretAccessKey: 'Secret Access Key',
|
||||
secretConfigured: 'Already configured, leave empty to keep',
|
||||
forcePathStyle: 'Force Path Style',
|
||||
testConnection: 'Test Connection',
|
||||
testSuccess: 'S3 connection test successful',
|
||||
testFailed: 'S3 connection test failed',
|
||||
saved: 'S3 configuration saved'
|
||||
},
|
||||
schedule: {
|
||||
title: 'Scheduled Backup',
|
||||
description: 'Configure automatic scheduled backups',
|
||||
enabled: 'Enable Scheduled Backup',
|
||||
cronExpr: 'Cron Expression',
|
||||
cronHint: 'e.g. "0 2 * * *" means every day at 2:00 AM',
|
||||
retainDays: 'Backup Expire Days',
|
||||
retainDaysHint: 'Backup files auto-delete after this many days, 0 = never expire',
|
||||
retainCount: 'Max Retain Count',
|
||||
retainCountHint: 'Maximum number of backups to keep, 0 = unlimited',
|
||||
saved: 'Schedule configuration saved'
|
||||
},
|
||||
operations: {
|
||||
title: 'Backup Records',
|
||||
description: 'Create manual backups and manage existing backup records',
|
||||
createBackup: 'Create Backup',
|
||||
backing: 'Backing up...',
|
||||
backupCreated: 'Backup created successfully',
|
||||
expireDays: 'Expire Days',
|
||||
alreadyInProgress: 'A backup is already in progress',
|
||||
backupRunning: 'Backup in progress...',
|
||||
backupFailed: 'Backup failed',
|
||||
restoreRunning: 'Restore in progress...',
|
||||
restoreFailed: 'Restore failed',
|
||||
},
|
||||
columns: {
|
||||
status: 'Status',
|
||||
fileName: 'File Name',
|
||||
size: 'Size',
|
||||
expiresAt: 'Expires At',
|
||||
triggeredBy: 'Triggered By',
|
||||
startedAt: 'Started At',
|
||||
actions: 'Actions'
|
||||
},
|
||||
status: {
|
||||
pending: 'Pending',
|
||||
running: 'Running',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed'
|
||||
},
|
||||
progress: {
|
||||
pending: 'Preparing',
|
||||
dumping: 'Dumping database',
|
||||
uploading: 'Uploading',
|
||||
},
|
||||
trigger: {
|
||||
manual: 'Manual',
|
||||
scheduled: 'Scheduled'
|
||||
},
|
||||
neverExpire: 'Never',
|
||||
empty: 'No backup records',
|
||||
actions: {
|
||||
download: 'Download',
|
||||
restore: 'Restore',
|
||||
restoreConfirm: 'Are you sure you want to restore from this backup? This will overwrite the current database!',
|
||||
restorePasswordPrompt: 'Please enter your admin password to confirm the restore operation',
|
||||
restoreSuccess: 'Database restored successfully',
|
||||
deleteConfirm: 'Are you sure you want to delete this backup?',
|
||||
deleted: 'Backup deleted'
|
||||
},
|
||||
r2Guide: {
|
||||
title: 'Cloudflare R2 Setup Guide',
|
||||
intro: 'Cloudflare R2 provides S3-compatible object storage with a free tier of 10GB storage + 1M Class A requests/month, ideal for database backups.',
|
||||
step1: {
|
||||
title: 'Create an R2 Bucket',
|
||||
line1: 'Log in to the Cloudflare Dashboard (dash.cloudflare.com), select "R2 Object Storage" from the sidebar',
|
||||
line2: 'Click "Create bucket", enter a name (e.g. sub2api-backups), choose a region',
|
||||
line3: 'Click create to finish'
|
||||
},
|
||||
step2: {
|
||||
title: 'Create an API Token',
|
||||
line1: 'On the R2 page, click "Manage R2 API Tokens" in the top right',
|
||||
line2: 'Click "Create API token", set permission to "Object Read & Write"',
|
||||
line3: 'Recommended: restrict to specific bucket for better security',
|
||||
line4: 'After creation, you will see the Access Key ID and Secret Access Key',
|
||||
warning: 'The Secret Access Key is only shown once — copy and save it immediately!'
|
||||
},
|
||||
step3: {
|
||||
title: 'Get the S3 Endpoint',
|
||||
desc: 'Find your Account ID on the R2 overview page (in the URL or the right panel). The endpoint format is:',
|
||||
accountId: 'your_account_id'
|
||||
},
|
||||
step4: {
|
||||
title: 'Fill in the Configuration',
|
||||
checkEnabled: 'Checked',
|
||||
bucketValue: 'Your bucket name',
|
||||
fromStep2: 'Value from Step 2',
|
||||
unchecked: 'Unchecked'
|
||||
},
|
||||
freeTier: 'R2 Free Tier: 10GB storage + 1M Class A requests + 10M Class B requests per month — more than enough for database backups.'
|
||||
}
|
||||
},
|
||||
|
||||
dataManagement: {
|
||||
title: 'Data Management',
|
||||
description: 'Manage data management agent status, object storage settings, and backup jobs in one place',
|
||||
agent: {
|
||||
title: 'Data Management Agent Status',
|
||||
description: 'The system probes a fixed Unix socket and enables data management only when reachable.',
|
||||
enabled: 'Data management agent is ready. Data management operations are available.',
|
||||
disabled: 'Data management agent is unavailable. Only diagnostic information is available now.',
|
||||
socketPath: 'Socket Path',
|
||||
version: 'Version',
|
||||
status: 'Status',
|
||||
uptime: 'Uptime',
|
||||
reasonLabel: 'Unavailable Reason',
|
||||
reason: {
|
||||
DATA_MANAGEMENT_AGENT_SOCKET_MISSING: 'Data management socket file is missing',
|
||||
DATA_MANAGEMENT_AGENT_UNAVAILABLE: 'Data management agent is unreachable',
|
||||
BACKUP_AGENT_SOCKET_MISSING: 'Backup socket file is missing',
|
||||
BACKUP_AGENT_UNAVAILABLE: 'Backup agent is unreachable',
|
||||
UNKNOWN: 'Unknown reason'
|
||||
}
|
||||
},
|
||||
sections: {
|
||||
config: {
|
||||
title: 'Backup Configuration',
|
||||
description: 'Configure backup source, retention policy, and S3 settings.'
|
||||
},
|
||||
s3: {
|
||||
title: 'S3 Object Storage',
|
||||
description: 'Configure and test uploads of backup artifacts to a standard S3-compatible storage.'
|
||||
},
|
||||
backup: {
|
||||
title: 'Backup Operations',
|
||||
description: 'Trigger PostgreSQL, Redis, and full backup jobs.'
|
||||
},
|
||||
history: {
|
||||
title: 'Backup History',
|
||||
description: 'Review backup job status, errors, and artifact metadata.'
|
||||
}
|
||||
},
|
||||
form: {
|
||||
sourceMode: 'Source Mode',
|
||||
backupRoot: 'Backup Root',
|
||||
activePostgresProfile: 'Active PostgreSQL Profile',
|
||||
activeRedisProfile: 'Active Redis Profile',
|
||||
activeS3Profile: 'Active S3 Profile',
|
||||
retentionDays: 'Retention Days',
|
||||
keepLast: 'Keep Last Jobs',
|
||||
uploadToS3: 'Upload to S3',
|
||||
useActivePostgresProfile: 'Use Active PostgreSQL Profile',
|
||||
useActiveRedisProfile: 'Use Active Redis Profile',
|
||||
useActiveS3Profile: 'Use Active Profile',
|
||||
idempotencyKey: 'Idempotency Key (Optional)',
|
||||
secretConfigured: 'Configured already, leave empty to keep unchanged',
|
||||
source: {
|
||||
profileID: 'Profile ID (Unique)',
|
||||
profileName: 'Profile Name',
|
||||
setActive: 'Set as active after creation'
|
||||
},
|
||||
postgres: {
|
||||
title: 'PostgreSQL',
|
||||
host: 'Host',
|
||||
port: 'Port',
|
||||
user: 'User',
|
||||
password: 'Password',
|
||||
database: 'Database',
|
||||
sslMode: 'SSL Mode',
|
||||
containerName: 'Container Name (docker_exec mode)'
|
||||
},
|
||||
redis: {
|
||||
title: 'Redis',
|
||||
addr: 'Address (host:port)',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
db: 'Database Index',
|
||||
containerName: 'Container Name (docker_exec mode)'
|
||||
},
|
||||
s3: {
|
||||
enabled: 'Enable S3 Upload',
|
||||
profileID: 'Profile ID (Unique)',
|
||||
profileName: 'Profile Name',
|
||||
endpoint: 'Endpoint (Optional)',
|
||||
region: 'Region',
|
||||
bucket: 'Bucket',
|
||||
accessKeyID: 'Access Key ID',
|
||||
secretAccessKey: 'Secret Access Key',
|
||||
prefix: 'Object Prefix',
|
||||
forcePathStyle: 'Force Path Style',
|
||||
useSSL: 'Use SSL',
|
||||
setActive: 'Set as active after creation'
|
||||
}
|
||||
},
|
||||
sourceProfiles: {
|
||||
createTitle: 'Create Source Profile',
|
||||
editTitle: 'Edit Source Profile',
|
||||
empty: 'No source profiles yet, create one first',
|
||||
deleteConfirm: 'Delete source profile {profileID}?',
|
||||
columns: {
|
||||
profile: 'Profile',
|
||||
active: 'Active',
|
||||
connection: 'Connection',
|
||||
database: 'Database',
|
||||
updatedAt: 'Updated At',
|
||||
actions: 'Actions'
|
||||
}
|
||||
},
|
||||
s3Profiles: {
|
||||
createTitle: 'Create S3 Profile',
|
||||
editTitle: 'Edit S3 Profile',
|
||||
empty: 'No S3 profiles yet, create one first',
|
||||
editHint: 'Click "Edit" to modify profile details in the right drawer.',
|
||||
deleteConfirm: 'Delete S3 profile {profileID}?',
|
||||
columns: {
|
||||
profile: 'Profile',
|
||||
active: 'Active',
|
||||
storage: 'Storage',
|
||||
updatedAt: 'Updated At',
|
||||
actions: 'Actions'
|
||||
}
|
||||
},
|
||||
history: {
|
||||
total: '{count} jobs',
|
||||
empty: 'No backup jobs yet',
|
||||
columns: {
|
||||
jobID: 'Job ID',
|
||||
type: 'Type',
|
||||
status: 'Status',
|
||||
triggeredBy: 'Triggered By',
|
||||
pgProfile: 'PostgreSQL Profile',
|
||||
redisProfile: 'Redis Profile',
|
||||
s3Profile: 'S3 Profile',
|
||||
finishedAt: 'Finished At',
|
||||
artifact: 'Artifact',
|
||||
error: 'Error'
|
||||
},
|
||||
status: {
|
||||
queued: 'Queued',
|
||||
running: 'Running',
|
||||
succeeded: 'Succeeded',
|
||||
failed: 'Failed',
|
||||
partial_succeeded: 'Partial Succeeded'
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
refresh: 'Refresh Status',
|
||||
disabledHint: 'Start datamanagementd first and ensure the socket is reachable.',
|
||||
reloadConfig: 'Reload Config',
|
||||
reloadSourceProfiles: 'Reload Source Profiles',
|
||||
reloadProfiles: 'Reload Profiles',
|
||||
newSourceProfile: 'New Source Profile',
|
||||
saveConfig: 'Save Config',
|
||||
configSaved: 'Configuration saved',
|
||||
testS3: 'Test S3 Connection',
|
||||
s3TestOK: 'S3 connection test succeeded',
|
||||
s3TestFailed: 'S3 connection test failed',
|
||||
newProfile: 'New Profile',
|
||||
saveProfile: 'Save Profile',
|
||||
activateProfile: 'Activate',
|
||||
profileIDRequired: 'Profile ID is required',
|
||||
profileNameRequired: 'Profile name is required',
|
||||
profileSelectRequired: 'Select a profile to edit first',
|
||||
profileCreated: 'S3 profile created',
|
||||
profileSaved: 'S3 profile saved',
|
||||
profileActivated: 'S3 profile activated',
|
||||
profileDeleted: 'S3 profile deleted',
|
||||
sourceProfileCreated: 'Source profile created',
|
||||
sourceProfileSaved: 'Source profile saved',
|
||||
sourceProfileActivated: 'Source profile activated',
|
||||
sourceProfileDeleted: 'Source profile deleted',
|
||||
createBackup: 'Create Backup Job',
|
||||
jobCreated: 'Backup job created: {jobID} ({status})',
|
||||
refreshJobs: 'Refresh Jobs',
|
||||
loadMore: 'Load More'
|
||||
}
|
||||
},
|
||||
|
||||
affiliates: {
|
||||
invitesDescription: 'View site-wide inviter and invitee relationships',
|
||||
rebatesDescription: 'View recharge orders that generated affiliate rebates',
|
||||
transfersDescription: 'View affiliate quota transfers into account balance',
|
||||
errors: {
|
||||
loadFailed: 'Failed to load affiliate records'
|
||||
},
|
||||
records: {
|
||||
search: 'Search',
|
||||
searchPlaceholder: 'Email, username, user ID, or order number',
|
||||
startAt: 'Start date',
|
||||
endAt: 'End date',
|
||||
inviter: 'Inviter',
|
||||
invitee: 'Invitee',
|
||||
user: 'User',
|
||||
affCode: 'Invite Code',
|
||||
order: 'Order',
|
||||
totalRebate: 'Total Rebate',
|
||||
orderAmount: 'Top-up Amount',
|
||||
payAmount: 'Paid Amount',
|
||||
rebateAmount: 'Rebate Amount',
|
||||
paymentType: 'Payment Method',
|
||||
orderStatus: 'Order Status',
|
||||
transferAmount: 'Transfer Amount',
|
||||
balanceAfter: 'Balance After',
|
||||
availableQuotaAfter: 'Available After',
|
||||
frozenQuotaAfter: 'Frozen After',
|
||||
historyQuotaAfter: 'Historical Rebate After',
|
||||
invitedAt: 'Invited At',
|
||||
rebatedAt: 'Rebated At',
|
||||
transferredAt: 'Transferred At'
|
||||
},
|
||||
overview: {
|
||||
title: 'Affiliate User Overview',
|
||||
affCode: 'Invite Code',
|
||||
rebateRate: 'Rebate Rate',
|
||||
invitedCount: 'Invited Users',
|
||||
rebatedInviteeCount: 'Rebated Invitees',
|
||||
availableQuota: 'Available Quota',
|
||||
historyQuota: 'Historical Rebate'
|
||||
}
|
||||
},
|
||||
|
||||
// Users
|
||||
users: {
|
||||
title: 'User Management',
|
||||
description: 'Manage users and their permissions',
|
||||
createUser: 'Create User',
|
||||
editUser: 'Edit User',
|
||||
deleteUser: 'Delete User',
|
||||
searchUsers: 'Search by email, username, notes, or API key...',
|
||||
allRoles: 'All Roles',
|
||||
allStatus: 'All Status',
|
||||
allGroups: 'All Groups',
|
||||
searchGroups: 'Search groups...',
|
||||
fuzzySearch: 'Fuzzy search',
|
||||
apiKeyGroupFilter: 'API Key Group',
|
||||
apiKeyGroupExclusive: 'Exclusive Groups',
|
||||
apiKeyGroupPublic: 'Public Groups',
|
||||
apiKeyGroupSubscription: 'Subscription Groups',
|
||||
apiKeyGroupDisabled: 'Disabled Groups',
|
||||
authorizedGroupFilter: 'Authorized Group',
|
||||
allAuthorizedGroups: 'All Authorized Groups',
|
||||
searchAuthorizedGroups: 'Search authorized groups...',
|
||||
allApiKeyGroups: 'All API Key Groups',
|
||||
searchApiKeyGroups: 'Search API Key groups...',
|
||||
admin: 'Admin',
|
||||
user: 'User',
|
||||
disabled: 'Disabled',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
username: 'Username',
|
||||
notes: 'Notes',
|
||||
enterEmail: 'Enter email',
|
||||
enterPassword: 'Enter password',
|
||||
enterUsername: 'Enter username (optional)',
|
||||
enterNotes: 'Enter notes (admin only)',
|
||||
notesHint: 'This note is only visible to administrators',
|
||||
enterNewPassword: 'Enter new password (optional)',
|
||||
leaveEmptyToKeep: 'Leave empty to keep current password',
|
||||
generatePassword: 'Generate random password',
|
||||
copyPassword: 'Copy password',
|
||||
creating: 'Creating...',
|
||||
updating: 'Updating...',
|
||||
form: {
|
||||
rpmLimit: 'Requests Per Minute (RPM)',
|
||||
rpmLimitPlaceholder: '0 = unlimited',
|
||||
rpmLimitHint: 'Max requests per minute for this user; 0 = unlimited. Acts as a fallback only when the group has no rpm_limit set.'
|
||||
},
|
||||
columns: {
|
||||
user: 'User',
|
||||
id: 'ID',
|
||||
email: 'Email',
|
||||
username: 'Username',
|
||||
notes: 'Notes',
|
||||
role: 'Role',
|
||||
groups: 'Groups',
|
||||
subscriptions: 'Subscriptions',
|
||||
balance: 'Balance',
|
||||
balancePlatformQuota: 'Balance (Platform Quota)',
|
||||
usage: 'Usage',
|
||||
usageAnthropic: 'Usage (Claude)',
|
||||
usageOpenAI: 'Usage (OpenAI)',
|
||||
usageGemini: 'Usage (Gemini)',
|
||||
usageAntigravity: 'Usage (Antigravity)',
|
||||
concurrency: 'Concurrency',
|
||||
status: 'Status',
|
||||
lastActive: 'Last Active',
|
||||
lastUsed: 'Last Used',
|
||||
created: 'Created',
|
||||
actions: 'Actions'
|
||||
},
|
||||
today: 'Today',
|
||||
total: 'Last 30d',
|
||||
sortBy: 'Sort By',
|
||||
sortCurrentPageOnly: 'Sorts current page only',
|
||||
noSubscription: 'No subscription',
|
||||
publicGroupCount: '+{count} public',
|
||||
exclusiveLabel: 'exclusive',
|
||||
publicLabel: 'public',
|
||||
daysRemaining: '{days}d',
|
||||
expired: 'Expired',
|
||||
disable: 'Disable',
|
||||
enable: 'Enable',
|
||||
disableUser: 'Disable User',
|
||||
enableUser: 'Enable User',
|
||||
viewApiKeys: 'View API Keys',
|
||||
groups: 'Groups',
|
||||
apiKeys: 'API Keys',
|
||||
userApiKeys: 'User API Keys',
|
||||
noApiKeys: 'This user has no API keys',
|
||||
group: 'Group',
|
||||
none: 'None',
|
||||
groupChangedSuccess: 'Group updated successfully',
|
||||
groupChangedWithGrant: 'Group updated. User auto-granted access to "{group}"',
|
||||
groupChangeFailed: 'Failed to update group',
|
||||
noUsersYet: 'No users yet',
|
||||
createFirstUser: 'Create your first user to get started.',
|
||||
userCreated: 'User created successfully',
|
||||
userUpdated: 'User updated successfully',
|
||||
userDeleted: 'User deleted successfully',
|
||||
userEnabled: 'User enabled successfully',
|
||||
userDisabled: 'User disabled successfully',
|
||||
failedToLoad: 'Failed to load users',
|
||||
failedToCreate: 'Failed to create user',
|
||||
failedToUpdate: 'Failed to update user',
|
||||
failedToDelete: 'Failed to delete user',
|
||||
failedToToggle: 'Failed to update user status',
|
||||
failedToLoadApiKeys: 'Failed to load user API keys',
|
||||
emailRequired: 'Please enter email',
|
||||
concurrencyMin: 'Concurrency must be at least 1',
|
||||
soraStorageQuota: 'Sora Storage Quota',
|
||||
soraStorageQuotaHint: 'In GB, 0 means use group or system default quota',
|
||||
amountRequired: 'Please enter a valid amount',
|
||||
insufficientBalance: 'Insufficient balance',
|
||||
deleteConfirm: "Are you sure you want to delete '{email}'? This action cannot be undone.",
|
||||
setAllowedGroups: 'Set Allowed Groups',
|
||||
allowedGroupsHint:
|
||||
'Select which standard groups this user can use. Subscription groups are managed separately.',
|
||||
noStandardGroups: 'No standard groups available',
|
||||
allowAllGroups: 'Allow All Groups',
|
||||
allowAllGroupsHint: 'User can use any non-exclusive group',
|
||||
allowedGroupsUpdated: 'Allowed groups updated successfully',
|
||||
failedToLoadGroups: 'Failed to load groups',
|
||||
failedToUpdateAllowedGroups: 'Failed to update allowed groups',
|
||||
// User Group Configuration
|
||||
groupConfig: 'User Group Configuration',
|
||||
groupConfigHint: 'Configure custom rate multipliers for user {email} (overrides group defaults)',
|
||||
exclusiveGroups: 'Exclusive Groups',
|
||||
publicGroups: 'Public Groups (Default Available)',
|
||||
defaultRate: 'Default Rate',
|
||||
customRate: 'Custom Rate',
|
||||
useDefaultRate: 'Use Default',
|
||||
customRatePlaceholder: 'Leave empty for default',
|
||||
groupConfigUpdated: 'Group configuration updated successfully',
|
||||
replaceGroup: 'Replace Group',
|
||||
clickToReplace: 'Click to replace',
|
||||
replaceGroupTitle: 'Replace Exclusive Group',
|
||||
replaceGroupHint: 'Select a new group to replace "{old}". Keys will be migrated and permissions updated automatically.',
|
||||
replaceGroupConfirm: 'Confirm Replace',
|
||||
replaceGroupSuccess: 'Group replaced successfully, {count} key(s) migrated',
|
||||
selectNewGroup: 'Select target group',
|
||||
noOtherGroups: 'No other exclusive groups available',
|
||||
deposit: 'Deposit',
|
||||
withdraw: 'Withdraw',
|
||||
depositAmount: 'Deposit Amount',
|
||||
withdrawAmount: 'Withdraw Amount',
|
||||
withdrawAll: 'All',
|
||||
currentBalance: 'Current Balance',
|
||||
depositNotesPlaceholder:
|
||||
'e.g., New user registration bonus, promotional credit, compensation, etc.',
|
||||
withdrawNotesPlaceholder:
|
||||
'e.g., Service issue refund, incorrect charge reversal, account closure refund, etc.',
|
||||
notesOptional: 'Notes are optional but helpful for record keeping',
|
||||
amountHint: 'Please enter a positive amount',
|
||||
newBalance: 'New Balance',
|
||||
depositing: 'Depositing...',
|
||||
withdrawing: 'Withdrawing...',
|
||||
confirmDeposit: 'Confirm Deposit',
|
||||
confirmWithdraw: 'Confirm Withdraw',
|
||||
depositSuccess: 'Deposit successful',
|
||||
withdrawSuccess: 'Withdraw successful',
|
||||
failedToDeposit: 'Failed to deposit',
|
||||
failedToWithdraw: 'Failed to withdraw',
|
||||
useDepositWithdrawButtons: 'Please use deposit/withdraw buttons to adjust balance',
|
||||
// Balance History
|
||||
balanceHistory: 'Recharge History',
|
||||
balanceHistoryTip: 'Click to open recharge history',
|
||||
columnAlwaysVisible: 'This column is always visible',
|
||||
// Per-platform usage breakdown (hover tooltip)
|
||||
platformBreakdown: 'Per-platform breakdown',
|
||||
platformBreakdownEmpty: 'No platform usage yet',
|
||||
platformBreakdownHint: 'Hover for per-platform usage',
|
||||
platformOther: 'Other',
|
||||
balanceHistoryTitle: 'User Recharge & Concurrency History',
|
||||
noBalanceHistory: 'No records found for this user',
|
||||
allTypes: 'All Types',
|
||||
typeBalance: 'Balance (Redeem)',
|
||||
typeAffiliateBalance: 'Balance (Affiliate Transfer)',
|
||||
typeAdminBalance: 'Balance (Admin)',
|
||||
typeConcurrency: 'Concurrency (Redeem)',
|
||||
typeAdminConcurrency: 'Concurrency (Admin)',
|
||||
typeSubscription: 'Subscription',
|
||||
failedToLoadBalanceHistory: 'Failed to load balance history',
|
||||
createdAt: 'Created',
|
||||
totalRecharged: 'Total Recharged',
|
||||
roles: {
|
||||
admin: 'Admin',
|
||||
user: 'User'
|
||||
},
|
||||
// Settings Dropdowns
|
||||
filterSettings: 'Filter Settings',
|
||||
columnSettings: 'Column Settings',
|
||||
filterValue: 'Enter value',
|
||||
// User Attributes
|
||||
attributes: {
|
||||
title: 'User Attributes',
|
||||
description: 'Configure custom user attribute fields',
|
||||
configButton: 'Attributes',
|
||||
addAttribute: 'Add Attribute',
|
||||
editAttribute: 'Edit Attribute',
|
||||
deleteAttribute: 'Delete Attribute',
|
||||
deleteConfirm: "Are you sure you want to delete attribute '{name}'? All user values for this attribute will be deleted.",
|
||||
noAttributes: 'No custom attributes',
|
||||
noAttributesHint: 'Click the button above to add custom attributes',
|
||||
key: 'Attribute Key',
|
||||
keyHint: 'For programmatic reference, only letters, numbers and underscores',
|
||||
name: 'Display Name',
|
||||
nameHint: 'Name shown in forms',
|
||||
type: 'Attribute Type',
|
||||
fieldDescription: 'Description',
|
||||
fieldDescriptionHint: 'Description text for the attribute',
|
||||
placeholder: 'Placeholder',
|
||||
placeholderHint: 'Placeholder text for input field',
|
||||
required: 'Required',
|
||||
enabled: 'Enabled',
|
||||
options: 'Options',
|
||||
optionsHint: 'For select/multi-select types',
|
||||
addOption: 'Add Option',
|
||||
optionValue: 'Option Value',
|
||||
optionLabel: 'Display Text',
|
||||
validation: 'Validation Rules',
|
||||
minLength: 'Min Length',
|
||||
maxLength: 'Max Length',
|
||||
min: 'Min Value',
|
||||
max: 'Max Value',
|
||||
pattern: 'Regex Pattern',
|
||||
patternMessage: 'Validation Error Message',
|
||||
types: {
|
||||
text: 'Text',
|
||||
textarea: 'Textarea',
|
||||
number: 'Number',
|
||||
email: 'Email',
|
||||
url: 'URL',
|
||||
date: 'Date',
|
||||
select: 'Select',
|
||||
multi_select: 'Multi-Select'
|
||||
},
|
||||
created: 'Attribute created successfully',
|
||||
updated: 'Attribute updated successfully',
|
||||
deleted: 'Attribute deleted successfully',
|
||||
reordered: 'Attribute order updated successfully',
|
||||
failedToLoad: 'Failed to load attributes',
|
||||
failedToCreate: 'Failed to create attribute',
|
||||
failedToUpdate: 'Failed to update attribute',
|
||||
keyRequired: 'Please enter attribute key',
|
||||
nameRequired: 'Please enter display name',
|
||||
optionsRequired: 'Please add at least one option',
|
||||
failedToDelete: 'Failed to delete attribute',
|
||||
failedToReorder: 'Failed to update order',
|
||||
keyExists: 'Attribute key already exists',
|
||||
dragToReorder: 'Drag to reorder'
|
||||
},
|
||||
platformQuota: {
|
||||
menuItem: 'Platform Quotas',
|
||||
title: 'Platform Quotas',
|
||||
subtitle: 'Configure daily / weekly / monthly USD usage limits for each upstream platform for user {email}',
|
||||
columns: {
|
||||
platform: 'Platform',
|
||||
daily: 'Daily (USD)',
|
||||
weekly: 'Weekly (USD)',
|
||||
monthly: 'Monthly (USD, 30-day rolling)',
|
||||
usage: 'Current Usage',
|
||||
},
|
||||
placeholder: 'unlimited',
|
||||
save: 'Save',
|
||||
saving: 'Saving...',
|
||||
cancel: 'Cancel',
|
||||
clearAll: 'Clear All (remove all limits)',
|
||||
clearAllConfirm: 'Clear daily / weekly / monthly limits for ALL platforms? All platforms will become "unlimited" with no local undo — you must manually re-enter values before saving.',
|
||||
reset: {
|
||||
button: 'Reset window',
|
||||
confirm: 'Reset the {window} usage for {platform} for this user? This is effective immediately.',
|
||||
success: 'Reset {platform} {window} usage',
|
||||
failed: 'Reset failed',
|
||||
},
|
||||
updateSuccess: 'Platform quotas updated',
|
||||
updateFailed: 'Save failed',
|
||||
loadFailed: 'Load failed',
|
||||
hint: 'Empty = no limit for that window.',
|
||||
windowDaily: 'daily',
|
||||
windowWeekly: 'weekly',
|
||||
windowMonthly: 'monthly',
|
||||
cellNotConfigured: 'Not configured',
|
||||
cellColumnTooltip: 'Only platforms with a limit are shown',
|
||||
subscriptionWarning: 'This user has an active subscription. Platform quotas only apply to balance (standard) mode requests; subscription mode requests are not subject to these limits.',
|
||||
invalidNumber: 'The following fields contain invalid numbers. Please fix them before saving: {fields}',
|
||||
}
|
||||
},
|
||||
|
||||
// Groups
|
||||
groups: {
|
||||
title: 'Group Management',
|
||||
description: 'Manage API key groups and rate multipliers',
|
||||
searchGroups: 'Search groups...',
|
||||
createGroup: 'Create Group',
|
||||
editGroup: 'Edit Group',
|
||||
deleteGroup: 'Delete Group',
|
||||
sortOrder: 'Sort',
|
||||
columnSettings: 'Column Settings',
|
||||
sortOrderHint: 'Drag groups to adjust display order, groups at the top will be displayed first',
|
||||
sortOrderUpdated: 'Sort order updated',
|
||||
failedToUpdateSortOrder: 'Failed to update sort order',
|
||||
allPlatforms: 'All Platforms',
|
||||
allStatus: 'All Status',
|
||||
allGroups: 'All Groups',
|
||||
exclusive: 'Exclusive',
|
||||
nonExclusive: 'Non-Exclusive',
|
||||
public: 'Public',
|
||||
columns: {
|
||||
name: 'Name',
|
||||
platform: 'Platform',
|
||||
rateMultiplier: 'Rate Multiplier',
|
||||
rpmOverride: 'RPM Override',
|
||||
rpmOverrideHint: 'Per-user RPM cap in this group; empty = group default; 0 = unlimited',
|
||||
rateDefault: 'default',
|
||||
rpmDefault: 'default',
|
||||
type: 'Type',
|
||||
accounts: 'Accounts',
|
||||
capacity: 'Capacity',
|
||||
usage: 'Usage',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
billingType: 'Billing Type',
|
||||
userName: 'Username',
|
||||
userEmail: 'Email',
|
||||
userNotes: 'Notes',
|
||||
userStatus: 'Status'
|
||||
},
|
||||
usageToday: 'Today',
|
||||
usageTotal: 'Total',
|
||||
accountsAvailable: 'Avail:',
|
||||
accountsRateLimited: 'Limited:',
|
||||
accountsTotal: 'Total:',
|
||||
accountsUnit: '',
|
||||
rateAndAccounts: '{rate}x rate · {count} accounts',
|
||||
accountsCount: '{count} accounts',
|
||||
rateLabel: 'rate',
|
||||
accountFilters: {
|
||||
title: 'Account Filter Controls',
|
||||
oauthOnly: 'Only allow OAuth accounts',
|
||||
oauthOnlyEnabled: 'Enabled — API Key accounts will be excluded',
|
||||
privacySetOnly: 'Only allow accounts with privacy protection set',
|
||||
privacySetOnlyEnabled: 'Enabled — accounts with unset Privacy will be excluded',
|
||||
disabled: 'Disabled'
|
||||
},
|
||||
form: {
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
platform: 'Platform',
|
||||
rateMultiplier: 'Rate Multiplier',
|
||||
status: 'Status',
|
||||
exclusive: 'Exclusive Group',
|
||||
rpmLimit: 'Requests Per Minute (RPM)',
|
||||
rpmLimitPlaceholder: '0 = unlimited',
|
||||
rpmLimitHint: 'Max requests per minute for each user in this group; 0 = unlimited. Once set, it takes over per-user rate limiting in this group (overrides the user-level rpm_limit fallback).'
|
||||
},
|
||||
enterGroupName: 'Enter group name',
|
||||
optionalDescription: 'Optional description',
|
||||
platformHint: 'Select the platform this group is associated with',
|
||||
platformNotEditable: 'Platform cannot be changed after creation',
|
||||
rateMultiplierHint: 'Cost multiplier for this group (e.g., 1.5 = 150% of base cost)',
|
||||
exclusiveHint: 'Exclusive group, manually assign to specific users',
|
||||
exclusiveTooltip: {
|
||||
title: 'What is an exclusive group?',
|
||||
description: 'When enabled, users cannot see this group when creating API Keys. Only after an admin manually assigns a user to this group can they use it.',
|
||||
example: 'Use case:',
|
||||
exampleContent: 'Public group rate is 0.8. Create an exclusive group with 0.7 rate, manually assign VIP users to give them better pricing.'
|
||||
},
|
||||
noGroupsYet: 'No groups yet',
|
||||
createFirstGroup: 'Create your first group to organize API keys.',
|
||||
creating: 'Creating...',
|
||||
updating: 'Updating...',
|
||||
limitDay: 'd',
|
||||
limitWeek: 'w',
|
||||
limitMonth: 'mo',
|
||||
groupCreated: 'Group created successfully',
|
||||
groupUpdated: 'Group updated successfully',
|
||||
groupDeleted: 'Group deleted successfully',
|
||||
failedToLoad: 'Failed to load groups',
|
||||
failedToCreate: 'Failed to create group',
|
||||
failedToUpdate: 'Failed to update group',
|
||||
failedToDelete: 'Failed to delete group',
|
||||
nameRequired: 'Please enter group name',
|
||||
rateMultipliers: 'Rate Multipliers',
|
||||
rateMultipliersTitle: 'Group Rate Multipliers',
|
||||
addUserRate: 'Add User Rate Multiplier',
|
||||
rpmOverrides: 'RPM Overrides',
|
||||
rpmOverridesTitle: 'Group RPM Overrides',
|
||||
addUserRpm: 'Add User RPM Override',
|
||||
noRpmOverrides: 'No users have an RPM override yet',
|
||||
rpmSaved: 'RPM overrides saved',
|
||||
groupRpmDefault: 'Group default RPM',
|
||||
searchUserPlaceholder: 'Search user email...',
|
||||
noRateMultipliers: 'No user rate multipliers configured',
|
||||
rateUpdated: 'Rate multiplier updated',
|
||||
rateDeleted: 'Rate multiplier removed',
|
||||
rateAdded: 'Rate multiplier added',
|
||||
clearAll: 'Clear All',
|
||||
confirmClearAll: 'Are you sure you want to clear all rate multiplier settings for this group? This cannot be undone.',
|
||||
rateCleared: 'All rate multipliers cleared',
|
||||
batchAdjust: 'Batch Adjust Rates',
|
||||
multiplierFactor: 'Factor',
|
||||
applyMultiplier: 'Apply',
|
||||
rateAdjusted: 'Rates adjusted successfully',
|
||||
rateSaved: 'Rate multipliers saved',
|
||||
finalRate: 'Final Rate',
|
||||
unsavedChanges: 'Unsaved changes',
|
||||
revertChanges: 'Revert',
|
||||
userInfo: 'User Info',
|
||||
platforms: {
|
||||
all: 'All Platforms',
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity',
|
||||
grok: 'Grok',
|
||||
},
|
||||
deleteConfirm:
|
||||
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
|
||||
deleteConfirmSubscription:
|
||||
"Are you sure you want to delete subscription group '{name}'? This will invalidate all API keys bound to this subscription and delete all related subscription records. This action cannot be undone.",
|
||||
subscription: {
|
||||
title: 'Subscription Settings',
|
||||
type: 'Billing Type',
|
||||
typeHint:
|
||||
'Standard billing deducts from user balance. Subscription mode uses quota limits instead.',
|
||||
typeNotEditable: 'Billing type cannot be changed after group creation.',
|
||||
standard: 'Standard (Balance)',
|
||||
subscription: 'Subscription (Quota)',
|
||||
dailyLimit: 'Daily Limit (USD)',
|
||||
weeklyLimit: 'Weekly Limit (USD)',
|
||||
monthlyLimit: 'Monthly Limit (USD)',
|
||||
defaultValidityDays: 'Default Validity (Days)',
|
||||
validityHint: 'Number of days the subscription is valid when assigned to a user',
|
||||
noLimit: 'No limit'
|
||||
},
|
||||
imagePricing: {
|
||||
title: 'Image Generation Pricing',
|
||||
description: 'Configure image generation access and base image prices. Leave empty to use default prices.',
|
||||
allowImageGeneration: 'Allow image generation for this group',
|
||||
allowBatchImageGeneration: 'Allow batch image generation for this group',
|
||||
independentMultiplier: 'Use independent image multiplier',
|
||||
imageMultiplier: 'Image multiplier',
|
||||
batchDiscountMultiplier: 'Batch image discount',
|
||||
batchHoldMultiplier: 'Batch hold price ratio',
|
||||
batchSectionHint: 'Batch image settings only apply to batch jobs: settlement applies the batch discount, and the upfront hold is normal image price × batch hold price ratio. Reference images also create upstream input-token usage, so a batch image discount above 0.5 is recommended.',
|
||||
batchDisabledHint: 'Enable image generation for this group before enabling batch image generation.',
|
||||
batchGeminiOnlyHint: 'Batch image generation is currently available only for Gemini groups.',
|
||||
modeHint: 'By default, image billing uses image price × current effective group multiplier. Independent mode uses image price × image multiplier.',
|
||||
finalPricePreview: 'Final per-image price preview',
|
||||
notConfigured: 'Not configured'
|
||||
},
|
||||
peakRate: {
|
||||
enable: 'Enable peak rate multiplier',
|
||||
peakStart: 'Peak start',
|
||||
peakEnd: 'Peak end',
|
||||
peakMultiplier: 'Peak multiplier',
|
||||
multiplierHint: 'Applies to token billing multiplier; image tokens in token billing are also affected. 0 means peak token requests are billed at 0x.'
|
||||
},
|
||||
modelsList: {
|
||||
title: 'Custom /v1/models Model List',
|
||||
hint: 'Only changes the /v1/models response. Whitelist model calls and account routing are unchanged.',
|
||||
loading: 'Loading model list...',
|
||||
empty: 'No displayable models',
|
||||
selectedSummary: 'Selected {selected} / {total}',
|
||||
selectAll: 'Select all',
|
||||
invertSelection: 'Invert'
|
||||
},
|
||||
claudeCode: {
|
||||
title: 'Claude Code Client Restriction',
|
||||
tooltip: 'When enabled, this group only allows official Claude Code clients. Non-Claude Code requests will be rejected or fallback to the specified group.',
|
||||
enabled: 'Claude Code Only',
|
||||
disabled: 'Allow All Clients',
|
||||
fallbackGroup: 'Fallback Group',
|
||||
fallbackHint: 'Non-Claude Code requests will use this group. Leave empty to reject directly.',
|
||||
noFallback: 'No Fallback (Reject)'
|
||||
},
|
||||
openaiMessages: {
|
||||
title: 'OpenAI Messages Dispatch',
|
||||
allowDispatch: 'Allow /v1/messages dispatch',
|
||||
allowDispatchHint: 'When enabled, API keys in this OpenAI group can dispatch requests through /v1/messages endpoint',
|
||||
familyMappingTitle: 'Family Default Mapping',
|
||||
familyMappingHint: 'Requests that match the Opus, Sonnet, or Haiku families will prefer the target model configured here.',
|
||||
opusModel: 'Opus Target Model',
|
||||
opusModelPlaceholder: 'e.g., gpt-5.4',
|
||||
sonnetModel: 'Sonnet Target Model',
|
||||
sonnetModelPlaceholder: 'e.g., gpt-5.3-codex',
|
||||
haikuModel: 'Haiku Target Model',
|
||||
haikuModelPlaceholder: 'e.g., gpt-5.4-mini',
|
||||
exactMappingTitle: 'Exact Model Overrides',
|
||||
exactMappingHint: 'Exact Claude model overrides take priority over the family defaults and can route a specific Claude model to a different target model.',
|
||||
noExactMappings: 'No exact model overrides yet',
|
||||
addExactMapping: 'Add Exact Mapping',
|
||||
claudeModel: 'Claude Model',
|
||||
claudeModelPlaceholder: 'e.g., claude-sonnet-4-5-20250929',
|
||||
targetModel: 'Target Model',
|
||||
targetModelPlaceholder: 'e.g., gpt-5.4',
|
||||
removeExactMapping: 'Remove Exact Mapping'
|
||||
},
|
||||
invalidRequestFallback: {
|
||||
title: 'Invalid Request Fallback Group',
|
||||
hint: 'Triggered only when upstream explicitly returns prompt too long. Leave empty to disable fallback.',
|
||||
noFallback: 'No Fallback'
|
||||
},
|
||||
copyAccounts: {
|
||||
title: 'Copy Accounts from Groups',
|
||||
tooltip: 'Select one or more groups of the same platform. After creation, all accounts from these groups will be automatically bound to the new group (deduplicated).',
|
||||
tooltipEdit: 'Select one or more groups of the same platform. After saving, current group accounts will be replaced with accounts from these groups (deduplicated).',
|
||||
selectPlaceholder: 'Select groups to copy accounts from...',
|
||||
hint: 'Multiple groups can be selected, accounts will be deduplicated',
|
||||
hintEdit: '⚠️ Warning: This will replace all existing account bindings'
|
||||
},
|
||||
modelRouting: {
|
||||
title: 'Model Routing',
|
||||
tooltip: 'Configure specific model requests to be routed to designated accounts. Supports wildcard matching, e.g., claude-opus-* matches all opus models.',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
disabledHint: 'Routing rules will only take effect when enabled',
|
||||
addRule: 'Add Routing Rule',
|
||||
modelPattern: 'Model Pattern',
|
||||
modelPatternPlaceholder: 'claude-opus-*',
|
||||
modelPatternHint: 'Supports * wildcard, e.g., claude-opus-* matches all opus models',
|
||||
accounts: 'Priority Accounts',
|
||||
selectAccounts: 'Select accounts',
|
||||
noAccounts: 'No accounts in this group',
|
||||
loadingAccounts: 'Loading accounts...',
|
||||
removeRule: 'Remove Rule',
|
||||
noRules: 'No routing rules',
|
||||
noRulesHint: 'Add routing rules to route specific model requests to designated accounts',
|
||||
searchAccountPlaceholder: 'Search accounts...',
|
||||
accountsHint: 'Select accounts to prioritize for this model pattern'
|
||||
},
|
||||
mcpXml: {
|
||||
title: 'MCP XML Protocol Injection',
|
||||
tooltip: 'When enabled, if the request contains MCP tools, an XML format call protocol prompt will be injected into the system prompt. Disable this to avoid interference with certain clients.',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled'
|
||||
},
|
||||
claudeMaxSimulation: {
|
||||
title: 'Claude Max Usage Simulation',
|
||||
tooltip:
|
||||
'When enabled, for Claude models without upstream cache-write usage, the system deterministically maps tokens to a small input plus 1h cache creation while keeping total tokens unchanged.',
|
||||
enabled: 'Enabled (simulate 1h cache)',
|
||||
disabled: 'Disabled',
|
||||
hint: 'Only token categories in usage billing logs are adjusted. No per-request mapping state is persisted.'
|
||||
},
|
||||
supportedScopes: {
|
||||
title: 'Supported Model Families',
|
||||
tooltip: 'Select the model families this group supports. Unchecked families will not be routed to this group.',
|
||||
claude: 'Claude',
|
||||
geminiText: 'Gemini Text',
|
||||
geminiImage: 'Gemini Image',
|
||||
hint: 'Select at least one model family'
|
||||
}
|
||||
},
|
||||
|
||||
// Available Channels (aggregated read-only view)
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
export default {
|
||||
scheduledTests: {
|
||||
title: 'Scheduled Tests',
|
||||
addPlan: 'Add Plan',
|
||||
editPlan: 'Edit Plan',
|
||||
deletePlan: 'Delete Plan',
|
||||
model: 'Model',
|
||||
cronExpression: 'Cron Expression',
|
||||
enabled: 'Enabled',
|
||||
lastRun: 'Last Run',
|
||||
nextRun: 'Next Run',
|
||||
maxResults: 'Max Results',
|
||||
noPlans: 'No scheduled test plans',
|
||||
confirmDelete: 'Are you sure you want to delete this plan?',
|
||||
createSuccess: 'Plan created successfully',
|
||||
updateSuccess: 'Plan updated successfully',
|
||||
deleteSuccess: 'Plan deleted successfully',
|
||||
results: 'Test Results',
|
||||
noResults: 'No test results yet',
|
||||
responseText: 'Response',
|
||||
errorMessage: 'Error',
|
||||
success: 'Success',
|
||||
failed: 'Failed',
|
||||
running: 'Running',
|
||||
schedule: 'Schedule',
|
||||
cronHelp: 'Standard 5-field cron expression (e.g., */30 * * * *)',
|
||||
cronTooltipTitle: 'Cron expression examples:',
|
||||
cronTooltipMeaning: 'Defines when the test runs automatically. The 5 fields are: minute, hour, day, month, and weekday.',
|
||||
cronTooltipExampleEvery30Min: '*/30 * * * *: run every 30 minutes',
|
||||
cronTooltipExampleHourly: '0 * * * *: run at the start of every hour',
|
||||
cronTooltipExampleDaily: '0 9 * * *: run every day at 09:00',
|
||||
cronTooltipExampleWeekly: '0 9 * * 1: run every Monday at 09:00',
|
||||
cronTooltipRange: 'Recommended range: use standard 5-field cron. For health checks, start with a moderate frequency such as every 30 minutes, every hour, or once a day instead of running too often.',
|
||||
maxResultsTooltipTitle: 'What Max Results means:',
|
||||
maxResultsTooltipMeaning: 'Sets how many historical test results are kept for a single plan so the result list does not grow without limit.',
|
||||
maxResultsTooltipBody: 'Only the newest test results are kept. Once the number of saved results exceeds this value, older records are pruned automatically so the history list and storage stay under control.',
|
||||
maxResultsTooltipExample: 'For example, 100 means keeping at most the latest 100 test results. When the 101st result is saved, the oldest one is removed.',
|
||||
maxResultsTooltipRange: 'Recommended range: usually 20 to 200. Use 20-50 when you only care about recent health status, or 100-200 if you want a longer trend history.',
|
||||
autoRecover: 'Auto Recover',
|
||||
autoRecoverHelp: 'Automatically recover account from error/rate-limited state on successful test'
|
||||
},
|
||||
|
||||
// Proxies
|
||||
proxies: {
|
||||
title: 'Proxy Management',
|
||||
description: 'Manage proxy servers for accounts',
|
||||
createProxy: 'Create Proxy',
|
||||
editProxy: 'Edit Proxy',
|
||||
deleteProxy: 'Delete Proxy',
|
||||
ad: {
|
||||
inline: 'Need proxy IP?'
|
||||
},
|
||||
dataImport: 'Import',
|
||||
dataExportSelected: 'Export Selected',
|
||||
dataImportTitle: 'Import Proxies',
|
||||
dataImportHint: 'Upload the exported proxy JSON file to import proxies in bulk.',
|
||||
dataImportWarning: 'Import will create or reuse proxies, keep their status, and trigger latency checks after completion.',
|
||||
dataImportFile: 'Data File',
|
||||
dataImportButton: 'Start Import',
|
||||
dataImporting: 'Importing...',
|
||||
dataImportSelectFile: 'Please select a data file',
|
||||
dataImportParseFailed: 'Failed to parse data',
|
||||
dataImportFailed: 'Failed to import data',
|
||||
dataImportResult: 'Import Result',
|
||||
dataImportResultSummary: 'Created {proxy_created}, reused {proxy_reused}, failed {proxy_failed}',
|
||||
dataImportErrors: 'Failure Details',
|
||||
dataImportSuccess: 'Import completed: created {proxy_created}, reused {proxy_reused}',
|
||||
dataImportCompletedWithErrors: 'Import completed with errors: failed {proxy_failed}',
|
||||
dataExport: 'Export',
|
||||
dataExportConfirmMessage: 'The exported data contains sensitive proxy information. Store it securely.',
|
||||
dataExportConfirm: 'Confirm Export',
|
||||
dataExported: 'Data exported successfully',
|
||||
dataExportFailed: 'Failed to export data',
|
||||
copyProxyUrl: 'Copy Proxy URL',
|
||||
urlCopied: 'Proxy URL copied',
|
||||
searchProxies: 'Search proxies...',
|
||||
allProtocols: 'All Protocols',
|
||||
allStatus: 'All Status',
|
||||
protocols: {
|
||||
http: 'HTTP',
|
||||
https: 'HTTPS',
|
||||
socks5: 'SOCKS5',
|
||||
socks5h: 'SOCKS5H (Remote DNS)'
|
||||
},
|
||||
columns: {
|
||||
name: 'Name',
|
||||
protocol: 'Protocol',
|
||||
address: 'Address',
|
||||
auth: 'Auth',
|
||||
location: 'Location',
|
||||
status: 'Status',
|
||||
accounts: 'Accounts',
|
||||
latency: 'Latency',
|
||||
expiry: 'Validity',
|
||||
createdAt: 'Created',
|
||||
actions: 'Actions'
|
||||
},
|
||||
testConnection: 'Test Connection',
|
||||
qualityCheck: 'Quality Check',
|
||||
batchQualityCheck: 'Batch Quality Check',
|
||||
batchTest: 'Test All Proxies',
|
||||
testFailed: 'Failed',
|
||||
latencyFailed: 'Connection failed',
|
||||
batchTestEmpty: 'No proxies available for testing',
|
||||
batchTestDone: 'Batch test completed for {count} proxies',
|
||||
batchTestFailed: 'Batch test failed',
|
||||
batchDeleteAction: 'Delete',
|
||||
batchDelete: 'Batch delete',
|
||||
batchDeleteConfirm: 'Delete {count} selected proxies? In-use ones will be skipped.',
|
||||
batchDeleteDone: 'Deleted {deleted} proxies, skipped {skipped}',
|
||||
batchDeleteSkipped: 'Skipped {skipped} proxies',
|
||||
batchDeleteFailed: 'Batch delete failed',
|
||||
deleteBlockedInUse: 'This proxy is in use and cannot be deleted',
|
||||
accountsTitle: 'Accounts using this IP',
|
||||
accountsEmpty: 'No accounts are using this proxy',
|
||||
accountsFailed: 'Failed to load accounts list',
|
||||
accountName: 'Account',
|
||||
accountPlatform: 'Platform',
|
||||
accountNotes: 'Notes',
|
||||
name: 'Name',
|
||||
protocol: 'Protocol',
|
||||
host: 'Host',
|
||||
port: 'Port',
|
||||
username: 'Username (Optional)',
|
||||
password: 'Password (Optional)',
|
||||
status: 'Status',
|
||||
enterProxyName: 'Enter proxy name',
|
||||
leaveEmptyToKeep: 'Leave empty to keep current',
|
||||
optionalAuth: 'Optional authentication',
|
||||
form: {
|
||||
hostPlaceholder: 'proxy.example.com',
|
||||
portPlaceholder: '8080'
|
||||
},
|
||||
noProxiesYet: 'No proxies yet',
|
||||
createFirstProxy: 'Create your first proxy to route traffic through it.',
|
||||
// Batch import
|
||||
standardAdd: 'Standard Add',
|
||||
batchAdd: 'Quick Add',
|
||||
batchInput: 'Proxy List',
|
||||
batchInputPlaceholder:
|
||||
"Enter one proxy per line in the following formats:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443",
|
||||
batchInputHint:
|
||||
"Supports http, https, socks5 protocols. Format: protocol://[user:pass{'@'}]host:port",
|
||||
parsedCount: '{count} valid',
|
||||
invalidCount: '{count} invalid',
|
||||
duplicateCount: '{count} duplicate',
|
||||
importing: 'Importing...',
|
||||
importProxies: 'Import {count} proxies',
|
||||
batchImportSuccess: 'Successfully imported {created} proxies, skipped {skipped} duplicates',
|
||||
batchImportAllSkipped: 'All {skipped} proxies already exist, skipped import',
|
||||
failedToImport: 'Failed to batch import',
|
||||
// Other messages
|
||||
creating: 'Creating...',
|
||||
updating: 'Updating...',
|
||||
proxyCreated: 'Proxy created successfully',
|
||||
proxyUpdated: 'Proxy updated successfully',
|
||||
proxyDeleted: 'Proxy deleted successfully',
|
||||
proxyWorking: 'Proxy is working!',
|
||||
proxyWorkingWithLatency: 'Proxy is working! Latency: {latency}ms',
|
||||
proxyTestFailed: 'Proxy test failed',
|
||||
qualityCheckDone: 'Quality check completed: score {score} ({grade})',
|
||||
qualityCheckFailed: 'Failed to run proxy quality check',
|
||||
batchQualityDone:
|
||||
'Batch quality check completed for {count} proxies: healthy {healthy}, warn {warn}, challenge {challenge}, abnormal {failed}',
|
||||
batchQualityFailed: 'Batch quality check failed',
|
||||
batchQualityEmpty: 'No proxies available for quality check',
|
||||
qualityReportTitle: 'Proxy Quality Report',
|
||||
qualityGrade: 'Grade {grade}',
|
||||
qualityExitIP: 'Exit IP',
|
||||
qualityCountry: 'Exit Region',
|
||||
qualityBaseLatency: 'Base Latency',
|
||||
qualityCheckedAt: 'Checked At',
|
||||
qualityTableTarget: 'Target',
|
||||
qualityTableStatus: 'Status',
|
||||
qualityTableLatency: 'Latency',
|
||||
qualityTableMessage: 'Message',
|
||||
qualityInline: 'Quality {grade}/{score}',
|
||||
qualityStatusHealthy: 'Healthy',
|
||||
qualityStatusPass: 'Pass',
|
||||
qualityStatusWarn: 'Warn',
|
||||
qualityStatusFail: 'Fail',
|
||||
qualityStatusChallenge: 'Challenge',
|
||||
qualityTargetBase: 'Base Connectivity',
|
||||
failedToLoad: 'Failed to load proxies',
|
||||
failedToCreate: 'Failed to create proxy',
|
||||
failedToUpdate: 'Failed to update proxy',
|
||||
failedToDelete: 'Failed to delete proxy',
|
||||
failedToTest: 'Failed to test proxy',
|
||||
nameRequired: 'Please enter proxy name',
|
||||
hostRequired: 'Please enter host address',
|
||||
portInvalid: 'Port must be between 1-65535',
|
||||
deleteConfirm:
|
||||
"Are you sure you want to delete '{name}'? Accounts using this proxy will have their proxy removed.",
|
||||
neverExpires: 'Never',
|
||||
expired: 'Expired',
|
||||
overdueDays: 'Overdue {days}d',
|
||||
expiringInDays: 'Expires in {days}d',
|
||||
remainingDays: '{days}d left',
|
||||
expiresAt: 'Validity',
|
||||
nDays: '{days}d',
|
||||
expiryDaysPlaceholder: 'Custom days, empty = never',
|
||||
expiryWarnDays: 'Expiry warning (days)',
|
||||
fallbackMode: 'Failure fallback',
|
||||
fallbackNone: 'No fallback',
|
||||
fallbackProxy: 'Backup proxy',
|
||||
fallbackDirect: 'Direct connection',
|
||||
backupProxy: 'Backup proxy',
|
||||
},
|
||||
|
||||
// Redeem Codes
|
||||
redeem: {
|
||||
title: 'Redeem Code Management',
|
||||
description: 'Generate and manage redeem codes',
|
||||
generateCodes: 'Generate Codes',
|
||||
searchCodes: 'Search codes or email...',
|
||||
allTypes: 'All Types',
|
||||
allStatus: 'All Status',
|
||||
balance: 'Balance',
|
||||
concurrency: 'Concurrency',
|
||||
subscription: 'Subscription',
|
||||
invitation: 'Invitation',
|
||||
invitationHint: 'Invitation codes are used to restrict user registration. They are automatically marked as used after use.',
|
||||
unused: 'Unused',
|
||||
used: 'Used',
|
||||
columns: {
|
||||
code: 'Code',
|
||||
type: 'Type',
|
||||
value: 'Value',
|
||||
status: 'Status',
|
||||
usedBy: 'Used By',
|
||||
usedAt: 'Used At',
|
||||
expiresAt: 'Expires At',
|
||||
actions: 'Actions'
|
||||
},
|
||||
userPrefix: 'User #{id}',
|
||||
exportCsv: 'Export CSV',
|
||||
batchUpdate: 'Batch Update',
|
||||
batchUpdateTitle: 'Batch Update Redeem Codes',
|
||||
selectedCount: '{count} redeem code(s) selected',
|
||||
clearSelection: 'Clear selection',
|
||||
selectCodesFirst: 'Select redeem codes first',
|
||||
noBatchFieldsSelected: 'Select at least one field to update',
|
||||
batchUpdateSuccess: 'Updated {count} redeem code(s)',
|
||||
failedToBatchUpdate: 'Failed to batch update redeem codes',
|
||||
batchFields: {
|
||||
status: 'Status',
|
||||
expiresAt: 'Expires At',
|
||||
notes: 'Notes',
|
||||
group: 'Group'
|
||||
},
|
||||
batchNotesPlaceholder: 'Enter the new note, or leave blank to clear it',
|
||||
clearGroup: 'Clear group',
|
||||
deleteAllUnused: 'Delete All Unused Codes',
|
||||
deleteCode: 'Delete Redeem Code',
|
||||
deleteCodeConfirm:
|
||||
'Are you sure you want to delete this redeem code? This action cannot be undone.',
|
||||
deleteAllUnusedConfirm:
|
||||
'Are you sure you want to delete all unused (active) redeem codes? This action cannot be undone.',
|
||||
deleteAll: 'Delete All',
|
||||
generateCodesTitle: 'Generate Redeem Codes',
|
||||
generatedSuccessfully: 'Generated Successfully',
|
||||
codesCreated: '{count} redeem code(s) created',
|
||||
codeType: 'Code Type',
|
||||
amount: 'Amount ($)',
|
||||
value: 'Value',
|
||||
count: 'Count',
|
||||
generating: 'Generating...',
|
||||
generate: 'Generate',
|
||||
copyAll: 'Copy All',
|
||||
copied: 'Copied!',
|
||||
download: 'Download',
|
||||
codesExported: 'Codes exported successfully',
|
||||
codeDeleted: 'Redeem code deleted successfully',
|
||||
codesDeleted: 'Successfully deleted {count} unused code(s)',
|
||||
noUnusedCodes: 'No unused codes to delete',
|
||||
failedToLoad: 'Failed to load redeem codes',
|
||||
failedToGenerate: 'Failed to generate codes',
|
||||
failedToExport: 'Failed to export codes',
|
||||
failedToDelete: 'Failed to delete code',
|
||||
failedToDeleteUnused: 'Failed to delete unused codes',
|
||||
failedToCopy: 'Failed to copy codes',
|
||||
types: {
|
||||
balance: 'Balance',
|
||||
concurrency: 'Concurrency',
|
||||
subscription: 'Subscription',
|
||||
invitation: 'Invitation',
|
||||
// Admin adjustment types (created when admin modifies user balance/concurrency)
|
||||
admin_balance: 'Balance (Admin)',
|
||||
admin_concurrency: 'Concurrency (Admin)'
|
||||
},
|
||||
selectGroup: 'Select Group',
|
||||
selectGroupPlaceholder: 'Choose a subscription group',
|
||||
validityDays: 'Validity Days',
|
||||
codeExpiry: 'Code Expiry',
|
||||
neverExpires: 'Never expires',
|
||||
expiryPresetDays: '{days} days',
|
||||
customExpiry: 'Custom',
|
||||
customExpiryDays: 'Custom days',
|
||||
expiryDaysRequired: 'Please enter a valid expiry day count',
|
||||
groupRequired: 'Please select a subscription group',
|
||||
days: ' days',
|
||||
status: {
|
||||
unused: 'Unused',
|
||||
used: 'Used',
|
||||
expired: 'Expired',
|
||||
disabled: 'Disabled'
|
||||
}
|
||||
},
|
||||
|
||||
// Announcements
|
||||
announcements: {
|
||||
title: 'Announcements',
|
||||
description: 'Create announcements and target by conditions',
|
||||
createAnnouncement: 'Create Announcement',
|
||||
editAnnouncement: 'Edit Announcement',
|
||||
deleteAnnouncement: 'Delete Announcement',
|
||||
searchAnnouncements: 'Search announcements...',
|
||||
status: 'Status',
|
||||
allStatus: 'All Status',
|
||||
columns: {
|
||||
title: 'Title',
|
||||
status: 'Status',
|
||||
notifyMode: 'Notify Mode',
|
||||
targeting: 'Targeting',
|
||||
timeRange: 'Schedule',
|
||||
createdAt: 'Created At',
|
||||
actions: 'Actions'
|
||||
},
|
||||
statusLabels: {
|
||||
draft: 'Draft',
|
||||
active: 'Active',
|
||||
archived: 'Archived'
|
||||
},
|
||||
notifyModeLabels: {
|
||||
silent: 'Silent',
|
||||
popup: 'Popup'
|
||||
},
|
||||
form: {
|
||||
title: 'Title',
|
||||
content: 'Content (Markdown supported)',
|
||||
status: 'Status',
|
||||
notifyMode: 'Notify Mode',
|
||||
notifyModeHint: 'Popup mode will show a popup notification to users',
|
||||
startsAt: 'Starts At',
|
||||
endsAt: 'Ends At',
|
||||
startsAtHint: 'Leave empty to start immediately',
|
||||
endsAtHint: 'Leave empty to never expire',
|
||||
targetingMode: 'Targeting',
|
||||
targetingAll: 'All users',
|
||||
targetingCustom: 'Custom rules',
|
||||
addOrGroup: 'Add OR group',
|
||||
addAndCondition: 'Add AND condition',
|
||||
conditionType: 'Condition type',
|
||||
conditionSubscription: 'Subscription',
|
||||
conditionBalance: 'Balance',
|
||||
operator: 'Operator',
|
||||
balanceValue: 'Balance threshold',
|
||||
selectPackages: 'Select packages'
|
||||
},
|
||||
operators: {
|
||||
gt: '>',
|
||||
gte: '≥',
|
||||
lt: '<',
|
||||
lte: '≤',
|
||||
eq: '='
|
||||
},
|
||||
targetingSummaryAll: 'All users',
|
||||
targetingSummaryCustom: 'Custom ({groups} groups)',
|
||||
timeImmediate: 'Immediate',
|
||||
timeNever: 'Never',
|
||||
readStatus: 'Read Status',
|
||||
eligible: 'Eligible',
|
||||
readAt: 'Read at',
|
||||
unread: 'Unread',
|
||||
searchUsers: 'Search users...',
|
||||
failedToLoad: 'Failed to load announcements',
|
||||
failedToCreate: 'Failed to create announcement',
|
||||
failedToUpdate: 'Failed to update announcement',
|
||||
failedToDelete: 'Failed to delete announcement',
|
||||
failedToLoadReadStatus: 'Failed to load read status',
|
||||
deleteConfirm: 'Are you sure you want to delete this announcement? This action cannot be undone.'
|
||||
},
|
||||
|
||||
// Promo Codes
|
||||
promo: {
|
||||
title: 'Promo Code Management',
|
||||
description: 'Create and manage registration promo codes',
|
||||
createCode: 'Create Promo Code',
|
||||
editCode: 'Edit Promo Code',
|
||||
deleteCode: 'Delete Promo Code',
|
||||
searchCodes: 'Search codes...',
|
||||
allStatus: 'All Status',
|
||||
columns: {
|
||||
code: 'Code',
|
||||
bonusAmount: 'Bonus Amount',
|
||||
maxUses: 'Max Uses',
|
||||
usedCount: 'Used',
|
||||
usage: 'Usage',
|
||||
status: 'Status',
|
||||
expiresAt: 'Expires At',
|
||||
createdAt: 'Created At',
|
||||
actions: 'Actions'
|
||||
},
|
||||
// Form labels (flat structure for template usage)
|
||||
code: 'Promo Code',
|
||||
autoGenerate: 'auto-generate if empty',
|
||||
codePlaceholder: 'Enter promo code or leave empty',
|
||||
bonusAmount: 'Bonus Amount ($)',
|
||||
maxUses: 'Max Uses',
|
||||
zeroUnlimited: '0 = unlimited',
|
||||
expiresAt: 'Expires At',
|
||||
notes: 'Notes',
|
||||
notesPlaceholder: 'Optional notes for this code',
|
||||
status: 'Status',
|
||||
neverExpires: 'Never expires',
|
||||
// Status labels
|
||||
statusActive: 'Active',
|
||||
statusDisabled: 'Disabled',
|
||||
statusExpired: 'Expired',
|
||||
statusMaxUsed: 'Used Up',
|
||||
// Usage records
|
||||
usageRecords: 'Usage Records',
|
||||
viewUsages: 'View Usages',
|
||||
noUsages: 'No usage records yet',
|
||||
userPrefix: 'User #{id}',
|
||||
copied: 'Copied!',
|
||||
// Messages
|
||||
noCodesYet: 'No promo codes yet',
|
||||
createFirstCode: 'Create your first promo code to offer registration bonuses.',
|
||||
codeCreated: 'Promo code created successfully',
|
||||
codeUpdated: 'Promo code updated successfully',
|
||||
codeDeleted: 'Promo code deleted successfully',
|
||||
deleteCodeConfirm: 'Are you sure you want to delete this promo code? This action cannot be undone.',
|
||||
copyRegisterLink: 'Copy register link',
|
||||
registerLinkCopied: 'Register link copied to clipboard',
|
||||
failedToLoad: 'Failed to load promo codes',
|
||||
failedToCreate: 'Failed to create promo code',
|
||||
failedToUpdate: 'Failed to update promo code',
|
||||
failedToDelete: 'Failed to delete promo code',
|
||||
failedToLoadUsages: 'Failed to load usage records'
|
||||
},
|
||||
|
||||
// Usage Records
|
||||
usage: {
|
||||
title: 'Usage Records',
|
||||
description: 'View and manage all user usage records',
|
||||
userFilter: 'User',
|
||||
searchUserPlaceholder: 'Search user by email...',
|
||||
searchApiKeyPlaceholder: 'Search API key by name...',
|
||||
searchAccountPlaceholder: 'Search account by name...',
|
||||
selectedUser: 'Selected',
|
||||
user: 'User',
|
||||
account: 'Account',
|
||||
group: 'Group',
|
||||
requestId: 'Request ID',
|
||||
requestIdCopied: 'Request ID copied',
|
||||
allModels: 'All Models',
|
||||
allAccounts: 'All Accounts',
|
||||
allGroups: 'All Groups',
|
||||
allTypes: 'All Types',
|
||||
inputCost: 'Input Cost',
|
||||
outputCost: 'Output Cost',
|
||||
cacheCreationCost: 'Cache Creation Cost',
|
||||
cacheReadCost: 'Cache Read Cost',
|
||||
inputTokens: 'Input Tokens',
|
||||
outputTokens: 'Output Tokens',
|
||||
cacheCreationTokens: 'Cache Creation Tokens',
|
||||
cacheCreation5mTokens: 'Cache Write',
|
||||
cacheCreation1hTokens: 'Cache Write',
|
||||
cacheReadTokens: 'Cache Read Tokens',
|
||||
failedToLoad: 'Failed to load usage records',
|
||||
billingType: 'Billing Type',
|
||||
allBillingTypes: 'All Billing Types',
|
||||
billingTypeBalance: 'Balance',
|
||||
billingTypeSubscription: 'Subscription',
|
||||
billingMode: 'Billing Mode',
|
||||
billingModeToken: 'Token',
|
||||
billingModePerRequest: 'Per Request',
|
||||
billingModeImage: 'Image',
|
||||
allBillingModes: 'All Billing Modes',
|
||||
ipAddress: 'IP',
|
||||
clickToViewBalance: 'Click to view balance history',
|
||||
failedToLoadUser: 'Failed to load user info',
|
||||
userDeletedBadge: 'Deleted',
|
||||
cleanup: {
|
||||
button: 'Cleanup',
|
||||
title: 'Cleanup Usage Records',
|
||||
warning: 'Cleanup is irreversible and will affect historical stats.',
|
||||
submit: 'Submit Cleanup',
|
||||
submitting: 'Submitting...',
|
||||
confirmTitle: 'Confirm Cleanup',
|
||||
confirmMessage: 'Are you sure you want to submit this cleanup task? This action cannot be undone.',
|
||||
confirmSubmit: 'Confirm Cleanup',
|
||||
cancel: 'Cancel',
|
||||
cancelConfirmTitle: 'Confirm Cancel',
|
||||
cancelConfirmMessage: 'Are you sure you want to cancel this cleanup task?',
|
||||
cancelConfirm: 'Confirm Cancel',
|
||||
cancelSuccess: 'Cleanup task canceled',
|
||||
cancelFailed: 'Failed to cancel cleanup task',
|
||||
recentTasks: 'Recent Cleanup Tasks',
|
||||
loadingTasks: 'Loading tasks...',
|
||||
noTasks: 'No cleanup tasks yet',
|
||||
range: 'Range',
|
||||
deletedRows: 'Deleted',
|
||||
missingRange: 'Please select a date range',
|
||||
submitSuccess: 'Cleanup task created',
|
||||
submitFailed: 'Failed to create cleanup task',
|
||||
loadFailed: 'Failed to load cleanup tasks',
|
||||
status: {
|
||||
pending: 'Pending',
|
||||
running: 'Running',
|
||||
succeeded: 'Succeeded',
|
||||
failed: 'Failed',
|
||||
canceled: 'Canceled'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Ops Monitoring
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,417 @@
|
||||
export default {
|
||||
common: {
|
||||
loading: 'Loading...',
|
||||
submitting: 'Submitting...',
|
||||
justNow: 'just now',
|
||||
peakRateTooltip: 'Peak rate: {window}',
|
||||
peakRateImageNote: '; image tokens billed as tokens are also affected, per-image billing is unaffected',
|
||||
save: 'Save',
|
||||
saved: 'Saved successfully',
|
||||
deleted: 'Deleted successfully',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete',
|
||||
edit: 'Edit',
|
||||
create: 'Create',
|
||||
update: 'Update',
|
||||
confirm: 'Confirm',
|
||||
reset: 'Reset',
|
||||
search: 'Search',
|
||||
filter: 'Filter',
|
||||
export: 'Export',
|
||||
import: 'Import',
|
||||
actions: 'Actions',
|
||||
status: 'Status',
|
||||
name: 'Name',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
submit: 'Submit',
|
||||
back: 'Back',
|
||||
next: 'Next',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
all: 'All',
|
||||
none: 'None',
|
||||
selectAll: 'Select all',
|
||||
noData: 'No data',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
success: 'Success',
|
||||
error: 'Error',
|
||||
critical: 'Critical',
|
||||
warning: 'Warning',
|
||||
info: 'Info',
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
more: 'More',
|
||||
close: 'Close',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
total: 'Total',
|
||||
balance: 'Balance',
|
||||
availableBalance: 'Available balance',
|
||||
frozenBalance: 'Frozen balance',
|
||||
totalBalance: 'Total balance',
|
||||
available: 'Available',
|
||||
copiedToClipboard: 'Copied to clipboard',
|
||||
copied: 'Copied',
|
||||
copyFailed: 'Failed to copy',
|
||||
verifying: 'Verifying...',
|
||||
processing: 'Processing...',
|
||||
contactSupport: 'Contact Support',
|
||||
add: 'Add',
|
||||
invalidEmail: 'Please enter a valid email address',
|
||||
optional: 'optional',
|
||||
selectOption: 'Select an option',
|
||||
searchPlaceholder: 'Search...',
|
||||
noOptionsFound: 'No options found',
|
||||
noGroupsAvailable: 'No groups available',
|
||||
unknownError: 'Unknown error occurred',
|
||||
saving: 'Saving...',
|
||||
selectedCount: '({count} selected)',
|
||||
refresh: 'Refresh',
|
||||
autoRefresh: {
|
||||
title: 'Auto Refresh',
|
||||
enable: 'Enable auto refresh',
|
||||
countdown: 'Auto refresh: {seconds}s',
|
||||
seconds: '{n} seconds',
|
||||
},
|
||||
view: 'View',
|
||||
settings: 'Settings',
|
||||
chooseFile: 'Choose File',
|
||||
copy: 'Copy',
|
||||
notAvailable: 'N/A',
|
||||
now: 'Now',
|
||||
today: 'Today',
|
||||
tomorrow: 'Tomorrow',
|
||||
unknown: 'Unknown',
|
||||
minutes: 'min',
|
||||
time: {
|
||||
never: 'Never',
|
||||
justNow: 'Just now',
|
||||
minutesAgo: '{n}m ago',
|
||||
hoursAgo: '{n}h ago',
|
||||
daysAgo: '{n}d ago',
|
||||
countdown: {
|
||||
daysHours: '{d}d {h}h',
|
||||
hoursMinutes: '{h}h {m}m',
|
||||
minutes: '{m}m',
|
||||
withSuffix: '{time} to lift'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
adminCompliance: {
|
||||
title: 'Deployment and Operation Compliance Acknowledgment',
|
||||
blockingNotice: 'Deployment and operation compliance acknowledgment is required before continuing to use the console.',
|
||||
riskNotice: 'This acknowledgment provides clear, conspicuous, and reproducible notice of compliance obligations and operation risks for self-hosted instances.',
|
||||
version: 'Document Version',
|
||||
openDocument: 'Open the GitHub document',
|
||||
documentSource: 'The agreement text comes from Markdown files in this project repository. When the agreement content changes, the document version must be incremented; acknowledgments of older versions become invalid and console users must acknowledge again.',
|
||||
inputLabel: 'Type the following confirmation phrase exactly',
|
||||
inputPlaceholder: 'Type the confirmation phrase to continue',
|
||||
inputMismatch: 'The confirmation phrase does not match. Type the displayed text exactly.',
|
||||
legalNote: 'This acknowledgment defines the no-affiliation relationship and responsibility boundary between self-hosted instances and the open-source project, copyright holders, contributors, and maintainers. The party that deploys, operates, or controls the relevant instance remains independently responsible for its applicable obligations.',
|
||||
logout: 'Log out',
|
||||
accept: 'Acknowledge and Continue',
|
||||
accepted: 'Compliance acknowledgment recorded',
|
||||
acceptFailed: 'Failed to submit acknowledgment'
|
||||
},
|
||||
|
||||
legal: {
|
||||
loadFailed: 'Failed to load document',
|
||||
retryLater: 'Refresh the page and try again later.',
|
||||
notFound: 'Document not found',
|
||||
notFoundDescription: 'This legal document does not exist or has been removed by an administrator.',
|
||||
updatedAt: 'Updated: {date}',
|
||||
empty: 'No content',
|
||||
loginAgreement: 'Login Agreement',
|
||||
adminCompliance: 'Deployment and Operation Compliance Commitment',
|
||||
loginAgreementPrompt: {
|
||||
checkboxPrefix: 'I have read and agree to ',
|
||||
documentSeparator: ', ',
|
||||
noticeTitle: 'Accept the latest terms before continuing.',
|
||||
noticeDescription: 'Account/password login and quick sign-in stay disabled until you accept.',
|
||||
viewTerms: 'View terms',
|
||||
dialogTitle: 'Terms Update Notice',
|
||||
dialogDescription: 'Our service terms were updated on {date}. Please read and accept the following terms before continuing.',
|
||||
recently: 'recently',
|
||||
relatedDocuments: 'Related documents',
|
||||
reject: 'Reject',
|
||||
accept: 'Accept and continue',
|
||||
loginRejectedWarning: 'Account/password login and quick sign-in are disabled until you accept the latest terms.',
|
||||
loginRequiredWarning: 'Please read and accept the latest terms before logging in.',
|
||||
registerRejectedWarning: 'Registration and quick sign-in are disabled until you accept the latest terms.',
|
||||
registerRequiredWarning: 'Please read and accept the latest terms before registering.'
|
||||
}
|
||||
},
|
||||
|
||||
// Navigation
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
announcements: 'Announcements',
|
||||
apiKeys: 'API Keys',
|
||||
batchImage: 'Batch Images',
|
||||
usage: 'Usage',
|
||||
redeem: 'Redeem',
|
||||
affiliate: 'Affiliate Rebates',
|
||||
affiliateManagement: 'Affiliate Rebates',
|
||||
affiliateInviteRecords: 'Invite Records',
|
||||
affiliateRebateRecords: 'Rebate Records',
|
||||
affiliateTransferRecords: 'Transfer Records',
|
||||
profile: 'Profile',
|
||||
users: 'Users',
|
||||
groups: 'Groups',
|
||||
channels: 'Channels',
|
||||
availableChannels: 'Available Channels',
|
||||
subscriptions: 'Subscriptions',
|
||||
accounts: 'Accounts',
|
||||
proxies: 'Proxies',
|
||||
redeemCodes: 'Redeem Codes',
|
||||
ops: 'Ops',
|
||||
promoCodes: 'Promo Codes',
|
||||
settings: 'Settings',
|
||||
myAccount: 'My Account',
|
||||
lightMode: 'Light Mode',
|
||||
darkMode: 'Dark Mode',
|
||||
collapse: 'Collapse',
|
||||
expand: 'Expand',
|
||||
logout: 'Logout',
|
||||
github: 'GitHub',
|
||||
mySubscriptions: 'My Subscriptions',
|
||||
buySubscription: 'Recharge / Subscription',
|
||||
docs: 'Docs',
|
||||
myOrders: 'My Orders',
|
||||
orderManagement: 'Orders',
|
||||
paymentDashboard: 'Payment Dashboard',
|
||||
paymentConfig: 'Payment Config',
|
||||
paymentPlans: 'Plans',
|
||||
channelManagement: 'Channels',
|
||||
channelPricing: 'Channel Pricing',
|
||||
channelMonitor: 'Channel Monitor',
|
||||
channelStatus: 'Channel Status',
|
||||
riskControl: 'Risk Control',
|
||||
},
|
||||
|
||||
// Auth
|
||||
auth: {
|
||||
welcomeBack: 'Welcome Back',
|
||||
signInToAccount: 'Sign in to your account to continue',
|
||||
signIn: 'Sign In',
|
||||
signingIn: 'Signing in...',
|
||||
createAccount: 'Create Account',
|
||||
signUpToStart: 'Sign up to start using {siteName}',
|
||||
signUp: 'Sign up',
|
||||
processing: 'Processing...',
|
||||
continue: 'Continue',
|
||||
rememberMe: 'Remember me',
|
||||
dontHaveAccount: "Don't have an account?",
|
||||
alreadyHaveAccount: 'Already have an account?',
|
||||
registrationDisabled: 'Registration is currently disabled. Please contact the administrator.',
|
||||
emailLabel: 'Email',
|
||||
emailPlaceholder: 'Enter your email',
|
||||
passwordLabel: 'Password',
|
||||
passwordPlaceholder: 'Enter your password',
|
||||
createPasswordPlaceholder: 'Create a strong password',
|
||||
passwordHint: 'At least 6 characters',
|
||||
emailRequired: 'Email is required',
|
||||
invalidEmail: 'Please enter a valid email address',
|
||||
passwordRequired: 'Password is required',
|
||||
passwordMinLength: 'Password must be at least 6 characters',
|
||||
loginFailed: 'Login failed. Please check your credentials and try again.',
|
||||
errors: {
|
||||
USER_NOT_ACTIVE: 'Account has been disabled.',
|
||||
},
|
||||
registrationFailed: 'Registration failed. Please try again.',
|
||||
emailSuffixNotAllowed: 'This email domain is not allowed for registration.',
|
||||
emailSuffixNotAllowedWithAllowed:
|
||||
'This email domain is not allowed. Allowed domains: {suffixes}',
|
||||
emailSuffixAllowedMore: 'and {count} more',
|
||||
loginSuccess: 'Login successful! Welcome back.',
|
||||
accountCreatedSuccess: 'Account created successfully! Welcome to {siteName}.',
|
||||
reloginRequired: 'Session expired. Please log in again.',
|
||||
turnstileExpired: 'Verification expired, please try again',
|
||||
turnstileFailed: 'Verification failed, please try again',
|
||||
completeVerification: 'Please complete the verification',
|
||||
verifyYourEmail: 'Verify Your Email',
|
||||
sessionExpired: 'Session expired',
|
||||
sessionExpiredDesc: 'Please go back to the registration page and start again.',
|
||||
verificationCode: 'Verification Code',
|
||||
verificationCodeHint: 'Enter the 6-digit code sent to your email',
|
||||
sendingCode: 'Sending...',
|
||||
sendCode: 'Send code',
|
||||
clickToResend: 'Click to resend code',
|
||||
resendCode: 'Resend verification code',
|
||||
sendCodeDesc: "We'll send a verification code to",
|
||||
codeSentSuccess: 'Verification code sent! Please check your inbox.',
|
||||
verifying: 'Verifying...',
|
||||
verifyAndCreate: 'Verify & Create Account',
|
||||
resendCountdown: 'Resend code in {countdown}s',
|
||||
backToRegistration: 'Back to registration',
|
||||
sendCodeFailed: 'Failed to send verification code. Please try again.',
|
||||
verifyFailed: 'Verification failed. Please try again.',
|
||||
codeRequired: 'Verification code is required',
|
||||
invalidCode: 'Please enter a valid 6-digit code',
|
||||
promoCodeLabel: 'Promo Code',
|
||||
promoCodePlaceholder: 'Enter promo code (optional)',
|
||||
promoCodeValid: 'Valid! You will receive ${amount} bonus balance',
|
||||
promoCodeInvalid: 'Invalid promo code',
|
||||
promoCodeNotFound: 'Promo code not found',
|
||||
promoCodeExpired: 'This promo code has expired',
|
||||
promoCodeDisabled: 'This promo code is disabled',
|
||||
promoCodeMaxUsed: 'This promo code has reached its usage limit',
|
||||
promoCodeAlreadyUsed: 'You have already used this promo code',
|
||||
promoCodeValidating: 'Promo code is being validated, please wait',
|
||||
promoCodeInvalidCannotRegister: 'Invalid promo code. Please check and try again or clear the promo code field',
|
||||
invitationCodeLabel: 'Invitation Code',
|
||||
invitationCodePlaceholder: 'Enter invitation code',
|
||||
invitationCodeRequired: 'Invitation code is required',
|
||||
invitationCodeValid: 'Invitation code is valid',
|
||||
invitationCodeInvalid: 'Invalid or used invitation code',
|
||||
invitationCodeValidating: 'Validating invitation code...',
|
||||
invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again',
|
||||
oauthOrContinue: 'or continue with others',
|
||||
linuxdo: {
|
||||
signIn: 'Continue with Linux.do',
|
||||
orContinue: 'or continue with email',
|
||||
callbackTitle: 'Signing you in',
|
||||
callbackProcessing: 'Completing login, please wait...',
|
||||
callbackHint: 'If you are not redirected automatically, go back to the login page and try again.',
|
||||
callbackMissingToken: 'Missing login token, please try again.',
|
||||
backToLogin: 'Back to Login',
|
||||
invitationRequired: 'This Linux.do account is not yet registered. The site requires an invitation code — please enter one to complete registration.',
|
||||
invalidPendingToken: 'The registration token has expired. Please sign in with Linux.do again.',
|
||||
completeRegistration: 'Complete Registration',
|
||||
completing: 'Completing registration…',
|
||||
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
|
||||
},
|
||||
dingtalk: {
|
||||
signIn: 'Continue with DingTalk',
|
||||
callbackTitle: 'Signing you in with DingTalk',
|
||||
callbackProcessing: 'Completing DingTalk login, please wait...',
|
||||
callbackHint: 'If you are not redirected automatically, go back to the login page and try again.',
|
||||
callbackMissingToken: 'Missing login token, please try again.',
|
||||
backToLogin: 'Back to Login',
|
||||
invitationRequired: 'This DingTalk account is not yet registered. The site requires an invitation code — please enter one to complete registration.',
|
||||
invalidPendingToken: 'The registration token has expired. Please sign in with DingTalk again.',
|
||||
completeRegistration: 'Complete Registration',
|
||||
completing: 'Completing registration…',
|
||||
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.',
|
||||
createAccountTitle: 'Create DingTalk Account',
|
||||
registrationDisabledRedirectToBind: 'New account registration is currently disabled. Please bind to your existing account with its email and password.',
|
||||
error: {
|
||||
title: 'DingTalk Sign-in Failed',
|
||||
csrf: 'Login session expired, please scan again',
|
||||
corp_rejected: 'Your DingTalk account is not part of this organization. Please contact administrator',
|
||||
dingtalk_not_enabled: 'DingTalk login is not enabled',
|
||||
upstream_error: 'DingTalk service is temporarily unavailable. Please try again later',
|
||||
missing_browser_session: 'Browser session lost. Please login again',
|
||||
missing_params: 'Request parameters are incomplete',
|
||||
invalid_state: 'Invalid login state',
|
||||
provider_error: 'DingTalk authorization failed',
|
||||
session_error: 'Failed to create session. Please retry',
|
||||
retry: 'Retry Login'
|
||||
}
|
||||
},
|
||||
emailOAuth: {
|
||||
signIn: 'Continue with {providerName}'
|
||||
},
|
||||
oidc: {
|
||||
signIn: 'Continue with {providerName}',
|
||||
callbackTitle: 'Signing you in with {providerName}',
|
||||
callbackProcessing: 'Completing login with {providerName}, please wait...',
|
||||
callbackHint: 'If you are not redirected automatically, go back to the login page and try again.',
|
||||
callbackMissingToken: 'Missing login token, please try again.',
|
||||
backToLogin: 'Back to Login',
|
||||
invitationRequired:
|
||||
'This {providerName} account is not yet registered. The site requires an invitation code — please enter one to complete registration.',
|
||||
invalidPendingToken: 'The registration token has expired. Please sign in again.',
|
||||
completeRegistration: 'Complete Registration',
|
||||
completing: 'Completing registration…',
|
||||
completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.'
|
||||
},
|
||||
oauthFlow: {
|
||||
profileDetailsTitle: 'Use {providerName} profile details',
|
||||
profileDetailsDescription: 'Choose whether to apply the nickname or avatar from {providerName} to this account.',
|
||||
useDisplayName: 'Use display name',
|
||||
useAvatar: 'Use avatar',
|
||||
avatarAlt: '{providerName} avatar',
|
||||
reviewProfileBeforeContinue: 'Review the {providerName} profile details before continuing.',
|
||||
chooseHowToContinue: 'Choose how to continue',
|
||||
chooseAccountActionHint: 'Choose whether to bind an existing account or create a new one.',
|
||||
suggestedEmail: 'Suggested email: {email}',
|
||||
bindExistingAccount: 'Bind existing account',
|
||||
createNewAccount: 'Create new account',
|
||||
createAccountHint: 'Enter an email address to create your account and continue.',
|
||||
bindLoginHint: 'Log in to an existing account to bind this {providerName} sign-in.',
|
||||
signInThenBindDescription: 'Sign in to an existing account, then bind this {providerName} sign-in to it.',
|
||||
bindSignInToExistingAccount: 'Bind this {providerName} sign-in to an existing account.',
|
||||
bindCurrentAccountTitle: 'Bind the current account',
|
||||
bindCurrentAccountDescription: 'Bind this {providerName} sign-in to the account currently signed in on this browser.',
|
||||
bindCurrentAccount: 'Bind current account',
|
||||
logInAndBind: 'Log in and bind',
|
||||
useDifferentEmail: 'Use a different email',
|
||||
backToOptions: 'Back to options',
|
||||
yourAccount: 'your account',
|
||||
totpHint: 'Enter the 6-digit verification code for {account} to finish binding this {providerName} sign-in.',
|
||||
verifyAndContinue: 'Verify and continue',
|
||||
wechatAvailabilityUnknown: 'WeChat sign-in availability could not be confirmed. Refresh and retry.',
|
||||
wechatSystemBrowserOnly: 'This WeChat sign-in flow is only available in your system browser.',
|
||||
wechatBrowserOnly: 'This WeChat sign-in flow is only available inside the WeChat browser.',
|
||||
wechatNotConfigured: 'WeChat sign-in is not configured yet.'
|
||||
},
|
||||
linuxdoCallbackPageTitle: 'LinuxDo Sign-In Callback',
|
||||
dingtalkCallbackPageTitle: 'DingTalk Sign-In Callback',
|
||||
dingtalkProviderName: 'DingTalk',
|
||||
oidcCallbackPageTitle: 'OIDC Sign-In Callback',
|
||||
oauthCallbackPageTitle: 'OAuth Callback',
|
||||
wechatProviderName: 'WeChat',
|
||||
wechatCallbackPageTitle: 'WeChat Sign-In Callback',
|
||||
wechatPaymentCallbackPageTitle: 'WeChat Payment Callback',
|
||||
wechatPayment: {
|
||||
callbackTitle: 'Resuming WeChat payment',
|
||||
callbackProcessing: 'Resuming WeChat payment...',
|
||||
backToPayment: 'Back to payment',
|
||||
callbackMissingResumeToken: 'The WeChat payment callback is missing the resume token.'
|
||||
},
|
||||
oauth: {
|
||||
callbackTitle: 'OAuth Callback',
|
||||
callbackHint: 'Copy the code and state back to the admin authorization flow when needed.',
|
||||
invalidCallbackTitle: 'Invalid sign-in callback',
|
||||
invalidCallbackHint: 'This page does not contain a valid authorization result. Return to the login page and start quick sign-in again.',
|
||||
code: 'Code',
|
||||
state: 'State',
|
||||
fullUrl: 'Full URL'
|
||||
},
|
||||
// Forgot password
|
||||
forgotPassword: 'Forgot password?',
|
||||
forgotPasswordTitle: 'Reset Your Password',
|
||||
forgotPasswordHint: 'Enter your email address and we will send you a link to reset your password.',
|
||||
sendResetLink: 'Send Reset Link',
|
||||
sendingResetLink: 'Sending...',
|
||||
sendResetLinkFailed: 'Failed to send reset link. Please try again.',
|
||||
resetEmailSent: 'Reset Link Sent',
|
||||
resetEmailSentHint: 'If an account exists with this email, you will receive a password reset link shortly. Please check your inbox and spam folder.',
|
||||
backToLogin: 'Back to Login',
|
||||
rememberedPassword: 'Remembered your password?',
|
||||
// Reset password
|
||||
resetPasswordTitle: 'Set New Password',
|
||||
resetPasswordHint: 'Enter your new password below.',
|
||||
newPassword: 'New Password',
|
||||
newPasswordPlaceholder: 'Enter your new password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
confirmPasswordPlaceholder: 'Confirm your new password',
|
||||
confirmPasswordRequired: 'Please confirm your password',
|
||||
passwordsDoNotMatch: 'Passwords do not match',
|
||||
resetPassword: 'Reset Password',
|
||||
resettingPassword: 'Resetting...',
|
||||
resetPasswordFailed: 'Failed to reset password. Please try again.',
|
||||
passwordResetSuccess: 'Password Reset Successful',
|
||||
passwordResetSuccessHint: 'Your password has been reset. You can now sign in with your new password.',
|
||||
invalidResetLink: 'Invalid Reset Link',
|
||||
invalidResetLinkHint: 'This password reset link is invalid or has expired. Please request a new one.',
|
||||
requestNewResetLink: 'Request New Reset Link',
|
||||
invalidOrExpiredToken: 'The password reset link is invalid or has expired. Please request a new one.'
|
||||
},
|
||||
|
||||
// Dashboard
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
export default {
|
||||
dashboard: {
|
||||
title: 'Dashboard',
|
||||
welcomeMessage: "Welcome back! Here's an overview of your account.",
|
||||
balance: 'Balance',
|
||||
apiKeys: 'API Keys',
|
||||
todayRequests: 'Today Requests',
|
||||
todayCost: 'Today Cost',
|
||||
todayTokens: 'Today Tokens',
|
||||
totalTokens: 'Total Tokens',
|
||||
cacheToday: 'Cache (Today)',
|
||||
performance: 'Performance',
|
||||
avgResponse: 'Avg Response',
|
||||
averageTime: 'Average time',
|
||||
timeRange: 'Time Range',
|
||||
granularity: 'Granularity',
|
||||
day: 'Day',
|
||||
hour: 'Hour',
|
||||
modelDistribution: 'Model Distribution',
|
||||
groupDistribution: 'Group Usage Distribution',
|
||||
platformBreakdown: 'Per-platform Breakdown',
|
||||
platformBreakdownEmpty: 'No platform usage yet',
|
||||
platformCount: '{count} platforms',
|
||||
platformOther: 'Other',
|
||||
platformQuota: {
|
||||
title: 'Quota Usage',
|
||||
daily: 'Daily',
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly (30-day rolling)',
|
||||
resetsAt: 'Resets {time}',
|
||||
noLimit: 'unlimited',
|
||||
disabled: 'Disabled',
|
||||
},
|
||||
tokenUsageTrend: 'Token Usage Trend',
|
||||
noDataAvailable: 'No data available',
|
||||
model: 'Model',
|
||||
group: 'Group',
|
||||
noGroup: 'No Group',
|
||||
requests: 'Requests',
|
||||
tokens: 'Tokens',
|
||||
actual: 'Actual',
|
||||
standard: 'Standard',
|
||||
input: 'Input',
|
||||
output: 'Output',
|
||||
cache: 'Cache',
|
||||
recentUsage: 'Recent Usage',
|
||||
last7Days: 'Last 7 days',
|
||||
noUsageRecords: 'No usage records',
|
||||
startUsingApi: 'Start using the API to see your usage history here.',
|
||||
viewAllUsage: 'View all usage',
|
||||
quickActions: 'Quick Actions',
|
||||
createApiKey: 'Create API Key',
|
||||
generateNewKey: 'Generate a new API key',
|
||||
batchImageAgent: 'Batch Image Assistant',
|
||||
batchImageAgentDesc: 'Copy instructions for an agent',
|
||||
viewUsage: 'View Usage',
|
||||
checkDetailedLogs: 'Check detailed usage logs',
|
||||
redeemCode: 'Redeem Code',
|
||||
addBalanceWithCode: 'Add balance with a code'
|
||||
},
|
||||
|
||||
// Groups (shared)
|
||||
groups: {
|
||||
subscription: 'Sub'
|
||||
},
|
||||
|
||||
// API Keys
|
||||
keys: {
|
||||
title: 'API Keys',
|
||||
description: 'Manage your API keys and access tokens',
|
||||
searchPlaceholder: 'Search name or key...',
|
||||
endpoints: {
|
||||
title: 'API Endpoints',
|
||||
default: 'Default',
|
||||
copied: 'Copied',
|
||||
copiedHint: 'Copied to clipboard',
|
||||
clickToCopy: 'Click to copy this endpoint',
|
||||
speedTest: 'Speed Test',
|
||||
},
|
||||
allGroups: 'All Groups',
|
||||
allStatus: 'All Status',
|
||||
columnSettings: 'Column Settings',
|
||||
columnAlwaysVisible: 'This column is always visible',
|
||||
createKey: 'Create API Key',
|
||||
editKey: 'Edit API Key',
|
||||
deleteKey: 'Delete API Key',
|
||||
deleteConfirmMessage: "Are you sure you want to delete '{name}'? This action cannot be undone.",
|
||||
apiKey: 'API Key',
|
||||
group: 'Group',
|
||||
currentConcurrency: 'Current Concurrency',
|
||||
noGroup: 'No group',
|
||||
searchGroup: 'Search groups...',
|
||||
noGroupFound: 'No groups found',
|
||||
created: 'Created',
|
||||
copyToClipboard: 'Copy to clipboard',
|
||||
copied: 'Copied!',
|
||||
importToCcSwitch: 'Import to CCS',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
nameLabel: 'Name',
|
||||
namePlaceholder: 'My API Key',
|
||||
groupLabel: 'Group',
|
||||
selectGroup: 'Select a group',
|
||||
statusLabel: 'Status',
|
||||
selectStatus: 'Select status',
|
||||
saving: 'Saving...',
|
||||
noKeysYet: 'No API keys yet',
|
||||
createFirstKey: 'Create your first API key to get started with the API.',
|
||||
keyCreatedSuccess: 'API key created successfully',
|
||||
keyUpdatedSuccess: 'API key updated successfully',
|
||||
keyDeletedSuccess: 'API key deleted successfully',
|
||||
keyEnabledSuccess: 'API key enabled successfully',
|
||||
keyDisabledSuccess: 'API key disabled successfully',
|
||||
failedToLoad: 'Failed to load API keys',
|
||||
failedToSave: 'Failed to save API key',
|
||||
failedToDelete: 'Failed to delete API key',
|
||||
failedToUpdateStatus: 'Failed to update API key status',
|
||||
clickToChangeGroup: 'Click to change group',
|
||||
groupChangedSuccess: 'Group changed successfully',
|
||||
failedToChangeGroup: 'Failed to change group',
|
||||
groupRequired: 'Please select a group',
|
||||
usage: 'Usage',
|
||||
today: 'Today',
|
||||
total: 'Last 30d',
|
||||
quota: 'Quota',
|
||||
lastUsedAt: 'Last Used',
|
||||
useKey: 'Use Key',
|
||||
useKeyModal: {
|
||||
title: 'Use API Key',
|
||||
description:
|
||||
'Add the following environment variables to your terminal profile or run directly in terminal to configure API access.',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
|
||||
noGroupTitle: 'Please assign a group first',
|
||||
noGroupDescription: 'This API key has not been assigned to a group. Please click the group column in the key list to assign one before viewing the configuration.',
|
||||
openai: {
|
||||
description: 'Add the following configuration files to your Codex CLI config directory.',
|
||||
configTomlHint: 'Make sure the following content is at the beginning of the config.toml file',
|
||||
note: 'Make sure the config directory exists. macOS/Linux users can run mkdir -p ~/.codex to create it.',
|
||||
noteWindows: 'Press Win+R and enter %userprofile%\\.codex to open the config directory. Create it manually if it does not exist.',
|
||||
},
|
||||
cliTabs: {
|
||||
claudeCode: 'Claude Code',
|
||||
geminiCli: 'Gemini CLI',
|
||||
codexCli: 'Codex CLI',
|
||||
codexCliWs: 'Codex CLI (WebSocket)',
|
||||
opencode: 'OpenCode',
|
||||
},
|
||||
antigravity: {
|
||||
description: 'Configure API access for Antigravity group. Select the configuration method based on your client.',
|
||||
claudeCode: 'Claude Code',
|
||||
geminiCli: 'Gemini CLI',
|
||||
claudeNote: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
|
||||
geminiNote: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
|
||||
},
|
||||
gemini: {
|
||||
description: 'Add the following environment variables to your terminal profile or run directly in terminal to configure Gemini CLI access.',
|
||||
modelComment: 'If you have Gemini 3 access, you can use: gemini-3-pro-preview',
|
||||
note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
|
||||
},
|
||||
opencode: {
|
||||
title: 'OpenCode Example',
|
||||
subtitle: 'opencode.json',
|
||||
hint: 'Config path: ~/.config/opencode/opencode.json (or opencode.jsonc), create if not exists. Use default providers (openai/anthropic/google) or custom provider_id. API Key can be configured directly or via /connect command. This is an example, adjust models and options as needed.',
|
||||
},
|
||||
},
|
||||
customKeyLabel: 'Custom Key',
|
||||
customKeyPlaceholder: 'Enter your custom key (min 16 chars)',
|
||||
customKeyHint: 'Only letters, numbers, underscores and hyphens allowed. Minimum 16 characters.',
|
||||
customKeyTooShort: 'Custom key must be at least 16 characters',
|
||||
customKeyInvalidChars: 'Custom key can only contain letters, numbers, underscores, and hyphens',
|
||||
customKeyRequired: 'Please enter a custom key',
|
||||
ipRestriction: 'IP Restriction',
|
||||
ipWhitelist: 'IP Whitelist',
|
||||
ipWhitelistPlaceholder: '192.168.1.100\n10.0.0.0/8',
|
||||
ipWhitelistHint: 'One IP or CIDR per line. Only these IPs can use this key when set.',
|
||||
ipBlacklist: 'IP Blacklist',
|
||||
ipBlacklistPlaceholder: '1.2.3.4\n5.6.0.0/16',
|
||||
ipBlacklistHint: 'One IP or CIDR per line. These IPs will be blocked from using this key.',
|
||||
ipRestrictionEnabled: 'IP restriction enabled',
|
||||
ccSwitchNotInstalled: 'CC-Switch is not installed or the protocol handler is not registered. Please install CC-Switch first or manually copy the API key.',
|
||||
ccsClientSelect: {
|
||||
title: 'Select Client',
|
||||
description: 'Please select the client type to import to CC-Switch:',
|
||||
claudeCode: 'Claude Code',
|
||||
claudeCodeDesc: 'Import as Claude Code configuration',
|
||||
geminiCli: 'Gemini CLI',
|
||||
geminiCliDesc: 'Import as Gemini CLI configuration',
|
||||
},
|
||||
// Quota and expiration
|
||||
quotaLimit: 'Quota Limit',
|
||||
quotaAmount: 'Quota Amount (USD)',
|
||||
quotaAmountPlaceholder: 'Enter quota limit in USD',
|
||||
quotaAmountHint: 'Set the maximum amount this key can spend. 0 = unlimited.',
|
||||
quotaUsed: 'Quota Used',
|
||||
reset: 'Reset',
|
||||
resetQuotaUsed: 'Reset used quota to 0',
|
||||
resetQuotaTitle: 'Confirm Reset Quota',
|
||||
resetQuotaConfirmMessage: 'Are you sure you want to reset the used quota (${used}) for key "{name}" to 0? This action cannot be undone.',
|
||||
quotaResetSuccess: 'Quota reset successfully',
|
||||
failedToResetQuota: 'Failed to reset quota',
|
||||
rateLimitColumn: 'Rate Limit',
|
||||
rateLimitSection: 'Rate Limit',
|
||||
resetUsage: 'Reset',
|
||||
rateLimit5h: '5-Hour Limit (USD)',
|
||||
rateLimit1d: 'Daily Limit (USD)',
|
||||
rateLimit7d: '7-Day Limit (USD)',
|
||||
rateLimitHint: 'Set the maximum spending for this key within each time window. 0 = unlimited.',
|
||||
rateLimitUsage: 'Rate Limit Usage',
|
||||
resetRateLimitUsage: 'Reset Rate Limit Usage',
|
||||
resetRateLimitTitle: 'Confirm Reset Rate Limit',
|
||||
resetRateLimitConfirmMessage: 'Are you sure you want to reset the rate limit usage for key "{name}"? All time window usage will be reset to zero. This action cannot be undone.',
|
||||
rateLimitResetSuccess: 'Rate limit usage reset successfully',
|
||||
failedToResetRateLimit: 'Failed to reset rate limit usage',
|
||||
resetNow: 'Resetting soon',
|
||||
expiration: 'Expiration',
|
||||
expiresInDays: '{days} days',
|
||||
extendDays: '+{days} days',
|
||||
customDate: 'Custom',
|
||||
expirationDate: 'Expiration Date',
|
||||
expirationDateHint: 'Select when this API key should expire.',
|
||||
currentExpiration: 'Current expiration',
|
||||
expiresAt: 'Expires',
|
||||
noExpiration: 'Never',
|
||||
status: {
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
quota_exhausted: 'Quota Exhausted',
|
||||
expired: 'Expired',
|
||||
},
|
||||
},
|
||||
|
||||
// Usage
|
||||
usage: {
|
||||
title: 'Usage Records',
|
||||
description: 'View and analyze your API usage history',
|
||||
costDetails: 'Cost Breakdown',
|
||||
tokenDetails: 'Token Breakdown',
|
||||
cacheTtlOverriddenHint: 'Cache TTL Override enabled',
|
||||
cacheTtlOverriddenLabel: 'TTL Override',
|
||||
cacheTtlOverridden5m: 'Billed as 5m',
|
||||
cacheTtlOverridden1h: 'Billed as 1h',
|
||||
totalRequests: 'Total Requests',
|
||||
totalTokens: 'Total Tokens',
|
||||
cacheTotal: 'Cache',
|
||||
cacheBreakdown: 'Cache Token Breakdown',
|
||||
cacheCreationTokensLabel: 'Cache Creation',
|
||||
cacheReadTokensLabel: 'Cache Read',
|
||||
totalCost: 'Total Cost',
|
||||
standardCost: 'Standard',
|
||||
actualCost: 'Actual',
|
||||
accountCost: 'Cost',
|
||||
userBilled: 'User billed',
|
||||
accountBilled: 'Account billed',
|
||||
resetNow: 'Now',
|
||||
resetPending: 'Pending refresh',
|
||||
accountMultiplier: 'Account rate',
|
||||
avgDuration: 'Avg Duration',
|
||||
inSelectedRange: 'in selected range',
|
||||
perRequest: 'per request',
|
||||
apiKeyFilter: 'API Key',
|
||||
allApiKeys: 'All API Keys',
|
||||
timeRange: 'Time Range',
|
||||
exportCsv: 'Export CSV',
|
||||
exportExcel: 'Export Excel',
|
||||
exportingProgress: 'Exporting data...',
|
||||
exportedCount: 'Exported {current}/{total} records',
|
||||
estimatedTime: 'Estimated time remaining: {time}',
|
||||
cancelExport: 'Cancel Export',
|
||||
exportCancelled: 'Export cancelled',
|
||||
exporting: 'Exporting...',
|
||||
preparingExport: 'Preparing export...',
|
||||
model: 'Model',
|
||||
requestedModel: 'Requested',
|
||||
upstreamModel: 'Upstream',
|
||||
reasoningEffort: 'Reasoning Effort',
|
||||
endpoint: 'Endpoint',
|
||||
endpointDistribution: 'Endpoint Distribution',
|
||||
inbound: 'Inbound',
|
||||
upstream: 'Upstream',
|
||||
mapping: 'Mapping',
|
||||
path: 'Path',
|
||||
inboundEndpoint: 'Inbound Endpoint',
|
||||
upstreamEndpoint: 'Upstream Endpoint',
|
||||
type: 'Type',
|
||||
tokens: 'Tokens',
|
||||
cost: 'Cost',
|
||||
firstToken: 'First Token',
|
||||
duration: 'Duration',
|
||||
time: 'Time',
|
||||
ws: 'WS',
|
||||
stream: 'Stream',
|
||||
sync: 'Sync',
|
||||
cyber: 'Cyber',
|
||||
unknown: 'Unknown',
|
||||
in: 'In',
|
||||
out: 'Out',
|
||||
cacheHit: 'Cache hit',
|
||||
cacheCreate: 'Cache create',
|
||||
cacheHitRate: 'Cache hit rate',
|
||||
inputTokenPrice: 'Input price',
|
||||
outputTokenPrice: 'Output price',
|
||||
perMillionTokens: '/ 1M tokens',
|
||||
unitPrice: 'Per-request price',
|
||||
imageUnitPrice: 'Per-image price',
|
||||
imageTotalPrice: 'Image total price',
|
||||
imageCount: 'Image count',
|
||||
imageBillingSize: 'Billing size',
|
||||
imageInputSize: 'Input size',
|
||||
imageOutputSize: 'Output size',
|
||||
imageOutputTokens: 'Image Output Tokens',
|
||||
imageOutputTokenPrice: 'Image Output Price',
|
||||
imageOutputCost: 'Image Output Cost',
|
||||
imageSizeSource: 'Size source',
|
||||
imageSizeBreakdown: 'Size breakdown',
|
||||
imageSizeSourceOutput: 'Upstream output',
|
||||
imageSizeSourceInput: 'Request input',
|
||||
imageSizeSourceDefault: 'Default billing tier',
|
||||
imageSizeSourceLegacy: 'Legacy record',
|
||||
imageSizeSourceMissing: 'Not recorded',
|
||||
imageSizeNotRecorded: 'not recorded',
|
||||
imageSizeLegacyUnstandardized: 'legacy unstandardized',
|
||||
imageSizeUnknown: 'unknown',
|
||||
cacheRead: 'Read',
|
||||
cacheWrite: 'Write',
|
||||
serviceTier: 'Service tier',
|
||||
serviceTierPriority: 'Fast',
|
||||
serviceTierFlex: 'Flex',
|
||||
serviceTierStandard: 'Standard',
|
||||
rate: 'Rate',
|
||||
original: 'Original',
|
||||
billed: 'Billed',
|
||||
noRecords: 'No usage records found. Try adjusting your filters.',
|
||||
failedToLoad: 'Failed to load usage logs',
|
||||
noDataToExport: 'No data to export',
|
||||
exportSuccess: 'Usage data exported successfully',
|
||||
exportFailed: 'Failed to export usage data',
|
||||
exportExcelSuccess: 'Usage data exported successfully (Excel format)',
|
||||
exportExcelFailed: 'Failed to export usage data',
|
||||
imageUnit: ' images',
|
||||
userAgent: 'User-Agent',
|
||||
ipGeo: {
|
||||
fetch: 'Fetch region',
|
||||
fetching: 'Fetching...',
|
||||
failed: 'Failed',
|
||||
private: 'Private address',
|
||||
refreshTitle: 'Refresh region info',
|
||||
batchFetch: 'Batch fetch regions',
|
||||
batchFetching: 'Fetching...',
|
||||
pending: '{count} IPs pending',
|
||||
batchFailed: 'Failed to batch fetch IP regions',
|
||||
detailOrg: 'ISP',
|
||||
detailTimezone: 'Timezone',
|
||||
detailAccuracy: 'Accuracy',
|
||||
detailCoordinates: 'Coordinates',
|
||||
},
|
||||
tabs: { usage: 'Usage', errors: 'Error Requests' },
|
||||
errors: {
|
||||
time: 'Time', model: 'Model', endpoint: 'Endpoint', status: 'Status',
|
||||
category: 'Category', platform: 'Platform', message: 'Message',
|
||||
keyName: 'Key Name', keyDeleted: 'Deleted', allKeys: 'All keys',
|
||||
modelPlaceholder: 'Search model', allCategories: 'All categories', allStatuses: 'All status codes',
|
||||
empty: 'No error requests', failedToLoad: 'Failed to load error requests',
|
||||
categories: {
|
||||
auth: 'Auth failed', rate_limit: 'Rate limited', quota: 'Balance/Subscription',
|
||||
invalid_request: 'Invalid request', service_unavailable: 'Service unavailable',
|
||||
upstream: 'Upstream error', internal: 'Platform error', other: 'Other', cyber: 'Cyber policy',
|
||||
},
|
||||
detail: {
|
||||
title: 'Error Request Detail',
|
||||
responseBody: 'Response Body',
|
||||
upstreamStatus: 'Upstream Status',
|
||||
loadFailed: 'Failed to load detail, please try again',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Shared keys for channel monitor (admin + user views)
|
||||
monitorCommon: {
|
||||
status: {
|
||||
operational: 'Operational',
|
||||
degraded: 'Degraded',
|
||||
failed: 'Failed',
|
||||
error: 'Error',
|
||||
unknown: '-'
|
||||
},
|
||||
providers: {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
gemini: 'Gemini'
|
||||
},
|
||||
extraModelsHeader: 'Extra Models',
|
||||
extraModelsEmpty: 'No extra models',
|
||||
latencyEmpty: '-',
|
||||
availabilityPrefix: 'Availability',
|
||||
dialogLatency: 'Dialog Latency',
|
||||
endpointPing: 'Endpoint PING',
|
||||
history60pts: 'HISTORY ({n} PTS)',
|
||||
nextUpdateIn: 'NEXT UPDATE IN {n}s',
|
||||
past: 'PAST',
|
||||
now: 'NOW',
|
||||
maintenancePaused: 'Maintenance · timeline paused',
|
||||
extraModelsCount: '+ {n} models',
|
||||
pollEvery: '{n}s polling',
|
||||
updatedAt: 'Updated {time}',
|
||||
relativeSecondsAgo: '{n}s ago',
|
||||
relativeMinutesAgo: '{n}m ago',
|
||||
relativeHoursAgo: '{n}h ago',
|
||||
relativeDaysAgo: '{n}d ago'
|
||||
},
|
||||
|
||||
// Channel Status (user-facing read-only view)
|
||||
channelStatus: {
|
||||
title: 'Channel Status',
|
||||
description: 'Inspect channel availability, latency and recent status',
|
||||
searchPlaceholder: 'Search channels...',
|
||||
allProviders: 'All Providers',
|
||||
loadError: 'Failed to load channel status',
|
||||
detailLoadError: 'Failed to load channel detail',
|
||||
detailTitle: 'Channel Detail',
|
||||
closeDetail: 'Close',
|
||||
windowTab: {
|
||||
'7d': '7 days',
|
||||
'15d': '15 days',
|
||||
'30d': '30 days'
|
||||
},
|
||||
overall: {
|
||||
operational: 'OPERATIONAL',
|
||||
degraded: 'DEGRADED',
|
||||
unavailable: 'UNAVAILABLE'
|
||||
},
|
||||
columns: {
|
||||
name: 'Name',
|
||||
provider: 'Provider',
|
||||
groupName: 'Group',
|
||||
primaryModel: 'Primary Model',
|
||||
availability7d: '7d Availability',
|
||||
latency: 'Latency (ms)'
|
||||
},
|
||||
detailColumns: {
|
||||
model: 'Model',
|
||||
latestStatus: 'Latest Status',
|
||||
latestLatency: 'Latest Latency (ms)',
|
||||
availability7d: '7d Availability',
|
||||
availability15d: '15d Availability',
|
||||
availability30d: '30d Availability',
|
||||
avgLatency7d: '7d Avg Latency (ms)'
|
||||
},
|
||||
empty: {
|
||||
title: 'No channels available',
|
||||
description: 'No monitored channels have been configured yet.'
|
||||
}
|
||||
},
|
||||
|
||||
// Available Channels (user-facing)
|
||||
availableChannels: {
|
||||
title: 'Available Channels',
|
||||
description: 'Channels you can access, along with their supported models and pricing',
|
||||
searchPlaceholder: 'Search channels or models...',
|
||||
empty: 'No available channels',
|
||||
noModels: 'No models configured',
|
||||
noPricing: 'Pricing not configured',
|
||||
exclusive: 'Exclusive',
|
||||
public: 'Public',
|
||||
exclusiveTooltip: 'Exclusive groups granted to you by an admin',
|
||||
publicTooltip: 'Groups open to all users',
|
||||
columns: {
|
||||
name: 'Channel',
|
||||
description: 'Description',
|
||||
platform: 'Platform',
|
||||
groups: 'Your Accessible Groups',
|
||||
supportedModels: 'Supported Models'
|
||||
},
|
||||
pricing: {
|
||||
billingMode: 'Billing Mode',
|
||||
billingModeToken: 'Per Token',
|
||||
billingModePerRequest: 'Per Request',
|
||||
billingModeImage: 'Per Image',
|
||||
inputPrice: 'Input',
|
||||
outputPrice: 'Output',
|
||||
cacheWritePrice: 'Cache Write',
|
||||
cacheReadPrice: 'Cache Read',
|
||||
imageOutputPrice: 'Image Output',
|
||||
perRequestPrice: 'Per Request',
|
||||
intervals: 'Tiered Pricing',
|
||||
unitPerMillion: '/ 1M tokens',
|
||||
unitPerRequest: '/ request'
|
||||
}
|
||||
},
|
||||
|
||||
affiliate: {
|
||||
title: 'Affiliate Rebates',
|
||||
description: 'Invite new users and convert your rebate quota into account balance',
|
||||
yourCode: 'Your Affiliate Code',
|
||||
inviteLink: 'Invite Link',
|
||||
copyCode: 'Copy Code',
|
||||
copyLink: 'Copy Link',
|
||||
codeCopied: 'Affiliate code copied',
|
||||
linkCopied: 'Invite link copied',
|
||||
loadFailed: 'Failed to load affiliate data',
|
||||
transferFailed: 'Failed to transfer affiliate quota',
|
||||
stats: {
|
||||
rebateRate: 'My Rebate Rate',
|
||||
rebateRateHint: 'What you earn each time an invitee recharges',
|
||||
invitedUsers: 'Invited Users',
|
||||
availableQuota: 'Available Rebate Quota',
|
||||
frozenQuota: 'Frozen',
|
||||
frozenQuotaHint: 'Recently earned rebates pending release',
|
||||
totalQuota: 'Historical Rebate Quota'
|
||||
},
|
||||
transfer: {
|
||||
title: 'Transfer Rebate Quota',
|
||||
description: 'Move available rebate quota into your account balance',
|
||||
button: 'Transfer to Balance',
|
||||
transferring: 'Transferring...',
|
||||
empty: 'No available rebate quota',
|
||||
success: '{amount} has been transferred to your balance'
|
||||
},
|
||||
invitees: {
|
||||
title: 'Invited Users',
|
||||
empty: 'No invited users yet',
|
||||
columns: {
|
||||
email: 'Email',
|
||||
username: 'Username',
|
||||
rebate: 'Rebate',
|
||||
joinedAt: 'Joined At'
|
||||
}
|
||||
},
|
||||
tips: {
|
||||
title: 'How It Works',
|
||||
line1: 'Share your affiliate code or invite link with new users.',
|
||||
line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.',
|
||||
line3: 'Transfer rebate quota to balance at any time.',
|
||||
line4: 'Newly earned rebates may have a waiting period before they can be transferred.'
|
||||
}
|
||||
},
|
||||
|
||||
// Redeem
|
||||
redeem: {
|
||||
title: 'Redeem Code',
|
||||
description: 'Enter your redeem code to add balance or increase concurrency',
|
||||
currentBalance: 'Current Balance',
|
||||
concurrency: 'Concurrency',
|
||||
requests: 'requests',
|
||||
redeemCodeLabel: 'Redeem Code',
|
||||
redeemCodePlaceholder: 'Enter your redeem code',
|
||||
redeemCodeHint: 'Redeem codes are case-sensitive',
|
||||
redeeming: 'Redeeming...',
|
||||
redeemButton: 'Redeem Code',
|
||||
redeemSuccess: 'Code Redeemed Successfully!',
|
||||
redeemFailed: 'Redemption Failed',
|
||||
added: 'Added',
|
||||
concurrentRequests: 'concurrent requests',
|
||||
newBalance: 'New Balance',
|
||||
newConcurrency: 'New Concurrency',
|
||||
aboutCodes: 'About Redeem Codes',
|
||||
codeRule1: 'Each code can only be used once',
|
||||
codeRule2: 'Codes may add balance, increase concurrency, or grant trial access',
|
||||
codeRule3: 'Contact support if you have issues redeeming a code',
|
||||
codeRule4: 'Balance and concurrency updates are immediate',
|
||||
recentActivity: 'Recent Activity',
|
||||
historyWillAppear: 'Your redemption history will appear here',
|
||||
balanceAddedRedeem: 'Balance Added (Redeem)',
|
||||
balanceAddedAffiliate: 'Balance Added (Affiliate Transfer)',
|
||||
balanceAddedAdmin: 'Balance Added (Admin)',
|
||||
balanceDeductedAdmin: 'Balance Deducted (Admin)',
|
||||
concurrencyAddedRedeem: 'Concurrency Added (Redeem)',
|
||||
concurrencyAddedAdmin: 'Concurrency Added (Admin)',
|
||||
concurrencyReducedAdmin: 'Concurrency Reduced (Admin)',
|
||||
adminAdjustment: 'Admin Adjustment',
|
||||
subscriptionAssigned: 'Subscription Assigned',
|
||||
subscriptionAssignedDesc: 'You have been granted access to {groupName}',
|
||||
subscriptionDays: '{days} days',
|
||||
days: ' days',
|
||||
codeRedeemSuccess: 'Code redeemed successfully!',
|
||||
failedToRedeem: 'Failed to redeem code. Please check the code and try again.',
|
||||
subscriptionRefreshFailed: 'Redeemed successfully, but failed to refresh subscription status.',
|
||||
pleaseEnterCode: 'Please enter a redeem code'
|
||||
},
|
||||
|
||||
// Profile
|
||||
profile: {
|
||||
title: 'Profile Settings',
|
||||
description: 'Manage your account information and settings',
|
||||
accountBalance: 'Account Balance',
|
||||
concurrencyLimit: 'Concurrency Limit',
|
||||
rpmLimit: 'RPM Limit',
|
||||
rpmUnlimited: 'Unlimited',
|
||||
memberSince: 'Member Since',
|
||||
overviewTitle: 'Account Overview',
|
||||
overviewDescription: 'Check account status, profile sources, and common actions at a glance.',
|
||||
basicsTitle: 'Profile & Avatar',
|
||||
basicsDescription: 'Keep your public profile details and avatar aligned.',
|
||||
linkedProfileSources: 'Profile Sources',
|
||||
linkedProfileSourcesDescription: 'Some profile details may stay synced from third-party sign-in methods.',
|
||||
securityTitle: 'Security Settings',
|
||||
securityDescription: 'Password, two-factor authentication, and alerts live in the right rail.',
|
||||
administrator: 'Administrator',
|
||||
user: 'User',
|
||||
username: 'Username',
|
||||
email: 'Email',
|
||||
status: 'Status',
|
||||
role: 'Role',
|
||||
enterUsername: 'Enter username',
|
||||
editProfile: 'Edit Profile',
|
||||
updateProfile: 'Update Profile',
|
||||
updating: 'Updating...',
|
||||
updateSuccess: 'Profile updated successfully',
|
||||
updateFailed: 'Failed to update profile',
|
||||
usernameRequired: 'Username is required',
|
||||
changePassword: 'Change Password',
|
||||
currentPassword: 'Current Password',
|
||||
newPassword: 'New Password',
|
||||
confirmNewPassword: 'Confirm New Password',
|
||||
passwordHint: 'Password must be at least 8 characters long',
|
||||
changingPassword: 'Changing...',
|
||||
changePasswordButton: 'Change Password',
|
||||
passwordsNotMatch: 'New passwords do not match',
|
||||
passwordTooShort: 'Password must be at least 8 characters long',
|
||||
passwordChangeSuccess: 'Password changed successfully',
|
||||
passwordChangeFailed: 'Failed to change password',
|
||||
// TOTP 2FA
|
||||
totp: {
|
||||
title: 'Two-Factor Authentication (2FA)',
|
||||
description: 'Enhance account security with Google Authenticator or similar apps',
|
||||
enabled: 'Enabled',
|
||||
enabledAt: 'Enabled at',
|
||||
notEnabled: 'Not Enabled',
|
||||
notEnabledHint: 'Enable two-factor authentication to enhance account security',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
featureDisabled: 'Feature Unavailable',
|
||||
featureDisabledHint: 'Two-factor authentication has not been enabled by the administrator',
|
||||
setupTitle: 'Set Up Two-Factor Authentication',
|
||||
setupStep1: 'Scan the QR code below with your authenticator app',
|
||||
setupStep2: 'Enter the 6-digit code from your app',
|
||||
manualEntry: "Can't scan? Enter the key manually:",
|
||||
enterCode: 'Enter 6-digit code',
|
||||
verify: 'Verify',
|
||||
setupFailed: 'Failed to get setup information',
|
||||
verifyFailed: 'Invalid code, please try again',
|
||||
enableSuccess: 'Two-factor authentication enabled',
|
||||
disableTitle: 'Disable Two-Factor Authentication',
|
||||
disableWarning: 'After disabling, you will no longer need a verification code to log in. This may reduce your account security.',
|
||||
enterPassword: 'Enter your current password to confirm',
|
||||
confirmDisable: 'Confirm Disable',
|
||||
disableSuccess: 'Two-factor authentication disabled',
|
||||
disableFailed: 'Failed to disable, please check your password',
|
||||
loginTitle: 'Two-Factor Authentication',
|
||||
loginHint: 'Enter the 6-digit code from your authenticator app',
|
||||
loginFailed: 'Verification failed, please try again',
|
||||
// New translations for email verification
|
||||
verifyEmailFirst: 'Please verify your email first',
|
||||
verifyPasswordFirst: 'Please verify your identity first',
|
||||
emailCode: 'Email Verification Code',
|
||||
enterEmailCode: 'Enter 6-digit code',
|
||||
sendCode: 'Send Code',
|
||||
codeSent: 'Verification code sent to your email',
|
||||
sendCodeFailed: 'Failed to send verification code'
|
||||
},
|
||||
balanceNotify: {
|
||||
title: 'Balance Low Notification',
|
||||
description: 'Send email alert when account balance falls below threshold',
|
||||
enabled: 'Enable Balance Low Notification',
|
||||
threshold: 'Custom Threshold',
|
||||
thresholdHint: 'Leave empty to use system default',
|
||||
thresholdPlaceholder: 'Enter amount',
|
||||
systemDefault: 'System Default',
|
||||
extraEmails: 'Notification Emails',
|
||||
extraEmailsHint: 'You must add and verify an email address to receive low balance alerts',
|
||||
primaryEmail: 'Primary',
|
||||
noExtraEmails: 'No extra notification emails',
|
||||
enterEmail: 'Enter email address',
|
||||
addEmail: 'Add Email',
|
||||
emailPlaceholder: 'Enter email address',
|
||||
sendCode: 'Send Code',
|
||||
resend: 'Resend',
|
||||
codeSent: 'Verification code sent',
|
||||
codeSentTo: 'Code sent to {email}',
|
||||
enterCode: 'Enter verification code',
|
||||
codePlaceholder: '6-digit code',
|
||||
verify: 'Verify',
|
||||
emailAdded: 'Email added',
|
||||
emailRemoved: 'Email removed',
|
||||
verifySuccess: 'Email added successfully',
|
||||
removeEmail: 'Remove',
|
||||
removeSuccess: 'Email removed',
|
||||
emailDuplicate: 'This email already exists',
|
||||
maxEmailsReached: 'Maximum number of notification emails reached',
|
||||
unverified: 'Unverified',
|
||||
verified: 'Verified',
|
||||
},
|
||||
avatar: {
|
||||
title: 'Profile Avatar',
|
||||
description: 'Upload an avatar image. Static uploads are compressed to 20KB before saving.',
|
||||
uploadAction: 'Upload image',
|
||||
uploadHint: 'Static uploads are compressed to 20KB when possible. GIF uploads must already be within 20KB.',
|
||||
uploadRequired: 'Upload an avatar image first',
|
||||
saveSuccess: 'Avatar updated',
|
||||
deleteSuccess: 'Avatar removed',
|
||||
invalidType: 'Please choose an image file',
|
||||
gifTooLarge: 'GIF avatars must already be 20KB or smaller',
|
||||
compressTooLarge: 'Unable to compress this image below 20KB. Try a smaller image.',
|
||||
compressFailed: 'Failed to compress the selected image.',
|
||||
readFailed: 'Failed to read the selected image.',
|
||||
emptyDeleteHint: 'Avatar is already empty',
|
||||
},
|
||||
authBindings: {
|
||||
title: 'Connected Sign-In Methods',
|
||||
description: 'View current bindings and connect another provider to this account.',
|
||||
bindAction: 'Bind {providerName}',
|
||||
bindSuccess: 'Account linked successfully',
|
||||
emailPlaceholder: 'Enter email address',
|
||||
codePlaceholder: 'Enter verification code',
|
||||
passwordPlaceholder: 'Set a login password',
|
||||
replaceEmailPasswordPlaceholder: 'Enter current password',
|
||||
sendCodeAction: 'Send code',
|
||||
manageEmailAction: 'Manage email',
|
||||
hideEmailFormAction: 'Hide email form',
|
||||
confirmEmailBindAction: 'Bind email',
|
||||
confirmEmailReplaceAction: 'Replace primary email',
|
||||
codeSentTo: 'Code sent to {email}',
|
||||
replaceSuccess: 'Primary email updated',
|
||||
unbindAction: 'Unbind',
|
||||
unbindSuccess: '{providerName} unbound',
|
||||
boundCount: '{count} linked records',
|
||||
status: {
|
||||
bound: 'Bound',
|
||||
notBound: 'Not bound',
|
||||
},
|
||||
providers: {
|
||||
email: 'Email',
|
||||
linuxdo: 'LinuxDo',
|
||||
dingtalk: 'DingTalk',
|
||||
oidc: '{providerName}',
|
||||
wechat: 'WeChat',
|
||||
},
|
||||
notes: {
|
||||
emailManagedFromProfile: 'Primary email is managed in the profile form',
|
||||
canUnbind: 'You can unbind this sign-in method',
|
||||
bindAnotherBeforeUnbind: 'Bind another sign-in method before unbinding',
|
||||
},
|
||||
source: {
|
||||
avatar: 'Avatar is currently synced from {providerName}',
|
||||
username: 'Nickname is currently synced from {providerName}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
// Empty States
|
||||
empty: {
|
||||
noData: 'No data found'
|
||||
},
|
||||
|
||||
// Table
|
||||
table: {
|
||||
expandActions: 'Expand More Actions',
|
||||
collapseActions: 'Collapse Actions'
|
||||
},
|
||||
|
||||
// Pagination
|
||||
pagination: {
|
||||
showing: 'Showing',
|
||||
to: 'to',
|
||||
of: 'of',
|
||||
results: 'results',
|
||||
page: 'Page',
|
||||
pageOf: 'Page {page} of {total}',
|
||||
previous: 'Previous',
|
||||
next: 'Next',
|
||||
perPage: 'Per page',
|
||||
goToPage: 'Go to page {page}',
|
||||
jumpTo: 'Jump to',
|
||||
jumpPlaceholder: 'Page',
|
||||
jumpAction: 'Go'
|
||||
},
|
||||
|
||||
// Errors
|
||||
errors: {
|
||||
somethingWentWrong: 'Something went wrong',
|
||||
pageNotFound: 'Page not found',
|
||||
unauthorized: 'Unauthorized',
|
||||
forbidden: 'Forbidden',
|
||||
serverError: 'Server error',
|
||||
networkError: 'Network error',
|
||||
timeout: 'Request timeout',
|
||||
tryAgain: 'Please try again'
|
||||
},
|
||||
|
||||
// Dates
|
||||
dates: {
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday',
|
||||
thisWeek: 'This Week',
|
||||
lastWeek: 'Last Week',
|
||||
thisMonth: 'This Month',
|
||||
lastMonth: 'Last Month',
|
||||
last24Hours: 'Last 24 Hours',
|
||||
last7Days: 'Last 7 Days',
|
||||
last14Days: 'Last 14 Days',
|
||||
last30Days: 'Last 30 Days',
|
||||
custom: 'Custom',
|
||||
startDate: 'Start Date',
|
||||
endDate: 'End Date',
|
||||
apply: 'Apply',
|
||||
selectDateRange: 'Select date range'
|
||||
},
|
||||
|
||||
// Admin
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import landing from './landing'
|
||||
import common from './common'
|
||||
import dashboard from './dashboard'
|
||||
import admin from './admin'
|
||||
import misc from './misc'
|
||||
|
||||
export default {
|
||||
...landing,
|
||||
...common,
|
||||
...dashboard,
|
||||
admin,
|
||||
...misc,
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
export default {
|
||||
batchImageGuide: {
|
||||
title: 'Batch Image Generation',
|
||||
description: 'Submit multiple prompts in one job and download the generated images when complete'
|
||||
},
|
||||
// Home Page
|
||||
home: {
|
||||
viewOnGithub: 'View on GitHub',
|
||||
viewDocs: 'View Documentation',
|
||||
docs: 'Docs',
|
||||
switchToLight: 'Switch to Light Mode',
|
||||
switchToDark: 'Switch to Dark Mode',
|
||||
dashboard: 'Dashboard',
|
||||
login: 'Login',
|
||||
getStarted: 'Get Started',
|
||||
goToDashboard: 'Go to Dashboard',
|
||||
// User-focused value proposition
|
||||
heroSubtitle: 'One Key, All AI Models',
|
||||
heroDescription: 'No need to manage multiple subscriptions. Access Claude, GPT, Gemini and more with a single API key',
|
||||
tags: {
|
||||
subscriptionToApi: 'Subscription to API',
|
||||
stickySession: 'Session Persistence',
|
||||
realtimeBilling: 'Pay As You Go'
|
||||
},
|
||||
// Pain points section
|
||||
painPoints: {
|
||||
title: 'Sound Familiar?',
|
||||
items: {
|
||||
expensive: {
|
||||
title: 'High Subscription Costs',
|
||||
desc: 'Paying for multiple AI subscriptions that add up every month'
|
||||
},
|
||||
complex: {
|
||||
title: 'Account Chaos',
|
||||
desc: 'Managing scattered accounts and API keys across different platforms'
|
||||
},
|
||||
unstable: {
|
||||
title: 'Service Interruptions',
|
||||
desc: 'Single accounts hitting rate limits and disrupting your workflow'
|
||||
},
|
||||
noControl: {
|
||||
title: 'No Usage Control',
|
||||
desc: "Can't track where your money goes or limit team member usage"
|
||||
}
|
||||
}
|
||||
},
|
||||
// Solutions section
|
||||
solutions: {
|
||||
title: 'We Solve These Problems',
|
||||
subtitle: 'Three simple steps to stress-free AI access'
|
||||
},
|
||||
features: {
|
||||
unifiedGateway: 'One-Click Access',
|
||||
unifiedGatewayDesc: 'Get a single API key to call all connected AI models. No separate applications needed.',
|
||||
multiAccount: 'Always Reliable',
|
||||
multiAccountDesc: 'Smart routing across multiple upstream accounts with automatic failover. Say goodbye to errors.',
|
||||
balanceQuota: 'Pay What You Use',
|
||||
balanceQuotaDesc: 'Usage-based billing with quota limits. Full visibility into team consumption.'
|
||||
},
|
||||
// Comparison section
|
||||
comparison: {
|
||||
title: 'Why Choose Us?',
|
||||
headers: {
|
||||
feature: 'Comparison',
|
||||
official: 'Official Subscriptions',
|
||||
us: 'Our Platform'
|
||||
},
|
||||
items: {
|
||||
pricing: {
|
||||
feature: 'Pricing',
|
||||
official: 'Fixed monthly fee, pay even if unused',
|
||||
us: 'Pay only for what you use'
|
||||
},
|
||||
models: {
|
||||
feature: 'Model Selection',
|
||||
official: 'Single provider only',
|
||||
us: 'Switch between models freely'
|
||||
},
|
||||
management: {
|
||||
feature: 'Account Management',
|
||||
official: 'Manage each service separately',
|
||||
us: 'Unified key, one dashboard'
|
||||
},
|
||||
stability: {
|
||||
feature: 'Stability',
|
||||
official: 'Single account rate limits',
|
||||
us: 'Multi-account pool, auto-failover'
|
||||
},
|
||||
control: {
|
||||
feature: 'Usage Control',
|
||||
official: 'Not available',
|
||||
us: 'Quotas & detailed analytics'
|
||||
}
|
||||
}
|
||||
},
|
||||
providers: {
|
||||
title: 'Supported AI Models',
|
||||
description: 'One API, Multiple Choices',
|
||||
supported: 'Supported',
|
||||
soon: 'Soon',
|
||||
claude: 'Claude',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity',
|
||||
more: 'More'
|
||||
},
|
||||
// CTA section
|
||||
cta: {
|
||||
title: 'Ready to Get Started?',
|
||||
description: 'Sign up now and get free trial credits to experience seamless AI access',
|
||||
button: 'Sign Up Free'
|
||||
},
|
||||
footer: {
|
||||
allRightsReserved: 'All rights reserved.'
|
||||
}
|
||||
},
|
||||
|
||||
// Key Usage Query Page
|
||||
keyUsage: {
|
||||
title: 'API Key Usage',
|
||||
subtitle: 'Enter your API Key to view real-time spending and usage status',
|
||||
placeholder: 'sk-ant-mirror-xxxxxxxxxxxx',
|
||||
query: 'Query',
|
||||
querying: 'Querying...',
|
||||
privacyNote: 'Your Key is processed locally in the browser and will not be stored',
|
||||
dateRange: 'Date Range:',
|
||||
dateRangeToday: 'Today',
|
||||
dateRange7d: '7 Days',
|
||||
dateRange30d: '30 Days',
|
||||
dateRange90d: '90 Days',
|
||||
dateRangeCustom: 'Custom',
|
||||
apply: 'Apply',
|
||||
used: 'Used',
|
||||
detailInfo: 'Detail Information',
|
||||
tokenStats: 'Token Statistics',
|
||||
dailyDetail: 'Daily Detail',
|
||||
modelStats: 'Model Usage Statistics',
|
||||
// Table headers
|
||||
date: 'Date',
|
||||
model: 'Model',
|
||||
requests: 'Requests',
|
||||
inputTokens: 'Input Tokens',
|
||||
outputTokens: 'Output Tokens',
|
||||
cacheCreationTokens: 'Cache Creation',
|
||||
cacheReadTokens: 'Cache Read',
|
||||
cacheWriteTokens: 'Cache Write',
|
||||
totalTokens: 'Total Tokens',
|
||||
cost: 'Cost',
|
||||
// Status
|
||||
quotaMode: 'Key Quota Mode',
|
||||
walletBalance: 'Wallet Balance',
|
||||
// Ring card titles
|
||||
totalQuota: 'Total Quota',
|
||||
limit5h: '5-Hour Limit',
|
||||
limitDaily: 'Daily Limit',
|
||||
limit7d: '7-Day Limit',
|
||||
limitWeekly: 'Weekly Limit',
|
||||
limitMonthly: 'Monthly Limit',
|
||||
// Detail rows
|
||||
remainingQuota: 'Remaining Quota',
|
||||
expiresAt: 'Expires At',
|
||||
todayExpires: '(expires today)',
|
||||
daysLeft: '({days} days)',
|
||||
usedQuota: 'Used Quota',
|
||||
resetNow: 'Resetting soon',
|
||||
subscriptionType: 'Subscription Type',
|
||||
subscriptionExpires: 'Subscription Expires',
|
||||
// Usage stat cells
|
||||
todayRequests: 'Today Requests',
|
||||
todayInputTokens: 'Today Input',
|
||||
todayOutputTokens: 'Today Output',
|
||||
todayTokens: 'Today Tokens',
|
||||
todayCacheCreation: 'Today Cache Creation',
|
||||
todayCacheRead: 'Today Cache Read',
|
||||
todayCost: 'Today Cost',
|
||||
rpmTpm: 'RPM / TPM',
|
||||
totalRequests: 'Total Requests',
|
||||
totalInputTokens: 'Total Input',
|
||||
totalOutputTokens: 'Total Output',
|
||||
totalTokensLabel: 'Total Tokens',
|
||||
totalCacheCreation: 'Total Cache Creation',
|
||||
totalCacheRead: 'Total Cache Read',
|
||||
totalCost: 'Total Cost',
|
||||
avgDuration: 'Avg Duration',
|
||||
// Messages
|
||||
enterApiKey: 'Please enter an API Key',
|
||||
querySuccess: 'Query successful',
|
||||
queryFailed: 'Query failed',
|
||||
queryFailedRetry: 'Query failed, please try again later',
|
||||
noDailyUsage: 'No daily usage data',
|
||||
},
|
||||
|
||||
// Setup Wizard
|
||||
setup: {
|
||||
title: 'Sub2API Setup',
|
||||
description: 'Configure your Sub2API instance',
|
||||
database: {
|
||||
title: 'Database Configuration',
|
||||
description: 'Connect to your PostgreSQL database',
|
||||
host: 'Host',
|
||||
port: 'Port',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
databaseName: 'Database Name',
|
||||
sslMode: 'SSL Mode',
|
||||
passwordPlaceholder: 'Password',
|
||||
ssl: {
|
||||
disable: 'Disable',
|
||||
require: 'Require',
|
||||
verifyCa: 'Verify CA',
|
||||
verifyFull: 'Verify Full'
|
||||
}
|
||||
},
|
||||
redis: {
|
||||
title: 'Redis Configuration',
|
||||
description: 'Connect to your Redis server',
|
||||
host: 'Host',
|
||||
port: 'Port',
|
||||
password: 'Password (optional)',
|
||||
database: 'Database',
|
||||
passwordPlaceholder: 'Password',
|
||||
enableTls: 'Enable TLS',
|
||||
enableTlsHint: 'Use TLS when connecting to Redis (public CA certs)'
|
||||
},
|
||||
admin: {
|
||||
title: 'Admin Account',
|
||||
description: 'Create your administrator account',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
passwordPlaceholder: 'Min 8 characters',
|
||||
confirmPasswordPlaceholder: 'Confirm password',
|
||||
passwordMismatch: 'Passwords do not match'
|
||||
},
|
||||
ready: {
|
||||
title: 'Ready to Install',
|
||||
description: 'Review your configuration and complete setup',
|
||||
database: 'Database',
|
||||
redis: 'Redis',
|
||||
adminEmail: 'Admin Email'
|
||||
},
|
||||
status: {
|
||||
testing: 'Testing...',
|
||||
success: 'Connection Successful',
|
||||
testConnection: 'Test Connection',
|
||||
installing: 'Installing...',
|
||||
completeInstallation: 'Complete Installation',
|
||||
completed: 'Installation completed!',
|
||||
redirecting: 'Redirecting to login page...',
|
||||
restarting: 'Service is restarting, please wait...',
|
||||
timeout: 'Service restart is taking longer than expected. Please refresh the page manually.'
|
||||
}
|
||||
},
|
||||
|
||||
// Common
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
export default {
|
||||
|
||||
// Subscription Progress (Header component)
|
||||
subscriptionProgress: {
|
||||
title: 'My Subscriptions',
|
||||
viewDetails: 'View subscription details',
|
||||
activeCount: '{count} active subscription(s)',
|
||||
daily: 'Daily',
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly',
|
||||
daysRemaining: '{days} days left',
|
||||
expired: 'Expired',
|
||||
expiresToday: 'Expires today',
|
||||
expiresTomorrow: 'Expires tomorrow',
|
||||
viewAll: 'View all subscriptions',
|
||||
noSubscriptions: 'No active subscriptions',
|
||||
unlimited: 'Unlimited'
|
||||
},
|
||||
|
||||
// Version Badge
|
||||
version: {
|
||||
currentVersion: 'Current Version',
|
||||
latestVersion: 'Latest Version',
|
||||
upToDate: "You're running the latest version.",
|
||||
updateAvailable: 'A new version is available!',
|
||||
releaseNotes: 'Release Notes',
|
||||
noReleaseNotes: 'No release notes',
|
||||
viewUpdate: 'View Update',
|
||||
viewRelease: 'View Release',
|
||||
viewChangelog: 'View Changelog',
|
||||
refresh: 'Refresh',
|
||||
sourceMode: 'Source Build',
|
||||
sourceModeHint: 'Source build, use git pull to update',
|
||||
updateNow: 'Update Now',
|
||||
updating: 'Updating...',
|
||||
updateComplete: 'Update Complete',
|
||||
updateFailed: 'Update Failed',
|
||||
restartRequired: 'Please restart the service to apply the update',
|
||||
restartNow: 'Restart Now',
|
||||
restarting: 'Restarting...',
|
||||
retry: 'Retry'
|
||||
},
|
||||
|
||||
// Recharge / Subscription Page
|
||||
purchase: {
|
||||
title: 'Recharge / Subscription',
|
||||
description: 'Recharge balance or purchase subscription via the embedded page',
|
||||
openInNewTab: 'Open in new tab',
|
||||
notEnabledTitle: 'Feature not enabled',
|
||||
notEnabledDesc: 'The administrator has not enabled the recharge/subscription entry. Please contact admin.',
|
||||
notConfiguredTitle: 'Recharge / Subscription URL not configured',
|
||||
notConfiguredDesc:
|
||||
'The administrator enabled the entry but has not configured a recharge/subscription URL. Please contact admin.'
|
||||
},
|
||||
|
||||
// Custom Page (iframe embed)
|
||||
customPage: {
|
||||
title: 'Custom Page',
|
||||
openInNewTab: 'Open in new tab',
|
||||
notFoundTitle: 'Page not found',
|
||||
notFoundDesc: 'This custom page does not exist or has been removed.',
|
||||
notConfiguredTitle: 'Page URL not configured',
|
||||
notConfiguredDesc: 'The URL for this custom page has not been properly configured.',
|
||||
tableOfContents: 'Contents',
|
||||
copyCode: 'Copy',
|
||||
copiedCode: 'Copied',
|
||||
copyCodeFailed: 'Failed'
|
||||
},
|
||||
|
||||
// Announcements Page
|
||||
announcements: {
|
||||
title: 'Announcements',
|
||||
description: 'View system announcements',
|
||||
unreadOnly: 'Show unread only',
|
||||
markRead: 'Mark as read',
|
||||
markAllRead: 'Mark all as read',
|
||||
viewAll: 'View all announcements',
|
||||
markedAsRead: 'Marked as read',
|
||||
allMarkedAsRead: 'All announcements marked as read',
|
||||
newCount: '{count} new announcement | {count} new announcements',
|
||||
readAt: 'Read at',
|
||||
read: 'Read',
|
||||
unread: 'Unread',
|
||||
startsAt: 'Starts at',
|
||||
endsAt: 'Ends at',
|
||||
empty: 'No announcements',
|
||||
emptyUnread: 'No unread announcements',
|
||||
total: 'announcements',
|
||||
emptyDescription: 'There are no system announcements at this time',
|
||||
readStatus: 'You have read this announcement',
|
||||
markReadHint: 'Click "Mark as read" to mark this announcement'
|
||||
},
|
||||
|
||||
// User Subscriptions Page
|
||||
userSubscriptions: {
|
||||
title: 'My Subscriptions',
|
||||
description: 'View your subscription plans and usage',
|
||||
noActiveSubscriptions: 'No Active Subscriptions',
|
||||
noActiveSubscriptionsDesc:
|
||||
"You don't have any active subscriptions. Contact administrator to get one.",
|
||||
failedToLoad: 'Failed to load subscriptions',
|
||||
status: {
|
||||
active: 'Active',
|
||||
expired: 'Expired',
|
||||
revoked: 'Revoked'
|
||||
},
|
||||
usage: 'Usage',
|
||||
expires: 'Expires',
|
||||
noExpiration: 'No expiration',
|
||||
unlimited: 'Unlimited',
|
||||
unlimitedDesc: 'No usage limits on this subscription',
|
||||
daily: 'Daily',
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly',
|
||||
daysRemaining: '{days} days remaining',
|
||||
expiresOn: 'Expires on {date}',
|
||||
resetIn: 'Resets in {time}',
|
||||
quotaEndsIn: 'Quota ends in {time}',
|
||||
windowNotActive: 'Awaiting first use',
|
||||
usageOf: '{used} of {limit}'
|
||||
},
|
||||
|
||||
// Onboarding Tour
|
||||
onboarding: {
|
||||
restartTour: 'Restart Onboarding Tour',
|
||||
dontShowAgain: "Don't show again",
|
||||
dontShowAgainTitle: 'Permanently close onboarding guide',
|
||||
confirmDontShow: "Are you sure you don't want to see the onboarding guide again?\n\nYou can restart it anytime from the user menu in the top right corner.",
|
||||
confirmExit: 'Are you sure you want to exit the onboarding guide? You can restart it anytime from the top right menu.',
|
||||
interactiveHint: 'Press Enter or Click to continue',
|
||||
navigation: {
|
||||
flipPage: 'Flip Page',
|
||||
exit: 'Exit'
|
||||
},
|
||||
// Admin tour steps
|
||||
admin: {
|
||||
welcome: {
|
||||
title: '👋 Welcome to Sub2API',
|
||||
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Sub2API is a powerful AI service gateway platform that helps you easily manage and distribute AI services.</p><p style="margin-bottom: 12px;"><b>🎯 Core Features:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>Group Management</b> - Create service tiers (VIP, Free Trial, etc.)</li><li>🔗 <b>Account Pool</b> - Connect multiple upstream AI service accounts</li><li>🔑 <b>Key Distribution</b> - Generate independent API Keys for users</li><li>💰 <b>Billing Control</b> - Flexible rate and quota management</li></ul><p style="color: #10b981; font-weight: 600;">Let\'s complete the initial setup in 3 minutes →</p></div>',
|
||||
nextBtn: 'Start Setup 🚀',
|
||||
prevBtn: 'Skip'
|
||||
},
|
||||
groupManage: {
|
||||
title: '📦 Step 1: Group Management',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>What is a Group?</b></p><p style="margin-bottom: 12px;">Groups are the core concept of Sub2API, like a "service package":</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 Each group can contain multiple upstream accounts</li><li>💰 Each group has independent billing multiplier</li><li>👥 Can be set as public or exclusive</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Example:</b> You can create "VIP Premium" (high rate) and "Free Trial" (low rate) groups</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 Click "Group Management" on the left sidebar</p></div>'
|
||||
},
|
||||
createGroup: {
|
||||
title: '➕ Create New Group',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Let\'s create your first group.</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📝 Tip:</b> Recommend creating a test group first to familiarize yourself with the process</p><p style="color: #10b981; font-weight: 600;">👉 Click the "Create Group" button</p></div>'
|
||||
},
|
||||
groupName: {
|
||||
title: '✏️ 1. Group Name',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Give your group an easy-to-identify name.</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>💡 Naming Suggestions:</b><ul style="margin: 8px 0 0 16px;"><li>"Test Group" - For testing</li><li>"VIP Premium" - High-quality service</li><li>"Free Trial" - Trial version</li></ul></div><p style="font-size: 13px; color: #6b7280;">Click "Next" when done</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
groupPlatform: {
|
||||
title: '🤖 2. Select Platform',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Choose the AI platform this group supports.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 Platform Guide:</b><ul style="margin: 8px 0 0 16px;"><li><b>Anthropic</b> - Claude models</li><li><b>OpenAI</b> - GPT models</li><li><b>Google</b> - Gemini models</li></ul></div><p style="font-size: 13px; color: #6b7280;">One group can only have one platform</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
groupMultiplier: {
|
||||
title: '💰 3. Rate Multiplier',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set the billing multiplier to control user charges.</p><div style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚙️ Billing Rules:</b><ul style="margin: 8px 0 0 16px;"><li><b>1.0</b> - Original price (cost price)</li><li><b>1.5</b> - User consumes $1, charged $1.5</li><li><b>2.0</b> - User consumes $1, charged $2</li><li><b>0.8</b> - Subsidy mode (loss-making)</li></ul></div><p style="font-size: 13px; color: #6b7280;">Recommend setting test group to 1.0</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
groupExclusive: {
|
||||
title: '🔒 4. Exclusive Group (Optional)',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Control group visibility and access permissions.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔐 Permission Guide:</b><ul style="margin: 8px 0 0 16px;"><li><b>Off</b> - Public group, visible to all users</li><li><b>On</b> - Exclusive group, only for specified users</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Use Cases:</b> VIP exclusive, internal testing, special customers</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
groupSubmit: {
|
||||
title: '✅ Save Group',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Confirm the information and click create to save the group.</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ Note:</b> Platform type cannot be changed after creation, but other settings can be edited anytime</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 Next Step:</b> After creation, we\'ll add upstream accounts to this group</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Create" button</p></div>'
|
||||
},
|
||||
accountManage: {
|
||||
title: '🔗 Step 2: Add Account',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>Great! Group created successfully 🎉</b></p><p style="margin-bottom: 12px;">Now add upstream AI service accounts to enable actual service delivery.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 Account Purpose:</b><ul style="margin: 8px 0 0 16px;"><li>Connect to upstream AI services (Claude, GPT, etc.)</li><li>One group can contain multiple accounts (load balancing)</li><li>Supports OAuth and Session Key methods</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 Click "Account Management" on the left sidebar</p></div>'
|
||||
},
|
||||
createAccount: {
|
||||
title: '➕ Add New Account',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Click the button to start adding your first upstream account.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Tip:</b> Recommend using OAuth method - more secure and no manual key extraction needed</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Add Account" button</p></div>'
|
||||
},
|
||||
accountName: {
|
||||
title: '✏️ 1. Account Name',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set an easy-to-identify name for the account.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Naming Suggestions:</b> "Claude Main", "GPT Backup 1", "Test Account", etc.</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
accountPlatform: {
|
||||
title: '🤖 2. Select Platform',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Choose the service provider platform for this account.</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px;"><b>⚠️ Important:</b> Platform must match the group you just created</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
accountType: {
|
||||
title: '🔐 3. Authorization Method',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Choose the account authorization method.</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>✅ Recommended: OAuth Method</b><ul style="margin: 8px 0 0 16px;"><li>No manual key extraction needed</li><li>More secure with auto-refresh support</li><li>Works with Claude Code, ChatGPT OAuth</li></ul></div><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Session Key Method</b><ul style="margin: 8px 0 0 16px;"><li>Requires manual extraction from browser</li><li>May need periodic updates</li><li>For platforms without OAuth support</li></ul></div></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
accountPriority: {
|
||||
title: '⚖️ 4. Priority (Optional)',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set the account call priority.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 Priority Rules:</b><ul style="margin: 8px 0 0 16px;"><li>Lower number = higher priority</li><li>System uses low-value accounts first</li><li>Same priority = random selection</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Use Case:</b> Set main account to lower value, backup accounts to higher value</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
accountGroups: {
|
||||
title: '🎯 5. Assign Groups',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>Key Step!</b> Assign the account to the group you just created.</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ Important Reminder:</b><ul style="margin: 8px 0 0 16px;"><li>Must select at least one group</li><li>Unassigned accounts cannot be used</li><li>One account can be assigned to multiple groups</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Tip:</b> Select the test group you just created</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
accountSubmit: {
|
||||
title: '✅ Save Account',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Confirm the information and click save.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 OAuth Flow:</b><ul style="margin: 8px 0 0 16px;"><li>Will redirect to service provider page after clicking save</li><li>Complete login and authorization on provider page</li><li>Auto-return after successful authorization</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 Next Step:</b> After adding account, we\'ll create an API key</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Save" button</p></div>'
|
||||
},
|
||||
keyManage: {
|
||||
title: '🔑 Step 3: Generate Key',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>Congratulations! Account setup complete 🎉</b></p><p style="margin-bottom: 12px;">Final step: generate an API Key to test if the service works properly.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 API Key Purpose:</b><ul style="margin: 8px 0 0 16px;"><li>Credential for calling AI services</li><li>Each key is bound to one group</li><li>Can set quota and expiration</li><li>Supports independent usage statistics</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 Click "API Keys" on the left sidebar</p></div>'
|
||||
},
|
||||
createKey: {
|
||||
title: '➕ Create Key',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Click the button to create your first API Key.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Tip:</b> Copy and save immediately after creation - key is only shown once</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Create Key" button</p></div>'
|
||||
},
|
||||
keyName: {
|
||||
title: '✏️ 1. Key Name',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Set an easy-to-manage name for the key.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Naming Suggestions:</b> "Test Key", "Production", "Mobile", etc.</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
keyGroup: {
|
||||
title: '🎯 2. Select Group',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Select the group you just configured.</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 Group Determines:</b><ul style="margin: 8px 0 0 16px;"><li>Which accounts this key can use</li><li>What billing multiplier applies</li><li>Whether it\'s an exclusive key</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Tip:</b> Select the test group you just created</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
keySubmit: {
|
||||
title: '🎉 Generate and Copy',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">System will generate a complete API Key after clicking create.</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ Important Reminder:</b><ul style="margin: 8px 0 0 16px;"><li>Key is only shown once, copy immediately</li><li>Need to regenerate if lost</li><li>Keep it safe, don\'t share with others</li></ul></div><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🚀 Next Steps:</b><ul style="margin: 8px 0 0 16px;"><li>Copy the generated sk-xxx key</li><li>Use in any OpenAI-compatible client</li><li>Start experiencing AI services!</li></ul></div><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Create" button</p></div>'
|
||||
}
|
||||
},
|
||||
// User tour steps
|
||||
user: {
|
||||
welcome: {
|
||||
title: '👋 Welcome to Sub2API',
|
||||
description: '<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Hello! Welcome to the Sub2API AI service platform.</p><p style="margin-bottom: 12px;"><b>🎯 Quick Start:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 Create API Key</li><li>📋 Copy key to your application</li><li>🚀 Start using AI services</li></ul><p style="color: #10b981; font-weight: 600;">Just 1 minute, let\'s get started →</p></div>',
|
||||
nextBtn: 'Start 🚀',
|
||||
prevBtn: 'Skip'
|
||||
},
|
||||
keyManage: {
|
||||
title: '🔑 API Key Management',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Manage all your API access keys here.</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 What is an API Key?</b><br/>An API key is your credential for accessing AI services, like a key that allows your application to call AI capabilities.</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click to enter key page</p></div>'
|
||||
},
|
||||
createKey: {
|
||||
title: '➕ Create New Key',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Click the button to create your first API key.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Tip:</b> Key is only shown once after creation, make sure to copy and save</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Create Key"</p></div>'
|
||||
},
|
||||
keyName: {
|
||||
title: '✏️ Key Name',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Give your key an easy-to-identify name.</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 Examples:</b> "My First Key", "For Testing", etc.</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
keyGroup: {
|
||||
title: '🎯 Select Group',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Select the service group assigned by the administrator.</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Group Info:</b><br/>Different groups may have different service quality and billing rates, choose according to your needs.</p></div>',
|
||||
nextBtn: 'Next'
|
||||
},
|
||||
keySubmit: {
|
||||
title: '🎉 Complete Creation',
|
||||
description: '<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">Click to confirm and create your API key.</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ Important:</b><ul style="margin: 8px 0 0 16px;"><li>Copy the key (sk-xxx) immediately after creation</li><li>Key is only shown once, need to regenerate if lost</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>🚀 How to Use:</b><br/>Configure the key in any OpenAI-compatible client (like ChatBox, OpenCat, etc.) and start using!</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 Click "Create" button</p></div>'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Payment System
|
||||
payment: {
|
||||
title: 'Recharge / Subscription',
|
||||
amountLabel: 'Amount',
|
||||
paymentAmount: 'Payment Amount',
|
||||
creditedBalance: 'Credited Balance',
|
||||
quickAmounts: 'Quick Amounts',
|
||||
customAmount: 'Custom Amount',
|
||||
enterAmount: 'Enter amount',
|
||||
paymentMethod: 'Payment Method',
|
||||
fee: 'Fee',
|
||||
actualPay: 'Actual Payment',
|
||||
createOrder: 'Confirm Payment',
|
||||
methods: {
|
||||
easypay: 'EasyPay',
|
||||
alipay: 'Alipay',
|
||||
wxpay: 'WeChat Pay',
|
||||
stripe: 'Stripe',
|
||||
airwallex: 'Airwallex',
|
||||
card: 'Card',
|
||||
link: 'Link',
|
||||
alipay_direct: 'Alipay (Direct)',
|
||||
wxpay_direct: 'WeChat Pay (Direct)',
|
||||
},
|
||||
status: {
|
||||
pending: 'Pending',
|
||||
paid: 'Paid',
|
||||
recharging: 'Recharging',
|
||||
completed: 'Completed',
|
||||
expired: 'Expired',
|
||||
cancelled: 'Cancelled',
|
||||
failed: 'Failed',
|
||||
refund_requested: 'Refund Requested',
|
||||
refunding: 'Refunding',
|
||||
refund_pending: 'Refund Pending',
|
||||
refunded: 'Refunded',
|
||||
partially_refunded: 'Partially Refunded',
|
||||
refund_failed: 'Refund Failed',
|
||||
},
|
||||
qr: {
|
||||
scanToPay: 'Scan to Pay',
|
||||
scanAlipay: 'Alipay QR Payment',
|
||||
scanWxpay: 'WeChat QR Payment',
|
||||
scanAlipayHint: 'Open Alipay on your phone and scan the QR code to pay',
|
||||
scanWxpayHint: 'Open WeChat on your phone and scan the QR code to pay',
|
||||
payInNewWindow: 'Complete Payment in New Window',
|
||||
payInNewWindowHint: 'The payment page has opened in a new window. Please complete the payment there and return to this page.',
|
||||
openPayWindow: 'Reopen Payment Page',
|
||||
expiresIn: 'Expires in',
|
||||
expired: 'Order Expired',
|
||||
expiredDesc: 'This order has expired. Please create a new one.',
|
||||
cancelled: 'Order Cancelled',
|
||||
cancelledDesc: 'You have cancelled this payment.',
|
||||
waitingPayment: 'Waiting for payment...',
|
||||
cancelOrder: 'Cancel Order',
|
||||
},
|
||||
orders: {
|
||||
title: 'My Orders',
|
||||
empty: 'No orders yet',
|
||||
orderId: 'Order ID',
|
||||
orderNo: 'Order No.',
|
||||
amount: 'Amount',
|
||||
payAmount: 'Paid',
|
||||
creditedAmount: 'Credited Amount',
|
||||
fee: 'Fee',
|
||||
baseAmount: 'Base Amount',
|
||||
includedInPayAmount: 'included in paid amount',
|
||||
status: 'Status',
|
||||
paymentMethod: 'Payment Method',
|
||||
createdAt: 'Created',
|
||||
cancel: 'Cancel Order',
|
||||
userId: 'User ID',
|
||||
orderType: 'Order Type',
|
||||
actions: 'Actions',
|
||||
requestRefund: 'Request Refund',
|
||||
},
|
||||
result: {
|
||||
success: 'Payment Successful',
|
||||
subscriptionSuccess: 'Subscription Successful',
|
||||
processing: 'Payment Processing',
|
||||
processingHint: 'Payment confirmation is still pending. This page will refresh automatically.',
|
||||
failed: 'Payment Failed',
|
||||
backToRecharge: 'Back to Recharge',
|
||||
viewOrders: 'View Orders',
|
||||
},
|
||||
currentBalance: 'Current Balance',
|
||||
groupFallback: 'Group #{id}',
|
||||
rechargeAccount: 'Recharge Account',
|
||||
activeSubscription: 'Active Subscription',
|
||||
noActiveSubscription: 'No active subscription',
|
||||
tabTopUp: 'Top Up',
|
||||
tabSubscribe: 'Subscribe',
|
||||
noPlans: 'No subscription plans available',
|
||||
notAvailable: 'Top-up is currently unavailable',
|
||||
confirmSubscription: 'Confirm Subscription',
|
||||
confirmCancel: 'Are you sure you want to cancel this order?',
|
||||
amountTooLow: 'Minimum amount is {min}',
|
||||
amountTooHigh: 'Maximum amount is {max}',
|
||||
amountNoMethod: 'No payment method available for this amount',
|
||||
rechargeRatePreview: 'Current rate: 1 CNY = {usd} USD',
|
||||
refundReason: 'Refund Reason',
|
||||
refundReasonPlaceholder: 'Please describe your refund reason',
|
||||
stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.',
|
||||
stripeMissingParams: 'Missing order ID or client secret',
|
||||
stripeNotConfigured: 'Stripe is not configured',
|
||||
airwallexLoadFailed: 'Failed to load Airwallex payment component. Please refresh and try again.',
|
||||
airwallexMissingParams: 'Missing Airwallex payment parameters',
|
||||
errors: {
|
||||
tooManyPending: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.',
|
||||
cancelRateLimited: 'Too many cancellations. Please try again later.',
|
||||
wechatH5NotAuthorized: 'This merchant has not enabled WeChat H5 payment. Open this page in WeChat to continue.',
|
||||
wechatPaymentMpNotConfigured: 'This site has not completed WeChat MP/JSAPI payment setup, so in-app WeChat payment is unavailable right now.',
|
||||
wechatJsapiUnavailable: 'WeChat payment could not be invoked in the current environment. Reopen this page inside WeChat and try again.',
|
||||
wechatJsapiFailed: 'WeChat payment did not complete. Try invoking it again or switch to QR payment.',
|
||||
wechatUnavailable: 'WeChat payment is temporarily unavailable. Please try again later.',
|
||||
wechatOpenInWeChatHint: 'Open the current page inside WeChat, or switch to desktop WeChat QR payment.',
|
||||
wechatScanOnDesktopHint: 'On desktop, use WeChat Scan to pay; on mobile, reopen the current page inside WeChat.',
|
||||
wechatSwitchBrowserHint: 'Switch to desktop WeChat QR payment, or reopen this page in an external browser and retry.',
|
||||
mobilePaymentFallbackToQr: 'This merchant has not enabled mobile payment. The flow has been switched to QR payment automatically.',
|
||||
alipayDesktopUnavailable: 'The desktop Alipay flow could not generate a QR code.',
|
||||
alipayDesktopQrHint: 'Desktop Alipay should render a QR code. Refresh and retry, or make sure the payment page was not blocked.',
|
||||
alipayMobileUnavailable: 'This page could not hand off to Alipay.',
|
||||
alipayMobileOpenHint: 'Allow the current page to open the Alipay app, or retry from the system browser.',
|
||||
// Structured error codes (reason strings from backend ApplicationError)
|
||||
PAYMENT_DISABLED: 'Payment system is disabled.',
|
||||
USER_INACTIVE: 'Your account is disabled.',
|
||||
BALANCE_PAYMENT_DISABLED: 'Balance recharge has been disabled.',
|
||||
INVALID_AMOUNT: 'Invalid amount.',
|
||||
INVALID_INPUT: 'Invalid request.',
|
||||
PLAN_NOT_AVAILABLE: 'Plan not found or no longer available.',
|
||||
GROUP_NOT_FOUND: 'Subscription group is no longer available.',
|
||||
GROUP_TYPE_MISMATCH: 'Group is not a subscription type.',
|
||||
TOO_MANY_PENDING: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.',
|
||||
DAILY_LIMIT_EXCEEDED: 'Daily recharge limit reached. Remaining: {remaining}.',
|
||||
PAYMENT_GATEWAY_ERROR: 'Payment method is unavailable.',
|
||||
NO_AVAILABLE_INSTANCE: 'No payment channel available right now.',
|
||||
PAYMENT_PROVIDER_MISCONFIGURED: 'Payment provider misconfigured. Please contact an administrator.',
|
||||
WXPAY_CONFIG_MISSING_KEY: 'WeChat Pay config missing required key: {key}.',
|
||||
WXPAY_CONFIG_INVALID_KEY_LENGTH: 'WeChat Pay {key} length is invalid (expected {expected} bytes, got {actual}).',
|
||||
WXPAY_CONFIG_INVALID_KEY: 'WeChat Pay {key} is malformed. Make sure you copied the full PEM content.',
|
||||
PENDING_ORDERS: 'This provider has pending orders. Please wait for them to complete before making changes.',
|
||||
PAYMENT_PROVIDER_CONFLICT: 'Another enabled provider instance is already serving this payment method. Disable it before continuing.',
|
||||
CANCEL_RATE_LIMITED: 'Too many cancellations. Please try again later.',
|
||||
NOT_FOUND: 'Order not found.',
|
||||
FORBIDDEN: 'No permission for this order.',
|
||||
CONFLICT: 'Order status has changed. Please refresh.',
|
||||
INVALID_ORDER_TYPE: 'Only balance orders can request a refund.',
|
||||
INVALID_STATUS: 'The current order status does not allow this operation.',
|
||||
BALANCE_NOT_ENOUGH: 'Refund amount exceeds balance.',
|
||||
REFUND_AMOUNT_EXCEEDED: 'Refund amount exceeds the recharge amount.',
|
||||
REFUND_FAILED: 'Refund failed.',
|
||||
},
|
||||
airwallexPay: 'Airwallex Payment',
|
||||
stripePay: 'Pay Now',
|
||||
stripeSuccessProcessing: 'Payment successful, processing your order...',
|
||||
stripePopup: {
|
||||
redirecting: 'Redirecting to payment page...',
|
||||
loadingQr: 'Loading WeChat Pay QR code...',
|
||||
timeout: 'Timed out waiting for payment credentials, please retry',
|
||||
qrFailed: 'Failed to get WeChat Pay QR code',
|
||||
},
|
||||
subscribeNow: 'Subscribe Now',
|
||||
renewNow: 'Renew',
|
||||
selectPlan: 'Select Plan',
|
||||
planFeatures: 'Features',
|
||||
planCard: {
|
||||
rate: 'Rate',
|
||||
peakRate: 'Peak Rate',
|
||||
dailyLimit: 'Daily',
|
||||
weeklyLimit: 'Weekly',
|
||||
monthlyLimit: 'Monthly',
|
||||
quota: 'Quota',
|
||||
unlimited: 'Unlimited',
|
||||
models: 'Models',
|
||||
},
|
||||
days: 'days',
|
||||
months: 'months',
|
||||
years: 'years',
|
||||
oneMonth: '1 Month',
|
||||
oneYear: '1 Year',
|
||||
perMonth: 'month',
|
||||
perYear: 'year',
|
||||
admin: {
|
||||
tabs: {
|
||||
overview: 'Overview',
|
||||
orders: 'Orders',
|
||||
channels: 'Channels',
|
||||
plans: 'Plans',
|
||||
},
|
||||
todayRevenue: 'Today Revenue',
|
||||
totalRevenue: 'Total Revenue',
|
||||
todayOrders: 'Today Orders',
|
||||
orderCount: 'Order Count',
|
||||
avgAmount: 'Average Amount',
|
||||
revenue: 'Revenue',
|
||||
dailyRevenue: 'Daily Revenue',
|
||||
paymentDistribution: 'Payment Distribution',
|
||||
colUser: 'User',
|
||||
topUsers: 'Top Users',
|
||||
noData: 'No data',
|
||||
days: 'days',
|
||||
weeks: 'weeks',
|
||||
months: 'months',
|
||||
searchOrders: 'Search orders...',
|
||||
allStatuses: 'All Statuses',
|
||||
allPaymentTypes: 'All Payment Types',
|
||||
allOrderTypes: 'All Order Types',
|
||||
orderDetail: 'Order Detail',
|
||||
orderType: 'Order Type',
|
||||
orders: 'Orders',
|
||||
balanceOrder: 'Balance Top-Up',
|
||||
subscriptionOrder: 'Subscription',
|
||||
paidAt: 'Paid At',
|
||||
completedAt: 'Completed At',
|
||||
expiresAt: 'Expires At',
|
||||
feeRate: 'Fee Rate',
|
||||
refund: 'Refund',
|
||||
refundOrder: 'Refund Order',
|
||||
refundAmount: 'Refund Amount',
|
||||
maxRefundable: 'Max Refundable',
|
||||
refundReason: 'Refund Reason',
|
||||
refundReasonPlaceholder: 'Please enter refund reason',
|
||||
confirmRefund: 'Confirm Refund',
|
||||
refundSuccess: 'Refund successful',
|
||||
refundPending: 'Refund pending gateway confirmation',
|
||||
queryRefundStatus: 'Query refund status',
|
||||
refundInfo: 'Refund Info',
|
||||
refundEnabled: 'Refund Enabled',
|
||||
allowUserRefund: 'Allow User Refund',
|
||||
alreadyRefunded: 'Already Refunded',
|
||||
deductBalance: 'Deduct Balance',
|
||||
deductBalanceHint: 'Subtract recharged amount from user balance',
|
||||
userBalance: 'User Balance',
|
||||
orderAmount: 'Order Amount',
|
||||
insufficientBalance: 'Insufficient balance — will deduct to $0',
|
||||
noDeduction: 'Will NOT deduct user balance',
|
||||
forceRefund: 'Force refund (ignore balance check)',
|
||||
orderCancelled: 'Order Cancelled',
|
||||
retry: 'Retry',
|
||||
retrySuccess: 'Retry successful',
|
||||
approveRefund: 'Approve Refund',
|
||||
retryRefund: 'Retry Refund',
|
||||
refundRequestInfo: 'Refund Request Info',
|
||||
refundRequestedAt: 'Requested At',
|
||||
refundRequestedBy: 'Requested By',
|
||||
refundRequestReason: 'Request Reason',
|
||||
auditLogs: 'Audit Logs',
|
||||
operator: 'Operator',
|
||||
channelName: 'Channel Name',
|
||||
channelDescription: 'Channel Description',
|
||||
createChannel: 'Create Channel',
|
||||
editChannel: 'Edit Channel',
|
||||
deleteChannel: 'Delete Channel',
|
||||
deleteChannelConfirm: 'Are you sure you want to delete this channel?',
|
||||
planName: 'Plan Name',
|
||||
planDescription: 'Plan Description',
|
||||
createPlan: 'Create Plan',
|
||||
editPlan: 'Edit Plan',
|
||||
deletePlan: 'Delete Plan',
|
||||
deletePlanConfirm: 'Are you sure you want to delete this plan?',
|
||||
originalPrice: 'Original Price',
|
||||
price: 'Price',
|
||||
subscriptionCnyPayPreview: 'CNY channel charge preview: {amount}',
|
||||
subscriptionCnyPayPreviewWithFee: '({feeRate}% fee included: {total})',
|
||||
validityDays: 'Validity (days)',
|
||||
validityUnit: 'Validity Unit',
|
||||
sortOrder: 'Sort Order',
|
||||
forSale: 'For Sale',
|
||||
onSale: 'On Sale',
|
||||
offSale: 'Off Sale',
|
||||
group: 'Group',
|
||||
groupId: 'Group ID',
|
||||
features: 'Features',
|
||||
featuresHint: 'One feature per line',
|
||||
featuresPlaceholder: 'Enter plan features...',
|
||||
providerManagement: 'Provider Management',
|
||||
providerManagementDesc: 'Manage payment provider instances',
|
||||
createProvider: 'Create Provider',
|
||||
editProvider: 'Edit Provider',
|
||||
deleteProvider: 'Delete Provider',
|
||||
deleteProviderConfirm: 'Are you sure you want to delete this provider?',
|
||||
providerName: 'Provider Name',
|
||||
providerKey: 'Provider Key',
|
||||
selectProviderKey: 'Select Provider Key',
|
||||
providerConfig: 'Provider Config',
|
||||
noProviders: 'No providers configured',
|
||||
noProvidersHint: 'Create a provider instance to start accepting payments',
|
||||
supportedTypes: 'Supported Payment Types',
|
||||
supportedTypesHint: 'Select the payment types this provider supports',
|
||||
rateMultiplier: 'Rate Multiplier',
|
||||
dashboardTitle: 'Payment Dashboard',
|
||||
dashboardDesc: 'Recharge order analytics and insights',
|
||||
daySuffix: 'd',
|
||||
paymentConfigTitle: 'Payment Config',
|
||||
paymentConfigDesc: 'Configure payment providers and settings',
|
||||
plansPageTitle: 'Subscription Plans',
|
||||
plansPageDesc: 'Manage subscription plan configuration',
|
||||
tabPlanConfig: 'Plan Configuration',
|
||||
tabUserSubs: 'User Subscriptions',
|
||||
selectGroup: 'Select a group',
|
||||
groupRequired: 'Please select a subscription group',
|
||||
priceRequired: 'Price must be greater than 0',
|
||||
validityDaysRequired: 'Validity days must be greater than 0',
|
||||
groupMissing: 'Missing',
|
||||
groupInfo: 'Group Info',
|
||||
platform: 'Platform',
|
||||
rateMultiplierLabel: 'Rate',
|
||||
dailyLimit: 'Daily Limit',
|
||||
weeklyLimit: 'Weekly Limit',
|
||||
monthlyLimit: 'Monthly Limit',
|
||||
unlimited: 'Unlimited',
|
||||
searchUserSubs: 'Search user subscriptions...',
|
||||
daily: 'D',
|
||||
weekly: 'W',
|
||||
monthly: 'M',
|
||||
subsStatus: {
|
||||
active: 'Active',
|
||||
expired: 'Expired',
|
||||
revoked: 'Revoked',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,712 @@
|
||||
export default {
|
||||
availableChannels: {
|
||||
title: '可用渠道',
|
||||
description: '按渠道聚合查看关联分组与支持模型(已展开通配符)',
|
||||
searchPlaceholder: '搜索渠道或模型...',
|
||||
columns: {
|
||||
name: '渠道名',
|
||||
status: '状态',
|
||||
billingSource: '计费模型来源',
|
||||
groups: '关联分组',
|
||||
supportedModels: '支持模型'
|
||||
},
|
||||
empty: '暂无数据',
|
||||
noGroups: '未关联分组',
|
||||
noModels: '未配置模型映射',
|
||||
noPricing: '未配置定价',
|
||||
statusActive: '启用',
|
||||
statusDisabled: '停用',
|
||||
billingSource: {
|
||||
requested: '请求模型',
|
||||
upstream: '上游模型',
|
||||
channel_mapped: '映射后模型'
|
||||
},
|
||||
pricing: {
|
||||
billingMode: '计费模式',
|
||||
billingModeToken: '按 Token',
|
||||
billingModePerRequest: '按次',
|
||||
billingModeImage: '按图片',
|
||||
inputPrice: '输入',
|
||||
outputPrice: '输出',
|
||||
cacheWritePrice: '缓存写入',
|
||||
cacheReadPrice: '缓存读取',
|
||||
imageOutputPrice: '图片输出',
|
||||
perRequestPrice: '每次请求',
|
||||
intervals: '阶梯定价',
|
||||
unitPerMillion: '/ 1M token',
|
||||
unitPerRequest: '/ 次'
|
||||
}
|
||||
},
|
||||
|
||||
// Channel Management
|
||||
channels: {
|
||||
title: '渠道管理',
|
||||
description: '管理渠道和自定义模型定价',
|
||||
searchChannels: '搜索渠道...',
|
||||
createChannel: '创建渠道',
|
||||
editChannel: '编辑渠道',
|
||||
deleteChannel: '删除渠道',
|
||||
statusActive: '启用',
|
||||
statusDisabled: '停用',
|
||||
allStatus: '全部状态',
|
||||
groupsUnit: '个分组',
|
||||
pricingUnit: '条定价',
|
||||
noChannelsYet: '暂无渠道',
|
||||
createFirstChannel: '创建第一个渠道来管理模型定价',
|
||||
loadError: '加载渠道列表失败',
|
||||
createSuccess: '渠道创建成功',
|
||||
updateSuccess: '渠道更新成功',
|
||||
deleteSuccess: '渠道删除成功',
|
||||
createError: '创建渠道失败',
|
||||
updateError: '更新渠道失败',
|
||||
deleteError: '删除渠道失败',
|
||||
nameRequired: '请输入渠道名称',
|
||||
duplicateModels: '模型「{0}」在多个定价条目中重复',
|
||||
modelConflict: "模型模式 '{model1}' 和 '{model2}' 冲突:匹配范围重叠。模型名称按大小写不敏感匹配,已有条目已覆盖其所有大小写变体,无需重复添加。",
|
||||
mappingConflict: "模型映射源 '{model1}' 和 '{model2}' 冲突:匹配范围重叠。源模式按大小写不敏感匹配,已有条目已覆盖其所有大小写变体。",
|
||||
intervalValidation: {
|
||||
negativeMin: '区间 #{index}:最小 token 数({value})不能为负数',
|
||||
maxPositive: '区间 #{index}:最大 token 数({value})必须大于 0',
|
||||
maxGreaterThanMin: '区间 #{index}:最大 token 数({max})必须大于最小 token 数({min})',
|
||||
negativePrice: '区间 #{index}:{field}不能为负数',
|
||||
unboundedLast: '区间 #{index}:无上限区间(最大 token 数为空)必须放在最后',
|
||||
overlap: '区间 #{previousIndex} 和 #{currentIndex} 重叠:前一个上界({previousMax})大于当前下界({currentMin})',
|
||||
price: {
|
||||
inputPrice: '输入价格',
|
||||
outputPrice: '输出价格',
|
||||
cacheWritePrice: '缓存写入价格',
|
||||
cacheReadPrice: '缓存读取价格',
|
||||
perRequestPrice: '单次价格'
|
||||
}
|
||||
},
|
||||
deleteConfirm: '确定要删除渠道「{name}」吗?此操作不可撤销。',
|
||||
columns: {
|
||||
name: '名称',
|
||||
description: '描述',
|
||||
status: '状态',
|
||||
groups: '分组',
|
||||
pricing: '定价',
|
||||
createdAt: '创建时间',
|
||||
actions: '操作'
|
||||
},
|
||||
billingMode: {
|
||||
token: 'Token',
|
||||
perRequest: '按次',
|
||||
image: '图片(按次)'
|
||||
},
|
||||
form: {
|
||||
name: '名称',
|
||||
namePlaceholder: '输入渠道名称',
|
||||
description: '描述',
|
||||
descriptionPlaceholder: '可选描述',
|
||||
status: '状态',
|
||||
groups: '关联分组',
|
||||
noGroupsAvailable: '暂无可用分组',
|
||||
inOtherChannel: '已属于「{name}」',
|
||||
modelPricing: '模型定价',
|
||||
models: '模型列表',
|
||||
modelsPlaceholder: '输入完整模型名后按回车添加',
|
||||
modelInputHint: '按回车添加,支持粘贴批量导入',
|
||||
billingMode: '计费模式',
|
||||
defaultPrices: '默认价格(未命中区间时使用)',
|
||||
inputPrice: '输入',
|
||||
outputPrice: '输出',
|
||||
cacheWritePrice: '缓存写入',
|
||||
cacheReadPrice: '缓存读取',
|
||||
cacheWritePriceShort: '缓存写',
|
||||
cacheReadPriceShort: '缓存读',
|
||||
imageTokenPrice: '图片输出',
|
||||
imageOutputPrice: '图片输出价格',
|
||||
pricePlaceholder: '默认',
|
||||
intervals: '上下文区间定价(可选)',
|
||||
minTokens: '最小',
|
||||
maxTokens: '最大',
|
||||
inclusive: '(含)',
|
||||
addInterval: '添加区间',
|
||||
requestTiers: '按次计费层级',
|
||||
imageTiers: '图片计费层级(按次)',
|
||||
addTier: '添加层级',
|
||||
noTiersYet: '暂无层级,点击添加配置按次计费价格',
|
||||
noPricingRules: '暂无定价规则,点击"添加"创建',
|
||||
perRequestPrice: '单次价格',
|
||||
perRequestPriceRequired: '按次/图片计费模式必须设置默认价格或至少一个计费层级',
|
||||
tierLabel: '层级',
|
||||
resolution: '分辨率',
|
||||
modelMapping: '模型映射',
|
||||
modelMappingHint: '将请求中的模型名映射为实际模型名。在账号级别映射之前执行。',
|
||||
noMappingRules: '暂无映射规则,点击"添加"创建',
|
||||
mappingSource: '源模型',
|
||||
mappingTarget: '目标模型',
|
||||
billingModelSource: '计费基准',
|
||||
billingModelSourceChannelMapped: '以渠道映射后的模型计费',
|
||||
billingModelSourceRequested: '以请求模型计费',
|
||||
billingModelSourceUpstream: '以最终模型计费',
|
||||
billingModelSourceHint: '控制使用哪个模型名称进行定价查找',
|
||||
selectedCount: '已选 {count} 个',
|
||||
searchGroups: '搜索分组...',
|
||||
noGroupsMatch: '没有匹配的分组',
|
||||
restrictModels: '限制模型',
|
||||
restrictModelsHint: '开启后,仅允许模型定价列表中的模型。不在列表中的模型请求将被拒绝。',
|
||||
defaultPerRequestPrice: '默认单次价格(未命中层级时使用)',
|
||||
defaultImagePrice: '默认图片价格(未命中层级时使用)',
|
||||
platformConfig: '平台配置',
|
||||
webSearchEmulation: 'Web Search 模拟',
|
||||
webSearchEmulationHint: '⚠️ 开启后该渠道下所有 Anthropic 分组的账号将自动拦截 web_search 请求,请谨慎操作',
|
||||
webSearchEmulationGlobalDisabled: '请先在系统设置 → 网关 → Web Search 模拟中启用全局开关',
|
||||
codexImageGenerationBridge: 'Codex 图片生成桥接',
|
||||
codexImageGenerationBridgeHint: '开启后,OpenAI 分组的 Codex /responses 文本请求可能会被自动注入 image_generation 工具。仅在路由账号支持图片生成时开启。',
|
||||
bedrockCCCompat: 'Bedrock CC 兼容',
|
||||
bedrockCCCompatHint: '⚠️ 开启后,该渠道下 Bedrock 账号的请求将进行 Claude Code 兼容处理(thinking 类型转换、tool_use ID 清理)',
|
||||
basicSettings: '基础设置',
|
||||
addPlatform: '添加平台',
|
||||
noPlatforms: '点击"添加平台"开始配置渠道',
|
||||
mappingCount: '条映射',
|
||||
pricingEntry: '定价配置',
|
||||
noModels: '未添加模型',
|
||||
applyPricingToAccountStats: '应用模型定价到账号统计',
|
||||
applyPricingToAccountStatsDesc: '启用后,未被自定义规则匹配的请求将使用模型定价文件中的标准价格计算账号统计费用',
|
||||
accountStatsPricingRules: '自定义账号统计定价规则',
|
||||
addRule: '添加规则',
|
||||
noRulesConfigured: '未配置自定义规则,将使用上方的模型定价。',
|
||||
ruleName: '规则名称(可选)',
|
||||
ruleGroups: '分组',
|
||||
ruleAccounts: '账号',
|
||||
searchAccountPlaceholder: '搜索账号...',
|
||||
ruleAccountsHint: '留空表示匹配所有账号',
|
||||
ruleModelPricing: '模型定价',
|
||||
noGroupsInChannel: '上方平台标签页中未选择分组',
|
||||
unnamed: '未命名',
|
||||
syncLatestModels: '同步最新模型',
|
||||
syncingModels: '同步中...',
|
||||
syncModelsSuccess: '已同步 {count} 个新模型',
|
||||
syncModelsAlreadyUpToDate: '模型列表已是最新',
|
||||
syncModelsError: '同步模型失败'
|
||||
}
|
||||
},
|
||||
|
||||
riskControl: {
|
||||
title: '风控中心',
|
||||
description: '配置内容审计策略并查看审核记录',
|
||||
loadFailed: '加载风控中心失败',
|
||||
saveFailed: '保存内容审计配置失败',
|
||||
logsFailed: '加载审核记录失败',
|
||||
saved: '内容审计配置已保存',
|
||||
refresh: '刷新',
|
||||
config: '内容审计配置',
|
||||
configHint: '调用 OpenAI Moderations 进行请求内容评分,命中阈值后按模式处理。',
|
||||
openSettings: '内容审计设置',
|
||||
settingsTitle: '内容审计设置',
|
||||
refreshStatus: '刷新状态',
|
||||
records: '审核记录',
|
||||
recordsHint: '展示命中、拦截、异常和已采样记录。',
|
||||
saveConfig: '保存内容审计配置',
|
||||
statusFailed: '加载运行状态失败',
|
||||
enabled: '开启内容审计',
|
||||
enabledHint: '关闭后即使风控中心菜单启用,也不会审核网关请求。',
|
||||
mode: '全局模式',
|
||||
modePreBlock: '前置拦截',
|
||||
modePreBlockDesc: '每次请求先同步审核最新用户输入,命中后立即拒绝请求。',
|
||||
modeObserve: '仅观察',
|
||||
modeObserveDesc: '请求直接放行,最新用户输入进入异步审核队列;命中后只记录、通知和按规则累计。',
|
||||
modeOff: '关闭',
|
||||
modeOffDesc: '不执行内容审计,也不会写入审核记录。',
|
||||
baseUrl: 'OpenAI Base URL',
|
||||
model: '模型名',
|
||||
apiKey: 'OpenAI API Key',
|
||||
apiKeys: 'OpenAI API Keys',
|
||||
apiKeyCount: '{count} 个 Key',
|
||||
apiKeyPlaceholder: '请输入 API Key',
|
||||
apiKeysPlaceholder: '新增 API Key,每行一个;保存后会追加到已保存 Key',
|
||||
apiKeysPlaceholderReplace: '覆盖保存 API Key,每行一个;保存后会替换全部已保存 Key',
|
||||
apiKeysPlaceholderKeep: '新增 API Key,每行一个;保存后会追加到已保存 Key',
|
||||
apiKeysHint: '当前已保存 {count} 个 Key;输入区只用于新增,保存时会增量追加并自动去重。',
|
||||
apiKeysWriteMode: '写入方式',
|
||||
apiKeysModeAppend: '增量添加',
|
||||
apiKeysModeReplace: '覆盖保存',
|
||||
apiKeysModeAppendHint: '默认模式:保存时追加输入区 Key,并保留已保存 Key。',
|
||||
apiKeysModeReplaceHint: '覆盖模式:保存时用输入区 Key 替换全部已保存 Key。',
|
||||
apiKeysReplaceWarning: '覆盖模式',
|
||||
apiKeysReplaceNoInput: '覆盖保存至少需要输入 1 个 API Key',
|
||||
apiKeyPlaceholderKeep: '留空保持不变',
|
||||
apiKeyWillClear: '保存后清除已配置 Key',
|
||||
apiKeyConfigured: '已配置',
|
||||
apiKeyTemporary: '待保存',
|
||||
apiKeyPendingDelete: '待删除',
|
||||
apiKeyPendingDeleteCount: '待删除 {count} 个 Key',
|
||||
deleteApiKey: '删除这个 Key',
|
||||
undoDeleteApiKey: '撤销删除',
|
||||
inputApiKeyCount: '输入区 {count} 个 Key',
|
||||
storedApiKeyCount: '已保存 {count} 个 Key',
|
||||
testInputApiKeys: '测试输入区 Key',
|
||||
testStoredApiKeys: '测试已保存 Key',
|
||||
testContentWithStoredApiKey: '用已保存 Key 试跑内容',
|
||||
testingApiKeys: '测试中',
|
||||
apiKeyTestNoInput: '请先输入需要测试的 OpenAI API Key',
|
||||
apiKeyTestDone: 'Key 测试完成,共 {count} 个',
|
||||
apiKeyTestFailed: '测试 OpenAI API Key 失败',
|
||||
apiKeyHealth: 'Key 可用状态',
|
||||
apiKeyFreezeRule: '400 不冻结;401/403 冻结 10 分钟;429/529 冻结 1 分钟;其他 HTTP 错误冻结 10 秒。',
|
||||
apiKeyRows: '{count} 个 Key',
|
||||
apiKeyRowsCollapsed: '已隐藏 {count} 个 Key',
|
||||
apiKeyRowsExpanded: '正在显示全部 {count} 个 Key',
|
||||
expandApiKeyRows: '展开',
|
||||
collapseApiKeyRows: '收起',
|
||||
apiKeyHealthEmpty: '暂无 Key 状态',
|
||||
apiKeyHealthEmptyHint: '保存 Key 或测试输入区 Key 后会显示可用性。',
|
||||
apiKeyStatusOk: '可用',
|
||||
apiKeyStatusError: '异常',
|
||||
apiKeyStatusFrozen: '冻结',
|
||||
apiKeyStatusUnknown: '未测试',
|
||||
apiKeyFailureCount: '失败 {count} 次',
|
||||
apiKeyLatency: '{ms} ms',
|
||||
apiKeyHTTPStatus: 'HTTP {status}',
|
||||
apiKeyFrozenUntil: '冻结至 {time}',
|
||||
apiKeyLastChecked: '检查于 {time}',
|
||||
apiKeyNotTested: '尚未测试',
|
||||
auditTestInput: '审计试跑输入',
|
||||
auditTestInputHint: '可填写提示词并上传或粘贴图片;图片以 base64 发送,不会保存文件。',
|
||||
auditTestPromptPlaceholder: '输入要测试的用户提示词;留空时仅测试 Key 可用性。',
|
||||
auditTestImages: '测试图片',
|
||||
auditTestImagesHint: '支持上传、拖拽或粘贴图片,最多 1 张,每张不超过 8MB。',
|
||||
addAuditTestImage: '添加图片',
|
||||
clearAuditTest: '清空试跑',
|
||||
auditTestImageLimit: '最多只能添加 {count} 张测试图片',
|
||||
auditTestImageTooLarge: '单张测试图片不能超过 8MB',
|
||||
auditTestImageReadFailed: '读取测试图片失败',
|
||||
auditTestResult: '审计试跑结果',
|
||||
auditTestHighest: '最高分类 {category},分数 {score}',
|
||||
auditTestComposite: '综合评分',
|
||||
auditTestFlagged: '命中阈值',
|
||||
auditTestPassed: '未命中',
|
||||
notConfigured: '未配置',
|
||||
clearApiKey: '清除已保存 Key',
|
||||
keepApiKey: '保留已保存 Key',
|
||||
timeoutMs: 'HTTP 超时 (ms)',
|
||||
retryCount: '失败重试次数',
|
||||
sampleRate: '采样率',
|
||||
recordNonHits: '记录未命中输入',
|
||||
recordNonHitsHint: '开启后会记录抽样但未命中的请求摘要,摘要会先脱敏再入库。',
|
||||
preHashCheck: '启用前置哈希比对',
|
||||
preHashCheckHint: '异步审核命中过的输入哈希会被前置拦截;该拦截不发送邮件,也不累计封禁次数。',
|
||||
flaggedHashCount: '当前哈希集合数量:{count} 个',
|
||||
flaggedHashHint: '哈希永久保存在 Redis 集合中;可粘贴完整 64 位哈希删除误拦截项,或一键清空全部风险哈希。',
|
||||
flaggedHashPlaceholder: '粘贴完整 64 位输入哈希',
|
||||
deleteFlaggedHash: '删除指定哈希',
|
||||
clearFlaggedHashes: '一键清空',
|
||||
clearFlaggedHashesConfirm: '确定要清空全部风险输入哈希吗?此操作不会删除审核记录,但会取消所有历史哈希拦截。',
|
||||
flaggedHashDeleted: '风险哈希已删除',
|
||||
flaggedHashNotFound: '该风险哈希不存在',
|
||||
flaggedHashDeleteFailed: '删除风险哈希失败',
|
||||
flaggedHashesCleared: '已清空 {count} 个风险哈希',
|
||||
flaggedHashesClearFailed: '清空风险哈希失败',
|
||||
workerCount: 'Worker 数',
|
||||
queueSize: '异步队列大小',
|
||||
blockStatus: '拦截 HTTP 状态码',
|
||||
blockMessage: '自定义拦截提示',
|
||||
defaultBlockMessage: '内容审计命中风险规则,请调整输入后重试',
|
||||
emailOnHit: '命中后发送邮件',
|
||||
emailOnHitHint: '开启后每次达到阈值都会向用户发送风控提醒邮件;自动封禁通知始终发送。',
|
||||
autoBan: '自动封禁用户',
|
||||
autoBanHint: '命中次数达到阈值后将禁用用户账号、刷新认证缓存并发送封禁通知邮件。',
|
||||
cyberPolicyExcludeBan: 'cyber_policy 不计入封号次数',
|
||||
cyberPolicyExcludeBanHint: '开启后,cyber_policy 拦截不再计入自动封号的违规次数:当次不判定封号,历史累计亦排除。风控日志与通知邮件照常。',
|
||||
violationNotCounted: '未计入封号',
|
||||
banThreshold: '封禁触发次数',
|
||||
violationWindowHours: '累计窗口(小时)',
|
||||
hitRetentionDays: '命中记录保留(天)',
|
||||
nonHitRetentionDays: '未命中记录保留(天,最多 3 天)',
|
||||
violationCount: '{count} 次',
|
||||
emailSent: '已发邮件',
|
||||
emailNotSent: '未发邮件',
|
||||
autoBanned: '已封禁',
|
||||
unbanUser: '解封',
|
||||
unbanSuccess: '用户已解封',
|
||||
unbanFailed: '解封用户失败',
|
||||
inputDetailTitle: '输入摘要详情',
|
||||
inputDetailContent: '完整内容',
|
||||
matchedKeyword: '命中关键词',
|
||||
queueDelay: '排队 {ms} ms',
|
||||
allGroups: '全部分组',
|
||||
allGroupsHint: '当前审计全部分组',
|
||||
selectedGroupsHint: '当前审计指定分组',
|
||||
groupScope: '审计分组',
|
||||
groupScopeHint: '开启右侧开关表示全部分组,关闭后选择指定分组。',
|
||||
selectedGroups: '指定分组',
|
||||
searchGroups: '搜索分组名称或平台',
|
||||
noGroups: '暂无可用分组',
|
||||
modelFilter: '模型范围',
|
||||
modelFilterHint: '按客户端请求的模型名决定是否执行内容审计,模型映射后仍以请求模型判断。',
|
||||
modelFilterAll: '所有模型',
|
||||
modelFilterAllDesc: '所有模型请求都会进入内容审计。',
|
||||
modelFilterInclude: '仅指定模型',
|
||||
modelFilterIncludeDesc: '只有列表中的模型会执行内容审计。',
|
||||
modelFilterExclude: '排除指定模型',
|
||||
modelFilterExcludeDesc: '列表中的模型跳过内容审计,其余模型执行审计。',
|
||||
modelFilterModels: '模型列表',
|
||||
modelFilterModelCount: '已配置 {count} 个模型',
|
||||
modelFilterModelsRequired: '当前模型范围至少需要配置 1 个模型',
|
||||
modelFilterAllSummary: '全部模型生效',
|
||||
modelFilterIncludeSummary: '仅 {count} 个模型生效',
|
||||
modelFilterExcludeSummary: '排除 {count} 个模型',
|
||||
emptyLogs: '暂无审核记录',
|
||||
preBlockSyncStatus: '前置拦截同步状态',
|
||||
preBlockSyncHint: '同步审核链路的实时计数,不包含异步写记录任务。',
|
||||
preBlockActive: '同步处理中',
|
||||
preBlockActiveHint: '当前正在审核',
|
||||
preBlockChecked: '已检查',
|
||||
preBlockCheckedHint: '进入前置拦截链路',
|
||||
preBlockAllowed: '已放行',
|
||||
preBlockAllowedHint: '未触发拦截',
|
||||
preBlockBlocked: '已拦截',
|
||||
preBlockBlockedHint: '命中后拒绝请求',
|
||||
preBlockErrors: '审核异常',
|
||||
preBlockErrorsHint: '失败或无可用 Key',
|
||||
preBlockAvgLatency: '平均耗时',
|
||||
preBlockAvgLatencyHint: '同步链路平均值',
|
||||
preBlockAPIKeyLoad: '审核 Key 负载',
|
||||
preBlockAPIKeyLoadHint: '同步前置拦截直接轮询可用审核 Key。',
|
||||
preBlockAPIKeyLoadSummary: '同步并发 {active} / 可用 Key {available},累计 {total} 次,worker:{workerActive} / {workerTotal}',
|
||||
preBlockAPIKeyTotals: '累计 {total},成功 {success},异常 {errors}',
|
||||
preBlockAPIKeyLoadEmpty: '暂无审核 Key 负载数据',
|
||||
preBlockKeyActiveShort: '并发',
|
||||
preBlockKeyTotalShort: '累计',
|
||||
preBlockKeyAvgShort: '平均',
|
||||
preBlockKeyLastShort: '最近',
|
||||
workerStatus: 'Worker 运行状态',
|
||||
workerStatusHint: '异步审计任务和前置拦截记录任务的队列与 Worker 池状态,不包含同步前置拦截审核请求。',
|
||||
workerPool: 'Worker 池',
|
||||
workerPoolMeta: '{active} 个处理中,{idle} 个空闲可用,共 {total} 个',
|
||||
queueUsage: '队列占用',
|
||||
activeWorkers: '处理中',
|
||||
idleWorkers: '空闲可用',
|
||||
workerActive: '正在处理异步审计或记录任务',
|
||||
workerIdle: '已启动,当前空闲可用',
|
||||
workerDisabled: '风控或内容审计未启用',
|
||||
processed: '已处理',
|
||||
droppedErrors: '丢弃/异常',
|
||||
autoRefresh: '每 15 秒自动刷新',
|
||||
lastCleanup: '上次清理:{time}',
|
||||
cleanupStats: '上次清理删除命中 {hit} 条,未命中 {nonHit} 条',
|
||||
riskSwitchOff: '系统开关关闭',
|
||||
riskThresholds: '风险阈值',
|
||||
riskThresholdsHint: '按 OpenAI Moderations 分类调整命中阈值,分数达到或超过阈值即视为命中。',
|
||||
riskThresholdDefault: '默认 {value}',
|
||||
riskThresholdReset: '恢复默认阈值',
|
||||
riskThresholdPercent: '阈值百分比',
|
||||
tabs: {
|
||||
basic: '基础',
|
||||
scope: '审计范围',
|
||||
runtime: '运行队列',
|
||||
response: '命中通知',
|
||||
riskThresholds: '风险阈值',
|
||||
keywords: '关键词拦截',
|
||||
retention: '日志保留',
|
||||
},
|
||||
blockedKeywords: '拦截关键词',
|
||||
blockedKeywordsPlaceholder: '每行输入一个关键词,例如:\n敏感词1\n敏感词2',
|
||||
blockedKeywordsDescription: '匹配忽略大小写;命中后会按下方策略决定是否调用上游审计接口。',
|
||||
blockedKeywordsPreBlockHint: '关键词拦截仅在「前置拦截」模式下生效。',
|
||||
blockedKeywordsModeWarning: '当前为「{mode}」模式,关键词拦截不会生效;请切换到「前置拦截」模式后再保存关键词。',
|
||||
blockedKeywordCount: '已配置 {count} 个关键词',
|
||||
blockedKeywordsLimit: '最多保存 {max} 个关键词,单个长度不超过 200 个字符;重复项会自动去重。',
|
||||
keywordBlockingMode: '审计策略',
|
||||
keywordModeKeywordAndApi: '关键词 + API',
|
||||
keywordModeKeywordAndApiDesc: '命中关键词直接拦截;未命中时再调用上游审计接口。',
|
||||
keywordModeKeywordOnly: '仅关键词',
|
||||
keywordModeKeywordOnlyDesc: '只用关键词判断,未命中即放行,不调用上游审计接口,可显著降低 API 用量。',
|
||||
keywordModeKeywordOnlyNotice: '当前为「仅关键词」策略:未命中关键词的请求将直接放行,不调用上游审计接口。',
|
||||
keywordModeApiOnly: '仅 API',
|
||||
keywordModeApiOnlyDesc: '只调用上游审计接口判断,本页的关键词列表将不会生效。',
|
||||
keywordModeApiOnlyNotice: '当前为「仅 API」策略:关键词列表不会生效,请求会全部交给上游审计接口判断。',
|
||||
overview: {
|
||||
status: '运行状态',
|
||||
enabled: '已启用',
|
||||
disabled: '未启用',
|
||||
apiKey: 'API Key',
|
||||
groupScope: '审计范围',
|
||||
logs: '审核记录',
|
||||
currentFilter: '当前筛选结果',
|
||||
},
|
||||
filters: {
|
||||
search: '按用户/Key/摘要搜索',
|
||||
from: '开始时间',
|
||||
to: '结束时间',
|
||||
allGroups: '全部分组',
|
||||
allEndpoints: '全部端点',
|
||||
},
|
||||
table: {
|
||||
time: '时间',
|
||||
group: '分组',
|
||||
user: '用户',
|
||||
apiKey: 'API Key',
|
||||
endpoint: '端点',
|
||||
result: '结果',
|
||||
highest: '最高分',
|
||||
actionMeta: '处置',
|
||||
latency: '上游耗时',
|
||||
input: '输入摘要',
|
||||
},
|
||||
result: {
|
||||
all: '全部结果',
|
||||
hit: '命中',
|
||||
blocked: '已拦截',
|
||||
pass: '未命中',
|
||||
error: '异常',
|
||||
},
|
||||
action: {
|
||||
block: '拦截',
|
||||
keywordBlock: '关键词拦截',
|
||||
cyberPolicy: '网络安全策略',
|
||||
error: '异常',
|
||||
},
|
||||
},
|
||||
|
||||
// Channel Monitor
|
||||
channelMonitor: {
|
||||
title: '渠道监控',
|
||||
description: '监测各渠道的可用性、延迟和状态',
|
||||
searchPlaceholder: '搜索监控名称...',
|
||||
allProviders: '全部供应商',
|
||||
allStatus: '全部状态',
|
||||
enabledFilter: '启用状态',
|
||||
onlyEnabled: '仅启用',
|
||||
onlyDisabled: '仅禁用',
|
||||
createButton: '新增监控',
|
||||
createTitle: '新增渠道监控',
|
||||
editTitle: '编辑渠道监控',
|
||||
runNow: '立即检测',
|
||||
runSuccess: '检测完成',
|
||||
runFailed: '检测失败',
|
||||
apiKeyDecryptFailed: 'API Key 解密失败,请重新编辑该监控并填入新的 Key',
|
||||
createSuccess: '监控创建成功',
|
||||
updateSuccess: '监控更新成功',
|
||||
deleteSuccess: '监控删除成功',
|
||||
loadError: '加载监控列表失败',
|
||||
deleteConfirm: '确定要删除监控「{name}」吗?此操作不可撤销。',
|
||||
nameRequired: '请输入监控名称',
|
||||
primaryModelRequired: '请输入主模型',
|
||||
columns: {
|
||||
name: '名称',
|
||||
provider: '供应商',
|
||||
primaryModel: '主模型',
|
||||
availability7d: '7 天可用率',
|
||||
latency: '延迟 (ms)',
|
||||
enabled: '启用',
|
||||
actions: '操作'
|
||||
},
|
||||
form: {
|
||||
name: '名称',
|
||||
namePlaceholder: '输入监控名称',
|
||||
provider: '平台',
|
||||
apiMode: 'OpenAI 协议',
|
||||
apiModeChatCompletions: 'OpenAI Compatible',
|
||||
apiModeChatCompletionsHint: '使用 /v1/chat/completions,发送 messages;适合大多数兼容站。',
|
||||
apiModeResponses: 'Responses API',
|
||||
apiModeResponsesHint: '使用 /v1/responses,默认带 instructions + input;适合本站自检/Codex。',
|
||||
endpoint: '上游地址',
|
||||
endpointPlaceholder: 'https://api.example.com',
|
||||
useCurrentDomain: '使用当前服务',
|
||||
apiKey: 'API Key',
|
||||
apiKeyPlaceholder: '请输入 API Key',
|
||||
apiKeyEditPlaceholder: '留空表示不修改',
|
||||
useMyKey: '使用我的 Key',
|
||||
selectKeyTitle: '选择我的 API Key',
|
||||
selectKeyHint: '仅显示当前账号下处于「启用」状态且未过期的 Key。',
|
||||
noActiveKey: '没有可用的启用状态 Key',
|
||||
primaryModel: '主模型',
|
||||
primaryModelPlaceholder: 'gpt-4o-mini',
|
||||
extraModels: '附加模型',
|
||||
extraModelsPlaceholder: '回车添加附加模型',
|
||||
groupName: '分组名称',
|
||||
groupNamePlaceholder: '可选,用于在用户视图中聚合显示',
|
||||
intervalSeconds: '检测间隔 (秒)',
|
||||
intervalSecondsHint: '范围:15 - 3600 秒',
|
||||
jitterSeconds: '随机抖动 (± 秒)',
|
||||
jitterSecondsHint: '每次检测在间隔基础上正负随机偏移该秒数,0 表示固定间隔;需满足 间隔 - 抖动 ≥ 15 秒',
|
||||
enabled: '启用监控',
|
||||
kindRequired: '请选择供应商'
|
||||
},
|
||||
runResultTitle: '检测结果',
|
||||
noMonitorsYet: '暂无监控',
|
||||
createFirstMonitor: '创建第一个监控来跟踪渠道可用性',
|
||||
advanced: {
|
||||
section: '高级(可选)',
|
||||
sectionHint: '自定义请求头和请求体,用于突破上游的客户端识别限制(如仅允许 Claude Code 客户端)。',
|
||||
headers: '自定义请求头',
|
||||
headersPlaceholder: 'User-Agent: claude-cli/1.0.83 (external, cli)\nx-app: cli\nanthropic-beta: claude-code-20250219',
|
||||
headerNamePlaceholder: 'Header 名',
|
||||
headerValuePlaceholder: 'Value',
|
||||
headerAddRow: '添加 Header',
|
||||
headerNameInvalid: 'Header 名不能包含空格或冒号:{name}',
|
||||
headersHint: '与默认请求头合并,用户值优先。hop-by-hop 类 header(Host/Content-Length/...)会被忽略。',
|
||||
headersParseError: '无法解析这一行:{line}',
|
||||
bodyMode: '请求体处理',
|
||||
bodyModeOff: '默认',
|
||||
bodyModeMerge: '合并',
|
||||
bodyModeReplace: '覆盖',
|
||||
bodyModeHintOff: '使用 adapter 默认请求体(带 challenge 数学题校验)。',
|
||||
bodyModeHintMerge: '与默认请求体浅合并,用户字段优先;但 model / messages / contents 会被保护不允许覆盖(动这些字段请用「覆盖」模式)。',
|
||||
bodyModeHintReplace: '完全用下方 JSON 作为请求体。注意:此模式下跳过 challenge 校验,改为 HTTP 2xx + 响应文本非空即视为可用。',
|
||||
bodyJson: 'Body JSON',
|
||||
bodyJsonFormat: '格式化',
|
||||
bodyJsonHint: '失焦时自动解析校验。留空等价于没有覆盖。',
|
||||
bodyJsonError: 'JSON 解析失败',
|
||||
bodyJsonObjectError: '请求体必须是一个 JSON 对象(不能是数组或基本类型)'
|
||||
},
|
||||
templateField: {
|
||||
label: '请求模板',
|
||||
none: '不使用模板',
|
||||
placeholder: '选择一个模板(按当前平台过滤)',
|
||||
applyHint: '选中模板后,会把模板的请求头和请求体拷贝到此监控(快照)。后续模板变动不自动同步。'
|
||||
},
|
||||
template: {
|
||||
manageButton: '模板管理',
|
||||
managerTitle: '请求模板管理',
|
||||
createButton: '新建模板',
|
||||
emptyState: '当前平台下还没有请求模板',
|
||||
missingName: '请输入模板名称',
|
||||
createSuccess: '模板创建成功',
|
||||
updateSuccess: '模板更新成功',
|
||||
deleteSuccess: '模板删除成功',
|
||||
applyButton: '应用到关联监控',
|
||||
applyTooltip: '把当前模板配置覆盖到所有关联的监控上',
|
||||
applyTitle: '应用模板',
|
||||
applyConfirm: '确认应用',
|
||||
applyConfirmMessage: '将把模板「{name}」的当前配置覆盖到 {n} 个关联监控。监控本地已编辑的自定义修改会被丢弃,是否继续?',
|
||||
applySuccess: '已应用到 {n} 个监控',
|
||||
applyPickerTitle: '应用模板「{name}」',
|
||||
applyPickerHint: '勾选要覆盖请求头/请求体的监控(默认全选)。监控本地已编辑的自定义修改会被丢弃。',
|
||||
applyPickerEmpty: '当前模板没有关联监控',
|
||||
applyPickerConfirm: '应用到 {n} 个监控',
|
||||
selectNone: '全不选',
|
||||
selectedCount: '已选 {n} / {total}',
|
||||
deleteConfirm: '确定要删除模板「{name}」吗?{n} 个关联监控会解除关联但保留自己的快照继续工作。',
|
||||
associatedCount: '{n} 个关联监控',
|
||||
headersSummary: '{n} 个自定义请求头',
|
||||
form: {
|
||||
name: '模板名称',
|
||||
namePlaceholder: '例:Claude Code 伪装',
|
||||
description: '说明',
|
||||
descriptionPlaceholder: '可选:说明这个模板的用途和来源(抓包日期等)'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Subscriptions Management
|
||||
subscriptions: {
|
||||
title: '订阅管理',
|
||||
description: '管理用户订阅和配额限制',
|
||||
assignSubscription: '分配订阅',
|
||||
adjustSubscription: '调整订阅',
|
||||
revokeSubscription: '撤销订阅',
|
||||
restoreSubscription: '恢复订阅',
|
||||
allStatus: '全部状态',
|
||||
allGroups: '全部分组',
|
||||
allPlatforms: '全部平台',
|
||||
daily: '每日',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
noLimits: '未配置限额',
|
||||
unlimited: '无限制',
|
||||
resetNow: '即将重置',
|
||||
windowNotActive: '窗口未激活',
|
||||
resetInMinutes: '{minutes} 分钟后重置',
|
||||
resetInHoursMinutes: '{hours} 小时 {minutes} 分钟后重置',
|
||||
resetInDaysHours: '{days} 天 {hours} 小时后重置',
|
||||
quotaEndsInMinutes: '额度将在 {minutes} 分钟后结束',
|
||||
quotaEndsInHoursMinutes: '额度将在 {hours} 小时 {minutes} 分钟后结束',
|
||||
quotaEndsInDaysHours: '额度将在 {days} 天 {hours} 小时后结束',
|
||||
daysRemaining: '天剩余',
|
||||
remainingDays: '剩余天数',
|
||||
noExpiration: '无过期时间',
|
||||
status: {
|
||||
active: '生效中',
|
||||
expired: '已过期',
|
||||
revoked: '已撤销',
|
||||
suspended: '已暂停'
|
||||
},
|
||||
columns: {
|
||||
user: '用户',
|
||||
group: '分组',
|
||||
usage: '用量',
|
||||
expires: '到期时间',
|
||||
status: '状态',
|
||||
actions: '操作'
|
||||
},
|
||||
form: {
|
||||
user: '用户',
|
||||
group: '订阅分组',
|
||||
validityDays: '有效期(天)',
|
||||
adjustDays: '调整天数'
|
||||
},
|
||||
selectUser: '选择用户',
|
||||
selectGroup: '选择订阅分组',
|
||||
groupHint: '仅显示订阅计费类型的分组',
|
||||
validityHint: '订阅的有效天数',
|
||||
adjustingFor: '为以下用户调整订阅',
|
||||
currentExpiration: '当前到期时间',
|
||||
adjustDaysPlaceholder: '正数延长,负数缩短',
|
||||
adjustHint: '输入正数延长订阅,负数缩短订阅(缩短后剩余天数需大于0)',
|
||||
assign: '分配',
|
||||
assigning: '分配中...',
|
||||
adjust: '调整',
|
||||
adjusting: '调整中...',
|
||||
revoke: '撤销',
|
||||
restore: '恢复',
|
||||
resetQuota: '重置配额',
|
||||
resetQuotaTitle: '重置用量配额',
|
||||
resetQuotaConfirm: "确定要重置 '{user}' 的每日、每周和每月用量配额吗?用量将归零并从今天开始重新计算。",
|
||||
quotaResetSuccess: '配额重置成功',
|
||||
failedToResetQuota: '重置配额失败',
|
||||
noSubscriptionsYet: '暂无订阅',
|
||||
assignFirstSubscription: '分配一个订阅以开始使用。',
|
||||
subscriptionAssigned: '订阅分配成功',
|
||||
subscriptionAdjusted: '订阅调整成功',
|
||||
subscriptionRevoked: '订阅撤销成功',
|
||||
subscriptionRestored: '订阅已恢复',
|
||||
failedToLoad: '加载订阅列表失败',
|
||||
failedToAssign: '分配订阅失败',
|
||||
failedToAdjust: '调整订阅失败',
|
||||
failedToRevoke: '撤销订阅失败',
|
||||
failedToRestore: '恢复订阅失败',
|
||||
adjustWouldExpire: '调整后剩余天数必须大于0',
|
||||
adjustOutOfRange: '调整天数必须在 -36500 到 36500 之间',
|
||||
pleaseSelectUser: '请选择用户',
|
||||
pleaseSelectGroup: '请选择分组',
|
||||
validityDaysRequired: '请输入有效的天数(至少1天)',
|
||||
revokeConfirm: "确定要撤销 '{user}' 的订阅吗?可稍后在已撤销列表中恢复。",
|
||||
restoreConfirm: "确定要恢复 '{user}' 的订阅吗?如果原订阅已过期,恢复后将显示为已过期。",
|
||||
guide: {
|
||||
title: '订阅管理教程',
|
||||
subtitle: '订阅模式允许你按时间周期为用户分配使用额度,支持日/周/月配额限制。按照以下步骤即可完成配置。',
|
||||
showGuide: '使用指南',
|
||||
step1: {
|
||||
title: '创建订阅分组',
|
||||
line1: '前往「分组管理」页面,点击「创建分组」',
|
||||
line2: '将计费类型设为「订阅」,配置日/周/月额度限制',
|
||||
line3: '保存分组,确保状态为「正常」',
|
||||
link: '前往分组管理'
|
||||
},
|
||||
step2: {
|
||||
title: '分配订阅给用户',
|
||||
line1: '点击本页右上角「分配订阅」按钮',
|
||||
line2: '在弹窗中搜索用户邮箱并选择目标用户',
|
||||
line3: '选择订阅分组、设置有效期天数,点击「分配」'
|
||||
},
|
||||
step3: {
|
||||
title: '管理已有订阅'
|
||||
},
|
||||
actions: {
|
||||
adjust: '调整',
|
||||
adjustDesc: '延长或缩短订阅有效期',
|
||||
resetQuota: '重置配额',
|
||||
resetQuotaDesc: '将日/周/月用量归零,重新开始计算',
|
||||
revoke: '撤销',
|
||||
revokeDesc: '立即终止该用户的订阅,可在已撤销列表中恢复'
|
||||
},
|
||||
tip: '提示:订阅分组下拉列表中只会显示计费类型为「订阅」且状态为「正常」的分组。如果没有可选项,请先到分组管理中创建。'
|
||||
}
|
||||
},
|
||||
|
||||
// Accounts Management
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import overview from './overview'
|
||||
import channels from './channels'
|
||||
import accounts from './accounts'
|
||||
import resources from './resources'
|
||||
import ops from './ops'
|
||||
import settings from './settings'
|
||||
|
||||
export default {
|
||||
...overview,
|
||||
...channels,
|
||||
...accounts,
|
||||
...resources,
|
||||
...ops,
|
||||
...settings,
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
export default {
|
||||
ops: {
|
||||
title: '运维监控',
|
||||
description: '运维监控与排障',
|
||||
// Dashboard
|
||||
systemHealth: '系统健康',
|
||||
overview: '概览',
|
||||
noSystemMetrics: '尚未收集系统指标。',
|
||||
collectedAt: '采集时间:',
|
||||
window: '窗口',
|
||||
memory: '内存',
|
||||
db: '数据库',
|
||||
goroutines: '协程',
|
||||
jobs: '后台任务',
|
||||
jobsHelp: '点击“明细”查看任务心跳与报错信息',
|
||||
active: '活跃',
|
||||
idle: '空闲',
|
||||
waiting: '等待',
|
||||
conns: '连接',
|
||||
queue: '队列',
|
||||
accountSwitches: '账号切换',
|
||||
ok: '正常',
|
||||
lastRun: '最近运行',
|
||||
lastSuccess: '最近成功',
|
||||
lastError: '最近错误',
|
||||
result: '结果',
|
||||
noData: '暂无数据',
|
||||
loadingText: '加载中...',
|
||||
ready: '就绪',
|
||||
autoRefreshRemaining: '剩余 {seconds}s',
|
||||
systemLogs: {
|
||||
title: '系统日志',
|
||||
description: '优先显示最新日志,可按条件筛选、搜索和清理。',
|
||||
queue: '队列',
|
||||
written: '已写入',
|
||||
dropped: '已丢弃',
|
||||
failed: '写入失败',
|
||||
runtimeConfig: '运行时日志配置(立即生效)',
|
||||
all: '全部',
|
||||
level: '级别',
|
||||
stacktraceThreshold: '堆栈阈值',
|
||||
samplingInitial: '采样初始条数',
|
||||
samplingThereafter: '后续采样间隔',
|
||||
retentionDays: '保留天数',
|
||||
caller: '调用方',
|
||||
sampling: '采样',
|
||||
saveAndApply: '保存并应用',
|
||||
resetDefaults: '重置默认值',
|
||||
latestWriteError: '最近写入错误:',
|
||||
timeRange: '时间范围',
|
||||
startTime: '开始时间(可选)',
|
||||
endTime: '结束时间(可选)',
|
||||
component: '组件',
|
||||
componentPlaceholder: '例如 http.access',
|
||||
keyId: 'KEY ID',
|
||||
platform: '平台',
|
||||
model: '模型',
|
||||
keyword: '关键词',
|
||||
keywordPlaceholder: 'message/request_id',
|
||||
search: '搜索',
|
||||
cleanCurrentFilters: '清理当前筛选结果',
|
||||
refreshHealth: '刷新健康状态',
|
||||
empty: '暂无系统日志',
|
||||
time: '时间',
|
||||
logDetails: '日志详情',
|
||||
loadFailed: '加载系统日志失败',
|
||||
runtimeConfigActive: '运行时日志配置已生效',
|
||||
runtimeConfigSaveFailed: '保存日志配置失败',
|
||||
resetRuntimeConfigConfirm: '确定要重置为启动配置(env/yaml)并立即应用吗?',
|
||||
runtimeConfigReset: '已重置为启动日志配置',
|
||||
runtimeConfigResetFailed: '重置日志配置失败',
|
||||
cleanupConfirm: '确定要清理匹配当前筛选条件的系统日志吗?此操作不可撤销。',
|
||||
cleanupSuccess: '清理完成,已删除 {count} 条日志。',
|
||||
cleanupFailed: '清理系统日志失败'
|
||||
},
|
||||
requestsTotal: '请求(总计)',
|
||||
slaScope: 'SLA 范围:',
|
||||
tokens: 'Token数',
|
||||
tps: 'TPS',
|
||||
current: '当前',
|
||||
peak: '峰值',
|
||||
average: '平均',
|
||||
totalRequests: '总请求',
|
||||
avgQps: '平均 QPS',
|
||||
avgTps: '平均 TPS',
|
||||
avgLatency: '平均请求时长',
|
||||
avgTtft: '平均首 Token 延迟',
|
||||
exceptions: '异常数',
|
||||
requestErrors: '请求错误',
|
||||
errorCount: '错误数',
|
||||
upstreamErrors: '上游错误',
|
||||
errorCountExcl429529: '错误数(排除429/529)',
|
||||
sla: 'SLA(排除业务限制)',
|
||||
businessLimited: '业务限制:',
|
||||
errors: '错误',
|
||||
errorRate: '错误率:',
|
||||
upstreamRate: '上游错误率:',
|
||||
latencyDuration: '请求时长',
|
||||
ttftLabel: '首 Token 延迟(毫秒)',
|
||||
p50: 'p50',
|
||||
p90: 'p90',
|
||||
p95: 'p95',
|
||||
p99: 'p99',
|
||||
avg: 'avg',
|
||||
max: 'max',
|
||||
requests: '请求数',
|
||||
requestsTitle: '请求',
|
||||
upstream: '上游',
|
||||
client: '客户端',
|
||||
system: '系统',
|
||||
other: '其他',
|
||||
errorsSla: '错误(SLA范围)',
|
||||
upstreamExcl429529: '上游(排除429/529)',
|
||||
failedToLoadData: '加载运维数据失败',
|
||||
failedToLoadOverview: '加载概览数据失败',
|
||||
failedToLoadThroughputTrend: '加载吞吐趋势失败',
|
||||
failedToLoadSwitchTrend: '加载平均账号切换趋势失败',
|
||||
failedToLoadLatencyHistogram: '加载请求时长分布失败',
|
||||
failedToLoadErrorTrend: '加载错误趋势失败',
|
||||
failedToLoadErrorDistribution: '加载错误分布失败',
|
||||
failedToLoadErrorDetail: '加载错误详情失败',
|
||||
retryFailed: '重试失败',
|
||||
tpsK: 'TPS(千)',
|
||||
top: '最高:',
|
||||
throughputTrend: '吞吐趋势',
|
||||
switchRateTrend: '平均账号切换趋势',
|
||||
latencyHistogram: '请求时长分布',
|
||||
errorTrend: '错误趋势',
|
||||
errorDistribution: '错误分布',
|
||||
switchRate: '平均账号切换',
|
||||
// Health Score & Diagnosis
|
||||
health: '健康',
|
||||
healthCondition: '健康状况',
|
||||
healthHelp: '基于 SLA、错误率和资源使用情况的系统整体健康评分',
|
||||
healthyStatus: '健康',
|
||||
riskyStatus: '风险',
|
||||
idleStatus: '待机',
|
||||
timeRange: {
|
||||
'5m': '近5分钟',
|
||||
'30m': '近30分钟',
|
||||
'1h': '近1小时',
|
||||
'1d': '近1天',
|
||||
'15d': '近15天',
|
||||
'6h': '近6小时',
|
||||
'24h': '近24小时',
|
||||
'7d': '近7天',
|
||||
'30d': '近30天',
|
||||
custom: '自定义'
|
||||
},
|
||||
openaiTokenStats: {
|
||||
title: 'OpenAI Token 请求统计',
|
||||
viewModeTopN: 'TopN',
|
||||
viewModePagination: '分页',
|
||||
prevPage: '上一页',
|
||||
nextPage: '下一页',
|
||||
pageInfo: '第 {page}/{total} 页',
|
||||
totalModels: '模型总数:{total}',
|
||||
failedToLoad: '加载 OpenAI Token 统计失败',
|
||||
empty: '当前筛选条件下暂无 OpenAI Token 请求统计数据',
|
||||
table: {
|
||||
model: '模型',
|
||||
requestCount: '请求数',
|
||||
avgTokensPerSec: '平均 Tokens/秒',
|
||||
avgFirstTokenMs: '平均首 Token 延迟(ms)',
|
||||
totalOutputTokens: '输出 Token 总数',
|
||||
avgDurationMs: '平均时长(ms)',
|
||||
requestsWithFirstToken: '首 Token 样本数'
|
||||
}
|
||||
},
|
||||
customTimeRange: {
|
||||
startTime: '开始时间',
|
||||
endTime: '结束时间'
|
||||
},
|
||||
fullscreen: {
|
||||
enter: '进入全屏'
|
||||
},
|
||||
diagnosis: {
|
||||
title: '智能诊断',
|
||||
footer: '基于当前指标的自动诊断建议',
|
||||
idle: '系统当前处于待机状态',
|
||||
idleImpact: '无活跃流量',
|
||||
// Resource diagnostics
|
||||
dbDown: '数据库连接失败',
|
||||
dbDownImpact: '所有数据库操作将失败',
|
||||
dbDownAction: '检查数据库服务状态、网络连接和连接配置',
|
||||
redisDown: 'Redis连接失败',
|
||||
redisDownImpact: '缓存功能降级,性能可能下降',
|
||||
redisDownAction: '检查Redis服务状态和网络连接',
|
||||
cpuCritical: 'CPU使用率严重过高 ({usage}%)',
|
||||
cpuCriticalImpact: '系统响应变慢,可能影响所有请求',
|
||||
cpuCriticalAction: '检查CPU密集型任务,考虑扩容或优化代码',
|
||||
cpuHigh: 'CPU使用率偏高 ({usage}%)',
|
||||
cpuHighImpact: '系统负载较高,需要关注',
|
||||
cpuHighAction: '监控CPU趋势,准备扩容方案',
|
||||
memoryCritical: '内存使用率严重过高 ({usage}%)',
|
||||
memoryCriticalImpact: '可能触发OOM,系统稳定性受威胁',
|
||||
memoryCriticalAction: '检查内存泄漏,考虑增加内存或优化内存使用',
|
||||
memoryHigh: '内存使用率偏高 ({usage}%)',
|
||||
memoryHighImpact: '内存压力较大,需要关注',
|
||||
memoryHighAction: '监控内存趋势,检查是否有内存泄漏',
|
||||
ttftHigh: '首 Token 时间偏高 ({ttft}ms)',
|
||||
ttftHighImpact: '用户感知时长增加',
|
||||
ttftHighAction: '优化请求处理流程,减少前置逻辑耗时',
|
||||
// Error rate diagnostics
|
||||
upstreamCritical: '上游错误率严重偏高 ({rate}%)',
|
||||
upstreamCriticalImpact: '可能影响大量用户请求',
|
||||
upstreamCriticalAction: '检查上游服务健康状态,启用降级策略',
|
||||
upstreamHigh: '上游错误率偏高 ({rate}%)',
|
||||
upstreamHighImpact: '建议检查上游服务状态',
|
||||
upstreamHighAction: '联系上游服务团队,准备降级方案',
|
||||
errorHigh: '错误率过高 ({rate}%)',
|
||||
errorHighImpact: '大量请求失败',
|
||||
errorHighAction: '查看错误日志,定位错误根因,紧急修复',
|
||||
errorElevated: '错误率偏高 ({rate}%)',
|
||||
errorElevatedImpact: '建议检查错误日志',
|
||||
errorElevatedAction: '分析错误类型和分布,制定修复计划',
|
||||
// SLA diagnostics
|
||||
slaCritical: 'SLA 严重低于目标 ({sla}%)',
|
||||
slaCriticalImpact: '用户体验严重受损',
|
||||
slaCriticalAction: '紧急排查错误原因,必要时采取限流保护',
|
||||
slaLow: 'SLA 低于目标 ({sla}%)',
|
||||
slaLowImpact: '需要关注服务质量',
|
||||
slaLowAction: '分析SLA下降原因,优化系统性能',
|
||||
// Health score diagnostics
|
||||
healthCritical: '综合健康评分过低 ({score})',
|
||||
healthCriticalImpact: '多个指标可能同时异常,建议优先排查错误与资源使用情况',
|
||||
healthCriticalAction: '全面检查系统状态,优先处理critical级别问题',
|
||||
healthLow: '综合健康评分偏低 ({score})',
|
||||
healthLowImpact: '可能存在轻度波动,建议关注 SLA 与错误率',
|
||||
healthLowAction: '监控指标趋势,预防问题恶化',
|
||||
healthy: '所有系统指标正常',
|
||||
healthyImpact: '服务运行稳定'
|
||||
},
|
||||
// Error Log
|
||||
errorLog: {
|
||||
timeId: '时间 / ID',
|
||||
commonErrors: {
|
||||
contextDeadlineExceeded: '请求超时',
|
||||
connectionRefused: '连接被拒绝',
|
||||
rateLimit: '触发限流'
|
||||
},
|
||||
time: '时间',
|
||||
type: '类型',
|
||||
context: '上下文',
|
||||
platform: '平台',
|
||||
model: '模型',
|
||||
group: '分组',
|
||||
user: '用户',
|
||||
userId: '用户 ID',
|
||||
apiKey: 'API Key',
|
||||
keyDeletedBadge: 'Key 已删除',
|
||||
account: '账号',
|
||||
accountId: '账号 ID',
|
||||
status: '状态码',
|
||||
message: '响应内容',
|
||||
ip: 'IP',
|
||||
latency: '请求时长',
|
||||
action: '操作',
|
||||
noErrors: '该窗口内暂无错误。',
|
||||
grp: 'GRP:',
|
||||
acc: 'ACC:',
|
||||
details: '详情',
|
||||
phase: '阶段',
|
||||
id: 'ID:',
|
||||
typeUpstream: '上游',
|
||||
typeRequest: '请求',
|
||||
typeAuth: '认证',
|
||||
typeRouting: '路由',
|
||||
typeInternal: '内部',
|
||||
endpoint: '端点',
|
||||
requestType: '类型',
|
||||
requestTypeSync: '同步',
|
||||
requestTypeStream: '流式',
|
||||
requestTypeWs: 'WS'
|
||||
},
|
||||
// Error Details Modal
|
||||
errorDetails: {
|
||||
upstreamErrors: '上游错误',
|
||||
requestErrors: '请求错误',
|
||||
unresolved: '未解决',
|
||||
resolved: '已解决',
|
||||
viewErrors: '错误',
|
||||
viewExcluded: '排除项',
|
||||
statusCodeOther: '其他',
|
||||
owner: {
|
||||
provider: '服务商',
|
||||
client: '客户端',
|
||||
platform: '平台'
|
||||
},
|
||||
phase: {
|
||||
request: '请求',
|
||||
auth: '认证',
|
||||
routing: '路由',
|
||||
upstream: '上游',
|
||||
network: '网络',
|
||||
internal: '内部'
|
||||
},
|
||||
total: '总计:',
|
||||
searchPlaceholder: '搜索 request_id / client_request_id / message'
|
||||
},
|
||||
// Error Detail Modal
|
||||
errorDetail: {
|
||||
title: '错误详情',
|
||||
titleWithId: '错误 #{id}',
|
||||
noErrorSelected: '未选择错误。',
|
||||
resolution: '已解决:',
|
||||
failedToUpdateResolvedStatus: '更新解决状态失败',
|
||||
classificationKeys: {
|
||||
phase: '阶段',
|
||||
owner: '归属方',
|
||||
source: '来源',
|
||||
resolvedAt: '解决时间',
|
||||
resolvedBy: '解决人'
|
||||
},
|
||||
source: {
|
||||
upstream_http: '上游 HTTP'
|
||||
},
|
||||
upstreamKeys: {
|
||||
status: '状态码',
|
||||
message: '消息',
|
||||
detail: '详情',
|
||||
upstreamErrors: '上游错误列表'
|
||||
},
|
||||
upstreamEvent: {
|
||||
account: '账号',
|
||||
status: '状态码',
|
||||
requestId: '请求ID'
|
||||
},
|
||||
responsePreview: {
|
||||
expand: '响应内容(点击展开)',
|
||||
collapse: '响应内容(点击收起)'
|
||||
},
|
||||
loading: '加载中…',
|
||||
requestId: '请求 ID',
|
||||
time: '时间',
|
||||
phase: '阶段',
|
||||
status: '状态码',
|
||||
message: '消息',
|
||||
basicInfo: '基本信息',
|
||||
platform: '平台',
|
||||
model: '模型',
|
||||
group: '分组',
|
||||
user: '用户',
|
||||
account: '账号',
|
||||
latency: '请求时长',
|
||||
businessLimited: '业务限制',
|
||||
requestPath: '请求路径',
|
||||
inboundEndpoint: '入站端点',
|
||||
upstreamEndpoint: '上游端点',
|
||||
requestedModel: '请求模型',
|
||||
upstreamModel: '上游模型',
|
||||
requestType: '请求类型',
|
||||
requestTypeUnknown: '未知',
|
||||
requestTypeSync: '同步',
|
||||
requestTypeStream: '流式',
|
||||
requestTypeWs: 'WebSocket',
|
||||
modelMapping: '模型映射',
|
||||
timings: '时序信息',
|
||||
auth: '认证',
|
||||
routing: '路由',
|
||||
upstream: '上游',
|
||||
response: '响应',
|
||||
classification: '错误分类',
|
||||
errorBody: '错误体',
|
||||
trimmed: '已截断',
|
||||
markResolved: '标记已解决',
|
||||
markUnresolved: '标记未解决',
|
||||
tabOverview: '概览',
|
||||
tabRequest: '请求详情',
|
||||
tabResponse: '响应详情',
|
||||
responseBody: '响应详情',
|
||||
compareA: '对比 A',
|
||||
compareB: '对比 B',
|
||||
suggestion: '处理建议',
|
||||
suggestUpstream: '⚠️ 上游服务不稳定,建议:检查上游账号状态 / 考虑切换账号',
|
||||
suggestRequest: '⚠️ 客户端请求错误,建议:联系客户修正请求参数 / 手动标记已解决',
|
||||
suggestAuth: '⚠️ 认证失败,建议:检查 API Key 是否有效 / 联系客户更新凭证',
|
||||
suggestPlatform: '🚨 平台错误,建议立即排查修复',
|
||||
suggestGeneric: '查看详情了解更多信息',
|
||||
apiKeyPrefix: 'Key 前缀',
|
||||
attemptedKeyPrefix: '尝试的 Key 前缀',
|
||||
deletedKeyOwner: '已删除 Key 所有者',
|
||||
keyDeletedBadge: 'Key 已删除'
|
||||
},
|
||||
requestDetails: {
|
||||
title: '请求明细',
|
||||
details: '明细',
|
||||
rangeLabel: '窗口:{range}',
|
||||
rangeMinutes: '{n} 分钟',
|
||||
rangeHours: '{n} 小时',
|
||||
empty: '该窗口内暂无请求。',
|
||||
emptyHint: '可尝试调整时间范围或取消部分筛选。',
|
||||
failedToLoad: '加载请求明细失败',
|
||||
requestIdCopied: '请求ID已复制',
|
||||
copyFailed: '复制失败',
|
||||
copy: '复制',
|
||||
viewError: '查看错误',
|
||||
kind: {
|
||||
success: '成功',
|
||||
error: '失败'
|
||||
},
|
||||
table: {
|
||||
time: '时间',
|
||||
kind: '类型',
|
||||
platform: '平台',
|
||||
model: '模型',
|
||||
duration: '耗时',
|
||||
status: '状态码',
|
||||
requestId: '请求ID',
|
||||
actions: '操作'
|
||||
}
|
||||
},
|
||||
alertEvents: {
|
||||
title: '告警事件',
|
||||
description: '最近的告警触发/恢复记录(仅邮件通知)',
|
||||
loading: '加载中...',
|
||||
empty: '暂无告警事件',
|
||||
loadFailed: '加载告警事件失败',
|
||||
status: {
|
||||
firing: '告警中',
|
||||
resolved: '已恢复',
|
||||
manualResolved: '手动已解决'
|
||||
},
|
||||
detail: {
|
||||
title: '告警详情',
|
||||
loading: '加载详情中...',
|
||||
empty: '暂无详情',
|
||||
loadFailed: '加载告警详情失败',
|
||||
manualResolve: '标记为已解决',
|
||||
manualResolvedSuccess: '已标记为手动解决',
|
||||
manualResolvedFailed: '标记为手动解决失败',
|
||||
silence: '忽略此告警',
|
||||
silenceSuccess: '已静默该告警',
|
||||
silenceFailed: '静默失败',
|
||||
viewRule: '查看规则',
|
||||
viewLogs: '查看相关日志',
|
||||
firedAt: '触发时间',
|
||||
resolvedAt: '解决时间',
|
||||
ruleId: '规则 ID',
|
||||
dimensions: '维度信息',
|
||||
historyTitle: '历史记录',
|
||||
historyHint: '同一规则 + 相同维度的最近事件',
|
||||
historyLoading: '加载历史中...',
|
||||
historyEmpty: '暂无历史记录'
|
||||
},
|
||||
table: {
|
||||
time: '时间',
|
||||
status: '状态',
|
||||
severity: '级别',
|
||||
platform: '平台',
|
||||
ruleId: '规则ID',
|
||||
title: '标题',
|
||||
duration: '持续时间',
|
||||
metric: '指标 / 阈值',
|
||||
dimensions: '维度',
|
||||
email: '邮件已发送',
|
||||
emailSent: '已发送',
|
||||
emailIgnored: '已忽略'
|
||||
}
|
||||
},
|
||||
alertRules: {
|
||||
title: '告警规则',
|
||||
description: '创建与管理系统阈值告警(仅邮件通知)',
|
||||
loading: '加载中...',
|
||||
empty: '暂无告警规则',
|
||||
loadFailed: '加载告警规则失败',
|
||||
saveSuccess: '警报规则保存成功',
|
||||
saveFailed: '保存告警规则失败',
|
||||
deleteSuccess: '警报规则删除成功',
|
||||
deleteFailed: '删除告警规则失败',
|
||||
create: '新建规则',
|
||||
createTitle: '新建告警规则',
|
||||
editTitle: '编辑告警规则',
|
||||
deleteConfirmTitle: '确认删除该规则?',
|
||||
deleteConfirmMessage: '将删除该规则及其关联的告警事件,是否继续?',
|
||||
manage: '预警规则',
|
||||
metricGroups: {
|
||||
system: '系统指标',
|
||||
group: '分组级别指标(需 group_id)',
|
||||
account: '账号级别指标'
|
||||
},
|
||||
metrics: {
|
||||
successRate: '成功率 (%)',
|
||||
errorRate: '错误率 (%)',
|
||||
upstreamErrorRate: '上游错误率 (%)',
|
||||
p95: 'P95 请求时长 (ms)',
|
||||
p99: 'P99 请求时长 (ms)',
|
||||
cpu: 'CPU 使用率 (%)',
|
||||
memory: '内存使用率 (%)',
|
||||
queueDepth: '并发排队深度',
|
||||
groupAvailableAccounts: '分组可用账号数',
|
||||
groupAvailableRatio: '分组可用比例 (%)',
|
||||
groupRateLimitRatio: '分组限流比例 (%)',
|
||||
accountRateLimitedCount: '限流账号数',
|
||||
accountErrorCount: '错误账号数(不含临时不可调度)',
|
||||
accountErrorRatio: '错误账号比例 (%)',
|
||||
accountTempUnscheduledCount: '临时不可调度账号数',
|
||||
overloadAccountCount: '过载账号数'
|
||||
},
|
||||
metricDescriptions: {
|
||||
successRate: '统计窗口内成功请求占比(0~100)。',
|
||||
errorRate: '统计窗口内失败请求占比(0~100)。',
|
||||
upstreamErrorRate: '统计窗口内上游错误占比(0~100)。',
|
||||
p95: '统计窗口内 P95 请求耗时(毫秒)。',
|
||||
p99: '统计窗口内 P99 请求耗时(毫秒)。',
|
||||
cpu: '当前实例 CPU 使用率(0~100)。',
|
||||
memory: '当前实例内存使用率(0~100)。',
|
||||
queueDepth: '统计窗口内并发队列排队深度(等待中的请求数)。',
|
||||
groupAvailableAccounts: '指定分组中当前可用账号数量(需要 group_id 过滤)。',
|
||||
groupAvailableRatio: '指定分组中可用账号占比(0~100,需要 group_id 过滤)。',
|
||||
groupRateLimitRatio: '指定分组中账号被限流的比例(0~100,需要 group_id 过滤)。',
|
||||
accountRateLimitedCount: '统计窗口内被限流的账号数量。',
|
||||
accountErrorCount: '统计窗口内产生错误的账号数量(不含临时不可调度)。',
|
||||
accountErrorRatio: '统计窗口内错误账号占比(0~100)。',
|
||||
accountTempUnscheduledCount: '当前处于临时不可调度状态的账号数量(如代理/凭据故障被自动摘除)。',
|
||||
overloadAccountCount: '统计窗口内过载账号数量。'
|
||||
},
|
||||
hints: {
|
||||
recommended: '推荐:运算符 {operator},阈值 {threshold}{unit}',
|
||||
groupRequired: '该指标为分组级别指标,必须选择分组(group_id)。',
|
||||
groupOptional: '可选:通过 group_id 将规则限定到某个分组。'
|
||||
},
|
||||
table: {
|
||||
name: '名称',
|
||||
metric: '指标',
|
||||
severity: '级别',
|
||||
enabled: '启用',
|
||||
actions: '操作'
|
||||
},
|
||||
form: {
|
||||
name: '名称',
|
||||
description: '描述',
|
||||
metric: '指标',
|
||||
operator: '运算符',
|
||||
groupId: '分组(group_id)',
|
||||
groupPlaceholder: '请选择分组',
|
||||
allGroups: '全部分组',
|
||||
threshold: '阈值',
|
||||
severity: '级别',
|
||||
window: '统计窗口(分钟)',
|
||||
sustained: '连续样本数(每分钟)',
|
||||
cooldown: '冷却期(分钟)',
|
||||
enabled: '启用',
|
||||
notifyEmail: '发送邮件通知'
|
||||
},
|
||||
validation: {
|
||||
title: '请先修正以下问题',
|
||||
invalid: '规则不合法',
|
||||
nameRequired: '名称不能为空',
|
||||
metricRequired: '指标不能为空',
|
||||
groupIdRequired: '分组级别指标必须指定 group_id',
|
||||
operatorRequired: '运算符不能为空',
|
||||
thresholdRequired: '阈值必须为数字',
|
||||
windowRange: '统计窗口必须为 1 / 5 / 60 分钟之一',
|
||||
sustainedRange: '连续样本数必须在 1 到 1440 之间',
|
||||
cooldownRange: '冷却期必须在 0 到 1440 分钟之间'
|
||||
}
|
||||
},
|
||||
runtime: {
|
||||
title: '运维监控运行设置',
|
||||
description: '配置存储在数据库中,无需修改 config 文件即可生效。',
|
||||
loading: '加载中...',
|
||||
noData: '暂无运行设置',
|
||||
loadFailed: '加载运行设置失败',
|
||||
saveSuccess: '运行设置已保存',
|
||||
saveFailed: '保存运行设置失败',
|
||||
alertTitle: '告警评估器',
|
||||
groupAvailabilityTitle: '分组可用性监控',
|
||||
evalIntervalSeconds: '评估间隔(秒)',
|
||||
silencing: {
|
||||
title: '告警静默(维护模式)',
|
||||
enabled: '启用静默',
|
||||
globalUntil: '静默截止时间(RFC3339)',
|
||||
untilHint: '建议填写截止时间,避免忘记关闭静默。',
|
||||
reason: '原因',
|
||||
reasonPlaceholder: '例如:计划维护',
|
||||
entries: {
|
||||
title: '高级:定向静默',
|
||||
hint: '可选:仅静默特定规则或特定级别。字段留空表示匹配全部。',
|
||||
add: '新增条目',
|
||||
empty: '暂无定向静默条目',
|
||||
entryTitle: '条目 #{n}',
|
||||
ruleId: '规则ID(可选)',
|
||||
ruleIdPlaceholder: '例如:1',
|
||||
severities: '级别(可选)',
|
||||
severitiesPlaceholder: '例如:P0,P1(留空=全部)',
|
||||
until: '截止时间(RFC3339)',
|
||||
reason: '原因',
|
||||
validation: {
|
||||
untilRequired: '条目截止时间不能为空',
|
||||
untilFormat: '条目截止时间必须为合法的 RFC3339 时间戳',
|
||||
ruleIdPositive: '条目 rule_id 必须为正整数',
|
||||
severitiesFormat: '条目级别必须为 P0..P3 的逗号分隔列表'
|
||||
}
|
||||
},
|
||||
validation: {
|
||||
timeFormat: '静默时间必须为合法的 RFC3339 时间戳'
|
||||
}
|
||||
},
|
||||
lockEnabled: '启用分布式锁',
|
||||
lockKey: '分布式锁 Key',
|
||||
lockTTLSeconds: '分布式锁 TTL(秒)',
|
||||
showAdvancedDeveloperSettings: '显示高级开发者设置 (Distributed Lock)',
|
||||
advancedSettingsSummary: '高级设置 (分布式锁)',
|
||||
evalIntervalHint: '检测任务的执行频率,建议保持默认。',
|
||||
validation: {
|
||||
title: '请先修正以下问题',
|
||||
invalid: '设置不合法',
|
||||
evalIntervalRange: '评估间隔必须在 1 到 86400 秒之间',
|
||||
lockKeyRequired: '启用分布式锁时必须填写 Lock Key',
|
||||
lockKeyPrefix: '分布式锁 Key 必须以「{prefix}」开头',
|
||||
lockKeyHint: '建议以「{prefix}」开头以避免冲突',
|
||||
lockTtlRange: '分布式锁 TTL 必须在 1 到 86400 秒之间',
|
||||
slaMinPercentRange: 'SLA 最低值必须在 0-100 之间',
|
||||
ttftP99MaxRange: 'TTFT P99 最大值必须大于或等于 0',
|
||||
requestErrorRateMaxRange: '请求错误率最大值必须在 0-100 之间',
|
||||
upstreamErrorRateMaxRange: '上游错误率最大值必须在 0-100 之间'
|
||||
}
|
||||
},
|
||||
email: {
|
||||
title: '邮件通知配置',
|
||||
description: '配置告警/报告邮件通知(存储在数据库中)。',
|
||||
loading: '加载中...',
|
||||
noData: '暂无邮件通知配置',
|
||||
loadFailed: '加载邮件通知配置失败',
|
||||
saveSuccess: '邮件通知配置已保存',
|
||||
saveFailed: '保存邮件通知配置失败',
|
||||
alertTitle: '告警邮件',
|
||||
reportTitle: '报告邮件',
|
||||
recipients: '收件人',
|
||||
recipientsHint: '若为空,系统可能会回退使用第一个管理员邮箱。',
|
||||
minSeverity: '最低级别',
|
||||
minSeverityAll: '全部级别',
|
||||
rateLimitPerHour: '每小时限额',
|
||||
batchWindowSeconds: '合并窗口(秒)',
|
||||
includeResolved: '包含恢复通知',
|
||||
dailySummary: '每日摘要',
|
||||
weeklySummary: '每周摘要',
|
||||
errorDigest: '错误摘要',
|
||||
errorDigestMinCount: '错误摘要最小数量',
|
||||
accountHealth: '账号健康报告',
|
||||
accountHealthThreshold: '错误率阈值(%)',
|
||||
cronPlaceholder: 'Cron 表达式',
|
||||
reportHint: '发送时间使用 Cron 语法;留空将使用默认值。',
|
||||
validation: {
|
||||
title: '请先修正以下问题',
|
||||
invalid: '邮件通知配置不合法',
|
||||
alertRecipientsRequired: '已启用告警邮件,但未配置任何收件人',
|
||||
reportRecipientsRequired: '已启用报告邮件,但未配置任何收件人',
|
||||
invalidRecipients: '存在不合法的收件人邮箱',
|
||||
rateLimitRange: '每小时限额必须为 ≥ 0 的数字',
|
||||
batchWindowRange: '合并窗口必须在 0 到 86400 秒之间',
|
||||
cronRequired: '启用定时任务时必须填写 Cron 表达式',
|
||||
cronFormat: 'Cron 表达式格式可能不正确(至少应包含 5 段)',
|
||||
digestMinCountRange: '错误摘要最小数量必须为 ≥ 0 的数字',
|
||||
accountHealthThresholdRange: '账号健康错误率阈值必须在 0 到 100 之间'
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
title: '运维监控设置',
|
||||
loadFailed: '加载设置失败',
|
||||
saveSuccess: '运维监控设置保存成功',
|
||||
saveFailed: '保存设置失败',
|
||||
dataCollection: '数据采集',
|
||||
evaluationInterval: '评估间隔(秒)',
|
||||
evaluationIntervalHint: '检测任务的执行频率,建议保持默认',
|
||||
alertConfig: '预警配置',
|
||||
enableAlert: '开启预警',
|
||||
alertRecipients: '预警接收邮箱',
|
||||
emailPlaceholder: '输入邮箱地址',
|
||||
recipientsHint: '若为空,系统将使用第一个管理员邮箱作为默认收件人',
|
||||
minSeverity: '最低级别',
|
||||
reportConfig: '评估报告配置',
|
||||
enableReport: '开启评估报告',
|
||||
reportRecipients: '评估报告接收邮箱',
|
||||
dailySummary: '每日摘要',
|
||||
weeklySummary: '每周摘要',
|
||||
metricThresholds: '指标阈值配置',
|
||||
metricThresholdsHint: '配置各项指标的告警阈值,超出阈值时将以红色显示',
|
||||
slaMinPercent: 'SLA最低百分比',
|
||||
slaMinPercentHint: 'SLA低于此值时显示为红色(默认:99.5%)',
|
||||
ttftP99MaxMs: 'TTFT P99最大值(毫秒)',
|
||||
ttftP99MaxMsHint: 'TTFT P99高于此值时显示为红色(默认:500ms)',
|
||||
requestErrorRateMaxPercent: '请求错误率最大值(%)',
|
||||
requestErrorRateMaxPercentHint: '请求错误率高于此值时显示为红色(默认:5%)',
|
||||
upstreamErrorRateMaxPercent: '上游错误率最大值(%)',
|
||||
upstreamErrorRateMaxPercentHint: '上游错误率高于此值时显示为红色(默认:5%)',
|
||||
advancedSettings: '高级设置',
|
||||
dataRetention: '数据保留策略',
|
||||
enableCleanup: '启用数据清理',
|
||||
cleanupSchedule: '清理计划(Cron)',
|
||||
cleanupScheduleHint: '例如:0 2 * * * 表示每天凌晨2点',
|
||||
errorLogRetentionDays: '错误日志保留天数',
|
||||
minuteMetricsRetentionDays: '分钟指标保留天数',
|
||||
hourlyMetricsRetentionDays: '小时指标保留天数',
|
||||
retentionDaysHint: '建议保留 7-90 天,过长会占用存储空间;填 0 表示每次定时清理时清空所有历史',
|
||||
aggregation: '预聚合任务',
|
||||
enableAggregation: '启用预聚合任务',
|
||||
aggregationHint: '预聚合可提升长时间窗口查询性能',
|
||||
openaiQuotaAutoPause: 'OpenAI 账号配额自动暂停',
|
||||
openaiQuotaAutoPauseHint: '当 OpenAI 账号 5h / 7d 用量达到阈值时,调度会自动跳过该账号;窗口滚动后自动恢复。账号级阈值优先于此全局默认值。',
|
||||
openaiQuotaAutoPauseDefault5h: '默认 5h 用量阈值 (%)',
|
||||
openaiQuotaAutoPauseDefault7d: '默认 7d 用量阈值 (%)',
|
||||
openaiQuotaAutoPauseThresholdHint: '取值 0-100,留空或 0 表示不启用全局默认阈值。',
|
||||
errorFiltering: '错误过滤',
|
||||
ignoreCountTokensErrors: '忽略 count_tokens 错误',
|
||||
ignoreCountTokensErrorsHint: '启用后,count_tokens 请求的错误将不会写入错误日志。',
|
||||
ignoreContextCanceled: '忽略客户端断连错误',
|
||||
ignoreContextCanceledHint:
|
||||
'启用后,客户端主动断开连接(context canceled)的错误将不会写入错误日志。',
|
||||
ignoreNoAvailableAccounts: '忽略无可用账号错误',
|
||||
ignoreNoAvailableAccountsHint: '启用后,"No available accounts" 错误将不会写入错误日志(不推荐,这通常是配置问题)。',
|
||||
ignoreInvalidApiKeyErrors: '忽略无效 API Key 错误',
|
||||
ignoreInvalidApiKeyErrorsHint: '启用后,无效或缺失 API Key 的错误(INVALID_API_KEY、API_KEY_REQUIRED)将不会写入错误日志。',
|
||||
ignoreInsufficientBalanceErrors: '忽略余额不足错误',
|
||||
ignoreInsufficientBalanceErrorsHint: '启用后,账号余额不足(Insufficient balance)的错误将不会写入错误日志。',
|
||||
autoRefresh: '自动刷新',
|
||||
enableAutoRefresh: '启用自动刷新',
|
||||
enableAutoRefreshHint: '自动刷新仪表板数据,启用后会定期拉取最新数据。',
|
||||
refreshInterval: '刷新间隔',
|
||||
refreshInterval15s: '15 秒',
|
||||
refreshInterval30s: '30 秒',
|
||||
refreshInterval60s: '60 秒',
|
||||
dashboardCards: '仪表盘卡片',
|
||||
displayAlertEvents: '展示告警事件',
|
||||
displayAlertEventsHint: '控制运维监控仪表盘中告警事件卡片是否显示,默认开启。',
|
||||
displayOpenAITokenStats: '展示 OpenAI Token 请求统计',
|
||||
displayOpenAITokenStatsHint: '控制运维监控仪表盘中 OpenAI Token 请求统计卡片是否显示,默认关闭。',
|
||||
autoRefreshCountdown: '自动刷新:{seconds}s',
|
||||
validation: {
|
||||
title: '请先修正以下问题',
|
||||
retentionDaysRange: '保留天数必须在 0-365 天之间(0 = 每次清理时清空所有)',
|
||||
slaMinPercentRange: 'SLA最低百分比必须在0-100之间',
|
||||
ttftP99MaxRange: 'TTFT P99最大值必须大于等于0',
|
||||
requestErrorRateMaxRange: '请求错误率最大值必须在0-100之间',
|
||||
upstreamErrorRateMaxRange: '上游错误率最大值必须在0-100之间',
|
||||
openaiQuotaAutoPauseRange: 'OpenAI 配额自动暂停阈值必须在 0-100 之间'
|
||||
}
|
||||
},
|
||||
concurrency: {
|
||||
title: '并发 / 排队',
|
||||
byPlatform: '按平台',
|
||||
byGroup: '按分组',
|
||||
byAccount: '按账号',
|
||||
byUser: '按用户',
|
||||
showByUserTooltip: '切换用户视图,显示每个用户的并发使用情况',
|
||||
switchToUser: '切换到用户视图',
|
||||
switchToPlatform: '切换回平台视图',
|
||||
totalRows: '共 {count} 项',
|
||||
disabledHint: '已在设置中关闭实时监控。',
|
||||
empty: '暂无数据',
|
||||
queued: '队列 {count}',
|
||||
rateLimited: '限流 {count}',
|
||||
errorAccounts: '异常 {count}',
|
||||
loadFailed: '加载并发数据失败'
|
||||
},
|
||||
realtime: {
|
||||
title: '实时信息',
|
||||
connected: '实时已连接',
|
||||
connecting: '实时连接中',
|
||||
reconnecting: '实时重连中',
|
||||
offline: '实时离线',
|
||||
closed: '实时已关闭',
|
||||
reconnectIn: '重连 {seconds}s'
|
||||
},
|
||||
queryMode: {
|
||||
auto: 'Auto(自动)',
|
||||
raw: 'Raw(不聚合)',
|
||||
preagg: 'Preagg(聚合)'
|
||||
},
|
||||
accountAvailability: {
|
||||
available: '可用',
|
||||
unavailable: '不可用',
|
||||
accountError: '异常'
|
||||
},
|
||||
tooltips: {
|
||||
totalRequests: '当前时间窗口内的总请求数和Token消耗量。',
|
||||
throughputTrend: '当前窗口内的请求/QPS 与 token/TPS 趋势。',
|
||||
switchRateTrend: '近5小时内账号切换次数 / 请求总数的趋势(平均切换次数)。',
|
||||
latencyHistogram: '成功请求的请求时长分布(毫秒)。',
|
||||
errorTrend: '错误趋势(SLA 口径排除业务限制;上游错误率排除 429/529)。',
|
||||
errorDistribution: '按状态码统计的错误分布(SLA 口径,排除业务限制)。',
|
||||
upstreamErrors: '上游服务返回的错误,包括API提供商的错误响应(排除429/529限流错误)。',
|
||||
goroutines:
|
||||
'Go 运行时的协程数量(轻量级线程)。没有绝对"安全值",建议以历史基线为准。经验参考:<2000 常见;2000-8000 需关注;>8000 且伴随队列上升时,优先排查阻塞/泄漏。',
|
||||
cpu: 'CPU 使用率,显示系统处理器的负载情况。',
|
||||
memory: '内存使用率,包括已使用和总可用内存。',
|
||||
db: '数据库连接池状态,包括活跃连接、空闲连接和等待连接数。',
|
||||
redis: 'Redis 连接池状态,显示活跃和空闲的连接数。',
|
||||
jobs: '后台任务执行状态,包括最近运行时间、成功时间和错误信息。',
|
||||
qps: '每秒查询数(QPS)和每秒Token数(TPS),实时显示系统吞吐量。',
|
||||
tokens: '当前时间窗口内处理的总Token数量。',
|
||||
sla: '服务等级协议达成率,排除业务限制(如余额不足、配额超限)的成功请求占比。',
|
||||
errors: '错误统计,包括总错误数、错误率和上游错误率。',
|
||||
latency: '请求时长统计,包括 p50、p90、p95、p99 等百分位数。',
|
||||
ttft: '首 Token 延迟(Time To First Token),衡量流式响应的首 Token 返回速度。',
|
||||
health: '系统健康评分(0-100),综合考虑 SLA、错误率和资源使用情况。'
|
||||
},
|
||||
charts: {
|
||||
emptyRequest: '该时间窗口内暂无请求。',
|
||||
emptyError: '该时间窗口内暂无错误。',
|
||||
resetZoom: '重置',
|
||||
resetZoomHint: '重置缩放(若启用)',
|
||||
downloadChart: '下载',
|
||||
downloadChartHint: '下载图表图片'
|
||||
}
|
||||
},
|
||||
|
||||
// Settings
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,581 @@
|
||||
export default {
|
||||
scheduledTests: {
|
||||
title: '定时测试',
|
||||
addPlan: '添加计划',
|
||||
editPlan: '编辑计划',
|
||||
deletePlan: '删除计划',
|
||||
model: '模型',
|
||||
cronExpression: 'Cron 表达式',
|
||||
enabled: '启用',
|
||||
lastRun: '上次运行',
|
||||
nextRun: '下次运行',
|
||||
maxResults: '最大结果数',
|
||||
noPlans: '暂无定时测试计划',
|
||||
confirmDelete: '确定要删除此计划吗?',
|
||||
createSuccess: '计划创建成功',
|
||||
updateSuccess: '计划更新成功',
|
||||
deleteSuccess: '计划删除成功',
|
||||
results: '测试结果',
|
||||
noResults: '暂无测试结果',
|
||||
responseText: '响应',
|
||||
errorMessage: '错误',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
running: '运行中',
|
||||
schedule: '定时测试',
|
||||
cronHelp: '标准 5 字段 cron 表达式(例如 */30 * * * *)',
|
||||
cronTooltipTitle: 'Cron 表达式示例:',
|
||||
cronTooltipMeaning: '用于定义自动执行测试的时间规则,格式依次为:分钟 小时 日 月 星期。',
|
||||
cronTooltipExampleEvery30Min: '*/30 * * * *:每 30 分钟运行一次',
|
||||
cronTooltipExampleHourly: '0 * * * *:每小时整点运行一次',
|
||||
cronTooltipExampleDaily: '0 9 * * *:每天 09:00 运行一次',
|
||||
cronTooltipExampleWeekly: '0 9 * * 1:每周一 09:00 运行一次',
|
||||
cronTooltipRange: '推荐填写范围:使用标准 5 字段 cron;如果只是健康检查,建议从每 30 分钟、每 1 小时或每天固定时间开始,不建议一开始就设置过高频率。',
|
||||
maxResultsTooltipTitle: '最大结果数说明:',
|
||||
maxResultsTooltipMeaning: '用于限制单个计划最多保留多少条历史测试结果,避免结果列表无限增长。',
|
||||
maxResultsTooltipBody: '系统只会保留最近的测试结果;当保存数量超过这个值时,更早的历史记录会自动清理,避免列表过长和存储持续增长。',
|
||||
maxResultsTooltipExample: '例如填写 100,表示最多保存最近 100 次测试结果;第 101 次结果写入后,最早的一条会被清理。',
|
||||
maxResultsTooltipRange: '推荐填写范围:一般可填 20 到 200。只关注近期可用性时可填 20-50;需要回看较长时间的波动趋势时可填 100-200。',
|
||||
autoRecover: '自动恢复',
|
||||
autoRecoverHelp: '测试成功后自动恢复异常状态的账号'
|
||||
},
|
||||
|
||||
// Proxies Management
|
||||
proxies: {
|
||||
title: 'IP管理',
|
||||
description: '管理代理服务器配置',
|
||||
createProxy: '添加代理',
|
||||
editProxy: '编辑代理',
|
||||
deleteProxy: '删除代理',
|
||||
ad: {
|
||||
inline: '正在寻找合适的代理 IP?'
|
||||
},
|
||||
deleteConfirmMessage: "确定要删除代理 '{name}' 吗?",
|
||||
testProxy: '测试代理',
|
||||
dataImport: '导入',
|
||||
dataExportSelected: '导出选中',
|
||||
dataImportTitle: '导入代理',
|
||||
dataImportHint: '上传代理导出的 JSON 文件以批量导入代理。',
|
||||
dataImportWarning: '导入将创建或复用代理,保留状态并在完成后自动触发延迟检测。',
|
||||
dataImportFile: '数据文件',
|
||||
dataImportButton: '开始导入',
|
||||
dataImporting: '导入中...',
|
||||
dataImportSelectFile: '请选择数据文件',
|
||||
dataImportParseFailed: '数据解析失败',
|
||||
dataImportFailed: '数据导入失败',
|
||||
dataImportResult: '导入结果',
|
||||
dataImportResultSummary: '创建 {proxy_created},复用 {proxy_reused},失败 {proxy_failed}',
|
||||
dataImportErrors: '失败详情',
|
||||
dataImportSuccess: '导入完成:创建 {proxy_created},复用 {proxy_reused}',
|
||||
dataImportCompletedWithErrors: '导入完成但有错误:失败 {proxy_failed}',
|
||||
dataExport: '导出',
|
||||
dataExportConfirmMessage: '导出的数据包含代理的敏感信息,请妥善保存。',
|
||||
dataExportConfirm: '确认导出',
|
||||
dataExported: '数据导出成功',
|
||||
dataExportFailed: '数据导出失败',
|
||||
columns: {
|
||||
name: '名称',
|
||||
protocol: '协议',
|
||||
address: '地址',
|
||||
auth: '认证',
|
||||
location: '地理位置',
|
||||
status: '状态',
|
||||
accounts: '账号数',
|
||||
latency: '延迟',
|
||||
expiry: '有效期',
|
||||
createdAt: '创建时间',
|
||||
actions: '操作',
|
||||
nameLabel: '名称',
|
||||
namePlaceholder: '请输入代理名称',
|
||||
protocolLabel: '协议',
|
||||
selectProtocol: '选择协议',
|
||||
hostLabel: '主机',
|
||||
hostPlaceholder: '请输入主机地址',
|
||||
portLabel: '端口',
|
||||
portPlaceholder: '请输入端口',
|
||||
usernameLabel: '用户名(可选)',
|
||||
usernamePlaceholder: '请输入用户名',
|
||||
passwordLabel: '密码(可选)',
|
||||
passwordPlaceholder: '请输入密码',
|
||||
priorityLabel: '优先级',
|
||||
statusLabel: '状态'
|
||||
},
|
||||
filters: {
|
||||
protocol: '协议',
|
||||
allProtocols: '全部协议',
|
||||
status: '状态',
|
||||
allStatuses: '全部状态'
|
||||
},
|
||||
// Additional keys used in ProxiesView
|
||||
copyProxyUrl: '复制代理 URL',
|
||||
urlCopied: '代理 URL 已复制',
|
||||
allProtocols: '全部协议',
|
||||
allStatus: '全部状态',
|
||||
searchProxies: '搜索代理...',
|
||||
protocols: {
|
||||
http: 'HTTP',
|
||||
https: 'HTTPS',
|
||||
socks5: 'SOCKS5',
|
||||
socks5h: 'SOCKS5H (远程 DNS)',
|
||||
},
|
||||
name: '名称',
|
||||
protocol: '协议',
|
||||
host: '主机',
|
||||
port: '端口',
|
||||
username: '用户名(可选)',
|
||||
password: '密码(可选)',
|
||||
status: '状态',
|
||||
enterProxyName: '请输入代理名称',
|
||||
optionalAuth: '可选认证信息',
|
||||
leaveEmptyToKeep: '留空保持不变',
|
||||
form: {
|
||||
hostPlaceholder: '请输入主机地址',
|
||||
portPlaceholder: '请输入端口'
|
||||
},
|
||||
noProxiesYet: '暂无代理',
|
||||
createFirstProxy: '添加您的第一个代理以开始使用。',
|
||||
testConnection: '测试连接',
|
||||
qualityCheck: '质量检测',
|
||||
batchQualityCheck: '批量质量检测',
|
||||
batchTest: '批量测试',
|
||||
testFailed: '失败',
|
||||
latencyFailed: '链接失败',
|
||||
batchTestEmpty: '暂无可测试的代理',
|
||||
batchTestDone: '批量测试完成,共测试 {count} 个代理',
|
||||
batchTestFailed: '批量测试失败',
|
||||
batchDeleteAction: '删除',
|
||||
batchDelete: '批量删除',
|
||||
batchDeleteConfirm: '确定删除选中的 {count} 个代理吗?已被账号使用的将自动跳过。',
|
||||
batchDeleteDone: '已删除 {deleted} 个代理,跳过 {skipped} 个',
|
||||
batchDeleteSkipped: '已跳过 {skipped} 个代理',
|
||||
batchDeleteFailed: '批量删除失败',
|
||||
deleteBlockedInUse: '该代理已有账号使用,无法删除',
|
||||
accountsTitle: '使用该IP的账号',
|
||||
accountsEmpty: '暂无账号使用此代理',
|
||||
accountsFailed: '获取账号列表失败',
|
||||
accountName: '账号名称',
|
||||
accountPlatform: '所属平台',
|
||||
accountNotes: '备注',
|
||||
// Batch import
|
||||
standardAdd: '标准添加',
|
||||
batchAdd: '快捷添加',
|
||||
batchInput: '代理列表',
|
||||
batchInputPlaceholder:
|
||||
"每行输入一个代理,支持以下格式:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443",
|
||||
batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码{'@'}]主机:端口",
|
||||
parsedCount: '有效 {count} 个',
|
||||
invalidCount: '无效 {count} 个',
|
||||
duplicateCount: '重复 {count} 个',
|
||||
importing: '导入中...',
|
||||
importProxies: '导入 {count} 个代理',
|
||||
batchImportSuccess: '成功导入 {created} 个代理,跳过 {skipped} 个重复',
|
||||
batchImportAllSkipped: '全部 {skipped} 个代理已存在,跳过导入',
|
||||
failedToImport: '批量导入失败',
|
||||
// Other messages
|
||||
saving: '保存中...',
|
||||
testing: '测试中...',
|
||||
creating: '创建中...',
|
||||
updating: '更新中...',
|
||||
noProxies: '暂无代理',
|
||||
noProxiesDescription: '添加代理服务器以增强 API 访问稳定性。',
|
||||
proxyCreated: '代理添加成功',
|
||||
proxyUpdated: '代理更新成功',
|
||||
proxyDeleted: '代理删除成功',
|
||||
proxyWorking: '代理连接正常',
|
||||
proxyWorkingWithLatency: '代理连接正常,延迟 {latency}ms',
|
||||
proxyTestFailed: '代理测试失败',
|
||||
qualityCheckDone: '质量检测完成:评分 {score}({grade})',
|
||||
qualityCheckFailed: '代理质量检测失败',
|
||||
batchQualityDone: '批量质量检测完成,共检测 {count} 个;优质 {healthy} 个,告警 {warn} 个,挑战 {challenge} 个,异常 {failed} 个',
|
||||
batchQualityFailed: '批量质量检测失败',
|
||||
batchQualityEmpty: '暂无可检测质量的代理',
|
||||
qualityReportTitle: '代理质量检测报告',
|
||||
qualityGrade: '等级 {grade}',
|
||||
qualityExitIP: '出口 IP',
|
||||
qualityCountry: '出口地区',
|
||||
qualityBaseLatency: '基础延迟',
|
||||
qualityCheckedAt: '检测时间',
|
||||
qualityTableTarget: '检测项',
|
||||
qualityTableStatus: '状态',
|
||||
qualityTableLatency: '延迟',
|
||||
qualityTableMessage: '说明',
|
||||
qualityInline: '质量 {grade}/{score}',
|
||||
qualityStatusHealthy: '优质',
|
||||
qualityStatusPass: '通过',
|
||||
qualityStatusWarn: '告警',
|
||||
qualityStatusFail: '失败',
|
||||
qualityStatusChallenge: '挑战',
|
||||
qualityTargetBase: '基础连通性',
|
||||
proxyCreatedSuccess: '代理添加成功',
|
||||
proxyUpdatedSuccess: '代理更新成功',
|
||||
proxyDeletedSuccess: '代理删除成功',
|
||||
testSuccess: '代理测试通过',
|
||||
failedToLoad: '加载代理列表失败',
|
||||
failedToSave: '保存代理失败',
|
||||
failedToDelete: '删除代理失败',
|
||||
failedToCreate: '创建代理失败',
|
||||
failedToUpdate: '更新代理失败',
|
||||
failedToTest: '测试代理失败',
|
||||
nameRequired: '请输入代理名称',
|
||||
hostRequired: '请输入主机地址',
|
||||
portInvalid: '端口必须在 1-65535 之间',
|
||||
deleteConfirm: "确定要删除代理 '{name}' 吗?使用此代理的账号将被移除代理设置。",
|
||||
neverExpires: '永不过期',
|
||||
expired: '已过期',
|
||||
overdueDays: '已超期 {days} 天',
|
||||
expiringInDays: '{days} 天后到期',
|
||||
remainingDays: '剩余 {days} 天',
|
||||
expiresAt: '有效期',
|
||||
nDays: '{days} 天',
|
||||
expiryDaysPlaceholder: '自定义天数,留空 = 永不过期',
|
||||
expiryWarnDays: '到期提醒提前天数',
|
||||
fallbackMode: '失败回退',
|
||||
fallbackNone: '不回退',
|
||||
fallbackProxy: '指定备用代理',
|
||||
fallbackDirect: '回退直连',
|
||||
backupProxy: '备用代理',
|
||||
},
|
||||
|
||||
// Redeem Codes Management
|
||||
redeem: {
|
||||
title: '兑换码管理',
|
||||
description: '生成和管理兑换码',
|
||||
generateCodes: '生成兑换码',
|
||||
columns: {
|
||||
code: '兑换码',
|
||||
type: '类型',
|
||||
value: '面值',
|
||||
status: '状态',
|
||||
usedBy: '使用者',
|
||||
usedAt: '使用时间',
|
||||
expiresAt: '过期时间',
|
||||
createdAt: '创建时间',
|
||||
actions: '操作'
|
||||
},
|
||||
types: {
|
||||
balance: '余额',
|
||||
concurrency: '并发数',
|
||||
subscription: '订阅',
|
||||
invitation: '邀请码',
|
||||
// 管理员在用户管理页面调整余额/并发时产生的记录
|
||||
admin_balance: '余额(管理员)',
|
||||
admin_concurrency: '并发数(管理员)'
|
||||
},
|
||||
// 用于选择器和筛选器的直接键
|
||||
balance: '余额',
|
||||
concurrency: '并发数',
|
||||
subscription: '订阅',
|
||||
invitation: '邀请码',
|
||||
invitationHint: '邀请码用于限制用户注册,使用后自动标记为已使用。',
|
||||
allTypes: '全部类型',
|
||||
allStatus: '全部状态',
|
||||
unused: '未使用',
|
||||
used: '已使用',
|
||||
searchCodes: '搜索兑换码或邮箱...',
|
||||
exportCsv: '导出 CSV',
|
||||
batchUpdate: '批量修改',
|
||||
batchUpdateTitle: '批量修改兑换码',
|
||||
selectedCount: '已选择 {count} 个兑换码',
|
||||
clearSelection: '清空选择',
|
||||
selectCodesFirst: '请先选择兑换码',
|
||||
noBatchFieldsSelected: '请至少勾选一个要修改的字段',
|
||||
batchUpdateSuccess: '成功修改 {count} 个兑换码',
|
||||
failedToBatchUpdate: '批量修改兑换码失败',
|
||||
batchFields: {
|
||||
status: '状态',
|
||||
expiresAt: '过期时间',
|
||||
notes: '备注',
|
||||
group: '分组'
|
||||
},
|
||||
batchNotesPlaceholder: '输入新的备注,留空可清空备注',
|
||||
clearGroup: '清空分组',
|
||||
deleteAllUnused: '删除全部未使用',
|
||||
deleteCodeConfirm: '确定要删除此兑换码吗?此操作无法撤销。',
|
||||
deleteAllUnusedConfirm: '确定要删除全部未使用的兑换码吗?此操作无法撤销。',
|
||||
deleteAll: '全部删除',
|
||||
generateCodesTitle: '生成兑换码',
|
||||
generatedSuccessfully: '生成成功',
|
||||
codesCreated: '已创建 {count} 个兑换码',
|
||||
codeType: '类型',
|
||||
amount: '金额 ($)',
|
||||
value: '面值',
|
||||
count: '数量',
|
||||
generate: '生成',
|
||||
copyAll: '全部复制',
|
||||
download: '下载',
|
||||
codesExported: '兑换码导出成功',
|
||||
codeDeleted: '兑换码删除成功',
|
||||
codesDeleted: '成功删除 {count} 个未使用的兑换码',
|
||||
noUnusedCodes: '没有未使用的兑换码可删除',
|
||||
userPrefix: '用户 #{id}',
|
||||
failedToExport: '导出兑换码失败',
|
||||
failedToDeleteUnused: '删除未使用的兑换码失败',
|
||||
failedToCopy: '复制失败',
|
||||
selectGroup: '选择分组',
|
||||
selectGroupPlaceholder: '选择订阅分组',
|
||||
validityDays: '有效天数',
|
||||
codeExpiry: '兑换码过期',
|
||||
neverExpires: '永不过期',
|
||||
expiryPresetDays: '{days} 天',
|
||||
customExpiry: '自定义',
|
||||
customExpiryDays: '自定义天数',
|
||||
expiryDaysRequired: '请输入有效的过期天数',
|
||||
groupRequired: '请选择订阅分组',
|
||||
days: '天',
|
||||
status: {
|
||||
unused: '未使用',
|
||||
used: '已使用',
|
||||
expired: '已过期',
|
||||
disabled: '已禁用'
|
||||
},
|
||||
form: {
|
||||
typeLabel: '类型',
|
||||
selectType: '选择类型',
|
||||
valueLabel: '面值',
|
||||
valuePlaceholder: '请输入面值',
|
||||
balanceHint: '余额金额(美元)',
|
||||
concurrencyHint: '并发数增量',
|
||||
countLabel: '数量',
|
||||
countPlaceholder: '请输入数量',
|
||||
countHint: '要生成的兑换码数量',
|
||||
prefixLabel: '前缀(可选)',
|
||||
prefixPlaceholder: '例如:GIFT',
|
||||
expiresLabel: '过期时间(可选)'
|
||||
},
|
||||
filters: {
|
||||
type: '类型',
|
||||
allTypes: '全部类型',
|
||||
status: '状态',
|
||||
allStatuses: '全部状态',
|
||||
search: '搜索兑换码'
|
||||
},
|
||||
generating: '生成中...',
|
||||
copyCode: '复制',
|
||||
copied: '已复制!',
|
||||
disableCode: '禁用',
|
||||
enableCode: '启用',
|
||||
deleteCode: '删除',
|
||||
deleteConfirmMessage: '确定要删除此兑换码吗?',
|
||||
noCodes: '暂无兑换码',
|
||||
noCodesDescription: '生成兑换码以向用户分发余额或并发数。',
|
||||
codesGeneratedSuccess: '兑换码生成成功,共 {count} 个',
|
||||
codeDisabledSuccess: '兑换码已禁用',
|
||||
codeEnabledSuccess: '兑换码已启用',
|
||||
codeDeletedSuccess: '兑换码删除成功',
|
||||
failedToLoad: '加载兑换码列表失败',
|
||||
failedToGenerate: '生成兑换码失败',
|
||||
failedToUpdate: '更新兑换码失败',
|
||||
failedToDelete: '删除兑换码失败'
|
||||
},
|
||||
|
||||
// Announcements
|
||||
announcements: {
|
||||
title: '公告管理',
|
||||
description: '创建公告并按条件投放',
|
||||
createAnnouncement: '创建公告',
|
||||
editAnnouncement: '编辑公告',
|
||||
deleteAnnouncement: '删除公告',
|
||||
searchAnnouncements: '搜索公告...',
|
||||
status: '状态',
|
||||
allStatus: '全部状态',
|
||||
columns: {
|
||||
title: '标题',
|
||||
status: '状态',
|
||||
notifyMode: '通知方式',
|
||||
targeting: '展示条件',
|
||||
timeRange: '有效期',
|
||||
createdAt: '创建时间',
|
||||
actions: '操作'
|
||||
},
|
||||
statusLabels: {
|
||||
draft: '草稿',
|
||||
active: '展示中',
|
||||
archived: '已归档'
|
||||
},
|
||||
notifyModeLabels: {
|
||||
silent: '静默',
|
||||
popup: '弹窗'
|
||||
},
|
||||
form: {
|
||||
title: '标题',
|
||||
content: '内容(支持 Markdown)',
|
||||
status: '状态',
|
||||
notifyMode: '通知方式',
|
||||
notifyModeHint: '弹窗模式会自动弹出通知给用户',
|
||||
startsAt: '开始时间',
|
||||
endsAt: '结束时间',
|
||||
startsAtHint: '留空表示立即生效',
|
||||
endsAtHint: '留空表示永久生效',
|
||||
targetingMode: '展示条件',
|
||||
targetingAll: '所有用户',
|
||||
targetingCustom: '按条件',
|
||||
addOrGroup: '添加 OR 条件组',
|
||||
addAndCondition: '添加 AND 条件',
|
||||
conditionType: '条件类型',
|
||||
conditionSubscription: '订阅套餐',
|
||||
conditionBalance: '余额',
|
||||
operator: '运算符',
|
||||
balanceValue: '余额阈值',
|
||||
selectPackages: '选择套餐'
|
||||
},
|
||||
operators: {
|
||||
gt: '>',
|
||||
gte: '≥',
|
||||
lt: '<',
|
||||
lte: '≤',
|
||||
eq: '='
|
||||
},
|
||||
targetingSummaryAll: '全部用户',
|
||||
targetingSummaryCustom: '自定义({groups} 组)',
|
||||
timeImmediate: '立即',
|
||||
timeNever: '永久',
|
||||
readStatus: '已读情况',
|
||||
eligible: '符合条件',
|
||||
readAt: '已读时间',
|
||||
unread: '未读',
|
||||
searchUsers: '搜索用户...',
|
||||
failedToLoad: '加载公告失败',
|
||||
failedToCreate: '创建公告失败',
|
||||
failedToUpdate: '更新公告失败',
|
||||
failedToDelete: '删除公告失败',
|
||||
failedToLoadReadStatus: '加载已读情况失败',
|
||||
deleteConfirm: '确定要删除该公告吗?此操作无法撤销。'
|
||||
},
|
||||
|
||||
// Promo Codes
|
||||
promo: {
|
||||
title: '优惠码管理',
|
||||
description: '创建和管理注册优惠码',
|
||||
createCode: '创建优惠码',
|
||||
editCode: '编辑优惠码',
|
||||
deleteCode: '删除优惠码',
|
||||
searchCodes: '搜索优惠码...',
|
||||
allStatus: '全部状态',
|
||||
columns: {
|
||||
code: '优惠码',
|
||||
bonusAmount: '赠送金额',
|
||||
maxUses: '最大使用次数',
|
||||
usedCount: '已使用',
|
||||
usage: '使用量',
|
||||
status: '状态',
|
||||
expiresAt: '过期时间',
|
||||
createdAt: '创建时间',
|
||||
actions: '操作'
|
||||
},
|
||||
// 表单标签(扁平结构便于模板使用)
|
||||
code: '优惠码',
|
||||
autoGenerate: '留空自动生成',
|
||||
codePlaceholder: '输入优惠码或留空',
|
||||
bonusAmount: '赠送金额 ($)',
|
||||
maxUses: '最大使用次数',
|
||||
zeroUnlimited: '0 = 无限制',
|
||||
expiresAt: '过期时间',
|
||||
notes: '备注',
|
||||
notesPlaceholder: '可选备注信息',
|
||||
status: '状态',
|
||||
neverExpires: '永不过期',
|
||||
// 状态标签
|
||||
statusActive: '启用',
|
||||
statusDisabled: '禁用',
|
||||
statusExpired: '已过期',
|
||||
statusMaxUsed: '已用完',
|
||||
// 使用记录
|
||||
usageRecords: '使用记录',
|
||||
viewUsages: '查看使用记录',
|
||||
noUsages: '暂无使用记录',
|
||||
userPrefix: '用户 #{id}',
|
||||
copied: '已复制!',
|
||||
// 消息
|
||||
noCodesYet: '暂无优惠码',
|
||||
createFirstCode: '创建您的第一个优惠码,为新用户提供注册奖励。',
|
||||
codeCreated: '优惠码创建成功',
|
||||
codeUpdated: '优惠码更新成功',
|
||||
codeDeleted: '优惠码删除成功',
|
||||
deleteCodeConfirm: '确定要删除此优惠码吗?此操作无法撤销。',
|
||||
copyRegisterLink: '复制注册链接',
|
||||
registerLinkCopied: '注册链接已复制到剪贴板',
|
||||
failedToLoad: '加载优惠码失败',
|
||||
failedToCreate: '创建优惠码失败',
|
||||
failedToUpdate: '更新优惠码失败',
|
||||
failedToDelete: '删除优惠码失败',
|
||||
failedToLoadUsages: '加载使用记录失败'
|
||||
},
|
||||
|
||||
// Usage Records
|
||||
usage: {
|
||||
title: '使用记录',
|
||||
description: '查看和管理所有用户的使用记录',
|
||||
userFilter: '用户',
|
||||
searchUserPlaceholder: '按邮箱搜索用户...',
|
||||
searchApiKeyPlaceholder: '按名称搜索 API 密钥...',
|
||||
searchAccountPlaceholder: '按名称搜索账号...',
|
||||
selectedUser: '已选择',
|
||||
user: '用户',
|
||||
account: '账户',
|
||||
group: '分组',
|
||||
requestId: '请求ID',
|
||||
requestIdCopied: '请求ID已复制',
|
||||
allModels: '全部模型',
|
||||
allAccounts: '全部账户',
|
||||
allGroups: '全部分组',
|
||||
allTypes: '全部类型',
|
||||
inputCost: '输入费用',
|
||||
outputCost: '输出费用',
|
||||
cacheCreationCost: '缓存创建费用',
|
||||
cacheReadCost: '缓存读取费用',
|
||||
inputTokens: '输入 Token',
|
||||
outputTokens: '输出 Token',
|
||||
cacheCreationTokens: '缓存创建 Token',
|
||||
cacheCreation5mTokens: '缓存创建',
|
||||
cacheCreation1hTokens: '缓存创建',
|
||||
cacheReadTokens: '缓存读取 Token',
|
||||
failedToLoad: '加载使用记录失败',
|
||||
billingType: '计费类型',
|
||||
allBillingTypes: '全部计费类型',
|
||||
billingTypeBalance: '钱包余额',
|
||||
billingTypeSubscription: '订阅套餐',
|
||||
billingMode: '计费模式',
|
||||
billingModeToken: '按量',
|
||||
billingModePerRequest: '按次',
|
||||
billingModeImage: '按次(图片)',
|
||||
allBillingModes: '全部计费模式',
|
||||
ipAddress: 'IP',
|
||||
clickToViewBalance: '点击查看充值记录',
|
||||
failedToLoadUser: '加载用户信息失败',
|
||||
userDeletedBadge: '已删除',
|
||||
cleanup: {
|
||||
button: '清理',
|
||||
title: '清理使用记录',
|
||||
warning: '清理不可恢复,且会影响历史统计回看。',
|
||||
submit: '提交清理',
|
||||
submitting: '提交中...',
|
||||
confirmTitle: '确认清理',
|
||||
confirmMessage: '确定要提交清理任务吗?清理不可恢复。',
|
||||
confirmSubmit: '确认清理',
|
||||
cancel: '取消任务',
|
||||
cancelConfirmTitle: '确认取消',
|
||||
cancelConfirmMessage: '确定要取消该清理任务吗?',
|
||||
cancelConfirm: '确认取消',
|
||||
cancelSuccess: '清理任务已取消',
|
||||
cancelFailed: '取消清理任务失败',
|
||||
recentTasks: '最近清理任务',
|
||||
loadingTasks: '正在加载任务...',
|
||||
noTasks: '暂无清理任务',
|
||||
range: '时间范围',
|
||||
deletedRows: '删除数量',
|
||||
missingRange: '请选择时间范围',
|
||||
submitSuccess: '清理任务已创建',
|
||||
submitFailed: '创建清理任务失败',
|
||||
loadFailed: '加载清理任务失败',
|
||||
status: {
|
||||
pending: '待执行',
|
||||
running: '执行中',
|
||||
succeeded: '已完成',
|
||||
failed: '失败',
|
||||
canceled: '已取消'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Ops Monitoring
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,416 @@
|
||||
export default {
|
||||
common: {
|
||||
loading: '加载中...',
|
||||
submitting: '提交中...',
|
||||
justNow: '刚刚',
|
||||
peakRateTooltip: '高峰倍率:{window}',
|
||||
peakRateImageNote: ';token 计费的图片 token 同样适用,图片按次计费不受高峰影响',
|
||||
save: '保存',
|
||||
saved: '保存成功',
|
||||
deleted: '删除成功',
|
||||
cancel: '取消',
|
||||
delete: '删除',
|
||||
edit: '编辑',
|
||||
create: '创建',
|
||||
update: '更新',
|
||||
confirm: '确认',
|
||||
reset: '重置',
|
||||
search: '搜索',
|
||||
filter: '筛选',
|
||||
export: '导出',
|
||||
import: '导入',
|
||||
actions: '操作',
|
||||
status: '状态',
|
||||
name: '名称',
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
submit: '提交',
|
||||
back: '返回',
|
||||
next: '下一步',
|
||||
yes: '是',
|
||||
no: '否',
|
||||
all: '全部',
|
||||
none: '无',
|
||||
selectAll: '全选',
|
||||
noData: '暂无数据',
|
||||
expand: '展开',
|
||||
collapse: '收起',
|
||||
success: '成功',
|
||||
error: '错误',
|
||||
critical: '严重',
|
||||
warning: '警告',
|
||||
info: '提示',
|
||||
active: '启用',
|
||||
inactive: '禁用',
|
||||
more: '更多',
|
||||
close: '关闭',
|
||||
enabled: '已启用',
|
||||
disabled: '已禁用',
|
||||
total: '总计',
|
||||
balance: '余额',
|
||||
availableBalance: '可用余额',
|
||||
frozenBalance: '冻结金额',
|
||||
totalBalance: '总余额',
|
||||
available: '可用',
|
||||
copiedToClipboard: '已复制到剪贴板',
|
||||
copied: '已复制',
|
||||
copyFailed: '复制失败',
|
||||
verifying: '验证中...',
|
||||
processing: '处理中...',
|
||||
contactSupport: '联系客服',
|
||||
add: '添加',
|
||||
invalidEmail: '请输入有效的邮箱地址',
|
||||
optional: '可选',
|
||||
selectOption: '请选择',
|
||||
searchPlaceholder: '搜索...',
|
||||
noOptionsFound: '无匹配选项',
|
||||
noGroupsAvailable: '无可用分组',
|
||||
unknownError: '发生未知错误',
|
||||
saving: '保存中...',
|
||||
selectedCount: '(已选 {count} 个)',
|
||||
refresh: '刷新',
|
||||
autoRefresh: {
|
||||
title: '自动刷新',
|
||||
enable: '启用自动刷新',
|
||||
countdown: '自动刷新: {seconds}s',
|
||||
seconds: '{n} 秒',
|
||||
},
|
||||
view: '查看',
|
||||
settings: '设置',
|
||||
chooseFile: '选择文件',
|
||||
copy: '复制',
|
||||
notAvailable: '不可用',
|
||||
now: '现在',
|
||||
today: '今天',
|
||||
tomorrow: '明天',
|
||||
unknown: '未知',
|
||||
minutes: '分钟',
|
||||
time: {
|
||||
never: '从未',
|
||||
justNow: '刚刚',
|
||||
minutesAgo: '{n}分钟前',
|
||||
hoursAgo: '{n}小时前',
|
||||
daysAgo: '{n}天前',
|
||||
countdown: {
|
||||
daysHours: '{d}d {h}h',
|
||||
hoursMinutes: '{h}h {m}m',
|
||||
minutes: '{m}m',
|
||||
withSuffix: '{time} 后解除'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
adminCompliance: {
|
||||
title: '部署与运营合规确认',
|
||||
blockingNotice: '继续使用控制台前,须完成部署与运营合规确认。',
|
||||
riskNotice: '本确认用于以清晰、显著、可留痕的方式提示自部署实例的合规义务与运营风险。',
|
||||
version: '协议版本',
|
||||
openDocument: '在 GitHub 查看协议文件',
|
||||
documentSource: '协议正文来自本项目仓库中的 Markdown 文件。修改协议内容时必须同步递增协议版本;已确认的旧版本将失效,控制台使用者须重新确认。',
|
||||
inputLabel: '请逐字输入以下确认短语',
|
||||
inputPlaceholder: '输入确认短语以继续',
|
||||
inputMismatch: '确认短语不匹配,请逐字输入提示内容。',
|
||||
legalNote: '本确认用于明确自部署实例与开源项目、著作权人、贡献者及维护者之间的非关联关系和责任边界;部署、运营或控制相关实例的主体应独立承担其适用义务。',
|
||||
logout: '退出登录',
|
||||
accept: '确认并继续',
|
||||
accepted: '合规确认已记录',
|
||||
acceptFailed: '提交确认失败'
|
||||
},
|
||||
|
||||
legal: {
|
||||
loadFailed: '文档加载失败',
|
||||
retryLater: '请稍后刷新页面重试。',
|
||||
notFound: '文档不存在',
|
||||
notFoundDescription: '当前条款文档不存在或已被管理员移除。',
|
||||
updatedAt: '更新日期:{date}',
|
||||
empty: '暂无正文内容',
|
||||
loginAgreement: '登录条款',
|
||||
adminCompliance: '部署与运营合规承诺',
|
||||
loginAgreementPrompt: {
|
||||
checkboxPrefix: '我已阅读并同意',
|
||||
documentSeparator: '、',
|
||||
noticeTitle: '继续登录前需要先同意最新条款。',
|
||||
noticeDescription: '未同意前,账号密码输入和快捷登录会保持禁用。',
|
||||
viewTerms: '查看条款',
|
||||
dialogTitle: '条款更新通知',
|
||||
dialogDescription: '我们的服务条款已于 {date} 更新。在继续使用服务之前,请仔细阅读并同意以下条款。',
|
||||
recently: '近期',
|
||||
relatedDocuments: '相关文档',
|
||||
reject: '拒绝',
|
||||
accept: '同意并继续',
|
||||
loginRejectedWarning: '未同意最新条款前,无法输入账号密码或使用快捷登录。',
|
||||
loginRequiredWarning: '请先阅读并同意最新条款后再登录。',
|
||||
registerRejectedWarning: '未同意最新条款前,无法注册或使用快捷登录。',
|
||||
registerRequiredWarning: '请先阅读并同意最新条款后再注册。'
|
||||
}
|
||||
},
|
||||
|
||||
// Navigation
|
||||
nav: {
|
||||
dashboard: '仪表盘',
|
||||
announcements: '公告',
|
||||
apiKeys: 'API 密钥',
|
||||
batchImage: '批量生图',
|
||||
usage: '使用记录',
|
||||
redeem: '兑换',
|
||||
affiliate: '邀请返利',
|
||||
affiliateManagement: '邀请返利',
|
||||
affiliateInviteRecords: '邀请记录',
|
||||
affiliateRebateRecords: '返利记录',
|
||||
affiliateTransferRecords: '提取记录',
|
||||
profile: '个人资料',
|
||||
users: '用户管理',
|
||||
groups: '分组管理',
|
||||
channels: '渠道管理',
|
||||
availableChannels: '可用渠道',
|
||||
subscriptions: '订阅管理',
|
||||
accounts: '账号管理',
|
||||
proxies: 'IP管理',
|
||||
redeemCodes: '兑换码',
|
||||
ops: '运维监控',
|
||||
promoCodes: '优惠码',
|
||||
settings: '系统设置',
|
||||
myAccount: '我的账户',
|
||||
lightMode: '浅色模式',
|
||||
darkMode: '深色模式',
|
||||
collapse: '收起',
|
||||
expand: '展开',
|
||||
logout: '退出登录',
|
||||
github: 'GitHub',
|
||||
mySubscriptions: '我的订阅',
|
||||
buySubscription: '充值/订阅',
|
||||
docs: '文档',
|
||||
myOrders: '我的订单',
|
||||
orderManagement: '订单管理',
|
||||
paymentDashboard: '支付概览',
|
||||
paymentConfig: '支付配置',
|
||||
paymentPlans: '订阅套餐',
|
||||
channelManagement: '渠道管理',
|
||||
channelPricing: '渠道定价',
|
||||
channelMonitor: '渠道监控',
|
||||
channelStatus: '渠道状态',
|
||||
riskControl: '风控中心',
|
||||
},
|
||||
|
||||
// Auth
|
||||
auth: {
|
||||
welcomeBack: '欢迎回来',
|
||||
signInToAccount: '登录您的账户以继续',
|
||||
signIn: '登录',
|
||||
signingIn: '登录中...',
|
||||
createAccount: '创建账户',
|
||||
signUpToStart: '注册以开始使用 {siteName}',
|
||||
signUp: '注册',
|
||||
processing: '处理中...',
|
||||
continue: '继续',
|
||||
rememberMe: '记住我',
|
||||
dontHaveAccount: '还没有账户?',
|
||||
alreadyHaveAccount: '已有账户?',
|
||||
registrationDisabled: '注册功能暂时关闭,请联系管理员。',
|
||||
emailLabel: '邮箱',
|
||||
emailPlaceholder: '请输入邮箱',
|
||||
passwordLabel: '密码',
|
||||
passwordPlaceholder: '请输入密码',
|
||||
createPasswordPlaceholder: '创建一个安全的密码',
|
||||
passwordHint: '至少 6 个字符',
|
||||
emailRequired: '请输入邮箱',
|
||||
invalidEmail: '请输入有效的邮箱地址',
|
||||
passwordRequired: '请输入密码',
|
||||
passwordMinLength: '密码至少需要 6 个字符',
|
||||
loginFailed: '登录失败,请检查您的凭据后重试。',
|
||||
errors: {
|
||||
USER_NOT_ACTIVE: '账号已被禁用',
|
||||
},
|
||||
registrationFailed: '注册失败,请重试。',
|
||||
emailSuffixNotAllowed: '该邮箱域名不在允许注册范围内。',
|
||||
emailSuffixNotAllowedWithAllowed: '该邮箱域名不被允许。可用域名:{suffixes}',
|
||||
emailSuffixAllowedMore: '等 {count} 项',
|
||||
loginSuccess: '登录成功!欢迎回来。',
|
||||
accountCreatedSuccess: '账户创建成功!欢迎使用 {siteName}。',
|
||||
reloginRequired: '会话已过期,请重新登录。',
|
||||
turnstileExpired: '验证已过期,请重试',
|
||||
turnstileFailed: '验证失败,请重试',
|
||||
completeVerification: '请完成验证',
|
||||
verifyYourEmail: '验证您的邮箱',
|
||||
sessionExpired: '会话已过期',
|
||||
sessionExpiredDesc: '请返回注册页面重新开始。',
|
||||
verificationCode: '验证码',
|
||||
verificationCodeHint: '请输入发送到您邮箱的6位验证码',
|
||||
sendingCode: '发送中...',
|
||||
sendCode: '发送验证码',
|
||||
clickToResend: '点击重新发送验证码',
|
||||
resendCode: '重新发送验证码',
|
||||
sendCodeDesc: '我们将发送验证码到',
|
||||
codeSentSuccess: '验证码已发送!请查收您的邮箱。',
|
||||
verifying: '验证中...',
|
||||
verifyAndCreate: '验证并创建账户',
|
||||
resendCountdown: '{countdown}秒后可重新发送',
|
||||
backToRegistration: '返回注册',
|
||||
sendCodeFailed: '发送验证码失败,请重试。',
|
||||
verifyFailed: '验证失败,请重试。',
|
||||
codeRequired: '请输入验证码',
|
||||
invalidCode: '请输入有效的6位验证码',
|
||||
promoCodeLabel: '优惠码',
|
||||
promoCodePlaceholder: '输入优惠码(可选)',
|
||||
promoCodeValid: '有效!注册后将获得 ${amount} 赠送余额',
|
||||
promoCodeInvalid: '无效的优惠码',
|
||||
promoCodeNotFound: '优惠码不存在',
|
||||
promoCodeExpired: '此优惠码已过期',
|
||||
promoCodeDisabled: '此优惠码已被禁用',
|
||||
promoCodeMaxUsed: '此优惠码已达到使用上限',
|
||||
promoCodeAlreadyUsed: '您已使用过此优惠码',
|
||||
promoCodeValidating: '优惠码正在验证中,请稍候',
|
||||
promoCodeInvalidCannotRegister: '优惠码无效,请检查后重试或清空优惠码',
|
||||
invitationCodeLabel: '邀请码',
|
||||
invitationCodePlaceholder: '请输入邀请码',
|
||||
invitationCodeRequired: '请输入邀请码',
|
||||
invitationCodeValid: '邀请码有效',
|
||||
invitationCodeInvalid: '邀请码无效或已被使用',
|
||||
invitationCodeValidating: '正在验证邀请码...',
|
||||
invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试',
|
||||
oauthOrContinue: '或使用其他继续',
|
||||
linuxdo: {
|
||||
signIn: '使用 Linux.do 登录',
|
||||
orContinue: '或使用邮箱密码继续',
|
||||
callbackTitle: '正在完成登录',
|
||||
callbackProcessing: '正在验证登录信息,请稍候...',
|
||||
callbackHint: '如果页面未自动跳转,请返回登录页重试。',
|
||||
callbackMissingToken: '登录信息缺失,请返回重试。',
|
||||
backToLogin: '返回登录',
|
||||
invitationRequired: '该 Linux.do 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。',
|
||||
invalidPendingToken: '注册凭证已失效,请重新使用 Linux.do 登录。',
|
||||
completeRegistration: '完成注册',
|
||||
completing: '正在完成注册...',
|
||||
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
|
||||
},
|
||||
dingtalk: {
|
||||
signIn: '钉钉登录',
|
||||
callbackTitle: '正在完成钉钉登录',
|
||||
callbackProcessing: '正在验证钉钉登录信息,请稍候...',
|
||||
callbackHint: '如果页面未自动跳转,请返回登录页重试。',
|
||||
callbackMissingToken: '登录信息缺失,请返回重试。',
|
||||
backToLogin: '返回登录',
|
||||
invitationRequired: '该钉钉账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。',
|
||||
invalidPendingToken: '注册凭证已失效,请重新使用钉钉登录。',
|
||||
completeRegistration: '完成注册',
|
||||
completing: '正在完成注册...',
|
||||
completeRegistrationFailed: '注册失败,请检查邀请码后重试。',
|
||||
createAccountTitle: '创建钉钉账户',
|
||||
registrationDisabledRedirectToBind: '当前已禁止注册新账户,请使用已有账户邮箱和密码绑定钉钉登录',
|
||||
error: {
|
||||
title: '钉钉登录失败',
|
||||
csrf: '登录会话已过期,请重新扫码登录',
|
||||
corp_rejected: '您的钉钉账号不属于本企业,请联系管理员',
|
||||
dingtalk_not_enabled: '钉钉登录暂未启用',
|
||||
upstream_error: '钉钉服务暂时不可用,请稍后重试',
|
||||
missing_browser_session: '浏览器会话丢失,请重新登录',
|
||||
missing_params: '请求参数不完整',
|
||||
invalid_state: '登录状态异常',
|
||||
provider_error: '钉钉授权失败',
|
||||
session_error: '会话创建失败,请重试',
|
||||
retry: '重新登录'
|
||||
}
|
||||
},
|
||||
emailOAuth: {
|
||||
signIn: '使用 {providerName} 登录'
|
||||
},
|
||||
oidc: {
|
||||
signIn: '使用 {providerName} 登录',
|
||||
callbackTitle: '正在完成 {providerName} 登录',
|
||||
callbackProcessing: '正在验证 {providerName} 登录信息,请稍候...',
|
||||
callbackHint: '如果页面未自动跳转,请返回登录页重试。',
|
||||
callbackMissingToken: '登录信息缺失,请返回重试。',
|
||||
backToLogin: '返回登录',
|
||||
invitationRequired: '该 {providerName} 账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。',
|
||||
invalidPendingToken: '注册凭证已失效,请重新登录。',
|
||||
completeRegistration: '完成注册',
|
||||
completing: '正在完成注册...',
|
||||
completeRegistrationFailed: '注册失败,请检查邀请码后重试。'
|
||||
},
|
||||
oauthFlow: {
|
||||
profileDetailsTitle: '使用 {providerName} 资料',
|
||||
profileDetailsDescription: '选择是否将 {providerName} 的昵称或头像应用到当前账户。',
|
||||
useDisplayName: '使用昵称',
|
||||
useAvatar: '使用头像',
|
||||
avatarAlt: '{providerName} 头像',
|
||||
reviewProfileBeforeContinue: '请先确认 {providerName} 资料后再继续。',
|
||||
chooseHowToContinue: '选择后续操作',
|
||||
chooseAccountActionHint: '请选择绑定已有账户,或创建一个新账户。',
|
||||
suggestedEmail: '建议邮箱:{email}',
|
||||
bindExistingAccount: '绑定已有账户',
|
||||
createNewAccount: '创建新账户',
|
||||
createAccountHint: '请输入邮箱地址以创建账户并继续。',
|
||||
bindLoginHint: '登录一个已有账户以绑定此次 {providerName} 登录。',
|
||||
signInThenBindDescription: '请先登录已有账户,再将此次 {providerName} 登录绑定到该账户。',
|
||||
bindSignInToExistingAccount: '将此次 {providerName} 登录绑定到已有账户。',
|
||||
bindCurrentAccountTitle: '绑定当前账户',
|
||||
bindCurrentAccountDescription: '将此次 {providerName} 登录绑定到当前浏览器已登录的账户。',
|
||||
bindCurrentAccount: '绑定当前账户',
|
||||
logInAndBind: '登录并绑定',
|
||||
useDifferentEmail: '使用其他邮箱',
|
||||
backToOptions: '返回选项',
|
||||
yourAccount: '当前账户',
|
||||
totpHint: '请输入 {account} 的 6 位验证码,以完成此次 {providerName} 登录绑定。',
|
||||
verifyAndContinue: '验证并继续',
|
||||
wechatAvailabilityUnknown: '暂时无法确认微信登录可用性,请刷新后重试。',
|
||||
wechatSystemBrowserOnly: '当前微信登录流程仅支持在系统浏览器中继续。',
|
||||
wechatBrowserOnly: '当前微信登录流程仅支持在微信内置浏览器中继续。',
|
||||
wechatNotConfigured: '微信登录尚未配置。'
|
||||
},
|
||||
linuxdoCallbackPageTitle: 'LinuxDo 登录回调',
|
||||
dingtalkCallbackPageTitle: '钉钉登录回调',
|
||||
dingtalkProviderName: '钉钉',
|
||||
oidcCallbackPageTitle: 'OIDC 登录回调',
|
||||
oauthCallbackPageTitle: 'OAuth 回调',
|
||||
wechatProviderName: '微信',
|
||||
wechatCallbackPageTitle: '微信登录回调',
|
||||
wechatPaymentCallbackPageTitle: '微信支付回调',
|
||||
wechatPayment: {
|
||||
callbackTitle: '正在恢复微信支付',
|
||||
callbackProcessing: '正在恢复微信支付...',
|
||||
backToPayment: '返回支付页',
|
||||
callbackMissingResumeToken: '微信支付回调缺少恢复令牌。'
|
||||
},
|
||||
oauth: {
|
||||
callbackTitle: 'OAuth 回调',
|
||||
callbackHint: '按需将授权码和状态值复制回后台授权流程。',
|
||||
invalidCallbackTitle: '无效的登录回调',
|
||||
invalidCallbackHint: '当前页面缺少有效的授权结果,请返回登录页重新发起快捷登录。',
|
||||
code: '授权码',
|
||||
state: '状态',
|
||||
fullUrl: '完整URL'
|
||||
},
|
||||
// 忘记密码
|
||||
forgotPassword: '忘记密码?',
|
||||
forgotPasswordTitle: '重置密码',
|
||||
forgotPasswordHint: '输入您的邮箱地址,我们将向您发送密码重置链接。',
|
||||
sendResetLink: '发送重置链接',
|
||||
sendingResetLink: '发送中...',
|
||||
sendResetLinkFailed: '发送重置链接失败,请重试。',
|
||||
resetEmailSent: '重置链接已发送',
|
||||
resetEmailSentHint:
|
||||
'如果该邮箱已注册,您将很快收到密码重置链接。请检查您的收件箱和垃圾邮件文件夹。',
|
||||
backToLogin: '返回登录',
|
||||
rememberedPassword: '想起密码了?',
|
||||
// 重置密码
|
||||
resetPasswordTitle: '设置新密码',
|
||||
resetPasswordHint: '请在下方输入您的新密码。',
|
||||
newPassword: '新密码',
|
||||
newPasswordPlaceholder: '输入新密码',
|
||||
confirmPassword: '确认密码',
|
||||
confirmPasswordPlaceholder: '再次输入新密码',
|
||||
confirmPasswordRequired: '请确认您的密码',
|
||||
passwordsDoNotMatch: '两次输入的密码不一致',
|
||||
resetPassword: '重置密码',
|
||||
resettingPassword: '重置中...',
|
||||
resetPasswordFailed: '重置密码失败,请重试。',
|
||||
passwordResetSuccess: '密码重置成功',
|
||||
passwordResetSuccessHint: '您的密码已重置。现在可以使用新密码登录。',
|
||||
invalidResetLink: '无效的重置链接',
|
||||
invalidResetLinkHint: '此密码重置链接无效或已过期。请重新请求一个新链接。',
|
||||
requestNewResetLink: '请求新的重置链接',
|
||||
invalidOrExpiredToken: '密码重置链接无效或已过期。请重新请求一个新链接。'
|
||||
},
|
||||
|
||||
// Dashboard
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
export default {
|
||||
dashboard: {
|
||||
title: '仪表盘',
|
||||
welcomeMessage: '欢迎回来!这是您账户的概览。',
|
||||
balance: '余额',
|
||||
apiKeys: 'API 密钥',
|
||||
todayRequests: '今日请求',
|
||||
todayCost: '今日消费',
|
||||
todayTokens: '今日 Token',
|
||||
totalTokens: '累计 Token',
|
||||
cacheToday: '今日缓存',
|
||||
performance: '性能指标',
|
||||
avgResponse: '平均响应',
|
||||
averageTime: '平均时间',
|
||||
timeRange: '时间范围',
|
||||
granularity: '粒度',
|
||||
day: '按天',
|
||||
hour: '按小时',
|
||||
modelDistribution: '模型分布',
|
||||
groupDistribution: '分组使用分布',
|
||||
platformBreakdown: '按平台拆分',
|
||||
platformBreakdownEmpty: '暂无平台用量',
|
||||
platformCount: '{count} 个平台',
|
||||
platformOther: '其他',
|
||||
platformQuota: {
|
||||
title: '配额用量',
|
||||
daily: '日',
|
||||
weekly: '周',
|
||||
monthly: '月(近30天)',
|
||||
resetsAt: '{time} 重置',
|
||||
noLimit: '不限制',
|
||||
disabled: '已禁用',
|
||||
},
|
||||
tokenUsageTrend: 'Token 使用趋势',
|
||||
noDataAvailable: '暂无数据',
|
||||
model: '模型',
|
||||
group: '分组',
|
||||
noGroup: '无分组',
|
||||
requests: '请求',
|
||||
tokens: 'Token',
|
||||
actual: '实际',
|
||||
standard: '标准',
|
||||
input: '输入',
|
||||
output: '输出',
|
||||
cache: '缓存',
|
||||
recentUsage: '最近使用',
|
||||
last7Days: '近 7 天',
|
||||
noUsageRecords: '暂无使用记录',
|
||||
startUsingApi: '开始使用 API 后,您的使用历史将显示在这里。',
|
||||
viewAllUsage: '查看全部',
|
||||
quickActions: '快捷操作',
|
||||
createApiKey: '创建 API 密钥',
|
||||
generateNewKey: '生成新的 API 密钥',
|
||||
batchImageAgent: '批量生图助手',
|
||||
batchImageAgentDesc: '复制给 Agent 的任务说明',
|
||||
viewUsage: '查看使用记录',
|
||||
checkDetailedLogs: '查看详细的使用日志',
|
||||
redeemCode: '兑换码',
|
||||
addBalanceWithCode: '使用兑换码充值'
|
||||
},
|
||||
|
||||
// Groups (shared)
|
||||
groups: {
|
||||
subscription: '订阅'
|
||||
},
|
||||
|
||||
// API Keys
|
||||
keys: {
|
||||
title: 'API 密钥',
|
||||
description: '管理您的 API 密钥和访问令牌',
|
||||
searchPlaceholder: '搜索名称或Key...',
|
||||
endpoints: {
|
||||
title: 'API 端点',
|
||||
default: '默认',
|
||||
copied: '已复制',
|
||||
copiedHint: '已复制到剪贴板',
|
||||
clickToCopy: '点击可复制此端点',
|
||||
speedTest: '测速',
|
||||
},
|
||||
allGroups: '全部分组',
|
||||
allStatus: '全部状态',
|
||||
columnSettings: '列设置',
|
||||
columnAlwaysVisible: '该列固定显示,不可隐藏',
|
||||
createKey: '创建密钥',
|
||||
editKey: '编辑密钥',
|
||||
deleteKey: '删除密钥',
|
||||
deleteConfirmMessage: "确定要删除 '{name}' 吗?此操作无法撤销。",
|
||||
apiKey: 'API 密钥',
|
||||
group: '分组',
|
||||
currentConcurrency: '当前并发',
|
||||
noGroup: '无分组',
|
||||
searchGroup: '搜索分组...',
|
||||
noGroupFound: '未找到匹配的分组',
|
||||
created: '创建时间',
|
||||
copyToClipboard: '复制到剪贴板',
|
||||
copied: '已复制!',
|
||||
importToCcSwitch: '导入到 CCS',
|
||||
enable: '启用',
|
||||
disable: '禁用',
|
||||
nameLabel: '名称',
|
||||
namePlaceholder: '我的 API 密钥',
|
||||
groupLabel: '分组',
|
||||
selectGroup: '选择分组',
|
||||
statusLabel: '状态',
|
||||
selectStatus: '选择状态',
|
||||
saving: '保存中...',
|
||||
noKeysYet: '暂无 API 密钥',
|
||||
createFirstKey: '创建您的第一个 API 密钥以开始使用 API。',
|
||||
keyCreatedSuccess: 'API 密钥创建成功',
|
||||
keyUpdatedSuccess: 'API 密钥更新成功',
|
||||
keyDeletedSuccess: 'API 密钥删除成功',
|
||||
keyEnabledSuccess: 'API 密钥已启用',
|
||||
keyDisabledSuccess: 'API 密钥已禁用',
|
||||
failedToLoad: '加载 API 密钥失败',
|
||||
failedToSave: '保存 API 密钥失败',
|
||||
failedToDelete: '删除 API 密钥失败',
|
||||
failedToUpdateStatus: '更新 API 密钥状态失败',
|
||||
clickToChangeGroup: '点击更换分组',
|
||||
groupChangedSuccess: '分组更换成功',
|
||||
failedToChangeGroup: '更换分组失败',
|
||||
groupRequired: '请选择分组',
|
||||
usage: '用量',
|
||||
today: '今日',
|
||||
total: '近30天',
|
||||
quota: '额度',
|
||||
lastUsedAt: '上次使用时间',
|
||||
useKey: '使用密钥',
|
||||
useKeyModal: {
|
||||
title: '使用 API 密钥',
|
||||
description: '将以下环境变量添加到您的终端配置文件或直接在终端中运行。',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
|
||||
noGroupTitle: '请先分配分组',
|
||||
noGroupDescription:
|
||||
'此 API 密钥尚未分配分组,请先在密钥列表中点击分组列进行分配,然后才能查看使用配置。',
|
||||
openai: {
|
||||
description: '将以下配置文件添加到 Codex CLI 配置目录中。',
|
||||
configTomlHint: '请确保以下内容位于 config.toml 文件的开头部分',
|
||||
note: '请确保配置目录存在。macOS/Linux 用户可运行 mkdir -p ~/.codex 创建目录。',
|
||||
noteWindows:
|
||||
'按 Win+R,输入 %userprofile%\\.codex 打开配置目录。如目录不存在,请先手动创建。'
|
||||
},
|
||||
cliTabs: {
|
||||
claudeCode: 'Claude Code',
|
||||
geminiCli: 'Gemini CLI',
|
||||
codexCli: 'Codex CLI',
|
||||
codexCliWs: 'Codex CLI (WebSocket)',
|
||||
opencode: 'OpenCode'
|
||||
},
|
||||
antigravity: {
|
||||
description: '为 Antigravity 分组配置 API 访问。请根据您使用的客户端选择对应的配置方式。',
|
||||
claudeCode: 'Claude Code',
|
||||
geminiCli: 'Gemini CLI',
|
||||
claudeNote:
|
||||
'这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。',
|
||||
geminiNote:
|
||||
'这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。'
|
||||
},
|
||||
gemini: {
|
||||
description:
|
||||
'将以下环境变量添加到您的终端配置文件或直接在终端中运行,以配置 Gemini CLI 访问。',
|
||||
modelComment: '如果你有 Gemini 3 权限可以填:gemini-3-pro-preview',
|
||||
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。'
|
||||
},
|
||||
opencode: {
|
||||
title: 'OpenCode 配置示例',
|
||||
subtitle: 'opencode.json',
|
||||
hint: '配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc),不存在需手动创建。可使用默认 provider(openai/anthropic/google)或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。'
|
||||
}
|
||||
},
|
||||
customKeyLabel: '自定义密钥',
|
||||
customKeyPlaceholder: '输入自定义密钥(至少16个字符)',
|
||||
customKeyHint: '仅允许字母、数字、下划线和连字符,最少16个字符。',
|
||||
customKeyTooShort: '自定义密钥至少需要16个字符',
|
||||
customKeyInvalidChars: '自定义密钥只能包含字母、数字、下划线和连字符',
|
||||
customKeyRequired: '请输入自定义密钥',
|
||||
ipRestriction: 'IP 限制',
|
||||
ipWhitelist: 'IP 白名单',
|
||||
ipWhitelistPlaceholder: '192.168.1.100\n10.0.0.0/8',
|
||||
ipWhitelistHint: '每行一个 IP 或 CIDR,设置后仅允许这些 IP 使用此密钥',
|
||||
ipBlacklist: 'IP 黑名单',
|
||||
ipBlacklistPlaceholder: '1.2.3.4\n5.6.0.0/16',
|
||||
ipBlacklistHint: '每行一个 IP 或 CIDR,这些 IP 将被禁止使用此密钥',
|
||||
ipRestrictionEnabled: '已配置 IP 限制',
|
||||
ccSwitchNotInstalled:
|
||||
'CC-Switch 未安装或协议处理程序未注册。请先安装 CC-Switch 或手动复制 API 密钥。',
|
||||
ccsClientSelect: {
|
||||
title: '选择客户端',
|
||||
description: '请选择您要导入到 CC-Switch 的客户端类型:',
|
||||
claudeCode: 'Claude Code',
|
||||
claudeCodeDesc: '导入为 Claude Code 配置',
|
||||
geminiCli: 'Gemini CLI',
|
||||
geminiCliDesc: '导入为 Gemini CLI 配置'
|
||||
},
|
||||
// 配额和有效期
|
||||
quotaLimit: '额度限制',
|
||||
quotaAmount: '额度金额 (USD)',
|
||||
quotaAmountPlaceholder: '输入 USD 额度限制',
|
||||
quotaAmountHint: '设置此密钥可消费的最大金额。0 = 无限制。',
|
||||
quotaUsed: '已用额度',
|
||||
reset: '重置',
|
||||
resetQuotaUsed: '将已用额度重置为 0',
|
||||
resetQuotaTitle: '确认重置额度',
|
||||
resetQuotaConfirmMessage: '确定要将密钥 "{name}" 的已用额度(${used})重置为 0 吗?此操作不可撤销。',
|
||||
quotaResetSuccess: '额度重置成功',
|
||||
failedToResetQuota: '重置额度失败',
|
||||
rateLimitColumn: '速率限制',
|
||||
rateLimitSection: '速率限制',
|
||||
resetUsage: '重置',
|
||||
rateLimit5h: '5小时限额 (USD)',
|
||||
rateLimit1d: '日限额 (USD)',
|
||||
rateLimit7d: '7天限额 (USD)',
|
||||
rateLimitHint: '设置此密钥在指定时间窗口内的最大消费额。0 = 无限制。',
|
||||
rateLimitUsage: '速率限制用量',
|
||||
resetRateLimitUsage: '重置速率限制用量',
|
||||
resetRateLimitTitle: '确认重置速率限制',
|
||||
resetRateLimitConfirmMessage: '确定要重置密钥 "{name}" 的速率限制用量吗?所有时间窗口的已用额度将归零。此操作不可撤销。',
|
||||
rateLimitResetSuccess: '速率限制已重置',
|
||||
failedToResetRateLimit: '重置速率限制失败',
|
||||
resetNow: '即将重置',
|
||||
expiration: '密钥有效期',
|
||||
expiresInDays: '{days} 天',
|
||||
extendDays: '+{days} 天',
|
||||
customDate: '自定义',
|
||||
expirationDate: '过期时间',
|
||||
expirationDateHint: '选择此 API 密钥的过期时间。',
|
||||
currentExpiration: '当前过期时间',
|
||||
expiresAt: '过期时间',
|
||||
noExpiration: '永久有效',
|
||||
status: {
|
||||
active: '活跃',
|
||||
inactive: '已停用',
|
||||
quota_exhausted: '额度耗尽',
|
||||
expired: '已过期'
|
||||
}
|
||||
},
|
||||
|
||||
// Usage
|
||||
usage: {
|
||||
title: '使用记录',
|
||||
description: '查看和分析您的 API 使用历史',
|
||||
costDetails: '费用明细',
|
||||
tokenDetails: 'Token 明细',
|
||||
cacheTtlOverriddenHint: '缓存 TTL Override 已启用',
|
||||
cacheTtlOverriddenLabel: 'TTL 替换',
|
||||
cacheTtlOverridden5m: '按 5m 计费',
|
||||
cacheTtlOverridden1h: '按 1h 计费',
|
||||
totalRequests: '总请求数',
|
||||
totalTokens: '总 Token',
|
||||
cacheTotal: '缓存',
|
||||
cacheBreakdown: '缓存 Token 明细',
|
||||
cacheCreationTokensLabel: '缓存创建',
|
||||
cacheReadTokensLabel: '缓存读取',
|
||||
totalCost: '总消费',
|
||||
standardCost: '标准',
|
||||
actualCost: '实际',
|
||||
accountCost: '成本',
|
||||
userBilled: '用户扣费',
|
||||
accountBilled: '账号计费',
|
||||
resetNow: '现在',
|
||||
resetPending: '待刷新',
|
||||
accountMultiplier: '账号倍率',
|
||||
avgDuration: '平均耗时',
|
||||
inSelectedRange: '所选范围内',
|
||||
perRequest: '每次请求',
|
||||
apiKeyFilter: 'API 密钥',
|
||||
allApiKeys: '全部密钥',
|
||||
timeRange: '时间范围',
|
||||
exportCsv: '导出 CSV',
|
||||
exportExcel: '导出 Excel',
|
||||
exportingProgress: '正在导出数据...',
|
||||
exportedCount: '已导出 {current}/{total} 条',
|
||||
estimatedTime: '预计剩余时间:{time}',
|
||||
cancelExport: '取消导出',
|
||||
exportCancelled: '导出已取消',
|
||||
exporting: '导出中...',
|
||||
preparingExport: '正在准备导出...',
|
||||
model: '模型',
|
||||
requestedModel: '请求',
|
||||
upstreamModel: '上游',
|
||||
reasoningEffort: '推理强度',
|
||||
endpoint: '端点',
|
||||
endpointDistribution: '端点分布',
|
||||
inbound: '入站',
|
||||
upstream: '上游',
|
||||
mapping: '映射',
|
||||
path: '路径',
|
||||
inboundEndpoint: '入站端点',
|
||||
upstreamEndpoint: '上游端点',
|
||||
type: '类型',
|
||||
tokens: 'Token',
|
||||
cost: '费用',
|
||||
firstToken: '首 Token',
|
||||
duration: '耗时',
|
||||
time: '时间',
|
||||
ws: 'WS',
|
||||
stream: '流式',
|
||||
sync: '同步',
|
||||
cyber: '安全策略',
|
||||
unknown: '未知',
|
||||
in: '输入',
|
||||
out: '输出',
|
||||
cacheHit: '缓存命中',
|
||||
cacheCreate: '缓存创建',
|
||||
cacheHitRate: '缓存命中率',
|
||||
inputTokenPrice: '输入单价',
|
||||
outputTokenPrice: '输出单价',
|
||||
perMillionTokens: '/ 1M Token',
|
||||
unitPrice: '单次价格',
|
||||
imageUnitPrice: '单张价格',
|
||||
imageTotalPrice: '图片总价',
|
||||
imageCount: '图片张数',
|
||||
imageBillingSize: '计费尺寸',
|
||||
imageInputSize: '输入尺寸',
|
||||
imageOutputSize: '输出尺寸',
|
||||
imageOutputTokens: '图片输出 Token',
|
||||
imageOutputTokenPrice: '图片输出单价',
|
||||
imageOutputCost: '图片输出费用',
|
||||
imageSizeSource: '尺寸来源',
|
||||
imageSizeBreakdown: '尺寸明细',
|
||||
imageSizeSourceOutput: '上游输出',
|
||||
imageSizeSourceInput: '请求输入',
|
||||
imageSizeSourceDefault: '默认计费档位',
|
||||
imageSizeSourceLegacy: '历史记录',
|
||||
imageSizeSourceMissing: '未记录',
|
||||
imageSizeNotRecorded: '未记录',
|
||||
imageSizeLegacyUnstandardized: '历史非标准',
|
||||
imageSizeUnknown: '未知',
|
||||
cacheRead: '读取',
|
||||
cacheWrite: '写入',
|
||||
serviceTier: '服务档位',
|
||||
serviceTierPriority: 'Fast',
|
||||
serviceTierFlex: 'Flex',
|
||||
serviceTierStandard: 'Standard',
|
||||
rate: '倍率',
|
||||
original: '原始',
|
||||
billed: '计费',
|
||||
noRecords: '未找到使用记录,请尝试调整筛选条件。',
|
||||
failedToLoad: '加载使用记录失败',
|
||||
noDataToExport: '没有可导出的数据',
|
||||
exportSuccess: '使用数据导出成功',
|
||||
exportFailed: '使用数据导出失败',
|
||||
exportExcelSuccess: '使用数据导出成功(Excel格式)',
|
||||
exportExcelFailed: '使用数据导出失败',
|
||||
imageUnit: '张',
|
||||
userAgent: 'User-Agent',
|
||||
ipGeo: {
|
||||
fetch: '获取地区',
|
||||
fetching: '获取中...',
|
||||
failed: '获取失败',
|
||||
private: '内网地址',
|
||||
refreshTitle: '刷新地区信息',
|
||||
batchFetch: '批量获取地区',
|
||||
batchFetching: '获取中...',
|
||||
pending: '{count} 个 IP 待获取地区',
|
||||
batchFailed: '批量获取地区信息失败',
|
||||
detailOrg: '运营商',
|
||||
detailTimezone: '时区',
|
||||
detailAccuracy: '定位精度',
|
||||
detailCoordinates: '坐标',
|
||||
},
|
||||
tabs: { usage: '用量明细', errors: '错误请求' },
|
||||
errors: {
|
||||
time: '时间', model: '模型', endpoint: '端点', status: '状态码',
|
||||
category: '分类', platform: '平台', message: '错误信息',
|
||||
keyName: 'Key 名称', keyDeleted: '已删除', allKeys: '全部 Key',
|
||||
modelPlaceholder: '搜索模型', allCategories: '全部分类', allStatuses: '全部状态码',
|
||||
empty: '暂无错误请求', failedToLoad: '加载错误请求失败',
|
||||
categories: {
|
||||
auth: '认证失败', rate_limit: '限流', quota: '余额/订阅',
|
||||
invalid_request: '参数错误', service_unavailable: '服务暂时不可用',
|
||||
upstream: '上游错误', internal: '平台错误', other: '其他', cyber: '安全策略',
|
||||
},
|
||||
detail: {
|
||||
title: '错误请求详情',
|
||||
responseBody: '上游响应内容',
|
||||
upstreamStatus: '上游状态码',
|
||||
loadFailed: '加载详情失败,请稍后重试',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Shared keys for channel monitor (admin + user views)
|
||||
monitorCommon: {
|
||||
status: {
|
||||
operational: '正常',
|
||||
degraded: '降级',
|
||||
failed: '失败',
|
||||
error: '错误',
|
||||
unknown: '-'
|
||||
},
|
||||
providers: {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
gemini: 'Gemini'
|
||||
},
|
||||
extraModelsHeader: '附加模型',
|
||||
extraModelsEmpty: '无附加模型',
|
||||
latencyEmpty: '-',
|
||||
availabilityPrefix: '可用性',
|
||||
dialogLatency: '对话延迟',
|
||||
endpointPing: '端点 PING',
|
||||
history60pts: '近 {n} 次记录',
|
||||
nextUpdateIn: '{n}s 后刷新',
|
||||
past: 'PAST',
|
||||
now: 'NOW',
|
||||
maintenancePaused: '维护中 · 已暂停时间线采集',
|
||||
extraModelsCount: '+ {n} 模型',
|
||||
pollEvery: '{n}s 轮询',
|
||||
updatedAt: '更新于 {time}',
|
||||
relativeSecondsAgo: '{n} 秒前',
|
||||
relativeMinutesAgo: '{n} 分钟前',
|
||||
relativeHoursAgo: '{n} 小时前',
|
||||
relativeDaysAgo: '{n} 天前'
|
||||
},
|
||||
|
||||
// Channel Status (user-facing read-only view)
|
||||
channelStatus: {
|
||||
title: '渠道状态',
|
||||
description: '查看渠道可用性、延迟和近期状态',
|
||||
searchPlaceholder: '搜索渠道...',
|
||||
allProviders: '全部供应商',
|
||||
loadError: '加载渠道状态失败',
|
||||
detailLoadError: '加载渠道详情失败',
|
||||
detailTitle: '渠道详情',
|
||||
closeDetail: '关闭',
|
||||
windowTab: {
|
||||
'7d': '7 天',
|
||||
'15d': '15 天',
|
||||
'30d': '30 天'
|
||||
},
|
||||
overall: {
|
||||
operational: 'OPERATIONAL',
|
||||
degraded: 'DEGRADED',
|
||||
unavailable: 'UNAVAILABLE'
|
||||
},
|
||||
columns: {
|
||||
name: '名称',
|
||||
provider: '供应商',
|
||||
groupName: '分组',
|
||||
primaryModel: '主模型',
|
||||
availability7d: '7 天可用率',
|
||||
latency: '延迟 (ms)'
|
||||
},
|
||||
detailColumns: {
|
||||
model: '模型',
|
||||
latestStatus: '最新状态',
|
||||
latestLatency: '最新延迟 (ms)',
|
||||
availability7d: '7 天可用率',
|
||||
availability15d: '15 天可用率',
|
||||
availability30d: '30 天可用率',
|
||||
avgLatency7d: '7 天平均延迟 (ms)'
|
||||
},
|
||||
empty: {
|
||||
title: '暂无可显示的渠道',
|
||||
description: '管理员尚未配置可监控的渠道。'
|
||||
}
|
||||
},
|
||||
|
||||
// Available Channels (user-facing)
|
||||
availableChannels: {
|
||||
title: '可用渠道',
|
||||
description: '查看您可访问的渠道与其支持的模型、定价',
|
||||
searchPlaceholder: '搜索渠道或模型...',
|
||||
empty: '暂无可用渠道',
|
||||
noModels: '未配置模型',
|
||||
noPricing: '未配置定价',
|
||||
exclusive: '专属',
|
||||
public: '公开',
|
||||
exclusiveTooltip: '管理员授权给你的专属分组',
|
||||
publicTooltip: '对所有用户公开的分组',
|
||||
columns: {
|
||||
name: '渠道名',
|
||||
description: '描述',
|
||||
platform: '平台',
|
||||
groups: '我可访问的分组',
|
||||
supportedModels: '支持模型'
|
||||
},
|
||||
pricing: {
|
||||
billingMode: '计费模式',
|
||||
billingModeToken: '按 Token',
|
||||
billingModePerRequest: '按次',
|
||||
billingModeImage: '按图片',
|
||||
inputPrice: '输入',
|
||||
outputPrice: '输出',
|
||||
cacheWritePrice: '缓存写入',
|
||||
cacheReadPrice: '缓存读取',
|
||||
imageOutputPrice: '图片输出',
|
||||
perRequestPrice: '每次请求',
|
||||
intervals: '阶梯定价',
|
||||
unitPerMillion: '/ 1M token',
|
||||
unitPerRequest: '/ 次'
|
||||
}
|
||||
},
|
||||
|
||||
affiliate: {
|
||||
title: '邀请返利',
|
||||
description: '邀请新用户注册,并将返利额度转入账户余额',
|
||||
yourCode: '我的邀请码',
|
||||
inviteLink: '邀请链接',
|
||||
copyCode: '复制邀请码',
|
||||
copyLink: '复制链接',
|
||||
codeCopied: '邀请码已复制',
|
||||
linkCopied: '邀请链接已复制',
|
||||
loadFailed: '加载邀请返利数据失败',
|
||||
transferFailed: '转入余额失败',
|
||||
stats: {
|
||||
rebateRate: '我的返利比例',
|
||||
rebateRateHint: '被邀请用户每次充值后你可获得的返利比例',
|
||||
invitedUsers: '邀请人数',
|
||||
availableQuota: '可转返利额度',
|
||||
frozenQuota: '冻结中',
|
||||
frozenQuotaHint: '新产生的返利正在冻结期中',
|
||||
totalQuota: '历史返利额度'
|
||||
},
|
||||
transfer: {
|
||||
title: '返利额度转余额',
|
||||
description: '将当前可用返利额度一键转入账户余额',
|
||||
button: '转入余额',
|
||||
transferring: '转入中...',
|
||||
empty: '当前没有可转入额度',
|
||||
success: '已转入余额:{amount}'
|
||||
},
|
||||
invitees: {
|
||||
title: '已邀请用户',
|
||||
empty: '暂无邀请记录',
|
||||
columns: {
|
||||
email: '邮箱',
|
||||
username: '用户名',
|
||||
rebate: '返利明细',
|
||||
joinedAt: '注册时间'
|
||||
}
|
||||
},
|
||||
tips: {
|
||||
title: '使用说明',
|
||||
line1: '将邀请码或邀请链接分享给新用户。',
|
||||
line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。',
|
||||
line3: '返利额度可随时转入账户余额。',
|
||||
line4: '新产生的返利需要经过冻结期后才能提现。'
|
||||
}
|
||||
},
|
||||
|
||||
// Redeem
|
||||
redeem: {
|
||||
title: '兑换码',
|
||||
description: '输入兑换码以充值余额或增加并发数',
|
||||
currentBalance: '当前余额',
|
||||
concurrency: '并发数',
|
||||
requests: '请求',
|
||||
redeemCodeLabel: '兑换码',
|
||||
redeemCodePlaceholder: '请输入兑换码',
|
||||
redeemCodeHint: '兑换码区分大小写',
|
||||
redeeming: '兑换中...',
|
||||
redeemButton: '兑换',
|
||||
redeemSuccess: '兑换成功!',
|
||||
redeemFailed: '兑换失败',
|
||||
added: '已添加',
|
||||
concurrentRequests: '并发请求',
|
||||
newBalance: '新余额',
|
||||
newConcurrency: '新并发数',
|
||||
aboutCodes: '关于兑换码',
|
||||
codeRule1: '每个兑换码只能使用一次',
|
||||
codeRule2: '兑换码可以增加余额、并发数或试用权限',
|
||||
codeRule3: '如有兑换问题,请联系客服',
|
||||
codeRule4: '余额和并发数即时更新',
|
||||
recentActivity: '最近活动',
|
||||
historyWillAppear: '您的兑换历史将显示在这里',
|
||||
balanceAddedRedeem: '余额充值(兑换)',
|
||||
balanceAddedAffiliate: '余额充值(返利转入)',
|
||||
balanceAddedAdmin: '余额充值(管理员)',
|
||||
balanceDeductedAdmin: '余额扣除(管理员)',
|
||||
concurrencyAddedRedeem: '并发增加(兑换)',
|
||||
concurrencyAddedAdmin: '并发增加(管理员)',
|
||||
concurrencyReducedAdmin: '并发减少(管理员)',
|
||||
adminAdjustment: '管理员调整',
|
||||
subscriptionAssigned: '订阅已分配',
|
||||
subscriptionAssignedDesc: '您已获得 {groupName} 的访问权限',
|
||||
subscriptionDays: '{days} 天',
|
||||
days: '天',
|
||||
codeRedeemSuccess: '兑换成功!',
|
||||
failedToRedeem: '兑换失败,请检查兑换码后重试。',
|
||||
subscriptionRefreshFailed: '兑换成功,但订阅状态刷新失败。',
|
||||
pleaseEnterCode: '请输入兑换码'
|
||||
},
|
||||
|
||||
// Profile
|
||||
profile: {
|
||||
title: '个人设置',
|
||||
description: '管理您的账户信息和设置',
|
||||
accountBalance: '账户余额',
|
||||
concurrencyLimit: '并发限制',
|
||||
rpmLimit: 'RPM 限制',
|
||||
rpmUnlimited: '不限制',
|
||||
memberSince: '注册时间',
|
||||
overviewTitle: '账户总览',
|
||||
overviewDescription: '快速查看账号状态、资料来源与常用设置。',
|
||||
basicsTitle: '资料与头像',
|
||||
basicsDescription: '维护公开展示信息,并保持头像与昵称风格一致。',
|
||||
linkedProfileSources: '资料来源',
|
||||
linkedProfileSourcesDescription: '部分头像和昵称可能同步自第三方登录方式。',
|
||||
securityTitle: '安全设置',
|
||||
securityDescription: '密码、双因素认证和通知提醒集中放在右侧。',
|
||||
administrator: '管理员',
|
||||
user: '用户',
|
||||
username: '用户名',
|
||||
email: '邮箱',
|
||||
status: '状态',
|
||||
role: '角色',
|
||||
enterUsername: '输入用户名',
|
||||
editProfile: '编辑个人资料',
|
||||
updateProfile: '更新资料',
|
||||
updating: '更新中...',
|
||||
updateSuccess: '资料更新成功',
|
||||
updateFailed: '资料更新失败',
|
||||
usernameRequired: '用户名不能为空',
|
||||
changePassword: '修改密码',
|
||||
currentPassword: '当前密码',
|
||||
newPassword: '新密码',
|
||||
confirmNewPassword: '确认新密码',
|
||||
passwordHint: '密码至少需要 8 个字符',
|
||||
changingPassword: '修改中...',
|
||||
changePasswordButton: '修改密码',
|
||||
passwordsNotMatch: '两次输入的密码不一致',
|
||||
passwordTooShort: '密码至少需要 8 个字符',
|
||||
passwordChangeSuccess: '密码修改成功',
|
||||
passwordChangeFailed: '密码修改失败',
|
||||
// TOTP 2FA
|
||||
totp: {
|
||||
title: '双因素认证 (2FA)',
|
||||
description: '使用 Google Authenticator 等应用增强账户安全',
|
||||
enabled: '已启用',
|
||||
enabledAt: '启用时间',
|
||||
notEnabled: '未启用',
|
||||
notEnabledHint: '启用双因素认证可以增强账户安全性',
|
||||
enable: '启用',
|
||||
disable: '禁用',
|
||||
featureDisabled: '功能未开放',
|
||||
featureDisabledHint: '管理员尚未开放双因素认证功能',
|
||||
setupTitle: '设置双因素认证',
|
||||
setupStep1: '使用认证器应用扫描下方二维码',
|
||||
setupStep2: '输入应用显示的 6 位验证码',
|
||||
manualEntry: '无法扫码?手动输入密钥:',
|
||||
enterCode: '输入 6 位验证码',
|
||||
verify: '验证',
|
||||
setupFailed: '获取设置信息失败',
|
||||
verifyFailed: '验证码错误,请重试',
|
||||
enableSuccess: '双因素认证已启用',
|
||||
disableTitle: '禁用双因素认证',
|
||||
disableWarning: '禁用后,登录时将不再需要验证码。这可能会降低您的账户安全性。',
|
||||
enterPassword: '请输入当前密码确认',
|
||||
confirmDisable: '确认禁用',
|
||||
disableSuccess: '双因素认证已禁用',
|
||||
disableFailed: '禁用失败,请检查密码是否正确',
|
||||
loginTitle: '双因素认证',
|
||||
loginHint: '请输入您认证器应用显示的 6 位验证码',
|
||||
loginFailed: '验证失败,请重试',
|
||||
// New translations for email verification
|
||||
verifyEmailFirst: '请先验证您的邮箱',
|
||||
verifyPasswordFirst: '请先验证您的身份',
|
||||
emailCode: '邮箱验证码',
|
||||
enterEmailCode: '请输入 6 位验证码',
|
||||
sendCode: '发送验证码',
|
||||
codeSent: '验证码已发送到您的邮箱',
|
||||
sendCodeFailed: '发送验证码失败'
|
||||
},
|
||||
balanceNotify: {
|
||||
title: '余额不足提醒',
|
||||
description: '当账户余额低于阈值时发送邮件提醒',
|
||||
enabled: '启用余额不足提醒',
|
||||
threshold: '自定义提醒阈值',
|
||||
thresholdHint: '留空使用系统默认值',
|
||||
thresholdPlaceholder: '输入金额',
|
||||
systemDefault: '系统默认值',
|
||||
extraEmails: '通知邮箱',
|
||||
extraEmailsHint: '必须添加并验证邮箱后,余额不足时才能收到提醒邮件',
|
||||
primaryEmail: '主邮箱',
|
||||
noExtraEmails: '暂无额外通知邮箱',
|
||||
enterEmail: '输入邮箱地址',
|
||||
addEmail: '添加邮箱',
|
||||
emailPlaceholder: '输入邮箱地址',
|
||||
sendCode: '发送验证码',
|
||||
resend: '重发',
|
||||
codeSent: '验证码已发送',
|
||||
codeSentTo: '验证码已发送到 {email}',
|
||||
enterCode: '输入验证码',
|
||||
codePlaceholder: '6位验证码',
|
||||
verify: '验证',
|
||||
emailAdded: '邮箱已添加',
|
||||
emailRemoved: '邮箱已移除',
|
||||
verifySuccess: '邮箱添加成功',
|
||||
removeEmail: '移除',
|
||||
removeSuccess: '邮箱已移除',
|
||||
emailDuplicate: '该邮箱已存在',
|
||||
maxEmailsReached: '已达到通知邮箱数量上限',
|
||||
unverified: '未验证',
|
||||
verified: '已验证',
|
||||
},
|
||||
avatar: {
|
||||
title: '资料头像',
|
||||
description: '仅支持上传头像图片;静态图片会自动压缩到 20KB 以内后再保存。',
|
||||
uploadAction: '上传图片',
|
||||
uploadHint: '上传图片时会自动压缩静态图片到 20KB 以内,GIF 需自行控制在 20KB 以内',
|
||||
uploadRequired: '请先上传头像图片',
|
||||
saveSuccess: '头像已更新',
|
||||
deleteSuccess: '头像已删除',
|
||||
invalidType: '请选择图片文件',
|
||||
gifTooLarge: 'GIF 头像必须在 20KB 以内',
|
||||
compressTooLarge: '无法将图片压缩到 20KB 以内,请换一张更小的图片',
|
||||
compressFailed: '压缩所选图片失败',
|
||||
readFailed: '读取所选图片失败',
|
||||
emptyDeleteHint: '当前没有可删除的头像',
|
||||
},
|
||||
authBindings: {
|
||||
title: '登录方式绑定',
|
||||
description: '查看当前绑定状态,并将更多第三方登录方式关联到这个账号。',
|
||||
bindAction: '绑定 {providerName}',
|
||||
bindSuccess: '账号绑定成功',
|
||||
emailPlaceholder: '输入邮箱地址',
|
||||
codePlaceholder: '输入验证码',
|
||||
passwordPlaceholder: '设置登录密码',
|
||||
replaceEmailPasswordPlaceholder: '输入当前密码',
|
||||
sendCodeAction: '发送验证码',
|
||||
manageEmailAction: '管理邮箱',
|
||||
hideEmailFormAction: '收起邮箱表单',
|
||||
confirmEmailBindAction: '绑定邮箱',
|
||||
confirmEmailReplaceAction: '更换主邮箱',
|
||||
codeSentTo: '验证码已发送到 {email}',
|
||||
replaceSuccess: '主邮箱已更新',
|
||||
unbindAction: '解绑',
|
||||
unbindSuccess: '{providerName} 已解绑',
|
||||
boundCount: '已关联 {count} 条记录',
|
||||
status: {
|
||||
bound: '已绑定',
|
||||
notBound: '未绑定',
|
||||
},
|
||||
providers: {
|
||||
email: '邮箱',
|
||||
linuxdo: 'LinuxDo',
|
||||
dingtalk: '钉钉',
|
||||
oidc: '{providerName}',
|
||||
wechat: '微信',
|
||||
},
|
||||
notes: {
|
||||
emailManagedFromProfile: '主邮箱在资料表单中管理',
|
||||
canUnbind: '你可以解绑这个登录方式。',
|
||||
bindAnotherBeforeUnbind: '请先绑定其他登录方式,再解除当前绑定。',
|
||||
},
|
||||
source: {
|
||||
avatar: '头像当前来自 {providerName}',
|
||||
username: '昵称当前来自 {providerName}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
// Empty States
|
||||
empty: {
|
||||
noData: '暂无数据'
|
||||
},
|
||||
|
||||
// Table
|
||||
table: {
|
||||
expandActions: '展开更多操作',
|
||||
collapseActions: '收起操作'
|
||||
},
|
||||
|
||||
// Pagination
|
||||
pagination: {
|
||||
showing: '显示',
|
||||
to: '至',
|
||||
of: '共',
|
||||
results: '条结果',
|
||||
page: '页',
|
||||
pageOf: '第 {page} / {total} 页',
|
||||
previous: '上一页',
|
||||
next: '下一页',
|
||||
perPage: '每页',
|
||||
goToPage: '跳转到第 {page} 页',
|
||||
jumpTo: '跳转页',
|
||||
jumpPlaceholder: '页码',
|
||||
jumpAction: '跳转'
|
||||
},
|
||||
|
||||
// Errors
|
||||
errors: {
|
||||
somethingWentWrong: '出错了',
|
||||
pageNotFound: '页面未找到',
|
||||
unauthorized: '未授权',
|
||||
forbidden: '禁止访问',
|
||||
serverError: '服务器错误',
|
||||
networkError: '网络错误',
|
||||
timeout: '请求超时',
|
||||
tryAgain: '请重试'
|
||||
},
|
||||
|
||||
// Dates
|
||||
dates: {
|
||||
today: '今天',
|
||||
yesterday: '昨天',
|
||||
thisWeek: '本周',
|
||||
lastWeek: '上周',
|
||||
thisMonth: '本月',
|
||||
lastMonth: '上月',
|
||||
last24Hours: '近24小时',
|
||||
last7Days: '近 7 天',
|
||||
last14Days: '近 14 天',
|
||||
last30Days: '近 30 天',
|
||||
custom: '自定义',
|
||||
startDate: '开始日期',
|
||||
endDate: '结束日期',
|
||||
apply: '应用',
|
||||
selectDateRange: '选择日期范围'
|
||||
},
|
||||
|
||||
// Admin
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import landing from './landing'
|
||||
import common from './common'
|
||||
import dashboard from './dashboard'
|
||||
import admin from './admin'
|
||||
import misc from './misc'
|
||||
|
||||
export default {
|
||||
...landing,
|
||||
...common,
|
||||
...dashboard,
|
||||
admin,
|
||||
...misc,
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
export default {
|
||||
batchImageGuide: {
|
||||
title: '图片批量生成',
|
||||
description: '一次提交多条提示词,任务完成后可统一下载图片结果'
|
||||
},
|
||||
// Home Page
|
||||
home: {
|
||||
viewOnGithub: '在 GitHub 上查看',
|
||||
viewDocs: '查看文档',
|
||||
docs: '文档',
|
||||
switchToLight: '切换到浅色模式',
|
||||
switchToDark: '切换到深色模式',
|
||||
dashboard: '控制台',
|
||||
login: '登录',
|
||||
getStarted: '立即开始',
|
||||
goToDashboard: '进入控制台',
|
||||
// 新增:面向用户的价值主张
|
||||
heroSubtitle: '一个密钥,畅用多个 AI 模型',
|
||||
heroDescription: '无需管理多个订阅账号,一站式接入 Claude、GPT、Gemini 等主流 AI 服务',
|
||||
tags: {
|
||||
subscriptionToApi: '订阅转 API',
|
||||
stickySession: '会话保持',
|
||||
realtimeBilling: '按量计费'
|
||||
},
|
||||
// 用户痛点区块
|
||||
painPoints: {
|
||||
title: '你是否也遇到这些问题?',
|
||||
items: {
|
||||
expensive: {
|
||||
title: '订阅费用高',
|
||||
desc: '每个 AI 服务都要单独订阅,每月支出越来越多'
|
||||
},
|
||||
complex: {
|
||||
title: '多账号难管理',
|
||||
desc: '不同平台的账号、密钥分散各处,管理起来很麻烦'
|
||||
},
|
||||
unstable: {
|
||||
title: '服务不稳定',
|
||||
desc: '单一账号容易触发限制,影响正常使用'
|
||||
},
|
||||
noControl: {
|
||||
title: '用量无法控制',
|
||||
desc: '不知道钱花在哪了,也无法限制团队成员的使用'
|
||||
}
|
||||
}
|
||||
},
|
||||
// 解决方案区块
|
||||
solutions: {
|
||||
title: '我们帮你解决',
|
||||
subtitle: '简单三步,开始省心使用 AI'
|
||||
},
|
||||
features: {
|
||||
unifiedGateway: '一键接入',
|
||||
unifiedGatewayDesc: '获取一个 API 密钥,即可调用所有已接入的 AI 模型,无需分别申请。',
|
||||
multiAccount: '稳定可靠',
|
||||
multiAccountDesc: '智能调度多个上游账号,自动切换和负载均衡,告别频繁报错。',
|
||||
balanceQuota: '用多少付多少',
|
||||
balanceQuotaDesc: '按实际使用量计费,支持设置配额上限,团队用量一目了然。'
|
||||
},
|
||||
// 优势对比
|
||||
comparison: {
|
||||
title: '为什么选择我们?',
|
||||
headers: {
|
||||
feature: '对比项',
|
||||
official: '官方订阅',
|
||||
us: '本平台'
|
||||
},
|
||||
items: {
|
||||
pricing: {
|
||||
feature: '付费方式',
|
||||
official: '固定月费,用不完也付',
|
||||
us: '按量付费,用多少付多少'
|
||||
},
|
||||
models: {
|
||||
feature: '模型选择',
|
||||
official: '单一服务商',
|
||||
us: '多模型随意切换'
|
||||
},
|
||||
management: {
|
||||
feature: '账号管理',
|
||||
official: '每个服务单独管理',
|
||||
us: '统一密钥,一站管理'
|
||||
},
|
||||
stability: {
|
||||
feature: '服务稳定性',
|
||||
official: '单账号易触发限制',
|
||||
us: '多账号池,自动切换'
|
||||
},
|
||||
control: {
|
||||
feature: '用量控制',
|
||||
official: '无法限制',
|
||||
us: '可设配额、查明细'
|
||||
}
|
||||
}
|
||||
},
|
||||
providers: {
|
||||
title: '已支持的 AI 模型',
|
||||
description: '一个 API,多种选择',
|
||||
supported: '已支持',
|
||||
soon: '即将推出',
|
||||
claude: 'Claude',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity',
|
||||
more: '更多'
|
||||
},
|
||||
// CTA 区块
|
||||
cta: {
|
||||
title: '准备好开始了吗?',
|
||||
description: '注册即可获得免费试用额度,体验一站式 AI 服务',
|
||||
button: '免费注册'
|
||||
},
|
||||
footer: {
|
||||
allRightsReserved: '保留所有权利。'
|
||||
}
|
||||
},
|
||||
|
||||
// Key Usage Query Page
|
||||
keyUsage: {
|
||||
title: 'API Key 用量查询',
|
||||
subtitle: '输入您的 API Key 以查看实时消费金额与使用状态',
|
||||
placeholder: 'sk-ant-mirror-xxxxxxxxxxxx',
|
||||
query: '查询',
|
||||
querying: '查询中...',
|
||||
privacyNote: '您的 Key 仅在浏览器本地处理,不会被存储',
|
||||
dateRange: '统计范围:',
|
||||
dateRangeToday: '今日',
|
||||
dateRange7d: '7 天',
|
||||
dateRange30d: '30 天',
|
||||
dateRange90d: '90 天',
|
||||
dateRangeCustom: '自定义',
|
||||
apply: '应用',
|
||||
used: '已使用',
|
||||
detailInfo: '详细信息',
|
||||
tokenStats: 'Token 统计',
|
||||
dailyDetail: '按日明细',
|
||||
modelStats: '模型用量统计',
|
||||
// Table headers
|
||||
date: '日期',
|
||||
model: '模型',
|
||||
requests: '请求数',
|
||||
inputTokens: '输入 Tokens',
|
||||
outputTokens: '输出 Tokens',
|
||||
cacheCreationTokens: '缓存创建',
|
||||
cacheReadTokens: '缓存读取',
|
||||
cacheWriteTokens: '缓存写入',
|
||||
totalTokens: '总 Tokens',
|
||||
cost: '费用',
|
||||
// Status
|
||||
quotaMode: 'Key 限额模式',
|
||||
walletBalance: '钱包余额',
|
||||
// Ring card titles
|
||||
totalQuota: '总额度',
|
||||
limit5h: '5 小时限额',
|
||||
limitDaily: '日限额',
|
||||
limit7d: '7 天限额',
|
||||
limitWeekly: '周限额',
|
||||
limitMonthly: '月限额',
|
||||
// Detail rows
|
||||
remainingQuota: '剩余额度',
|
||||
expiresAt: '过期时间',
|
||||
todayExpires: '(今日到期)',
|
||||
daysLeft: '({days} 天)',
|
||||
usedQuota: '已用额度',
|
||||
resetNow: '即将重置',
|
||||
subscriptionType: '订阅类型',
|
||||
subscriptionExpires: '订阅到期',
|
||||
// Usage stat cells
|
||||
todayRequests: '今日请求',
|
||||
todayInputTokens: '今日输入',
|
||||
todayOutputTokens: '今日输出',
|
||||
todayTokens: '今日 Tokens',
|
||||
todayCacheCreation: '今日缓存创建',
|
||||
todayCacheRead: '今日缓存读取',
|
||||
todayCost: '今日费用',
|
||||
rpmTpm: 'RPM / TPM',
|
||||
totalRequests: '累计请求',
|
||||
totalInputTokens: '累计输入',
|
||||
totalOutputTokens: '累计输出',
|
||||
totalTokensLabel: '累计 Tokens',
|
||||
totalCacheCreation: '累计缓存创建',
|
||||
totalCacheRead: '累计缓存读取',
|
||||
totalCost: '累计费用',
|
||||
avgDuration: '平均耗时',
|
||||
// Messages
|
||||
enterApiKey: '请输入 API Key',
|
||||
querySuccess: '查询成功',
|
||||
queryFailed: '查询失败',
|
||||
queryFailedRetry: '查询失败,请稍后重试',
|
||||
noDailyUsage: '暂无按日用量数据',
|
||||
},
|
||||
|
||||
// Setup Wizard
|
||||
setup: {
|
||||
title: 'Sub2API 安装向导',
|
||||
description: '配置您的 Sub2API 实例',
|
||||
database: {
|
||||
title: '数据库配置',
|
||||
description: '连接到您的 PostgreSQL 数据库',
|
||||
host: '主机',
|
||||
port: '端口',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
databaseName: '数据库名称',
|
||||
sslMode: 'SSL 模式',
|
||||
passwordPlaceholder: '密码',
|
||||
ssl: {
|
||||
disable: '禁用',
|
||||
require: '要求',
|
||||
verifyCa: '验证 CA',
|
||||
verifyFull: '完全验证'
|
||||
}
|
||||
},
|
||||
redis: {
|
||||
title: 'Redis 配置',
|
||||
description: '连接到您的 Redis 服务器',
|
||||
host: '主机',
|
||||
port: '端口',
|
||||
password: '密码(可选)',
|
||||
database: '数据库',
|
||||
passwordPlaceholder: '密码',
|
||||
enableTls: '启用 TLS',
|
||||
enableTlsHint: '连接 Redis 时使用 TLS(公共 CA 证书)'
|
||||
},
|
||||
admin: {
|
||||
title: '管理员账户',
|
||||
description: '创建您的管理员账户',
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
confirmPassword: '确认密码',
|
||||
passwordPlaceholder: '至少 8 个字符',
|
||||
confirmPasswordPlaceholder: '确认密码',
|
||||
passwordMismatch: '密码不匹配'
|
||||
},
|
||||
ready: {
|
||||
title: '准备安装',
|
||||
description: '检查您的配置并完成安装',
|
||||
database: '数据库',
|
||||
redis: 'Redis',
|
||||
adminEmail: '管理员邮箱'
|
||||
},
|
||||
status: {
|
||||
testing: '测试中...',
|
||||
success: '连接成功',
|
||||
testConnection: '测试连接',
|
||||
installing: '安装中...',
|
||||
completeInstallation: '完成安装',
|
||||
completed: '安装完成!',
|
||||
redirecting: '正在跳转到登录页面...',
|
||||
restarting: '服务正在重启,请稍候...',
|
||||
timeout: '服务重启时间超出预期,请手动刷新页面。'
|
||||
}
|
||||
},
|
||||
|
||||
// Common
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
export default {
|
||||
|
||||
// Subscription Progress (Header component)
|
||||
subscriptionProgress: {
|
||||
title: '我的订阅',
|
||||
viewDetails: '查看订阅详情',
|
||||
activeCount: '{count} 个有效订阅',
|
||||
daily: '每日',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
daysRemaining: '剩余 {days} 天',
|
||||
expired: '已过期',
|
||||
expiresToday: '今天到期',
|
||||
expiresTomorrow: '明天到期',
|
||||
viewAll: '查看全部订阅',
|
||||
noSubscriptions: '暂无有效订阅',
|
||||
unlimited: '无限制'
|
||||
},
|
||||
|
||||
// Version Badge
|
||||
version: {
|
||||
currentVersion: '当前版本',
|
||||
latestVersion: '最新版本',
|
||||
upToDate: '已是最新版本',
|
||||
updateAvailable: '有新版本可用!',
|
||||
releaseNotes: '更新日志',
|
||||
noReleaseNotes: '暂无更新日志',
|
||||
viewUpdate: '查看更新',
|
||||
viewRelease: '查看发布',
|
||||
viewChangelog: '查看更新日志',
|
||||
refresh: '刷新',
|
||||
sourceMode: '源码构建',
|
||||
sourceModeHint: '源码构建请使用 git pull 更新',
|
||||
updateNow: '立即更新',
|
||||
updating: '正在更新...',
|
||||
updateComplete: '更新完成',
|
||||
updateFailed: '更新失败',
|
||||
restartRequired: '请重启服务以应用更新',
|
||||
restartNow: '立即重启',
|
||||
restarting: '正在重启...',
|
||||
retry: '重试'
|
||||
},
|
||||
|
||||
// Recharge / Subscription Page
|
||||
purchase: {
|
||||
title: '充值/订阅',
|
||||
description: '通过内嵌页面完成充值/订阅',
|
||||
openInNewTab: '新窗口打开',
|
||||
notEnabledTitle: '该功能未开启',
|
||||
notEnabledDesc: '管理员暂未开启充值/订阅入口,请联系管理员。',
|
||||
notConfiguredTitle: '充值/订阅链接未配置',
|
||||
notConfiguredDesc: '管理员已开启入口,但尚未配置充值/订阅链接,请联系管理员。'
|
||||
},
|
||||
|
||||
// Custom Page (iframe embed)
|
||||
customPage: {
|
||||
title: '自定义页面',
|
||||
openInNewTab: '新窗口打开',
|
||||
notFoundTitle: '页面不存在',
|
||||
notFoundDesc: '该自定义页面不存在或已被删除。',
|
||||
notConfiguredTitle: '页面链接未配置',
|
||||
notConfiguredDesc: '该自定义页面的 URL 未正确配置。',
|
||||
tableOfContents: '目录',
|
||||
copyCode: '复制',
|
||||
copiedCode: '已复制',
|
||||
copyCodeFailed: '失败'
|
||||
},
|
||||
|
||||
// Announcements Page
|
||||
announcements: {
|
||||
title: '公告',
|
||||
description: '查看系统公告',
|
||||
unreadOnly: '仅显示未读',
|
||||
markRead: '标记已读',
|
||||
markAllRead: '全部已读',
|
||||
viewAll: '查看全部公告',
|
||||
markedAsRead: '已标记为已读',
|
||||
allMarkedAsRead: '所有公告已标记为已读',
|
||||
newCount: '有 {count} 条新公告',
|
||||
readAt: '已读时间',
|
||||
read: '已读',
|
||||
unread: '未读',
|
||||
startsAt: '开始时间',
|
||||
endsAt: '结束时间',
|
||||
empty: '暂无公告',
|
||||
emptyUnread: '暂无未读公告',
|
||||
total: '条公告',
|
||||
emptyDescription: '暂时没有任何系统公告',
|
||||
readStatus: '您已阅读此公告',
|
||||
markReadHint: '点击"已读"标记此公告'
|
||||
},
|
||||
|
||||
// User Subscriptions Page
|
||||
userSubscriptions: {
|
||||
title: '我的订阅',
|
||||
description: '查看您的订阅计划和用量',
|
||||
noActiveSubscriptions: '暂无有效订阅',
|
||||
noActiveSubscriptionsDesc: '您没有任何有效订阅。请联系管理员获取订阅。',
|
||||
failedToLoad: '加载订阅失败',
|
||||
status: {
|
||||
active: '有效',
|
||||
expired: '已过期',
|
||||
revoked: '已撤销'
|
||||
},
|
||||
usage: '用量',
|
||||
expires: '到期时间',
|
||||
noExpiration: '无到期时间',
|
||||
unlimited: '无限制',
|
||||
unlimitedDesc: '该订阅无用量限制',
|
||||
daily: '每日',
|
||||
weekly: '每周',
|
||||
monthly: '每月',
|
||||
daysRemaining: '剩余 {days} 天',
|
||||
expiresOn: '{date} 到期',
|
||||
resetIn: '{time} 后重置',
|
||||
quotaEndsIn: '额度将在 {time} 后结束',
|
||||
windowNotActive: '等待首次使用',
|
||||
usageOf: '已用 {used} / {limit}'
|
||||
},
|
||||
|
||||
// Onboarding Tour
|
||||
onboarding: {
|
||||
restartTour: '重新查看新手引导',
|
||||
dontShowAgain: '不再提示',
|
||||
dontShowAgainTitle: '永久关闭新手引导',
|
||||
confirmDontShow: '确定不再显示新手引导吗?\n\n您可以随时在右上角头像菜单中重新开启。',
|
||||
confirmExit: '确定要退出新手引导吗?您可以随时在右上角菜单重新开始。',
|
||||
interactiveHint: '按 Enter 或点击继续',
|
||||
navigation: {
|
||||
flipPage: '翻页',
|
||||
exit: '退出'
|
||||
},
|
||||
// Admin tour steps
|
||||
admin: {
|
||||
welcome: {
|
||||
title: '👋 欢迎使用 Sub2API',
|
||||
description:
|
||||
'<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">Sub2API 是一个强大的 AI 服务中转平台,让您轻松管理和分发 AI 服务。</p><p style="margin-bottom: 12px;"><b>🎯 核心功能:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>📦 <b>分组管理</b> - 创建不同的服务套餐(VIP、免费试用等)</li><li>🔗 <b>账号池</b> - 连接多个上游 AI 服务商账号</li><li>🔑 <b>密钥分发</b> - 为用户生成独立的 API Key</li><li>💰 <b>计费管理</b> - 灵活的费率和配额控制</li></ul><p style="color: #10b981; font-weight: 600;">接下来,我们将用 3 分钟带您完成首次配置 →</p></div>',
|
||||
nextBtn: '开始配置 🚀',
|
||||
prevBtn: '跳过'
|
||||
},
|
||||
groupManage: {
|
||||
title: '📦 第一步:分组管理',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>什么是分组?</b></p><p style="margin-bottom: 12px;">分组是 Sub2API 的核心概念,它就像一个"服务套餐":</p><ul style="margin-left: 20px; margin-bottom: 12px; font-size: 13px;"><li>🎯 每个分组可以包含多个上游账号</li><li>💰 每个分组有独立的计费倍率</li><li>👥 可以设置为公开或专属分组</li></ul><p style="margin-top: 12px; padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>您可以创建"VIP专线"(高倍率)和"免费试用"(低倍率)两个分组</p><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"分组管理"开始</p></div>'
|
||||
},
|
||||
createGroup: {
|
||||
title: '➕ 创建新分组',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">现在让我们创建第一个分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📝 提示:</b>建议先创建一个测试分组,熟悉流程后再创建正式分组</p><p style="color: #10b981; font-weight: 600;">👉 点击"创建分组"按钮</p></div>'
|
||||
},
|
||||
groupName: {
|
||||
title: '✏️ 1. 分组名称',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为您的分组起一个易于识别的名称。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>💡 命名建议:</b><ul style="margin: 8px 0 0 16px;"><li>"测试分组" - 用于测试</li><li>"VIP专线" - 高质量服务</li><li>"免费试用" - 体验版</li></ul></div><p style="font-size: 13px; color: #6b7280;">填写完成后点击"下一步"继续</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
groupPlatform: {
|
||||
title: '🤖 2. 选择平台',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该分组支持的 AI 平台。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 平台说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>Anthropic</b> - Claude 系列模型</li><li><b>OpenAI</b> - GPT 系列模型</li><li><b>Google</b> - Gemini 系列模型</li></ul></div><p style="font-size: 13px; color: #6b7280;">一个分组只能选择一个平台</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
groupMultiplier: {
|
||||
title: '💰 3. 费率倍数',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置该分组的计费倍率,控制用户的实际扣费。</p><div style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚙️ 计费规则:</b><ul style="margin: 8px 0 0 16px;"><li><b>1.0</b> - 原价计费(成本价)</li><li><b>1.5</b> - 用户消耗 $1,扣除 $1.5</li><li><b>2.0</b> - 用户消耗 $1,扣除 $2</li><li><b>0.8</b> - 补贴模式(亏本运营)</li></ul></div><p style="font-size: 13px; color: #6b7280;">建议测试分组设置为 1.0</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
groupExclusive: {
|
||||
title: '🔒 4. 专属分组(可选)',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">控制分组的可见性和访问权限。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔐 权限说明:</b><ul style="margin: 8px 0 0 16px;"><li><b>关闭</b> - 公开分组,所有用户可见</li><li><b>开启</b> - 专属分组,仅指定用户可见</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>VIP 用户专属、内部测试、特殊客户等</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
groupSubmit: {
|
||||
title: '✅ 保存分组',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击创建按钮保存分组。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 注意:</b>分组创建后,平台类型不可修改,其他信息可以随时编辑</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>创建成功后,我们将添加上游账号到这个分组</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
|
||||
},
|
||||
accountManage: {
|
||||
title: '🔗 第二步:添加账号',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>太棒了!分组已创建成功 🎉</b></p><p style="margin-bottom: 12px;">现在需要添加上游 AI 服务商的账号,让分组能够实际提供服务。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 账号的作用:</b><ul style="margin: 8px 0 0 16px;"><li>连接到上游 AI 服务(Claude、GPT 等)</li><li>一个分组可以包含多个账号(负载均衡)</li><li>支持 OAuth 和 Session Key 两种方式</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"账号管理"</p></div>'
|
||||
},
|
||||
createAccount: {
|
||||
title: '➕ 添加新账号',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮开始添加您的第一个上游账号。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>建议使用 OAuth 方式,更安全且无需手动提取密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"添加账号"按钮</p></div>'
|
||||
},
|
||||
accountName: {
|
||||
title: '✏️ 1. 账号名称',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为账号设置一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"Claude主账号"、"GPT备用1"、"测试账号" 等</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
accountPlatform: {
|
||||
title: '🤖 2. 选择平台',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择该账号对应的服务商平台。</p><p style="padding: 8px 12px; background: #fef3c7; border-left: 3px solid #f59e0b; border-radius: 4px; font-size: 13px;"><b>⚠️ 重要:</b>平台必须与刚才创建的分组平台一致</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
accountType: {
|
||||
title: '🔐 3. 授权方式',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择账号的授权方式。</p><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>✅ 推荐:OAuth 方式</b><ul style="margin: 8px 0 0 16px;"><li>无需手动提取密钥</li><li>更安全,支持自动刷新</li><li>适用于 Claude Code、ChatGPT OAuth</li></ul></div><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 Session Key 方式</b><ul style="margin: 8px 0 0 16px;"><li>需要手动从浏览器提取</li><li>可能需要定期更新</li><li>适用于不支持 OAuth 的平台</li></ul></div></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
accountPriority: {
|
||||
title: '⚖️ 4. 优先级(可选)',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">设置账号的调用优先级。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📊 优先级规则:</b><ul style="margin: 8px 0 0 16px;"><li>数字越小,优先级越高</li><li>系统优先使用低数值账号</li><li>相同优先级则随机选择</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 使用场景:</b>主账号设置低数值,备用账号设置高数值</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
accountGroups: {
|
||||
title: '🎯 5. 分配分组',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>关键步骤!</b>将账号分配到刚才创建的分组。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>必须勾选至少一个分组</li><li>未分配分组的账号无法使用</li><li>一个账号可以分配给多个分组</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>请勾选刚才创建的测试分组</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
accountSubmit: {
|
||||
title: '✅ 保存账号',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">确认信息无误后,点击保存按钮。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 OAuth 授权流程:</b><ul style="margin: 8px 0 0 16px;"><li>点击保存后会跳转到服务商页面</li><li>在服务商页面完成登录授权</li><li>授权成功后自动返回</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>📌 下一步:</b>账号添加成功后,我们将创建 API 密钥</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"保存"按钮</p></div>'
|
||||
},
|
||||
keyManage: {
|
||||
title: '🔑 第三步:生成密钥',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;"><b>恭喜!账号配置完成 🎉</b></p><p style="margin-bottom: 12px;">最后一步,生成 API Key 来测试服务是否正常工作。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🔑 API Key 的作用:</b><ul style="margin: 8px 0 0 16px;"><li>用于调用 AI 服务的凭证</li><li>每个 Key 绑定一个分组</li><li>可以设置配额和有效期</li><li>支持独立的使用统计</li></ul></div><p style="margin-top: 16px; color: #10b981; font-weight: 600;">👉 点击左侧的"API 密钥"</p></div>'
|
||||
},
|
||||
createKey: {
|
||||
title: '➕ 创建密钥',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API Key。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后请立即复制保存,密钥只显示一次</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"按钮</p></div>'
|
||||
},
|
||||
keyName: {
|
||||
title: '✏️ 1. 密钥名称',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥设置一个便于管理的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 命名建议:</b>"测试密钥"、"生产环境"、"移动端" 等</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
keyGroup: {
|
||||
title: '🎯 2. 选择分组',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择刚才配置好的分组。</p><div style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>📌 分组决定:</b><ul style="margin: 8px 0 0 16px;"><li>该密钥可以使用哪些账号</li><li>计费倍率是多少</li><li>是否为专属密钥</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>选择刚才创建的测试分组</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
keySubmit: {
|
||||
title: '🎉 生成并复制',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击创建后,系统会生成完整的 API Key。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要提醒:</b><ul style="margin: 8px 0 0 16px;"><li>密钥只显示一次,请立即复制</li><li>丢失后需要重新生成</li><li>妥善保管,不要泄露给他人</li></ul></div><div style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>🚀 下一步:</b><ul style="margin: 8px 0 0 16px;"><li>复制生成的 sk-xxx 密钥</li><li>在支持 OpenAI 接口的客户端中使用</li><li>开始体验 AI 服务!</li></ul></div><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
|
||||
}
|
||||
},
|
||||
// User tour steps
|
||||
user: {
|
||||
welcome: {
|
||||
title: '👋 欢迎使用 Sub2API',
|
||||
description:
|
||||
'<div style="line-height: 1.8;"><p style="margin-bottom: 16px;">您好!欢迎来到 Sub2API AI 服务平台。</p><p style="margin-bottom: 12px;"><b>🎯 快速开始:</b></p><ul style="margin-left: 20px; margin-bottom: 16px;"><li>🔑 创建 API 密钥</li><li>📋 复制密钥到您的应用</li><li>🚀 开始使用 AI 服务</li></ul><p style="color: #10b981; font-weight: 600;">只需 1 分钟,让我们开始吧 →</p></div>',
|
||||
nextBtn: '开始 🚀',
|
||||
prevBtn: '跳过'
|
||||
},
|
||||
keyManage: {
|
||||
title: '🔑 API 密钥管理',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">在这里管理您的所有 API 访问密钥。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 什么是 API 密钥?</b><br/>API 密钥是您访问 AI 服务的凭证,就像一把钥匙,让您的应用能够调用 AI 能力。</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击进入密钥页面</p></div>'
|
||||
},
|
||||
createKey: {
|
||||
title: '➕ 创建新密钥',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击按钮创建您的第一个 API 密钥。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 提示:</b>创建后密钥只显示一次,请务必复制保存</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建密钥"</p></div>'
|
||||
},
|
||||
keyName: {
|
||||
title: '✏️ 密钥名称',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">为密钥起一个便于识别的名称。</p><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>💡 示例:</b>"我的第一个密钥"、"测试用" 等</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
keyGroup: {
|
||||
title: '🎯 选择分组',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">选择管理员为您分配的服务分组。</p><p style="padding: 8px 12px; background: #eff6ff; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 13px;"><b>📌 分组说明:</b><br/>不同分组可能有不同的服务质量和计费标准,请根据需要选择。</p></div>',
|
||||
nextBtn: '下一步'
|
||||
},
|
||||
keySubmit: {
|
||||
title: '🎉 完成创建',
|
||||
description:
|
||||
'<div style="line-height: 1.7;"><p style="margin-bottom: 12px;">点击确认创建您的 API 密钥。</p><div style="padding: 8px 12px; background: #fee2e2; border-left: 3px solid #ef4444; border-radius: 4px; font-size: 13px; margin-bottom: 12px;"><b>⚠️ 重要:</b><ul style="margin: 8px 0 0 16px;"><li>创建后请立即复制密钥(sk-xxx)</li><li>密钥只显示一次,丢失需重新生成</li></ul></div><p style="padding: 8px 12px; background: #f0fdf4; border-left: 3px solid #10b981; border-radius: 4px; font-size: 13px;"><b>🚀 如何使用:</b><br/>将密钥配置到支持 OpenAI 接口的任何客户端(如 ChatBox、OpenCat 等),即可开始使用!</p><p style="margin-top: 12px; color: #10b981; font-weight: 600;">👉 点击"创建"按钮</p></div>'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Payment System
|
||||
payment: {
|
||||
title: '充值/订阅',
|
||||
amountLabel: '充值金额',
|
||||
paymentAmount: '支付金额',
|
||||
creditedBalance: '到账余额',
|
||||
quickAmounts: '快捷金额',
|
||||
customAmount: '自定义金额',
|
||||
enterAmount: '输入金额',
|
||||
paymentMethod: '支付方式',
|
||||
fee: '手续费',
|
||||
actualPay: '实付金额',
|
||||
createOrder: '确认支付',
|
||||
methods: {
|
||||
easypay: '易支付',
|
||||
alipay: '支付宝',
|
||||
wxpay: '微信支付',
|
||||
stripe: 'Stripe',
|
||||
airwallex: 'Airwallex',
|
||||
card: '银行卡',
|
||||
link: 'Link',
|
||||
alipay_direct: '支付宝(直连)',
|
||||
wxpay_direct: '微信支付(直连)',
|
||||
},
|
||||
status: {
|
||||
pending: '待支付',
|
||||
paid: '已支付',
|
||||
recharging: '充值中',
|
||||
completed: '已完成',
|
||||
expired: '已过期',
|
||||
cancelled: '已取消',
|
||||
failed: '失败',
|
||||
refund_requested: '退款申请中',
|
||||
refunding: '退款中',
|
||||
refund_pending: '退款处理中',
|
||||
refunded: '已退款',
|
||||
partially_refunded: '部分退款',
|
||||
refund_failed: '退款失败',
|
||||
},
|
||||
qr: {
|
||||
scanToPay: '请扫码支付',
|
||||
scanAlipay: '支付宝扫码支付',
|
||||
scanWxpay: '微信扫码支付',
|
||||
scanAlipayHint: '请使用手机打开支付宝,扫描二维码完成支付',
|
||||
scanWxpayHint: '请使用手机打开微信,扫描二维码完成支付',
|
||||
payInNewWindow: '请在新窗口中完成支付',
|
||||
payInNewWindowHint: '支付页面已在新窗口打开,请在新窗口中完成支付后返回此页面',
|
||||
openPayWindow: '重新打开支付页面',
|
||||
expiresIn: '剩余支付时间',
|
||||
expired: '订单已过期',
|
||||
expiredDesc: '订单已超时,请重新创建订单',
|
||||
cancelled: '订单已取消',
|
||||
cancelledDesc: '您已取消本次支付',
|
||||
waitingPayment: '等待支付...',
|
||||
cancelOrder: '取消订单',
|
||||
},
|
||||
orders: {
|
||||
title: '我的订单',
|
||||
empty: '暂无订单',
|
||||
orderId: '订单 ID',
|
||||
orderNo: '订单编号',
|
||||
amount: '金额',
|
||||
payAmount: '实付',
|
||||
creditedAmount: '到账金额',
|
||||
fee: '手续费',
|
||||
baseAmount: '充值金额',
|
||||
includedInPayAmount: '已含在实付金额中',
|
||||
status: '状态',
|
||||
paymentMethod: '支付方式',
|
||||
createdAt: '创建时间',
|
||||
cancel: '取消订单',
|
||||
userId: '用户 ID',
|
||||
orderType: '订单类型',
|
||||
actions: '操作',
|
||||
requestRefund: '申请退款',
|
||||
},
|
||||
result: {
|
||||
success: '支付成功',
|
||||
subscriptionSuccess: '订阅成功',
|
||||
processing: '支付处理中',
|
||||
processingHint: '支付结果仍在确认中,页面会自动刷新。',
|
||||
failed: '支付失败',
|
||||
backToRecharge: '返回充值',
|
||||
viewOrders: '查看订单',
|
||||
},
|
||||
currentBalance: '当前余额',
|
||||
groupFallback: '分组 #{id}',
|
||||
rechargeAccount: '充值账户',
|
||||
activeSubscription: '当前订阅',
|
||||
noActiveSubscription: '暂无有效订阅',
|
||||
tabTopUp: '充值',
|
||||
tabSubscribe: '订阅',
|
||||
noPlans: '暂无可用订阅套餐',
|
||||
notAvailable: '充值功能暂未开放',
|
||||
confirmSubscription: '确认订阅',
|
||||
confirmCancel: '确定要取消此订单吗?',
|
||||
amountTooLow: '最低金额为 {min}',
|
||||
amountTooHigh: '最高金额为 {max}',
|
||||
amountNoMethod: '该金额没有可用的支付方式',
|
||||
rechargeRatePreview: '当前倍率:1 CNY = {usd} USD',
|
||||
refundReason: '退款原因',
|
||||
refundReasonPlaceholder: '请描述您的退款原因',
|
||||
stripeLoadFailed: '支付组件加载失败,请刷新页面重试',
|
||||
stripeMissingParams: '缺少订单ID或支付密钥',
|
||||
stripeNotConfigured: 'Stripe 未配置',
|
||||
airwallexLoadFailed: 'Airwallex 支付组件加载失败,请刷新页面重试',
|
||||
airwallexMissingParams: '缺少 Airwallex 支付参数',
|
||||
errors: {
|
||||
tooManyPending: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单',
|
||||
cancelRateLimited: '取消订单过于频繁,请稍后再试',
|
||||
wechatH5NotAuthorized: '当前商户未开通微信 H5 支付,请在微信中打开当前页面继续支付。',
|
||||
wechatPaymentMpNotConfigured: '当前站点未完成公众号/JSAPI 支付配置,暂时无法在微信内直接拉起支付。',
|
||||
wechatJsapiUnavailable: '当前环境未能拉起微信支付,请确认正在微信内打开本页后重试。',
|
||||
wechatJsapiFailed: '微信支付未完成,请重新拉起支付或改用扫码支付。',
|
||||
wechatUnavailable: '当前微信支付暂不可用,请稍后重试。',
|
||||
wechatOpenInWeChatHint: '请复制当前页面链接到微信内打开,或直接改用电脑端微信扫码支付。',
|
||||
wechatScanOnDesktopHint: '电脑端请直接使用微信扫一扫完成支付;移动端请在微信内打开当前页面。',
|
||||
wechatSwitchBrowserHint: '请改用电脑端微信扫码,或在外部浏览器重新打开本页后再试。',
|
||||
mobilePaymentFallbackToQr: '当前商户未开通移动支付,已自动切换为扫码支付。',
|
||||
alipayDesktopUnavailable: '当前支付宝桌面支付未成功生成二维码。',
|
||||
alipayDesktopQrHint: '电脑端支付宝应展示扫码单,请刷新后重试,或确认浏览器未拦截当前支付页。',
|
||||
alipayMobileUnavailable: '当前页面未成功跳转到支付宝。',
|
||||
alipayMobileOpenHint: '请允许当前页面打开支付宝 App,或改用系统浏览器重新发起支付。',
|
||||
// Structured error codes (reason strings from backend ApplicationError)
|
||||
PAYMENT_DISABLED: '支付系统已关闭',
|
||||
USER_INACTIVE: '账号已被禁用',
|
||||
BALANCE_PAYMENT_DISABLED: '余额充值功能已关闭',
|
||||
INVALID_AMOUNT: '金额无效',
|
||||
INVALID_INPUT: '参数有误',
|
||||
PLAN_NOT_AVAILABLE: '套餐不存在或已下架',
|
||||
GROUP_NOT_FOUND: '订阅分组不可用',
|
||||
GROUP_TYPE_MISMATCH: '分组类型不是订阅类型',
|
||||
TOO_MANY_PENDING: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单',
|
||||
DAILY_LIMIT_EXCEEDED: '今日充值已达上限,剩余额度 {remaining}',
|
||||
PAYMENT_GATEWAY_ERROR: '支付方式不可用',
|
||||
NO_AVAILABLE_INSTANCE: '暂无可用的支付通道',
|
||||
PAYMENT_PROVIDER_MISCONFIGURED: '支付通道配置错误,请联系管理员',
|
||||
WXPAY_CONFIG_MISSING_KEY: '微信支付配置缺少必填项:{key}',
|
||||
WXPAY_CONFIG_INVALID_KEY_LENGTH: '微信支付 {key} 长度错误,应为 {expected} 字节(实际 {actual})',
|
||||
WXPAY_CONFIG_INVALID_KEY: '微信支付 {key} 格式错误,请确认复制了完整的 PEM 内容',
|
||||
PENDING_ORDERS: '该服务商有未完成的订单,请等待订单完成后再操作',
|
||||
PAYMENT_PROVIDER_CONFLICT: '该支付方式已有其他启用中的服务商实例,请先停用后再继续。',
|
||||
CANCEL_RATE_LIMITED: '取消订单过于频繁,请稍后再试',
|
||||
NOT_FOUND: '订单不存在',
|
||||
FORBIDDEN: '无权限操作此订单',
|
||||
CONFLICT: '订单状态已变更,请刷新',
|
||||
INVALID_ORDER_TYPE: '仅余额订单可申请退款',
|
||||
INVALID_STATUS: '当前订单状态不允许此操作',
|
||||
BALANCE_NOT_ENOUGH: '退款金额超过余额',
|
||||
REFUND_AMOUNT_EXCEEDED: '退款金额超过充值金额',
|
||||
REFUND_FAILED: '退款失败',
|
||||
},
|
||||
airwallexPay: 'Airwallex 支付',
|
||||
stripePay: '立即支付',
|
||||
stripeSuccessProcessing: '支付成功,正在处理订单...',
|
||||
stripePopup: {
|
||||
redirecting: '正在跳转到支付页面...',
|
||||
loadingQr: '正在获取微信支付二维码...',
|
||||
timeout: '等待支付凭证超时,请重试',
|
||||
qrFailed: '未能获取微信支付二维码',
|
||||
},
|
||||
subscribeNow: '立即开通',
|
||||
renewNow: '续费',
|
||||
selectPlan: '选择套餐',
|
||||
planFeatures: '功能特性',
|
||||
planCard: {
|
||||
rate: '倍率',
|
||||
peakRate: '高峰倍率',
|
||||
dailyLimit: '日限额',
|
||||
weeklyLimit: '周限额',
|
||||
monthlyLimit: '月限额',
|
||||
quota: '配额',
|
||||
unlimited: '无限制',
|
||||
models: '模型',
|
||||
},
|
||||
days: '天',
|
||||
months: '个月',
|
||||
years: '年',
|
||||
oneMonth: '1 个月',
|
||||
oneYear: '1 年',
|
||||
perMonth: '月',
|
||||
perYear: '年',
|
||||
admin: {
|
||||
tabs: {
|
||||
overview: '概览',
|
||||
orders: '订单管理',
|
||||
channels: '支付渠道',
|
||||
plans: '订阅套餐',
|
||||
},
|
||||
todayRevenue: '今日收入',
|
||||
totalRevenue: '总收入',
|
||||
todayOrders: '今日订单',
|
||||
orderCount: '订单数',
|
||||
avgAmount: '平均金额',
|
||||
revenue: '收入',
|
||||
dailyRevenue: '每日收入',
|
||||
paymentDistribution: '支付方式分布',
|
||||
colUser: '用户',
|
||||
topUsers: '消费排行',
|
||||
noData: '暂无数据',
|
||||
days: '天',
|
||||
weeks: '周',
|
||||
months: '月',
|
||||
searchOrders: '搜索订单...',
|
||||
allStatuses: '全部状态',
|
||||
allPaymentTypes: '全部支付方式',
|
||||
allOrderTypes: '全部订单类型',
|
||||
orderDetail: '订单详情',
|
||||
orderType: '订单类型',
|
||||
orders: '订单',
|
||||
balanceOrder: '余额充值',
|
||||
subscriptionOrder: '订阅',
|
||||
paidAt: '支付时间',
|
||||
completedAt: '完成时间',
|
||||
expiresAt: '过期时间',
|
||||
feeRate: '手续费率',
|
||||
refund: '退款',
|
||||
refundOrder: '退款订单',
|
||||
refundAmount: '退款金额',
|
||||
maxRefundable: '最大可退金额',
|
||||
refundReason: '退款原因',
|
||||
refundReasonPlaceholder: '请输入退款原因',
|
||||
confirmRefund: '确认退款',
|
||||
refundSuccess: '退款成功',
|
||||
refundPending: '退款处理中,待网关确认',
|
||||
queryRefundStatus: '查询退款状态',
|
||||
refundInfo: '退款信息',
|
||||
refundEnabled: '允许退款',
|
||||
alreadyRefunded: '已退款',
|
||||
deductBalance: '扣除余额',
|
||||
deductBalanceHint: '从用户余额中扣回充值金额',
|
||||
userBalance: '用户余额',
|
||||
orderAmount: '订单金额',
|
||||
insufficientBalance: '余额不足,将扣至 $0',
|
||||
noDeduction: '将不扣除用户余额',
|
||||
forceRefund: '强制退款(忽略余额检查)',
|
||||
orderCancelled: '订单已取消',
|
||||
retry: '重试',
|
||||
retrySuccess: '重试成功',
|
||||
approveRefund: '批准退款',
|
||||
retryRefund: '重试退款',
|
||||
refundRequestInfo: '退款申请信息',
|
||||
refundRequestedAt: '申请时间',
|
||||
refundRequestedBy: '申请人',
|
||||
refundRequestReason: '申请原因',
|
||||
auditLogs: '操作日志',
|
||||
operator: '操作人',
|
||||
channelName: '渠道名称',
|
||||
channelDescription: '渠道描述',
|
||||
createChannel: '创建渠道',
|
||||
editChannel: '编辑渠道',
|
||||
deleteChannel: '删除渠道',
|
||||
deleteChannelConfirm: '确定要删除此渠道吗?',
|
||||
planName: '套餐名称',
|
||||
planDescription: '套餐描述',
|
||||
createPlan: '创建套餐',
|
||||
editPlan: '编辑套餐',
|
||||
deletePlan: '删除套餐',
|
||||
deletePlanConfirm: '确定要删除此套餐吗?',
|
||||
originalPrice: '原价',
|
||||
price: '价格',
|
||||
subscriptionCnyPayPreview: 'CNY 通道实扣预览:{amount}',
|
||||
subscriptionCnyPayPreviewWithFee: '(含 {feeRate}% 手续费:{total})',
|
||||
validityDays: '有效期(天)',
|
||||
validityUnit: '有效期单位',
|
||||
sortOrder: '排序',
|
||||
forSale: '上架状态',
|
||||
onSale: '上架',
|
||||
offSale: '下架',
|
||||
group: '分组',
|
||||
groupId: '分组 ID',
|
||||
features: '功能特性',
|
||||
featuresHint: '每行一个特性',
|
||||
featuresPlaceholder: '输入套餐特性...',
|
||||
providerManagement: '服务商管理',
|
||||
providerManagementDesc: '管理支付服务商实例',
|
||||
createProvider: '创建服务商',
|
||||
editProvider: '编辑服务商',
|
||||
deleteProvider: '删除服务商',
|
||||
deleteProviderConfirm: '确定要删除此服务商吗?',
|
||||
providerName: '服务商名称',
|
||||
providerKey: '服务商标识',
|
||||
selectProviderKey: '选择服务商标识',
|
||||
providerConfig: '服务商配置',
|
||||
noProviders: '暂无服务商',
|
||||
noProvidersHint: '创建一个服务商实例以开始接受支付',
|
||||
supportedTypes: '支持的支付方式',
|
||||
supportedTypesHint: '选择此服务商支持的支付方式',
|
||||
rateMultiplier: '费率倍数',
|
||||
dashboardTitle: '支付概览',
|
||||
dashboardDesc: '充值订单统计与分析',
|
||||
daySuffix: '天',
|
||||
paymentConfigTitle: '支付配置',
|
||||
paymentConfigDesc: '管理支付服务商与相关设置',
|
||||
plansPageTitle: '订阅套餐管理',
|
||||
plansPageDesc: '管理订阅套餐配置',
|
||||
tabPlanConfig: '套餐配置',
|
||||
tabUserSubs: '用户订阅',
|
||||
selectGroup: '请选择分组',
|
||||
groupRequired: '请选择订阅分组',
|
||||
priceRequired: '价格必须大于 0',
|
||||
validityDaysRequired: '有效期天数必须大于 0',
|
||||
groupMissing: '缺失',
|
||||
groupInfo: '分组信息',
|
||||
platform: '平台',
|
||||
rateMultiplierLabel: '倍率',
|
||||
dailyLimit: '日限额',
|
||||
weeklyLimit: '周限额',
|
||||
monthlyLimit: '月限额',
|
||||
unlimited: '无限制',
|
||||
searchUserSubs: '搜索用户订阅...',
|
||||
daily: '日',
|
||||
weekly: '周',
|
||||
monthly: '月',
|
||||
subsStatus: {
|
||||
active: '生效中',
|
||||
expired: '已过期',
|
||||
revoked: '已撤销',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user