Merge branch 'main' of github.com:Wei-Shaw/sub2api

This commit is contained in:
cyhhao
2026-01-29 01:16:36 +08:00
24 changed files with 586 additions and 211 deletions

View File

@@ -1 +1 @@
0.1.46 0.1.61

View File

@@ -73,6 +73,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
DocURL: settings.DocURL, DocURL: settings.DocURL,
HomeContent: settings.HomeContent, HomeContent: settings.HomeContent,
HideCcsImportButton: settings.HideCcsImportButton, HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
DefaultConcurrency: settings.DefaultConcurrency, DefaultConcurrency: settings.DefaultConcurrency,
DefaultBalance: settings.DefaultBalance, DefaultBalance: settings.DefaultBalance,
EnableModelFallback: settings.EnableModelFallback, EnableModelFallback: settings.EnableModelFallback,
@@ -127,6 +129,8 @@ type UpdateSettingsRequest struct {
DocURL string `json:"doc_url"` DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"` HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"` HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL *string `json:"purchase_subscription_url"`
// 默认配置 // 默认配置
DefaultConcurrency int `json:"default_concurrency"` DefaultConcurrency int `json:"default_concurrency"`
@@ -242,6 +246,34 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
} }
} }
// “购买订阅”页面配置验证
purchaseEnabled := previousSettings.PurchaseSubscriptionEnabled
if req.PurchaseSubscriptionEnabled != nil {
purchaseEnabled = *req.PurchaseSubscriptionEnabled
}
purchaseURL := previousSettings.PurchaseSubscriptionURL
if req.PurchaseSubscriptionURL != nil {
purchaseURL = strings.TrimSpace(*req.PurchaseSubscriptionURL)
}
// - 启用时要求 URL 合法且非空
// - 禁用时允许为空;若提供了 URL 也做基本校验,避免误配置
if purchaseEnabled {
if purchaseURL == "" {
response.BadRequest(c, "Purchase Subscription URL is required when enabled")
return
}
if err := config.ValidateAbsoluteHTTPURL(purchaseURL); err != nil {
response.BadRequest(c, "Purchase Subscription URL must be an absolute http(s) URL")
return
}
} else if purchaseURL != "" {
if err := config.ValidateAbsoluteHTTPURL(purchaseURL); err != nil {
response.BadRequest(c, "Purchase Subscription URL must be an absolute http(s) URL")
return
}
}
// Ops metrics collector interval validation (seconds). // Ops metrics collector interval validation (seconds).
if req.OpsMetricsIntervalSeconds != nil { if req.OpsMetricsIntervalSeconds != nil {
v := *req.OpsMetricsIntervalSeconds v := *req.OpsMetricsIntervalSeconds
@@ -282,6 +314,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
DocURL: req.DocURL, DocURL: req.DocURL,
HomeContent: req.HomeContent, HomeContent: req.HomeContent,
HideCcsImportButton: req.HideCcsImportButton, HideCcsImportButton: req.HideCcsImportButton,
PurchaseSubscriptionEnabled: purchaseEnabled,
PurchaseSubscriptionURL: purchaseURL,
DefaultConcurrency: req.DefaultConcurrency, DefaultConcurrency: req.DefaultConcurrency,
DefaultBalance: req.DefaultBalance, DefaultBalance: req.DefaultBalance,
EnableModelFallback: req.EnableModelFallback, EnableModelFallback: req.EnableModelFallback,
@@ -360,6 +394,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
DocURL: updatedSettings.DocURL, DocURL: updatedSettings.DocURL,
HomeContent: updatedSettings.HomeContent, HomeContent: updatedSettings.HomeContent,
HideCcsImportButton: updatedSettings.HideCcsImportButton, HideCcsImportButton: updatedSettings.HideCcsImportButton,
PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL,
DefaultConcurrency: updatedSettings.DefaultConcurrency, DefaultConcurrency: updatedSettings.DefaultConcurrency,
DefaultBalance: updatedSettings.DefaultBalance, DefaultBalance: updatedSettings.DefaultBalance,
EnableModelFallback: updatedSettings.EnableModelFallback, EnableModelFallback: updatedSettings.EnableModelFallback,

View File

@@ -34,6 +34,8 @@ type SystemSettings struct {
DocURL string `json:"doc_url"` DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"` HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"` HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
DefaultConcurrency int `json:"default_concurrency"` DefaultConcurrency int `json:"default_concurrency"`
DefaultBalance float64 `json:"default_balance"` DefaultBalance float64 `json:"default_balance"`
@@ -72,6 +74,8 @@ type PublicSettings struct {
DocURL string `json:"doc_url"` DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"` HomeContent string `json:"home_content"`
HideCcsImportButton bool `json:"hide_ccs_import_button"` HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
Version string `json:"version"` Version string `json:"version"`
} }

