Compare commits
11 Commits
6b19490393
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6449da6c8d | ||
|
|
755c7d5026 | ||
|
|
1da4bd72df | ||
|
|
5551349349 | ||
|
|
c6d25f69d5 | ||
|
|
45065c23d5 | ||
|
|
ddf80f5ea1 | ||
|
|
c048ca80a4 | ||
|
|
22385be515 | ||
|
|
4d0483f5b8 | ||
|
|
1e0d466002 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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/
|
||||
|
||||
12
README.md
12
README.md
@@ -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
|
||||
|
||||
12
README_CN.md
12
README_CN.md
@@ -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 构建 |
|
||||
|
||||
## 技术栈
|
||||
|
||||
12
README_JA.md
12
README_JA.md
@@ -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 で構築 |
|
||||
|
||||
## 技術スタック
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.114
|
||||
0.1.115
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 账号,回退到 basic(OAuth 账号)
|
||||
if requiredCapability == OpenAIImagesCapabilityNative {
|
||||
return s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, OpenAIImagesCapabilityBasic)
|
||||
}
|
||||
return selection, decision, err
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) selectAccountWithScheduler(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
243
docs/ADMIN_PAYMENT_INTEGRATION_API.md
Normal file
243
docs/ADMIN_PAYMENT_INTEGRATION_API.md
Normal 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
287
docs/PAYMENT.md
Normal 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
287
docs/PAYMENT_CN.md
Normal 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 协议的支付服务商。
|
||||
|
||||
| 参数 | 说明 | 必填 |
|
||||
|------|------|------|
|
||||
| **商户 ID(PID)** | 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 一段时间以便查询历史记录。
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '使用统计',
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user