refactor: migrate legacy purchase subscription to custom menu item

- Restore MigrateLegacyPurchaseURL auto-call on startup: converts old
  purchase_subscription_url into a custom menu item (id: migrated_purchase_subscription)
  and clears the legacy settings
- Delete PurchaseRouter.vue and PurchaseSubscriptionView.vue (replaced
  by existing CustomPageView.vue via custom menu system)
- /purchase route now exclusively serves the new built-in payment system
- Remove purchase_subscription_enabled from sidebar, router, settings UI,
  TypeScript types, and i18n keys
- Legacy purchase URL users will see their link as a custom menu entry
  after upgrade, editable in admin custom menu settings
This commit is contained in:
erio
2026-04-06 02:09:26 +08:00
parent 4a703cb084
commit 3703b5c08b
14 changed files with 18 additions and 266 deletions
@@ -448,7 +448,10 @@ func pcParseInt(s string, defaultVal int) int {
// MigrateLegacyPurchaseURL checks if the old purchase_subscription_url setting
// exists and is enabled, and if so, migrates it to a custom_menu_item entry.
// This is a one-time migration that runs at startup.
// After migration, both purchase_subscription_enabled and purchase_subscription_url
// are cleared since the functionality is fully replaced by custom menu items.
// This is idempotent: it skips if a menu item with ID "migrated_purchase_subscription"
// already exists.
func (s *PaymentConfigService) MigrateLegacyPurchaseURL(ctx context.Context) error {
enabled, _ := s.settingRepo.GetValue(ctx, SettingKeyPurchaseSubscriptionEnabled)
if enabled != "true" {
@@ -475,9 +478,9 @@ func (s *PaymentConfigService) MigrateLegacyPurchaseURL(ctx context.Context) err
_ = json.Unmarshal([]byte(raw), &items)
}
// Check if already migrated (same URL exists)
// Check if already migrated (same ID exists)
for _, item := range items {
if item.URL == url {
if item.ID == "migrated_purchase_subscription" {
return nil
}
}
@@ -489,7 +492,7 @@ func (s *PaymentConfigService) MigrateLegacyPurchaseURL(ctx context.Context) err
IconSVG: "",
URL: url,
Visibility: "user",
SortOrder: 0,
SortOrder: 100,
})
data, err := json.Marshal(items)
@@ -497,10 +500,11 @@ func (s *PaymentConfigService) MigrateLegacyPurchaseURL(ctx context.Context) err
return fmt.Errorf("marshal custom_menu_items: %w", err)
}
// Save updated menu items and clear old settings
// Save updated menu items and clear legacy settings entirely
if err := s.settingRepo.SetMultiple(ctx, map[string]string{
SettingKeyCustomMenuItems: string(data),
SettingKeyPurchaseSubscriptionEnabled: "false",
SettingKeyPurchaseSubscriptionURL: "",
}); err != nil {
return fmt.Errorf("save migrated settings: %w", err)
}
+4 -5
View File
@@ -150,11 +150,10 @@ func NewPaymentService(entClient *dbent.Client, registry *payment.Registry, load
}
// 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) Start(ctx context.Context) {
if err := s.configService.MigrateLegacyPurchaseURL(ctx); err != nil {
slog.Error("[payment] failed to migrate legacy purchase URL", "error", err)
}
}
func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*CreateOrderResponse, error) {
-4
View File
@@ -38,8 +38,6 @@ export interface SystemSettings {
doc_url: string
home_content: string
hide_ccs_import_button: boolean
purchase_subscription_enabled: boolean
purchase_subscription_url: string
sora_client_enabled: boolean
backend_mode_enabled: boolean
custom_menu_items: CustomMenuItem[]
@@ -112,8 +110,6 @@ export interface UpdateSettingsRequest {
doc_url?: string
home_content?: string
hide_ccs_import_button?: boolean
purchase_subscription_enabled?: boolean
purchase_subscription_url?: string
backend_mode_enabled?: boolean
custom_menu_items?: CustomMenuItem[]
custom_endpoints?: CustomEndpoint[]
@@ -552,7 +552,7 @@ const userNavItems = computed((): NavItem[] => {
...(appStore.cachedPublicSettings?.sora_client_enabled
? [{ path: '/sora', label: t('nav.sora'), icon: SoraIcon }]
: []),
...(appStore.cachedPublicSettings?.payment_enabled || appStore.cachedPublicSettings?.purchase_subscription_enabled
...(appStore.cachedPublicSettings?.payment_enabled
? [
{
path: '/purchase',
@@ -593,7 +593,7 @@ const personalNavItems = computed((): NavItem[] => {
...(appStore.cachedPublicSettings?.sora_client_enabled
? [{ path: '/sora', label: t('nav.sora'), icon: SoraIcon }]
: []),
...(appStore.cachedPublicSettings?.payment_enabled || appStore.cachedPublicSettings?.purchase_subscription_enabled
...(appStore.cachedPublicSettings?.payment_enabled
? [
{
path: '/purchase',
-8
View File
@@ -4342,14 +4342,6 @@ 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,14 +4515,6 @@ export default {
helpImageUrl: '帮助图片链接',
manageProviders: '管理服务商',
balancePaymentDisabled: '余额支付已禁用',
customPurchaseLink: {
title: '自定义充值链接(旧版)',
description: '将用户重定向到外部充值页面,而不是使用内置支付系统',
enabled: '启用自定义充值链接',
enabledHint: '启用后,用户将被重定向到下方的 URL 进行充值',
url: '充值页面 URL',
urlHint: '用户购买订阅或充值的外部链接地址',
},
},
smtp: {
title: 'SMTP 设置',
+2 -12
View File
@@ -192,14 +192,14 @@ const routes: RouteRecordRaw[] = [
{
path: '/purchase',
name: 'PurchaseSubscription',
component: () => import('@/views/user/PurchaseRouter.vue'),
component: () => import('@/views/user/PaymentView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Purchase Subscription',
titleKey: 'purchase.title',
descriptionKey: 'purchase.description',
requiresEitherPayment: true
requiresPayment: true
}
},
{
@@ -576,16 +576,6 @@ 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 = [
-6
View File
@@ -49,12 +49,6 @@ declare module 'vue-router' {
*/
requiresPayment?: boolean
/**
* Whether this route requires either internal payment or external purchase subscription
* @default false
*/
requiresEitherPayment?: boolean
/**
* i18n key for the page title
*/
-2
View File
@@ -327,9 +327,7 @@ export const useAppStore = defineStore('app', () => {
doc_url: docUrl.value,
home_content: '',
hide_ccs_import_button: false,
purchase_subscription_enabled: false,
payment_enabled: false,
purchase_subscription_url: '',
custom_menu_items: [],
custom_endpoints: [],
linuxdo_oauth_enabled: false,
-2
View File
@@ -104,9 +104,7 @@ export interface PublicSettings {
doc_url: string
home_content: string
hide_ccs_import_button: boolean
purchase_subscription_enabled: boolean
payment_enabled: boolean
purchase_subscription_url: string
custom_menu_items: CustomMenuItem[]
custom_endpoints: CustomEndpoint[]
linuxdo_oauth_enabled: boolean
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Shared URL builder for iframe-embedded pages.
* Used by PurchaseSubscriptionView and CustomPageView to build consistent URLs
* Used by CustomPageView to build consistent URLs
* with user_id, token, theme, lang, ui_mode, src_host, and src parameters.
*/
-35
View File
@@ -1679,37 +1679,6 @@
</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 -->
@@ -2120,8 +2089,6 @@ const form = reactive<SettingsForm>({
home_content: '',
backend_mode_enabled: false,
hide_ccs_import_button: false,
purchase_subscription_enabled: false,
purchase_subscription_url: '',
payment_enabled: false, payment_min_amount: 1, payment_max_amount: 10000, payment_daily_limit: 50000, payment_max_pending_orders: 3, payment_order_timeout_minutes: 30, payment_balance_disabled: false, payment_enabled_types: '', payment_help_image_url: '', payment_help_text: '',
sora_client_enabled: false,
custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>,
@@ -2444,8 +2411,6 @@ async function saveSettings() {
home_content: form.home_content,
backend_mode_enabled: form.backend_mode_enabled,
hide_ccs_import_button: form.hide_ccs_import_button,
purchase_subscription_enabled: form.purchase_subscription_enabled,
purchase_subscription_url: form.purchase_subscription_url,
custom_menu_items: form.custom_menu_items,
custom_endpoints: form.custom_endpoints,
frontend_url: form.frontend_url,
@@ -1,17 +0,0 @@
<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>
@@ -1,159 +0,0 @@
<template>
<AppLayout>
<div class="purchase-page-layout">
<div class="card flex-1 min-h-0 overflow-hidden">
<div v-if="loading" class="flex h-full items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
></div>
</div>
<div
v-else-if="!purchaseEnabled"
class="flex h-full items-center justify-center p-10 text-center"
>
<div class="max-w-md">
<div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
>
<Icon name="creditCard" size="lg" class="text-gray-400" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('purchase.notEnabledTitle') }}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ t('purchase.notEnabledDesc') }}
</p>
</div>
</div>
<div
v-else-if="!isValidUrl"
class="flex h-full items-center justify-center p-10 text-center"
>
<div class="max-w-md">
<div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
>
<Icon name="link" size="lg" class="text-gray-400" />
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('purchase.notConfiguredTitle') }}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ t('purchase.notConfiguredDesc') }}
</p>
</div>
</div>
<div v-else class="purchase-embed-shell">
<a
:href="purchaseUrl"
target="_blank"
rel="noopener noreferrer"
class="btn btn-secondary btn-sm purchase-open-fab"
>
<Icon name="externalLink" size="sm" class="mr-1.5" :stroke-width="2" />
{{ t('purchase.openInNewTab') }}
</a>
<iframe
:src="purchaseUrl"
class="purchase-embed-frame"
allowfullscreen
></iframe>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores'
import { useAuthStore } from '@/stores/auth'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import { buildEmbeddedUrl, detectTheme } from '@/utils/embedded-url'
const { t, locale } = useI18n()
const appStore = useAppStore()
const authStore = useAuthStore()
const loading = ref(false)
const purchaseTheme = ref<'light' | 'dark'>('light')
let themeObserver: MutationObserver | null = null
const purchaseEnabled = computed(() => {
return appStore.cachedPublicSettings?.purchase_subscription_enabled ?? false
})
const purchaseUrl = computed(() => {
const baseUrl = (appStore.cachedPublicSettings?.purchase_subscription_url || '').trim()
return buildEmbeddedUrl(baseUrl, authStore.user?.id, authStore.token, purchaseTheme.value, locale.value)
})
const isValidUrl = computed(() => {
const url = purchaseUrl.value
return url.startsWith('http://') || url.startsWith('https://')
})
onMounted(async () => {
purchaseTheme.value = detectTheme()
if (typeof document !== 'undefined') {
themeObserver = new MutationObserver(() => {
purchaseTheme.value = detectTheme()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
}
if (appStore.publicSettingsLoaded) return
loading.value = true
try {
await appStore.fetchPublicSettings()
} finally {
loading.value = false
}
})
onUnmounted(() => {
if (themeObserver) {
themeObserver.disconnect()
themeObserver = null
}
})
</script>
<style scoped>
.purchase-page-layout {
@apply flex flex-col;
height: calc(100vh - 64px - 4rem);
}
.purchase-embed-shell {
@apply relative;
@apply h-full w-full overflow-hidden rounded-2xl;
@apply bg-gradient-to-b from-gray-50 to-white dark:from-dark-900 dark:to-dark-950;
@apply p-0;
}
.purchase-open-fab {
@apply absolute right-3 top-3 z-10;
@apply shadow-sm backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:supports-[backdrop-filter]:bg-dark-800/80;
}
.purchase-embed-frame {
display: block;
margin: 0;
width: 100%;
height: 100%;
border: 0;
border-radius: 0;
box-shadow: none;
background: transparent;
}
</style>