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)
This commit is contained in:
erio
2026-04-06 01:51:12 +08:00
parent 96b1a96cf9
commit 4a703cb084
15 changed files with 258 additions and 27 deletions
+1
View File
@@ -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"`
}
@@ -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,
})
}
+6 -5
View File
@@ -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) {
@@ -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
}
@@ -111,6 +111,7 @@ type PublicSettings struct {
LinuxDoOAuthEnabled bool
BackendModeEnabled bool
PaymentEnabled bool
Version string
}
+42
View File
@@ -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<AdminPaymentConfig>('/admin/payment/config')
},
/** Update payment configuration */
updateConfig(data: UpdatePaymentConfigRequest) {
return apiClient.put('/admin/payment/config', data)
},
// ==================== Dashboard ====================
/** Get payment dashboard statistics */
-1
View File
@@ -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[]
+10 -2
View File
@@ -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 },
+8
View File
@@ -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',
+8
View File
@@ -4515,6 +4515,14 @@ export default {
helpImageUrl: '帮助图片链接',
manageProviders: '管理服务商',
balancePaymentDisabled: '余额支付已禁用',
customPurchaseLink: {
title: '自定义充值链接(旧版)',
description: '将用户重定向到外部充值页面,而不是使用内置支付系统',
enabled: '启用自定义充值链接',
enabledHint: '启用后,用户将被重定向到下方的 URL 进行充值',
url: '充值页面 URL',
urlHint: '用户购买订阅或充值的外部链接地址',
},
},
smtp: {
title: 'SMTP 设置',
+13 -3
View File
@@ -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 = [
+22
View File
@@ -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
}
}
+5 -2
View File
@@ -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
+120 -14
View File
@@ -1637,7 +1637,80 @@
</div><!-- /Tab: General -->
<!-- Tab: Email -->
<!-- Tab: Payment --> <div v-show="activeTab === 'payment'" class="space-y-6"> <div class="card"> <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> <h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.payment.title') }}</h2> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.description') }}</p> </div> <div class="space-y-6 p-6"> <div class="flex items-center justify-between"> <div> <label class="font-medium text-gray-900 dark:text-white">{{ t('admin.settings.payment.enabled') }}</label> <p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.enabledHint') }}</p> </div> <Toggle v-model="form.payment_enabled" /> </div> <template v-if="form.payment_enabled"> <div class="grid grid-cols-2 gap-4"> <div><label class="input-label">{{ t('admin.settings.payment.minAmount') }}</label><input v-model.number="form.payment_min_amount" type="number" step="0.01" min="0" class="input" /></div> <div><label class="input-label">{{ t('admin.settings.payment.maxAmount') }}</label><input v-model.number="form.payment_max_amount" type="number" step="0.01" min="0" class="input" /></div> </div> <div class="grid grid-cols-2 gap-4"> <div><label class="input-label">{{ t('admin.settings.payment.dailyLimit') }}</label><input v-model.number="form.payment_daily_limit" type="number" step="0.01" min="0" class="input" /></div> <div><label class="input-label">{{ t('admin.settings.payment.maxPendingOrders') }}</label><input v-model.number="form.payment_max_pending_orders" type="number" min="1" class="input" /></div> </div> <div class="grid grid-cols-2 gap-4"> <div><label class="input-label">{{ t('admin.settings.payment.orderTimeout') }}</label><input v-model.number="form.payment_order_timeout_minutes" type="number" min="1" class="input" /></div> <div class="flex items-center gap-2 pt-6"><input id="balance-disabled" v-model="form.payment_balance_disabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" /><label for="balance-disabled" class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.settings.payment.balancePaymentDisabled') }}</label></div> </div> <div><label class="input-label">{{ t('admin.settings.payment.enabledPaymentTypes') }}</label><input v-model="form.payment_enabled_types" type="text" class="input" placeholder="alipay,wxpay,stripe" /><p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.enabledPaymentTypesHint') }}</p></div> <div class="grid grid-cols-2 gap-4"> <div><label class="input-label">{{ t('admin.settings.payment.helpImageUrl') }}</label><input v-model="form.payment_help_image_url" type="url" class="input" /></div> <div><label class="input-label">{{ t('admin.settings.payment.helpText') }}</label><input v-model="form.payment_help_text" type="text" class="input" /></div> </div> <div> <a href="/admin/orders/providers" class="text-sm text-blue-600 hover:underline dark:text-blue-400">{{ t('admin.settings.payment.manageProviders') }} &rarr;</a> </div> </template> </div> </div> </div>
<!-- Tab: Payment -->
<div v-show="activeTab === 'payment'" class="space-y-6">
<!-- Payment System Settings -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.payment.title') }}</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.description') }}</p>
</div>
<div class="space-y-6 p-6">
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.settings.payment.enabled') }}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.enabledHint') }}</p>
</div>
<Toggle v-model="form.payment_enabled" />
</div>
<template v-if="form.payment_enabled">
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('admin.settings.payment.minAmount') }}</label><input v-model.number="form.payment_min_amount" type="number" step="0.01" min="0" class="input" /></div>
<div><label class="input-label">{{ t('admin.settings.payment.maxAmount') }}</label><input v-model.number="form.payment_max_amount" type="number" step="0.01" min="0" class="input" /></div>
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('admin.settings.payment.dailyLimit') }}</label><input v-model.number="form.payment_daily_limit" type="number" step="0.01" min="0" class="input" /></div>
<div><label class="input-label">{{ t('admin.settings.payment.maxPendingOrders') }}</label><input v-model.number="form.payment_max_pending_orders" type="number" min="1" class="input" /></div>
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('admin.settings.payment.orderTimeout') }}</label><input v-model.number="form.payment_order_timeout_minutes" type="number" min="1" class="input" /></div>
<div class="flex items-center gap-2 pt-6"><input id="balance-disabled" v-model="form.payment_balance_disabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" /><label for="balance-disabled" class="text-sm text-gray-700 dark:text-gray-300">{{ t('admin.settings.payment.balancePaymentDisabled') }}</label></div>
</div>
<div><label class="input-label">{{ t('admin.settings.payment.enabledPaymentTypes') }}</label><input v-model="form.payment_enabled_types" type="text" class="input" placeholder="alipay,wxpay,stripe" /><p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.enabledPaymentTypesHint') }}</p></div>
<div class="grid grid-cols-2 gap-4">
<div><label class="input-label">{{ t('admin.settings.payment.helpImageUrl') }}</label><input v-model="form.payment_help_image_url" type="url" class="input" /></div>
<div><label class="input-label">{{ t('admin.settings.payment.helpText') }}</label><input v-model="form.payment_help_text" type="text" class="input" /></div>
</div>
<div>
<a href="/admin/orders/providers" class="text-sm text-blue-600 hover:underline dark:text-blue-400">{{ t('admin.settings.payment.manageProviders') }} &rarr;</a>
</div>
</template>
</div>
</div>
<!-- Custom Purchase Link (Legacy) -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.settings.payment.customPurchaseLink.title') }}</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.customPurchaseLink.description') }}</p>
</div>
<div class="space-y-4 p-6">
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.settings.payment.customPurchaseLink.enabled') }}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.customPurchaseLink.enabledHint') }}</p>
</div>
<Toggle v-model="form.purchase_subscription_enabled" />
</div>
<div v-if="form.purchase_subscription_enabled">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.payment.customPurchaseLink.url') }}
</label>
<input
v-model="form.purchase_subscription_url"
type="url"
class="input"
placeholder="https://..."
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.payment.customPurchaseLink.urlHint') }}
</p>
</div>
</div>
</div>
</div>
<div v-show="activeTab === 'email'" class="space-y-6">
<!-- Email disabled hint - show when email_verify_enabled is off -->
<div v-if="!form.email_verify_enabled" class="card">
@@ -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<SettingsForm>({
@@ -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
@@ -0,0 +1,17 @@
<template>
<PaymentView v-if="paymentEnabled" />
<PurchaseSubscriptionView v-else-if="purchaseSubscriptionEnabled" />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAppStore } from '@/stores/app'
import PaymentView from './PaymentView.vue'
import PurchaseSubscriptionView from './PurchaseSubscriptionView.vue'
const appStore = useAppStore()
const paymentEnabled = computed(() => appStore.cachedPublicSettings?.payment_enabled ?? false)
const purchaseSubscriptionEnabled = computed(
() => appStore.cachedPublicSettings?.purchase_subscription_enabled ?? false
)
</script>