From 4a703cb0842d386f4298492bac201ee6656b7003 Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 6 Apr 2026 01:51:12 +0800 Subject: [PATCH] fix: backward compatibility for legacy purchase subscription system - Add PurchaseRouter.vue to dynamically render PaymentView or PurchaseSubscriptionView based on payment_enabled vs purchase_subscription_enabled settings - Fix route guard to allow access when either payment system is enabled - Split sidebar menu: /purchase shows for both systems, /orders only for built-in payment - Remove auto-migration that forcibly disabled legacy purchase system on first startup (MigrateLegacyPurchaseURL) - Add payment_enabled to PublicSettings API response so frontend can check both systems from a single endpoint - Restore legacy purchase link config UI controls in admin Payment tab - Split payment settings save to use dedicated PUT /admin/payment/config API instead of mixing into general settings endpoint - Add admin payment config API functions (getConfig/updateConfig) --- backend/internal/handler/dto/settings.go | 1 + backend/internal/handler/setting_handler.go | 1 + backend/internal/service/payment_service.go | 11 +- backend/internal/service/setting_service.go | 4 + backend/internal/service/settings_view.go | 1 + frontend/src/api/admin/payment.ts | 42 ++++++ frontend/src/api/admin/settings.ts | 1 - frontend/src/components/layout/AppSidebar.vue | 12 +- frontend/src/i18n/locales/en.ts | 8 ++ frontend/src/i18n/locales/zh.ts | 8 ++ frontend/src/router/index.ts | 16 ++- frontend/src/router/meta.d.ts | 22 +++ frontend/src/stores/adminSettings.ts | 7 +- frontend/src/views/admin/SettingsView.vue | 134 ++++++++++++++++-- frontend/src/views/user/PurchaseRouter.vue | 17 +++ 15 files changed, 258 insertions(+), 27 deletions(-) create mode 100644 frontend/src/views/user/PurchaseRouter.vue diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index acc1129cd2..639a6cd5b3 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -128,6 +128,7 @@ type PublicSettings struct { CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"` + PaymentEnabled bool `json:"payment_enabled"` Version string `json:"version"` } diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 977c2301bb..5917fed010 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -55,6 +55,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, BackendModeEnabled: settings.BackendModeEnabled, + PaymentEnabled: settings.PaymentEnabled, Version: h.version, }) } diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index c1d8a9a342..6af376e468 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -149,11 +149,12 @@ func NewPaymentService(entClient *dbent.Client, registry *payment.Registry, load return &PaymentService{entClient: entClient, registry: registry, loadBalancer: loadBalancer, redeemService: redeemService, subscriptionSvc: subscriptionSvc, configService: configService, userRepo: userRepo, groupRepo: groupRepo} } -// Start performs one-time startup tasks such as legacy settings migration. -func (s *PaymentService) Start(ctx context.Context) { - if err := s.configService.MigrateLegacyPurchaseURL(ctx); err != nil { - slog.Error("[payment] legacy purchase URL migration failed", "error", err) - } +// Start performs one-time startup tasks. +// NOTE: MigrateLegacyPurchaseURL is intentionally NOT called here to avoid +// automatically disabling the legacy purchase_subscription system on upgrade. +// Admins should trigger migration manually when ready to switch to the new +// payment system. +func (s *PaymentService) Start(_ context.Context) { } func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*CreateOrderResponse, error) { diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index a85efabd6d..080fa12244 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -163,6 +163,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyCustomEndpoints, SettingKeyLinuxDoConnectEnabled, SettingKeyBackendModeEnabled, + SettingPaymentEnabled, } settings, err := s.settingRepo.GetMultiple(ctx, keys) @@ -208,6 +209,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings CustomEndpoints: settings[SettingKeyCustomEndpoints], LinuxDoOAuthEnabled: linuxDoEnabled, BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", + PaymentEnabled: settings[SettingPaymentEnabled] == "true", }, nil } @@ -255,6 +257,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any CustomEndpoints json.RawMessage `json:"custom_endpoints"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"` + PaymentEnabled bool `json:"payment_enabled"` Version string `json:"version,omitempty"` }{ RegistrationEnabled: settings.RegistrationEnabled, @@ -280,6 +283,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, BackendModeEnabled: settings.BackendModeEnabled, + PaymentEnabled: settings.PaymentEnabled, Version: s.version, }, nil } diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 4b64267fec..0d6cb0cd05 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -111,6 +111,7 @@ type PublicSettings struct { LinuxDoOAuthEnabled bool BackendModeEnabled bool + PaymentEnabled bool Version string } diff --git a/frontend/src/api/admin/payment.ts b/frontend/src/api/admin/payment.ts index 06434cb784..7a5321243c 100644 --- a/frontend/src/api/admin/payment.ts +++ b/frontend/src/api/admin/payment.ts @@ -13,7 +13,49 @@ import type { } from '@/types/payment' import type { BasePaginationResponse } from '@/types' +/** Admin-facing payment config returned by GET /admin/payment/config */ +export interface AdminPaymentConfig { + enabled: boolean + min_amount: number + max_amount: number + daily_limit: number + order_timeout_minutes: number + max_pending_orders: number + enabled_payment_types: string[] + balance_disabled: boolean + load_balance_strategy: string + product_name_prefix: string + product_name_suffix: string +} + +/** Fields accepted by PUT /admin/payment/config (all optional via pointer semantics) */ +export interface UpdatePaymentConfigRequest { + enabled?: boolean + min_amount?: number + max_amount?: number + daily_limit?: number + order_timeout_minutes?: number + max_pending_orders?: number + enabled_payment_types?: string[] + balance_disabled?: boolean + load_balance_strategy?: string + product_name_prefix?: string + product_name_suffix?: string +} + export const adminPaymentAPI = { + // ==================== Config ==================== + + /** Get payment configuration (admin view) */ + getConfig() { + return apiClient.get('/admin/payment/config') + }, + + /** Update payment configuration */ + updateConfig(data: UpdatePaymentConfigRequest) { + return apiClient.put('/admin/payment/config', data) + }, + // ==================== Dashboard ==================== /** Get payment dashboard statistics */ diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 3efc732070..98baad5151 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -40,7 +40,6 @@ export interface SystemSettings { hide_ccs_import_button: boolean purchase_subscription_enabled: boolean purchase_subscription_url: string - payment_enabled: boolean sora_client_enabled: boolean backend_mode_enabled: boolean custom_menu_items: CustomMenuItem[] diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 00b085acb7..a1efd4b4cb 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -560,12 +560,16 @@ const userNavItems = computed((): NavItem[] => { icon: RechargeSubscriptionIcon, hideInSimpleMode: true }, + ] + : []), + ...(appStore.cachedPublicSettings?.payment_enabled + ? [ { path: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true - } + }, ] : []), { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, @@ -597,12 +601,16 @@ const personalNavItems = computed((): NavItem[] => { icon: RechargeSubscriptionIcon, hideInSimpleMode: true }, + ] + : []), + ...(appStore.cachedPublicSettings?.payment_enabled + ? [ { path: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true - } + }, ] : []), { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 86e2def63d..c843ef22ef 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4342,6 +4342,14 @@ export default { helpImageUrl: 'Help Image URL', manageProviders: 'Manage Providers', balancePaymentDisabled: 'Balance payment is disabled', + customPurchaseLink: { + title: 'Custom Purchase Link (Legacy)', + description: 'Redirect users to an external purchase page instead of the built-in payment system', + enabled: 'Enable Custom Purchase Link', + enabledHint: 'When enabled, users will be redirected to the URL below for purchases', + url: 'Purchase Page URL', + urlHint: 'The external URL where users can purchase subscriptions or top up', + }, }, smtp: { title: 'SMTP Settings', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 9901c8d705..645613410e 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4515,6 +4515,14 @@ export default { helpImageUrl: '帮助图片链接', manageProviders: '管理服务商', balancePaymentDisabled: '余额支付已禁用', + customPurchaseLink: { + title: '自定义充值链接(旧版)', + description: '将用户重定向到外部充值页面,而不是使用内置支付系统', + enabled: '启用自定义充值链接', + enabledHint: '启用后,用户将被重定向到下方的 URL 进行充值', + url: '充值页面 URL', + urlHint: '用户购买订阅或充值的外部链接地址', + }, }, smtp: { title: 'SMTP 设置', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 62221739e6..2f240e93be 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -192,14 +192,14 @@ const routes: RouteRecordRaw[] = [ { path: '/purchase', name: 'PurchaseSubscription', - component: () => import('@/views/user/PaymentView.vue'), + component: () => import('@/views/user/PurchaseRouter.vue'), meta: { requiresAuth: true, requiresAdmin: false, title: 'Purchase Subscription', titleKey: 'purchase.title', descriptionKey: 'purchase.description', - requiresPayment: true + requiresEitherPayment: true } }, { @@ -567,7 +567,7 @@ router.beforeEach((to, _from, next) => { } - // Check payment requirement + // Check payment requirement (internal payment system only) if (to.meta.requiresPayment) { const paymentEnabled = appStore.cachedPublicSettings?.payment_enabled if (!paymentEnabled) { @@ -576,6 +576,16 @@ router.beforeEach((to, _from, next) => { } } + // Check either payment requirement (internal payment OR external purchase subscription) + if (to.meta.requiresEitherPayment) { + const paymentEnabled = appStore.cachedPublicSettings?.payment_enabled + const purchaseSubscriptionEnabled = appStore.cachedPublicSettings?.purchase_subscription_enabled + if (!paymentEnabled && !purchaseSubscriptionEnabled) { + next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard') + return + } + } + // 简易模式下限制访问某些页面 if (authStore.isSimpleMode) { const restrictedPaths = [ diff --git a/frontend/src/router/meta.d.ts b/frontend/src/router/meta.d.ts index e7546537d0..96df04311d 100644 --- a/frontend/src/router/meta.d.ts +++ b/frontend/src/router/meta.d.ts @@ -42,5 +42,27 @@ declare module 'vue-router' { * @default false */ hideInMenu?: boolean + + /** + * Whether this route requires internal payment system to be enabled + * @default false + */ + requiresPayment?: boolean + + /** + * Whether this route requires either internal payment or external purchase subscription + * @default false + */ + requiresEitherPayment?: boolean + + /** + * i18n key for the page title + */ + titleKey?: string + + /** + * i18n key for the page description + */ + descriptionKey?: string } } diff --git a/frontend/src/stores/adminSettings.ts b/frontend/src/stores/adminSettings.ts index 7717dda73d..ca34cc8eca 100644 --- a/frontend/src/stores/adminSettings.ts +++ b/frontend/src/stores/adminSettings.ts @@ -57,7 +57,10 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => { loading.value = true try { - const settings = await adminAPI.settings.getSettings() + const [settings, paymentConfigResp] = await Promise.all([ + adminAPI.settings.getSettings(), + adminAPI.payment.getConfig() + ]) opsMonitoringEnabled.value = settings.ops_monitoring_enabled ?? true writeCachedBool('ops_monitoring_enabled_cached', opsMonitoringEnabled.value) @@ -69,7 +72,7 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => { customMenuItems.value = Array.isArray(settings.custom_menu_items) ? settings.custom_menu_items : [] - paymentEnabled.value = settings.payment_enabled ?? false + paymentEnabled.value = paymentConfigResp.data?.enabled ?? false writeCachedBool('payment_enabled_cached', paymentEnabled.value) loaded.value = true diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index e521480d81..a29f70b9b1 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1637,7 +1637,80 @@ -

{{ t('admin.settings.payment.title') }}

{{ t('admin.settings.payment.description') }}

{{ t('admin.settings.payment.enabledHint') }}

+ +
+ + +
+
+

{{ t('admin.settings.payment.title') }}

+

{{ t('admin.settings.payment.description') }}

+
+
+
+
+ +

{{ t('admin.settings.payment.enabledHint') }}

+
+ +
+ +
+
+ + +
+
+

{{ t('admin.settings.payment.customPurchaseLink.title') }}

+

{{ t('admin.settings.payment.customPurchaseLink.description') }}

+
+
+
+
+ +

{{ t('admin.settings.payment.customPurchaseLink.enabledHint') }}

+
+ +
+
+ + +

+ {{ t('admin.settings.payment.customPurchaseLink.urlHint') }} +

+
+
+
+ +
@@ -2013,6 +2086,17 @@ type SettingsForm = SystemSettings & { smtp_password: string turnstile_secret_key: string linuxdo_connect_client_secret: string + // Payment config fields (loaded from/saved to separate payment config API) + payment_enabled: boolean + payment_min_amount: number + payment_max_amount: number + payment_daily_limit: number + payment_max_pending_orders: number + payment_order_timeout_minutes: number + payment_balance_disabled: boolean + payment_enabled_types: string + payment_help_image_url: string + payment_help_text: string } const form = reactive({ @@ -2227,7 +2311,10 @@ async function loadSettings() { loading.value = true loadFailed.value = false try { - const settings = await adminAPI.settings.getSettings() + const [settings, paymentConfigResp] = await Promise.all([ + adminAPI.settings.getSettings(), + adminAPI.payment.getConfig() + ]) Object.assign(form, settings) form.backend_mode_enabled = settings.backend_mode_enabled form.default_subscriptions = Array.isArray(settings.default_subscriptions) @@ -2246,6 +2333,17 @@ async function loadSettings() { smtpPasswordManuallyEdited.value = false form.turnstile_secret_key = '' form.linuxdo_connect_client_secret = '' + + // Populate payment config fields from the dedicated payment config API + const paymentConfig = paymentConfigResp.data + form.payment_enabled = paymentConfig.enabled + form.payment_min_amount = paymentConfig.min_amount + form.payment_max_amount = paymentConfig.max_amount + form.payment_daily_limit = paymentConfig.daily_limit + form.payment_max_pending_orders = paymentConfig.max_pending_orders + form.payment_order_timeout_minutes = paymentConfig.order_timeout_minutes + form.payment_balance_disabled = paymentConfig.balance_disabled + form.payment_enabled_types = (paymentConfig.enabled_payment_types || []).join(',') } catch (error: any) { loadFailed.value = true appStore.showError( @@ -2348,17 +2446,6 @@ async function saveSettings() { hide_ccs_import_button: form.hide_ccs_import_button, purchase_subscription_enabled: form.purchase_subscription_enabled, purchase_subscription_url: form.purchase_subscription_url, - payment_enabled: form.payment_enabled, - payment_min_amount: form.payment_min_amount, - payment_max_amount: form.payment_max_amount, - payment_daily_limit: form.payment_daily_limit, - payment_max_pending_orders: form.payment_max_pending_orders, - payment_order_timeout_minutes: form.payment_order_timeout_minutes, - payment_balance_disabled: form.payment_balance_disabled, - payment_enabled_types: form.payment_enabled_types, - payment_help_image_url: form.payment_help_image_url, - payment_help_text: form.payment_help_text, - sora_client_enabled: form.sora_client_enabled, custom_menu_items: form.custom_menu_items, custom_endpoints: form.custom_endpoints, frontend_url: form.frontend_url, @@ -2389,7 +2476,26 @@ async function saveSettings() { enable_fingerprint_unification: form.enable_fingerprint_unification, enable_metadata_passthrough: form.enable_metadata_passthrough } - const updated = await adminAPI.settings.updateSettings(payload) + + // Save payment config via dedicated payment config API + const enabledTypes = form.payment_enabled_types + ? form.payment_enabled_types.split(',').map((s: string) => s.trim()).filter(Boolean) + : [] + const paymentPayload = { + enabled: form.payment_enabled, + min_amount: form.payment_min_amount, + max_amount: form.payment_max_amount, + daily_limit: form.payment_daily_limit, + max_pending_orders: form.payment_max_pending_orders, + order_timeout_minutes: form.payment_order_timeout_minutes, + balance_disabled: form.payment_balance_disabled, + enabled_payment_types: enabledTypes + } + + const [updated] = await Promise.all([ + adminAPI.settings.updateSettings(payload), + adminAPI.payment.updateConfig(paymentPayload) + ]) Object.assign(form, updated) registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains( updated.registration_email_suffix_whitelist diff --git a/frontend/src/views/user/PurchaseRouter.vue b/frontend/src/views/user/PurchaseRouter.vue new file mode 100644 index 0000000000..b0309c3fae --- /dev/null +++ b/frontend/src/views/user/PurchaseRouter.vue @@ -0,0 +1,17 @@ + + +