Merge remote-tracking branch 'upstream/main'
# Conflicts: # backend/internal/server/api_contract_test.go # backend/internal/service/setting_service.go # deploy/docker-compose.yml # frontend/src/components/layout/AppSidebar.vue # frontend/src/views/admin/SettingsView.vue
This commit is contained in:
@@ -50,14 +50,138 @@ func TestAPIContracts(t *testing.T) {
|
||||
"data": {
|
||||
"id": 1,
|
||||
"email": "alice@example.com",
|
||||
"email_bound": true,
|
||||
"username": "alice",
|
||||
"role": "user",
|
||||
"balance": 12.5,
|
||||
"concurrency": 5,
|
||||
"rpm_limit": 0,
|
||||
"status": "active",
|
||||
"allowed_groups": null,
|
||||
"created_at": "2025-01-02T03:04:05Z",
|
||||
"updated_at": "2025-01-02T03:04:05Z",
|
||||
"balance_notify_enabled": false,
|
||||
"balance_notify_threshold_type": "",
|
||||
"balance_notify_threshold": null,
|
||||
"balance_notify_extra_emails": null,
|
||||
"total_recharged": 0,
|
||||
"linuxdo_bound": false,
|
||||
"oidc_bound": false,
|
||||
"wechat_bound": false,
|
||||
"identities": {
|
||||
"email": {
|
||||
"provider": "email",
|
||||
"provider_key": "email",
|
||||
"bound": true,
|
||||
"bound_count": 1,
|
||||
"can_bind": false,
|
||||
"can_unbind": false,
|
||||
"display_name": "alice@example.com",
|
||||
"subject_hint": "a***e@example.com",
|
||||
"note_key": "profile.authBindings.notes.emailManagedFromProfile",
|
||||
"note": "Primary account email is managed from the profile form."
|
||||
},
|
||||
"linuxdo": {
|
||||
"provider": "linuxdo",
|
||||
"bound": false,
|
||||
"bound_count": 0,
|
||||
"can_bind": true,
|
||||
"can_unbind": false,
|
||||
"bind_start_path": "/api/v1/auth/oauth/linuxdo/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||
},
|
||||
"oidc": {
|
||||
"provider": "oidc",
|
||||
"bound": false,
|
||||
"bound_count": 0,
|
||||
"can_bind": true,
|
||||
"can_unbind": false,
|
||||
"bind_start_path": "/api/v1/auth/oauth/oidc/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||
},
|
||||
"wechat": {
|
||||
"provider": "wechat",
|
||||
"bound": false,
|
||||
"bound_count": 0,
|
||||
"can_bind": true,
|
||||
"can_unbind": false,
|
||||
"bind_start_path": "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||
}
|
||||
},
|
||||
"identity_bindings": {
|
||||
"email": {
|
||||
"provider": "email",
|
||||
"provider_key": "email",
|
||||
"bound": true,
|
||||
"bound_count": 1,
|
||||
"can_bind": false,
|
||||
"can_unbind": false,
|
||||
"display_name": "alice@example.com",
|
||||
"subject_hint": "a***e@example.com",
|
||||
"note_key": "profile.authBindings.notes.emailManagedFromProfile",
|
||||
"note": "Primary account email is managed from the profile form."
|
||||
},
|
||||
"linuxdo": {
|
||||
"provider": "linuxdo",
|
||||
"bound": false,
|
||||
"bound_count": 0,
|
||||
"can_bind": true,
|
||||
"can_unbind": false,
|
||||
"bind_start_path": "/api/v1/auth/oauth/linuxdo/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||
},
|
||||
"oidc": {
|
||||
"provider": "oidc",
|
||||
"bound": false,
|
||||
"bound_count": 0,
|
||||
"can_bind": true,
|
||||
"can_unbind": false,
|
||||
"bind_start_path": "/api/v1/auth/oauth/oidc/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||
},
|
||||
"wechat": {
|
||||
"provider": "wechat",
|
||||
"bound": false,
|
||||
"bound_count": 0,
|
||||
"can_bind": true,
|
||||
"can_unbind": false,
|
||||
"bind_start_path": "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||
}
|
||||
},
|
||||
"auth_bindings": {
|
||||
"email": {
|
||||
"provider": "email",
|
||||
"provider_key": "email",
|
||||
"bound": true,
|
||||
"bound_count": 1,
|
||||
"can_bind": false,
|
||||
"can_unbind": false,
|
||||
"display_name": "alice@example.com",
|
||||
"subject_hint": "a***e@example.com",
|
||||
"note_key": "profile.authBindings.notes.emailManagedFromProfile",
|
||||
"note": "Primary account email is managed from the profile form."
|
||||
},
|
||||
"linuxdo": {
|
||||
"provider": "linuxdo",
|
||||
"bound": false,
|
||||
"bound_count": 0,
|
||||
"can_bind": true,
|
||||
"can_unbind": false,
|
||||
"bind_start_path": "/api/v1/auth/oauth/linuxdo/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||
},
|
||||
"oidc": {
|
||||
"provider": "oidc",
|
||||
"bound": false,
|
||||
"bound_count": 0,
|
||||
"can_bind": true,
|
||||
"can_unbind": false,
|
||||
"bind_start_path": "/api/v1/auth/oauth/oidc/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||
},
|
||||
"wechat": {
|
||||
"provider": "wechat",
|
||||
"bound": false,
|
||||
"bound_count": 0,
|
||||
"can_bind": true,
|
||||
"can_unbind": false,
|
||||
"bind_start_path": "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile"
|
||||
}
|
||||
},
|
||||
"run_mode": "standard"
|
||||
}
|
||||
}`,
|
||||
@@ -204,18 +328,13 @@ func TestAPIContracts(t *testing.T) {
|
||||
"image_price_1k": null,
|
||||
"image_price_2k": null,
|
||||
"image_price_4k": null,
|
||||
"sora_image_price_360": null,
|
||||
"sora_image_price_540": null,
|
||||
"sora_storage_quota_bytes": 0,
|
||||
"sora_video_price_per_request": null,
|
||||
"sora_video_price_per_request_hd": null,
|
||||
"claude_code_only": false,
|
||||
"claude_code_only": false,
|
||||
"allow_messages_dispatch": false,
|
||||
"fallback_group_id": null,
|
||||
"fallback_group_id_on_invalid_request": null,
|
||||
"allow_messages_dispatch": false,
|
||||
"require_oauth_only": false,
|
||||
"require_privacy_set": false,
|
||||
"rpm_limit": 0,
|
||||
"created_at": "2025-01-02T03:04:05Z",
|
||||
"updated_at": "2025-01-02T03:04:05Z"
|
||||
}
|
||||
@@ -467,6 +586,28 @@ func TestAPIContracts(t *testing.T) {
|
||||
service.SettingKeyTurnstileSiteKey: "site-key",
|
||||
service.SettingKeyTurnstileSecretKey: "secret-key",
|
||||
|
||||
service.SettingKeyOIDCConnectEnabled: "false",
|
||||
service.SettingKeyOIDCConnectProviderName: "OIDC",
|
||||
service.SettingKeyOIDCConnectClientID: "",
|
||||
service.SettingKeyOIDCConnectIssuerURL: "",
|
||||
service.SettingKeyOIDCConnectDiscoveryURL: "",
|
||||
service.SettingKeyOIDCConnectAuthorizeURL: "",
|
||||
service.SettingKeyOIDCConnectTokenURL: "",
|
||||
service.SettingKeyOIDCConnectUserInfoURL: "",
|
||||
service.SettingKeyOIDCConnectJWKSURL: "",
|
||||
service.SettingKeyOIDCConnectScopes: "openid email profile",
|
||||
service.SettingKeyOIDCConnectRedirectURL: "",
|
||||
service.SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback",
|
||||
service.SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post",
|
||||
service.SettingKeyOIDCConnectUsePKCE: "true",
|
||||
service.SettingKeyOIDCConnectValidateIDToken: "true",
|
||||
service.SettingKeyOIDCConnectAllowedSigningAlgs: "RS256,ES256,PS256",
|
||||
service.SettingKeyOIDCConnectClockSkewSeconds: "120",
|
||||
service.SettingKeyOIDCConnectRequireEmailVerified: "false",
|
||||
service.SettingKeyOIDCConnectUserInfoEmailPath: "",
|
||||
service.SettingKeyOIDCConnectUserInfoIDPath: "",
|
||||
service.SettingKeyOIDCConnectUserInfoUsernamePath: "",
|
||||
|
||||
service.SettingKeySiteName: "TianShuAPI",
|
||||
service.SettingKeySiteLogo: "",
|
||||
service.SettingKeySiteSubtitle: "Subtitle",
|
||||
@@ -474,13 +615,20 @@ func TestAPIContracts(t *testing.T) {
|
||||
service.SettingKeyContactInfo: "support",
|
||||
service.SettingKeyDocURL: "https://docs.example.com",
|
||||
|
||||
service.SettingKeyDefaultConcurrency: "5",
|
||||
service.SettingKeyDefaultBalance: "1.25",
|
||||
service.SettingKeyDefaultConcurrency: "5",
|
||||
service.SettingKeyDefaultBalance: "1.25",
|
||||
service.SettingKeyTableDefaultPageSize: "20",
|
||||
service.SettingKeyTablePageSizeOptions: "[10,20,50,100]",
|
||||
|
||||
service.SettingKeyOpsMonitoringEnabled: "false",
|
||||
service.SettingKeyOpsRealtimeMonitoringEnabled: "true",
|
||||
service.SettingKeyOpsQueryModeDefault: "auto",
|
||||
service.SettingKeyOpsMetricsIntervalSeconds: "60",
|
||||
service.SettingKeyOpsMonitoringEnabled: "false",
|
||||
service.SettingKeyOpsRealtimeMonitoringEnabled: "true",
|
||||
service.SettingKeyOpsQueryModeDefault: "auto",
|
||||
service.SettingKeyOpsMetricsIntervalSeconds: "60",
|
||||
service.SettingPaymentVisibleMethodAlipaySource: service.VisibleMethodSourceEasyPayAlipay,
|
||||
service.SettingPaymentVisibleMethodWxpaySource: service.VisibleMethodSourceOfficialWechat,
|
||||
service.SettingPaymentVisibleMethodAlipayEnabled: "true",
|
||||
service.SettingPaymentVisibleMethodWxpayEnabled: "false",
|
||||
"openai_advanced_scheduler_enabled": "true",
|
||||
})
|
||||
},
|
||||
method: http.MethodGet,
|
||||
@@ -508,10 +656,32 @@ func TestAPIContracts(t *testing.T) {
|
||||
"turnstile_enabled": true,
|
||||
"turnstile_site_key": "site-key",
|
||||
"turnstile_secret_key_configured": true,
|
||||
"linuxdo_connect_enabled": false,
|
||||
"linuxdo_connect_enabled": false,
|
||||
"linuxdo_connect_client_id": "",
|
||||
"linuxdo_connect_client_secret_configured": false,
|
||||
"linuxdo_connect_redirect_url": "",
|
||||
"oidc_connect_enabled": false,
|
||||
"oidc_connect_provider_name": "OIDC",
|
||||
"oidc_connect_client_id": "",
|
||||
"oidc_connect_client_secret_configured": false,
|
||||
"oidc_connect_issuer_url": "",
|
||||
"oidc_connect_discovery_url": "",
|
||||
"oidc_connect_authorize_url": "",
|
||||
"oidc_connect_token_url": "",
|
||||
"oidc_connect_userinfo_url": "",
|
||||
"oidc_connect_jwks_url": "",
|
||||
"oidc_connect_scopes": "openid email profile",
|
||||
"oidc_connect_redirect_url": "",
|
||||
"oidc_connect_frontend_redirect_url": "/auth/oidc/callback",
|
||||
"oidc_connect_token_auth_method": "client_secret_post",
|
||||
"oidc_connect_use_pkce": true,
|
||||
"oidc_connect_validate_id_token": true,
|
||||
"oidc_connect_allowed_signing_algs": "RS256,ES256,PS256",
|
||||
"oidc_connect_clock_skew_seconds": 120,
|
||||
"oidc_connect_require_email_verified": false,
|
||||
"oidc_connect_userinfo_email_path": "",
|
||||
"oidc_connect_userinfo_id_path": "",
|
||||
"oidc_connect_userinfo_username_path": "",
|
||||
"ops_monitoring_enabled": false,
|
||||
"ops_realtime_monitoring_enabled": true,
|
||||
"ops_query_mode_default": "auto",
|
||||
@@ -522,8 +692,34 @@ func TestAPIContracts(t *testing.T) {
|
||||
"api_base_url": "https://api.example.com",
|
||||
"contact_info": "support",
|
||||
"doc_url": "https://docs.example.com",
|
||||
"auth_source_default_email_balance": 0,
|
||||
"auth_source_default_email_concurrency": 5,
|
||||
"auth_source_default_email_subscriptions": [],
|
||||
"auth_source_default_email_grant_on_signup": false,
|
||||
"auth_source_default_email_grant_on_first_bind": false,
|
||||
"auth_source_default_linuxdo_balance": 0,
|
||||
"auth_source_default_linuxdo_concurrency": 5,
|
||||
"auth_source_default_linuxdo_subscriptions": [],
|
||||
"auth_source_default_linuxdo_grant_on_signup": false,
|
||||
"auth_source_default_linuxdo_grant_on_first_bind": false,
|
||||
"auth_source_default_oidc_balance": 0,
|
||||
"auth_source_default_oidc_concurrency": 5,
|
||||
"auth_source_default_oidc_subscriptions": [],
|
||||
"auth_source_default_oidc_grant_on_signup": false,
|
||||
"auth_source_default_oidc_grant_on_first_bind": false,
|
||||
"auth_source_default_wechat_balance": 0,
|
||||
"auth_source_default_wechat_concurrency": 5,
|
||||
"auth_source_default_wechat_subscriptions": [],
|
||||
"auth_source_default_wechat_grant_on_signup": false,
|
||||
"auth_source_default_wechat_grant_on_first_bind": false,
|
||||
"force_email_on_third_party_signup": false,
|
||||
"default_concurrency": 5,
|
||||
"default_balance": 1.25,
|
||||
"affiliate_rebate_rate": 20,
|
||||
"affiliate_rebate_freeze_hours": 0,
|
||||
"affiliate_rebate_duration_days": 0,
|
||||
"affiliate_rebate_per_invitee_cap": 0,
|
||||
"default_user_rpm_limit": 0,
|
||||
"default_subscriptions": [],
|
||||
"enable_model_fallback": false,
|
||||
"fallback_model_anthropic": "claude-3-5-sonnet-20241022",
|
||||
@@ -532,20 +728,274 @@ func TestAPIContracts(t *testing.T) {
|
||||
"fallback_model_openai": "gpt-4o",
|
||||
"enable_identity_patch": true,
|
||||
"identity_patch_prompt": "",
|
||||
"sora_client_enabled": false,
|
||||
"invitation_code_enabled": false,
|
||||
"home_content": "",
|
||||
"hide_ccs_import_button": false,
|
||||
"purchase_subscription_enabled": false,
|
||||
"purchase_subscription_url": "",
|
||||
"table_default_page_size": 20,
|
||||
"table_page_size_options": [10, 20, 50, 100],
|
||||
"min_claude_code_version": "",
|
||||
"max_claude_code_version": "",
|
||||
"allow_ungrouped_key_scheduling": false,
|
||||
"backend_mode_enabled": false,
|
||||
"enable_cch_signing": false,
|
||||
"enable_fingerprint_unification": true,
|
||||
"enable_metadata_passthrough": false,
|
||||
"web_search_emulation_enabled": false,
|
||||
"payment_visible_method_alipay_source": "easypay_alipay",
|
||||
"payment_visible_method_wxpay_source": "official_wxpay",
|
||||
"payment_visible_method_alipay_enabled": true,
|
||||
"payment_visible_method_wxpay_enabled": false,
|
||||
"openai_advanced_scheduler_enabled": true,
|
||||
"custom_menu_items": [],
|
||||
"custom_endpoints": [],
|
||||
"payment_enabled": false,
|
||||
"payment_min_amount": 0,
|
||||
"payment_max_amount": 0,
|
||||
"payment_daily_limit": 0,
|
||||
"payment_order_timeout_minutes": 0,
|
||||
"payment_max_pending_orders": 0,
|
||||
"payment_balance_disabled": false,
|
||||
"payment_balance_recharge_multiplier": 0,
|
||||
"payment_recharge_fee_rate": 0,
|
||||
"payment_load_balance_strategy": "",
|
||||
"payment_product_name_prefix": "",
|
||||
"payment_product_name_suffix": "",
|
||||
"payment_help_image_url": "",
|
||||
"payment_help_text": "",
|
||||
"payment_enabled_types": null,
|
||||
"payment_cancel_rate_limit_enabled": false,
|
||||
"payment_cancel_rate_limit_max": 0,
|
||||
"payment_cancel_rate_limit_window": 0,
|
||||
"payment_cancel_rate_limit_unit": "",
|
||||
"payment_cancel_rate_limit_window_mode": "",
|
||||
"balance_low_notify_enabled": false,
|
||||
"account_quota_notify_enabled": false,
|
||||
"balance_low_notify_threshold": 0,
|
||||
"balance_low_notify_recharge_url": "",
|
||||
"account_quota_notify_emails": [],
|
||||
"channel_monitor_enabled": true,
|
||||
"channel_monitor_default_interval_seconds": 60,
|
||||
"available_channels_enabled": false,
|
||||
"affiliate_enabled": false,
|
||||
"wechat_connect_enabled": false,
|
||||
"wechat_connect_app_id": "",
|
||||
"wechat_connect_app_secret_configured": false,
|
||||
"wechat_connect_mode": "open",
|
||||
"wechat_connect_open_enabled": false,
|
||||
"wechat_connect_open_app_id": "",
|
||||
"wechat_connect_open_app_secret_configured": false,
|
||||
"wechat_connect_mp_enabled": false,
|
||||
"wechat_connect_mp_app_id": "",
|
||||
"wechat_connect_mp_app_secret_configured": false,
|
||||
"wechat_connect_mobile_enabled": false,
|
||||
"wechat_connect_mobile_app_id": "",
|
||||
"wechat_connect_mobile_app_secret_configured": false,
|
||||
"wechat_connect_redirect_url": "",
|
||||
"wechat_connect_frontend_redirect_url": "/auth/wechat/callback",
|
||||
"wechat_connect_scopes": "snsapi_login"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "GET /api/v1/admin/settings falls back to config oauth defaults",
|
||||
setup: func(t *testing.T, deps *contractDeps) {
|
||||
t.Helper()
|
||||
deps.cfg.OIDC = config.OIDCConnectConfig{
|
||||
Enabled: true,
|
||||
ProviderName: "ConfigOIDC",
|
||||
ClientID: "oidc-config-client",
|
||||
ClientSecret: "oidc-config-secret",
|
||||
IssuerURL: "https://issuer.example.com",
|
||||
RedirectURL: "https://api.example.com/api/v1/auth/oauth/oidc/callback",
|
||||
FrontendRedirectURL: "/auth/oidc/callback",
|
||||
Scopes: "openid email profile",
|
||||
TokenAuthMethod: "client_secret_post",
|
||||
UsePKCE: true,
|
||||
ValidateIDToken: true,
|
||||
AllowedSigningAlgs: "RS256,ES256,PS256",
|
||||
ClockSkewSeconds: 120,
|
||||
}
|
||||
deps.cfg.WeChat = config.WeChatConnectConfig{
|
||||
Enabled: true,
|
||||
OpenEnabled: true,
|
||||
OpenAppID: "wx-open-config",
|
||||
OpenAppSecret: "wx-open-secret",
|
||||
Mode: "open",
|
||||
Scopes: "snsapi_login",
|
||||
FrontendRedirectURL: "/auth/wechat/callback",
|
||||
}
|
||||
deps.settingRepo.SetAll(map[string]string{
|
||||
service.SettingKeyRegistrationEnabled: "true",
|
||||
service.SettingKeyEmailVerifyEnabled: "false",
|
||||
service.SettingKeyRegistrationEmailSuffixWhitelist: "[]",
|
||||
})
|
||||
},
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/admin/settings",
|
||||
wantStatus: http.StatusOK,
|
||||
wantJSON: `{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"registration_enabled": true,
|
||||
"email_verify_enabled": false,
|
||||
"registration_email_suffix_whitelist": [],
|
||||
"promo_code_enabled": true,
|
||||
"password_reset_enabled": false,
|
||||
"frontend_url": "",
|
||||
"invitation_code_enabled": false,
|
||||
"totp_enabled": false,
|
||||
"totp_encryption_key_configured": false,
|
||||
"smtp_host": "",
|
||||
"smtp_port": 587,
|
||||
"smtp_username": "",
|
||||
"smtp_password_configured": false,
|
||||
"smtp_from_email": "",
|
||||
"smtp_from_name": "",
|
||||
"smtp_use_tls": false,
|
||||
"turnstile_enabled": false,
|
||||
"turnstile_site_key": "",
|
||||
"turnstile_secret_key_configured": false,
|
||||
"linuxdo_connect_enabled": false,
|
||||
"linuxdo_connect_client_id": "",
|
||||
"linuxdo_connect_client_secret_configured": false,
|
||||
"linuxdo_connect_redirect_url": "",
|
||||
"oidc_connect_enabled": true,
|
||||
"oidc_connect_provider_name": "ConfigOIDC",
|
||||
"oidc_connect_client_id": "oidc-config-client",
|
||||
"oidc_connect_client_secret_configured": true,
|
||||
"oidc_connect_issuer_url": "https://issuer.example.com",
|
||||
"oidc_connect_discovery_url": "",
|
||||
"oidc_connect_authorize_url": "",
|
||||
"oidc_connect_token_url": "",
|
||||
"oidc_connect_userinfo_url": "",
|
||||
"oidc_connect_jwks_url": "",
|
||||
"oidc_connect_scopes": "openid email profile",
|
||||
"oidc_connect_redirect_url": "https://api.example.com/api/v1/auth/oauth/oidc/callback",
|
||||
"oidc_connect_frontend_redirect_url": "/auth/oidc/callback",
|
||||
"oidc_connect_token_auth_method": "client_secret_post",
|
||||
"oidc_connect_use_pkce": true,
|
||||
"oidc_connect_validate_id_token": true,
|
||||
"oidc_connect_allowed_signing_algs": "RS256,ES256,PS256",
|
||||
"oidc_connect_clock_skew_seconds": 120,
|
||||
"oidc_connect_require_email_verified": false,
|
||||
"oidc_connect_userinfo_email_path": "",
|
||||
"oidc_connect_userinfo_id_path": "",
|
||||
"oidc_connect_userinfo_username_path": "",
|
||||
"site_name": "Sub2API",
|
||||
"site_logo": "",
|
||||
"site_subtitle": "Subscription to API Conversion Platform",
|
||||
"api_base_url": "",
|
||||
"contact_info": "",
|
||||
"doc_url": "",
|
||||
"home_content": "",
|
||||
"hide_ccs_import_button": false,
|
||||
"purchase_subscription_enabled": false,
|
||||
"purchase_subscription_url": "",
|
||||
"table_default_page_size": 20,
|
||||
"table_page_size_options": [10, 20, 50],
|
||||
"custom_menu_items": [],
|
||||
"custom_endpoints": [],
|
||||
"default_concurrency": 0,
|
||||
"default_balance": 0,
|
||||
"affiliate_rebate_rate": 20,
|
||||
"affiliate_rebate_freeze_hours": 0,
|
||||
"affiliate_rebate_duration_days": 0,
|
||||
"affiliate_rebate_per_invitee_cap": 0,
|
||||
"default_user_rpm_limit": 0,
|
||||
"default_subscriptions": [],
|
||||
"enable_model_fallback": false,
|
||||
"fallback_model_anthropic": "claude-3-5-sonnet-20241022",
|
||||
"fallback_model_openai": "gpt-4o",
|
||||
"fallback_model_gemini": "gemini-2.5-pro",
|
||||
"fallback_model_antigravity": "gemini-2.5-pro",
|
||||
"enable_identity_patch": true,
|
||||
"identity_patch_prompt": "",
|
||||
"ops_monitoring_enabled": false,
|
||||
"ops_realtime_monitoring_enabled": true,
|
||||
"ops_query_mode_default": "auto",
|
||||
"ops_metrics_interval_seconds": 60,
|
||||
"min_claude_code_version": "",
|
||||
"max_claude_code_version": "",
|
||||
"allow_ungrouped_key_scheduling": false,
|
||||
"backend_mode_enabled": false,
|
||||
"enable_fingerprint_unification": true,
|
||||
"enable_metadata_passthrough": false,
|
||||
"custom_menu_items": [],
|
||||
"custom_endpoints": []
|
||||
"enable_cch_signing": false,
|
||||
"web_search_emulation_enabled": false,
|
||||
"payment_visible_method_alipay_source": "",
|
||||
"payment_visible_method_wxpay_source": "",
|
||||
"payment_visible_method_alipay_enabled": false,
|
||||
"payment_visible_method_wxpay_enabled": false,
|
||||
"openai_advanced_scheduler_enabled": false,
|
||||
"payment_enabled": false,
|
||||
"payment_min_amount": 0,
|
||||
"payment_max_amount": 0,
|
||||
"payment_daily_limit": 0,
|
||||
"payment_order_timeout_minutes": 0,
|
||||
"payment_max_pending_orders": 0,
|
||||
"payment_enabled_types": null,
|
||||
"payment_balance_disabled": false,
|
||||
"payment_balance_recharge_multiplier": 0,
|
||||
"payment_recharge_fee_rate": 0,
|
||||
"payment_load_balance_strategy": "",
|
||||
"payment_product_name_prefix": "",
|
||||
"payment_product_name_suffix": "",
|
||||
"payment_help_image_url": "",
|
||||
"payment_help_text": "",
|
||||
"payment_cancel_rate_limit_enabled": false,
|
||||
"payment_cancel_rate_limit_max": 0,
|
||||
"payment_cancel_rate_limit_window": 0,
|
||||
"payment_cancel_rate_limit_unit": "",
|
||||
"payment_cancel_rate_limit_window_mode": "",
|
||||
"balance_low_notify_enabled": false,
|
||||
"account_quota_notify_enabled": false,
|
||||
"balance_low_notify_threshold": 0,
|
||||
"balance_low_notify_recharge_url": "",
|
||||
"account_quota_notify_emails": [],
|
||||
"channel_monitor_enabled": true,
|
||||
"channel_monitor_default_interval_seconds": 60,
|
||||
"available_channels_enabled": false,
|
||||
"affiliate_enabled": false,
|
||||
"wechat_connect_enabled": true,
|
||||
"wechat_connect_app_id": "wx-open-config",
|
||||
"wechat_connect_app_secret_configured": true,
|
||||
"wechat_connect_mode": "open",
|
||||
"wechat_connect_open_enabled": true,
|
||||
"wechat_connect_open_app_id": "wx-open-config",
|
||||
"wechat_connect_open_app_secret_configured": true,
|
||||
"wechat_connect_mp_enabled": false,
|
||||
"wechat_connect_mp_app_id": "wx-open-config",
|
||||
"wechat_connect_mp_app_secret_configured": true,
|
||||
"wechat_connect_mobile_enabled": false,
|
||||
"wechat_connect_mobile_app_id": "wx-open-config",
|
||||
"wechat_connect_mobile_app_secret_configured": true,
|
||||
"wechat_connect_redirect_url": "",
|
||||
"wechat_connect_frontend_redirect_url": "/auth/wechat/callback",
|
||||
"wechat_connect_scopes": "snsapi_login",
|
||||
"auth_source_default_email_balance": 0,
|
||||
"auth_source_default_email_concurrency": 5,
|
||||
"auth_source_default_email_subscriptions": [],
|
||||
"auth_source_default_email_grant_on_signup": false,
|
||||
"auth_source_default_email_grant_on_first_bind": false,
|
||||
"auth_source_default_linuxdo_balance": 0,
|
||||
"auth_source_default_linuxdo_concurrency": 5,
|
||||
"auth_source_default_linuxdo_subscriptions": [],
|
||||
"auth_source_default_linuxdo_grant_on_signup": false,
|
||||
"auth_source_default_linuxdo_grant_on_first_bind": false,
|
||||
"auth_source_default_oidc_balance": 0,
|
||||
"auth_source_default_oidc_concurrency": 5,
|
||||
"auth_source_default_oidc_subscriptions": [],
|
||||
"auth_source_default_oidc_grant_on_signup": false,
|
||||
"auth_source_default_oidc_grant_on_first_bind": false,
|
||||
"auth_source_default_wechat_balance": 0,
|
||||
"auth_source_default_wechat_concurrency": 5,
|
||||
"auth_source_default_wechat_subscriptions": [],
|
||||
"auth_source_default_wechat_grant_on_signup": false,
|
||||
"auth_source_default_wechat_grant_on_first_bind": false,
|
||||
"force_email_on_third_party_signup": false
|
||||
}
|
||||
}`,
|
||||
},
|
||||
@@ -592,6 +1042,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
type contractDeps struct {
|
||||
now time.Time
|
||||
router http.Handler
|
||||
cfg *config.Config
|
||||
apiKeyRepo *stubApiKeyRepo
|
||||
groupRepo *stubGroupRepo
|
||||
userSubRepo *stubUserSubscriptionRepo
|
||||
@@ -638,7 +1089,7 @@ func newContractDeps(t *testing.T) *contractDeps {
|
||||
RunMode: config.RunModeStandard,
|
||||
}
|
||||
|
||||
userService := service.NewUserService(userRepo, nil, nil)
|
||||
userService := service.NewUserService(userRepo, nil, nil, nil)
|
||||
apiKeyService := service.NewAPIKeyService(apiKeyRepo, userRepo, groupRepo, userSubRepo, nil, apiKeyCache, cfg)
|
||||
|
||||
usageRepo := newStubUsageLogRepo()
|
||||
@@ -653,11 +1104,11 @@ func newContractDeps(t *testing.T) *contractDeps {
|
||||
settingRepo := newStubSettingRepo()
|
||||
settingService := service.NewSettingService(settingRepo, cfg)
|
||||
|
||||
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, nil, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil)
|
||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
||||
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil, nil)
|
||||
adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil, nil, nil)
|
||||
adminAccountHandler := adminhandler.NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
jwtAuth := func(c *gin.Context) {
|
||||
@@ -712,6 +1163,7 @@ func newContractDeps(t *testing.T) *contractDeps {
|
||||
return &contractDeps{
|
||||
now: now,
|
||||
router: r,
|
||||
cfg: cfg,
|
||||
apiKeyRepo: apiKeyRepo,
|
||||
groupRepo: groupRepo,
|
||||
userSubRepo: userSubRepo,
|
||||
@@ -785,6 +1237,18 @@ func (r *stubUserRepo) Delete(ctx context.Context, id int64) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubUserRepo) GetUserAvatar(ctx context.Context, userID int64) (*service.UserAvatar, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *stubUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input service.UpsertUserAvatarInput) (*service.UserAvatar, error) {
|
||||
return nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubUserRepo) DeleteUserAvatar(ctx context.Context, userID int64) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubUserRepo) List(ctx context.Context, params pagination.PaginationParams) ([]service.User, *pagination.PaginationResult, error) {
|
||||
return nil, nil, errors.New("not implemented")
|
||||
}
|
||||
@@ -821,6 +1285,26 @@ func (r *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubUserRepo) ListUserAuthIdentities(ctx context.Context, userID int64) ([]service.UserAuthIdentityRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *stubUserRepo) UnbindUserAuthProvider(context.Context, int64, string) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubUserRepo) GetLatestUsedAtByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*time.Time, error) {
|
||||
return map[int64]*time.Time{}, nil
|
||||
}
|
||||
|
||||
func (r *stubUserRepo) GetLatestUsedAtByUserID(ctx context.Context, userID int64) (*time.Time, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *stubUserRepo) UpdateUserLastActiveAt(ctx context.Context, userID int64, activeAt time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubUserRepo) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/websearch"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
@@ -56,6 +59,42 @@ func ProvideRouter(
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up websearch Manager builder so it initializes on startup and rebuilds on config save.
|
||||
settingService.SetWebSearchManagerBuilder(context.Background(), func(cfg *service.WebSearchEmulationConfig, proxyURLs map[int64]string) {
|
||||
if cfg == nil || !cfg.Enabled || len(cfg.Providers) == 0 {
|
||||
service.SetWebSearchManager(nil)
|
||||
return
|
||||
}
|
||||
configs := make([]websearch.ProviderConfig, 0, len(cfg.Providers))
|
||||
for _, p := range cfg.Providers {
|
||||
if p.APIKey == "" {
|
||||
continue
|
||||
}
|
||||
pc := websearch.ProviderConfig{
|
||||
Type: p.Type,
|
||||
APIKey: p.APIKey,
|
||||
QuotaLimit: derefInt64(p.QuotaLimit),
|
||||
ExpiresAt: p.ExpiresAt,
|
||||
}
|
||||
if p.SubscribedAt != nil {
|
||||
pc.SubscribedAt = p.SubscribedAt
|
||||
}
|
||||
if p.ProxyID != nil {
|
||||
pc.ProxyID = *p.ProxyID
|
||||
if u, ok := proxyURLs[*p.ProxyID]; ok {
|
||||
pc.ProxyURL = u
|
||||
} else {
|
||||
// Proxy configured but not found — skip this provider to prevent direct connection.
|
||||
slog.Warn("websearch: proxy not found for provider, skipping",
|
||||
"provider", p.Type, "proxy_id", *p.ProxyID)
|
||||
continue
|
||||
}
|
||||
}
|
||||
configs = append(configs, pc)
|
||||
}
|
||||
service.SetWebSearchManager(websearch.NewManager(configs, redisClient))
|
||||
})
|
||||
|
||||
return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg, redisClient)
|
||||
}
|
||||
|
||||
@@ -102,3 +141,10 @@ func ProvideHTTPServer(cfg *config.Config, router *gin.Engine) *http.Server {
|
||||
// 不设置 ReadTimeout,因为大请求体可能需要较长时间读取
|
||||
}
|
||||
}
|
||||
|
||||
func derefInt64(p *int64) int64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
@@ -19,7 +20,7 @@ func TestAdminAuthJWTValidatesTokenVersion(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
cfg := &config.Config{JWT: config.JWTConfig{Secret: "test-secret", ExpireHour: 1}}
|
||||
authService := service.NewAuthService(nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil)
|
||||
authService := service.NewAuthService(nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
admin := &service.User{
|
||||
ID: 1,
|
||||
@@ -39,7 +40,7 @@ func TestAdminAuthJWTValidatesTokenVersion(t *testing.T) {
|
||||
return &clone, nil
|
||||
},
|
||||
}
|
||||
userService := service.NewUserService(userRepo, nil, nil)
|
||||
userService := service.NewUserService(userRepo, nil, nil, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(gin.HandlerFunc(NewAdminAuthMiddleware(authService, userService, nil)))
|
||||
@@ -153,6 +154,18 @@ func (s *stubUserRepo) Delete(ctx context.Context, id int64) error {
|
||||
panic("unexpected Delete call")
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) GetUserAvatar(ctx context.Context, userID int64) (*service.UserAvatar, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input service.UpsertUserAvatarInput) (*service.UserAvatar, error) {
|
||||
panic("unexpected UpsertUserAvatar call")
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) DeleteUserAvatar(ctx context.Context, userID int64) error {
|
||||
panic("unexpected DeleteUserAvatar call")
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) List(ctx context.Context, params pagination.PaginationParams) ([]service.User, *pagination.PaginationResult, error) {
|
||||
panic("unexpected List call")
|
||||
}
|
||||
@@ -161,6 +174,18 @@ func (s *stubUserRepo) ListWithFilters(ctx context.Context, params pagination.Pa
|
||||
panic("unexpected ListWithFilters call")
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) GetLatestUsedAtByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*time.Time, error) {
|
||||
panic("unexpected GetLatestUsedAtByUserIDs call")
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) GetLatestUsedAtByUserID(ctx context.Context, userID int64) (*time.Time, error) {
|
||||
panic("unexpected GetLatestUsedAtByUserID call")
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) UpdateUserLastActiveAt(ctx context.Context, userID int64, activeAt time.Time) error {
|
||||
panic("unexpected UpdateUserLastActiveAt call")
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) UpdateBalance(ctx context.Context, id int64, amount float64) error {
|
||||
panic("unexpected UpdateBalance call")
|
||||
}
|
||||
@@ -189,6 +214,14 @@ func (s *stubUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64
|
||||
panic("unexpected AddGroupToAllowedGroups call")
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) ListUserAuthIdentities(ctx context.Context, userID int64) ([]service.UserAuthIdentityRecord, error) {
|
||||
panic("unexpected ListUserAuthIdentities call")
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) UnbindUserAuthProvider(context.Context, int64, string) error {
|
||||
panic("unexpected UnbindUserAuthProvider call")
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error {
|
||||
panic("unexpected UpdateTotpSecret call")
|
||||
}
|
||||
|
||||
@@ -27,23 +27,50 @@ func BackendModeUserGuard(settingService *service.SettingService) gin.HandlerFun
|
||||
}
|
||||
}
|
||||
|
||||
func backendModeAllowsAuthPath(path string) bool {
|
||||
path = strings.ToLower(strings.TrimSpace(path))
|
||||
for _, suffix := range []string{"/auth/login", "/auth/login/2fa", "/auth/logout", "/auth/refresh"} {
|
||||
if strings.HasSuffix(path, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, suffix := range []string{
|
||||
"/auth/oauth/linuxdo/callback",
|
||||
"/auth/oauth/wechat/callback",
|
||||
"/auth/oauth/wechat/payment/callback",
|
||||
"/auth/oauth/oidc/callback",
|
||||
"/auth/oauth/linuxdo/complete-registration",
|
||||
"/auth/oauth/wechat/complete-registration",
|
||||
"/auth/oauth/oidc/complete-registration",
|
||||
"/auth/oauth/linuxdo/create-account",
|
||||
"/auth/oauth/wechat/create-account",
|
||||
"/auth/oauth/oidc/create-account",
|
||||
"/auth/oauth/linuxdo/bind-login",
|
||||
"/auth/oauth/wechat/bind-login",
|
||||
"/auth/oauth/oidc/bind-login",
|
||||
} {
|
||||
if strings.HasSuffix(path, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Contains(path, "/auth/oauth/pending/")
|
||||
}
|
||||
|
||||
// BackendModeAuthGuard selectively blocks auth endpoints when backend mode is enabled.
|
||||
// Allows: login, login/2fa, logout, refresh (admin needs these).
|
||||
// Blocks: register, forgot-password, reset-password, OAuth, etc.
|
||||
// Allows the minimal auth surface admins still need in backend mode, including
|
||||
// OAuth callbacks and pending continuations. Handler-level backend mode checks
|
||||
// still enforce admin-only login and forbid self-service registration.
|
||||
func BackendModeAuthGuard(settingService *service.SettingService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if settingService == nil || !settingService.IsBackendModeEnabled(c.Request.Context()) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
// Allow login, 2FA, logout, refresh, public settings
|
||||
allowedSuffixes := []string{"/auth/login", "/auth/login/2fa", "/auth/logout", "/auth/refresh"}
|
||||
for _, suffix := range allowedSuffixes {
|
||||
if strings.HasSuffix(path, suffix) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if backendModeAllowsAuthPath(c.Request.URL.Path) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
response.Forbidden(c, "Backend mode is active. Registration and self-service auth flows are disabled.")
|
||||
c.Abort()
|
||||
|
||||
@@ -198,6 +198,96 @@ func TestBackendModeAuthGuard(t *testing.T) {
|
||||
path: "/api/v1/auth/refresh",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "enabled_blocks_linuxdo_oauth_start",
|
||||
enabled: "true",
|
||||
path: "/api/v1/auth/oauth/linuxdo/start",
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "enabled_allows_linuxdo_oauth_callback",
|
||||
enabled: "true",
|
||||
path: "/api/v1/auth/oauth/linuxdo/callback",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "enabled_blocks_wechat_oauth_start",
|
||||
enabled: "true",
|
||||
path: "/api/v1/auth/oauth/wechat/start",
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "enabled_allows_wechat_oauth_callback",
|
||||
enabled: "true",
|
||||
path: "/api/v1/auth/oauth/wechat/callback",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "enabled_blocks_wechat_payment_oauth_start",
|
||||
enabled: "true",
|
||||
path: "/api/v1/auth/oauth/wechat/payment/start",
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "enabled_allows_wechat_payment_oauth_callback",
|
||||
enabled: "true",
|
||||
path: "/api/v1/auth/oauth/wechat/payment/callback",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "enabled_blocks_oidc_oauth_start",
|
||||
enabled: "true",
|
||||
path: "/api/v1/auth/oauth/oidc/start",
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "enabled_allows_oidc_oauth_callback",
|
||||
enabled: "true",
|
||||
path: "/api/v1/auth/oauth/oidc/callback",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "enabled_allows_oauth_pending_exchange",
|
||||
enabled: "true",
|
||||
path: "/api/v1/auth/oauth/pending/exchange",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "enabled_allows_oauth_pending_send_verify_code",
|
||||
enabled: "true",
|
||||
path: "/api/v1/auth/oauth/pending/send-verify-code",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "enabled_allows_oauth_pending_create_account",
|
||||
enabled: "true",
|
||||
path: "/api/v1/auth/oauth/pending/create-account",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "enabled_allows_oauth_pending_bind_login",
|
||||
enabled: "true",
|
||||
path: "/api/v1/auth/oauth/pending/bind-login",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "enabled_allows_provider_bind_login",
|
||||
enabled: "true",
|
||||
path: "/api/v1/auth/oauth/oidc/bind-login",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "enabled_allows_provider_create_account",
|
||||
enabled: "true",
|
||||
path: "/api/v1/auth/oauth/wechat/create-account",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "enabled_allows_legacy_complete_registration",
|
||||
enabled: "true",
|
||||
path: "/api/v1/auth/oauth/linuxdo/complete-registration",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "enabled_blocks_register",
|
||||
enabled: "true",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
@@ -11,11 +12,19 @@ import (
|
||||
|
||||
// NewJWTAuthMiddleware 创建 JWT 认证中间件
|
||||
func NewJWTAuthMiddleware(authService *service.AuthService, userService *service.UserService) JWTAuthMiddleware {
|
||||
return JWTAuthMiddleware(jwtAuth(authService, userService))
|
||||
return JWTAuthMiddleware(jwtAuth(authService, userService, userService))
|
||||
}
|
||||
|
||||
type jwtUserReader interface {
|
||||
GetByID(ctx context.Context, id int64) (*service.User, error)
|
||||
}
|
||||
|
||||
type userActivityToucher interface {
|
||||
TouchLastActiveForUser(ctx context.Context, user *service.User)
|
||||
}
|
||||
|
||||
// jwtAuth JWT认证中间件实现
|
||||
func jwtAuth(authService *service.AuthService, userService *service.UserService) gin.HandlerFunc {
|
||||
func jwtAuth(authService *service.AuthService, userService jwtUserReader, activityToucher userActivityToucher) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 从Authorization header中提取token
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
@@ -73,6 +82,9 @@ func jwtAuth(authService *service.AuthService, userService *service.UserService)
|
||||
Concurrency: user.Concurrency,
|
||||
})
|
||||
c.Set(string(ContextKeyUserRole), user.Role)
|
||||
if activityToucher != nil {
|
||||
activityToucher.TouchLastActiveForUser(c.Request.Context(), user)
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -30,6 +31,25 @@ func (r *stubJWTUserRepo) GetByID(_ context.Context, id int64) (*service.User, e
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *stubJWTUserRepo) GetUserAvatar(_ context.Context, _ int64) (*service.UserAvatar, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *stubJWTUserRepo) UpdateUserLastActiveAt(_ context.Context, _ int64, _ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type recordingActivityToucher struct {
|
||||
userIDs []int64
|
||||
}
|
||||
|
||||
func (r *recordingActivityToucher) TouchLastActiveForUser(_ context.Context, user *service.User) {
|
||||
if user == nil {
|
||||
return
|
||||
}
|
||||
r.userIDs = append(r.userIDs, user.ID)
|
||||
}
|
||||
|
||||
// newJWTTestEnv 创建 JWT 认证中间件测试环境。
|
||||
// 返回 gin.Engine(已注册 JWT 中间件)和 AuthService(用于生成 Token)。
|
||||
func newJWTTestEnv(users map[int64]*service.User) (*gin.Engine, *service.AuthService) {
|
||||
@@ -40,8 +60,8 @@ func newJWTTestEnv(users map[int64]*service.User) (*gin.Engine, *service.AuthSer
|
||||
cfg.JWT.AccessTokenExpireMinutes = 60
|
||||
|
||||
userRepo := &stubJWTUserRepo{users: users}
|
||||
authSvc := service.NewAuthService(nil, userRepo, nil, nil, cfg, nil, nil, nil, nil, nil, nil)
|
||||
userSvc := service.NewUserService(userRepo, nil, nil)
|
||||
authSvc := service.NewAuthService(nil, userRepo, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil)
|
||||
userSvc := service.NewUserService(userRepo, nil, nil, nil)
|
||||
mw := NewJWTAuthMiddleware(authSvc, userSvc)
|
||||
|
||||
r := gin.New()
|
||||
@@ -106,6 +126,45 @@ func TestJWTAuth_ValidToken_LowercaseBearer(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestJWTAuth_ValidToken_TouchesLastActive(t *testing.T) {
|
||||
user := &service.User{
|
||||
ID: 1,
|
||||
Email: "test@example.com",
|
||||
Role: "user",
|
||||
Status: service.StatusActive,
|
||||
Concurrency: 5,
|
||||
TokenVersion: 1,
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.JWT.Secret = "test-jwt-secret-32bytes-long!!!"
|
||||
cfg.JWT.AccessTokenExpireMinutes = 60
|
||||
|
||||
userRepo := &stubJWTUserRepo{users: map[int64]*service.User{1: user}}
|
||||
authSvc := service.NewAuthService(nil, userRepo, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil)
|
||||
userSvc := service.NewUserService(userRepo, nil, nil, nil)
|
||||
toucher := &recordingActivityToucher{}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(jwtAuth(authSvc, userSvc, toucher))
|
||||
r.GET("/protected", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
token, err := authSvc.GenerateToken(user)
|
||||
require.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, []int64{1}, toucher.userIDs)
|
||||
}
|
||||
|
||||
func TestJWTAuth_MissingAuthorizationHeader(t *testing.T) {
|
||||
router, _ := newJWTTestEnv(nil)
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ const (
|
||||
NonceTemplate = "__CSP_NONCE__"
|
||||
// CloudflareInsightsDomain is the domain for Cloudflare Web Analytics
|
||||
CloudflareInsightsDomain = "https://static.cloudflareinsights.com"
|
||||
// StripeDomain is the domain for Stripe.js SDK
|
||||
StripeDomain = "https://*.stripe.com"
|
||||
)
|
||||
|
||||
// GenerateNonce generates a cryptographically secure random nonce.
|
||||
@@ -94,12 +96,13 @@ func isAPIRoutePath(c *gin.Context) bool {
|
||||
return strings.HasPrefix(path, "/v1/") ||
|
||||
strings.HasPrefix(path, "/v1beta/") ||
|
||||
strings.HasPrefix(path, "/antigravity/") ||
|
||||
strings.HasPrefix(path, "/sora/") ||
|
||||
strings.HasPrefix(path, "/responses")
|
||||
strings.HasPrefix(path, "/responses") ||
|
||||
strings.HasPrefix(path, "/images")
|
||||
}
|
||||
|
||||
// enhanceCSPPolicy ensures the CSP policy includes nonce support and Cloudflare Insights domain.
|
||||
// This allows the application to work correctly even if the config file has an older CSP policy.
|
||||
// enhanceCSPPolicy ensures the CSP policy includes nonce support, Cloudflare Insights,
|
||||
// and Stripe.js domains. This allows the application to work correctly even if the
|
||||
// config file has an older CSP policy.
|
||||
func enhanceCSPPolicy(policy string) string {
|
||||
// Add nonce placeholder to script-src if not present
|
||||
if !strings.Contains(policy, NonceTemplate) && !strings.Contains(policy, "'nonce-") {
|
||||
@@ -111,6 +114,12 @@ func enhanceCSPPolicy(policy string) string {
|
||||
policy = addToDirective(policy, "script-src", CloudflareInsightsDomain)
|
||||
}
|
||||
|
||||
// Add Stripe.js domain to script-src and frame-src if not present
|
||||
if !strings.Contains(policy, "stripe.com") {
|
||||
policy = addToDirective(policy, "script-src", StripeDomain)
|
||||
policy = addToDirective(policy, "frame-src", StripeDomain)
|
||||
}
|
||||
|
||||
return policy
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ func registerRoutes(
|
||||
// 注册各模块路由
|
||||
routes.RegisterAuthRoutes(v1, h, jwtAuth, redisClient, settingService)
|
||||
routes.RegisterUserRoutes(v1, h, jwtAuth, settingService)
|
||||
routes.RegisterSoraClientRoutes(v1, h, jwtAuth, settingService)
|
||||
routes.RegisterAdminRoutes(v1, h, adminAuth)
|
||||
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg)
|
||||
routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, settingService)
|
||||
}
|
||||
|
||||
@@ -34,8 +34,6 @@ func RegisterAdminRoutes(
|
||||
|
||||
// OpenAI OAuth
|
||||
registerOpenAIOAuthRoutes(admin, h)
|
||||
// Sora OAuth(实现复用 OpenAI OAuth 服务,入口独立)
|
||||
registerSoraOAuthRoutes(admin, h)
|
||||
|
||||
// Gemini OAuth
|
||||
registerGeminiOAuthRoutes(admin, h)
|
||||
@@ -87,6 +85,15 @@ func RegisterAdminRoutes(
|
||||
|
||||
// 定时测试计划
|
||||
registerScheduledTestRoutes(admin, h)
|
||||
|
||||
// 渠道管理
|
||||
registerChannelRoutes(admin, h)
|
||||
|
||||
// 渠道监控
|
||||
registerChannelMonitorRoutes(admin, h)
|
||||
|
||||
// 邀请返利(专属用户管理)
|
||||
registerAffiliateRoutes(admin, h)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +218,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
{
|
||||
users.GET("", h.Admin.User.List)
|
||||
users.GET("/:id", h.Admin.User.GetByID)
|
||||
users.POST("/:id/auth-identities", h.Admin.User.BindAuthIdentity)
|
||||
users.POST("", h.Admin.User.Create)
|
||||
users.PUT("/:id", h.Admin.User.Update)
|
||||
users.DELETE("/:id", h.Admin.User.Delete)
|
||||
@@ -219,6 +227,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
users.GET("/:id/usage", h.Admin.User.GetUserUsage)
|
||||
users.GET("/:id/balance-history", h.Admin.User.GetBalanceHistory)
|
||||
users.POST("/:id/replace-group", h.Admin.User.ReplaceGroup)
|
||||
users.GET("/:id/rpm-status", h.Admin.User.GetUserRPMStatus)
|
||||
|
||||
// User attribute values
|
||||
users.GET("/:id/attributes", h.Admin.UserAttribute.GetUserAttributes)
|
||||
@@ -242,6 +251,8 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
groups.GET("/:id/rate-multipliers", h.Admin.Group.GetGroupRateMultipliers)
|
||||
groups.PUT("/:id/rate-multipliers", h.Admin.Group.BatchSetGroupRateMultipliers)
|
||||
groups.DELETE("/:id/rate-multipliers", h.Admin.Group.ClearGroupRateMultipliers)
|
||||
groups.PUT("/:id/rpm-overrides", h.Admin.Group.BatchSetGroupRPMOverrides)
|
||||
groups.DELETE("/:id/rpm-overrides", h.Admin.Group.ClearGroupRPMOverrides)
|
||||
groups.GET("/:id/api-keys", h.Admin.Group.GetGroupAPIKeys)
|
||||
}
|
||||
}
|
||||
@@ -318,19 +329,6 @@ func registerOpenAIOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
}
|
||||
}
|
||||
|
||||
func registerSoraOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
sora := admin.Group("/sora")
|
||||
{
|
||||
sora.POST("/generate-auth-url", h.Admin.OpenAIOAuth.GenerateAuthURL)
|
||||
sora.POST("/exchange-code", h.Admin.OpenAIOAuth.ExchangeCode)
|
||||
sora.POST("/refresh-token", h.Admin.OpenAIOAuth.RefreshToken)
|
||||
sora.POST("/st2at", h.Admin.OpenAIOAuth.ExchangeSoraSessionToken)
|
||||
sora.POST("/rt2at", h.Admin.OpenAIOAuth.RefreshToken)
|
||||
sora.POST("/accounts/:id/refresh", h.Admin.OpenAIOAuth.RefreshAccountToken)
|
||||
sora.POST("/create-from-oauth", h.Admin.OpenAIOAuth.CreateAccountFromOAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func registerGeminiOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
gemini := admin.Group("/gemini")
|
||||
{
|
||||
@@ -419,15 +417,11 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
// Beta 策略配置
|
||||
adminSettings.GET("/beta-policy", h.Admin.Setting.GetBetaPolicySettings)
|
||||
adminSettings.PUT("/beta-policy", h.Admin.Setting.UpdateBetaPolicySettings)
|
||||
// Sora S3 存储配置
|
||||
adminSettings.GET("/sora-s3", h.Admin.Setting.GetSoraS3Settings)
|
||||
adminSettings.PUT("/sora-s3", h.Admin.Setting.UpdateSoraS3Settings)
|
||||
adminSettings.POST("/sora-s3/test", h.Admin.Setting.TestSoraS3Connection)
|
||||
adminSettings.GET("/sora-s3/profiles", h.Admin.Setting.ListSoraS3Profiles)
|
||||
adminSettings.POST("/sora-s3/profiles", h.Admin.Setting.CreateSoraS3Profile)
|
||||
adminSettings.PUT("/sora-s3/profiles/:profile_id", h.Admin.Setting.UpdateSoraS3Profile)
|
||||
adminSettings.DELETE("/sora-s3/profiles/:profile_id", h.Admin.Setting.DeleteSoraS3Profile)
|
||||
adminSettings.POST("/sora-s3/profiles/:profile_id/activate", h.Admin.Setting.SetActiveSoraS3Profile)
|
||||
// Web Search 模拟配置
|
||||
adminSettings.GET("/web-search-emulation", h.Admin.Setting.GetWebSearchEmulationConfig)
|
||||
adminSettings.PUT("/web-search-emulation", h.Admin.Setting.UpdateWebSearchEmulationConfig)
|
||||
adminSettings.POST("/web-search-emulation/test", h.Admin.Setting.TestWebSearchEmulation)
|
||||
adminSettings.POST("/web-search-emulation/reset-usage", h.Admin.Setting.ResetWebSearchUsage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,3 +561,54 @@ func registerTLSFingerprintProfileRoutes(admin *gin.RouterGroup, h *handler.Hand
|
||||
profiles.DELETE("/:id", h.Admin.TLSFingerprintProfile.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
func registerChannelRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
channels := admin.Group("/channels")
|
||||
{
|
||||
channels.GET("", h.Admin.Channel.List)
|
||||
channels.GET("/model-pricing", h.Admin.Channel.GetModelDefaultPricing)
|
||||
channels.GET("/:id", h.Admin.Channel.GetByID)
|
||||
channels.POST("", h.Admin.Channel.Create)
|
||||
channels.PUT("/:id", h.Admin.Channel.Update)
|
||||
channels.DELETE("/:id", h.Admin.Channel.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
monitors := admin.Group("/channel-monitors")
|
||||
{
|
||||
monitors.GET("", h.Admin.ChannelMonitor.List)
|
||||
monitors.POST("", h.Admin.ChannelMonitor.Create)
|
||||
monitors.GET("/:id", h.Admin.ChannelMonitor.Get)
|
||||
monitors.PUT("/:id", h.Admin.ChannelMonitor.Update)
|
||||
monitors.DELETE("/:id", h.Admin.ChannelMonitor.Delete)
|
||||
monitors.POST("/:id/run", h.Admin.ChannelMonitor.Run)
|
||||
monitors.GET("/:id/history", h.Admin.ChannelMonitor.History)
|
||||
}
|
||||
|
||||
templates := admin.Group("/channel-monitor-templates")
|
||||
{
|
||||
templates.GET("", h.Admin.ChannelMonitorTemplate.List)
|
||||
templates.POST("", h.Admin.ChannelMonitorTemplate.Create)
|
||||
templates.GET("/:id", h.Admin.ChannelMonitorTemplate.Get)
|
||||
templates.PUT("/:id", h.Admin.ChannelMonitorTemplate.Update)
|
||||
templates.DELETE("/:id", h.Admin.ChannelMonitorTemplate.Delete)
|
||||
templates.GET("/:id/monitors", h.Admin.ChannelMonitorTemplate.AssociatedMonitors)
|
||||
templates.POST("/:id/apply", h.Admin.ChannelMonitorTemplate.Apply)
|
||||
}
|
||||
}
|
||||
|
||||
// registerAffiliateRoutes 注册邀请返利的管理端路由(专属用户配置)
|
||||
func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
affiliates := admin.Group("/affiliates")
|
||||
{
|
||||
users := affiliates.Group("/users")
|
||||
{
|
||||
users.GET("", h.Admin.Affiliate.ListUsers)
|
||||
users.GET("/lookup", h.Admin.Affiliate.LookupUsers)
|
||||
users.POST("/batch-rate", h.Admin.Affiliate.BatchSetRate)
|
||||
users.PUT("/:user_id", h.Admin.Affiliate.UpdateUserSettings)
|
||||
users.DELETE("/:user_id", h.Admin.Affiliate.ClearUserSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,13 +63,109 @@ func RegisterAuthRoutes(
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}), h.Auth.ResetPassword)
|
||||
auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart)
|
||||
auth.GET("/oauth/linuxdo/bind/start", func(c *gin.Context) {
|
||||
query := c.Request.URL.Query()
|
||||
query.Set("intent", "bind_current_user")
|
||||
c.Request.URL.RawQuery = query.Encode()
|
||||
h.Auth.LinuxDoOAuthStart(c)
|
||||
})
|
||||
auth.GET("/oauth/linuxdo/callback", h.Auth.LinuxDoOAuthCallback)
|
||||
auth.GET("/oauth/wechat/start", h.Auth.WeChatOAuthStart)
|
||||
auth.GET("/oauth/wechat/bind/start", func(c *gin.Context) {
|
||||
query := c.Request.URL.Query()
|
||||
query.Set("intent", "bind_current_user")
|
||||
c.Request.URL.RawQuery = query.Encode()
|
||||
h.Auth.WeChatOAuthStart(c)
|
||||
})
|
||||
auth.GET("/oauth/wechat/callback", h.Auth.WeChatOAuthCallback)
|
||||
auth.GET("/oauth/wechat/payment/start", h.Auth.WeChatPaymentOAuthStart)
|
||||
auth.GET("/oauth/wechat/payment/callback", h.Auth.WeChatPaymentOAuthCallback)
|
||||
auth.POST("/oauth/pending/exchange",
|
||||
rateLimiter.LimitWithOptions("oauth-pending-exchange", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}),
|
||||
h.Auth.ExchangePendingOAuthCompletion,
|
||||
)
|
||||
auth.POST("/oauth/pending/send-verify-code",
|
||||
rateLimiter.LimitWithOptions("oauth-pending-send-verify-code", 5, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}),
|
||||
h.Auth.SendPendingOAuthVerifyCode,
|
||||
)
|
||||
auth.POST("/oauth/pending/create-account",
|
||||
rateLimiter.LimitWithOptions("oauth-pending-create-account", 10, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}),
|
||||
h.Auth.CreatePendingOAuthAccount,
|
||||
)
|
||||
auth.POST("/oauth/pending/bind-login",
|
||||
rateLimiter.LimitWithOptions("oauth-pending-bind-login", 10, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}),
|
||||
h.Auth.BindPendingOAuthLogin,
|
||||
)
|
||||
auth.POST("/oauth/linuxdo/complete-registration",
|
||||
rateLimiter.LimitWithOptions("oauth-linuxdo-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}),
|
||||
h.Auth.CompleteLinuxDoOAuthRegistration,
|
||||
)
|
||||
auth.POST("/oauth/linuxdo/bind-login",
|
||||
rateLimiter.LimitWithOptions("oauth-linuxdo-bind-login", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}),
|
||||
h.Auth.BindLinuxDoOAuthLogin,
|
||||
)
|
||||
auth.POST("/oauth/linuxdo/create-account",
|
||||
rateLimiter.LimitWithOptions("oauth-linuxdo-create-account", 10, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}),
|
||||
h.Auth.CreateLinuxDoOAuthAccount,
|
||||
)
|
||||
auth.POST("/oauth/wechat/complete-registration",
|
||||
rateLimiter.LimitWithOptions("oauth-wechat-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}),
|
||||
h.Auth.CompleteWeChatOAuthRegistration,
|
||||
)
|
||||
auth.POST("/oauth/wechat/bind-login",
|
||||
rateLimiter.LimitWithOptions("oauth-wechat-bind-login", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}),
|
||||
h.Auth.BindWeChatOAuthLogin,
|
||||
)
|
||||
auth.POST("/oauth/wechat/create-account",
|
||||
rateLimiter.LimitWithOptions("oauth-wechat-create-account", 10, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}),
|
||||
h.Auth.CreateWeChatOAuthAccount,
|
||||
)
|
||||
auth.GET("/oauth/oidc/start", h.Auth.OIDCOAuthStart)
|
||||
auth.GET("/oauth/oidc/bind/start", func(c *gin.Context) {
|
||||
query := c.Request.URL.Query()
|
||||
query.Set("intent", "bind_current_user")
|
||||
c.Request.URL.RawQuery = query.Encode()
|
||||
h.Auth.OIDCOAuthStart(c)
|
||||
})
|
||||
auth.GET("/oauth/oidc/callback", h.Auth.OIDCOAuthCallback)
|
||||
auth.POST("/oauth/oidc/complete-registration",
|
||||
rateLimiter.LimitWithOptions("oauth-oidc-complete", 10, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}),
|
||||
h.Auth.CompleteOIDCOAuthRegistration,
|
||||
)
|
||||
auth.POST("/oauth/oidc/bind-login",
|
||||
rateLimiter.LimitWithOptions("oauth-oidc-bind-login", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}),
|
||||
h.Auth.BindOIDCOAuthLogin,
|
||||
)
|
||||
auth.POST("/oauth/oidc/create-account",
|
||||
rateLimiter.LimitWithOptions("oauth-oidc-create-account", 10, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}),
|
||||
h.Auth.CreateOIDCOAuthAccount,
|
||||
)
|
||||
}
|
||||
|
||||
// 公开设置(无需认证)
|
||||
@@ -86,5 +182,6 @@ func RegisterAuthRoutes(
|
||||
authenticated.GET("/auth/me", h.Auth.GetCurrentUser)
|
||||
// 撤销所有会话(需要认证)
|
||||
authenticated.POST("/auth/revoke-all-sessions", h.Auth.RevokeAllSessions)
|
||||
authenticated.POST("/auth/oauth/bind-token", h.Auth.PrepareOAuthBindAccessTokenCookie)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ func TestAuthRoutesRateLimitFailCloseWhenRedisUnavailable(t *testing.T) {
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/login/2fa",
|
||||
"/api/v1/auth/send-verify-code",
|
||||
"/api/v1/auth/oauth/pending/send-verify-code",
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
|
||||
@@ -23,11 +23,6 @@ func RegisterGatewayRoutes(
|
||||
cfg *config.Config,
|
||||
) {
|
||||
bodyLimit := middleware.RequestBodyLimit(cfg.Gateway.MaxBodySize)
|
||||
soraMaxBodySize := cfg.Gateway.SoraMaxBodySize
|
||||
if soraMaxBodySize <= 0 {
|
||||
soraMaxBodySize = cfg.Gateway.MaxBodySize
|
||||
}
|
||||
soraBodyLimit := middleware.RequestBodyLimit(soraMaxBodySize)
|
||||
clientRequestID := middleware.ClientRequestID()
|
||||
opsErrorLogger := handler.OpsErrorLoggerMiddleware(opsService)
|
||||
endpointNorm := handler.InboundEndpointMiddleware()
|
||||
@@ -93,6 +88,30 @@ func RegisterGatewayRoutes(
|
||||
}
|
||||
h.Gateway.ChatCompletions(c)
|
||||
})
|
||||
gateway.POST("/images/generations", func(c *gin.Context) {
|
||||
if getGroupPlatform(c) != service.PlatformOpenAI {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "not_found_error",
|
||||
"message": "Images API is not supported for this platform",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
h.OpenAIGateway.Images(c)
|
||||
})
|
||||
gateway.POST("/images/edits", func(c *gin.Context) {
|
||||
if getGroupPlatform(c) != service.PlatformOpenAI {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "not_found_error",
|
||||
"message": "Images API is not supported for this platform",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
h.OpenAIGateway.Images(c)
|
||||
})
|
||||
}
|
||||
|
||||
// Gemini 原生 API 兼容层(Gemini SDK/CLI 直连)
|
||||
@@ -121,6 +140,13 @@ func RegisterGatewayRoutes(
|
||||
r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, responsesHandler)
|
||||
r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, responsesHandler)
|
||||
r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.ResponsesWebSocket)
|
||||
codexDirect := r.Group("/backend-api/codex")
|
||||
codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic)
|
||||
{
|
||||
codexDirect.POST("/responses", responsesHandler)
|
||||
codexDirect.POST("/responses/*subpath", responsesHandler)
|
||||
codexDirect.GET("/responses", h.OpenAIGateway.ResponsesWebSocket)
|
||||
}
|
||||
// OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform
|
||||
r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) {
|
||||
if getGroupPlatform(c) == service.PlatformOpenAI {
|
||||
@@ -129,6 +155,30 @@ func RegisterGatewayRoutes(
|
||||
}
|
||||
h.Gateway.ChatCompletions(c)
|
||||
})
|
||||
r.POST("/images/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) {
|
||||
if getGroupPlatform(c) != service.PlatformOpenAI {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "not_found_error",
|
||||
"message": "Images API is not supported for this platform",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
h.OpenAIGateway.Images(c)
|
||||
})
|
||||
r.POST("/images/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) {
|
||||
if getGroupPlatform(c) != service.PlatformOpenAI {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "not_found_error",
|
||||
"message": "Images API is not supported for this platform",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
h.OpenAIGateway.Images(c)
|
||||
})
|
||||
|
||||
// Antigravity 模型列表
|
||||
r.GET("/antigravity/models", gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.Gateway.AntigravityModels)
|
||||
@@ -163,28 +213,6 @@ func RegisterGatewayRoutes(
|
||||
antigravityV1Beta.POST("/models/*modelAction", h.Gateway.GeminiV1BetaModels)
|
||||
}
|
||||
|
||||
// Sora 专用路由(强制使用 sora 平台)
|
||||
soraV1 := r.Group("/sora/v1")
|
||||
soraV1.Use(soraBodyLimit)
|
||||
soraV1.Use(clientRequestID)
|
||||
soraV1.Use(opsErrorLogger)
|
||||
soraV1.Use(endpointNorm)
|
||||
soraV1.Use(middleware.ForcePlatform(service.PlatformSora))
|
||||
soraV1.Use(gin.HandlerFunc(apiKeyAuth))
|
||||
soraV1.Use(requireGroupAnthropic)
|
||||
{
|
||||
soraV1.POST("/chat/completions", h.SoraGateway.ChatCompletions)
|
||||
soraV1.GET("/models", h.Gateway.Models)
|
||||
}
|
||||
|
||||
// Sora 媒体代理(可选 API Key 验证)
|
||||
if cfg.Gateway.SoraMediaRequireAPIKey {
|
||||
r.GET("/sora/media/*filepath", gin.HandlerFunc(apiKeyAuth), h.SoraGateway.MediaProxy)
|
||||
} else {
|
||||
r.GET("/sora/media/*filepath", h.SoraGateway.MediaProxy)
|
||||
}
|
||||
// Sora 媒体代理(签名 URL,无需 API Key)
|
||||
r.GET("/sora/media-signed/*filepath", h.SoraGateway.MediaProxySigned)
|
||||
}
|
||||
|
||||
// getGroupPlatform extracts the group platform from the API Key stored in context.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -22,9 +23,13 @@ func newGatewayRoutesTestRouter() *gin.Engine {
|
||||
&handler.Handlers{
|
||||
Gateway: &handler.GatewayHandler{},
|
||||
OpenAIGateway: &handler.OpenAIGatewayHandler{},
|
||||
SoraGateway: &handler.SoraGatewayHandler{},
|
||||
},
|
||||
servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) {
|
||||
groupID := int64(1)
|
||||
c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{Platform: service.PlatformOpenAI},
|
||||
})
|
||||
c.Next()
|
||||
}),
|
||||
nil,
|
||||
@@ -40,7 +45,12 @@ func newGatewayRoutesTestRouter() *gin.Engine {
|
||||
func TestGatewayRoutesOpenAIResponsesCompactPathIsRegistered(t *testing.T) {
|
||||
router := newGatewayRoutesTestRouter()
|
||||
|
||||
for _, path := range []string{"/v1/responses/compact", "/responses/compact"} {
|
||||
for _, path := range []string{
|
||||
"/v1/responses/compact",
|
||||
"/responses/compact",
|
||||
"/backend-api/codex/responses",
|
||||
"/backend-api/codex/responses/compact",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"gpt-5"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
@@ -49,3 +59,21 @@ func TestGatewayRoutesOpenAIResponsesCompactPathIsRegistered(t *testing.T) {
|
||||
require.NotEqual(t, http.StatusNotFound, w.Code, "path=%s should hit OpenAI responses handler", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayRoutesOpenAIImagesPathsAreRegistered(t *testing.T) {
|
||||
router := newGatewayRoutesTestRouter()
|
||||
|
||||
for _, path := range []string{
|
||||
"/v1/images/generations",
|
||||
"/v1/images/edits",
|
||||
"/images/generations",
|
||||
"/images/edits",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"gpt-image-2","prompt":"draw a cat"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
require.NotEqual(t, http.StatusNotFound, w.Code, "path=%s should hit OpenAI images handler", path)
|
||||
}
|
||||
}
|
||||
|
||||
106
backend/internal/server/routes/payment.go
Normal file
106
backend/internal/server/routes/payment.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterPaymentRoutes registers all payment-related routes:
|
||||
// user-facing endpoints, webhook endpoints, and admin endpoints.
|
||||
func RegisterPaymentRoutes(
|
||||
v1 *gin.RouterGroup,
|
||||
paymentHandler *handler.PaymentHandler,
|
||||
webhookHandler *handler.PaymentWebhookHandler,
|
||||
adminPaymentHandler *admin.PaymentHandler,
|
||||
jwtAuth middleware.JWTAuthMiddleware,
|
||||
adminAuth middleware.AdminAuthMiddleware,
|
||||
settingService *service.SettingService,
|
||||
) {
|
||||
// --- User-facing payment endpoints (authenticated) ---
|
||||
authenticated := v1.Group("/payment")
|
||||
authenticated.Use(gin.HandlerFunc(jwtAuth))
|
||||
authenticated.Use(middleware.BackendModeUserGuard(settingService))
|
||||
{
|
||||
authenticated.GET("/config", paymentHandler.GetPaymentConfig)
|
||||
authenticated.GET("/checkout-info", paymentHandler.GetCheckoutInfo)
|
||||
authenticated.GET("/plans", paymentHandler.GetPlans)
|
||||
authenticated.GET("/channels", paymentHandler.GetChannels)
|
||||
authenticated.GET("/limits", paymentHandler.GetLimits)
|
||||
|
||||
orders := authenticated.Group("/orders")
|
||||
{
|
||||
orders.POST("", paymentHandler.CreateOrder)
|
||||
orders.POST("/verify", paymentHandler.VerifyOrder)
|
||||
orders.GET("/my", paymentHandler.GetMyOrders)
|
||||
orders.GET("/:id", paymentHandler.GetOrder)
|
||||
orders.POST("/:id/cancel", paymentHandler.CancelOrder)
|
||||
orders.POST("/:id/refund-request", paymentHandler.RequestRefund)
|
||||
orders.GET("/refund-eligible-providers", paymentHandler.GetRefundEligibleProviders)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Public payment endpoints (no auth) ---
|
||||
// Signed resume-token recovery is the preferred public lookup path.
|
||||
// The legacy anonymous out_trade_no verify endpoint remains available as a
|
||||
// persisted-state compatibility path for staggered upgrades.
|
||||
public := v1.Group("/payment/public")
|
||||
{
|
||||
public.POST("/orders/verify", paymentHandler.VerifyOrderPublic)
|
||||
public.POST("/orders/resolve", paymentHandler.ResolveOrderPublicByResumeToken)
|
||||
}
|
||||
|
||||
// --- Webhook endpoints (no auth) ---
|
||||
webhook := v1.Group("/payment/webhook")
|
||||
{
|
||||
// EasyPay sends GET callbacks with query params
|
||||
webhook.GET("/easypay", webhookHandler.EasyPayNotify)
|
||||
webhook.POST("/easypay", webhookHandler.EasyPayNotify)
|
||||
webhook.POST("/alipay", webhookHandler.AlipayNotify)
|
||||
webhook.POST("/wxpay", webhookHandler.WxpayNotify)
|
||||
webhook.POST("/stripe", webhookHandler.StripeWebhook)
|
||||
}
|
||||
|
||||
// --- Admin payment endpoints (admin auth) ---
|
||||
adminGroup := v1.Group("/admin/payment")
|
||||
adminGroup.Use(gin.HandlerFunc(adminAuth))
|
||||
{
|
||||
// Dashboard
|
||||
adminGroup.GET("/dashboard", adminPaymentHandler.GetDashboard)
|
||||
|
||||
// Config
|
||||
adminGroup.GET("/config", adminPaymentHandler.GetConfig)
|
||||
adminGroup.PUT("/config", adminPaymentHandler.UpdateConfig)
|
||||
|
||||
// Orders
|
||||
adminOrders := adminGroup.Group("/orders")
|
||||
{
|
||||
adminOrders.GET("", adminPaymentHandler.ListOrders)
|
||||
adminOrders.GET("/:id", adminPaymentHandler.GetOrderDetail)
|
||||
adminOrders.POST("/:id/cancel", adminPaymentHandler.CancelOrder)
|
||||
adminOrders.POST("/:id/retry", adminPaymentHandler.RetryFulfillment)
|
||||
adminOrders.POST("/:id/refund", adminPaymentHandler.ProcessRefund)
|
||||
}
|
||||
|
||||
// Subscription Plans
|
||||
plans := adminGroup.Group("/plans")
|
||||
{
|
||||
plans.GET("", adminPaymentHandler.ListPlans)
|
||||
plans.POST("", adminPaymentHandler.CreatePlan)
|
||||
plans.PUT("/:id", adminPaymentHandler.UpdatePlan)
|
||||
plans.DELETE("/:id", adminPaymentHandler.DeletePlan)
|
||||
}
|
||||
|
||||
// Provider Instances
|
||||
providers := adminGroup.Group("/providers")
|
||||
{
|
||||
providers.GET("", adminPaymentHandler.ListProviders)
|
||||
providers.POST("", adminPaymentHandler.CreateProvider)
|
||||
providers.PUT("/:id", adminPaymentHandler.UpdateProvider)
|
||||
providers.DELETE("/:id", adminPaymentHandler.DeleteProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterSoraClientRoutes 注册 Sora 客户端 API 路由(需要用户认证)。
|
||||
func RegisterSoraClientRoutes(
|
||||
v1 *gin.RouterGroup,
|
||||
h *handler.Handlers,
|
||||
jwtAuth middleware.JWTAuthMiddleware,
|
||||
settingService *service.SettingService,
|
||||
) {
|
||||
if h.SoraClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
authenticated := v1.Group("/sora")
|
||||
authenticated.Use(gin.HandlerFunc(jwtAuth))
|
||||
authenticated.Use(middleware.BackendModeUserGuard(settingService))
|
||||
{
|
||||
authenticated.POST("/generate", h.SoraClient.Generate)
|
||||
authenticated.GET("/generations", h.SoraClient.ListGenerations)
|
||||
authenticated.GET("/generations/:id", h.SoraClient.GetGeneration)
|
||||
authenticated.DELETE("/generations/:id", h.SoraClient.DeleteGeneration)
|
||||
authenticated.POST("/generations/:id/cancel", h.SoraClient.CancelGeneration)
|
||||
authenticated.POST("/generations/:id/save", h.SoraClient.SaveToStorage)
|
||||
authenticated.GET("/quota", h.SoraClient.GetQuota)
|
||||
authenticated.GET("/models", h.SoraClient.GetModels)
|
||||
authenticated.GET("/storage-status", h.SoraClient.GetStorageStatus)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,21 @@ func RegisterUserRoutes(
|
||||
user.GET("/profile", h.User.GetProfile)
|
||||
user.PUT("/password", h.User.ChangePassword)
|
||||
user.PUT("", h.User.UpdateProfile)
|
||||
user.GET("/aff", h.User.GetAffiliate)
|
||||
user.POST("/aff/transfer", h.User.TransferAffiliateQuota)
|
||||
user.POST("/account-bindings/email/send-code", h.User.SendEmailBindingCode)
|
||||
user.POST("/account-bindings/email", h.User.BindEmailIdentity)
|
||||
user.DELETE("/account-bindings/:provider", h.User.UnbindIdentity)
|
||||
user.POST("/auth-identities/bind/start", h.User.StartIdentityBinding)
|
||||
|
||||
// 通知邮箱管理
|
||||
notifyEmail := user.Group("/notify-email")
|
||||
{
|
||||
notifyEmail.POST("/send-code", h.User.SendNotifyEmailCode)
|
||||
notifyEmail.POST("/verify", h.User.VerifyNotifyEmail)
|
||||
notifyEmail.PUT("/toggle", h.User.ToggleNotifyEmail)
|
||||
notifyEmail.DELETE("", h.User.RemoveNotifyEmail)
|
||||
}
|
||||
|
||||
// TOTP 双因素认证
|
||||
totp := user.Group("/totp")
|
||||
@@ -55,6 +70,12 @@ func RegisterUserRoutes(
|
||||
groups.GET("/rates", h.APIKey.GetUserGroupRates)
|
||||
}
|
||||
|
||||
// 用户可用渠道(非管理员接口)
|
||||
channels := authenticated.Group("/channels")
|
||||
{
|
||||
channels.GET("/available", h.AvailableChannel.List)
|
||||
}
|
||||
|
||||
// 使用记录
|
||||
usage := authenticated.Group("/usage")
|
||||
{
|
||||
@@ -90,5 +111,12 @@ func RegisterUserRoutes(
|
||||
subscriptions.GET("/progress", h.Subscription.GetProgress)
|
||||
subscriptions.GET("/summary", h.Subscription.GetSummary)
|
||||
}
|
||||
|
||||
// 渠道监控(用户只读)
|
||||
monitors := authenticated.Group("/channel-monitors")
|
||||
{
|
||||
monitors.GET("", h.ChannelMonitor.List)
|
||||
monitors.GET("/:id/status", h.ChannelMonitor.GetStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user