Skip to content

Commit 8f0ea7a

Browse files
committed
Merge branch 'main' into fix/enc_coot
2 parents e4a4dfd + a1dc008 commit 8f0ea7a

81 files changed

Lines changed: 4407 additions & 728 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
3939
- **Concurrency Control** - Per-user and per-account concurrency limits
4040
- **Rate Limiting** - Configurable request and token rate limits
4141
- **Admin Dashboard** - Web interface for monitoring and management
42+
- **External System Integration** - Embed external systems (e.g. payment, ticketing) via iframe to extend the admin dashboard
43+
44+
## Ecosystem
45+
46+
Community projects that extend or integrate with Sub2API:
47+
48+
| Project | Description | Features |
49+
|---------|-------------|----------|
50+
| [Sub2ApiPay](https://github.com/touwaeriol/sub2apipay) | Self-service payment system | Self-service top-up and subscription purchase; supports YiPay protocol, WeChat Pay, Alipay, Stripe; embeddable via iframe |
51+
| [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 |
4252

4353
## Tech Stack
4454

README_CN.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅(
3939
- **并发控制** - 用户级和账号级并发限制
4040
- **速率限制** - 可配置的请求和 Token 速率限制
4141
- **管理后台** - Web 界面进行监控和管理
42+
- **外部系统集成** - 支持通过 iframe 嵌入外部系统(如支付、工单等),扩展管理后台功能
43+
44+
## 生态项目
45+
46+
围绕 Sub2API 的社区扩展与集成项目:
47+
48+
| 项目 | 说明 | 功能 |
49+
|------|------|------|
50+
| [Sub2ApiPay](https://github.com/touwaeriol/sub2apipay) | 自助支付系统 | 用户自助充值、自助订阅购买;兼容易支付协议、微信官方支付、支付宝官方支付、Stripe;支持 iframe 嵌入管理后台 |
51+
| [sub2api-mobile](https://github.com/ckken/sub2api-mobile) | 移动端管理控制台 | 跨平台应用(iOS/Android/Web),支持用户管理、账号管理、监控看板、多后端切换;基于 Expo + React Native 构建 |
4252

4353
## 技术栈
4454

backend/internal/domain/constants.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,11 @@ const (
2727

2828
// Account type constants
2929
const (
30-
AccountTypeOAuth = "oauth" // OAuth类型账号(full scope: profile + inference)
31-
AccountTypeSetupToken = "setup-token" // Setup Token类型账号(inference only scope)
32-
AccountTypeAPIKey = "apikey" // API Key类型账号
33-
AccountTypeUpstream = "upstream" // 上游透传类型账号(通过 Base URL + API Key 连接上游)
34-
AccountTypeBedrock = "bedrock" // AWS Bedrock 类型账号(通过 SigV4 签名连接 Bedrock)
35-
AccountTypeBedrockAPIKey = "bedrock-apikey" // AWS Bedrock API Key 类型账号(通过 Bearer Token 连接 Bedrock)
30+
AccountTypeOAuth = "oauth" // OAuth类型账号(full scope: profile + inference)
31+
AccountTypeSetupToken = "setup-token" // Setup Token类型账号(inference only scope)
32+
AccountTypeAPIKey = "apikey" // API Key类型账号
33+
AccountTypeUpstream = "upstream" // 上游透传类型账号(通过 Base URL + API Key 连接上游)
34+
AccountTypeBedrock = "bedrock" // AWS Bedrock 类型账号(通过 SigV4 签名或 API Key 连接 Bedrock,由 credentials.auth_mode 区分)
3635
)
3736

3837
// Redeem type constants

backend/internal/handler/admin/account_handler.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ type CreateAccountRequest struct {
9797
Name string `json:"name" binding:"required"`
9898
Notes *string `json:"notes"`
9999
Platform string `json:"platform" binding:"required"`
100-
Type string `json:"type" binding:"required,oneof=oauth setup-token apikey upstream bedrock bedrock-apikey"`
100+
Type string `json:"type" binding:"required,oneof=oauth setup-token apikey upstream bedrock"`
101101
Credentials map[string]any `json:"credentials" binding:"required"`
102102
Extra map[string]any `json:"extra"`
103103
ProxyID *int64 `json:"proxy_id"`
@@ -116,7 +116,7 @@ type CreateAccountRequest struct {
116116
type UpdateAccountRequest struct {
117117
Name string `json:"name"`
118118
Notes *string `json:"notes"`
119-
Type string `json:"type" binding:"omitempty,oneof=oauth setup-token apikey upstream bedrock bedrock-apikey"`
119+
Type string `json:"type" binding:"omitempty,oneof=oauth setup-token apikey upstream bedrock"`
120120
Credentials map[string]any `json:"credentials"`
121121
Extra map[string]any `json:"extra"`
122122
ProxyID *int64 `json:"proxy_id"`
@@ -1718,13 +1718,12 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
17181718

17191719
// Handle OpenAI accounts
17201720
if account.IsOpenAI() {
1721-
// For OAuth accounts: return default OpenAI models
1722-
if account.IsOAuth() {
1721+
// OpenAI 自动透传会绕过常规模型改写,测试/模型列表也应回落到默认模型集。
1722+
if account.IsOpenAIPassthroughEnabled() {
17231723
response.Success(c, openai.DefaultModels)
17241724
return
17251725
}
17261726

1727-
// For API Key accounts: check model_mapping
17281727
mapping := account.GetModelMapping()
17291728
if len(mapping) == 0 {
17301729
response.Success(c, openai.DefaultModels)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package admin
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
"github.com/Wei-Shaw/sub2api/internal/service"
11+
"github.com/gin-gonic/gin"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
type availableModelsAdminService struct {
16+
*stubAdminService
17+
account service.Account
18+
}
19+
20+
func (s *availableModelsAdminService) GetAccount(_ context.Context, id int64) (*service.Account, error) {
21+
if s.account.ID == id {
22+
acc := s.account
23+
return &acc, nil
24+
}
25+
return s.stubAdminService.GetAccount(context.Background(), id)
26+
}
27+
28+
func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine {
29+
gin.SetMode(gin.TestMode)
30+
router := gin.New()
31+
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
32+
router.GET("/api/v1/admin/accounts/:id/models", handler.GetAvailableModels)
33+
return router
34+
}
35+
36+
func TestAccountHandlerGetAvailableModels_OpenAIOAuthUsesExplicitModelMapping(t *testing.T) {
37+
svc := &availableModelsAdminService{
38+
stubAdminService: newStubAdminService(),
39+
account: service.Account{
40+
ID: 42,
41+
Name: "openai-oauth",
42+
Platform: service.PlatformOpenAI,
43+
Type: service.AccountTypeOAuth,
44+
Status: service.StatusActive,
45+
Credentials: map[string]any{
46+
"model_mapping": map[string]any{
47+
"gpt-5": "gpt-5.1",
48+
},
49+
},
50+
},
51+
}
52+
router := setupAvailableModelsRouter(svc)
53+
54+
rec := httptest.NewRecorder()
55+
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/42/models", nil)
56+
router.ServeHTTP(rec, req)
57+
58+
require.Equal(t, http.StatusOK, rec.Code)
59+
60+
var resp struct {
61+
Data []struct {
62+
ID string `json:"id"`
63+
} `json:"data"`
64+
}
65+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
66+
require.Len(t, resp.Data, 1)
67+
require.Equal(t, "gpt-5", resp.Data[0].ID)
68+
}
69+
70+
func TestAccountHandlerGetAvailableModels_OpenAIOAuthPassthroughFallsBackToDefaults(t *testing.T) {
71+
svc := &availableModelsAdminService{
72+
stubAdminService: newStubAdminService(),
73+
account: service.Account{
74+
ID: 43,
75+
Name: "openai-oauth-passthrough",
76+
Platform: service.PlatformOpenAI,
77+
Type: service.AccountTypeOAuth,
78+
Status: service.StatusActive,
79+
Credentials: map[string]any{
80+
"model_mapping": map[string]any{
81+
"gpt-5": "gpt-5.1",
82+
},
83+
},
84+
Extra: map[string]any{
85+
"openai_passthrough": true,
86+
},
87+
},
88+
}
89+
router := setupAvailableModelsRouter(svc)
90+
91+
rec := httptest.NewRecorder()
92+
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/43/models", nil)
93+
router.ServeHTTP(rec, req)
94+
95+
require.Equal(t, http.StatusOK, rec.Code)
96+
97+
var resp struct {
98+
Data []struct {
99+
ID string `json:"id"`
100+
} `json:"data"`
101+
}
102+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
103+
require.NotEmpty(t, resp.Data)
104+
require.NotEqual(t, "gpt-5", resp.Data[0].ID)
105+
}

backend/internal/handler/admin/setting_handler.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
125125
OpsMetricsIntervalSeconds: settings.OpsMetricsIntervalSeconds,
126126
MinClaudeCodeVersion: settings.MinClaudeCodeVersion,
127127
AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling,
128+
BackendModeEnabled: settings.BackendModeEnabled,
128129
})
129130
}
130131

@@ -199,6 +200,9 @@ type UpdateSettingsRequest struct {
199200

200201
// 分组隔离
201202
AllowUngroupedKeyScheduling bool `json:"allow_ungrouped_key_scheduling"`
203+
204+
// Backend Mode
205+
BackendModeEnabled bool `json:"backend_mode_enabled"`
202206
}
203207

204208
// UpdateSettings 更新系统设置
@@ -473,6 +477,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
473477
IdentityPatchPrompt: req.IdentityPatchPrompt,
474478
MinClaudeCodeVersion: req.MinClaudeCodeVersion,
475479
AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling,
480+
BackendModeEnabled: req.BackendModeEnabled,
476481
OpsMonitoringEnabled: func() bool {
477482
if req.OpsMonitoringEnabled != nil {
478483
return *req.OpsMonitoringEnabled
@@ -571,6 +576,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
571576
OpsMetricsIntervalSeconds: updatedSettings.OpsMetricsIntervalSeconds,
572577
MinClaudeCodeVersion: updatedSettings.MinClaudeCodeVersion,
573578
AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling,
579+
BackendModeEnabled: updatedSettings.BackendModeEnabled,
574580
})
575581
}
576582

@@ -725,6 +731,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
725731
if before.AllowUngroupedKeyScheduling != after.AllowUngroupedKeyScheduling {
726732
changed = append(changed, "allow_ungrouped_key_scheduling")
727733
}
734+
if before.BackendModeEnabled != after.BackendModeEnabled {
735+
changed = append(changed, "backend_mode_enabled")
736+
}
728737
if before.PurchaseSubscriptionEnabled != after.PurchaseSubscriptionEnabled {
729738
changed = append(changed, "purchase_subscription_enabled")
730739
}

backend/internal/handler/auth_handler.go

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,12 @@ func (h *AuthHandler) Login(c *gin.Context) {
194194
return
195195
}
196196

197+
// Backend mode: only admin can login
198+
if h.settingSvc.IsBackendModeEnabled(c.Request.Context()) && !user.IsAdmin() {
199+
response.Forbidden(c, "Backend mode is active. Only admin login is allowed.")
200+
return
201+
}
202+
197203
h.respondWithTokenPair(c, user)
198204
}
199205

@@ -250,16 +256,22 @@ func (h *AuthHandler) Login2FA(c *gin.Context) {
250256
return
251257
}
252258

253-
// Delete the login session
254-
_ = h.totpService.DeleteLoginSession(c.Request.Context(), req.TempToken)
255-
256-
// Get the user
259+
// Get the user (before session deletion so we can check backend mode)
257260
user, err := h.userService.GetByID(c.Request.Context(), session.UserID)
258261
if err != nil {
259262
response.ErrorFrom(c, err)
260263
return
261264
}
262265

266+
// Backend mode: only admin can login (check BEFORE deleting session)
267+
if h.settingSvc.IsBackendModeEnabled(c.Request.Context()) && !user.IsAdmin() {
268+
response.Forbidden(c, "Backend mode is active. Only admin login is allowed.")
269+
return
270+
}
271+
272+
// Delete the login session (only after all checks pass)
273+
_ = h.totpService.DeleteLoginSession(c.Request.Context(), req.TempToken)
274+
263275
h.respondWithTokenPair(c, user)
264276
}
265277

@@ -522,16 +534,22 @@ func (h *AuthHandler) RefreshToken(c *gin.Context) {
522534
return
523535
}
524536

525-
tokenPair, err := h.authService.RefreshTokenPair(c.Request.Context(), req.RefreshToken)
537+
result, err := h.authService.RefreshTokenPair(c.Request.Context(), req.RefreshToken)
526538
if err != nil {
527539
response.ErrorFrom(c, err)
528540
return
529541
}
530542

543+
// Backend mode: block non-admin token refresh
544+
if h.settingSvc.IsBackendModeEnabled(c.Request.Context()) && result.UserRole != "admin" {
545+
response.Forbidden(c, "Backend mode is active. Only admin login is allowed.")
546+
return
547+
}
548+
531549
response.Success(c, RefreshTokenResponse{
532-
AccessToken: tokenPair.AccessToken,
533-
RefreshToken: tokenPair.RefreshToken,
534-
ExpiresIn: tokenPair.ExpiresIn,
550+
AccessToken: result.AccessToken,
551+
RefreshToken: result.RefreshToken,
552+
ExpiresIn: result.ExpiresIn,
535553
TokenType: "Bearer",
536554
})
537555
}

backend/internal/handler/dto/mappers.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,8 +264,8 @@ func AccountFromServiceShallow(a *service.Account) *Account {
264264
}
265265
}
266266

267-
// 提取 API Key 账号配额限制(仅 apikey 类型有效)
268-
if a.Type == service.AccountTypeAPIKey {
267+
// 提取账号配额限制(apikey / bedrock 类型有效)
268+
if a.IsAPIKeyOrBedrock() {
269269
if limit := a.GetQuotaLimit(); limit > 0 {
270270
out.QuotaLimit = &limit
271271
used := a.GetQuotaUsed()
@@ -281,6 +281,31 @@ func AccountFromServiceShallow(a *service.Account) *Account {
281281
used := a.GetQuotaWeeklyUsed()
282282
out.QuotaWeeklyUsed = &used
283283
}
284+
// 固定时间重置配置
285+
if mode := a.GetQuotaDailyResetMode(); mode == "fixed" {
286+
out.QuotaDailyResetMode = &mode
287+
hour := a.GetQuotaDailyResetHour()
288+
out.QuotaDailyResetHour = &hour
289+
}
290+
if mode := a.GetQuotaWeeklyResetMode(); mode == "fixed" {
291+
out.QuotaWeeklyResetMode = &mode
292+
day := a.GetQuotaWeeklyResetDay()
293+
out.QuotaWeeklyResetDay = &day
294+
hour := a.GetQuotaWeeklyResetHour()
295+
out.QuotaWeeklyResetHour = &hour
296+
}
297+
if a.GetQuotaDailyResetMode() == "fixed" || a.GetQuotaWeeklyResetMode() == "fixed" {
298+
tz := a.GetQuotaResetTimezone()
299+
out.QuotaResetTimezone = &tz
300+
}
301+
if a.Extra != nil {
302+
if v, ok := a.Extra["quota_daily_reset_at"].(string); ok && v != "" {
303+
out.QuotaDailyResetAt = &v
304+
}
305+
if v, ok := a.Extra["quota_weekly_reset_at"].(string); ok && v != "" {
306+
out.QuotaWeeklyResetAt = &v
307+
}
308+
}
284309
}
285310

286311
return out

backend/internal/handler/dto/settings.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ type SystemSettings struct {
8181

8282
// 分组隔离
8383
AllowUngroupedKeyScheduling bool `json:"allow_ungrouped_key_scheduling"`
84+
85+
// Backend Mode
86+
BackendModeEnabled bool `json:"backend_mode_enabled"`
8487
}
8588

8689
type DefaultSubscriptionSetting struct {
@@ -111,6 +114,7 @@ type PublicSettings struct {
111114
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
112115
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
113116
SoraClientEnabled bool `json:"sora_client_enabled"`
117+
BackendModeEnabled bool `json:"backend_mode_enabled"`
114118
Version string `json:"version"`
115119
}
116120

backend/internal/handler/dto/types.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,16 @@ type Account struct {
203203
QuotaWeeklyLimit *float64 `json:"quota_weekly_limit,omitempty"`
204204
QuotaWeeklyUsed *float64 `json:"quota_weekly_used,omitempty"`
205205

206+
// 配额固定时间重置配置
207+
QuotaDailyResetMode *string `json:"quota_daily_reset_mode,omitempty"`
208+
QuotaDailyResetHour *int `json:"quota_daily_reset_hour,omitempty"`
209+
QuotaWeeklyResetMode *string `json:"quota_weekly_reset_mode,omitempty"`
210+
QuotaWeeklyResetDay *int `json:"quota_weekly_reset_day,omitempty"`
211+
QuotaWeeklyResetHour *int `json:"quota_weekly_reset_hour,omitempty"`
212+
QuotaResetTimezone *string `json:"quota_reset_timezone,omitempty"`
213+
QuotaDailyResetAt *string `json:"quota_daily_reset_at,omitempty"`
214+
QuotaWeeklyResetAt *string `json:"quota_weekly_reset_at,omitempty"`
215+
206216
Proxy *Proxy `json:"proxy,omitempty"`
207217
AccountGroups []AccountGroup `json:"account_groups,omitempty"`
208218

0 commit comments

Comments
 (0)