View File

@@ -36,6 +36,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
EmailVerifyEnabled: settings.EmailVerifyEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled, PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled, PasswordResetEnabled: settings.PasswordResetEnabled,
TotpEnabled: settings.TotpEnabled,
TurnstileEnabled: settings.TurnstileEnabled, TurnstileEnabled: settings.TurnstileEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey, TurnstileSiteKey: settings.TurnstileSiteKey,
SiteName: settings.SiteName, SiteName: settings.SiteName,
@@ -46,6 +47,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
DocURL: settings.DocURL, DocURL: settings.DocURL,
HomeContent: settings.HomeContent, HomeContent: settings.HomeContent,
HideCcsImportButton: settings.HideCcsImportButton, HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
Version: h.version, Version: h.version,
}) })

View File

@@ -2,6 +2,7 @@
package response package response
import ( import (
"log"
"math" "math"
"net/http" "net/http"
@@ -74,6 +75,12 @@ func ErrorFrom(c *gin.Context, err error) bool {
} }
statusCode, status := infraerrors.ToHTTP(err) statusCode, status := infraerrors.ToHTTP(err)
// Log internal errors with full details for debugging
if statusCode >= 500 && c.Request != nil {
log.Printf("[ERROR] %s %s\n Error: %s", c.Request.Method, c.Request.URL.Path, err.Error())
}
ErrorWithDetails(c, statusCode, status.Message, status.Reason, status.Metadata) ErrorWithDetails(c, statusCode, status.Message, status.Reason, status.Metadata)
return true return true
} }

View File

@@ -2,11 +2,11 @@ package repository
import ( import (
"context" "context"
"fmt" "net/http"
"net/url" "net/url"
"strings"
"time" "time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/imroc/req/v3" "github.com/imroc/req/v3"
@@ -22,7 +22,7 @@ type openaiOAuthService struct {
} }
func (s *openaiOAuthService) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL string) (*openai.TokenResponse, error) { func (s *openaiOAuthService) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL string) (*openai.TokenResponse, error) {
client := createOpenAIReqClient(s.tokenURL, proxyURL) client := createOpenAIReqClient(proxyURL)
if redirectURI == "" { if redirectURI == "" {
redirectURI = openai.DefaultRedirectURI redirectURI = openai.DefaultRedirectURI
@@ -39,23 +39,24 @@ func (s *openaiOAuthService) ExchangeCode(ctx context.Context, code, codeVerifie
resp, err := client.R(). resp, err := client.R().
SetContext(ctx). SetContext(ctx).
SetHeader("User-Agent", "codex-cli/0.91.0").
SetFormDataFromValues(formData). SetFormDataFromValues(formData).
SetSuccessResult(&tokenResp). SetSuccessResult(&tokenResp).
Post(s.tokenURL) Post(s.tokenURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("request failed: %w", err) return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_OAUTH_REQUEST_FAILED", "request failed: %v", err)
} }
if !resp.IsSuccessState() { if !resp.IsSuccessState() {
return nil, fmt.Errorf("token exchange failed: status %d, body: %s", resp.StatusCode, resp.String()) return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_OAUTH_TOKEN_EXCHANGE_FAILED", "token exchange failed: status %d, body: %s", resp.StatusCode, resp.String())
} }
return &tokenResp, nil return &tokenResp, nil
} }
func (s *openaiOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*openai.TokenResponse, error) { func (s *openaiOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*openai.TokenResponse, error) {
client := createOpenAIReqClient(s.tokenURL, proxyURL) client := createOpenAIReqClient(proxyURL)
formData := url.Values{} formData := url.Values{}
formData.Set("grant_type", "refresh_token") formData.Set("grant_type", "refresh_token")
@@ -67,29 +68,25 @@ func (s *openaiOAuthService) RefreshToken(ctx context.Context, refreshToken, pro
resp, err := client.R(). resp, err := client.R().
SetContext(ctx). SetContext(ctx).
SetHeader("User-Agent", "codex-cli/0.91.0").
SetFormDataFromValues(formData). SetFormDataFromValues(formData).
SetSuccessResult(&tokenResp). SetSuccessResult(&tokenResp).
Post(s.tokenURL) Post(s.tokenURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("request failed: %w", err) return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_OAUTH_REQUEST_FAILED", "request failed: %v", err)
} }
if !resp.IsSuccessState() { if !resp.IsSuccessState() {
return nil, fmt.Errorf("token refresh failed: status %d, body: %s", resp.StatusCode, resp.String()) return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_OAUTH_TOKEN_REFRESH_FAILED", "token refresh failed: status %d, body: %s", resp.StatusCode, resp.String())
} }
return &tokenResp, nil return &tokenResp, nil
} }
func createOpenAIReqClient(tokenURL, proxyURL string) *req.Client { func createOpenAIReqClient(proxyURL string) *req.Client {
forceHTTP2 := false
if parsedURL, err := url.Parse(tokenURL); err == nil {
forceHTTP2 = strings.EqualFold(parsedURL.Scheme, "https")
}
return getSharedReqClient(reqClientOptions{ return getSharedReqClient(reqClientOptions{
ProxyURL: proxyURL, ProxyURL: proxyURL,
Timeout: 120 * time.Second, Timeout: 120 * time.Second,
ForceHTTP2: forceHTTP2,
}) })
} }

View File

@@ -77,21 +77,9 @@ func TestGetSharedReqClient_ImpersonateAndProxy(t *testing.T) {
require.Equal(t, "http://proxy.local:8080|4s|true|false", buildReqClientKey(opts)) require.Equal(t, "http://proxy.local:8080|4s|true|false", buildReqClientKey(opts))
} }
func TestCreateOpenAIReqClient_ForceHTTP2Enabled(t *testing.T) {
sharedReqClients = sync.Map{}
client := createOpenAIReqClient("https://auth.openai.com/oauth/token", "http://proxy.local:8080")
require.Equal(t, "2", forceHTTPVersion(t, client))
}
func TestCreateOpenAIReqClient_ForceHTTP2DisabledForHTTP(t *testing.T) {
sharedReqClients = sync.Map{}
client := createOpenAIReqClient("http://localhost/oauth/token", "http://proxy.local:8080")
require.Equal(t, "", forceHTTPVersion(t, client))
}
func TestCreateOpenAIReqClient_Timeout120Seconds(t *testing.T) { func TestCreateOpenAIReqClient_Timeout120Seconds(t *testing.T) {
sharedReqClients = sync.Map{} sharedReqClients = sync.Map{}
client := createOpenAIReqClient("https://auth.openai.com/oauth/token", "http://proxy.local:8080") client := createOpenAIReqClient("http://proxy.local:8080")
require.Equal(t, 120*time.Second, client.GetClient().Timeout) require.Equal(t, 120*time.Second, client.GetClient().Timeout)
} }

