diff --git a/frontend/src/api/__tests__/settings.paymentVisibleMethods.spec.ts b/frontend/src/api/__tests__/settings.paymentVisibleMethods.spec.ts new file mode 100644 index 00000000..3b1a373f --- /dev/null +++ b/frontend/src/api/__tests__/settings.paymentVisibleMethods.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' + +import { + getPaymentVisibleMethodSourceOptions, + normalizePaymentVisibleMethodSource, +} from '@/api/admin/settings' + +describe('admin settings payment visible method helpers', () => { + it('normalizes aliases into canonical source keys per visible method', () => { + expect(normalizePaymentVisibleMethodSource('alipay', 'official')).toBe('official_alipay') + expect(normalizePaymentVisibleMethodSource('alipay', 'alipay_direct')).toBe('official_alipay') + expect(normalizePaymentVisibleMethodSource('alipay', 'easypay')).toBe('easypay_alipay') + + expect(normalizePaymentVisibleMethodSource('wxpay', 'official')).toBe('official_wxpay') + expect(normalizePaymentVisibleMethodSource('wxpay', 'wechat')).toBe('official_wxpay') + expect(normalizePaymentVisibleMethodSource('wxpay', 'easypay')).toBe('easypay_wxpay') + }) + + it('rejects unknown or cross-method source values', () => { + expect(normalizePaymentVisibleMethodSource('alipay', 'official_wxpay')).toBe('') + expect(normalizePaymentVisibleMethodSource('wxpay', 'official_alipay')).toBe('') + expect(normalizePaymentVisibleMethodSource('alipay', 'unknown')).toBe('') + expect(normalizePaymentVisibleMethodSource('wxpay', null)).toBe('') + }) + + it('exposes method-scoped source options instead of arbitrary strings', () => { + expect(getPaymentVisibleMethodSourceOptions('alipay')).toEqual([ + { + value: '', + labelZh: '自动路由', + labelEn: 'Automatic routing', + }, + { + value: 'official_alipay', + labelZh: '支付宝官方', + labelEn: 'Official Alipay', + }, + { + value: 'easypay_alipay', + labelZh: '易支付支付宝', + labelEn: 'EasyPay Alipay', + }, + ]) + + expect(getPaymentVisibleMethodSourceOptions('wxpay')).toEqual([ + { + value: '', + labelZh: '自动路由', + labelEn: 'Automatic routing', + }, + { + value: 'official_wxpay', + labelZh: '微信官方', + labelEn: 'Official WeChat Pay', + }, + { + value: 'easypay_wxpay', + labelZh: '易支付微信', + labelEn: 'EasyPay WeChat Pay', + }, + ]) + }) +}) diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 8e182c1c..505fcdca 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -22,10 +22,60 @@ export interface AuthSourceDefaultsValue { } export type AuthSourceDefaultsState = Record +export type PaymentVisibleMethod = 'alipay' | 'wxpay' +export type PaymentVisibleMethodSource = + | '' + | 'official_alipay' + | 'easypay_alipay' + | 'official_wxpay' + | 'easypay_wxpay' + +export interface PaymentVisibleMethodSourceOption { + value: PaymentVisibleMethodSource + labelZh: string + labelEn: string +} const AUTH_SOURCE_TYPES: AuthSourceType[] = ['email', 'linuxdo', 'oidc', 'wechat'] const AUTH_SOURCE_DEFAULT_BALANCE = 0 const AUTH_SOURCE_DEFAULT_CONCURRENCY = 5 +const PAYMENT_VISIBLE_METHOD_SOURCE_OPTIONS: Record< + PaymentVisibleMethod, + PaymentVisibleMethodSourceOption[] +> = { + alipay: [ + { value: '', labelZh: '自动路由', labelEn: 'Automatic routing' }, + { value: 'official_alipay', labelZh: '支付宝官方', labelEn: 'Official Alipay' }, + { value: 'easypay_alipay', labelZh: '易支付支付宝', labelEn: 'EasyPay Alipay' }, + ], + wxpay: [ + { value: '', labelZh: '自动路由', labelEn: 'Automatic routing' }, + { value: 'official_wxpay', labelZh: '微信官方', labelEn: 'Official WeChat Pay' }, + { value: 'easypay_wxpay', labelZh: '易支付微信', labelEn: 'EasyPay WeChat Pay' }, + ], +} +const PAYMENT_VISIBLE_METHOD_SOURCE_ALIASES: Record< + PaymentVisibleMethod, + Record +> = { + alipay: { + official_alipay: 'official_alipay', + alipay: 'official_alipay', + alipay_direct: 'official_alipay', + official: 'official_alipay', + easypay_alipay: 'easypay_alipay', + easypay: 'easypay_alipay', + }, + wxpay: { + official_wxpay: 'official_wxpay', + wxpay: 'official_wxpay', + wxpay_direct: 'official_wxpay', + wechat: 'official_wxpay', + official: 'official_wxpay', + easypay_wxpay: 'easypay_wxpay', + easypay: 'easypay_wxpay', + }, +} export function normalizeDefaultSubscriptionSettings( subscriptions: DefaultSubscriptionSetting[] | null | undefined @@ -86,6 +136,24 @@ export function appendAuthSourceDefaultsToUpdateRequest( return payload } +export function getPaymentVisibleMethodSourceOptions( + method: PaymentVisibleMethod +): PaymentVisibleMethodSourceOption[] { + return PAYMENT_VISIBLE_METHOD_SOURCE_OPTIONS[method] +} + +export function normalizePaymentVisibleMethodSource( + method: PaymentVisibleMethod, + source: unknown +): PaymentVisibleMethodSource { + if (typeof source !== 'string') return '' + + const normalized = source.trim().toLowerCase() + if (!normalized) return '' + + return PAYMENT_VISIBLE_METHOD_SOURCE_ALIASES[method][normalized] ?? '' +} + /** * System settings interface */ diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 0d23baa5..8a042e70 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -2717,20 +2717,19 @@
-

