Merge upstream/main: v0.1.62-v0.1.64 updates
Major updates: - fix(gateway): fix SSE streaming usage data overwrite issue - feat(purchase): add purchase subscription iframe page and config - fix(scheduler): fix race condition from empty cache - feat(settings): add PurchaseSubscriptionEnabled and PurchaseSubscriptionURL Resolved conflicts: - backend/cmd/server/VERSION: updated to 0.1.61 - setting_service.go: keep TianShuAPI site name while adding purchase subscription features Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -222,8 +222,9 @@ jobs:
|
|||||||
REPO="${{ github.repository }}"
|
REPO="${{ github.repository }}"
|
||||||
GHCR_IMAGE="ghcr.io/${REPO,,}" # ${,,} converts to lowercase
|
GHCR_IMAGE="ghcr.io/${REPO,,}" # ${,,} converts to lowercase
|
||||||
|
|
||||||
# 获取 tag message 内容
|
# 获取 tag message 内容并转义 Markdown 特殊字符
|
||||||
TAG_MESSAGE='${{ steps.tag_message.outputs.message }}'
|
TAG_MESSAGE='${{ steps.tag_message.outputs.message }}'
|
||||||
|
TAG_MESSAGE=$(echo "$TAG_MESSAGE" | sed 's/\([_*`\[]\)/\\\1/g')
|
||||||
|
|
||||||
# 限制消息长度(Telegram 消息限制 4096 字符,预留空间给头尾固定内容)
|
# 限制消息长度(Telegram 消息限制 4096 字符,预留空间给头尾固定内容)
|
||||||
if [ ${#TAG_MESSAGE} -gt 3500 ]; then
|
if [ ${#TAG_MESSAGE} -gt 3500 ]; then
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.1.56
|
0.1.61
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -119,14 +121,16 @@ type UpdateSettingsRequest struct {
|
|||||||
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
|
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
|
||||||
|
|
||||||
// OEM设置
|
// OEM设置
|
||||||
SiteName string `json:"site_name"`
|
SiteName string `json:"site_name"`
|
||||||
SiteLogo string `json:"site_logo"`
|
SiteLogo string `json:"site_logo"`
|
||||||
SiteSubtitle string `json:"site_subtitle"`
|
SiteSubtitle string `json:"site_subtitle"`
|
||||||
APIBaseURL string `json:"api_base_url"`
|
APIBaseURL string `json:"api_base_url"`
|
||||||
ContactInfo string `json:"contact_info"`
|
ContactInfo string `json:"contact_info"`
|
||||||
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
|
||||||
@@ -255,42 +287,44 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
settings := &service.SystemSettings{
|
settings := &service.SystemSettings{
|
||||||
RegistrationEnabled: req.RegistrationEnabled,
|
RegistrationEnabled: req.RegistrationEnabled,
|
||||||
EmailVerifyEnabled: req.EmailVerifyEnabled,
|
EmailVerifyEnabled: req.EmailVerifyEnabled,
|
||||||
PromoCodeEnabled: req.PromoCodeEnabled,
|
PromoCodeEnabled: req.PromoCodeEnabled,
|
||||||
PasswordResetEnabled: req.PasswordResetEnabled,
|
PasswordResetEnabled: req.PasswordResetEnabled,
|
||||||
TotpEnabled: req.TotpEnabled,
|
TotpEnabled: req.TotpEnabled,
|
||||||
SMTPHost: req.SMTPHost,
|
SMTPHost: req.SMTPHost,
|
||||||
SMTPPort: req.SMTPPort,
|
SMTPPort: req.SMTPPort,
|
||||||
SMTPUsername: req.SMTPUsername,
|
SMTPUsername: req.SMTPUsername,
|
||||||
SMTPPassword: req.SMTPPassword,
|
SMTPPassword: req.SMTPPassword,
|
||||||
SMTPFrom: req.SMTPFrom,
|
SMTPFrom: req.SMTPFrom,
|
||||||
SMTPFromName: req.SMTPFromName,
|
SMTPFromName: req.SMTPFromName,
|
||||||
SMTPUseTLS: req.SMTPUseTLS,
|
SMTPUseTLS: req.SMTPUseTLS,
|
||||||
TurnstileEnabled: req.TurnstileEnabled,
|
TurnstileEnabled: req.TurnstileEnabled,
|
||||||
TurnstileSiteKey: req.TurnstileSiteKey,
|
TurnstileSiteKey: req.TurnstileSiteKey,
|
||||||
TurnstileSecretKey: req.TurnstileSecretKey,
|
TurnstileSecretKey: req.TurnstileSecretKey,
|
||||||
LinuxDoConnectEnabled: req.LinuxDoConnectEnabled,
|
LinuxDoConnectEnabled: req.LinuxDoConnectEnabled,
|
||||||
LinuxDoConnectClientID: req.LinuxDoConnectClientID,
|
LinuxDoConnectClientID: req.LinuxDoConnectClientID,
|
||||||
LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret,
|
LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret,
|
||||||
LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL,
|
LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL,
|
||||||
SiteName: req.SiteName,
|
SiteName: req.SiteName,
|
||||||
SiteLogo: req.SiteLogo,
|
SiteLogo: req.SiteLogo,
|
||||||
SiteSubtitle: req.SiteSubtitle,
|
SiteSubtitle: req.SiteSubtitle,
|
||||||
APIBaseURL: req.APIBaseURL,
|
APIBaseURL: req.APIBaseURL,
|
||||||
ContactInfo: req.ContactInfo,
|
ContactInfo: req.ContactInfo,
|
||||||
DocURL: req.DocURL,
|
DocURL: req.DocURL,
|
||||||
HomeContent: req.HomeContent,
|
HomeContent: req.HomeContent,
|
||||||
HideCcsImportButton: req.HideCcsImportButton,
|
HideCcsImportButton: req.HideCcsImportButton,
|
||||||
DefaultConcurrency: req.DefaultConcurrency,
|
PurchaseSubscriptionEnabled: purchaseEnabled,
|
||||||
DefaultBalance: req.DefaultBalance,
|
PurchaseSubscriptionURL: purchaseURL,
|
||||||
EnableModelFallback: req.EnableModelFallback,
|
DefaultConcurrency: req.DefaultConcurrency,
|
||||||
FallbackModelAnthropic: req.FallbackModelAnthropic,
|
DefaultBalance: req.DefaultBalance,
|
||||||
FallbackModelOpenAI: req.FallbackModelOpenAI,
|
EnableModelFallback: req.EnableModelFallback,
|
||||||
FallbackModelGemini: req.FallbackModelGemini,
|
FallbackModelAnthropic: req.FallbackModelAnthropic,
|
||||||
FallbackModelAntigravity: req.FallbackModelAntigravity,
|
FallbackModelOpenAI: req.FallbackModelOpenAI,
|
||||||
EnableIdentityPatch: req.EnableIdentityPatch,
|
FallbackModelGemini: req.FallbackModelGemini,
|
||||||
IdentityPatchPrompt: req.IdentityPatchPrompt,
|
FallbackModelAntigravity: req.FallbackModelAntigravity,
|
||||||
|
EnableIdentityPatch: req.EnableIdentityPatch,
|
||||||
|
IdentityPatchPrompt: req.IdentityPatchPrompt,
|
||||||
OpsMonitoringEnabled: func() bool {
|
OpsMonitoringEnabled: func() bool {
|
||||||
if req.OpsMonitoringEnabled != nil {
|
if req.OpsMonitoringEnabled != nil {
|
||||||
return *req.OpsMonitoringEnabled
|
return *req.OpsMonitoringEnabled
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -26,14 +26,16 @@ type SystemSettings struct {
|
|||||||
LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"`
|
LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"`
|
||||||
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
|
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
|
||||||
|
|
||||||
SiteName string `json:"site_name"`
|
SiteName string `json:"site_name"`
|
||||||
SiteLogo string `json:"site_logo"`
|
SiteLogo string `json:"site_logo"`
|
||||||
SiteSubtitle string `json:"site_subtitle"`
|
SiteSubtitle string `json:"site_subtitle"`
|
||||||
APIBaseURL string `json:"api_base_url"`
|
APIBaseURL string `json:"api_base_url"`
|
||||||
ContactInfo string `json:"contact_info"`
|
ContactInfo string `json:"contact_info"`
|
||||||
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"`
|
||||||
@@ -57,23 +59,25 @@ type SystemSettings struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PublicSettings struct {
|
type PublicSettings struct {
|
||||||
RegistrationEnabled bool `json:"registration_enabled"`
|
RegistrationEnabled bool `json:"registration_enabled"`
|
||||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||||
SiteName string `json:"site_name"`
|
SiteName string `json:"site_name"`
|
||||||
SiteLogo string `json:"site_logo"`
|
SiteLogo string `json:"site_logo"`
|
||||||
SiteSubtitle string `json:"site_subtitle"`
|
SiteSubtitle string `json:"site_subtitle"`
|
||||||
APIBaseURL string `json:"api_base_url"`
|
APIBaseURL string `json:"api_base_url"`
|
||||||
ContactInfo string `json:"contact_info"`
|
ContactInfo string `json:"contact_info"`
|
||||||
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"`
|
||||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||||
Version string `json:"version"`
|
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
||||||
|
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||||
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamTimeoutSettings 流超时处理配置 DTO
|
// StreamTimeoutSettings 流超时处理配置 DTO
|
||||||
|
|||||||
@@ -32,21 +32,24 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
response.Success(c, dto.PublicSettings{
|
response.Success(c, dto.PublicSettings{
|
||||||
RegistrationEnabled: settings.RegistrationEnabled,
|
RegistrationEnabled: settings.RegistrationEnabled,
|
||||||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||||||
PromoCodeEnabled: settings.PromoCodeEnabled,
|
PromoCodeEnabled: settings.PromoCodeEnabled,
|
||||||
PasswordResetEnabled: settings.PasswordResetEnabled,
|
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||||||
TurnstileEnabled: settings.TurnstileEnabled,
|
TotpEnabled: settings.TotpEnabled,
|
||||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
TurnstileEnabled: settings.TurnstileEnabled,
|
||||||
SiteName: settings.SiteName,
|
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||||
SiteLogo: settings.SiteLogo,
|
SiteName: settings.SiteName,
|
||||||
SiteSubtitle: settings.SiteSubtitle,
|
SiteLogo: settings.SiteLogo,
|
||||||
APIBaseURL: settings.APIBaseURL,
|
SiteSubtitle: settings.SiteSubtitle,
|
||||||
ContactInfo: settings.ContactInfo,
|
APIBaseURL: settings.APIBaseURL,
|
||||||
DocURL: settings.DocURL,
|
ContactInfo: settings.ContactInfo,
|
||||||
HomeContent: settings.HomeContent,
|
DocURL: settings.DocURL,
|
||||||
HideCcsImportButton: settings.HideCcsImportButton,
|
HomeContent: settings.HomeContent,
|
||||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
HideCcsImportButton: settings.HideCcsImportButton,
|
||||||
Version: h.version,
|
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||||
|
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||||
|
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||||
|
Version: h.version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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": ""
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -98,14 +98,16 @@ const (
|
|||||||
SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url"
|
SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url"
|
||||||
|
|
||||||
// OEM设置
|
// OEM设置
|
||||||
SettingKeySiteName = "site_name" // 网站名称
|
SettingKeySiteName = "site_name" // 网站名称
|
||||||
SettingKeySiteLogo = "site_logo" // 网站Logo (base64)
|
SettingKeySiteLogo = "site_logo" // 网站Logo (base64)
|
||||||
SettingKeySiteSubtitle = "site_subtitle" // 网站副标题
|
SettingKeySiteSubtitle = "site_subtitle" // 网站副标题
|
||||||
SettingKeyAPIBaseURL = "api_base_url" // API端点地址(用于客户端配置和导入)
|
SettingKeyAPIBaseURL = "api_base_url" // API端点地址(用于客户端配置和导入)
|
||||||
SettingKeyContactInfo = "contact_info" // 客服联系方式
|
SettingKeyContactInfo = "contact_info" // 客服联系方式
|
||||||
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" // 新用户默认并发量
|
||||||
|
|||||||
@@ -3372,17 +3372,19 @@ 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" {
|
||||||
// output_tokens 总是从 message_delta 获取
|
// message_delta 仅覆盖存在且非0的字段
|
||||||
usage.OutputTokens = msgDelta.Usage.OutputTokens
|
// 避免覆盖 message_start 中已有的值(如 input_tokens)
|
||||||
|
// Claude API 的 message_delta 通常只包含 output_tokens
|
||||||
// 如果 message_start 中没有值,则从 message_delta 获取(兼容GLM等API)
|
if msgDelta.Usage.InputTokens > 0 {
|
||||||
if usage.InputTokens == 0 {
|
|
||||||
usage.InputTokens = msgDelta.Usage.InputTokens
|
usage.InputTokens = msgDelta.Usage.InputTokens
|
||||||
}
|
}
|
||||||
if usage.CacheCreationInputTokens == 0 {
|
if msgDelta.Usage.OutputTokens > 0 {
|
||||||
|
usage.OutputTokens = msgDelta.Usage.OutputTokens
|
||||||
|
}
|
||||||
|
if msgDelta.Usage.CacheCreationInputTokens > 0 {
|
||||||
usage.CacheCreationInputTokens = msgDelta.Usage.CacheCreationInputTokens
|
usage.CacheCreationInputTokens = msgDelta.Usage.CacheCreationInputTokens
|
||||||
}
|
}
|
||||||
if usage.CacheReadInputTokens == 0 {
|
if msgDelta.Usage.CacheReadInputTokens > 0 {
|
||||||
usage.CacheReadInputTokens = msgDelta.Usage.CacheReadInputTokens
|
usage.CacheReadInputTokens = msgDelta.Usage.CacheReadInputTokens
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
SettingKeyDocURL,
|
SettingKeyDocURL,
|
||||||
SettingKeyHomeContent,
|
SettingKeyHomeContent,
|
||||||
SettingKeyHideCcsImportButton,
|
SettingKeyHideCcsImportButton,
|
||||||
|
SettingKeyPurchaseSubscriptionEnabled,
|
||||||
|
SettingKeyPurchaseSubscriptionURL,
|
||||||
SettingKeyLinuxDoConnectEnabled,
|
SettingKeyLinuxDoConnectEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,22 +95,24 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
|||||||
passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true"
|
passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true"
|
||||||
|
|
||||||
return &PublicSettings{
|
return &PublicSettings{
|
||||||
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
||||||
EmailVerifyEnabled: emailVerifyEnabled,
|
EmailVerifyEnabled: emailVerifyEnabled,
|
||||||
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
||||||
PasswordResetEnabled: passwordResetEnabled,
|
PasswordResetEnabled: passwordResetEnabled,
|
||||||
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
||||||
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
||||||
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
||||||
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "TianShuAPI"),
|
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "TianShuAPI"),
|
||||||
SiteLogo: settings[SettingKeySiteLogo],
|
SiteLogo: settings[SettingKeySiteLogo],
|
||||||
SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
|
SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
|
||||||
APIBaseURL: settings[SettingKeyAPIBaseURL],
|
APIBaseURL: settings[SettingKeyAPIBaseURL],
|
||||||
ContactInfo: settings[SettingKeyContactInfo],
|
ContactInfo: settings[SettingKeyContactInfo],
|
||||||
DocURL: settings[SettingKeyDocURL],
|
DocURL: settings[SettingKeyDocURL],
|
||||||
HomeContent: settings[SettingKeyHomeContent],
|
HomeContent: settings[SettingKeyHomeContent],
|
||||||
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
||||||
LinuxDoOAuthEnabled: linuxDoEnabled,
|
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
||||||
|
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
||||||
|
LinuxDoOAuthEnabled: linuxDoEnabled,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,41 +137,45 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
|||||||
|
|
||||||
// Return a struct that matches the frontend's expected format
|
// Return a struct that matches the frontend's expected format
|
||||||
return &struct {
|
return &struct {
|
||||||
RegistrationEnabled bool `json:"registration_enabled"`
|
RegistrationEnabled bool `json:"registration_enabled"`
|
||||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||||
TotpEnabled bool `json:"totp_enabled"`
|
TotpEnabled bool `json:"totp_enabled"`
|
||||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||||
TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
|
TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
|
||||||
SiteName string `json:"site_name"`
|
SiteName string `json:"site_name"`
|
||||||
SiteLogo string `json:"site_logo,omitempty"`
|
SiteLogo string `json:"site_logo,omitempty"`
|
||||||
SiteSubtitle string `json:"site_subtitle,omitempty"`
|
SiteSubtitle string `json:"site_subtitle,omitempty"`
|
||||||
APIBaseURL string `json:"api_base_url,omitempty"`
|
APIBaseURL string `json:"api_base_url,omitempty"`
|
||||||
ContactInfo string `json:"contact_info,omitempty"`
|
ContactInfo string `json:"contact_info,omitempty"`
|
||||||
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"`
|
||||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||||
Version string `json:"version,omitempty"`
|
PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"`
|
||||||
|
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
}{
|
}{
|
||||||
RegistrationEnabled: settings.RegistrationEnabled,
|
RegistrationEnabled: settings.RegistrationEnabled,
|
||||||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||||||
PromoCodeEnabled: settings.PromoCodeEnabled,
|
PromoCodeEnabled: settings.PromoCodeEnabled,
|
||||||
PasswordResetEnabled: settings.PasswordResetEnabled,
|
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||||||
TotpEnabled: settings.TotpEnabled,
|
TotpEnabled: settings.TotpEnabled,
|
||||||
TurnstileEnabled: settings.TurnstileEnabled,
|
TurnstileEnabled: settings.TurnstileEnabled,
|
||||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||||
SiteName: settings.SiteName,
|
SiteName: settings.SiteName,
|
||||||
SiteLogo: settings.SiteLogo,
|
SiteLogo: settings.SiteLogo,
|
||||||
SiteSubtitle: settings.SiteSubtitle,
|
SiteSubtitle: settings.SiteSubtitle,
|
||||||
APIBaseURL: settings.APIBaseURL,
|
APIBaseURL: settings.APIBaseURL,
|
||||||
ContactInfo: settings.ContactInfo,
|
ContactInfo: settings.ContactInfo,
|
||||||
DocURL: settings.DocURL,
|
DocURL: settings.DocURL,
|
||||||
HomeContent: settings.HomeContent,
|
HomeContent: settings.HomeContent,
|
||||||
HideCcsImportButton: settings.HideCcsImportButton,
|
HideCcsImportButton: settings.HideCcsImportButton,
|
||||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||||
Version: s.version,
|
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||||
|
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||||
|
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)
|
||||||
@@ -352,15 +362,17 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
|||||||
|
|
||||||
// 初始化默认设置
|
// 初始化默认设置
|
||||||
defaults := map[string]string{
|
defaults := map[string]string{
|
||||||
SettingKeyRegistrationEnabled: "true",
|
SettingKeyRegistrationEnabled: "true",
|
||||||
SettingKeyEmailVerifyEnabled: "false",
|
SettingKeyEmailVerifyEnabled: "false",
|
||||||
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
|
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
|
||||||
SettingKeySiteName: "TianShuAPI",
|
SettingKeySiteName: "TianShuAPI",
|
||||||
SettingKeySiteLogo: "",
|
SettingKeySiteLogo: "",
|
||||||
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
SettingKeyPurchaseSubscriptionEnabled: "false",
|
||||||
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
|
SettingKeyPurchaseSubscriptionURL: "",
|
||||||
SettingKeySMTPPort: "587",
|
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
||||||
SettingKeySMTPUseTLS: "false",
|
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
|
||||||
|
SettingKeySMTPPort: "587",
|
||||||
|
SettingKeySMTPUseTLS: "false",
|
||||||
// Model fallback defaults
|
// Model fallback defaults
|
||||||
SettingKeyEnableModelFallback: "false",
|
SettingKeyEnableModelFallback: "false",
|
||||||
SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022",
|
SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022",
|
||||||
@@ -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]),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析整数类型
|
// 解析整数类型
|
||||||
|
|||||||
@@ -28,14 +28,16 @@ type SystemSettings struct {
|
|||||||
LinuxDoConnectClientSecretConfigured bool
|
LinuxDoConnectClientSecretConfigured bool
|
||||||
LinuxDoConnectRedirectURL string
|
LinuxDoConnectRedirectURL string
|
||||||
|
|
||||||
SiteName string
|
SiteName string
|
||||||
SiteLogo string
|
SiteLogo string
|
||||||
SiteSubtitle string
|
SiteSubtitle string
|
||||||
APIBaseURL string
|
APIBaseURL string
|
||||||
ContactInfo string
|
ContactInfo string
|
||||||
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,8 +76,12 @@ type PublicSettings struct {
|
|||||||
DocURL string
|
DocURL string
|
||||||
HomeContent string
|
HomeContent string
|
||||||
HideCcsImportButton bool
|
HideCcsImportButton bool
|
||||||
LinuxDoOAuthEnabled bool
|
|
||||||
Version string
|
PurchaseSubscriptionEnabled bool
|
||||||
|
PurchaseSubscriptionURL string
|
||||||
|
|
||||||
|
LinuxDoOAuthEnabled bool
|
||||||
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制)
|
// StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 或 CSP(frame-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: '我的订阅',
|
||||||
|
|||||||
@@ -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 ====================
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
121
frontend/src/views/user/PurchaseSubscriptionView.vue
Normal file
121
frontend/src/views/user/PurchaseSubscriptionView.vue
Normal 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>
|
||||||
|
|
||||||
Reference in New Issue
Block a user