View File

@@ -58,7 +58,9 @@ func (c *schedulerCache) GetSnapshot(ctx context.Context, bucket service.Schedul
return nil, false, err return nil, false, err
} }
if len(ids) == 0 { if len(ids) == 0 {
return []*service.Account{}, true, nil // 空快照视为缓存未命中,触发数据库回退查询
// 这解决了新分组创建后立即绑定账号时的竞态条件问题
return nil, false, nil
} }
keys := make([]string, 0, len(ids)) keys := make([]string, 0, len(ids))

View File

@@ -489,7 +489,9 @@ func TestAPIContracts(t *testing.T) {
"enable_identity_patch": true, "enable_identity_patch": true,
"identity_patch_prompt": "", "identity_patch_prompt": "",
"home_content": "", "home_content": "",
"hide_ccs_import_button": false "hide_ccs_import_button": false,
"purchase_subscription_enabled": false,
"purchase_subscription_url": ""
} }
}`, }`,
}, },

View File

@@ -106,6 +106,8 @@ const (
SettingKeyDocURL = "doc_url" // 文档链接 SettingKeyDocURL = "doc_url" // 文档链接
SettingKeyHomeContent = "home_content" // 首页内容(支持 Markdown/HTML或 URL 作为 iframe src SettingKeyHomeContent = "home_content" // 首页内容(支持 Markdown/HTML或 URL 作为 iframe src
SettingKeyHideCcsImportButton = "hide_ccs_import_button" // 是否隐藏 API Keys 页面的导入 CCS 按钮 SettingKeyHideCcsImportButton = "hide_ccs_import_button" // 是否隐藏 API Keys 页面的导入 CCS 按钮
SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示“购买订阅”页面入口
SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // “购买订阅”页面 URL作为 iframe src
// 默认配置 // 默认配置
SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量 SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量

View File

@@ -4102,14 +4102,23 @@ func (s *GatewayService) parseSSEUsage(data string, usage *ClaudeUsage) {
} `json:"usage"` } `json:"usage"`
} }
if json.Unmarshal([]byte(data), &msgDelta) == nil && msgDelta.Type == "message_delta" { if json.Unmarshal([]byte(data), &msgDelta) == nil && msgDelta.Type == "message_delta" {
// message_delta 是推理结束后的最终统计,应完全覆盖 message_start 的数据 // message_delta 仅覆盖存在且非0的字段
// 这对于 Claude API 和 GLM 等兼容 API 都是正确的行为 // 避免覆盖 message_start 中已有的值(如 input_tokens
// Claude API 的 message_delta 通常只包含 output_tokens
if msgDelta.Usage.InputTokens > 0 {
usage.InputTokens = msgDelta.Usage.InputTokens usage.InputTokens = msgDelta.Usage.InputTokens
}
if msgDelta.Usage.OutputTokens > 0 {
usage.OutputTokens = msgDelta.Usage.OutputTokens usage.OutputTokens = msgDelta.Usage.OutputTokens
}
if msgDelta.Usage.CacheCreationInputTokens > 0 {
usage.CacheCreationInputTokens = msgDelta.Usage.CacheCreationInputTokens usage.CacheCreationInputTokens = msgDelta.Usage.CacheCreationInputTokens
}
if msgDelta.Usage.CacheReadInputTokens > 0 {
usage.CacheReadInputTokens = msgDelta.Usage.CacheReadInputTokens usage.CacheReadInputTokens = msgDelta.Usage.CacheReadInputTokens
} }
} }
}
func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string, toolNameMap map[string]string, mimicClaudeCode bool) (*ClaudeUsage, error) { func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string, toolNameMap map[string]string, mimicClaudeCode bool) (*ClaudeUsage, error) {
// 更新5h窗口状态 // 更新5h窗口状态

View File

@@ -931,6 +931,13 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
} }
} }
// 图片生成计费
imageCount := 0
imageSize := s.extractImageSize(body)
if isImageGenerationModel(originalModel) {
imageCount = 1
}
return &ForwardResult{ return &ForwardResult{
RequestID: requestID, RequestID: requestID,
Usage: *usage, Usage: *usage,
@@ -938,6 +945,8 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
Stream: req.Stream, Stream: req.Stream,
Duration: time.Since(startTime), Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs, FirstTokenMs: firstTokenMs,
ImageCount: imageCount,
ImageSize: imageSize,
}, nil }, nil
} }
@@ -1371,6 +1380,13 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
usage = &ClaudeUsage{} usage = &ClaudeUsage{}
} }
// 图片生成计费
imageCount := 0
imageSize := s.extractImageSize(body)
if isImageGenerationModel(originalModel) {
imageCount = 1
}
return &ForwardResult{ return &ForwardResult{
RequestID: requestID, RequestID: requestID,
Usage: *usage, Usage: *usage,
@@ -1378,6 +1394,8 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
Stream: stream, Stream: stream,
Duration: time.Since(startTime), Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs, FirstTokenMs: firstTokenMs,
ImageCount: imageCount,
ImageSize: imageSize,
}, nil }, nil
} }
@@ -3031,3 +3049,26 @@ func convertClaudeGenerationConfig(req map[string]any) map[string]any {
} }
return out return out
} }
// extractImageSize 从 Gemini 请求中提取 image_size 参数
func (s *GeminiMessagesCompatService) extractImageSize(body []byte) string {
var req struct {
GenerationConfig *struct {
ImageConfig *struct {
ImageSize string `json:"imageSize"`
} `json:"imageConfig"`
} `json:"generationConfig"`
}
if err := json.Unmarshal(body, &req); err != nil {
return "2K"
}
if req.GenerationConfig != nil && req.GenerationConfig.ImageConfig != nil {
size := strings.ToUpper(strings.TrimSpace(req.GenerationConfig.ImageConfig.ImageSize))
if size == "1K" || size == "2K" || size == "4K" {
return size
}
}
return "2K"
}

