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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('customPage.notFoundTitle') }}
+
+
+ {{ t('customPage.notFoundDesc') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('customPage.notConfiguredTitle') }}
+
+
+ {{ t('customPage.notConfiguredDesc') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
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 },
+ );
+ }
+}