From 7d4b7deea921e19ce779881f40243d90a8aef7a9 Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 28 Dec 2025 22:19:18 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=AE=80?= =?UTF-8?q?=E5=8D=95=E6=A8=A1=E5=BC=8F=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增简单模式设置,适合个人使用场景: - 隐藏多用户管理相关菜单(用户管理、兑换码等) - 自动关闭用户注册功能 - 管理员并发数自动设为无限制(99999) - 侧边栏根据模式动态调整菜单项 同时优化分组页面的"专属分组"功能,添加帮助提示说明使用场景 --- backend/cmd/server/wire_gen.go | 2 +- .../internal/handler/admin/setting_handler.go | 26 ++- backend/internal/handler/dto/settings.go | 3 + backend/internal/handler/setting_handler.go | 1 + backend/internal/service/domain_constants.go | 3 + backend/internal/service/setting_service.go | 6 + backend/internal/service/settings_view.go | 3 + backend/internal/service/user_service.go | 8 + frontend/src/api/admin/settings.ts | 2 + frontend/src/components/layout/AppSidebar.vue | 75 +++++--- frontend/src/i18n/locales/en.ts | 21 ++- frontend/src/i18n/locales/zh.ts | 29 ++- frontend/src/stores/app.ts | 6 +- frontend/src/types/index.ts | 1 + frontend/src/views/admin/GroupsView.vue | 174 +++++++++++++----- frontend/src/views/admin/SettingsView.vue | 108 ++++++++++- 16 files changed, 378 insertions(+), 90 deletions(-) diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 9904aa0d..1ff07f1e 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -99,7 +99,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService) proxyHandler := admin.NewProxyHandler(adminService) adminRedeemHandler := admin.NewRedeemHandler(adminService) - settingHandler := admin.NewSettingHandler(settingService, emailService) + settingHandler := admin.NewSettingHandler(settingService, emailService, userService) updateCache := repository.NewUpdateCache(client) gitHubReleaseClient := repository.NewGitHubReleaseClient() serviceBuildInfo := provideServiceBuildInfo(buildInfo) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 14b569de..50ac3e68 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -12,13 +12,15 @@ import ( type SettingHandler struct { settingService *service.SettingService emailService *service.EmailService + userService *service.UserService } // NewSettingHandler 创建系统设置处理器 -func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService) *SettingHandler { +func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, userService *service.UserService) *SettingHandler { return &SettingHandler{ settingService: settingService, emailService: emailService, + userService: userService, } } @@ -52,6 +54,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { DocUrl: settings.DocUrl, DefaultConcurrency: settings.DefaultConcurrency, DefaultBalance: settings.DefaultBalance, + SimpleMode: settings.SimpleMode, }) } @@ -86,6 +89,9 @@ type UpdateSettingsRequest struct { // 默认配置 DefaultConcurrency int `json:"default_concurrency"` DefaultBalance float64 `json:"default_balance"` + + // 使用模式 + SimpleMode bool `json:"simple_mode"` } // UpdateSettings 更新系统设置 @@ -108,8 +114,14 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { req.SmtpPort = 587 } + // 简单模式下自动关闭开放注册 + registrationEnabled := req.RegistrationEnabled + if req.SimpleMode { + registrationEnabled = false + } + settings := &service.SystemSettings{ - RegistrationEnabled: req.RegistrationEnabled, + RegistrationEnabled: registrationEnabled, EmailVerifyEnabled: req.EmailVerifyEnabled, SmtpHost: req.SmtpHost, SmtpPort: req.SmtpPort, @@ -129,6 +141,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { DocUrl: req.DocUrl, DefaultConcurrency: req.DefaultConcurrency, DefaultBalance: req.DefaultBalance, + SimpleMode: req.SimpleMode, } if err := h.settingService.UpdateSettings(c.Request.Context(), settings); err != nil { @@ -136,6 +149,14 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { return } + // 如果切换到简单模式,自动将管理员并发数设为 99999 + if req.SimpleMode { + admin, err := h.userService.GetFirstAdmin(c.Request.Context()) + if err == nil && admin != nil { + _ = h.userService.UpdateConcurrency(c.Request.Context(), admin.ID, 99999) + } + } + // 重新获取设置返回 updatedSettings, err := h.settingService.GetAllSettings(c.Request.Context()) if err != nil { @@ -164,6 +185,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { DocUrl: updatedSettings.DocUrl, DefaultConcurrency: updatedSettings.DefaultConcurrency, DefaultBalance: updatedSettings.DefaultBalance, + SimpleMode: updatedSettings.SimpleMode, }) } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 96e59e3f..bb1f8475 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -26,6 +26,8 @@ type SystemSettings struct { DefaultConcurrency int `json:"default_concurrency"` DefaultBalance float64 `json:"default_balance"` + + SimpleMode bool `json:"simple_mode"` // 简单模式 } type PublicSettings struct { @@ -40,4 +42,5 @@ type PublicSettings struct { ContactInfo string `json:"contact_info"` DocUrl string `json:"doc_url"` Version string `json:"version"` + SimpleMode bool `json:"simple_mode"` // 简单模式 } diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 90165288..038280dd 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -43,5 +43,6 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { ContactInfo: settings.ContactInfo, DocUrl: settings.DocUrl, Version: h.version, + SimpleMode: settings.SimpleMode, }) } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index b0f3fc9e..b8da35f6 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -90,6 +90,9 @@ const ( // 管理员 API Key SettingKeyAdminApiKey = "admin_api_key" // 全局管理员 API Key(用于外部系统集成) + + // 使用模式 + SettingKeySimpleMode = "simple_mode" // 简单模式(隐藏多用户管理功能) ) // Admin API Key prefix (distinct from user "sk-" keys) diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 0ffe991d..7f17fe8e 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -64,6 +64,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyApiBaseUrl, SettingKeyContactInfo, SettingKeyDocUrl, + SettingKeySimpleMode, } settings, err := s.settingRepo.GetMultiple(ctx, keys) @@ -82,6 +83,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ApiBaseUrl: settings[SettingKeyApiBaseUrl], ContactInfo: settings[SettingKeyContactInfo], DocUrl: settings[SettingKeyDocUrl], + SimpleMode: settings[SettingKeySimpleMode] == "true", }, nil } @@ -123,6 +125,9 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64) + // 使用模式 + updates[SettingKeySimpleMode] = strconv.FormatBool(settings.SimpleMode) + return s.settingRepo.SetMultiple(ctx, updates) } @@ -223,6 +228,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin ApiBaseUrl: settings[SettingKeyApiBaseUrl], ContactInfo: settings[SettingKeyContactInfo], DocUrl: settings[SettingKeyDocUrl], + SimpleMode: settings[SettingKeySimpleMode] == "true", } // 解析整数类型 diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index cb9751d1..f67d5e9d 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -25,6 +25,8 @@ type SystemSettings struct { DefaultConcurrency int DefaultBalance float64 + + SimpleMode bool // 简单模式 } type PublicSettings struct { @@ -39,4 +41,5 @@ type PublicSettings struct { ContactInfo string DocUrl string Version string + SimpleMode bool // 简单模式 } diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 3ff47e7d..6b190cf3 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -164,6 +164,14 @@ func (s *UserService) UpdateBalance(ctx context.Context, userID int64, amount fl return nil } +// UpdateConcurrency 更新用户并发数(管理员功能) +func (s *UserService) UpdateConcurrency(ctx context.Context, userID int64, concurrency int) error { + if err := s.userRepo.UpdateConcurrency(ctx, userID, concurrency); err != nil { + return fmt.Errorf("update concurrency: %w", err) + } + return nil +} + // UpdateStatus 更新用户状态(管理员功能) func (s *UserService) UpdateStatus(ctx context.Context, userID int64, status string) error { user, err := s.userRepo.GetByID(ctx, userID) diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index cf5cba6d..7da99351 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -34,6 +34,8 @@ export interface SystemSettings { turnstile_enabled: boolean turnstile_site_key: string turnstile_secret_key: string + // Usage mode + simple_mode: boolean } /** diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index cfbd7c14..ce553e1d 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -45,8 +45,8 @@ - -
-

- {{ t('admin.groups.subscription.title') }} -

- -
+
[ { value: 'inactive', label: t('common.inactive') } ]) -const subscriptionTypeOptions = computed(() => [ - { value: 'standard', label: t('admin.groups.subscription.standard') }, - { value: 'subscription', label: t('admin.groups.subscription.subscription') } -]) +const subscriptionTypeOptions = computed(() => { + // 简单模式下只显示订阅模式(配额模式) + if (appStore.simpleMode) { + return [ + { value: 'subscription', label: t('admin.groups.subscription.subscription') } + ] + } + return [ + { value: 'standard', label: t('admin.groups.subscription.standard') }, + { value: 'subscription', label: t('admin.groups.subscription.subscription') } + ] +}) const groups = ref([]) const loading = ref(false) @@ -732,7 +804,7 @@ const closeCreateModal = () => { createForm.platform = 'anthropic' createForm.rate_multiplier = 1.0 createForm.is_exclusive = false - createForm.subscription_type = 'standard' + createForm.subscription_type = appStore.simpleMode ? 'subscription' : 'standard' createForm.daily_limit_usd = null createForm.weekly_limit_usd = null createForm.monthly_limit_usd = null @@ -823,5 +895,9 @@ watch( onMounted(() => { loadGroups() + // 简单模式下默认使用订阅模式 + if (appStore.simpleMode) { + createForm.subscription_type = 'subscription' + } }) diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index e8d64ab1..cada1c95 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -153,6 +153,58 @@
+ +
+
+

+ {{ t('admin.settings.usageMode.title') }} +

+

+ {{ t('admin.settings.usageMode.description') }} +

+
+
+ +
+
+ +

+ {{ t('admin.settings.usageMode.simpleModeHint') }} +

+
+ +
+ + +
+
+ + + +

+ {{ t('admin.settings.usageMode.simpleModeWarning') }} +

+
+
+
+
+
@@ -706,6 +758,19 @@
+ + + @@ -716,6 +781,7 @@ import { adminAPI } from '@/api' import type { SystemSettings } from '@/api/admin/settings' import AppLayout from '@/components/layout/AppLayout.vue' import Toggle from '@/components/common/Toggle.vue' +import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import { useAppStore } from '@/stores' const { t } = useI18n() @@ -728,6 +794,10 @@ const sendingTestEmail = ref(false) const testEmailAddress = ref('') const logoError = ref('') +// Simple mode confirmation dialog +const showSimpleModeConfirm = ref(false) +const pendingSimpleModeValue = ref(false) + // Admin API Key 状态 const adminApiKeyLoading = ref(true) const adminApiKeyExists = ref(false) @@ -756,7 +826,9 @@ const form = reactive({ // Cloudflare Turnstile turnstile_enabled: false, turnstile_site_key: '', - turnstile_secret_key: '' + turnstile_secret_key: '', + // Usage mode + simple_mode: false }) function handleLogoUpload(event: Event) { @@ -827,6 +899,40 @@ async function saveSettings() { } } +// Simple mode toggle handlers +function onSimpleModeToggle(value: boolean) { + pendingSimpleModeValue.value = value + showSimpleModeConfirm.value = true +} + +async function confirmSimpleModeChange() { + showSimpleModeConfirm.value = false + form.simple_mode = pendingSimpleModeValue.value + + saving.value = true + try { + await adminAPI.settings.updateSettings(form) + await appStore.fetchPublicSettings(true) + appStore.showSuccess(t('admin.settings.settingsSaved')) + // Reload page to apply menu changes + setTimeout(() => { + window.location.reload() + }, 500) + } catch (error: any) { + // Revert on error + form.simple_mode = !pendingSimpleModeValue.value + appStore.showError( + t('admin.settings.failedToSave') + ': ' + (error.message || t('common.unknownError')) + ) + } finally { + saving.value = false + } +} + +function cancelSimpleModeChange() { + showSimpleModeConfirm.value = false +} + async function testSmtpConnection() { testingSmtp.value = true try { From 31d4c1d2fe230e9684792279b540f5cf3883d83b Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 28 Dec 2025 22:34:42 +0800 Subject: [PATCH 02/14] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8D=20Sel?= =?UTF-8?q?ect=20=E4=B8=8B=E6=8B=89=E8=8F=9C=E5=8D=95=E9=80=89=E9=A1=B9?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E8=A2=AB=E6=88=AA=E6=96=AD=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改下拉框宽度策略为 min-w-full w-max max-w-[300px],允许自动扩展 - 添加 left-0 确保下拉框左对齐 - 为选项标签添加 flex-1 min-w-0 text-left 确保正确布局 --- frontend/src/components/common/Select.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/common/Select.vue b/frontend/src/components/common/Select.vue index 71a41431..725aa1f3 100644 --- a/frontend/src/components/common/Select.vue +++ b/frontend/src/components/common/Select.vue @@ -297,7 +297,7 @@ onUnmounted(() => { } .select-dropdown { - @apply absolute z-[100] mt-2 w-full; + @apply absolute left-0 z-[100] mt-2 min-w-full w-max max-w-[300px]; @apply bg-white dark:bg-dark-800; @apply rounded-xl; @apply border border-gray-200 dark:border-dark-700; @@ -339,7 +339,7 @@ onUnmounted(() => { } .select-option-label { - @apply truncate; + @apply flex-1 min-w-0 truncate text-left; } .select-empty { From 0084da9ca5ab0b5c79e360f935123c85bbb4871a Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 28 Dec 2025 22:45:13 +0800 Subject: [PATCH 03/14] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20NewSettingHan?= =?UTF-8?q?dler=20=E5=8F=82=E6=95=B0=E4=B8=8D=E8=B6=B3=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=9A=84=E7=BC=96=E8=AF=91=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 测试文件添加第三个参数 userService(nil) - Handler 添加 userService 空指针检查,防止测试环境 panic --- backend/internal/handler/admin/setting_handler.go | 2 +- backend/internal/server/api_contract_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 50ac3e68..b0eec935 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -150,7 +150,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } // 如果切换到简单模式,自动将管理员并发数设为 99999 - if req.SimpleMode { + if req.SimpleMode && h.userService != nil { admin, err := h.userService.GetFirstAdmin(c.Request.Context()) if err == nil && admin != nil { _ = h.userService.UpdateConcurrency(c.Request.Context(), admin.ID, 99999) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 06eb2ebf..71479d19 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -383,7 +383,7 @@ func newContractDeps(t *testing.T) *contractDeps { authHandler := handler.NewAuthHandler(nil, userService) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService) - adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil) + adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil) jwtAuth := func(c *gin.Context) { c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{ From 25b8a2264861e14830c40a99c94391ba90e8298c Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 28 Dec 2025 22:51:22 +0800 Subject: [PATCH 04/14] =?UTF-8?q?fix(test):=20=E6=B5=8B=E8=AF=95=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E6=B7=BB=E5=8A=A0=20simple=5Fmode=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API 响应新增 simple_mode 字段,同步更新测试期望值 --- backend/internal/server/api_contract_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 71479d19..59bf7a44 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -280,6 +280,7 @@ func TestAPIContracts(t *testing.T) { service.SettingKeyDefaultConcurrency: "5", service.SettingKeyDefaultBalance: "1.25", + service.SettingKeySimpleMode: "false", }) }, method: http.MethodGet, @@ -308,7 +309,8 @@ func TestAPIContracts(t *testing.T) { "contact_info": "support", "doc_url": "https://docs.example.com", "default_concurrency": 5, - "default_balance": 1.25 + "default_balance": 1.25, + "simple_mode": false } }`, }, From 30b95cf5ce52f07331ad402ba5ce24be49b65795 Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 28 Dec 2025 23:12:44 +0800 Subject: [PATCH 05/14] =?UTF-8?q?fix(usage):=20=E5=88=86=E7=A6=BB=20API=20?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=92=8C=E7=AA=97=E5=8F=A3=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E7=BC=93=E5=AD=98=EF=BC=8C=E4=BF=AE=E5=A4=8D=205h=20=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E6=9C=AA=E6=BF=80=E6=B4=BB=E6=97=B6=E7=9A=84=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: 1. WindowStats 与 API 响应一起缓存 10 分钟,导致费用数据更新延迟 2. 当 5h 窗口未激活(ResetsAt 为空)时,FiveHour 为 nil,导致所有窗口的 WindowStats 都无法显示 修复: - 分离缓存:API 响应缓存 10 分钟,窗口统计独立缓存 1 分钟 - RemainingSeconds 每次请求时实时计算 - FiveHour 对象始终创建(即使 ResetsAt 为空) - addWindowStats 增强防护,支持 FiveHour 为 nil 时仍处理其他窗口 --- .../internal/service/account_usage_service.go | 145 ++++++++++-------- 1 file changed, 85 insertions(+), 60 deletions(-) diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 575e72b1..94d4c747 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -54,15 +54,23 @@ type UsageLogRepository interface { GetApiKeyStatsAggregated(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) (*usagestats.UsageStats, error) } -// usageCache 用于缓存usage数据 -type usageCache struct { - data *UsageInfo +// apiUsageCache 缓存从 Anthropic API 获取的使用率数据(utilization, resets_at) +type apiUsageCache struct { + response *ClaudeUsageResponse + timestamp time.Time +} + +// windowStatsCache 缓存从本地数据库查询的窗口统计(requests, tokens, cost) +type windowStatsCache struct { + stats *WindowStats timestamp time.Time } var ( - usageCacheMap = sync.Map{} - cacheTTL = 10 * time.Minute + apiCacheMap = sync.Map{} // 缓存 API 响应 + windowStatsCacheMap = sync.Map{} // 缓存窗口统计 + apiCacheTTL = 10 * time.Minute + windowStatsCacheTTL = 1 * time.Minute ) // WindowStats 窗口期统计 @@ -126,7 +134,7 @@ func NewAccountUsageService(accountRepo AccountRepository, usageLogRepo UsageLog } // GetUsage 获取账号使用量 -// OAuth账号: 调用Anthropic API获取真实数据(需要profile scope),缓存10分钟 +// OAuth账号: 调用Anthropic API获取真实数据(需要profile scope),API响应缓存10分钟,窗口统计缓存1分钟 // Setup Token账号: 根据session_window推算5h窗口,7d数据不可用(没有profile scope) // API Key账号: 不支持usage查询 func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*UsageInfo, error) { @@ -137,30 +145,34 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U // 只有oauth类型账号可以通过API获取usage(有profile scope) if account.CanGetUsage() { - // 检查缓存 - if cached, ok := usageCacheMap.Load(accountID); ok { - cache, ok := cached.(*usageCache) - if !ok { - usageCacheMap.Delete(accountID) - } else if time.Since(cache.timestamp) < cacheTTL { - return cache.data, nil + var apiResp *ClaudeUsageResponse + + // 1. 检查 API 缓存(10 分钟) + if cached, ok := apiCacheMap.Load(accountID); ok { + if cache, ok := cached.(*apiUsageCache); ok && time.Since(cache.timestamp) < apiCacheTTL { + apiResp = cache.response } } - // 从API获取数据 - usage, err := s.fetchOAuthUsage(ctx, account) - if err != nil { - return nil, err + // 2. 如果没有缓存,从 API 获取 + if apiResp == nil { + apiResp, err = s.fetchOAuthUsageRaw(ctx, account) + if err != nil { + return nil, err + } + // 缓存 API 响应 + apiCacheMap.Store(accountID, &apiUsageCache{ + response: apiResp, + timestamp: time.Now(), + }) } - // 添加5h窗口统计数据 - s.addWindowStats(ctx, account, usage) + // 3. 构建 UsageInfo(每次都重新计算 RemainingSeconds) + now := time.Now() + usage := s.buildUsageInfo(apiResp, &now) - // 缓存结果 - usageCacheMap.Store(accountID, &usageCache{ - data: usage, - timestamp: time.Now(), - }) + // 4. 添加窗口统计(有独立缓存,1 分钟) + s.addWindowStats(ctx, account, usage) return usage, nil } @@ -177,31 +189,54 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U return nil, fmt.Errorf("account type %s does not support usage query", account.Type) } -// addWindowStats 为usage数据添加窗口期统计 +// addWindowStats 为 usage 数据添加窗口期统计 +// 使用独立缓存(1 分钟),与 API 缓存分离 func (s *AccountUsageService) addWindowStats(ctx context.Context, account *Account, usage *UsageInfo) { - if usage.FiveHour == nil { + // 修复:即使 FiveHour 为 nil,也要尝试获取统计数据 + // 因为 SevenDay/SevenDaySonnet 可能需要 + if usage.FiveHour == nil && usage.SevenDay == nil && usage.SevenDaySonnet == nil { return } - // 使用session_window_start作为统计起始时间 - var startTime time.Time - if account.SessionWindowStart != nil { - startTime = *account.SessionWindowStart - } else { - // 如果没有窗口信息,使用5小时前作为默认 - startTime = time.Now().Add(-5 * time.Hour) + // 检查窗口统计缓存(1 分钟) + var windowStats *WindowStats + if cached, ok := windowStatsCacheMap.Load(account.ID); ok { + if cache, ok := cached.(*windowStatsCache); ok && time.Since(cache.timestamp) < windowStatsCacheTTL { + windowStats = cache.stats + } } - stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, startTime) - if err != nil { - log.Printf("Failed to get window stats for account %d: %v", account.ID, err) - return + // 如果没有缓存,从数据库查询 + if windowStats == nil { + var startTime time.Time + if account.SessionWindowStart != nil { + startTime = *account.SessionWindowStart + } else { + startTime = time.Now().Add(-5 * time.Hour) + } + + stats, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, startTime) + if err != nil { + log.Printf("Failed to get window stats for account %d: %v", account.ID, err) + return + } + + windowStats = &WindowStats{ + Requests: stats.Requests, + Tokens: stats.Tokens, + Cost: stats.Cost, + } + + // 缓存窗口统计(1 分钟) + windowStatsCacheMap.Store(account.ID, &windowStatsCache{ + stats: windowStats, + timestamp: time.Now(), + }) } - usage.FiveHour.WindowStats = &WindowStats{ - Requests: stats.Requests, - Tokens: stats.Tokens, - Cost: stats.Cost, + // 为 FiveHour 添加 WindowStats(5h 窗口统计) + if usage.FiveHour != nil { + usage.FiveHour.WindowStats = windowStats } } @@ -227,8 +262,8 @@ func (s *AccountUsageService) GetAccountUsageStats(ctx context.Context, accountI return stats, nil } -// fetchOAuthUsage 从Anthropic API获取OAuth账号的使用量 -func (s *AccountUsageService) fetchOAuthUsage(ctx context.Context, account *Account) (*UsageInfo, error) { +// fetchOAuthUsageRaw 从 Anthropic API 获取原始响应(不构建 UsageInfo) +func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *Account) (*ClaudeUsageResponse, error) { accessToken := account.GetCredential("access_token") if accessToken == "" { return nil, fmt.Errorf("no access token available") @@ -239,13 +274,7 @@ func (s *AccountUsageService) fetchOAuthUsage(ctx context.Context, account *Acco proxyURL = account.Proxy.URL() } - usageResp, err := s.usageFetcher.FetchUsage(ctx, accessToken, proxyURL) - if err != nil { - return nil, err - } - - now := time.Now() - return s.buildUsageInfo(usageResp, &now), nil + return s.usageFetcher.FetchUsage(ctx, accessToken, proxyURL) } // parseTime 尝试多种格式解析时间 @@ -270,20 +299,16 @@ func (s *AccountUsageService) buildUsageInfo(resp *ClaudeUsageResponse, updatedA UpdatedAt: updatedAt, } - // 5小时窗口 + // 5小时窗口 - 始终创建对象(即使 ResetsAt 为空) + info.FiveHour = &UsageProgress{ + Utilization: resp.FiveHour.Utilization, + } if resp.FiveHour.ResetsAt != "" { if fiveHourReset, err := parseTime(resp.FiveHour.ResetsAt); err == nil { - info.FiveHour = &UsageProgress{ - Utilization: resp.FiveHour.Utilization, - ResetsAt: &fiveHourReset, - RemainingSeconds: int(time.Until(fiveHourReset).Seconds()), - } + info.FiveHour.ResetsAt = &fiveHourReset + info.FiveHour.RemainingSeconds = int(time.Until(fiveHourReset).Seconds()) } else { log.Printf("Failed to parse FiveHour.ResetsAt: %s, error: %v", resp.FiveHour.ResetsAt, err) - // 即使解析失败也返回utilization - info.FiveHour = &UsageProgress{ - Utilization: resp.FiveHour.Utilization, - } } } From e247be6eadb6fa6be146d88172b3e21c4510ac37 Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 28 Dec 2025 23:24:46 +0800 Subject: [PATCH 06/14] =?UTF-8?q?fix(frontend):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=20API=20?= =?UTF-8?q?Key=20=E7=B1=BB=E5=9E=8B=E7=9A=84=E6=8F=90=E7=A4=BA=E6=96=87?= =?UTF-8?q?=E6=A1=88=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 OpenAI/Gemini 平台的 baseUrlHint 和 apiKeyHint 国际化文案 - 修改 CreateAccountModal 和 EditAccountModal 根据平台显示正确提示 - 将重复的平台判断逻辑抽取为 computed 属性,优化代码结构 --- .../components/account/CreateAccountModal.vue | 23 ++++++++++++------- .../components/account/EditAccountModal.vue | 10 +++++++- frontend/src/i18n/locales/en.ts | 6 +++++ frontend/src/i18n/locales/zh.ts | 8 ++++++- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index fddd7384..1e0b4afe 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -530,7 +530,7 @@ : 'https://api.anthropic.com' " /> -

{{ t('admin.accounts.baseUrlHint') }}

+

{{ baseUrlHint }}

@@ -547,13 +547,7 @@ : 'sk-ant-...' " /> -

- {{ - form.platform === 'gemini' - ? t('admin.accounts.gemini.apiKeyHint') - : t('admin.accounts.apiKeyHint') - }} -

+

{{ apiKeyHint }}

@@ -1115,6 +1109,19 @@ const oauthStepTitle = computed(() => { return t('admin.accounts.oauth.title') }) +// Platform-specific hints for API Key type +const baseUrlHint = computed(() => { + if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint') + if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint') + return t('admin.accounts.baseUrlHint') +}) + +const apiKeyHint = computed(() => { + if (form.platform === 'openai') return t('admin.accounts.openai.apiKeyHint') + if (form.platform === 'gemini') return t('admin.accounts.gemini.apiKeyHint') + return t('admin.accounts.apiKeyHint') +}) + interface Props { show: boolean proxies: Proxy[] diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index dc981650..75ce204d 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -32,7 +32,7 @@ : 'https://api.anthropic.com' " /> -

{{ t('admin.accounts.baseUrlHint') }}

+

{{ baseUrlHint }}

@@ -536,6 +536,14 @@ const emit = defineEmits<{ const { t } = useI18n() const appStore = useAppStore() +// Platform-specific hint for Base URL +const baseUrlHint = computed(() => { + if (!props.account) return t('admin.accounts.baseUrlHint') + if (props.account.platform === 'openai') return t('admin.accounts.openai.baseUrlHint') + if (props.account.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint') + return t('admin.accounts.baseUrlHint') +}) + // Model mapping type interface ModelMapping { from: string diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 1affb8d5..e3a2ca22 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -915,6 +915,11 @@ export default { apiKeyRequired: 'API Key *', apiKeyPlaceholder: 'sk-ant-api03-...', apiKeyHint: 'Your Claude Console API Key', + // OpenAI specific hints + openai: { + baseUrlHint: 'Leave default for official OpenAI API', + apiKeyHint: 'Your OpenAI API Key' + }, modelRestriction: 'Model Restriction (Optional)', modelWhitelist: 'Model Whitelist', modelMapping: 'Model Mapping', @@ -1076,6 +1081,7 @@ export default { modelPassthrough: 'Gemini Model Passthrough', modelPassthroughDesc: 'All model requests are forwarded directly to the Gemini API without model restrictions or mappings.', + baseUrlHint: 'Leave default for official Gemini API', apiKeyHint: 'Your Gemini API Key (starts with AIza)' }, // Re-Auth Modal diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index c3a52832..7298654a 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1054,6 +1054,11 @@ export default { apiKeyRequired: 'API Key *', apiKeyPlaceholder: 'sk-ant-api03-...', apiKeyHint: '您的 Claude Console API Key', + // OpenAI specific hints + openai: { + baseUrlHint: '留空使用官方 OpenAI API', + apiKeyHint: '您的 OpenAI API Key' + }, modelRestriction: '模型限制(可选)', modelWhitelist: '模型白名单', modelMapping: '模型映射', @@ -1197,7 +1202,8 @@ export default { gemini: { modelPassthrough: 'Gemini 直接转发模型', modelPassthroughDesc: '所有模型请求将直接转发至 Gemini API,不进行模型限制或映射。', - apiKeyHint: 'Your Gemini API Key(以 AIza 开头)' + baseUrlHint: '留空使用官方 Gemini API', + apiKeyHint: '您的 Gemini API Key(以 AIza 开头)' }, // Re-Auth Modal reAuthorizeAccount: '重新授权账号', From ecfad788d938832c3eacc38f5e9e6e1444c2eb50 Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Mon, 29 Dec 2025 03:17:25 +0800 Subject: [PATCH 07/14] =?UTF-8?q?feat(=E5=85=A8=E6=A0=88):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E7=AE=80=E6=98=93=E6=A8=A1=E5=BC=8F=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **功能概述**: 实现简易模式(Simple Mode),为个人用户和小团队提供简化的使用体验,隐藏复杂的分组、订阅、配额等概念。 **后端改动**: 1. 配置系统 - 新增 run_mode 配置项(standard/simple) - 支持环境变量 RUN_MODE - 默认值为 standard 2. 数据库初始化 - 自动创建3个默认分组:anthropic-default、openai-default、gemini-default - 默认分组配置:无并发限制、active状态、非独占 - 幂等性保证:重复启动不会重复创建 3. 账号管理 - 创建账号时自动绑定对应平台的默认分组 - 如果未指定分组,自动查找并绑定默认分组 **前端改动**: 1. 状态管理 - authStore 新增 isSimpleMode 计算属性 - 从后端API获取并同步运行模式 2. UI隐藏 - 侧边栏:隐藏分组管理、订阅管理、兑换码菜单 - 账号管理页面:隐藏分组列 - 创建/编辑账号对话框:隐藏分组选择器 3. 路由守卫 - 限制访问分组、订阅、兑换码相关页面 - 访问受限页面时自动重定向到仪表板 **配置示例**: ```yaml run_mode: simple run_mode: standard ``` **影响范围**: - 后端:配置、数据库迁移、账号服务 - 前端:认证状态、路由、UI组件 - 部署:配置文件示例 **兼容性**: - 简易模式和标准模式可无缝切换 - 不需要数据迁移 - 现有数据不受影响 --- README_CN.md | 10 + backend/cmd/server/main.go | 8 + backend/cmd/server/wire_gen.go | 6 +- backend/internal/config/config.go | 20 ++ backend/internal/config/config_test.go | 23 ++ backend/internal/handler/auth_handler.go | 17 +- backend/internal/repository/auto_migrate.go | 57 ++++ backend/internal/server/api_contract_test.go | 6 +- backend/internal/server/http.go | 2 +- .../server/middleware/api_key_auth.go | 19 +- .../server/middleware/api_key_auth_google.go | 19 +- .../server/middleware/api_key_auth_test.go | 286 ++++++++++++++++++ backend/internal/server/router.go | 7 +- backend/internal/server/routes/gateway.go | 4 +- backend/internal/service/admin_service.go | 22 +- .../internal/service/billing_cache_service.go | 10 +- backend/internal/service/gateway_service.go | 11 +- .../service/openai_gateway_service.go | 12 +- deploy/.env.example | 4 + deploy/config.example.yaml | 8 + deploy/docker-compose.yml | 1 + frontend/src/api/auth.ts | 7 +- .../components/account/CreateAccountModal.vue | 11 +- .../components/account/EditAccountModal.vue | 11 +- frontend/src/components/layout/AppSidebar.vue | 4 +- frontend/src/router/index.ts | 17 ++ frontend/src/stores/auth.ts | 19 +- frontend/src/types/index.ts | 4 + frontend/src/views/admin/AccountsView.vue | 41 ++- 29 files changed, 615 insertions(+), 51 deletions(-) create mode 100644 backend/internal/config/config_test.go create mode 100644 backend/internal/server/middleware/api_key_auth_test.go diff --git a/README_CN.md b/README_CN.md index a93fb9d8..db7de488 100644 --- a/README_CN.md +++ b/README_CN.md @@ -283,6 +283,16 @@ npm run dev --- +## 简易模式 + +简易模式适合个人开发者或内部团队快速使用,不依赖完整 SaaS 功能。 + +- 启用方式:设置环境变量 `RUN_MODE=simple` +- 功能差异:隐藏 SaaS 相关功能,跳过计费流程 +- 安全注意事项:生产环境需同时设置 `SIMPLE_MODE_CONFIRM=true` 才允许启动 + +--- + ## 项目结构 ``` diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a81a572e..6b87eb73 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -107,6 +107,14 @@ func runSetupServer() { } func runMainServer() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + if cfg.RunMode == config.RunModeSimple { + log.Println("⚠️ WARNING: Running in SIMPLE mode - billing and quota checks are DISABLED") + } + buildInfo := handler.BuildInfo{ Version: Version, BuildType: BuildType, diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 1ff07f1e..c5b31bd5 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -49,7 +49,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { emailQueueService := service.ProvideEmailQueueService(emailService) authService := service.NewAuthService(userRepository, configConfig, settingService, emailService, turnstileService, emailQueueService) userService := service.NewUserService(userRepository) - authHandler := handler.NewAuthHandler(authService, userService) + authHandler := handler.NewAuthHandler(configConfig, authService, userService) userHandler := handler.NewUserHandler(userService) apiKeyRepository := repository.NewApiKeyRepository(db) groupRepository := repository.NewGroupRepository(db) @@ -62,7 +62,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { usageHandler := handler.NewUsageHandler(usageService, apiKeyService) redeemCodeRepository := repository.NewRedeemCodeRepository(db) billingCache := repository.NewBillingCache(client) - billingCacheService := service.NewBillingCacheService(billingCache, userRepository, userSubscriptionRepository) + billingCacheService := service.NewBillingCacheService(billingCache, userRepository, userSubscriptionRepository, configConfig) subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService) redeemCache := repository.NewRedeemCache(client) redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService) @@ -128,7 +128,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService) - apiKeyAuthMiddleware := middleware.NewApiKeyAuthMiddleware(apiKeyService, subscriptionService) + apiKeyAuthMiddleware := middleware.NewApiKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig) engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService) httpServer := server.ProvideHTTPServer(configConfig, engine) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 485ed42d..5ce1222f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -7,6 +7,11 @@ import ( "github.com/spf13/viper" ) +const ( + RunModeStandard = "standard" + RunModeSimple = "simple" +) + type Config struct { Server ServerConfig `mapstructure:"server"` Database DatabaseConfig `mapstructure:"database"` @@ -17,6 +22,7 @@ type Config struct { Pricing PricingConfig `mapstructure:"pricing"` Gateway GatewayConfig `mapstructure:"gateway"` TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"` + RunMode string `mapstructure:"run_mode" yaml:"run_mode"` Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC" Gemini GeminiConfig `mapstructure:"gemini"` } @@ -135,6 +141,16 @@ type RateLimitConfig struct { OverloadCooldownMinutes int `mapstructure:"overload_cooldown_minutes"` // 529过载冷却时间(分钟) } +func NormalizeRunMode(value string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + switch normalized { + case RunModeStandard, RunModeSimple: + return normalized + default: + return RunModeStandard + } +} + func Load() (*Config, error) { viper.SetConfigName("config") viper.SetConfigType("yaml") @@ -161,6 +177,8 @@ func Load() (*Config, error) { return nil, fmt.Errorf("unmarshal config error: %w", err) } + cfg.RunMode = NormalizeRunMode(cfg.RunMode) + if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("validate config error: %w", err) } @@ -169,6 +187,8 @@ func Load() (*Config, error) { } func setDefaults() { + viper.SetDefault("run_mode", RunModeStandard) + // Server viper.SetDefault("server.host", "0.0.0.0") viper.SetDefault("server.port", 8080) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 00000000..1f1becb8 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,23 @@ +package config + +import "testing" + +func TestNormalizeRunMode(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"simple", "simple"}, + {"SIMPLE", "simple"}, + {"standard", "standard"}, + {"invalid", "standard"}, + {"", "standard"}, + } + + for _, tt := range tests { + result := NormalizeRunMode(tt.input) + if result != tt.expected { + t.Errorf("NormalizeRunMode(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 799d63d8..8466f131 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -1,6 +1,7 @@ package handler import ( + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" @@ -11,13 +12,15 @@ import ( // AuthHandler handles authentication-related requests type AuthHandler struct { + cfg *config.Config authService *service.AuthService userService *service.UserService } // NewAuthHandler creates a new AuthHandler -func NewAuthHandler(authService *service.AuthService, userService *service.UserService) *AuthHandler { +func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService) *AuthHandler { return &AuthHandler{ + cfg: cfg, authService: authService, userService: userService, } @@ -157,5 +160,15 @@ func (h *AuthHandler) GetCurrentUser(c *gin.Context) { return } - response.Success(c, dto.UserFromService(user)) + type UserResponse struct { + *dto.User + RunMode string `json:"run_mode"` + } + + runMode := config.RunModeStandard + if h.cfg != nil { + runMode = h.cfg.RunMode + } + + response.Success(c, UserResponse{User: dto.UserFromService(user), RunMode: runMode}) } diff --git a/backend/internal/repository/auto_migrate.go b/backend/internal/repository/auto_migrate.go index 9127eeb9..f76e3719 100644 --- a/backend/internal/repository/auto_migrate.go +++ b/backend/internal/repository/auto_migrate.go @@ -30,6 +30,11 @@ func AutoMigrate(db *gorm.DB) error { return err } + // 创建默认分组(简易模式支持) + if err := ensureDefaultGroups(db); err != nil { + return err + } + // 修复无效的过期时间(年份超过 2099 会导致 JSON 序列化失败) return fixInvalidExpiresAt(db) } @@ -47,3 +52,55 @@ func fixInvalidExpiresAt(db *gorm.DB) error { } return nil } + +// ensureDefaultGroups 确保默认分组存在(简易模式支持) +// 为每个平台创建一个默认分组,配置最大权限以确保简易模式下不受限制 +func ensureDefaultGroups(db *gorm.DB) error { + defaultGroups := []struct { + name string + platform string + description string + }{ + { + name: "anthropic-default", + platform: "anthropic", + description: "Default group for Anthropic accounts (Simple Mode)", + }, + { + name: "openai-default", + platform: "openai", + description: "Default group for OpenAI accounts (Simple Mode)", + }, + { + name: "gemini-default", + platform: "gemini", + description: "Default group for Gemini accounts (Simple Mode)", + }, + } + + for _, dg := range defaultGroups { + var count int64 + if err := db.Model(&groupModel{}).Where("name = ?", dg.name).Count(&count).Error; err != nil { + return err + } + + if count == 0 { + group := &groupModel{ + Name: dg.name, + Description: dg.description, + Platform: dg.platform, + RateMultiplier: 1.0, + IsExclusive: false, + Status: "active", + SubscriptionType: "standard", + } + if err := db.Create(group).Error; err != nil { + log.Printf("[AutoMigrate] Failed to create default group %s: %v", dg.name, err) + return err + } + log.Printf("[AutoMigrate] Created default group: %s (platform: %s)", dg.name, dg.platform) + } + } + + return nil +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 59bf7a44..ba5d173f 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -59,7 +59,8 @@ func TestAPIContracts(t *testing.T) { "status": "active", "allowed_groups": null, "created_at": "2025-01-02T03:04:05Z", - "updated_at": "2025-01-02T03:04:05Z" + "updated_at": "2025-01-02T03:04:05Z", + "run_mode": "standard" } }`, }, @@ -371,6 +372,7 @@ func newContractDeps(t *testing.T) *contractDeps { Default: config.DefaultConfig{ ApiKeyPrefix: "sk-", }, + RunMode: config.RunModeStandard, } userService := service.NewUserService(userRepo) @@ -382,7 +384,7 @@ func newContractDeps(t *testing.T) *contractDeps { settingRepo := newStubSettingRepo() settingService := service.NewSettingService(settingRepo, cfg) - authHandler := handler.NewAuthHandler(nil, userService) + authHandler := handler.NewAuthHandler(cfg, nil, userService) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService) adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil) diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go index 88833d63..b64220d9 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -36,7 +36,7 @@ func ProvideRouter( r := gin.New() r.Use(middleware2.Recovery()) - return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService) + return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, cfg) } // ProvideHTTPServer 提供 HTTP 服务器 diff --git a/backend/internal/server/middleware/api_key_auth.go b/backend/internal/server/middleware/api_key_auth.go index c4620d91..75e508dd 100644 --- a/backend/internal/server/middleware/api_key_auth.go +++ b/backend/internal/server/middleware/api_key_auth.go @@ -5,18 +5,19 @@ import ( "log" "strings" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" ) // NewApiKeyAuthMiddleware 创建 API Key 认证中间件 -func NewApiKeyAuthMiddleware(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService) ApiKeyAuthMiddleware { - return ApiKeyAuthMiddleware(apiKeyAuthWithSubscription(apiKeyService, subscriptionService)) +func NewApiKeyAuthMiddleware(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) ApiKeyAuthMiddleware { + return ApiKeyAuthMiddleware(apiKeyAuthWithSubscription(apiKeyService, subscriptionService, cfg)) } // apiKeyAuthWithSubscription API Key认证中间件(支持订阅验证) -func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService) gin.HandlerFunc { +func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { // 尝试从Authorization header中提取API key (Bearer scheme) authHeader := c.GetHeader("Authorization") @@ -85,6 +86,18 @@ func apiKeyAuthWithSubscription(apiKeyService *service.ApiKeyService, subscripti return } + if cfg.RunMode == config.RunModeSimple { + // 简易模式:跳过余额和订阅检查,但仍需设置必要的上下文 + c.Set(string(ContextKeyApiKey), apiKey) + c.Set(string(ContextKeyUser), AuthSubject{ + UserID: apiKey.User.ID, + Concurrency: apiKey.User.Concurrency, + }) + c.Set(string(ContextKeyUserRole), apiKey.User.Role) + c.Next() + return + } + // 判断计费方式:订阅模式 vs 余额模式 isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType() diff --git a/backend/internal/server/middleware/api_key_auth_google.go b/backend/internal/server/middleware/api_key_auth_google.go index 199aca82..d8f47bd2 100644 --- a/backend/internal/server/middleware/api_key_auth_google.go +++ b/backend/internal/server/middleware/api_key_auth_google.go @@ -4,6 +4,7 @@ import ( "errors" "strings" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/googleapi" "github.com/Wei-Shaw/sub2api/internal/service" @@ -11,15 +12,15 @@ import ( ) // ApiKeyAuthGoogle is a Google-style error wrapper for API key auth. -func ApiKeyAuthGoogle(apiKeyService *service.ApiKeyService) gin.HandlerFunc { - return ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil) +func ApiKeyAuthGoogle(apiKeyService *service.ApiKeyService, cfg *config.Config) gin.HandlerFunc { + return ApiKeyAuthWithSubscriptionGoogle(apiKeyService, nil, cfg) } // ApiKeyAuthWithSubscriptionGoogle behaves like ApiKeyAuthWithSubscription but returns Google-style errors: // {"error":{"code":401,"message":"...","status":"UNAUTHENTICATED"}} // // It is intended for Gemini native endpoints (/v1beta) to match Gemini SDK expectations. -func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService) gin.HandlerFunc { +func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { apiKeyString := extractAPIKeyFromRequest(c) if apiKeyString == "" { @@ -50,6 +51,18 @@ func ApiKeyAuthWithSubscriptionGoogle(apiKeyService *service.ApiKeyService, subs return } + // 简易模式:跳过余额和订阅检查 + if cfg.RunMode == config.RunModeSimple { + c.Set(string(ContextKeyApiKey), apiKey) + c.Set(string(ContextKeyUser), AuthSubject{ + UserID: apiKey.User.ID, + Concurrency: apiKey.User.Concurrency, + }) + c.Set(string(ContextKeyUserRole), apiKey.User.Role) + c.Next() + return + } + isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType() if isSubscriptionType && subscriptionService != nil { subscription, err := subscriptionService.GetActiveSubscription( diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go new file mode 100644 index 00000000..a9d22ede --- /dev/null +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -0,0 +1,286 @@ +//go:build unit + +package middleware + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestSimpleModeBypassesQuotaCheck(t *testing.T) { + gin.SetMode(gin.TestMode) + + limit := 1.0 + group := &service.Group{ + ID: 42, + Name: "sub", + Status: service.StatusActive, + SubscriptionType: service.SubscriptionTypeSubscription, + DailyLimitUSD: &limit, + } + user := &service.User{ + ID: 7, + Role: service.RoleUser, + Status: service.StatusActive, + Balance: 10, + Concurrency: 3, + } + apiKey := &service.ApiKey{ + ID: 100, + UserID: user.ID, + Key: "test-key", + Status: service.StatusActive, + User: user, + Group: group, + } + apiKey.GroupID = &group.ID + + apiKeyRepo := &stubApiKeyRepo{ + getByKey: func(ctx context.Context, key string) (*service.ApiKey, error) { + if key != apiKey.Key { + return nil, service.ErrApiKeyNotFound + } + clone := *apiKey + return &clone, nil + }, + } + + t.Run("simple_mode_bypasses_quota_check", func(t *testing.T) { + cfg := &config.Config{RunMode: config.RunModeSimple} + apiKeyService := service.NewApiKeyService(apiKeyRepo, nil, nil, nil, nil, cfg) + subscriptionService := service.NewSubscriptionService(nil, &stubUserSubscriptionRepo{}, nil) + router := newAuthTestRouter(apiKeyService, subscriptionService, cfg) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/t", nil) + req.Header.Set("x-api-key", apiKey.Key) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("standard_mode_enforces_quota_check", func(t *testing.T) { + cfg := &config.Config{RunMode: config.RunModeStandard} + apiKeyService := service.NewApiKeyService(apiKeyRepo, nil, nil, nil, nil, cfg) + + now := time.Now() + sub := &service.UserSubscription{ + ID: 55, + UserID: user.ID, + GroupID: group.ID, + Status: service.SubscriptionStatusActive, + ExpiresAt: now.Add(24 * time.Hour), + DailyWindowStart: &now, + DailyUsageUSD: 10, + } + subscriptionRepo := &stubUserSubscriptionRepo{ + getActive: func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) { + if userID != sub.UserID || groupID != sub.GroupID { + return nil, service.ErrSubscriptionNotFound + } + clone := *sub + return &clone, nil + }, + updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil }, + activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil }, + resetDaily: func(ctx context.Context, id int64, start time.Time) error { return nil }, + resetWeekly: func(ctx context.Context, id int64, start time.Time) error { return nil }, + resetMonthly: func(ctx context.Context, id int64, start time.Time) error { return nil }, + } + subscriptionService := service.NewSubscriptionService(nil, subscriptionRepo, nil) + router := newAuthTestRouter(apiKeyService, subscriptionService, cfg) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/t", nil) + req.Header.Set("x-api-key", apiKey.Key) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusTooManyRequests, w.Code) + require.Contains(t, w.Body.String(), "USAGE_LIMIT_EXCEEDED") + }) +} + +func newAuthTestRouter(apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) *gin.Engine { + router := gin.New() + router.Use(gin.HandlerFunc(NewApiKeyAuthMiddleware(apiKeyService, subscriptionService, cfg))) + router.GET("/t", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + return router +} + +type stubApiKeyRepo struct { + getByKey func(ctx context.Context, key string) (*service.ApiKey, error) +} + +func (r *stubApiKeyRepo) Create(ctx context.Context, key *service.ApiKey) error { + return errors.New("not implemented") +} + +func (r *stubApiKeyRepo) GetByID(ctx context.Context, id int64) (*service.ApiKey, error) { + return nil, errors.New("not implemented") +} + +func (r *stubApiKeyRepo) GetByKey(ctx context.Context, key string) (*service.ApiKey, error) { + if r.getByKey != nil { + return r.getByKey(ctx, key) + } + return nil, errors.New("not implemented") +} + +func (r *stubApiKeyRepo) Update(ctx context.Context, key *service.ApiKey) error { + return errors.New("not implemented") +} + +func (r *stubApiKeyRepo) Delete(ctx context.Context, id int64) error { + return errors.New("not implemented") +} + +func (r *stubApiKeyRepo) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams) ([]service.ApiKey, *pagination.PaginationResult, error) { + return nil, nil, errors.New("not implemented") +} + +func (r *stubApiKeyRepo) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) { + return nil, errors.New("not implemented") +} + +func (r *stubApiKeyRepo) CountByUserID(ctx context.Context, userID int64) (int64, error) { + return 0, errors.New("not implemented") +} + +func (r *stubApiKeyRepo) ExistsByKey(ctx context.Context, key string) (bool, error) { + return false, errors.New("not implemented") +} + +func (r *stubApiKeyRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.ApiKey, *pagination.PaginationResult, error) { + return nil, nil, errors.New("not implemented") +} + +func (r *stubApiKeyRepo) SearchApiKeys(ctx context.Context, userID int64, keyword string, limit int) ([]service.ApiKey, error) { + return nil, errors.New("not implemented") +} + +func (r *stubApiKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int64) (int64, error) { + return 0, errors.New("not implemented") +} + +func (r *stubApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) { + return 0, errors.New("not implemented") +} + +type stubUserSubscriptionRepo struct { + getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) + updateStatus func(ctx context.Context, subscriptionID int64, status string) error + activateWindow func(ctx context.Context, id int64, start time.Time) error + resetDaily func(ctx context.Context, id int64, start time.Time) error + resetWeekly func(ctx context.Context, id int64, start time.Time) error + resetMonthly func(ctx context.Context, id int64, start time.Time) error +} + +func (r *stubUserSubscriptionRepo) Create(ctx context.Context, sub *service.UserSubscription) error { + return errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) GetByID(ctx context.Context, id int64) (*service.UserSubscription, error) { + return nil, errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) GetByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) { + return nil, errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) GetActiveByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) { + if r.getActive != nil { + return r.getActive(ctx, userID, groupID) + } + return nil, errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) Update(ctx context.Context, sub *service.UserSubscription) error { + return errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) Delete(ctx context.Context, id int64) error { + return errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) ListByUserID(ctx context.Context, userID int64) ([]service.UserSubscription, error) { + return nil, errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) ListActiveByUserID(ctx context.Context, userID int64) ([]service.UserSubscription, error) { + return nil, errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.UserSubscription, *pagination.PaginationResult, error) { + return nil, nil, errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status string) ([]service.UserSubscription, *pagination.PaginationResult, error) { + return nil, nil, errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) { + return false, errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) ExtendExpiry(ctx context.Context, subscriptionID int64, newExpiresAt time.Time) error { + return errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) UpdateStatus(ctx context.Context, subscriptionID int64, status string) error { + if r.updateStatus != nil { + return r.updateStatus(ctx, subscriptionID, status) + } + return errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error { + return errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) ActivateWindows(ctx context.Context, id int64, start time.Time) error { + if r.activateWindow != nil { + return r.activateWindow(ctx, id, start) + } + return errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) ResetDailyUsage(ctx context.Context, id int64, newWindowStart time.Time) error { + if r.resetDaily != nil { + return r.resetDaily(ctx, id, newWindowStart) + } + return errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) ResetWeeklyUsage(ctx context.Context, id int64, newWindowStart time.Time) error { + if r.resetWeekly != nil { + return r.resetWeekly(ctx, id, newWindowStart) + } + return errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) ResetMonthlyUsage(ctx context.Context, id int64, newWindowStart time.Time) error { + if r.resetMonthly != nil { + return r.resetMonthly(ctx, id, newWindowStart) + } + return errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) IncrementUsage(ctx context.Context, id int64, costUSD float64) error { + return errors.New("not implemented") +} + +func (r *stubUserSubscriptionRepo) BatchUpdateExpiredStatus(ctx context.Context) (int64, error) { + return 0, errors.New("not implemented") +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 5489468b..2371dafb 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -1,6 +1,7 @@ package server import ( + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/server/routes" @@ -19,6 +20,7 @@ func SetupRouter( apiKeyAuth middleware2.ApiKeyAuthMiddleware, apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, + cfg *config.Config, ) *gin.Engine { // 应用中间件 r.Use(middleware2.Logger()) @@ -30,7 +32,7 @@ func SetupRouter( } // 注册路由 - registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService) + registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, cfg) return r } @@ -44,6 +46,7 @@ func registerRoutes( apiKeyAuth middleware2.ApiKeyAuthMiddleware, apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, + cfg *config.Config, ) { // 通用路由(健康检查、状态等) routes.RegisterCommonRoutes(r) @@ -55,5 +58,5 @@ func registerRoutes( routes.RegisterAuthRoutes(v1, h, jwtAuth) routes.RegisterUserRoutes(v1, h, jwtAuth) routes.RegisterAdminRoutes(v1, h, adminAuth) - routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService) + routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, cfg) } diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index eab36ef8..27864ba0 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -1,6 +1,7 @@ package routes import ( + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -15,6 +16,7 @@ func RegisterGatewayRoutes( apiKeyAuth middleware.ApiKeyAuthMiddleware, apiKeyService *service.ApiKeyService, subscriptionService *service.SubscriptionService, + cfg *config.Config, ) { // API网关(Claude API兼容) gateway := r.Group("/v1") @@ -30,7 +32,7 @@ func RegisterGatewayRoutes( // Gemini 原生 API 兼容层(Gemini SDK/CLI 直连) gemini := r.Group("/v1beta") - gemini.Use(middleware.ApiKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService)) + gemini.Use(middleware.ApiKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg)) { gemini.GET("/models", h.Gateway.GeminiV1BetaListModels) gemini.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index db207ce5..9ffd342d 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -609,12 +609,30 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou if err := s.accountRepo.Create(ctx, account); err != nil { return nil, err } + // 绑定分组 - if len(input.GroupIDs) > 0 { - if err := s.accountRepo.BindGroups(ctx, account.ID, input.GroupIDs); err != nil { + groupIDs := input.GroupIDs + // 如果没有指定分组,自动绑定对应平台的默认分组 + if len(groupIDs) == 0 { + defaultGroupName := input.Platform + "-default" + groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform) + if err == nil { + for _, g := range groups { + if g.Name == defaultGroupName { + groupIDs = []int64{g.ID} + log.Printf("[CreateAccount] Auto-binding account %d to default group %s (ID: %d)", account.ID, defaultGroupName, g.ID) + break + } + } + } + } + + if len(groupIDs) > 0 { + if err := s.accountRepo.BindGroups(ctx, account.ID, groupIDs); err != nil { return nil, err } } + return account, nil } diff --git a/backend/internal/service/billing_cache_service.go b/backend/internal/service/billing_cache_service.go index 18f125ca..9493a11f 100644 --- a/backend/internal/service/billing_cache_service.go +++ b/backend/internal/service/billing_cache_service.go @@ -6,6 +6,7 @@ import ( "log" "time" + "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/infrastructure/errors" ) @@ -32,14 +33,16 @@ type BillingCacheService struct { cache BillingCache userRepo UserRepository subRepo UserSubscriptionRepository + cfg *config.Config } // NewBillingCacheService 创建计费缓存服务 -func NewBillingCacheService(cache BillingCache, userRepo UserRepository, subRepo UserSubscriptionRepository) *BillingCacheService { +func NewBillingCacheService(cache BillingCache, userRepo UserRepository, subRepo UserSubscriptionRepository, cfg *config.Config) *BillingCacheService { return &BillingCacheService{ cache: cache, userRepo: userRepo, subRepo: subRepo, + cfg: cfg, } } @@ -224,6 +227,11 @@ func (s *BillingCacheService) InvalidateSubscription(ctx context.Context, userID // 余额模式:检查缓存余额 > 0 // 订阅模式:检查缓存用量未超过限额(Group限额从参数传入) func (s *BillingCacheService) CheckBillingEligibility(ctx context.Context, user *User, apiKey *ApiKey, group *Group, subscription *UserSubscription) error { + // 简易模式:跳过所有计费检查 + if s.cfg.RunMode == config.RunModeSimple { + return nil + } + // 判断计费模式 isSubscriptionMode := group != nil && group.IsSubscriptionType() && subscription != nil diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index d4e1a07b..fdff5987 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -313,7 +313,10 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context // 2. 获取可调度账号列表(排除限流和过载的账号,仅限 Anthropic 平台) var accounts []Account var err error - if groupID != nil { + if s.cfg.RunMode == config.RunModeSimple { + // 简易模式:忽略 groupID,查询所有可用账号 + accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, PlatformAnthropic) + } else if groupID != nil { accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformAnthropic) } else { accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, PlatformAnthropic) @@ -1065,6 +1068,12 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu log.Printf("Create usage log failed: %v", err) } + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { + log.Printf("[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens()) + s.deferredService.ScheduleLastUsedUpdate(account.ID) + return nil + } + // 根据计费类型执行扣费 if isSubscriptionBilling { // 订阅模式:更新订阅用量(使用 TotalCost 原始费用,不考虑倍率) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 20bf57f2..79801b29 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "io" + "log" "net/http" "regexp" "strconv" @@ -155,7 +156,10 @@ func (s *OpenAIGatewayService) SelectAccountForModelWithExclusions(ctx context.C // 2. Get schedulable OpenAI accounts var accounts []Account var err error - if groupID != nil { + // 简易模式:忽略分组限制,查询所有可用账号 + if s.cfg.RunMode == config.RunModeSimple { + accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, PlatformOpenAI) + } else if groupID != nil { accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformOpenAI) } else { accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, PlatformOpenAI) @@ -754,6 +758,12 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec _ = s.usageLogRepo.Create(ctx, usageLog) + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { + log.Printf("[SIMPLE MODE] Usage recorded (not billed): user=%d, tokens=%d", usageLog.UserID, usageLog.TotalTokens()) + s.deferredService.ScheduleLastUsedUpdate(account.ID) + return nil + } + // Deduct based on billing type if isSubscriptionBilling { if cost.TotalCost > 0 { diff --git a/deploy/.env.example b/deploy/.env.example index de7ea722..19fcc853 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -20,6 +20,10 @@ SERVER_PORT=8080 # Server mode: release or debug SERVER_MODE=release +# 运行模式: standard (默认) 或 simple (内部自用) +# standard: 完整 SaaS 功能,包含计费/余额校验;simple: 隐藏 SaaS 功能并跳过计费/余额校验 +RUN_MODE=standard + # Timezone TZ=Asia/Shanghai diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index b6df4f65..fcaa7b7c 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -13,6 +13,14 @@ server: # Mode: "debug" for development, "release" for production mode: "release" +# ============================================================================= +# Run Mode Configuration +# ============================================================================= +# Run mode: "standard" (default) or "simple" (for internal use) +# - standard: Full SaaS features with billing/balance checks +# - simple: Hides SaaS features and skips billing/balance checks +run_mode: "standard" + # ============================================================================= # Database Configuration (PostgreSQL) # ============================================================================= diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 9e10ec54..0e3fb16e 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -36,6 +36,7 @@ services: - SERVER_HOST=0.0.0.0 - SERVER_PORT=8080 - SERVER_MODE=${SERVER_MODE:-release} + - RUN_MODE=${RUN_MODE:-standard} # ======================================================================= # Database Configuration (PostgreSQL) diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index ccac8a77..9c5379f2 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -8,7 +8,7 @@ import type { LoginRequest, RegisterRequest, AuthResponse, - User, + CurrentUserResponse, SendVerifyCodeRequest, SendVerifyCodeResponse, PublicSettings @@ -70,9 +70,8 @@ export async function register(userData: RegisterRequest): Promise * Get current authenticated user * @returns User profile data */ -export async function getCurrentUser(): Promise { - const { data } = await apiClient.get('/auth/me') - return data +export async function getCurrentUser() { + return apiClient.get('/auth/me') } /** diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 1e0b4afe..1b247f18 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -964,8 +964,13 @@
- - + + @@ -1076,6 +1081,7 @@ import { ref, reactive, computed, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' +import { useAuthStore } from '@/stores/auth' import { adminAPI } from '@/api/admin' import { useAccountOAuth, @@ -1102,6 +1108,7 @@ interface OAuthFlowExposed { } const { t } = useI18n() +const authStore = useAuthStore() const oauthStepTitle = computed(() => { if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title') diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 75ce204d..3e81ac9a 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -466,8 +466,13 @@