View File

@@ -2,9 +2,10 @@ package service
import ( import (
"context" "context"
"fmt" "net/http"
"time" "time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
) )
@@ -35,12 +36,12 @@ func (s *OpenAIOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64
// Generate PKCE values // Generate PKCE values
state, err := openai.GenerateState() state, err := openai.GenerateState()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate state: %w", err) return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_OAUTH_STATE_FAILED", "failed to generate state: %v", err)
} }
codeVerifier, err := openai.GenerateCodeVerifier() codeVerifier, err := openai.GenerateCodeVerifier()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate code verifier: %w", err) return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_OAUTH_VERIFIER_FAILED", "failed to generate code verifier: %v", err)
} }
codeChallenge := openai.GenerateCodeChallenge(codeVerifier) codeChallenge := openai.GenerateCodeChallenge(codeVerifier)
@@ -48,14 +49,17 @@ func (s *OpenAIOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64
// Generate session ID // Generate session ID
sessionID, err := openai.GenerateSessionID() sessionID, err := openai.GenerateSessionID()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate session ID: %w", err) return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_OAUTH_SESSION_FAILED", "failed to generate session ID: %v", err)
} }
// Get proxy URL if specified // Get proxy URL if specified
var proxyURL string var proxyURL string
if proxyID != nil { if proxyID != nil {
proxy, err := s.proxyRepo.GetByID(ctx, *proxyID) proxy, err := s.proxyRepo.GetByID(ctx, *proxyID)
if err == nil && proxy != nil { if err != nil {
return nil, infraerrors.Newf(http.StatusBadRequest, "OPENAI_OAUTH_PROXY_NOT_FOUND", "proxy not found: %v", err)
}
if proxy != nil {
proxyURL = proxy.URL() proxyURL = proxy.URL()
} }
} }
@@ -110,14 +114,17 @@ func (s *OpenAIOAuthService) ExchangeCode(ctx context.Context, input *OpenAIExch
// Get session // Get session
session, ok := s.sessionStore.Get(input.SessionID) session, ok := s.sessionStore.Get(input.SessionID)
if !ok { if !ok {
return nil, fmt.Errorf("session not found or expired") return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_SESSION_NOT_FOUND", "session not found or expired")
} }
// Get proxy URL // Get proxy URL: prefer input.ProxyID, fallback to session.ProxyURL
proxyURL := session.ProxyURL proxyURL := session.ProxyURL
if input.ProxyID != nil { if input.ProxyID != nil {
proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID) proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID)
if err == nil && proxy != nil { if err != nil {
return nil, infraerrors.Newf(http.StatusBadRequest, "OPENAI_OAUTH_PROXY_NOT_FOUND", "proxy not found: %v", err)
}
if proxy != nil {
proxyURL = proxy.URL() proxyURL = proxy.URL()
} }
} }
@@ -131,7 +138,7 @@ func (s *OpenAIOAuthService) ExchangeCode(ctx context.Context, input *OpenAIExch
// Exchange code for token // Exchange code for token
tokenResp, err := s.oauthClient.ExchangeCode(ctx, input.Code, session.CodeVerifier, redirectURI, proxyURL) tokenResp, err := s.oauthClient.ExchangeCode(ctx, input.Code, session.CodeVerifier, redirectURI, proxyURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to exchange code: %w", err) return nil, err
} }
// Parse ID token to get user info // Parse ID token to get user info
@@ -201,12 +208,12 @@ func (s *OpenAIOAuthService) RefreshToken(ctx context.Context, refreshToken stri
// RefreshAccountToken refreshes token for an OpenAI account // RefreshAccountToken refreshes token for an OpenAI account
func (s *OpenAIOAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*OpenAITokenInfo, error) { func (s *OpenAIOAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*OpenAITokenInfo, error) {
if !account.IsOpenAI() { if !account.IsOpenAI() {
return nil, fmt.Errorf("account is not an OpenAI account") return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_INVALID_ACCOUNT", "account is not an OpenAI account")
} }
refreshToken := account.GetOpenAIRefreshToken() refreshToken := account.GetOpenAIRefreshToken()
if refreshToken == "" { if refreshToken == "" {
return nil, fmt.Errorf("no refresh token available") return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_OAUTH_NO_REFRESH_TOKEN", "no refresh token available")
} }
var proxyURL string var proxyURL string

View File

@@ -73,6 +73,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyDocURL, SettingKeyDocURL,
SettingKeyHomeContent, SettingKeyHomeContent,
SettingKeyHideCcsImportButton, SettingKeyHideCcsImportButton,
SettingKeyPurchaseSubscriptionEnabled,
SettingKeyPurchaseSubscriptionURL,
SettingKeyLinuxDoConnectEnabled, SettingKeyLinuxDoConnectEnabled,
} }
@@ -108,6 +110,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
DocURL: settings[SettingKeyDocURL], DocURL: settings[SettingKeyDocURL],
HomeContent: settings[SettingKeyHomeContent], HomeContent: settings[SettingKeyHomeContent],
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
LinuxDoOAuthEnabled: linuxDoEnabled, LinuxDoOAuthEnabled: linuxDoEnabled,
}, nil }, nil
} }
@@ -148,6 +152,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
DocURL string `json:"doc_url,omitempty"` DocURL string `json:"doc_url,omitempty"`
HomeContent string `json:"home_content,omitempty"` HomeContent string `json:"home_content,omitempty"`
HideCcsImportButton bool `json:"hide_ccs_import_button"` HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
}{ }{
@@ -166,6 +172,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
DocURL: settings.DocURL, DocURL: settings.DocURL,
HomeContent: settings.HomeContent, HomeContent: settings.HomeContent,
HideCcsImportButton: settings.HideCcsImportButton, HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
Version: s.version, Version: s.version,
}, nil }, nil
@@ -217,6 +225,8 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates[SettingKeyDocURL] = settings.DocURL updates[SettingKeyDocURL] = settings.DocURL
updates[SettingKeyHomeContent] = settings.HomeContent updates[SettingKeyHomeContent] = settings.HomeContent
updates[SettingKeyHideCcsImportButton] = strconv.FormatBool(settings.HideCcsImportButton) updates[SettingKeyHideCcsImportButton] = strconv.FormatBool(settings.HideCcsImportButton)
updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled)
updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL)
// 默认配置 // 默认配置
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
@@ -357,6 +367,8 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能 SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
SettingKeySiteName: "Sub2API", SettingKeySiteName: "Sub2API",
SettingKeySiteLogo: "", SettingKeySiteLogo: "",
SettingKeyPurchaseSubscriptionEnabled: "false",
SettingKeyPurchaseSubscriptionURL: "",
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
SettingKeySMTPPort: "587", SettingKeySMTPPort: "587",
@@ -407,6 +419,8 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
DocURL: settings[SettingKeyDocURL], DocURL: settings[SettingKeyDocURL],
HomeContent: settings[SettingKeyHomeContent], HomeContent: settings[SettingKeyHomeContent],
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true", HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
} }
// 解析整数类型 // 解析整数类型

