Compare commits

...

11 Commits

Author SHA1 Message Date
github-actions[bot]
6449da6c8d chore: sync VERSION to 0.1.115 [skip ci] 2026-04-22 12:08:51 +00:00
shaw
755c7d5026 chore: revert README files to 78f691d2 version 2026-04-22 19:55:13 +08:00
Wesley Liddick
1da4bd72df Merge pull request #1802 from IanShaw027/fix/profile-auth-bindings-i18n
fix(profile): 修正邮箱重复显示问题并添加国际化语言支持
2026-04-22 19:49:43 +08:00
IanShaw027
5551349349 fix: clean up profile auth binding notes 2026-04-22 19:11:51 +08:00
shaw
c6d25f69d5 chore: 恢复PAYMENT系列文件 2026-04-22 18:48:40 +08:00
shaw
45065c23d5 fix(ci): run 108a migration before 109 in backfill integration test 2026-04-22 18:36:44 +08:00
Wesley Liddick
ddf80f5ea1 Merge pull request #1799 from IanShaw027/rebuild/auth-identity-foundation
fix(auth,payment,profile): 修复认证身份和支付系统的后续问题
2026-04-22 18:18:39 +08:00
Wesley Liddick
c048ca80a4 Merge branch 'main' into rebuild/auth-identity-foundation 2026-04-22 18:17:12 +08:00
IanShaw027
22385be515 Merge remote-tracking branch 'upstream/main' into rebuild/auth-identity-foundation
# Conflicts:
#	backend/internal/service/openai_images.go
2026-04-22 18:13:05 +08:00
shaw
4d0483f5b8 feat: 补充gpt生图模型测试功能 2026-04-22 18:12:03 +08:00
shaw
1e0d466002 feat: 补充gpt生图模型测试功能 2026-04-22 18:06:14 +08:00
28 changed files with 1511 additions and 118 deletions

5
.gitignore vendored
View File

