mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-23 01:23:35 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -4515,14 +4515,6 @@ export default {
|
||||
helpImageUrl: '帮助图片链接',
|
||||
manageProviders: '管理服务商',
|
||||
balancePaymentDisabled: '余额支付已禁用',
|
||||
customPurchaseLink: {
|
||||
title: '自定义充值链接(旧版)',
|
||||
description: '将用户重定向到外部充值页面,而不是使用内置支付系统',
|
||||
enabled: '启用自定义充值链接',
|
||||
enabledHint: '启用后,用户将被重定向到下方的 URL 进行充值',
|
||||
url: '充值页面 URL',
|
||||
urlHint: '用户购买订阅或充值的外部链接地址',
|
||||
},
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP 设置',
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Vendored
-6
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user