View File

@@ -36,6 +36,8 @@ type SystemSettings struct {
DocURL string DocURL string
HomeContent string HomeContent string
HideCcsImportButton bool HideCcsImportButton bool
PurchaseSubscriptionEnabled bool
PurchaseSubscriptionURL string
DefaultConcurrency int DefaultConcurrency int
DefaultBalance float64 DefaultBalance float64
@@ -74,6 +76,10 @@ type PublicSettings struct {
DocURL string DocURL string
HomeContent string HomeContent string
HideCcsImportButton bool HideCcsImportButton bool
PurchaseSubscriptionEnabled bool
PurchaseSubscriptionURL string
LinuxDoOAuthEnabled bool LinuxDoOAuthEnabled bool
Version string Version string
} }

View File

@@ -28,6 +28,8 @@ export interface SystemSettings {
doc_url: string doc_url: string
home_content: string home_content: string
hide_ccs_import_button: boolean hide_ccs_import_button: boolean
purchase_subscription_enabled: boolean
purchase_subscription_url: string
// SMTP settings // SMTP settings
smtp_host: string smtp_host: string
smtp_port: number smtp_port: number
@@ -81,6 +83,8 @@ export interface UpdateSettingsRequest {
doc_url?: string doc_url?: string
home_content?: string home_content?: string
hide_ccs_import_button?: boolean hide_ccs_import_button?: boolean
purchase_subscription_enabled?: boolean
purchase_subscription_url?: string
smtp_host?: string smtp_host?: string
smtp_port?: number smtp_port?: number
smtp_username?: string smtp_username?: string

View File

@@ -421,6 +421,16 @@ const userNavItems = computed(() => {
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
? [
{
path: '/purchase',
label: t('nav.buySubscription'),
icon: CreditCardIcon,
hideInSimpleMode: true
}
]
: []),
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon } { path: '/profile', label: t('nav.profile'), icon: UserIcon }
] ]
@@ -433,6 +443,16 @@ const personalNavItems = computed(() => {
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
...(appStore.cachedPublicSettings?.purchase_subscription_enabled
? [
{
path: '/purchase',
label: t('nav.buySubscription'),
icon: CreditCardIcon,
hideInSimpleMode: true
}
]
: []),
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon } { path: '/profile', label: t('nav.profile'), icon: UserIcon }
] ]