{{ localText( - '留空表示由后端使用默认来源;可填 easypay、alipay、wxpay 等来源标识。', - 'Leave blank to let the backend decide. Typical values are easypay, alipay, or wxpay.' + '留空表示自动路由;仅允许当前系统支持的官方或易支付来源。', + 'Leave blank for automatic routing. Only supported official or EasyPay sources are allowed.' ) }}

@@ -3117,11 +3116,14 @@ import { adminAPI } from '@/api' import { appendAuthSourceDefaultsToUpdateRequest, buildAuthSourceDefaultsState, + getPaymentVisibleMethodSourceOptions, + normalizePaymentVisibleMethodSource, normalizeDefaultSubscriptionSettings, } from '@/api/admin/settings' import type { AuthSourceDefaultsState, AuthSourceType, + PaymentVisibleMethod, SystemSettings, UpdateSettingsRequest, DefaultSubscriptionSetting, @@ -3429,12 +3431,23 @@ function getPaymentVisibleMethodSource(method: 'alipay' | 'wxpay'): string { : form.payment_visible_method_wxpay_source } -function setPaymentVisibleMethodSource(method: 'alipay' | 'wxpay', source: string) { +function getPaymentVisibleMethodSourceSelectOptions(method: PaymentVisibleMethod) { + return getPaymentVisibleMethodSourceOptions(method).map((option) => ({ + value: option.value, + label: localText(option.labelZh, option.labelEn), + })) +} + +function setPaymentVisibleMethodSource( + method: 'alipay' | 'wxpay', + source: string | number | boolean | null +) { + const normalized = normalizePaymentVisibleMethodSource(method, source) if (method === 'alipay') { - form.payment_visible_method_alipay_source = source + form.payment_visible_method_alipay_source = normalized return } - form.payment_visible_method_wxpay_source = source + form.payment_visible_method_wxpay_source = normalized } // Proxies for web search emulation ProxySelector @@ -3805,6 +3818,14 @@ async function loadSettings() { Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(settings)) form.backend_mode_enabled = settings.backend_mode_enabled form.default_subscriptions = normalizeDefaultSubscriptionSettings(settings.default_subscriptions) + form.payment_visible_method_alipay_source = normalizePaymentVisibleMethodSource( + 'alipay', + settings.payment_visible_method_alipay_source + ) + form.payment_visible_method_wxpay_source = normalizePaymentVisibleMethodSource( + 'wxpay', + settings.payment_visible_method_wxpay_source + ) registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains( settings.registration_email_suffix_whitelist ) @@ -4070,8 +4091,14 @@ async function saveSettings() { payment_cancel_rate_limit_window: Number(form.payment_cancel_rate_limit_window) || 1, payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit, payment_cancel_rate_limit_window_mode: form.payment_cancel_rate_limit_window_mode, - payment_visible_method_alipay_source: form.payment_visible_method_alipay_source, - payment_visible_method_wxpay_source: form.payment_visible_method_wxpay_source, + payment_visible_method_alipay_source: normalizePaymentVisibleMethodSource( + 'alipay', + form.payment_visible_method_alipay_source + ), + payment_visible_method_wxpay_source: normalizePaymentVisibleMethodSource( + 'wxpay', + form.payment_visible_method_wxpay_source + ), payment_visible_method_alipay_enabled: form.payment_visible_method_alipay_enabled, payment_visible_method_wxpay_enabled: form.payment_visible_method_wxpay_enabled, openai_advanced_scheduler_enabled: form.openai_advanced_scheduler_enabled, @@ -4092,6 +4119,14 @@ async function saveSettings() { } } Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(updated)) + form.payment_visible_method_alipay_source = normalizePaymentVisibleMethodSource( + 'alipay', + updated.payment_visible_method_alipay_source + ) + form.payment_visible_method_wxpay_source = normalizePaymentVisibleMethodSource( + 'wxpay', + updated.payment_visible_method_wxpay_source + ) registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains( updated.registration_email_suffix_whitelist ) diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts new file mode 100644 index 00000000..f20170e9 --- /dev/null +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -0,0 +1,452 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, ref } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' + +import SettingsView from '../SettingsView.vue' + +const { + getSettings, + updateSettings, + getWebSearchEmulationConfig, + updateWebSearchEmulationConfig, + getAdminApiKey, + getOverloadCooldownSettings, + getStreamTimeoutSettings, + getRectifierSettings, + getBetaPolicySettings, + getGroups, + listProxies, + getProviders, + fetchPublicSettings, + adminSettingsFetch, + showError, + showSuccess, +} = vi.hoisted(() => ({ + getSettings: vi.fn(), + updateSettings: vi.fn(), + getWebSearchEmulationConfig: vi.fn(), + updateWebSearchEmulationConfig: vi.fn(), + getAdminApiKey: vi.fn(), + getOverloadCooldownSettings: vi.fn(), + getStreamTimeoutSettings: vi.fn(), + getRectifierSettings: vi.fn(), + getBetaPolicySettings: vi.fn(), + getGroups: vi.fn(), + listProxies: vi.fn(), + getProviders: vi.fn(), + fetchPublicSettings: vi.fn(), + adminSettingsFetch: vi.fn(), + showError: vi.fn(), + showSuccess: vi.fn(), +})) + +vi.mock('@/api', () => ({ + adminAPI: { + settings: { + getSettings, + updateSettings, + getWebSearchEmulationConfig, + updateWebSearchEmulationConfig, + getAdminApiKey, + getOverloadCooldownSettings, + getStreamTimeoutSettings, + getRectifierSettings, + getBetaPolicySettings, + }, + groups: { + getAll: getGroups, + }, + proxies: { + list: listProxies, + }, + payment: { + getProviders, + }, + }, +})) + +vi.mock('@/stores', () => ({ + useAppStore: () => ({ + showError, + showSuccess, + showWarning: vi.fn(), + showInfo: vi.fn(), + fetchPublicSettings, + }), +})) + +vi.mock('@/stores/adminSettings', () => ({ + useAdminSettingsStore: () => ({ + fetch: adminSettingsFetch, + }), +})) + +vi.mock('@/composables/useClipboard', () => ({ + useClipboard: () => ({ + copyToClipboard: vi.fn(), + }), +})) + +vi.mock('@/utils/apiError', () => ({ + extractApiErrorMessage: () => 'error', +})) + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key, + locale: ref('zh-CN'), + }), + } +}) + +const AppLayoutStub = { template: '
' } +const ToggleStub = defineComponent({ + props: { + modelValue: { + type: Boolean, + default: false, + }, + }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => + h('input', { + class: 'toggle-stub', + type: 'checkbox', + checked: props.modelValue, + onChange: (event: Event) => { + emit('update:modelValue', (event.target as HTMLInputElement).checked) + }, + }) + }, +}) + +const SelectStub = defineComponent({ + props: { + modelValue: { + type: [String, Number, Boolean, null], + default: '', + }, + options: { + type: Array, + default: () => [], + }, + placeholder: { + type: String, + default: '', + }, + }, + emits: ['update:modelValue', 'change'], + setup(props, { emit }) { + const onChange = (event: Event) => { + const target = event.target as HTMLSelectElement + emit('update:modelValue', target.value) + const option = (props.options as Array>).find( + (item) => String(item.value ?? '') === target.value + ) ?? null + emit('change', target.value, option) + } + + return () => + h( + 'select', + { + class: 'select-stub', + value: props.modelValue ?? '', + 'data-placeholder': props.placeholder, + onChange, + }, + (props.options as Array>).map((option) => + h( + 'option', + { + key: `${String(option.value ?? '')}:${String(option.label ?? '')}`, + value: option.value as string, + }, + String(option.label ?? '') + ) + ) + ) + }, +}) + +const baseSettingsResponse = { + registration_enabled: true, + email_verify_enabled: false, + registration_email_suffix_whitelist: [], + promo_code_enabled: true, + invitation_code_enabled: false, + password_reset_enabled: false, + totp_enabled: false, + totp_encryption_key_configured: false, + default_balance: 0, + default_concurrency: 1, + default_subscriptions: [], + site_name: 'Sub2API', + site_logo: '', + site_subtitle: '', + api_base_url: '', + contact_info: '', + doc_url: '', + home_content: '', + hide_ccs_import_button: false, + table_default_page_size: 20, + table_page_size_options: [10, 20, 50, 100], + backend_mode_enabled: false, + custom_menu_items: [], + custom_endpoints: [], + frontend_url: '', + smtp_host: '', + smtp_port: 587, + smtp_username: '', + smtp_password_configured: false, + smtp_from_email: '', + smtp_from_name: '', + smtp_use_tls: true, + 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: 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: '', + enable_model_fallback: false, + fallback_model_anthropic: '', + fallback_model_openai: '', + fallback_model_gemini: '', + fallback_model_antigravity: '', + enable_identity_patch: false, + identity_patch_prompt: '', + ops_monitoring_enabled: false, + ops_realtime_monitoring_enabled: false, + ops_query_mode_default: 'auto', + ops_metrics_interval_seconds: 60, + min_claude_code_version: '', + max_claude_code_version: '', + allow_ungrouped_key_scheduling: false, + enable_fingerprint_unification: true, + enable_metadata_passthrough: false, + enable_cch_signing: false, + payment_enabled: true, + payment_min_amount: 1, + payment_max_amount: 10000, + payment_daily_limit: 50000, + payment_order_timeout_minutes: 30, + payment_max_pending_orders: 3, + payment_enabled_types: [], + payment_balance_disabled: false, + payment_balance_recharge_multiplier: 1, + payment_recharge_fee_rate: 0, + payment_load_balance_strategy: 'round-robin', + 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: 10, + payment_cancel_rate_limit_window: 1, + payment_cancel_rate_limit_unit: 'day', + payment_cancel_rate_limit_window_mode: 'rolling', + payment_visible_method_alipay_source: 'alipay_direct', + payment_visible_method_wxpay_source: 'invalid-source', + payment_visible_method_alipay_enabled: true, + payment_visible_method_wxpay_enabled: true, + openai_advanced_scheduler_enabled: false, + balance_low_notify_enabled: false, + balance_low_notify_threshold: 0, + balance_low_notify_recharge_url: '', + account_quota_notify_enabled: false, + account_quota_notify_emails: [], +} + +function mountView() { + return mount(SettingsView, { + global: { + stubs: { + AppLayout: AppLayoutStub, + Select: SelectStub, + Toggle: ToggleStub, + Icon: true, + ConfirmDialog: true, + PaymentProviderList: true, + PaymentProviderDialog: true, + GroupBadge: true, + GroupOptionItem: true, + ProxySelector: true, + ImageUpload: true, + BackupSettings: true, + }, + }, + }) +} + +async function openPaymentTab(wrapper: ReturnType) { + const paymentTabButton = wrapper + .findAll('button') + .find((node) => node.text().includes('admin.settings.tabs.payment')) + + expect(paymentTabButton).toBeDefined() + await paymentTabButton?.trigger('click') + await flushPromises() +} + +describe('admin SettingsView payment visible method controls', () => { + beforeEach(() => { + getSettings.mockReset() + updateSettings.mockReset() + getWebSearchEmulationConfig.mockReset() + updateWebSearchEmulationConfig.mockReset() + getAdminApiKey.mockReset() + getOverloadCooldownSettings.mockReset() + getStreamTimeoutSettings.mockReset() + getRectifierSettings.mockReset() + getBetaPolicySettings.mockReset() + getGroups.mockReset() + listProxies.mockReset() + getProviders.mockReset() + fetchPublicSettings.mockReset() + adminSettingsFetch.mockReset() + showError.mockReset() + showSuccess.mockReset() + + getSettings.mockResolvedValue({ ...baseSettingsResponse }) + updateSettings.mockImplementation(async (payload) => ({ + ...baseSettingsResponse, + ...payload, + })) + getWebSearchEmulationConfig.mockResolvedValue({ + enabled: false, + providers: [], + }) + updateWebSearchEmulationConfig.mockResolvedValue({ + enabled: false, + providers: [], + }) + getAdminApiKey.mockResolvedValue({ + exists: false, + masked_key: '', + }) + getOverloadCooldownSettings.mockResolvedValue({ + enabled: true, + cooldown_minutes: 10, + }) + getStreamTimeoutSettings.mockResolvedValue({ + enabled: true, + action: 'temp_unsched', + temp_unsched_minutes: 5, + threshold_count: 3, + threshold_window_minutes: 10, + }) + getRectifierSettings.mockResolvedValue({ + enabled: true, + thinking_signature_enabled: true, + thinking_budget_enabled: true, + apikey_signature_enabled: false, + apikey_signature_patterns: [], + }) + getBetaPolicySettings.mockResolvedValue({ + rules: [], + }) + getGroups.mockResolvedValue([]) + listProxies.mockResolvedValue({ + items: [], + }) + getProviders.mockResolvedValue({ + data: [], + }) + fetchPublicSettings.mockResolvedValue(undefined) + adminSettingsFetch.mockResolvedValue(undefined) + }) + + it('loads canonical source options and normalizes existing values', async () => { + const wrapper = mountView() + + await flushPromises() + await openPaymentTab(wrapper) + + const paymentSourceSelects = wrapper + .findAll('select.select-stub') + .filter((node) => ['alipay', 'wxpay'].includes(node.attributes('data-placeholder'))) + + expect(paymentSourceSelects).toHaveLength(2) + + const alipaySelect = paymentSourceSelects.find( + (node) => node.attributes('data-placeholder') === 'alipay' + ) + const wxpaySelect = paymentSourceSelects.find( + (node) => node.attributes('data-placeholder') === 'wxpay' + ) + + expect(alipaySelect?.element.value).toBe('official_alipay') + expect(alipaySelect?.findAll('option').map((option) => option.element.value)).toEqual([ + '', + 'official_alipay', + 'easypay_alipay', + ]) + + expect(wxpaySelect?.element.value).toBe('') + expect(wxpaySelect?.findAll('option').map((option) => option.element.value)).toEqual([ + '', + 'official_wxpay', + 'easypay_wxpay', + ]) + }) + + it('saves canonical source keys selected from the dropdowns', async () => { + const wrapper = mountView() + + await flushPromises() + await openPaymentTab(wrapper) + + const paymentSourceSelects = wrapper + .findAll('select.select-stub') + .filter((node) => ['alipay', 'wxpay'].includes(node.attributes('data-placeholder'))) + + const alipaySelect = paymentSourceSelects.find( + (node) => node.attributes('data-placeholder') === 'alipay' + ) + const wxpaySelect = paymentSourceSelects.find( + (node) => node.attributes('data-placeholder') === 'wxpay' + ) + + await alipaySelect?.setValue('easypay_alipay') + await wxpaySelect?.setValue('official_wxpay') + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(updateSettings).toHaveBeenCalledTimes(1) + expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + 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: true, + }) + ) + }) +})