@@ -126,7 +126,10 @@ backend/cmd/server/server
deploy/docker-compose.override.yml
.gocache/
vite.config.js
docs/
docs/*
!docs/PAYMENT.md
!docs/PAYMENT_CN.md
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
.serena/
.codex/
frontend/coverage/

View File

@@ -42,18 +42,10 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
- **Smart Scheduling** - Intelligent account selection with sticky sessions
- **Concurrency Control** - Per-user and per-account concurrency limits
- **Rate Limiting** - Configurable request and token rate limits
- **Built-in Payment System** - Supports EasyPay, Alipay, WeChat Pay, and Stripe for user self-service top-up, no separate payment service needed ([Payment Setup](#payment))
- **Built-in Payment System** - Supports EasyPay, Alipay, WeChat Pay, and Stripe for user self-service top-up, no separate payment service needed ([Configuration Guide](docs/PAYMENT.md))
- **Admin Dashboard** - Web interface for monitoring and management
- **External System Integration** - Embed external systems (e.g. ticketing) via iframe to extend the admin dashboard
## Payment
Sub2API includes the payment system in the main service. No standalone payment service or separate payment guide is required.
- Supported providers: EasyPay, Alipay, WeChat Pay, Stripe
- The frontend keeps user-facing methods unified; admins choose the backing source in `Admin -> Settings -> Payment`
- Callback URLs are generated from the site domain when configuring providers
## ❤️ Sponsors
> [Want to appear here?](mailto:support@pincc.ai)
@@ -117,7 +109,7 @@ Community projects that extend or integrate with Sub2API:
| Project | Description | Features |
|---------|-------------|----------|
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~Self-service payment system~~ | **Now Built-in** — Payment is now integrated into Sub2API, no separate deployment needed. See [Payment Setup](#payment) |
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~Self-service payment system~~ | **Now Built-in** — Payment is now integrated into Sub2API, no separate deployment needed. See [Payment Configuration Guide](docs/PAYMENT.md) |
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | Mobile admin console | Cross-platform app (iOS/Android/Web) for user management, account management, monitoring dashboard, and multi-backend switching; built with Expo + React Native |
## Tech Stack

View File

@@ -41,18 +41,10 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的
- **智能调度** - 智能账号选择,支持粘性会话
- **并发控制** - 用户级和账号级并发限制
- **速率限制** - 可配置的请求和 Token 速率限制
- **内置支付系统** - 支持 EasyPay 易支付、支付宝官方、微信官方、Stripe用户自助充值无需独立部署支付服务[支付说明](#支付)
- **内置支付系统** - 支持 EasyPay 易支付、支付宝官方、微信官方、Stripe用户自助充值无需独立部署支付服务[配置指南](docs/PAYMENT_CN.md)
- **管理后台** - Web 界面进行监控和管理
- **外部系统集成** - 支持通过 iframe 嵌入外部系统(如工单等),扩展管理后台功能
## 支付
Sub2API 已将支付系统集成到主服务中,无需独立支付服务,也不再依赖单独的支付配置文档。
- 支持服务商EasyPay 易支付、支付宝官方、微信官方、Stripe
- 前台统一展示用户可见支付方式,管理员在 `管理后台 -> 设置 -> 支付` 里选择对应来源
- 添加服务商时会基于站点域名生成回调地址
## ❤️ 赞助商
> [想出现在这里?](mailto:support@pincc.ai)
@@ -116,7 +108,7 @@ Sub2API 已将支付系统集成到主服务中,无需独立支付服务,也
| 项目 | 说明 | 功能 |
|------|------|------|
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~自助支付系统~~ | **已内置** — 支付功能已集成到 Sub2API 中,无需独立部署。详见 [支付说明](#支付) |
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~自助支付系统~~ | **已内置** — 支付功能已集成到 Sub2API 中,无需独立部署。详见 [支付配置指南](docs/PAYMENT_CN.md) |
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | 移动端管理控制台 | 跨平台应用iOS/Android/Web支持用户管理、账号管理、监控看板、多后端切换基于 Expo + React Native 构建 |
## 技术栈

View File

@@ -42,18 +42,10 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを
- **スマートスケジューリング** - スティッキーセッション付きのインテリジェントなアカウント選択
- **同時実行制御** - ユーザーごと・アカウントごとの同時実行数制限
- **レート制限** - 設定可能なリクエスト数およびトークンレート制限
- **内蔵決済システム** - EasyPay、Alipay、WeChat Pay、Stripe に対応。ユーザーのセルフサービスチャージが可能で、別途決済サービスのデプロイは不要([決済案内](#決済)
- **内蔵決済システム** - EasyPay、Alipay、WeChat Pay、Stripe に対応。ユーザーのセルフサービスチャージが可能で、別途決済サービスのデプロイは不要([設定ガイド](docs/PAYMENT.md)
- **管理ダッシュボード** - 監視・管理のための Web インターフェース
- **外部システム連携** - 外部システム(チケット管理など)を iframe 経由で管理ダッシュボードに埋め込み可能
## 決済
Sub2API の決済機能は本体に統合されています。独立した決済サービスや別個の決済ガイドは不要です。
- 対応プロバイダー: EasyPay、Alipay、WeChat Pay、Stripe
- フロントエンドではユーザー向け決済方法を統一表示し、管理者は `管理画面 -> 設定 -> 決済` で実際の接続先を選択します
- プロバイダー設定時のコールバック URL はサイトドメインから自動生成されます
## ❤️ スポンサー
> [こちらに掲載しませんか?](mailto:support@pincc.ai)
@@ -116,7 +108,7 @@ Sub2API を拡張・統合するコミュニティプロジェクト:
| プロジェクト | 説明 | 機能 |
|---------|-------------|----------|
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~セルフサービス決済システム~~ | **内蔵済み** — 決済機能は Sub2API に統合されました。別途デプロイは不要です。[決済案内](#決済)をご参照ください |
| ~~[Sub2ApiPay](https://github.com/touwaeriol/sub2apipay)~~ | ~~セルフサービス決済システム~~ | **内蔵済み** — 決済機能は Sub2API に統合されました。別途デプロイは不要です。[決済設定ガイド](docs/PAYMENT.md)をご参照ください |
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | モバイル管理コンソール | ユーザー管理、アカウント管理、監視ダッシュボード、マルチバックエンド切り替えが可能なクロスプラットフォームアプリiOS/Android/Web。Expo + React Native で構築 |
## 技術スタック

View File

@@ -1 +1 @@
0.1.114
0.1.115

View File

@@ -323,6 +323,11 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
emailBinding, ok := identityBindings["email"].(map[string]any)
require.True(t, ok)
require.Equal(t, true, emailBinding["bound"])
require.Equal(t, "profile.authBindings.notes.emailManagedFromProfile", emailBinding["note_key"])
linuxdoCompatBinding, ok := identityBindings["linuxdo"].(map[string]any)
require.True(t, ok)
require.Equal(t, "profile.authBindings.notes.canUnbind", linuxdoCompatBinding["note_key"])
profileSources, ok := resp.Data["profile_sources"].(map[string]any)
require.True(t, ok)

View File

@@ -20,6 +20,8 @@ var DefaultModels = []Model{
{ID: "gpt-5.3-codex", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.3 Codex"},
{ID: "gpt-5.3-codex-spark", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.3 Codex Spark"},
{ID: "gpt-5.2", Object: "model", Created: 1733875200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.2"},
{ID: "gpt-image-1", Object: "model", Created: 1733875200, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 1"},
{ID: "gpt-image-1.5", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 1.5"},
{ID: "gpt-image-2", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 2"},
}

View File

@@ -20,6 +20,10 @@ func TestAuthIdentityCompatBackfillMigration_AllowsLongReportTypes(t *testing.T)
migration108SQL, err := os.ReadFile(migration108Path)
require.NoError(t, err)
migration108aPath := filepath.Join("..", "..", "migrations", "108a_widen_auth_identity_migration_report_type.sql")
migration108aSQL, err := os.ReadFile(migration108aPath)
require.NoError(t, err)
migration109Path := filepath.Join("..", "..", "migrations", "109_auth_identity_compat_backfill.sql")
migration109SQL, err := os.ReadFile(migration109Path)
require.NoError(t, err)
@@ -41,6 +45,9 @@ ALTER TABLE users
_, err = tx.ExecContext(ctx, string(migration108SQL))
require.NoError(t, err)
_, err = tx.ExecContext(ctx, string(migration108aSQL))
require.NoError(t, err)
var userID int64
require.NoError(t, tx.QueryRowContext(ctx, `
INSERT INTO users (email, password_hash, role, status, balance, concurrency)

View File

@@ -77,6 +77,7 @@ func TestAPIContracts(t *testing.T) {
"can_unbind": false,
"display_name": "alice@example.com",
"subject_hint": "a***e@example.com",
"note_key": "profile.authBindings.notes.emailManagedFromProfile",
"note": "Primary account email is managed from the profile form."
},
"linuxdo": {
@@ -114,6 +115,7 @@ func TestAPIContracts(t *testing.T) {
"can_unbind": false,
"display_name": "alice@example.com",
"subject_hint": "a***e@example.com",
"note_key": "profile.authBindings.notes.emailManagedFromProfile",
"note": "Primary account email is managed from the profile form."
},
"linuxdo": {
@@ -151,6 +153,7 @@ func TestAPIContracts(t *testing.T) {
"can_unbind": false,
"display_name": "alice@example.com",
"subject_hint": "a***e@example.com",
"note_key": "profile.authBindings.notes.emailManagedFromProfile",
"note": "Primary account email is managed from the profile form."
},
"linuxdo": {

View File

@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
@@ -52,8 +53,14 @@ type TestEvent struct {
const (
defaultGeminiTextTestPrompt = "hi"
defaultGeminiImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background."
defaultOpenAIImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background."
)
// isOpenAIImageModel checks if the model is an OpenAI image generation model (e.g. gpt-image-2).
func isOpenAIImageModel(model string) bool {
return strings.HasPrefix(strings.ToLower(model), "gpt-image-")
}
// AccountTestService handles account testing operations
type AccountTestService struct {
accountRepo AccountRepository
@@ -430,6 +437,18 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
}
}
// Route to image generation test if an image model is selected
if isOpenAIImageModel(testModelID) {
imagePrompt := strings.TrimSpace(prompt)
if imagePrompt == "" {
imagePrompt = defaultOpenAIImageTestPrompt
}
if account.Type == "apikey" {
return s.testOpenAIImageAPIKey(c, ctx, account, testModelID, imagePrompt)
}
return s.testOpenAIImageOAuth(c, ctx, account, testModelID, imagePrompt)
}
// Determine authentication method and API URL
var authToken string
var apiURL string
@@ -1026,7 +1045,336 @@ func (s *AccountTestService) processOpenAIStream(c *gin.Context, body io.Reader)
}
}
// sendEvent sends a SSE event to the client
// testOpenAIImageAPIKey tests OpenAI image generation using an API Key account.
func (s *AccountTestService) testOpenAIImageAPIKey(c *gin.Context, ctx context.Context, account *Account, modelID, prompt string) error {
authToken := account.GetOpenAIApiKey()
if authToken == "" {
return s.sendErrorAndEnd(c, "No API key available")
}
baseURL := account.GetOpenAIBaseURL()
if baseURL == "" {
baseURL = "https://api.openai.com"
}
normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error()))
}
apiURL := strings.TrimSuffix(normalizedBaseURL, "/") + "/v1/images/generations"
// Set SSE headers
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Writer.Flush()
s.sendEvent(c, TestEvent{Type: "test_start", Model: modelID})
payload := map[string]any{
"model": modelID,
"prompt": prompt,
"n": 1,
"response_format": "b64_json",
}
payloadBytes, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(payloadBytes))
if err != nil {
return s.sendErrorAndEnd(c, "Failed to create request")
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+authToken)
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to read response: %s", err.Error()))
}
if resp.StatusCode != http.StatusOK {
return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body)))
}
// Parse {"data": [{"b64_json": "...", "revised_prompt": "..."}]}
var result struct {
Data []struct {
B64JSON string `json:"b64_json"`
RevisedPrompt string `json:"revised_prompt"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to parse response: %s", err.Error()))
}
if len(result.Data) == 0 {
return s.sendErrorAndEnd(c, "No images returned from API")
}
for _, item := range result.Data {
if item.RevisedPrompt != "" {
s.sendEvent(c, TestEvent{Type: "content", Text: item.RevisedPrompt})
}
if item.B64JSON != "" {
s.sendEvent(c, TestEvent{
Type: "image",
ImageURL: "data:image/png;base64," + item.B64JSON,
MimeType: "image/png",
})
}
}
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
return nil
}
// testOpenAIImageOAuth tests OpenAI image generation using an OAuth account via ChatGPT backend API.
func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Context, account *Account, modelID, prompt string) error {
authToken := account.GetOpenAIAccessToken()
if authToken == "" {
return s.sendErrorAndEnd(c, "No access token available")
}
// Set SSE headers
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Writer.Flush()
s.sendEvent(c, TestEvent{Type: "test_start", Model: modelID})
s.sendEvent(c, TestEvent{Type: "content", Text: "Initializing ChatGPT backend...\n"})
// Build headers (replicating buildOpenAIBackendAPIHeaders logic)
headers := buildOpenAIBackendAPIHeadersForTest(ctx, account, authToken, s.accountRepo)
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
client, err := newOpenAIBackendAPIClient(proxyURL)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to create client: %s", err.Error()))
}
// Bootstrap
if bootstrapErr := bootstrapOpenAIBackendAPI(ctx, client, headers); bootstrapErr != nil {
log.Printf("OpenAI image test bootstrap warning: %v", bootstrapErr)
}
// Fetch chat requirements
s.sendEvent(c, TestEvent{Type: "content", Text: "Fetching chat requirements...\n"})
chatReqs, err := fetchOpenAIChatRequirements(ctx, client, headers)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Chat requirements failed: %s", err.Error()))
}
if chatReqs.Arkose.Required {
return s.sendErrorAndEnd(c, "Unsupported challenge: arkose required")
}
// Initialize and prepare conversation
s.sendEvent(c, TestEvent{Type: "content", Text: "Preparing image conversation...\n"})
parentMessageID := uuid.NewString()
proofToken := generateOpenAIProofToken(chatReqs.ProofOfWork.Required, chatReqs.ProofOfWork.Seed, chatReqs.ProofOfWork.Difficulty, headers.Get("User-Agent"))
_ = initializeOpenAIImageConversation(ctx, client, headers)
conduitToken, err := prepareOpenAIImageConversation(ctx, client, headers, prompt, parentMessageID, chatReqs.Token, proofToken)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Conversation prepare failed: %s", err.Error()))
}
// Build simplified conversation request (no file uploads)
convReq := buildOpenAIImageTestConversationRequest(prompt, parentMessageID)
convHeaders := cloneHTTPHeader(headers)
convHeaders.Set("Accept", "text/event-stream")
convHeaders.Set("Content-Type", "application/json")
convHeaders.Set("openai-sentinel-chat-requirements-token", chatReqs.Token)
if conduitToken != "" {
convHeaders.Set("x-conduit-token", conduitToken)
}
if proofToken != "" {
convHeaders.Set("openai-sentinel-proof-token", proofToken)
}
s.sendEvent(c, TestEvent{Type: "content", Text: "Generating image...\n"})
resp, err := client.R().
SetContext(ctx).
DisableAutoReadResponse().
SetHeaders(headerToMap(convHeaders)).
SetBodyJsonMarshal(convReq).
Post(openAIChatGPTConversationURL)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Conversation request failed: %s", err.Error()))
}
defer func() {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
}()
if resp.StatusCode >= 400 {
return s.sendErrorAndEnd(c, fmt.Sprintf("Conversation API returned %d", resp.StatusCode))
}
startTime := time.Now()
conversationID, pointerInfos, _, _, err := readOpenAIImageConversationStream(resp, startTime)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Stream read failed: %s", err.Error()))
}
pointerInfos = mergeOpenAIImagePointerInfos(pointerInfos, nil)
if conversationID != "" && !hasOpenAIFileServicePointerInfos(pointerInfos) {
s.sendEvent(c, TestEvent{Type: "content", Text: "Waiting for image generation to complete...\n"})
polledPointers, pollErr := pollOpenAIImageConversation(ctx, client, headers, conversationID)
if pollErr != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Poll failed: %s", pollErr.Error()))
}
pointerInfos = mergeOpenAIImagePointerInfos(pointerInfos, polledPointers)
}
pointerInfos = preferOpenAIFileServicePointerInfos(pointerInfos)
if len(pointerInfos) == 0 {
return s.sendErrorAndEnd(c, "No images returned from conversation")
}
s.sendEvent(c, TestEvent{Type: "content", Text: "Downloading generated image...\n"})
// Download and encode each image
for _, pointer := range pointerInfos {
downloadURL, err := fetchOpenAIImageDownloadURL(ctx, client, headers, conversationID, pointer.Pointer)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Download URL fetch failed: %s", err.Error()))
}
data, err := downloadOpenAIImageBytes(ctx, client, headers, downloadURL)
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Image download failed: %s", err.Error()))
}
b64 := base64.StdEncoding.EncodeToString(data)
mimeType := http.DetectContentType(data)
if pointer.Prompt != "" {
s.sendEvent(c, TestEvent{Type: "content", Text: pointer.Prompt})
}
s.sendEvent(c, TestEvent{
Type: "image",
ImageURL: "data:" + mimeType + ";base64," + b64,
MimeType: mimeType,
})
}
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
return nil
}
// buildOpenAIBackendAPIHeadersForTest builds ChatGPT backend API headers for test purposes.
// Replicates the logic from OpenAIGatewayService.buildOpenAIBackendAPIHeaders without
// requiring the full gateway service dependency.
func buildOpenAIBackendAPIHeadersForTest(ctx context.Context, account *Account, token string, repo AccountRepository) http.Header {
// Ensure device and session IDs exist
deviceID := account.GetOpenAIDeviceID()
sessionID := account.GetOpenAISessionID()
if deviceID == "" || sessionID == "" {
updates := map[string]any{}
if deviceID == "" {
deviceID = uuid.NewString()
updates["openai_device_id"] = deviceID
}
if sessionID == "" {
sessionID = uuid.NewString()
updates["openai_session_id"] = sessionID
}
if account.Extra == nil {
account.Extra = map[string]any{}
}
for key, value := range updates {
account.Extra[key] = value
}
if repo != nil {
updateCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_ = repo.UpdateExtra(updateCtx, account.ID, updates)
}
}
headers := make(http.Header)
headers.Set("Authorization", "Bearer "+token)
headers.Set("Accept", "application/json")
headers.Set("Origin", "https://chatgpt.com")
headers.Set("Referer", "https://chatgpt.com/")
headers.Set("Sec-Fetch-Dest", "empty")
headers.Set("Sec-Fetch-Mode", "cors")
headers.Set("Sec-Fetch-Site", "same-origin")
headers.Set("User-Agent", openAIImageBackendUserAgent)
if customUA := strings.TrimSpace(account.GetOpenAIUserAgent()); customUA != "" {
headers.Set("User-Agent", customUA)
}
if chatgptAccountID := strings.TrimSpace(account.GetChatGPTAccountID()); chatgptAccountID != "" {
headers.Set("chatgpt-account-id", chatgptAccountID)
}
if deviceID != "" {
headers.Set("oai-device-id", deviceID)
headers.Set("Cookie", "oai-did="+deviceID)
}
if sessionID != "" {
headers.Set("oai-session-id", sessionID)
}
return headers
}
// buildOpenAIImageTestConversationRequest creates a simplified image generation conversation request.
func buildOpenAIImageTestConversationRequest(prompt, parentMessageID string) map[string]any {
promptText := strings.TrimSpace(prompt)
if promptText == "" {
promptText = "Generate an image."
}
metadata := map[string]any{
"developer_mode_connector_ids": []any{},
"selected_github_repos": []any{},
"selected_all_github_repos": false,
"system_hints": []string{"picture_v2"},
"serialization_metadata": map[string]any{
"custom_symbol_offsets": []any{},
},
}
message := map[string]any{
"id": uuid.NewString(),
"author": map[string]any{"role": "user"},
"content": map[string]any{
"content_type": "text",
"parts": []any{promptText},
},
"metadata": metadata,
"create_time": float64(time.Now().UnixMilli()) / 1000,
}
return map[string]any{
"action": "next",
"client_prepare_state": "sent",
"parent_message_id": parentMessageID,
"messages": []any{message},
"model": "auto",
"timezone_offset_min": openAITimezoneOffsetMinutes(),
"timezone": openAITimezoneName(),
"conversation_mode": map[string]any{"kind": "primary_assistant"},
"system_hints": []string{"picture_v2"},
"supports_buffering": true,
"supported_encodings": []string{"v1"},
"client_contextual_info": map[string]any{"app_name": "chatgpt.com"},
"force_nulligen": false,
"force_paragen": false,
"force_paragen_model_slug": "",
"force_rate_limit": false,
"websocket_request_id": uuid.NewString(),
}
}
func (s *AccountTestService) sendEvent(c *gin.Context, event TestEvent) {
eventJSON, _ := json.Marshal(event)
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON); err != nil {

View File

@@ -917,7 +917,15 @@ func (s *OpenAIGatewayService) SelectAccountWithSchedulerForImages(
excludedIDs map[int64]struct{},
requiredCapability OpenAIImagesCapability,
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
return s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, requiredCapability)
selection, decision, err := s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, requiredCapability)
if err == nil && selection != nil && selection.Account != nil {
return selection, decision, nil
}
// 如果要求 native 能力(如指定了模型)但没有可用的 APIKey 账号,回退到 basicOAuth 账号)
if requiredCapability == OpenAIImagesCapabilityNative {
return s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, OpenAIImagesCapabilityBasic)
}
return selection, decision, err
}
func (s *OpenAIGatewayService) selectAccountWithScheduler(

View File

@@ -1294,7 +1294,7 @@ type openAIImageToolMessage struct {
}
func readOpenAIImageConversationStream(resp *req.Response, startTime time.Time) (string, []openAIImagePointerInfo, OpenAIUsage, *int, error) {
if resp == nil || resp.Response == nil || resp.Body == nil {
if resp == nil || resp.Body == nil {
return "", nil, OpenAIUsage{}, nil, fmt.Errorf("empty conversation response")
}
reader := bufio.NewReader(resp.Body)
@@ -1752,8 +1752,8 @@ func newOpenAIImageStatusError(resp *req.Response, fallback string) error {
if resp.Response != nil {
headers = resp.Header.Clone()
requestID = strings.TrimSpace(resp.Header.Get("x-request-id"))
if resp.Response.Request != nil && resp.Response.Request.URL != nil {
requestURL = resp.Response.Request.URL.String()
if resp.Request != nil && resp.Request.URL != nil {
requestURL = resp.Request.URL.String()
}
if resp.Body != nil {
body, _ = io.ReadAll(io.LimitReader(resp.Body, 2<<20))

View File

@@ -134,6 +134,7 @@ type UserIdentitySummary struct {
BindStartPath string `json:"bind_start_path,omitempty"`
CanBind bool `json:"can_bind"`
CanUnbind bool `json:"can_unbind"`
NoteKey string `json:"note_key,omitempty"`
Note string `json:"note,omitempty"`
}
@@ -156,6 +157,12 @@ type StartUserIdentityBindingResult struct {
UseBrowserRedirect bool `json:"use_browser_redirect"`
}
const (
userIdentityNoteEmailManagedFromProfile = "profile.authBindings.notes.emailManagedFromProfile"
userIdentityNoteCanUnbind = "profile.authBindings.notes.canUnbind"
userIdentityNoteBindAnotherBeforeUnbind = "profile.authBindings.notes.bindAnotherBeforeUnbind"
)
// UpdateProfileRequest 更新用户资料请求
type UpdateProfileRequest struct {
Email *string `json:"email"`
@@ -601,6 +608,7 @@ func (s *UserService) buildEmailIdentitySummary(user *User, records []UserAuthId
Provider: "email",
CanBind: false,
CanUnbind: false,
NoteKey: userIdentityNoteEmailManagedFromProfile,
Note: "Primary account email is managed from the profile form.",
}
if user == nil {
@@ -668,8 +676,10 @@ func (s *UserService) buildProviderIdentitySummary(provider string, user *User,
summary.VerifiedAt = primary.VerifiedAt
summary.CanUnbind = s.canUnbindProvider(provider, user, records)
if summary.CanUnbind {
summary.NoteKey = userIdentityNoteCanUnbind
summary.Note = "You can unbind this sign-in method."
} else {
summary.NoteKey = userIdentityNoteBindAnotherBeforeUnbind
summary.Note = "Bind another sign-in method before unbinding."
}
return summary

View File

@@ -0,0 +1,243 @@
# ADMIN_PAYMENT_INTEGRATION_API
> 单文件中英双语文档 / Single-file bilingual documentation (Chinese + English)
---
## 中文
### 目标
本文档用于对接外部支付系统(如 `sub2apipay`)与 Sub2API 的 Admin API覆盖
- 支付成功后充值
- 用户查询
- 人工余额修正
- 前端购买页参数透传
### 基础地址
- 生产:`https://<your-domain>`
- Beta`http://<your-server-ip>:8084`
### 认证
推荐使用:
- `x-api-key: admin-<64hex>`
- `Content-Type: application/json`
- 幂等接口额外传:`Idempotency-Key`
说明:管理员 JWT 也可访问 admin 路由,但服务间调用建议使用 Admin API Key。
### 1) 一步完成创建并兑换
`POST /api/v1/admin/redeem-codes/create-and-redeem`
用途:原子完成“创建兑换码 + 兑换到指定用户”。
请求头:
- `x-api-key`
- `Idempotency-Key`
请求体示例:
```json
{
"code": "s2p_cm1234567890",
"type": "balance",
"value": 100.0,
"user_id": 123,
"notes": "sub2apipay order: cm1234567890"
}
```
幂等语义:
-`code``used_by` 一致:`200`
-`code``used_by` 不一致:`409`
- 缺少 `Idempotency-Key``400``IDEMPOTENCY_KEY_REQUIRED`
curl 示例:
```bash
curl -X POST "${BASE}/api/v1/admin/redeem-codes/create-and-redeem" \
-H "x-api-key: ${KEY}" \
-H "Idempotency-Key: pay-cm1234567890-success" \
-H "Content-Type: application/json" \
-d '{
"code":"s2p_cm1234567890",
"type":"balance",
"value":100.00,
"user_id":123,
"notes":"sub2apipay order: cm1234567890"
}'
```
### 2) 查询用户(可选前置校验)
`GET /api/v1/admin/users/:id`
```bash
curl -s "${BASE}/api/v1/admin/users/123" \
-H "x-api-key: ${KEY}"
```
### 3) 余额调整(已有接口)
`POST /api/v1/admin/users/:id/balance`
用途:人工补偿 / 扣减,支持 `set` / `add` / `subtract`
请求体示例(扣减):
```json
{
"balance": 100.0,
"operation": "subtract",
"notes": "manual correction"
}
```
```bash
curl -X POST "${BASE}/api/v1/admin/users/123/balance" \
-H "x-api-key: ${KEY}" \
-H "Idempotency-Key: balance-subtract-cm1234567890" \
-H "Content-Type: application/json" \
-d '{
"balance":100.00,
"operation":"subtract",
"notes":"manual correction"
}'
```
### 4) 购买页 / 自定义页面 URL Query 透传iframe / 新窗口一致)
当 Sub2API 打开 `purchase_subscription_url` 或用户侧自定义页面 iframe URL 时,会统一追加:
- `user_id`
- `token`
- `theme``light` / `dark`
- `lang`(例如 `zh` / `en`,用于向嵌入页传递当前界面语言)
- `ui_mode`(固定 `embedded`
示例:
```text
https://pay.example.com/pay?user_id=123&token=<jwt>&theme=light&lang=zh&ui_mode=embedded
```
### 5) 失败处理建议
- 支付成功与充值成功分状态落库
- 回调验签成功后立即标记“支付成功”
- 支付成功但充值失败的订单允许后续重试
- 重试保持相同 `code`,并使用新的 `Idempotency-Key`
### 6) `doc_url` 配置建议
- 查看链接:`https://github.com/Wei-Shaw/sub2api/blob/main/ADMIN_PAYMENT_INTEGRATION_API.md`
- 下载链接:`https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/ADMIN_PAYMENT_INTEGRATION_API.md`
---
## English
### Purpose
This document describes the minimal Sub2API Admin API surface for external payment integrations (for example, `sub2apipay`), including:
- Recharge after payment success
- User lookup
- Manual balance correction
- Purchase page query parameter forwarding
### Base URL
- Production: `https://<your-domain>`
- Beta: `http://<your-server-ip>:8084`
### Authentication
Recommended headers:
- `x-api-key: admin-<64hex>`
- `Content-Type: application/json`
- `Idempotency-Key` for idempotent endpoints
Note: Admin JWT can also access admin routes, but Admin API Key is recommended for server-to-server integration.
### 1) Create and Redeem in one step
`POST /api/v1/admin/redeem-codes/create-and-redeem`
Use case: atomically create a redeem code and redeem it to a target user.
Headers:
- `x-api-key`
- `Idempotency-Key`
Request body:
```json
{
"code": "s2p_cm1234567890",
"type": "balance",
"value": 100.0,
"user_id": 123,
"notes": "sub2apipay order: cm1234567890"
}
```
Idempotency behavior:
- Same `code` and same `used_by`: `200`
- Same `code` but different `used_by`: `409`
- Missing `Idempotency-Key`: `400` (`IDEMPOTENCY_KEY_REQUIRED`)
curl example:
```bash
curl -X POST "${BASE}/api/v1/admin/redeem-codes/create-and-redeem" \
-H "x-api-key: ${KEY}" \
-H "Idempotency-Key: pay-cm1234567890-success" \
-H "Content-Type: application/json" \
-d '{
"code":"s2p_cm1234567890",
"type":"balance",
"value":100.00,
"user_id":123,
"notes":"sub2apipay order: cm1234567890"
}'
```
### 2) Query User (optional pre-check)
`GET /api/v1/admin/users/:id`
```bash
curl -s "${BASE}/api/v1/admin/users/123" \
-H "x-api-key: ${KEY}"
```
### 3) Balance Adjustment (existing API)
`POST /api/v1/admin/users/:id/balance`
Use case: manual correction with `set` / `add` / `subtract`.
Request body example (`subtract`):
```json
{
"balance": 100.0,
"operation": "subtract",
"notes": "manual correction"
}
```
```bash
curl -X POST "${BASE}/api/v1/admin/users/123/balance" \
-H "x-api-key: ${KEY}" \
-H "Idempotency-Key: balance-subtract-cm1234567890" \
-H "Content-Type: application/json" \
-d '{
"balance":100.00,
"operation":"subtract",
"notes":"manual correction"
}'
```
### 4) Purchase / Custom Page URL query forwarding (iframe and new tab)
When Sub2API opens `purchase_subscription_url` or a user-facing custom page iframe URL, it appends:
- `user_id`
- `token`
- `theme` (`light` / `dark`)
- `lang` (for example `zh` / `en`, used to pass the current UI language to the embedded page)
- `ui_mode` (fixed: `embedded`)
Example:
```text
https://pay.example.com/pay?user_id=123&token=<jwt>&theme=light&lang=zh&ui_mode=embedded
```
### 5) Failure handling recommendations
- Persist payment success and recharge success as separate states
- Mark payment as successful immediately after verified callback
- Allow retry for orders with payment success but recharge failure
- Keep the same `code` for retry, and use a new `Idempotency-Key`
### 6) Recommended `doc_url`
- View URL: `https://github.com/Wei-Shaw/sub2api/blob/main/ADMIN_PAYMENT_INTEGRATION_API.md`
- Download URL: `https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/ADMIN_PAYMENT_INTEGRATION_API.md`

287
docs/PAYMENT.md Normal file
View File

@@ -0,0 +1,287 @@
# Payment System Configuration Guide
Sub2API has a built-in payment system that enables user self-service top-up without deploying a separate payment service.
---
## Table of Contents
- [Supported Payment Methods](#supported-payment-methods)
- [Quick Start](#quick-start)
- [System Settings](#system-settings)
- [Provider Configuration](#provider-configuration)
- [Provider Instance Management](#provider-instance-management)
- [Webhook Configuration](#webhook-configuration)
- [Payment Flow](#payment-flow)
- [Migrating from Sub2ApiPay](#migrating-from-sub2apipay)
---
## Supported Payment Methods
| Provider | Payment Methods | Description |
|----------|----------------|-------------|
| **EasyPay** | Alipay, WeChat Pay | Third-party aggregation via EasyPay protocol |
| **Alipay (Direct)** | Desktop QR code, mobile Alipay redirect | Direct integration with Alipay Open Platform, returning desktop QR codes and mobile WAP/app launch links |
| **WeChat Pay (Direct)** | Native QR, H5, MP/JSAPI Pay | Direct integration with WeChat Pay APIv3 with environment-aware routing |
| **Stripe** | Card, Alipay, WeChat Pay, Link, etc. | International payments, multi-currency support |
> Alipay/WeChat Pay direct and EasyPay can both exist as backend provider instances, but the frontend always exposes only two visible buttons: `Alipay` and `WeChat Pay`. Admins choose exactly one source for each visible method: direct or EasyPay. Direct channels connect to payment APIs directly with lower fees; EasyPay aggregates through third-party platforms with easier setup.
> **EasyPay Provider Recommendations**: Both options below are third-party aggregators compatible with the EasyPay protocol. Pick based on the funding channel and settlement currency you need:
>
> - **Domestic channel / CNY settlement** — [ZPay](https://z-pay.cn/?uid=23808) (`https://z-pay.cn/?uid=23808`): direct integration with official Alipay / WeChat Pay APIs, fee **1.6%**; funds go straight to the merchant account with **T+1 automatic settlement**. Supports **individual users** (no business license required) with up to 10,000 CNY daily transactions; business-licensed accounts have no limit. Link contains the referral code of [Sub2ApiPay](https://github.com/touwaeriol/sub2apipay) original author [@touwaeriol](https://github.com/touwaeriol) — feel free to remove it.
> - **International channel / USDT or USD settlement** — [Kyren Topup](https://kyren.top/?code=SUB2API) (`https://kyren.top/?code=SUB2API`): a ready-to-launch global payment stack for AI startups with WeChat Pay and Alipay support, local-currency checkout, and USD settlement. Fees: WeChat 2%, Alipay 2.5%; withdrawal 0.1% (min $40, max $150), settled in **USDT or USD**. No qualification review required — sign up and use immediately, making it the lowest barrier to entry. Withdrawal threshold is relatively high, recommended for users **who do not use domestic Chinese payment channels, cannot tolerate Stripe's 6%+ fees, have high transaction volume, and have USD or USDT channels to receive withdrawn funds**. Kyren Topup charges a $200 account opening fee; signing up via this link (which contains Sub2Api author [@Wei-Shaw](https://github.com/Wei-Shaw)'s referral code) **waives the opening fee**. Feel free to remove it if you prefer.
>
> Please evaluate the security, reliability, and compliance of any third-party payment provider on your own — this project does not endorse or guarantee any of them.
---
## Quick Start
1. Go to Admin Dashboard → **Settings****Payment Settings** tab
2. Enable **Payment**
3. Configure basic parameters (amount range, timeout, etc.)
4. Add at least one provider instance in **Provider Management**
5. Users can now top up from the frontend
---
## System Settings
Configure the following in Admin Dashboard **Settings → Payment Settings**:
### Basic Settings
| Setting | Description | Default |
|---------|-------------|---------|
| **Enable Payment** | Enable or disable the payment system | Off |
| **Product Name Prefix** | Prefix shown on payment page | - |
| **Product Name Suffix** | Suffix (e.g., "Credits") | - |
| **Minimum Amount** | Minimum single top-up amount | 1 |
| **Maximum Amount** | Maximum single top-up amount (empty = unlimited) | - |
| **Daily Limit** | Per-user daily cumulative limit (empty = unlimited) | - |
| **Order Timeout** | Order timeout in minutes (minimum 1) | 30 |
| **Max Pending Orders** | Maximum concurrent pending orders per user | 3 |
| **Load Balance Strategy** | Strategy for selecting provider instances | Round Robin |
### Frontend Visible Method Routing
The current payment UX keeps the frontend method list unified and does not expose provider brands directly:
- **Alipay**: when enabled, this button must be routed to either `Alipay (Direct)` or `EasyPay Alipay`
- **WeChat Pay**: when enabled, this button must be routed to either `WeChat Pay (Direct)` or `EasyPay WeChat`
- Each visible method can route to only one source at a time
- If a visible method is enabled without a selected source, the frontend will not expose that method
### Load Balance Strategies
| Strategy | Description |
|----------|-------------|
| **Round Robin** | Distribute orders to instances in rotation |
| **Least Amount** | Prefer instances with the lowest daily cumulative amount |
### Cancel Rate Limiting
Prevents users from repeatedly creating and canceling orders:
| Setting | Description |
|---------|-------------|
| **Enable Limit** | Toggle |
| **Window Mode** | Sliding / Fixed window |
| **Time Window** | Window duration |
| **Window Unit** | Minutes / Hours |
| **Max Cancels** | Maximum cancellations allowed within the window |
### Help Information
| Setting | Description |
|---------|-------------|
| **Help Image** | Customer service QR code or help image (supports upload) |
| **Help Text** | Instructions displayed on the payment page |
---
## Provider Configuration
Each provider type requires different credentials. Select the type when adding a new provider instance in **Provider Management → Add Provider**.
> **Callback URLs are auto-generated**: When adding a provider, the Notify URL and Return URL are automatically constructed from your site domain. You only need to confirm the domain is correct.
### EasyPay
Compatible with any payment service that implements the EasyPay protocol.
| Parameter | Description | Required |
|-----------|-------------|----------|
| **Merchant ID (PID)** | EasyPay merchant ID | Yes |
| **Merchant Key (PKey)** | EasyPay merchant secret key | Yes |
| **API Base URL** | EasyPay API base address | Yes |
| **Alipay Channel ID** | Specify Alipay channel (optional) | No |
| **WeChat Channel ID** | Specify WeChat channel (optional) | No |
### Alipay (Direct)
Direct integration with Alipay Open Platform. Desktop flows return a QR code for in-page display, while mobile flows return an Alipay WAP/app redirect URL.
| Parameter | Description | Required |
|-----------|-------------|----------|
| **AppID** | Alipay application AppID | Yes |
| **Private Key** | RSA2 application private key | Yes |
| **Alipay Public Key** | Alipay public key | Yes |
### WeChat Pay (Direct)
Direct integration with WeChat Pay APIv3. Supports Native QR code payment, H5 payment, and MP/JSAPI payment inside the WeChat environment.
| Parameter | Description | Required |
|-----------|-------------|----------|
| **AppID** | WeChat Pay AppID | Yes |
| **Merchant ID (MchID)** | WeChat Pay merchant ID | Yes |
| **Merchant API Private Key** | Merchant API private key (PEM format) | Yes |
| **APIv3 Key** | 32-byte APIv3 key | Yes |
| **WeChat Pay Public Key** | WeChat Pay public key (PEM format) | Yes |
| **WeChat Pay Public Key ID** | WeChat Pay public key ID | Yes |
| **Certificate Serial Number** | Merchant certificate serial number | Yes |
### Stripe
International payment platform supporting multiple payment methods and currencies.
| Parameter | Description | Required |
|-----------|-------------|----------|
| **Secret Key** | Stripe secret key (`sk_live_...` or `sk_test_...`) | Yes |
| **Publishable Key** | Stripe publishable key (`pk_live_...` or `pk_test_...`) | Yes |
| **Webhook Secret** | Stripe Webhook signing secret (`whsec_...`) | Yes |
---
## Provider Instance Management
You can create **multiple instances** of the same provider type for load balancing and risk control:
- **Multi-instance load balancing** — Distribute orders via round-robin or least-amount strategy
- **Independent limits** — Each instance can have its own min/max amount and daily limit
- **Independent toggle** — Enable/disable individual instances without affecting others
- **Refund control** — Enable or disable refunds per instance
- **Payment methods** — Each instance can support a subset of payment methods
- **Ordering** — Drag to reorder instances
### Instance Limit Configuration
Each instance supports these limits:
| Limit | Description |
|-------|-------------|
| **Minimum Amount** | Minimum order amount accepted by this instance |
| **Maximum Amount** | Maximum order amount accepted by this instance |
| **Daily Limit** | Daily cumulative transaction limit for this instance |
> During load balancing, instances that exceed their limits are automatically skipped.
---
## Webhook Configuration
Payment callbacks are essential for the payment system to work correctly.
### Callback URL Format
When adding a provider, the system auto-generates callback URLs from your site domain:
| Provider | Callback Path |
|----------|-------------|
| **EasyPay** | `https://your-domain.com/api/v1/payment/webhook/easypay` |
| **Alipay (Direct)** | `https://your-domain.com/api/v1/payment/webhook/alipay` |
| **WeChat Pay (Direct)** | `https://your-domain.com/api/v1/payment/webhook/wxpay` |
| **Stripe** | `https://your-domain.com/api/v1/payment/webhook/stripe` |
> Replace `your-domain.com` with your actual domain. For EasyPay / Alipay / WeChat Pay, the callback URL is auto-filled when adding the provider — no manual configuration needed.
### Stripe Webhook Setup
1. Log in to [Stripe Dashboard](https://dashboard.stripe.com/)
2. Go to **Developers → Webhooks**
3. Add an endpoint with the callback URL
4. Subscribe to events: `payment_intent.succeeded`, `payment_intent.payment_failed`
5. Copy the generated Webhook Secret (`whsec_...`) to your provider configuration
### Important Notes
- Callback URLs must use **HTTPS** (required by Stripe, strongly recommended for others)
- Ensure your firewall allows callback requests from payment platforms
- The system automatically verifies callback signatures to prevent forgery
- Balance top-up is processed automatically upon successful payment — no manual intervention needed
---
## Payment Flow
```
User selects amount and payment method
Create Order (PENDING)
├─ Validate amount range, pending order count, daily limit
├─ Load balance to select provider instance
└─ Call provider to get payment info
User completes payment
├─ EasyPay → QR code / H5 redirect
├─ Alipay → Desktop QR / mobile Alipay redirect
├─ WeChat Pay → Desktop Native QR / non-WeChat H5 / in-WeChat JSAPI
└─ Stripe → Payment Element (card/Alipay/WeChat/etc.)
Webhook callback verified → Order PAID
Auto top-up to user balance → Order COMPLETED
```
### Order Status Reference
| Status | Description |
|--------|-------------|
| `PENDING` | Waiting for user to complete payment |
| `PAID` | Payment confirmed, awaiting balance credit |
| `COMPLETED` | Balance credited successfully |
| `EXPIRED` | Timed out without payment |
| `CANCELLED` | Cancelled by user |
| `FAILED` | Balance credit failed, admin can retry |
| `REFUND_REQUESTED` | Refund requested |
| `REFUNDING` | Refund in progress |
| `REFUNDED` | Refund completed |
### Timeout and Fallback
- Before marking an order as expired, the background job queries the upstream payment status first
- If the user has actually paid but the callback was delayed, the system will reconcile automatically
- The background job runs every 60 seconds to check for timed-out orders
---
## Migrating from Sub2ApiPay
If you previously used [Sub2ApiPay](https://github.com/touwaeriol/sub2apipay) as an external payment system, you can migrate to the built-in payment system:
### Key Differences
| Aspect | Sub2ApiPay | Built-in Payment |
|--------|-----------|-----------------|
| Deployment | Separate service (Next.js + PostgreSQL) | Built into Sub2API, no extra deployment |
| Payment Methods | EasyPay, Alipay, WeChat, Stripe | Same |
| Configuration | Environment variables + separate admin UI | Unified in Sub2API admin dashboard |
| Top-up Integration | Via Admin API callback | Internal processing, more reliable |
| Subscription Plans | Supported | Not yet (planned) |
| Order Management | Separate admin interface | Integrated in Sub2API admin dashboard |
### Migration Steps
1. Enable payment in Sub2API admin dashboard and configure providers (use the same payment credentials)
2. Update webhook callback URLs to Sub2API's callback endpoints
3. Verify that new orders are processed correctly via built-in payment
4. Decommission the Sub2ApiPay service
> **Note**: Historical order data from Sub2ApiPay will not be automatically migrated. Keep Sub2ApiPay running for a while to access historical records.

287
docs/PAYMENT_CN.md Normal file
View File

@@ -0,0 +1,287 @@
# 支付系统配置指南
Sub2API 内置支付系统,支持用户自助充值,无需部署独立的支付服务。
---
## 目录
- [支持的支付方式](#支持的支付方式)
- [快速开始](#快速开始)
- [系统设置](#系统设置)
- [服务商配置](#服务商配置)
- [服务商实例管理](#服务商实例管理)
- [Webhook 配置](#webhook-配置)
- [支付流程](#支付流程)
- [从 Sub2ApiPay 迁移](#从-sub2apipay-迁移)
---
## 支持的支付方式
| 服务商 | 支付方式 | 说明 |
|--------|---------|------|
| **EasyPay易支付** | 支付宝、微信支付 | 兼容易支付协议的第三方聚合支付 |
| **支付宝官方** | 桌面二维码扫码、移动端支付宝跳转 | 直接对接支付宝开放平台,桌面端返回二维码,移动端返回 WAP/唤起链接 |
| **微信官方** | Native 扫码、H5、公众号/JSAPI 支付 | 直接对接微信支付 APIv3按终端环境自动分流 |
| **Stripe** | 银行卡、支付宝、微信支付、Link 等 | 国际支付,支持多币种 |
> 支付宝官方 / 微信官方与易支付可以同时作为后台服务商实例存在,但前台始终只展示 `支付宝`、`微信支付` 两个可见按钮。管理员需要分别为这两个按钮选择唯一支付来源:官方或易支付。官方渠道直接对接 API资金直达商户账户手续费更低易支付通过第三方平台聚合接入门槛更低。
> **易支付服务商推荐**:以下两家均为兼容易支付协议的第三方聚合支付,按资金通道与结算方式选择:
>
> - **国内渠道 / 人民币结算** — [ZPay](https://z-pay.cn/?uid=23808)`https://z-pay.cn/?uid=23808`):支付宝 / 微信官方 API 直连,手续费 **1.6%**;资金直达商家账户,**T+1 自动到账**。支持**个人用户**(无营业执照)每日 1 万元以内交易;拥有营业执照则无限额。链接含 [Sub2ApiPay](https://github.com/touwaeriol/sub2apipay) 原作者 [@touwaeriol](https://github.com/touwaeriol) 的邀请码,介意可去掉。
> - **国际渠道 / USDT 或美元结算** — [启润支付](https://kyren.top/?code=SUB2API)`https://kyren.top/?code=SUB2API`):为 AI 项目提供低门槛国际收款通道,支持国际版微信支付与支付宝,本地货币支付、美元结算。手续费:微信 2%、支付宝 2.5%;提现 0.1%(最低 40 美元、最高 150 美元),以 **USDT 或美元**到账。无资质审核、注册即用,使用门槛最低;提现门槛略高,适合**不使用国内支付渠道、无法接受 Stripe 高达 6%+ 手续费、流水较大,且拥有美元或 USDT 渠道可接收提现资金**的用户。启润支付开户费 200 美元,通过本链接注册(含 Sub2Api 作者 [@Wei-Shaw](https://github.com/Wei-Shaw) 邀请码)可**免开户费**,介意可去掉。
>
> 支付渠道的安全性、稳定性及合规性请自行鉴别,本项目不对任何第三方支付服务商做担保或背书。
---
## 快速开始
1. 进入管理后台 → **设置****支付设置** 标签页
2. 开启 **启用支付**
3. 配置基本参数(金额范围、超时时间等)
4.**服务商管理** 中添加至少一个服务商实例
5. 用户即可在前端页面进行充值
---
## 系统设置
在管理后台 **设置 → 支付设置** 中配置以下参数:
### 基本设置
| 设置项 | 说明 | 默认值 |
|--------|------|--------|
| **启用支付** | 启用或禁用支付系统 | 关闭 |
| **商品名前缀** | 支付页面显示的商品名前缀 | - |
| **商品名后缀** | 商品名后缀(如"元" | - |
| **最低金额** | 单笔最低充值金额 | 1 |
| **最高金额** | 单笔最高充值金额(留空表示不限制) | - |
| **每日限额** | 每用户每日累计充值上限(留空表示不限制) | - |
| **订单超时时间** | 订单超时分钟数,至少 1 分钟 | 30 |
| **最大待支付订单数** | 同一用户最大并行待支付订单数 | 3 |
| **负载均衡策略** | 多服务商实例时的选择策略 | 轮询 |
### 前台可见支付方式路由
当前版本对用户统一展示支付方式,不区分官方渠道还是易支付:
- **支付宝**:后台启用后,需要额外指定该按钮路由到 `支付宝官方``易支付支付宝`
- **微信支付**:后台启用后,需要额外指定该按钮路由到 `微信官方``易支付微信`
- 同一个可见支付方式在同一时刻只能路由到一个来源
- 支付来源未选择时,即使对应按钮被开启,前台也不会暴露该支付方式
### 负载均衡策略
| 策略 | 说明 |
|------|------|
| **轮询round-robin** | 按顺序轮流分配到各服务商实例 |
| **最少金额least-amount** | 优先分配到当日累计金额最少的实例 |
### 取消频率限制
防止用户频繁创建并取消订单:
| 设置项 | 说明 |
|--------|------|
| **启用限制** | 开关 |
| **窗口模式** | 滚动窗口 / 固定窗口 |
| **时间窗口** | 窗口长度 |
| **窗口单位** | 分钟 / 小时 |
| **最大次数** | 窗口内允许的最大取消次数 |
### 帮助信息
| 设置项 | 说明 |
|--------|------|
| **帮助图片** | 充值页面显示的客服二维码等图片(支持上传) |
| **帮助文本** | 充值页面显示的说明文字 |
---
## 服务商配置
每种服务商需要不同的凭证和参数。在 **服务商管理 → 添加服务商** 中选择类型后填写。
> **回调地址自动生成**添加服务商时异步回调地址Notify URL和同步跳转地址Return URL由系统根据你的站点域名自动拼接无需手动填写。管理员只需确认域名正确即可。
### EasyPay易支付
兼容任何 EasyPay 协议的支付服务商。
| 参数 | 说明 | 必填 |
|------|------|------|
| **商户 IDPID** | EasyPay 商户 ID | 是 |
| **商户密钥PKey** | EasyPay 商户密钥 | 是 |
| **API 地址** | EasyPay API 基础地址 | 是 |
| **支付宝通道 ID** | 指定支付宝通道(可选) | 否 |
| **微信通道 ID** | 指定微信通道(可选) | 否 |
### 支付宝官方
直接对接支付宝开放平台。桌面端返回二维码供页面内展示和扫码,移动端返回支付宝手机网站支付跳转链接。
| 参数 | 说明 | 必填 |
|------|------|------|
| **AppID** | 支付宝应用 AppID | 是 |
| **应用私钥** | RSA2 应用私钥 | 是 |
| **支付宝公钥** | 支付宝公钥 | 是 |
### 微信官方
直接对接微信支付 APIv3支持 Native 扫码支付、H5 支付,以及在微信环境内的公众号/JSAPI 支付。
| 参数 | 说明 | 必填 |
|------|------|------|
| **AppID** | 微信支付 AppID | 是 |
| **商户号MchID** | 微信支付商户号 | 是 |
| **商户 API 私钥** | 商户 API 私钥PEM 格式) | 是 |
| **APIv3 密钥** | 32 位 APIv3 密钥 | 是 |
| **微信支付公钥** | 微信支付公钥PEM 格式) | 是 |
| **微信支付公钥 ID** | 微信支付公钥 ID | 是 |
| **商户证书序列号** | 商户证书序列号 | 是 |
### Stripe
国际支付平台,支持多种支付方式和币种。
| 参数 | 说明 | 必填 |
|------|------|------|
| **Secret Key** | Stripe 密钥(`sk_live_...``sk_test_...` | 是 |
| **Publishable Key** | Stripe 可公开密钥(`pk_live_...``pk_test_...` | 是 |
| **Webhook Secret** | Stripe Webhook 签名密钥(`whsec_...` | 是 |
---
## 服务商实例管理
同一种服务商可以创建**多个实例**,实现负载均衡和风控:
- **多实例负载均衡** — 按轮询或最少金额策略分流订单
- **独立限额** — 每个实例可独立配置单笔最小/最大金额和每日限额
- **独立启停** — 可单独启用/禁用某个实例,不影响其他实例
- **退款控制** — 每个实例可单独开启或关闭退款功能
- **支付方式** — 每个实例可选择支持的支付方式子集
- **排序** — 拖拽调整实例顺序
### 实例限额配置
每个实例支持以下限额:
| 限额项 | 说明 |
|--------|------|
| **单笔最小金额** | 该实例接受的最小订单金额 |
| **单笔最大金额** | 该实例接受的最大订单金额 |
| **每日限额** | 该实例每日累计交易上限 |
> 负载均衡时,系统会自动跳过超出限额的实例。
---
## Webhook 配置
支付回调是支付系统的核心环节,必须正确配置:
### 回调地址格式
添加服务商时,系统会自动根据站点域名拼接回调地址,格式如下:
| 服务商 | 回调路径 |
|--------|---------|
| **EasyPay** | `https://your-domain.com/api/v1/payment/webhook/easypay` |
| **支付宝官方** | `https://your-domain.com/api/v1/payment/webhook/alipay` |
| **微信官方** | `https://your-domain.com/api/v1/payment/webhook/wxpay` |
| **Stripe** | `https://your-domain.com/api/v1/payment/webhook/stripe` |
> 将 `your-domain.com` 替换为你的实际域名。EasyPay / 支付宝 / 微信的回调地址在添加服务商时自动填入,无需手动配置。
### Stripe Webhook 设置
1. 登录 [Stripe Dashboard](https://dashboard.stripe.com/)
2. 进入 **Developers → Webhooks**
3. 添加端点,填写回调地址
4. 订阅事件:`payment_intent.succeeded``payment_intent.payment_failed`
5. 将生成的 Webhook Secret`whsec_...`)填入服务商配置
### 注意事项
- 回调地址必须是 **HTTPS**Stripe 强制要求,其他服务商强烈推荐)
- 确保服务器防火墙允许支付平台的回调请求
- 系统会自动进行签名验证,防止伪造回调
- 支付成功后自动完成余额充值,无需人工干预
---
## 支付流程
```
用户选择充值金额和支付方式
创建订单 (PENDING)
├─ 校验金额范围、待支付订单数、每日限额
├─ 负载均衡选择服务商实例
└─ 调用服务商获取支付信息
用户完成支付
├─ EasyPay → 扫码 / H5 跳转
├─ 支付宝官方 → 桌面二维码 / 移动端支付宝跳转
├─ 微信官方 → 桌面 Native 扫码 / 非微信 H5 / 微信内 JSAPI
└─ Stripe → Payment Element银行卡/支付宝/微信等)
支付回调验签 → 订单 PAID
自动充值到用户余额 → 订单 COMPLETED
```
### 订单状态说明
| 状态 | 说明 |
|------|------|
| `PENDING` | 待支付,等待用户完成支付 |
| `PAID` | 已支付,等待充值到账 |
| `COMPLETED` | 已完成,余额已到账 |
| `EXPIRED` | 已过期,超时未支付 |
| `CANCELLED` | 已取消,用户主动取消 |
| `FAILED` | 充值失败,可管理员重试 |
| `REFUND_REQUESTED` | 已申请退款 |
| `REFUNDING` | 退款处理中 |
| `REFUNDED` | 已退款 |
### 超时与兜底
- 订单超时后,后台任务会先查询上游支付状态再标记过期
- 如果用户实际已支付但回调延迟,系统会通过查询补单
- 后台任务每 60 秒执行一次超时检查
---
## 从 Sub2ApiPay 迁移
如果你之前使用 [Sub2ApiPay](https://github.com/touwaeriol/sub2apipay) 作为外部支付系统,现在可以迁移到内置支付:
### 主要差异
| 对比项 | Sub2ApiPay | 内置支付 |
|--------|-----------|---------|
| 部署方式 | 独立服务Next.js + PostgreSQL | 内置于 Sub2API无需额外部署 |
| 支付方式 | EasyPay、支付宝、微信、Stripe | 相同 |
| 配置方式 | 环境变量 + 独立管理后台 | Sub2API 管理后台内统一配置 |
| 充值对接 | 通过 Admin API 回调 | 内部直接处理,更可靠 |
| 订阅套餐 | 支持 | 暂不支持(计划中) |
| 订单管理 | 独立管理界面 | 集成在 Sub2API 管理后台 |
### 迁移步骤
1. 在 Sub2API 管理后台启用支付并配置服务商(使用相同的支付凭证)
2. 更新 Webhook 回调地址为 Sub2API 的回调地址
3. 确认新订单通过内置支付正常处理
4. 停用 Sub2ApiPay 服务
> **注意**Sub2ApiPay 中的历史订单数据不会自动迁移。建议保留 Sub2ApiPay 一段时间以便查询历史记录。

View File

@@ -55,12 +55,12 @@
/>
</div>
<div v-if="supportsGeminiImageTest" class="space-y-1.5">
<div v-if="supportsImageTest" class="space-y-1.5">
<TextArea
v-model="testPrompt"
:label="t('admin.accounts.geminiImagePromptLabel')"
:placeholder="t('admin.accounts.geminiImagePromptPlaceholder')"
:hint="t('admin.accounts.geminiImageTestHint')"
:label="t('admin.accounts.imagePromptLabel')"
:placeholder="t('admin.accounts.imagePromptPlaceholder')"
:hint="t('admin.accounts.imageTestHint')"
:disabled="status === 'connecting'"
rows="3"
/>
@@ -122,25 +122,49 @@
<div v-if="generatedImages.length > 0" class="space-y-2">
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
{{ t('admin.accounts.geminiImagePreview') }}
{{ t('admin.accounts.imagePreview') }}
</div>
<div class="grid gap-3 sm:grid-cols-2">
<a
<div class="flex flex-wrap justify-center gap-3">
<div
v-for="(image, index) in generatedImages"
:key="`${image.url}-${index}`"
:href="image.url"
target="_blank"
rel="noopener noreferrer"
class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:border-primary-300 hover:shadow-md dark:border-dark-500 dark:bg-dark-700"
class="group/img relative cursor-pointer overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:border-primary-300 hover:shadow-md dark:border-dark-500 dark:bg-dark-700"
@click="previewImageUrl = image.url"
>
<img :src="image.url" :alt="`gemini-test-image-${index + 1}`" class="h-48 w-full object-cover" />
<div class="border-t border-gray-100 px-3 py-2 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
<img :src="image.url" :alt="`test-image-${index + 1}`" class="max-h-[360px] w-full object-contain" />
<div class="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover/img:bg-black/20">
<Icon name="eye" size="lg" class="text-white opacity-0 drop-shadow-lg transition-opacity group-hover/img:opacity-100" :stroke-width="2" />
</div>
<div class="border-t border-gray-100 px-3 py-1.5 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
{{ image.mimeType || 'image/*' }}
</div>
</a>
</div>
</div>
</div>
<!-- Image Lightbox -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="previewImageUrl"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4"
@click.self="previewImageUrl = ''"
>
<button
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
@click="previewImageUrl = ''"
>
<Icon name="x" size="lg" :stroke-width="2" />
</button>
<img
:src="previewImageUrl"
alt="preview"
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
/>
</div>
</Transition>
</Teleport>
<!-- Test Info -->
<div class="flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-3">
@@ -152,8 +176,8 @@
<span class="flex items-center gap-1">
<Icon name="chat" size="sm" :stroke-width="2" />
{{
supportsGeminiImageTest
? t('admin.accounts.geminiImageTestMode')
supportsImageTest
? t('admin.accounts.imageTestMode')
: t('admin.accounts.testPrompt')
}}
</span>
@@ -250,6 +274,7 @@ const testPrompt = ref('')
const loadingModels = ref(false)
let abortController: AbortController | null = null
const generatedImages = ref<PreviewImage[]>([])
const previewImageUrl = ref('')
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
const supportsGeminiImageTest = computed(() => {
const modelID = selectedModelId.value.toLowerCase()
@@ -258,6 +283,14 @@ const supportsGeminiImageTest = computed(() => {
return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey')
})
const supportsOpenAIImageTest = computed(() => {
const modelID = selectedModelId.value.toLowerCase()
if (!modelID.startsWith('gpt-image-')) return false
return props.account?.platform === 'openai'
})
const supportsImageTest = computed(() => supportsGeminiImageTest.value || supportsOpenAIImageTest.value)
const sortTestModels = (models: ClaudeModel[]) => {
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
@@ -284,8 +317,8 @@ watch(
)
watch(selectedModelId, () => {
if (supportsGeminiImageTest.value && !testPrompt.value.trim()) {
testPrompt.value = t('admin.accounts.geminiImagePromptDefault')
if (supportsImageTest.value && !testPrompt.value.trim()) {
testPrompt.value = t('admin.accounts.imagePromptDefault')
}
})
@@ -325,6 +358,7 @@ const resetState = () => {
streamingContent.value = ''
errorMessage.value = ''
generatedImages.value = []
previewImageUrl.value = ''
}
const handleClose = () => {
@@ -377,7 +411,7 @@ const startTest = async () => {
},
body: JSON.stringify({
model_id: selectedModelId.value,
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
prompt: supportsImageTest.value ? testPrompt.value.trim() : ''
}),
signal: abortController.signal
})
@@ -444,8 +478,8 @@ const handleEvent = (event: {
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
}
addLine(
supportsGeminiImageTest.value
? t('admin.accounts.sendingGeminiImageRequest')
supportsImageTest.value
? t('admin.accounts.sendingImageRequest')
: t('admin.accounts.sendingTestMessage'),
'text-gray-400'
)
@@ -466,7 +500,7 @@ const handleEvent = (event: {
url: event.image_url,
mimeType: event.mime_type
})
addLine(t('admin.accounts.geminiImageReceived', { count: generatedImages.value.length }), 'text-purple-300')
addLine(t('admin.accounts.imageReceived', { count: generatedImages.value.length }), 'text-purple-300')
}
break
@@ -500,3 +534,14 @@ const copyOutput = () => {
copyToClipboard(text, t('admin.accounts.outputCopied'))
}
</script>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -55,12 +55,12 @@
/>
</div>
<div v-if="supportsGeminiImageTest" class="space-y-1.5">
<div v-if="supportsImageTest" class="space-y-1.5">
<TextArea
v-model="testPrompt"
:label="t('admin.accounts.geminiImagePromptLabel')"
:placeholder="t('admin.accounts.geminiImagePromptPlaceholder')"
:hint="t('admin.accounts.geminiImageTestHint')"
:label="t('admin.accounts.imagePromptLabel')"
:placeholder="t('admin.accounts.imagePromptPlaceholder')"
:hint="t('admin.accounts.imageTestHint')"
:disabled="status === 'connecting'"
rows="3"
/>
@@ -122,25 +122,49 @@
<div v-if="generatedImages.length > 0" class="space-y-2">
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
{{ t('admin.accounts.geminiImagePreview') }}
{{ t('admin.accounts.imagePreview') }}
</div>
<div class="grid gap-3 sm:grid-cols-2">
<a
<div class="flex flex-wrap justify-center gap-3">
<div
v-for="(image, index) in generatedImages"
:key="`${image.url}-${index}`"
:href="image.url"
target="_blank"
rel="noopener noreferrer"
class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:border-primary-300 hover:shadow-md dark:border-dark-500 dark:bg-dark-700"
class="group/img relative cursor-pointer overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:border-primary-300 hover:shadow-md dark:border-dark-500 dark:bg-dark-700"
@click="previewImageUrl = image.url"
>
<img :src="image.url" :alt="`gemini-test-image-${index + 1}`" class="h-48 w-full object-cover" />
<div class="border-t border-gray-100 px-3 py-2 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
<img :src="image.url" :alt="`test-image-${index + 1}`" class="max-h-[360px] w-full object-contain" />
<div class="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover/img:bg-black/20">
<Icon name="eye" size="lg" class="text-white opacity-0 drop-shadow-lg transition-opacity group-hover/img:opacity-100" :stroke-width="2" />
</div>
<div class="border-t border-gray-100 px-3 py-1.5 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
{{ image.mimeType || 'image/*' }}
</div>
</a>
</div>
</div>
</div>
<!-- Image Lightbox -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="previewImageUrl"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4"
@click.self="previewImageUrl = ''"
>
<button
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
@click="previewImageUrl = ''"
>
<Icon name="x" size="lg" :stroke-width="2" />
</button>
<img
:src="previewImageUrl"
alt="preview"
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
/>
</div>
</Transition>
</Teleport>
<!-- Test Info -->
<div class="flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-3">
@@ -152,8 +176,8 @@
<span class="flex items-center gap-1">
<Icon name="chat" size="sm" :stroke-width="2" />
{{
supportsGeminiImageTest
? t('admin.accounts.geminiImageTestMode')
supportsImageTest
? t('admin.accounts.imageTestMode')
: t('admin.accounts.testPrompt')
}}
</span>
@@ -250,6 +274,7 @@ const testPrompt = ref('')
const loadingModels = ref(false)
let abortController: AbortController | null = null
const generatedImages = ref<PreviewImage[]>([])
const previewImageUrl = ref('')
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
const supportsGeminiImageTest = computed(() => {
const modelID = selectedModelId.value.toLowerCase()
@@ -258,6 +283,14 @@ const supportsGeminiImageTest = computed(() => {
return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey')
})
const supportsOpenAIImageTest = computed(() => {
const modelID = selectedModelId.value.toLowerCase()
if (!modelID.startsWith('gpt-image-')) return false
return props.account?.platform === 'openai'
})
const supportsImageTest = computed(() => supportsGeminiImageTest.value || supportsOpenAIImageTest.value)
const sortTestModels = (models: ClaudeModel[]) => {
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
@@ -284,8 +317,8 @@ watch(
)
watch(selectedModelId, () => {
if (supportsGeminiImageTest.value && !testPrompt.value.trim()) {
testPrompt.value = t('admin.accounts.geminiImagePromptDefault')
if (supportsImageTest.value && !testPrompt.value.trim()) {
testPrompt.value = t('admin.accounts.imagePromptDefault')
}
})
@@ -325,6 +358,7 @@ const resetState = () => {
streamingContent.value = ''
errorMessage.value = ''
generatedImages.value = []
previewImageUrl.value = ''
}
const handleClose = () => {
@@ -377,7 +411,7 @@ const startTest = async () => {
},
body: JSON.stringify({
model_id: selectedModelId.value,
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
prompt: supportsImageTest.value ? testPrompt.value.trim() : ''
}),
signal: abortController.signal
})
@@ -444,8 +478,8 @@ const handleEvent = (event: {
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
}
addLine(
supportsGeminiImageTest.value
? t('admin.accounts.sendingGeminiImageRequest')
supportsImageTest.value
? t('admin.accounts.sendingImageRequest')
: t('admin.accounts.sendingTestMessage'),
'text-gray-400'
)
@@ -466,7 +500,7 @@ const handleEvent = (event: {
url: event.image_url,
mimeType: event.mime_type
})
addLine(t('admin.accounts.geminiImageReceived', { count: generatedImages.value.length }), 'text-purple-300')
addLine(t('admin.accounts.imageReceived', { count: generatedImages.value.length }), 'text-purple-300')
}
break
@@ -500,3 +534,14 @@ const copyOutput = () => {
copyToClipboard(text, t('admin.accounts.outputCopied'))
}
</script>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -24,13 +24,13 @@ vi.mock('@/composables/useClipboard', () => ({
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
const messages: Record<string, string> = {
'admin.accounts.geminiImagePromptDefault': 'Generate a cute orange cat astronaut sticker on a clean pastel background.'
'admin.accounts.imagePromptDefault': 'Generate a cute orange cat astronaut sticker on a clean pastel background.'
}
return {
...actual,
useI18n: () => ({
t: (key: string, params?: Record<string, string | number>) => {
if (key === 'admin.accounts.geminiImageReceived' && params?.count) {
if (key === 'admin.accounts.imageReceived' && params?.count) {
return `received-${params.count}`
}
return messages[key] || key
@@ -140,7 +140,7 @@ describe('AccountTestModal', () => {
prompt: 'draw a tiny orange cat astronaut'
})
const preview = wrapper.find('img[alt="gemini-test-image-1"]')
const preview = wrapper.find('img[alt="test-image-1"]')
expect(preview.exists()).toBe(true)
expect(preview.attributes('src')).toBe('data:image/png;base64,QUJD')
})

View File

@@ -67,23 +67,23 @@
</p>
<div
v-if="item.details && (item.details.display_name || item.details.subject_hint || bindingCountLabel(item.details) || item.details.note)"
v-if="hasBindingDetails(item.provider, item.details)"
class="grid gap-1 text-sm text-gray-500 dark:text-gray-400"
>
<p
v-if="item.details.display_name"
v-if="item.provider !== 'email' && item.details?.display_name"
class="font-medium text-gray-700 dark:text-gray-200"
>
{{ item.details.display_name }}
</p>
<p v-if="item.details.subject_hint">
<p v-if="item.provider !== 'email' && item.details?.subject_hint">
{{ item.details.subject_hint }}
</p>
<p v-if="bindingCountLabel(item.details)">
{{ bindingCountLabel(item.details) }}
</p>
<p v-if="item.details.note">
{{ item.details.note }}
<p v-if="bindingNote(item.details)">
{{ bindingNote(item.details) }}
</p>
</div>
@@ -298,6 +298,13 @@ const emailSubmitActionLabel = computed(() =>
? t('profile.authBindings.confirmEmailReplaceAction')
: t('profile.authBindings.confirmEmailBindAction')
)
const legacyBindingNoteKeys: Record<string, string> = {
'Primary account email is managed from the profile form.':
'profile.authBindings.notes.emailManagedFromProfile',
'You can unbind this sign-in method.': 'profile.authBindings.notes.canUnbind',
'Bind another sign-in method before unbinding.':
'profile.authBindings.notes.bindAnotherBeforeUnbind',
}
function resolveLegacyCompatibleWeChatSettings(
settings: WeChatOAuthPublicSettings | null | undefined
@@ -489,6 +496,36 @@ function bindingCountLabel(details: UserAuthBindingStatus | null): string {
return t('profile.authBindings.boundCount', { count: details.bound_count })
}
function bindingNote(details: UserAuthBindingStatus | null): string {
if (!details) {
return ''
}
const noteKey = details.note_key?.trim() || legacyBindingNoteKeys[details.note?.trim() || ''] || ''
if (noteKey) {
const translated = t(noteKey)
if (translated !== noteKey) {
return translated
}
}
return details.note?.trim() || ''
}
function hasBindingDetails(
provider: UserAuthProvider,
details: UserAuthBindingStatus | null
): boolean {
if (!details) {
return false
}
const showsProviderIdentityDetails =
provider !== 'email' && Boolean(details.display_name || details.subject_hint)
return Boolean(showsProviderIdentityDetails || bindingCountLabel(details) || bindingNote(details))
}
function toggleEmailForm(): void {
isEmailFormExpanded.value = !isEmailFormExpanded.value
}

View File

@@ -64,6 +64,12 @@ vi.mock('vue-i18n', async (importOriginal) => {
if (key === 'profile.authBindings.codeSentTo') return `Code sent to ${params?.email || ''}`.trim()
if (key === 'profile.authBindings.bindSuccess') return 'Bind success'
if (key === 'profile.authBindings.replaceSuccess') return 'Primary email updated'
if (key === 'profile.authBindings.notes.emailManagedFromProfile')
return 'Primary email is managed in the profile form'
if (key === 'profile.authBindings.notes.canUnbind')
return 'You can unbind this sign-in method'
if (key === 'profile.authBindings.notes.bindAnotherBeforeUnbind')
return 'Bind another sign-in method before unbinding'
return key
},
}),
@@ -164,7 +170,7 @@ describe('ProfileIdentityBindingsSection', () => {
await wrapper.get('[data-testid="profile-binding-wechat-action"]').trigger('click')
expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/start?')
expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/bind/start?')
expect(locationState.current.href).toContain('mode=open')
expect(locationState.current.href).toContain('intent=bind_current_user')
expect(locationState.current.href).toContain('redirect=%2Fprofile')
@@ -219,7 +225,7 @@ describe('ProfileIdentityBindingsSection', () => {
await wrapper.get('[data-testid="profile-binding-wechat-action"]').trigger('click')
expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/start?')
expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/bind/start?')
expect(locationState.current.href).toContain('mode=open')
expect(locationState.current.href).toContain('intent=bind_current_user')
expect(locationState.current.href).toContain('redirect=%2Fprofile')
@@ -401,6 +407,36 @@ describe('ProfileIdentityBindingsSection', () => {
expect(wrapper.get('[data-testid="profile-binding-email-status"]').text()).toBe('Not bound')
})
it('shows the bound email only once and localizes the email management note', () => {
const wrapper = mount(ProfileIdentityBindingsSection, {
global: {
plugins: [pinia],
},
props: {
user: createUser({
email: 'alice@example.com',
email_bound: true,
auth_bindings: {
email: {
bound: true,
display_name: 'alice@example.com',
subject_hint: 'a***e@example.com',
note_key: 'profile.authBindings.notes.emailManagedFromProfile',
note: 'Primary account email is managed from the profile form.',
} as any,
},
}),
linuxdoEnabled: false,
oidcEnabled: false,
wechatEnabled: false,
},
})
expect(wrapper.text().match(/alice@example\.com/g)).toHaveLength(1)
expect(wrapper.text()).not.toContain('a***e@example.com')
expect(wrapper.text()).toContain('Primary email is managed in the profile form')
})
it('keeps the email form available for replacing a bound primary email', async () => {
userApiMocks.sendEmailBindingCode.mockResolvedValue(undefined)
userApiMocks.bindEmailIdentity.mockResolvedValue(
@@ -541,6 +577,36 @@ describe('ProfileIdentityBindingsSection', () => {
expect(wrapper.get('[data-testid="profile-binding-linuxdo-status"]').text()).toBe('Not bound')
})
it('localizes third-party unbind guidance from note_key', () => {
const wrapper = mount(ProfileIdentityBindingsSection, {
global: {
plugins: [pinia],
},
props: {
user: createUser({
email_bound: true,
linuxdo_bound: true,
auth_bindings: {
email: { bound: true },
linuxdo: {
bound: true,
display_name: 'linuxdo-handle',
note_key: 'profile.authBindings.notes.canUnbind',
note: 'You can unbind this sign-in method.',
can_unbind: true,
} as any,
},
}),
linuxdoEnabled: true,
oidcEnabled: false,
wechatEnabled: false,
},
})
expect(wrapper.text()).toContain('You can unbind this sign-in method')
expect(wrapper.text()).not.toContain('You can unbind this sign-in method.')
})
it('hides bind actions when provider details say bindable but the provider is disabled', () => {
const wrapper = mount(ProfileIdentityBindingsSection, {
global: {

View File

@@ -21,7 +21,9 @@ const openaiModels = [
// GPT-5.3 系列
'gpt-5.3-codex', 'gpt-5.3-codex-spark',
'chatgpt-4o-latest',
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview'
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview',
// GPT Image 系列
'gpt-image-1', 'gpt-image-1.5', 'gpt-image-2'
]
// Anthropic Claude

View File

@@ -1042,6 +1042,11 @@ export default {
oidc: '{providerName}',
wechat: 'WeChat',
},
notes: {
emailManagedFromProfile: 'Primary email is managed in the profile form',
canUnbind: 'You can unbind this sign-in method',
bindAnotherBeforeUnbind: 'Bind another sign-in method before unbinding',
},
source: {
avatar: 'Avatar is currently synced from {providerName}',
username: 'Nickname is currently synced from {providerName}',
@@ -3023,7 +3028,7 @@ export default {
connectedToApi: 'Connected to API',
usingModel: 'Using model: {model}',
sendingTestMessage: 'Sending test message: "hi"',
sendingGeminiImageRequest: 'Sending Gemini image generation test request...',
sendingImageRequest: 'Sending image generation test request...',
response: 'Response:',
startTest: 'Start Test',
testing: 'Testing...',
@@ -3035,13 +3040,13 @@ export default {
selectTestModel: 'Select Test Model',
testModel: 'Test model',
testPrompt: 'Prompt: "hi"',
geminiImagePromptLabel: 'Image prompt',
geminiImagePromptPlaceholder: 'Example: Generate an orange cat astronaut sticker in pixel-art style on a solid background.',
geminiImagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
geminiImageTestHint: 'When a Gemini image model is selected, this test sends a real image-generation request and previews the returned image below.',
geminiImageTestMode: 'Mode: Gemini image generation test',
geminiImagePreview: 'Generated images:',
geminiImageReceived: 'Received test image #{count}',
imagePromptLabel: 'Image prompt',
imagePromptPlaceholder: 'Example: Generate an orange cat astronaut sticker in pixel-art style on a solid background.',
imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
imageTestHint: 'When an image model is selected, this test sends a real image-generation request and previews the returned image below.',
imageTestMode: 'Mode: Image generation test',
imagePreview: 'Generated images:',
imageReceived: 'Received test image #{count}',
// Stats Modal
viewStats: 'View Stats',
usageStatistics: 'Usage Statistics',

View File

@@ -1046,6 +1046,11 @@ export default {
oidc: '{providerName}',
wechat: '微信',
},
notes: {
emailManagedFromProfile: '主邮箱在资料表单中管理',
canUnbind: '你可以解绑这个登录方式。',
bindAnotherBeforeUnbind: '请先绑定其他登录方式,再解除当前绑定。',
},
source: {
avatar: '头像当前来自 {providerName}',
username: '昵称当前来自 {providerName}',
@@ -3154,7 +3159,7 @@ export default {
connectedToApi: '已连接到 API',
usingModel: '使用模型:{model}',
sendingTestMessage: '发送测试消息:"hi"',
sendingGeminiImageRequest: '发送 Gemini 生图测试请求...',
sendingImageRequest: '发送生图测试请求...',
response: '响应:',
startTest: '开始测试',
retry: '重试',
@@ -3165,13 +3170,13 @@ export default {
selectTestModel: '选择测试模型',
testModel: '测试模型',
testPrompt: '提示词:"hi"',
geminiImagePromptLabel: '生图提示词',
geminiImagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。',
geminiImagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
geminiImageTestHint: '选择 Gemini 图片模型后,这里会直接发起生图测试,并在下方展示返回图片。',
geminiImageTestMode: '模式:Gemini 生图测试',
geminiImagePreview: '生成结果:',
geminiImageReceived: '已收到第 {count} 张测试图片',
imagePromptLabel: '生图提示词',
imagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。',
imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
imageTestHint: '选择图片模型后,这里会直接发起生图测试,并在下方展示返回图片。',
imageTestMode: '模式:生图测试',
imagePreview: '生成结果:',
imageReceived: '已收到第 {count} 张测试图片',
// Stats Modal
viewStats: '查看统计',
usageStatistics: '使用统计',

View File

@@ -51,6 +51,7 @@ export interface UserAuthBindingStatus {
bind_start_path?: string | null
can_bind?: boolean
can_unbind?: boolean
note_key?: string | null
note?: string | null
metadata?: Record<string, unknown>
}

View File

@@ -628,11 +628,12 @@
</div>
</div>
<!-- 图片生成计费配置antigravity 和 gemini 平台) -->
<!-- 图片生成计费配置 -->
<div
v-if="
createForm.platform === 'antigravity' ||
createForm.platform === 'gemini'
createForm.platform === 'gemini' ||
createForm.platform === 'openai'
"
class="border-t pt-4"
>
@@ -1750,11 +1751,12 @@
</div>
</div>
<!-- 图片生成计费配置antigravity 和 gemini 平台) -->
<!-- 图片生成计费配置 -->
<div
v-if="
editForm.platform === 'antigravity' ||
editForm.platform === 'gemini'
editForm.platform === 'gemini' ||
editForm.platform === 'openai'
"
class="border-t pt-4"
>

View File

@@ -4136,7 +4136,7 @@
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.payment.enabledPaymentTypesHint") }}
<a
:href="paymentGuideHref"
:href="paymentMethodsHref"
target="_blank"
rel="noopener noreferrer"
class="ml-1 text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300"
@@ -4723,8 +4723,14 @@ function localText(zh: string, en: string): string {
const paymentGuideHref = computed(() =>
locale.value.startsWith("zh")
? "https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98"
: "https://github.com/Wei-Shaw/sub2api/blob/main/README.md#payment",
? "https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md"
: "https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md",
);
const paymentMethodsHref = computed(() =>
locale.value.startsWith("zh")
? "https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md#支持的支付方式"
: "https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md#supported-payment-methods",
);
type SettingsTab =

View File

@@ -508,13 +508,13 @@ describe("admin SettingsView payment visible method controls", () => {
expect(paymentLinks).toHaveLength(2);
expect(paymentLinks[0]?.attributes("href")).toBe(
"https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98",
"https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md",
);
expect(paymentLinks[1]?.attributes("href")).toBe(
"https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98",
"https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md#支持的支付方式",
);
for (const link of paymentLinks) {
expect(link.attributes("href")).not.toContain("docs/PAYMENT");
expect(link.attributes("href")).toContain("docs/PAYMENT");
}
});