View File

@@ -206,6 +206,7 @@ export default {
logout: 'Logout', logout: 'Logout',
github: 'GitHub', github: 'GitHub',
mySubscriptions: 'My Subscriptions', mySubscriptions: 'My Subscriptions',
buySubscription: 'Purchase Subscription',
docs: 'Docs' docs: 'Docs'
}, },
@@ -2894,6 +2895,17 @@ export default {
hideCcsImportButton: 'Hide CCS Import Button', hideCcsImportButton: 'Hide CCS Import Button',
hideCcsImportButtonHint: 'When enabled, the "Import to CCS" button will be hidden on the API Keys page' hideCcsImportButtonHint: 'When enabled, the "Import to CCS" button will be hidden on the API Keys page'
}, },
purchase: {
title: 'Purchase Page',
description: 'Show a "Purchase Subscription" entry in the sidebar and open the configured URL in an iframe',
enabled: 'Show Purchase Entry',
enabledHint: 'Only shown in standard mode (not simple mode)',
url: 'Purchase URL',
urlPlaceholder: 'https://example.com/purchase',
urlHint: 'Must be an absolute http(s) URL',
iframeWarning:
'⚠️ iframe note: Some websites block embedding via X-Frame-Options or CSP (frame-ancestors). If the page is blank, provide an "Open in new tab" alternative.'
},
smtp: { smtp: {
title: 'SMTP Settings', title: 'SMTP Settings',
description: 'Configure email sending for verification codes', description: 'Configure email sending for verification codes',
@@ -3039,6 +3051,18 @@ export default {
retry: 'Retry' retry: 'Retry'
}, },
// Purchase Subscription Page
purchase: {
title: 'Purchase Subscription',
description: 'Purchase a subscription via the embedded page',
openInNewTab: 'Open in new tab',
notEnabledTitle: 'Feature not enabled',
notEnabledDesc: 'The administrator has not enabled the purchase page. Please contact admin.',
notConfiguredTitle: 'Purchase URL not configured',
notConfiguredDesc:
'The administrator enabled the entry but has not configured a purchase URL. Please contact admin.'
},
// User Subscriptions Page // User Subscriptions Page
userSubscriptions: { userSubscriptions: {
title: 'My Subscriptions', title: 'My Subscriptions',

View File

@@ -203,6 +203,7 @@ export default {
logout: '退出登录', logout: '退出登录',
github: 'GitHub', github: 'GitHub',
mySubscriptions: '我的订阅', mySubscriptions: '我的订阅',
buySubscription: '购买订阅',
docs: '文档' docs: '文档'
}, },
@@ -3045,6 +3046,17 @@ export default {
hideCcsImportButton: '隐藏 CCS 导入按钮', hideCcsImportButton: '隐藏 CCS 导入按钮',
hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮' hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮'
}, },
purchase: {
title: '购买订阅页面',
description: '在侧边栏展示“购买订阅”入口,并在页面内通过 iframe 打开指定链接',
enabled: '显示购买订阅入口',
enabledHint: '仅在标准模式(非简单模式)下展示',
url: '购买页面 URL',
urlPlaceholder: 'https://example.com/purchase',
urlHint: '必须是完整的 http(s) 链接',
iframeWarning:
'⚠️ iframe 提示:部分网站会通过 X-Frame-Options 或 CSPframe-ancestors禁止被 iframe 嵌入,出现空白时可引导用户使用“新窗口打开”。'
},
smtp: { smtp: {
title: 'SMTP 设置', title: 'SMTP 设置',
description: '配置用于发送验证码的邮件服务', description: '配置用于发送验证码的邮件服务',
@@ -3189,6 +3201,17 @@ export default {
retry: '重试' retry: '重试'
}, },
// Purchase Subscription Page
purchase: {
title: '购买订阅',
description: '通过内嵌页面完成订阅购买',
openInNewTab: '新窗口打开',
notEnabledTitle: '该功能未开启',
notEnabledDesc: '管理员暂未开启购买订阅入口,请联系管理员。',
notConfiguredTitle: '购买链接未配置',
notConfiguredDesc: '管理员已开启入口,但尚未配置购买订阅链接,请联系管理员。'
},
// User Subscriptions Page // User Subscriptions Page
userSubscriptions: { userSubscriptions: {
title: '我的订阅', title: '我的订阅',

View File

@@ -175,6 +175,18 @@ const routes: RouteRecordRaw[] = [
descriptionKey: 'userSubscriptions.description' descriptionKey: 'userSubscriptions.description'
} }
}, },
{
path: '/purchase',
name: 'PurchaseSubscription',
component: () => import('@/views/user/PurchaseSubscriptionView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Purchase Subscription',
titleKey: 'purchase.title',
descriptionKey: 'purchase.description'
}
},
// ==================== Admin Routes ==================== // ==================== Admin Routes ====================
{ {

View File

@@ -324,6 +324,8 @@ export const useAppStore = defineStore('app', () => {
doc_url: docUrl.value, doc_url: docUrl.value,
home_content: '', home_content: '',
hide_ccs_import_button: false, hide_ccs_import_button: false,
purchase_subscription_enabled: false,
purchase_subscription_url: '',
linuxdo_oauth_enabled: false, linuxdo_oauth_enabled: false,
version: siteVersion.value version: siteVersion.value
} }

View File

@@ -82,6 +82,8 @@ export interface PublicSettings {
doc_url: string doc_url: string
home_content: string home_content: string
hide_ccs_import_button: boolean hide_ccs_import_button: boolean
purchase_subscription_enabled: boolean
purchase_subscription_url: string
linuxdo_oauth_enabled: boolean linuxdo_oauth_enabled: boolean
version: string version: string
} }

View File

@@ -935,6 +935,51 @@
</div> </div>
</div> </div>
<!-- Purchase Subscription Page -->
<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.purchase.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.purchase.description') }}
</p>
</div>
<div class="space-y-6 p-6">
<!-- Enable Toggle -->
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.purchase.enabled')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.purchase.enabledHint') }}
</p>
</div>
<Toggle v-model="form.purchase_subscription_enabled" />
</div>
<!-- URL -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.purchase.url') }}
</label>
<input
v-model="form.purchase_subscription_url"
type="url"
class="input font-mono text-sm"
:placeholder="t('admin.settings.purchase.urlPlaceholder')"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.purchase.urlHint') }}
</p>
<p class="mt-2 text-xs text-amber-600 dark:text-amber-400">
{{ t('admin.settings.purchase.iframeWarning') }}
</p>
</div>
</div>
</div>
<!-- Send Test Email - Only show when email verification is enabled --> <!-- Send Test Email - Only show when email verification is enabled -->
<div v-if="form.email_verify_enabled" class="card"> <div v-if="form.email_verify_enabled" class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"> <div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
@@ -1083,6 +1128,8 @@ const form = reactive<SettingsForm>({
doc_url: '', doc_url: '',
home_content: '', home_content: '',
hide_ccs_import_button: false, hide_ccs_import_button: false,
purchase_subscription_enabled: false,
purchase_subscription_url: '',
smtp_host: '', smtp_host: '',
smtp_port: 587, smtp_port: 587,
smtp_username: '', smtp_username: '',
@@ -1208,6 +1255,8 @@ async function saveSettings() {
doc_url: form.doc_url, doc_url: form.doc_url,
home_content: form.home_content, home_content: form.home_content,
hide_ccs_import_button: form.hide_ccs_import_button, hide_ccs_import_button: form.hide_ccs_import_button,
purchase_subscription_enabled: form.purchase_subscription_enabled,
purchase_subscription_url: form.purchase_subscription_url,
smtp_host: form.smtp_host, smtp_host: form.smtp_host,
smtp_port: form.smtp_port, smtp_port: form.smtp_port,
smtp_username: form.smtp_username, smtp_username: form.smtp_username,

View File

@@ -0,0 +1,121 @@
<template>
<AppLayout>
<div class="purchase-page-layout">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('purchase.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
{{ t('purchase.description') }}
</p>
</div>
<div class="flex items-center gap-2">
<a
v-if="isValidUrl"
:href="purchaseUrl"
target="_blank"
rel="noopener noreferrer"
class="btn btn-secondary btn-sm"
>
<Icon name="externalLink" size="sm" class="mr-1.5" :stroke-width="2" />
{{ t('purchase.openInNewTab') }}
</a>
</div>
</div>
<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>
<iframe v-else :src="purchaseUrl" class="h-full w-full border-0" allowfullscreen></iframe>
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const purchaseEnabled = computed(() => {
return appStore.cachedPublicSettings?.purchase_subscription_enabled ?? false
})
const purchaseUrl = computed(() => {
return (appStore.cachedPublicSettings?.purchase_subscription_url || '').trim()
})
const isValidUrl = computed(() => {
const url = purchaseUrl.value
return url.startsWith('http://') || url.startsWith('https://')
})
onMounted(async () => {
if (appStore.publicSettingsLoaded) return
loading.value = true
try {
await appStore.fetchPublicSettings()
} finally {
loading.value = false
}
})
</script>
<style scoped>
.purchase-page-layout {
@apply flex flex-col gap-6;
height: calc(100vh - 64px - 4rem); /* 减去 header + lg:p-8 的上下padding */
}
</style>