From 067810fa9888e2fa226718f54e86cdf712d04267 Mon Sep 17 00:00:00 2001 From: erio Date: Mon, 2 Mar 2026 19:37:40 +0800 Subject: [PATCH] feat: custom menu pages with iframe embedding and CSP injection Add configurable custom menu items that appear in sidebar, each rendering an iframe-embedded external page. Includes shared URL builder with src_host/src_url tracking, CSP frame-src multi-origin deduplication, admin settings UI, and i18n support. chore: bump version to 0.1.87.19 Co-Authored-By: Claude Opus 4.6 --- .../internal/handler/admin/setting_handler.go | 62 +++++++ backend/internal/handler/dto/settings.go | 12 ++ backend/internal/handler/setting_handler.go | 17 ++ .../server/middleware/security_headers.go | 19 +- backend/internal/server/router.go | 91 +++++++++- backend/internal/service/domain_constants.go | 5 +- backend/internal/service/setting_service.go | 5 + backend/internal/service/settings_view.go | 2 + frontend/src/api/admin/settings.ts | 3 + frontend/src/components/layout/AppSidebar.vue | 74 +++++++- frontend/src/i18n/locales/en.ts | 29 +++ frontend/src/i18n/locales/zh.ts | 29 +++ frontend/src/router/index.ts | 11 ++ frontend/src/stores/app.ts | 1 + frontend/src/types/index.ts | 10 ++ frontend/src/utils/embedded-url.ts | 46 +++++ frontend/src/views/admin/SettingsView.vue | 164 +++++++++++++++++ frontend/src/views/user/CustomPageView.vue | 166 ++++++++++++++++++ .../views/user/PurchaseSubscriptionView.vue | 37 +--- tmp_api_admin_orders/[id]/cancel/route.ts | 25 +++ tmp_api_admin_orders/[id]/retry/route.ts | 25 +++ tmp_api_admin_orders/[id]/route.ts | 31 ++++ tmp_api_admin_orders/route.ts | 60 +++++++ tmp_api_orders/[id]/cancel/route.ts | 37 ++++ tmp_api_orders/[id]/route.ts | 50 ++++++ tmp_api_orders/my/route.ts | 46 +++++ tmp_api_orders/route.ts | 68 +++++++ 27 files changed, 1071 insertions(+), 54 deletions(-) create mode 100644 frontend/src/utils/embedded-url.ts create mode 100644 frontend/src/views/user/CustomPageView.vue create mode 100644 tmp_api_admin_orders/[id]/cancel/route.ts create mode 100644 tmp_api_admin_orders/[id]/retry/route.ts create mode 100644 tmp_api_admin_orders/[id]/route.ts create mode 100644 tmp_api_admin_orders/route.ts create mode 100644 tmp_api_orders/[id]/cancel/route.ts create mode 100644 tmp_api_orders/[id]/route.ts create mode 100644 tmp_api_orders/my/route.ts create mode 100644 tmp_api_orders/route.ts diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index e7da042c..eec403dc 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -1,6 +1,9 @@ package admin import ( + "crypto/rand" + "encoding/hex" + "encoding/json" "fmt" "log" "net/http" @@ -20,6 +23,27 @@ import ( // semverPattern 预编译 semver 格式校验正则 var semverPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`) +// generateMenuItemID generates a short random hex ID for a custom menu item. +func generateMenuItemID() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// parseCustomMenuItems parses a JSON string into a slice of CustomMenuItem. +// Returns empty slice on empty/invalid input. +func parseCustomMenuItems(raw string) []dto.CustomMenuItem { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "[]" { + return []dto.CustomMenuItem{} + } + var items []dto.CustomMenuItem + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return []dto.CustomMenuItem{} + } + return items +} + // SettingHandler 系统设置处理器 type SettingHandler struct { settingService *service.SettingService @@ -92,6 +116,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, SoraClientEnabled: settings.SoraClientEnabled, + CustomMenuItems: parseCustomMenuItems(settings.CustomMenuItems), DefaultConcurrency: settings.DefaultConcurrency, DefaultBalance: settings.DefaultBalance, DefaultSubscriptions: defaultSubscriptions, @@ -152,6 +177,7 @@ type UpdateSettingsRequest struct { PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"` PurchaseSubscriptionURL *string `json:"purchase_subscription_url"` SoraClientEnabled bool `json:"sora_client_enabled"` + CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"` // 默认配置 DefaultConcurrency int `json:"default_concurrency"` @@ -299,6 +325,40 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } + // 自定义菜单项验证 + customMenuJSON := previousSettings.CustomMenuItems + if req.CustomMenuItems != nil { + items := *req.CustomMenuItems + for i, item := range items { + if strings.TrimSpace(item.Label) == "" { + response.BadRequest(c, "Custom menu item label is required") + return + } + if strings.TrimSpace(item.URL) == "" { + response.BadRequest(c, "Custom menu item URL is required") + return + } + if err := config.ValidateAbsoluteHTTPURL(strings.TrimSpace(item.URL)); err != nil { + response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL") + return + } + if item.Visibility != "user" && item.Visibility != "admin" { + response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'") + return + } + // Auto-generate ID if missing + if strings.TrimSpace(item.ID) == "" { + items[i].ID = generateMenuItemID() + } + } + menuBytes, err := json.Marshal(items) + if err != nil { + response.BadRequest(c, "Failed to serialize custom menu items") + return + } + customMenuJSON = string(menuBytes) + } + // Ops metrics collector interval validation (seconds). if req.OpsMetricsIntervalSeconds != nil { v := *req.OpsMetricsIntervalSeconds @@ -358,6 +418,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { PurchaseSubscriptionEnabled: purchaseEnabled, PurchaseSubscriptionURL: purchaseURL, SoraClientEnabled: req.SoraClientEnabled, + CustomMenuItems: customMenuJSON, DefaultConcurrency: req.DefaultConcurrency, DefaultBalance: req.DefaultBalance, DefaultSubscriptions: defaultSubscriptions, @@ -449,6 +510,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled, PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL, SoraClientEnabled: updatedSettings.SoraClientEnabled, + CustomMenuItems: parseCustomMenuItems(updatedSettings.CustomMenuItems), DefaultConcurrency: updatedSettings.DefaultConcurrency, DefaultBalance: updatedSettings.DefaultBalance, DefaultSubscriptions: updatedDefaultSubscriptions, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index e9086010..a7d5da22 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -1,5 +1,15 @@ package dto +// CustomMenuItem represents a user-configured custom menu entry. +type CustomMenuItem struct { + ID string `json:"id"` + Label string `json:"label"` + IconSVG string `json:"icon_svg"` + URL string `json:"url"` + Visibility string `json:"visibility"` // "user" or "admin" + SortOrder int `json:"sort_order"` +} + // SystemSettings represents the admin settings API response payload. type SystemSettings struct { RegistrationEnabled bool `json:"registration_enabled"` @@ -38,6 +48,7 @@ type SystemSettings struct { PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` PurchaseSubscriptionURL string `json:"purchase_subscription_url"` SoraClientEnabled bool `json:"sora_client_enabled"` + CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` DefaultConcurrency int `json:"default_concurrency"` DefaultBalance float64 `json:"default_balance"` @@ -87,6 +98,7 @@ type PublicSettings struct { HideCcsImportButton bool `json:"hide_ccs_import_button"` PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` PurchaseSubscriptionURL string `json:"purchase_subscription_url"` + CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` SoraClientEnabled bool `json:"sora_client_enabled"` Version string `json:"version"` diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 2141a9ee..1b8e33a8 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -1,6 +1,9 @@ package handler import ( + "encoding/json" + "strings" + "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" @@ -50,8 +53,22 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { HideCcsImportButton: settings.HideCcsImportButton, PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled, PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL, + CustomMenuItems: parsePublicCustomMenuItems(settings.CustomMenuItems), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, SoraClientEnabled: settings.SoraClientEnabled, Version: h.version, }) } + +// parsePublicCustomMenuItems parses a JSON string into a slice of CustomMenuItem. +func parsePublicCustomMenuItems(raw string) []dto.CustomMenuItem { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "[]" { + return []dto.CustomMenuItem{} + } + var items []dto.CustomMenuItem + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return []dto.CustomMenuItem{} + } + return items +} diff --git a/backend/internal/server/middleware/security_headers.go b/backend/internal/server/middleware/security_headers.go index f061db90..d9ec951e 100644 --- a/backend/internal/server/middleware/security_headers.go +++ b/backend/internal/server/middleware/security_headers.go @@ -41,7 +41,9 @@ func GetNonceFromContext(c *gin.Context) string { } // SecurityHeaders sets baseline security headers for all responses. -func SecurityHeaders(cfg config.CSPConfig) gin.HandlerFunc { +// getFrameSrcOrigins is an optional function that returns extra origins to inject into frame-src; +// pass nil to disable dynamic frame-src injection. +func SecurityHeaders(cfg config.CSPConfig, getFrameSrcOrigins func() []string) gin.HandlerFunc { policy := strings.TrimSpace(cfg.Policy) if policy == "" { policy = config.DefaultCSPPolicy @@ -51,6 +53,15 @@ func SecurityHeaders(cfg config.CSPConfig) gin.HandlerFunc { policy = enhanceCSPPolicy(policy) return func(c *gin.Context) { + finalPolicy := policy + if getFrameSrcOrigins != nil { + for _, origin := range getFrameSrcOrigins() { + if origin != "" { + finalPolicy = addToDirective(finalPolicy, "frame-src", origin) + } + } + } + c.Header("X-Content-Type-Options", "nosniff") c.Header("X-Frame-Options", "DENY") c.Header("Referrer-Policy", "strict-origin-when-cross-origin") @@ -65,12 +76,10 @@ func SecurityHeaders(cfg config.CSPConfig) gin.HandlerFunc { if err != nil { // crypto/rand 失败时降级为无 nonce 的 CSP 策略 log.Printf("[SecurityHeaders] %v — 降级为无 nonce 的 CSP", err) - finalPolicy := strings.ReplaceAll(policy, NonceTemplate, "'unsafe-inline'") - c.Header("Content-Security-Policy", finalPolicy) + c.Header("Content-Security-Policy", strings.ReplaceAll(finalPolicy, NonceTemplate, "'unsafe-inline'")) } else { c.Set(CSPNonceKey, nonce) - finalPolicy := strings.ReplaceAll(policy, NonceTemplate, "'nonce-"+nonce+"'") - c.Header("Content-Security-Policy", finalPolicy) + c.Header("Content-Security-Policy", strings.ReplaceAll(finalPolicy, NonceTemplate, "'nonce-"+nonce+"'")) } } c.Next() diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 07b51f23..c44a4608 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -1,7 +1,13 @@ package server import ( + "context" + "encoding/json" "log" + "net/url" + "strings" + "sync/atomic" + "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler" @@ -14,6 +20,25 @@ import ( "github.com/redis/go-redis/v9" ) +// extractOrigin returns the scheme+host origin from rawURL, or "" on error. +// Only http and https schemes are accepted; other values (e.g. "//host/path") return "". +func extractOrigin(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "" + } + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return "" + } + if u.Scheme != "http" && u.Scheme != "https" { + return "" + } + return u.Scheme + "://" + u.Host +} + +const paymentOriginFetchTimeout = 5 * time.Second + // SetupRouter 配置路由器中间件和路由 func SetupRouter( r *gin.Engine, @@ -28,11 +53,65 @@ func SetupRouter( cfg *config.Config, redisClient *redis.Client, ) *gin.Engine { + // 缓存 iframe 页面的 origin 列表,用于动态注入 CSP frame-src + // 包含 purchase_subscription_url 和所有 custom_menu_items 的 origin(去重) + var cachedFrameOrigins atomic.Pointer[[]string] + emptyOrigins := []string{} + cachedFrameOrigins.Store(&emptyOrigins) + + refreshFrameOrigins := func() { + ctx, cancel := context.WithTimeout(context.Background(), paymentOriginFetchTimeout) + defer cancel() + settings, err := settingService.GetPublicSettings(ctx) + if err != nil { + // 获取失败时保留已有缓存,避免 frame-src 被意外清空 + return + } + + seen := make(map[string]struct{}) + var origins []string + + // purchase subscription URL + if settings.PurchaseSubscriptionEnabled { + if origin := extractOrigin(settings.PurchaseSubscriptionURL); origin != "" { + if _, ok := seen[origin]; !ok { + seen[origin] = struct{}{} + origins = append(origins, origin) + } + } + } + + // custom menu items + if raw := strings.TrimSpace(settings.CustomMenuItems); raw != "" && raw != "[]" { + var items []struct { + URL string `json:"url"` + } + if err := json.Unmarshal([]byte(raw), &items); err == nil { + for _, item := range items { + if origin := extractOrigin(item.URL); origin != "" { + if _, ok := seen[origin]; !ok { + seen[origin] = struct{}{} + origins = append(origins, origin) + } + } + } + } + } + + cachedFrameOrigins.Store(&origins) + } + refreshFrameOrigins() // 启动时初始化 + // 应用中间件 r.Use(middleware2.RequestLogger()) r.Use(middleware2.Logger()) r.Use(middleware2.CORS(cfg.CORS)) - r.Use(middleware2.SecurityHeaders(cfg.Security.CSP)) + r.Use(middleware2.SecurityHeaders(cfg.Security.CSP, func() []string { + if p := cachedFrameOrigins.Load(); p != nil { + return *p + } + return nil + })) // Serve embedded frontend with settings injection if available if web.HasEmbeddedFrontend() { @@ -40,11 +119,17 @@ func SetupRouter( if err != nil { log.Printf("Warning: Failed to create frontend server with settings injection: %v, using legacy mode", err) r.Use(web.ServeEmbeddedFrontend()) + settingService.SetOnUpdateCallback(refreshFrameOrigins) } else { - // Register cache invalidation callback - settingService.SetOnUpdateCallback(frontendServer.InvalidateCache) + // Register combined callback: invalidate HTML cache + refresh frame origins + settingService.SetOnUpdateCallback(func() { + frontendServer.InvalidateCache() + refreshFrameOrigins() + }) r.Use(frontendServer.Middleware()) } + } else { + settingService.SetOnUpdateCallback(refreshFrameOrigins) } // 注册路由 diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index b304bc9f..cf61e3d1 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -113,8 +113,9 @@ const ( SettingKeyDocURL = "doc_url" // 文档链接 SettingKeyHomeContent = "home_content" // 首页内容(支持 Markdown/HTML,或 URL 作为 iframe src) SettingKeyHideCcsImportButton = "hide_ccs_import_button" // 是否隐藏 API Keys 页面的导入 CCS 按钮 - SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示“购买订阅”页面入口 - SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // “购买订阅”页面 URL(作为 iframe src) + SettingKeyPurchaseSubscriptionEnabled = “purchase_subscription_enabled” // 是否展示”购买订阅”页面入口 + SettingKeyPurchaseSubscriptionURL = “purchase_subscription_url” // “购买订阅”页面 URL(作为 iframe src) + SettingKeyCustomMenuItems = “custom_menu_items” // 自定义菜单项(JSON 数组) // 默认配置 SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量 diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 64871b9a..04f49273 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -124,6 +124,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyPurchaseSubscriptionEnabled, SettingKeyPurchaseSubscriptionURL, SettingKeySoraClientEnabled, + SettingKeyCustomMenuItems, SettingKeyLinuxDoConnectEnabled, } @@ -163,6 +164,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true", + CustomMenuItems: settings[SettingKeyCustomMenuItems], LinuxDoOAuthEnabled: linuxDoEnabled, }, nil } @@ -293,6 +295,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled) updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL) updates[SettingKeySoraClientEnabled] = strconv.FormatBool(settings.SoraClientEnabled) + updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems // 默认配置 updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) @@ -509,6 +512,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyPurchaseSubscriptionEnabled: "false", SettingKeyPurchaseSubscriptionURL: "", SettingKeySoraClientEnabled: "false", + SettingKeyCustomMenuItems: "[]", SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), SettingKeyDefaultSubscriptions: "[]", @@ -567,6 +571,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true", PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]), SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true", + CustomMenuItems: settings[SettingKeyCustomMenuItems], } // 解析整数类型 diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 5a441ea1..9f0de600 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -40,6 +40,7 @@ type SystemSettings struct { PurchaseSubscriptionEnabled bool PurchaseSubscriptionURL string SoraClientEnabled bool + CustomMenuItems string // JSON array of custom menu items DefaultConcurrency int DefaultBalance float64 @@ -92,6 +93,7 @@ type PublicSettings struct { PurchaseSubscriptionEnabled bool PurchaseSubscriptionURL string SoraClientEnabled bool + CustomMenuItems string // JSON array of custom menu items LinuxDoOAuthEnabled bool Version string diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index c1b767ba..52855a04 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -4,6 +4,7 @@ */ import { apiClient } from '../client' +import type { CustomMenuItem } from '@/types' export interface DefaultSubscriptionSetting { group_id: number @@ -38,6 +39,7 @@ export interface SystemSettings { purchase_subscription_enabled: boolean purchase_subscription_url: string sora_client_enabled: boolean + custom_menu_items: CustomMenuItem[] // SMTP settings smtp_host: string smtp_port: number @@ -99,6 +101,7 @@ export interface UpdateSettingsRequest { purchase_subscription_enabled?: boolean purchase_subscription_url?: string sora_client_enabled?: boolean + custom_menu_items?: CustomMenuItem[] smtp_host?: string smtp_port?: number smtp_username?: string diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index b356e3e5..5b5db67e 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -47,7 +47,8 @@ " @click="handleMenuItemClick(item.path)" > - + + {{ item.label }} @@ -71,7 +72,8 @@ :data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined" @click="handleMenuItemClick(item.path)" > - + + {{ item.label }} @@ -92,7 +94,8 @@ :data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined" @click="handleMenuItemClick(item.path)" > - + + {{ item.label }} @@ -150,6 +153,14 @@ import { useI18n } from 'vue-i18n' import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores' import VersionBadge from '@/components/common/VersionBadge.vue' +interface NavItem { + path: string + label: string + icon: unknown + iconSvg?: string + hideInSimpleMode?: boolean +} + const { t } = useI18n() const route = useRoute() @@ -496,8 +507,8 @@ const ChevronDoubleRightIcon = { } // User navigation items (for regular users) -const userNavItems = computed(() => { - const items = [ +const userNavItems = computed((): NavItem[] => { + const items: NavItem[] = [ { path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, @@ -515,6 +526,13 @@ const userNavItems = computed(() => { } ] : []), + ...customMenuItemsForUser.value.map((item): NavItem => ({ + path: `/custom/${item.id}`, + label: item.label, + icon: null, + iconSvg: item.icon_svg, + hideInSimpleMode: true, + })), { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/profile', label: t('nav.profile'), icon: UserIcon } ] @@ -522,8 +540,8 @@ const userNavItems = computed(() => { }) // Personal navigation items (for admin's "My Account" section, without Dashboard) -const personalNavItems = computed(() => { - const items = [ +const personalNavItems = computed((): NavItem[] => { + const items: NavItem[] = [ { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, @@ -540,15 +558,37 @@ const personalNavItems = computed(() => { } ] : []), + ...customMenuItemsForUser.value.map((item): NavItem => ({ + path: `/custom/${item.id}`, + label: item.label, + icon: null, + iconSvg: item.icon_svg, + hideInSimpleMode: true, + })), { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/profile', label: t('nav.profile'), icon: UserIcon } ] return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items }) +// Custom menu items filtered by visibility +const customMenuItemsForUser = computed(() => { + const items = appStore.cachedPublicSettings?.custom_menu_items ?? [] + return items + .filter((item) => item.visibility === 'user') + .sort((a, b) => a.sort_order - b.sort_order) +}) + +const customMenuItemsForAdmin = computed(() => { + const items = appStore.cachedPublicSettings?.custom_menu_items ?? [] + return items + .filter((item) => item.visibility === 'admin') + .sort((a, b) => a.sort_order - b.sort_order) +}) + // Admin navigation items -const adminNavItems = computed(() => { - const baseItems = [ +const adminNavItems = computed((): NavItem[] => { + const baseItems: NavItem[] = [ { path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon }, ...(adminSettingsStore.opsMonitoringEnabled ? [{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon }] @@ -567,6 +607,10 @@ const adminNavItems = computed(() => { // 简单模式下,在系统设置前插入 API密钥 if (authStore.isSimpleMode) { const filtered = baseItems.filter(item => !item.hideInSimpleMode) + // Add admin custom menu items + for (const cm of customMenuItemsForAdmin.value) { + filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg }) + } filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }) filtered.push({ path: '/admin/data-management', label: t('nav.dataManagement'), icon: DatabaseIcon }) filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }) @@ -574,6 +618,10 @@ const adminNavItems = computed(() => { } baseItems.push({ path: '/admin/data-management', label: t('nav.dataManagement'), icon: DatabaseIcon }) + // Add admin custom menu items before settings + for (const cm of customMenuItemsForAdmin.value) { + baseItems.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg }) + } baseItems.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon }) return baseItems }) @@ -654,4 +702,12 @@ onMounted(() => { .fade-leave-to { opacity: 0; } + +/* Custom SVG icon in sidebar: inherit color, constrain size */ +.sidebar-svg-icon :deep(svg) { + width: 1.25rem; + height: 1.25rem; + stroke: currentColor; + fill: none; +} diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 01b7919a..42cf9765 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3625,6 +3625,25 @@ export default { enabled: 'Enable Sora Client', enabledHint: 'When enabled, the Sora entry will be shown in the sidebar for users to access Sora features' }, + customMenu: { + title: 'Custom Menu Pages', + description: 'Add custom iframe pages to the sidebar navigation. Each page can be visible to regular users or administrators.', + itemLabel: 'Menu Item #{n}', + name: 'Menu Name', + namePlaceholder: 'e.g. Help Center', + url: 'Page URL', + urlPlaceholder: 'https://example.com/page', + iconSvg: 'SVG Icon', + iconSvgPlaceholder: '...', + iconPreview: 'Icon Preview', + visibility: 'Visible To', + visibilityUser: 'Regular Users', + visibilityAdmin: 'Administrators', + add: 'Add Menu Item', + remove: 'Remove', + moveUp: 'Move Up', + moveDown: 'Move Down', + }, smtp: { title: 'SMTP Settings', description: 'Configure email sending for verification codes', @@ -3913,6 +3932,16 @@ export default { 'The administrator enabled the entry but has not configured a recharge/subscription URL. Please contact admin.' }, + // Custom Page (iframe embed) + customPage: { + title: 'Custom Page', + openInNewTab: 'Open in new tab', + notFoundTitle: 'Page not found', + notFoundDesc: 'This custom page does not exist or has been removed.', + notConfiguredTitle: 'Page URL not configured', + notConfiguredDesc: 'The URL for this custom page has not been properly configured.', + }, + // Announcements Page announcements: { title: 'Announcements', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 3411d310..a0632fd9 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3795,6 +3795,25 @@ export default { enabled: '启用 Sora 客户端', enabledHint: '开启后,侧边栏将显示 Sora 入口,用户可访问 Sora 功能' }, + customMenu: { + title: '自定义菜单页面', + description: '添加自定义 iframe 页面到侧边栏导航。每个页面可以设置为普通用户或管理员可见。', + itemLabel: '菜单项 #{n}', + name: '菜单名称', + namePlaceholder: '如:帮助中心', + url: '页面 URL', + urlPlaceholder: 'https://example.com/page', + iconSvg: 'SVG 图标', + iconSvgPlaceholder: '...', + iconPreview: '图标预览', + visibility: '可见角色', + visibilityUser: '普通用户', + visibilityAdmin: '管理员', + add: '添加菜单项', + remove: '删除', + moveUp: '上移', + moveDown: '下移', + }, smtp: { title: 'SMTP 设置', description: '配置用于发送验证码的邮件服务', @@ -4081,6 +4100,16 @@ export default { notConfiguredDesc: '管理员已开启入口,但尚未配置充值/订阅链接,请联系管理员。' }, + // Custom Page (iframe embed) + customPage: { + title: '自定义页面', + openInNewTab: '新窗口打开', + notFoundTitle: '页面不存在', + notFoundDesc: '该自定义页面不存在或已被删除。', + notConfiguredTitle: '页面链接未配置', + notConfiguredDesc: '该自定义页面的 URL 未正确配置。', + }, + // Announcements Page announcements: { title: '公告', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index cb81d160..142828cb 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -203,6 +203,17 @@ const routes: RouteRecordRaw[] = [ descriptionKey: 'sora.description' } }, + { + path: '/custom/:id', + name: 'CustomPage', + component: () => import('@/views/user/CustomPageView.vue'), + meta: { + requiresAuth: true, + requiresAdmin: false, + title: 'Custom Page', + titleKey: 'customPage.title', + } + }, // ==================== Admin Routes ==================== { diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 42a42272..37439a4c 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -327,6 +327,7 @@ export const useAppStore = defineStore('app', () => { hide_ccs_import_button: false, purchase_subscription_enabled: false, purchase_subscription_url: '', + custom_menu_items: [], linuxdo_oauth_enabled: false, sora_client_enabled: false, version: siteVersion.value diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ccdde8ae..7f2f5f51 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -75,6 +75,15 @@ export interface SendVerifyCodeResponse { countdown: number } +export interface CustomMenuItem { + id: string + label: string + icon_svg: string + url: string + visibility: 'user' | 'admin' + sort_order: number +} + export interface PublicSettings { registration_enabled: boolean email_verify_enabled: boolean @@ -93,6 +102,7 @@ export interface PublicSettings { hide_ccs_import_button: boolean purchase_subscription_enabled: boolean purchase_subscription_url: string + custom_menu_items: CustomMenuItem[] linuxdo_oauth_enabled: boolean sora_client_enabled: boolean version: string diff --git a/frontend/src/utils/embedded-url.ts b/frontend/src/utils/embedded-url.ts new file mode 100644 index 00000000..9319ee07 --- /dev/null +++ b/frontend/src/utils/embedded-url.ts @@ -0,0 +1,46 @@ +/** + * Shared URL builder for iframe-embedded pages. + * Used by PurchaseSubscriptionView and CustomPageView to build consistent URLs + * with user_id, token, theme, ui_mode, src_host, and src parameters. + */ + +const EMBEDDED_USER_ID_QUERY_KEY = 'user_id' +const EMBEDDED_AUTH_TOKEN_QUERY_KEY = 'token' +const EMBEDDED_THEME_QUERY_KEY = 'theme' +const EMBEDDED_UI_MODE_QUERY_KEY = 'ui_mode' +const EMBEDDED_UI_MODE_VALUE = 'embedded' +const EMBEDDED_SRC_HOST_QUERY_KEY = 'src_host' +const EMBEDDED_SRC_QUERY_KEY = 'src_url' + +export function buildEmbeddedUrl( + baseUrl: string, + userId?: number, + authToken?: string | null, + theme: 'light' | 'dark' = 'light', +): string { + if (!baseUrl) return baseUrl + try { + const url = new URL(baseUrl) + if (userId) { + url.searchParams.set(EMBEDDED_USER_ID_QUERY_KEY, String(userId)) + } + if (authToken) { + url.searchParams.set(EMBEDDED_AUTH_TOKEN_QUERY_KEY, authToken) + } + url.searchParams.set(EMBEDDED_THEME_QUERY_KEY, theme) + url.searchParams.set(EMBEDDED_UI_MODE_QUERY_KEY, EMBEDDED_UI_MODE_VALUE) + // Source tracking: let the embedded page know where it's being loaded from + if (typeof window !== 'undefined') { + url.searchParams.set(EMBEDDED_SRC_HOST_QUERY_KEY, window.location.origin) + url.searchParams.set(EMBEDDED_SRC_QUERY_KEY, window.location.href) + } + return url.toString() + } catch { + return baseUrl + } +} + +export function detectTheme(): 'light' | 'dark' { + if (typeof document === 'undefined') return 'light' + return document.documentElement.classList.contains('dark') ? 'dark' : 'light' +} diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 39e1a6b5..02f7f449 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1160,6 +1160,135 @@ + +
+
+

+ {{ t('admin.settings.customMenu.title') }} +

+

+ {{ t('admin.settings.customMenu.description') }} +

+
+
+ +
+
+ + {{ t('admin.settings.customMenu.itemLabel', { n: index + 1 }) }} + +
+ + + + + + +
+
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+ +
+
+
+
+
+ + + +
+
+
@@ -1332,6 +1461,7 @@ const form = reactive({ purchase_subscription_enabled: false, purchase_subscription_url: '', sora_client_enabled: false, + custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>, smtp_host: '', smtp_port: 587, smtp_username: '', @@ -1396,6 +1526,39 @@ async function setAndCopyLinuxdoRedirectUrl() { await copyToClipboard(url, t('admin.settings.linuxdo.redirectUrlSetAndCopied')) } +// Custom menu item management +function addMenuItem() { + form.custom_menu_items.push({ + id: '', + label: '', + icon_svg: '', + url: '', + visibility: 'user', + sort_order: form.custom_menu_items.length, + }) +} + +function removeMenuItem(index: number) { + form.custom_menu_items.splice(index, 1) + // Re-index sort_order + form.custom_menu_items.forEach((item, i) => { + item.sort_order = i + }) +} + +function moveMenuItem(index: number, direction: -1 | 1) { + const targetIndex = index + direction + if (targetIndex < 0 || targetIndex >= form.custom_menu_items.length) return + const items = form.custom_menu_items + const temp = items[index] + items[index] = items[targetIndex] + items[targetIndex] = temp + // Re-index sort_order + items.forEach((item, i) => { + item.sort_order = i + }) +} + function handleLogoUpload(event: Event) { const input = event.target as HTMLInputElement const file = input.files?.[0] @@ -1534,6 +1697,7 @@ async function saveSettings() { purchase_subscription_enabled: form.purchase_subscription_enabled, purchase_subscription_url: form.purchase_subscription_url, sora_client_enabled: form.sora_client_enabled, + custom_menu_items: form.custom_menu_items, smtp_host: form.smtp_host, smtp_port: form.smtp_port, smtp_username: form.smtp_username, diff --git a/frontend/src/views/user/CustomPageView.vue b/frontend/src/views/user/CustomPageView.vue new file mode 100644 index 00000000..45e61e17 --- /dev/null +++ b/frontend/src/views/user/CustomPageView.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/frontend/src/views/user/PurchaseSubscriptionView.vue b/frontend/src/views/user/PurchaseSubscriptionView.vue index fdcd0d34..d6d356f5 100644 --- a/frontend/src/views/user/PurchaseSubscriptionView.vue +++ b/frontend/src/views/user/PurchaseSubscriptionView.vue @@ -74,17 +74,12 @@ 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 } = useI18n() const appStore = useAppStore() const authStore = useAuthStore() -const PURCHASE_USER_ID_QUERY_KEY = 'user_id' -const PURCHASE_AUTH_TOKEN_QUERY_KEY = 'token' -const PURCHASE_THEME_QUERY_KEY = 'theme' -const PURCHASE_UI_MODE_QUERY_KEY = 'ui_mode' -const PURCHASE_UI_MODE_EMBEDDED = 'embedded' - const loading = ref(false) const purchaseTheme = ref<'light' | 'dark'>('light') let themeObserver: MutationObserver | null = null @@ -93,37 +88,9 @@ const purchaseEnabled = computed(() => { return appStore.cachedPublicSettings?.purchase_subscription_enabled ?? false }) -function detectTheme(): 'light' | 'dark' { - if (typeof document === 'undefined') return 'light' - return document.documentElement.classList.contains('dark') ? 'dark' : 'light' -} - -function buildPurchaseUrl( - baseUrl: string, - userId?: number, - authToken?: string | null, - theme: 'light' | 'dark' = 'light', -): string { - if (!baseUrl) return baseUrl - try { - const url = new URL(baseUrl) - if (userId) { - url.searchParams.set(PURCHASE_USER_ID_QUERY_KEY, String(userId)) - } - if (authToken) { - url.searchParams.set(PURCHASE_AUTH_TOKEN_QUERY_KEY, authToken) - } - url.searchParams.set(PURCHASE_THEME_QUERY_KEY, theme) - url.searchParams.set(PURCHASE_UI_MODE_QUERY_KEY, PURCHASE_UI_MODE_EMBEDDED) - return url.toString() - } catch { - return baseUrl - } -} - const purchaseUrl = computed(() => { const baseUrl = (appStore.cachedPublicSettings?.purchase_subscription_url || '').trim() - return buildPurchaseUrl(baseUrl, authStore.user?.id, authStore.token, purchaseTheme.value) + return buildEmbeddedUrl(baseUrl, authStore.user?.id, authStore.token, purchaseTheme.value) }) const isValidUrl = computed(() => { diff --git a/tmp_api_admin_orders/[id]/cancel/route.ts b/tmp_api_admin_orders/[id]/cancel/route.ts new file mode 100644 index 00000000..0857b4e0 --- /dev/null +++ b/tmp_api_admin_orders/[id]/cancel/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; +import { adminCancelOrder, OrderError } from '@/lib/order/service'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + if (!verifyAdminToken(request)) return unauthorizedResponse(); + + try { + const { id } = await params; + await adminCancelOrder(id); + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof OrderError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.statusCode }, + ); + } + console.error('Admin cancel order error:', error); + return NextResponse.json({ error: '取消订单失败' }, { status: 500 }); + } +} diff --git a/tmp_api_admin_orders/[id]/retry/route.ts b/tmp_api_admin_orders/[id]/retry/route.ts new file mode 100644 index 00000000..07a3c0d0 --- /dev/null +++ b/tmp_api_admin_orders/[id]/retry/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; +import { retryRecharge, OrderError } from '@/lib/order/service'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + if (!verifyAdminToken(request)) return unauthorizedResponse(); + + try { + const { id } = await params; + await retryRecharge(id); + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof OrderError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.statusCode }, + ); + } + console.error('Retry recharge error:', error); + return NextResponse.json({ error: '重试充值失败' }, { status: 500 }); + } +} diff --git a/tmp_api_admin_orders/[id]/route.ts b/tmp_api_admin_orders/[id]/route.ts new file mode 100644 index 00000000..941ed839 --- /dev/null +++ b/tmp_api_admin_orders/[id]/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + if (!verifyAdminToken(request)) return unauthorizedResponse(); + + const { id } = await params; + + const order = await prisma.order.findUnique({ + where: { id }, + include: { + auditLogs: { + orderBy: { createdAt: 'desc' }, + }, + }, + }); + + if (!order) { + return NextResponse.json({ error: '订单不存在' }, { status: 404 }); + } + + return NextResponse.json({ + ...order, + amount: Number(order.amount), + refundAmount: order.refundAmount ? Number(order.refundAmount) : null, + }); +} diff --git a/tmp_api_admin_orders/route.ts b/tmp_api_admin_orders/route.ts new file mode 100644 index 00000000..110560bf --- /dev/null +++ b/tmp_api_admin_orders/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth'; +import { Prisma } from '@prisma/client'; + +export async function GET(request: NextRequest) { + if (!verifyAdminToken(request)) return unauthorizedResponse(); + + const searchParams = request.nextUrl.searchParams; + const page = Math.max(1, Number(searchParams.get('page') || '1')); + const pageSize = Math.min(100, Math.max(1, Number(searchParams.get('page_size') || '20'))); + const status = searchParams.get('status'); + const userId = searchParams.get('user_id'); + const dateFrom = searchParams.get('date_from'); + const dateTo = searchParams.get('date_to'); + + const where: Prisma.OrderWhereInput = {}; + if (status) where.status = status as any; + if (userId) where.userId = Number(userId); + if (dateFrom || dateTo) { + where.createdAt = {}; + if (dateFrom) where.createdAt.gte = new Date(dateFrom); + if (dateTo) where.createdAt.lte = new Date(dateTo); + } + + const [orders, total] = await Promise.all([ + prisma.order.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + select: { + id: true, + userId: true, + userName: true, + userEmail: true, + amount: true, + status: true, + paymentType: true, + createdAt: true, + paidAt: true, + completedAt: true, + failedReason: true, + expiresAt: true, + }, + }), + prisma.order.count({ where }), + ]); + + return NextResponse.json({ + orders: orders.map(o => ({ + ...o, + amount: Number(o.amount), + })), + total, + page, + page_size: pageSize, + total_pages: Math.ceil(total / pageSize), + }); +} diff --git a/tmp_api_orders/[id]/cancel/route.ts b/tmp_api_orders/[id]/cancel/route.ts new file mode 100644 index 00000000..4e0b0dc6 --- /dev/null +++ b/tmp_api_orders/[id]/cancel/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { cancelOrder, OrderError } from '@/lib/order/service'; + +const cancelSchema = z.object({ + user_id: z.number().int().positive(), +}); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + const parsed = cancelSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { error: '参数错误', details: parsed.error.flatten().fieldErrors }, + { status: 400 }, + ); + } + + await cancelOrder(id, parsed.data.user_id); + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof OrderError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.statusCode }, + ); + } + console.error('Cancel order error:', error); + return NextResponse.json({ error: '取消订单失败' }, { status: 500 }); + } +} diff --git a/tmp_api_orders/[id]/route.ts b/tmp_api_orders/[id]/route.ts new file mode 100644 index 00000000..08448607 --- /dev/null +++ b/tmp_api_orders/[id]/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + const order = await prisma.order.findUnique({ + where: { id }, + select: { + id: true, + userId: true, + userName: true, + amount: true, + status: true, + paymentType: true, + payUrl: true, + qrCode: true, + qrCodeImg: true, + expiresAt: true, + paidAt: true, + completedAt: true, + failedReason: true, + createdAt: true, + }, + }); + + if (!order) { + return NextResponse.json({ error: '订单不存在' }, { status: 404 }); + } + + return NextResponse.json({ + order_id: order.id, + user_id: order.userId, + user_name: order.userName, + amount: Number(order.amount), + status: order.status, + payment_type: order.paymentType, + pay_url: order.payUrl, + qr_code: order.qrCode, + qr_code_img: order.qrCodeImg, + expires_at: order.expiresAt, + paid_at: order.paidAt, + completed_at: order.completedAt, + failed_reason: order.failedReason, + created_at: order.createdAt, + }); +} diff --git a/tmp_api_orders/my/route.ts b/tmp_api_orders/my/route.ts new file mode 100644 index 00000000..43ca2f0a --- /dev/null +++ b/tmp_api_orders/my/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { getCurrentUserByToken } from '@/lib/sub2api/client'; + +export async function GET(request: NextRequest) { + const token = request.nextUrl.searchParams.get('token')?.trim(); + if (!token) { + return NextResponse.json({ error: 'token is required' }, { status: 400 }); + } + + try { + const user = await getCurrentUserByToken(token); + const orders = await prisma.order.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: 'desc' }, + take: 20, + select: { + id: true, + amount: true, + status: true, + paymentType: true, + createdAt: true, + }, + }); + + return NextResponse.json({ + user: { + id: user.id, + username: user.username, + email: user.email, + displayName: user.username || user.email || `用户 #${user.id}`, + balance: user.balance, + }, + orders: orders.map((item) => ({ + id: item.id, + amount: Number(item.amount), + status: item.status, + paymentType: item.paymentType, + createdAt: item.createdAt, + })), + }); + } catch (error) { + console.error('Get my orders error:', error); + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } +} diff --git a/tmp_api_orders/route.ts b/tmp_api_orders/route.ts new file mode 100644 index 00000000..0fd93aa4 --- /dev/null +++ b/tmp_api_orders/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { createOrder, OrderError } from '@/lib/order/service'; +import { getEnv } from '@/lib/config'; + +const createOrderSchema = z.object({ + user_id: z.number().int().positive(), + amount: z.number().positive(), + payment_type: z.enum(['alipay', 'wxpay']), +}); + +export async function POST(request: NextRequest) { + try { + const env = getEnv(); + const body = await request.json(); + const parsed = createOrderSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { error: '参数错误', details: parsed.error.flatten().fieldErrors }, + { status: 400 }, + ); + } + + const { user_id, amount, payment_type } = parsed.data; + + // Validate amount range + if (amount < env.MIN_RECHARGE_AMOUNT || amount > env.MAX_RECHARGE_AMOUNT) { + return NextResponse.json( + { error: `充值金额需在 ${env.MIN_RECHARGE_AMOUNT} - ${env.MAX_RECHARGE_AMOUNT} 之间` }, + { status: 400 }, + ); + } + + // Validate payment type is enabled + if (!env.ENABLED_PAYMENT_TYPES.includes(payment_type)) { + return NextResponse.json( + { error: `不支持的支付方式: ${payment_type}` }, + { status: 400 }, + ); + } + + const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() + || request.headers.get('x-real-ip') + || '127.0.0.1'; + + const result = await createOrder({ + userId: user_id, + amount, + paymentType: payment_type, + clientIp, + }); + + return NextResponse.json(result); + } catch (error) { + if (error instanceof OrderError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.statusCode }, + ); + } + console.error('Create order error:', error); + return NextResponse.json( + { error: '创建订单失败,请稍后重试' }, + { status: 500 }, + ); + } +}