Compare commits

...

12 Commits

Author SHA1 Message Date
shaw
876e85e7ad Merge branch 'feat/rename-go-module' 2025-12-24 21:34:37 +08:00
shaw
2e7818d688 feat(settings): 添加文档链接配置功能
- 后台系统设置新增文档链接(doc_url)配置项
- 首页顶部导航栏显示文档链接图标(条件渲染)
- Footer区域添加文档链接和GitHub链接
- 支持中英文国际化
2025-12-24 21:30:19 +08:00
Forest
836c4dda2b refactor: 重命名 go module 2025-12-24 21:07:21 +08:00
shaw
e65e9587b4 fix(concurrency): 重构并发管理使用独立Key+原生TTL
问题:旧方案使用计数器模式,每次acquire都刷新TTL,导致僵尸数据永不过期

解决方案:
- 每个槽位使用独立Redis Key: concurrency:account:{id}:{requestID}
- 利用Redis原生TTL,每个槽位独立5分钟过期
- 服务崩溃后僵尸数据自动清理,无需手动干预
- 兼容多实例K8s部署

技术改动:
- 新增SCAN脚本统计活跃槽位数量
- 移除冗余的releaseScript,直接使用DEL命令
- Wait队列TTL只在首次创建时设置,避免刷新
2025-12-24 21:00:29 +08:00
shaw
aaadd6ed04 fix(dashboard): 修复性能指标 RPM/TPM 显示为0的问题
- 修复 Admin Dashboard Handler 遗漏返回 rpm/tpm 字段
- 将性能统计时间窗口从1分钟改为5分钟平均值,数据更稳定
2025-12-24 19:58:33 +08:00
shaw
870b21916c feat(install): 添加安装指定版本和回退功能
- 新增 rollback 命令支持回退到指定版本
- 新增 list-versions 命令列出可用版本
- 新增 -v/--version 参数指定安装版本
- upgrade 命令支持升级到指定版本
- 添加安装状态检查,未安装时给出明确提示
- 版本切换仅替换二进制文件,保留配置和数据
- 自动备份当前版本(带版本号或时间戳后缀)
- 改进网络错误处理,添加超时和友好提示
- 修复 grep -oP 兼容性问题,改用 grep -oE
2025-12-24 17:44:13 +08:00
shaw
fb119f9a67 fix(version): 优化服务重启后页面刷新时机
- 将重启后等待时间从 3 秒增加到 8 秒
- 添加倒计时显示,提升用户体验
- 倒计时结束后先检测服务健康状态再刷新页面
- 避免刷新过早导致 502 错误
2025-12-24 17:21:17 +08:00
shaw
ad54795a24 feat(gateway): 添加上游错误重试机制
- OAuth/Setup Token 账号遇到 403 错误时,等待 2 秒后重试,最多 3 次
- Console 账号遇到未配置的错误码时,同样进行重试
- 重试耗尽后:OAuth 403 标记账号异常,Console 未配置错误码不标记账号
- 移除 handleErrorResponse 中已被重试逻辑覆盖的死代码
2025-12-24 16:55:46 +08:00
shaw
0abe322cca feat(accounts): 账户列表显示实时并发数
- 在账户列表 API 返回中添加 current_concurrency 字段
- 合并平台和类型列为 PlatformTypeBadge 组件,节省表格空间
- 新增并发状态列,显示 当前/最大 并发数,支持颜色编码
2025-12-24 15:44:45 +08:00
shaw
b071511676 refactor(accounts): 优化用量窗口显示,统一 OAuth 和 Setup Token 处理
- Setup Token 账号现在也调用 API 获取 5h 窗口用量数据
- 重新设计 UsageProgressBar UI,将用量统计移到进度条上方
- 删除冗余的 SetupTokenTimeWindow 组件
- 请求数/Token数支持 K/M/B 单位显示
2025-12-24 10:57:40 +08:00
shaw
7d9a757a26 feat(dashboard): 添加 RPM/TPM 性能指标
在 Dashboard 中用 RPM/TPM 卡片替换原来的"今日缓存"卡片,
实时显示最近1分钟的请求数和 Token 吞吐量。
2025-12-24 10:24:02 +08:00
Forest
bbf4024dc7 refactor(usage): 移动 usage 查询到 services 2025-12-24 08:41:31 +08:00
124 changed files with 1686 additions and 896 deletions

View File

@@ -17,10 +17,17 @@ linters:
service-no-repository: service-no-repository:
list-mode: original list-mode: original
files: files:
- internal/service/** - "**/internal/service/**"
deny: deny:
- pkg: sub2api/internal/repository - pkg: sub2api/internal/repository
desc: "service must not import repository" desc: "service must not import repository"
handler-no-repository:
list-mode: original
files:
- "**/internal/handler/**"
deny:
- pkg: sub2api/internal/repository
desc: "handler must not import repository"
errcheck: errcheck:
# Report about not checking of errors in type assertions: `a := b.(MyStruct)`. # Report about not checking of errors in type assertions: `a := b.(MyStruct)`.
# Such cases aren't reported by default. # Such cases aren't reported by default.

View File

@@ -15,11 +15,11 @@ import (
"syscall" "syscall"
"time" "time"
"sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"sub2api/internal/handler" "github.com/Wei-Shaw/sub2api/internal/handler"
"sub2api/internal/middleware" "github.com/Wei-Shaw/sub2api/internal/middleware"
"sub2api/internal/setup" "github.com/Wei-Shaw/sub2api/internal/setup"
"sub2api/internal/web" "github.com/Wei-Shaw/sub2api/internal/web"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -4,12 +4,12 @@
package main package main
import ( import (
"sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"sub2api/internal/handler" "github.com/Wei-Shaw/sub2api/internal/handler"
"sub2api/internal/infrastructure" "github.com/Wei-Shaw/sub2api/internal/infrastructure"
"sub2api/internal/repository" "github.com/Wei-Shaw/sub2api/internal/repository"
"sub2api/internal/server" "github.com/Wei-Shaw/sub2api/internal/server"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"context" "context"
"log" "log"

View File

@@ -8,17 +8,17 @@ package main
import ( import (
"context" "context"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
"github.com/Wei-Shaw/sub2api/internal/infrastructure"
"github.com/Wei-Shaw/sub2api/internal/repository"
"github.com/Wei-Shaw/sub2api/internal/server"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"gorm.io/gorm" "gorm.io/gorm"
"log" "log"
"net/http" "net/http"
"sub2api/internal/config"
"sub2api/internal/handler"
"sub2api/internal/handler/admin"
"sub2api/internal/infrastructure"
"sub2api/internal/repository"
"sub2api/internal/server"
"sub2api/internal/service"
"time" "time"
) )
@@ -58,7 +58,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageLogRepository := repository.NewUsageLogRepository(db) usageLogRepository := repository.NewUsageLogRepository(db)
usageService := service.NewUsageService(usageLogRepository, userRepository) usageService := service.NewUsageService(usageLogRepository, userRepository)
usageHandler := handler.NewUsageHandler(usageService, usageLogRepository, apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
redeemCodeRepository := repository.NewRedeemCodeRepository(db) redeemCodeRepository := repository.NewRedeemCodeRepository(db)
billingCache := repository.NewBillingCache(client) billingCache := repository.NewBillingCache(client)
billingCacheService := service.NewBillingCacheService(billingCache, userRepository, userSubscriptionRepository) billingCacheService := service.NewBillingCacheService(billingCache, userRepository, userSubscriptionRepository)
@@ -67,7 +67,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService) redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService)
redeemHandler := handler.NewRedeemHandler(redeemService) redeemHandler := handler.NewRedeemHandler(redeemService)
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService) subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
dashboardHandler := admin.NewDashboardHandler(usageLogRepository) dashboardService := service.NewDashboardService(usageLogRepository)
dashboardHandler := admin.NewDashboardHandler(dashboardService)
accountRepository := repository.NewAccountRepository(db) accountRepository := repository.NewAccountRepository(db)
proxyRepository := repository.NewProxyRepository(db) proxyRepository := repository.NewProxyRepository(db)
proxyExitInfoProber := repository.NewProxyExitInfoProber() proxyExitInfoProber := repository.NewProxyExitInfoProber()
@@ -83,7 +84,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher) accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher)
httpUpstream := repository.NewHTTPUpstream(configConfig) httpUpstream := repository.NewHTTPUpstream(configConfig)
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream) accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream)
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, usageLogRepository) concurrencyCache := repository.NewConcurrencyCache(client)
concurrencyService := service.NewConcurrencyService(concurrencyCache)
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService)
oAuthHandler := admin.NewOAuthHandler(oAuthService) oAuthHandler := admin.NewOAuthHandler(oAuthService)
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService) openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
proxyHandler := admin.NewProxyHandler(adminService) proxyHandler := admin.NewProxyHandler(adminService)
@@ -95,7 +98,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
updateService := service.ProvideUpdateService(updateCache, gitHubReleaseClient, serviceBuildInfo) updateService := service.ProvideUpdateService(updateCache, gitHubReleaseClient, serviceBuildInfo)
systemHandler := handler.ProvideSystemHandler(updateService) systemHandler := handler.ProvideSystemHandler(updateService)
adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService) adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService)
adminUsageHandler := admin.NewUsageHandler(usageLogRepository, apiKeyRepository, usageService, adminService) adminUsageHandler := admin.NewUsageHandler(usageService, apiKeyService, adminService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler) adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler)
gatewayCache := repository.NewGatewayCache(client) gatewayCache := repository.NewGatewayCache(client)
pricingRemoteClient := repository.NewPricingRemoteClient() pricingRemoteClient := repository.NewPricingRemoteClient()
@@ -107,8 +110,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
identityCache := repository.NewIdentityCache(client) identityCache := repository.NewIdentityCache(client)
identityService := service.NewIdentityService(identityCache) identityService := service.NewIdentityService(identityCache)
gatewayService := service.NewGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream) gatewayService := service.NewGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream)
concurrencyCache := repository.NewConcurrencyCache(client)
concurrencyService := service.NewConcurrencyService(concurrencyCache)
gatewayHandler := handler.NewGatewayHandler(gatewayService, userService, concurrencyService, billingCacheService) gatewayHandler := handler.NewGatewayHandler(gatewayService, userService, concurrencyService, billingCacheService)
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream) openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream)
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService) openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService)

View File

@@ -1,4 +1,4 @@
module sub2api module github.com/Wei-Shaw/sub2api
go 1.24.0 go 1.24.0

View File

@@ -3,12 +3,12 @@ package admin
import ( import (
"strconv" "strconv"
"sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/repository" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -33,11 +33,11 @@ type AccountHandler struct {
rateLimitService *service.RateLimitService rateLimitService *service.RateLimitService
accountUsageService *service.AccountUsageService accountUsageService *service.AccountUsageService
accountTestService *service.AccountTestService accountTestService *service.AccountTestService
usageLogRepo *repository.UsageLogRepository concurrencyService *service.ConcurrencyService
} }
// NewAccountHandler creates a new admin account handler // NewAccountHandler creates a new admin account handler
func NewAccountHandler(adminService service.AdminService, oauthService *service.OAuthService, openaiOAuthService *service.OpenAIOAuthService, rateLimitService *service.RateLimitService, accountUsageService *service.AccountUsageService, accountTestService *service.AccountTestService, usageLogRepo *repository.UsageLogRepository) *AccountHandler { func NewAccountHandler(adminService service.AdminService, oauthService *service.OAuthService, openaiOAuthService *service.OpenAIOAuthService, rateLimitService *service.RateLimitService, accountUsageService *service.AccountUsageService, accountTestService *service.AccountTestService, concurrencyService *service.ConcurrencyService) *AccountHandler {
return &AccountHandler{ return &AccountHandler{
adminService: adminService, adminService: adminService,
oauthService: oauthService, oauthService: oauthService,
@@ -45,7 +45,7 @@ func NewAccountHandler(adminService service.AdminService, oauthService *service.
rateLimitService: rateLimitService, rateLimitService: rateLimitService,
accountUsageService: accountUsageService, accountUsageService: accountUsageService,
accountTestService: accountTestService, accountTestService: accountTestService,
usageLogRepo: usageLogRepo, concurrencyService: concurrencyService,
} }
} }
@@ -76,6 +76,12 @@ type UpdateAccountRequest struct {
GroupIDs *[]int64 `json:"group_ids"` GroupIDs *[]int64 `json:"group_ids"`
} }
// AccountWithConcurrency extends Account with real-time concurrency info
type AccountWithConcurrency struct {
*model.Account
CurrentConcurrency int `json:"current_concurrency"`
}
// List handles listing all accounts with pagination // List handles listing all accounts with pagination
// GET /api/v1/admin/accounts // GET /api/v1/admin/accounts
func (h *AccountHandler) List(c *gin.Context) { func (h *AccountHandler) List(c *gin.Context) {
@@ -91,7 +97,28 @@ func (h *AccountHandler) List(c *gin.Context) {
return return
} }
response.Paginated(c, accounts, total, page, pageSize) // Get current concurrency counts for all accounts
accountIDs := make([]int64, len(accounts))
for i, acc := range accounts {
accountIDs[i] = acc.ID
}
concurrencyCounts, err := h.concurrencyService.GetAccountConcurrencyBatch(c.Request.Context(), accountIDs)
if err != nil {
// Log error but don't fail the request, just use 0 for all
concurrencyCounts = make(map[int64]int)
}
// Build response with concurrency info
result := make([]AccountWithConcurrency, len(accounts))
for i := range accounts {
result[i] = AccountWithConcurrency{
Account: &accounts[i],
CurrentConcurrency: concurrencyCounts[accounts[i].ID],
}
}
response.Paginated(c, result, total, page, pageSize)
} }
// GetByID handles getting an account by ID // GetByID handles getting an account by ID
@@ -314,7 +341,7 @@ func (h *AccountHandler) GetStats(c *gin.Context) {
endTime := timezone.StartOfDay(now.AddDate(0, 0, 1)) endTime := timezone.StartOfDay(now.AddDate(0, 0, 1))
startTime := timezone.StartOfDay(now.AddDate(0, 0, -days+1)) startTime := timezone.StartOfDay(now.AddDate(0, 0, -days+1))
stats, err := h.usageLogRepo.GetAccountUsageStats(c.Request.Context(), accountID, startTime, endTime) stats, err := h.accountUsageService.GetAccountUsageStats(c.Request.Context(), accountID, startTime, endTime)
if err != nil { if err != nil {
response.InternalError(c, "Failed to get account stats: "+err.Error()) response.InternalError(c, "Failed to get account stats: "+err.Error())
return return

View File

@@ -1,10 +1,10 @@
package admin package admin
import ( import (
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/service"
"strconv" "strconv"
"sub2api/internal/pkg/response"
"sub2api/internal/pkg/timezone"
"sub2api/internal/repository"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -12,15 +12,15 @@ import (
// DashboardHandler handles admin dashboard statistics // DashboardHandler handles admin dashboard statistics
type DashboardHandler struct { type DashboardHandler struct {
usageRepo *repository.UsageLogRepository dashboardService *service.DashboardService
startTime time.Time // Server start time for uptime calculation startTime time.Time // Server start time for uptime calculation
} }
// NewDashboardHandler creates a new admin dashboard handler // NewDashboardHandler creates a new admin dashboard handler
func NewDashboardHandler(usageRepo *repository.UsageLogRepository) *DashboardHandler { func NewDashboardHandler(dashboardService *service.DashboardService) *DashboardHandler {
return &DashboardHandler{ return &DashboardHandler{
usageRepo: usageRepo, dashboardService: dashboardService,
startTime: time.Now(), startTime: time.Now(),
} }
} }
@@ -58,7 +58,7 @@ func parseTimeRange(c *gin.Context) (time.Time, time.Time) {
// GetStats handles getting dashboard statistics // GetStats handles getting dashboard statistics
// GET /api/v1/admin/dashboard/stats // GET /api/v1/admin/dashboard/stats
func (h *DashboardHandler) GetStats(c *gin.Context) { func (h *DashboardHandler) GetStats(c *gin.Context) {
stats, err := h.usageRepo.GetDashboardStats(c.Request.Context()) stats, err := h.dashboardService.GetDashboardStats(c.Request.Context())
if err != nil { if err != nil {
response.Error(c, 500, "Failed to get dashboard statistics") response.Error(c, 500, "Failed to get dashboard statistics")
return return
@@ -107,6 +107,10 @@ func (h *DashboardHandler) GetStats(c *gin.Context) {
// 系统运行统计 // 系统运行统计
"average_duration_ms": stats.AverageDurationMs, "average_duration_ms": stats.AverageDurationMs,
"uptime": uptime, "uptime": uptime,
// 性能指标
"rpm": stats.Rpm,
"tpm": stats.Tpm,
}) })
} }
@@ -142,7 +146,7 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
} }
} }
trend, err := h.usageRepo.GetUsageTrendWithFilters(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID) trend, err := h.dashboardService.GetUsageTrendWithFilters(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID)
if err != nil { if err != nil {
response.Error(c, 500, "Failed to get usage trend") response.Error(c, 500, "Failed to get usage trend")
return return
@@ -175,7 +179,7 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
} }
} }
stats, err := h.usageRepo.GetModelStatsWithFilters(c.Request.Context(), startTime, endTime, userID, apiKeyID, 0) stats, err := h.dashboardService.GetModelStatsWithFilters(c.Request.Context(), startTime, endTime, userID, apiKeyID)
if err != nil { if err != nil {
response.Error(c, 500, "Failed to get model statistics") response.Error(c, 500, "Failed to get model statistics")
return return
@@ -200,7 +204,7 @@ func (h *DashboardHandler) GetApiKeyUsageTrend(c *gin.Context) {
limit = 5 limit = 5
} }
trend, err := h.usageRepo.GetApiKeyUsageTrend(c.Request.Context(), startTime, endTime, granularity, limit) trend, err := h.dashboardService.GetApiKeyUsageTrend(c.Request.Context(), startTime, endTime, granularity, limit)
if err != nil { if err != nil {
response.Error(c, 500, "Failed to get API key usage trend") response.Error(c, 500, "Failed to get API key usage trend")
return return
@@ -226,7 +230,7 @@ func (h *DashboardHandler) GetUserUsageTrend(c *gin.Context) {
limit = 12 limit = 12
} }
trend, err := h.usageRepo.GetUserUsageTrend(c.Request.Context(), startTime, endTime, granularity, limit) trend, err := h.dashboardService.GetUserUsageTrend(c.Request.Context(), startTime, endTime, granularity, limit)
if err != nil { if err != nil {
response.Error(c, 500, "Failed to get user usage trend") response.Error(c, 500, "Failed to get user usage trend")
return return
@@ -259,7 +263,7 @@ func (h *DashboardHandler) GetBatchUsersUsage(c *gin.Context) {
return return
} }
stats, err := h.usageRepo.GetBatchUserUsageStats(c.Request.Context(), req.UserIDs) stats, err := h.dashboardService.GetBatchUserUsageStats(c.Request.Context(), req.UserIDs)
if err != nil { if err != nil {
response.Error(c, 500, "Failed to get user usage stats") response.Error(c, 500, "Failed to get user usage stats")
return return
@@ -287,7 +291,7 @@ func (h *DashboardHandler) GetBatchApiKeysUsage(c *gin.Context) {
return return
} }
stats, err := h.usageRepo.GetBatchApiKeyUsageStats(c.Request.Context(), req.ApiKeyIDs) stats, err := h.dashboardService.GetBatchApiKeyUsageStats(c.Request.Context(), req.ApiKeyIDs)
if err != nil { if err != nil {
response.Error(c, 500, "Failed to get API key usage stats") response.Error(c, 500, "Failed to get API key usage stats")
return return

View File

@@ -3,9 +3,9 @@ package admin
import ( import (
"strconv" "strconv"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -3,8 +3,8 @@ package admin
import ( import (
"strconv" "strconv"
"sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -4,8 +4,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -6,8 +6,8 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -1,9 +1,9 @@
package admin package admin
import ( import (
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -60,6 +60,7 @@ type UpdateSettingsRequest struct {
SiteSubtitle string `json:"site_subtitle"` SiteSubtitle string `json:"site_subtitle"`
ApiBaseUrl string `json:"api_base_url"` ApiBaseUrl string `json:"api_base_url"`
ContactInfo string `json:"contact_info"` ContactInfo string `json:"contact_info"`
DocUrl string `json:"doc_url"`
// 默认配置 // 默认配置
DefaultConcurrency int `json:"default_concurrency"` DefaultConcurrency int `json:"default_concurrency"`
@@ -104,6 +105,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
SiteSubtitle: req.SiteSubtitle, SiteSubtitle: req.SiteSubtitle,
ApiBaseUrl: req.ApiBaseUrl, ApiBaseUrl: req.ApiBaseUrl,
ContactInfo: req.ContactInfo, ContactInfo: req.ContactInfo,
DocUrl: req.DocUrl,
DefaultConcurrency: req.DefaultConcurrency, DefaultConcurrency: req.DefaultConcurrency,
DefaultBalance: req.DefaultBalance, DefaultBalance: req.DefaultBalance,
} }

View File

@@ -3,10 +3,10 @@ package admin
import ( import (
"strconv" "strconv"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -4,9 +4,9 @@ import (
"net/http" "net/http"
"time" "time"
"sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/pkg/sysutil" "github.com/Wei-Shaw/sub2api/internal/pkg/sysutil"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -4,35 +4,32 @@ import (
"strconv" "strconv"
"time" "time"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"sub2api/internal/repository" "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// UsageHandler handles admin usage-related requests // UsageHandler handles admin usage-related requests
type UsageHandler struct { type UsageHandler struct {
usageRepo *repository.UsageLogRepository usageService *service.UsageService
apiKeyRepo *repository.ApiKeyRepository apiKeyService *service.ApiKeyService
usageService *service.UsageService adminService service.AdminService
adminService service.AdminService
} }
// NewUsageHandler creates a new admin usage handler // NewUsageHandler creates a new admin usage handler
func NewUsageHandler( func NewUsageHandler(
usageRepo *repository.UsageLogRepository,
apiKeyRepo *repository.ApiKeyRepository,
usageService *service.UsageService, usageService *service.UsageService,
apiKeyService *service.ApiKeyService,
adminService service.AdminService, adminService service.AdminService,
) *UsageHandler { ) *UsageHandler {
return &UsageHandler{ return &UsageHandler{
usageRepo: usageRepo, usageService: usageService,
apiKeyRepo: apiKeyRepo, apiKeyService: apiKeyService,
usageService: usageService, adminService: adminService,
adminService: adminService,
} }
} }
@@ -84,14 +81,14 @@ func (h *UsageHandler) List(c *gin.Context) {
} }
params := pagination.PaginationParams{Page: page, PageSize: pageSize} params := pagination.PaginationParams{Page: page, PageSize: pageSize}
filters := repository.UsageLogFilters{ filters := usagestats.UsageLogFilters{
UserID: userID, UserID: userID,
ApiKeyID: apiKeyID, ApiKeyID: apiKeyID,
StartTime: startTime, StartTime: startTime,
EndTime: endTime, EndTime: endTime,
} }
records, result, err := h.usageRepo.ListWithFilters(c.Request.Context(), params, filters) records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
if err != nil { if err != nil {
response.InternalError(c, "Failed to list usage records: "+err.Error()) response.InternalError(c, "Failed to list usage records: "+err.Error())
return return
@@ -179,7 +176,7 @@ func (h *UsageHandler) Stats(c *gin.Context) {
} }
// Get global stats // Get global stats
stats, err := h.usageRepo.GetGlobalStats(c.Request.Context(), startTime, endTime) stats, err := h.usageService.GetGlobalStats(c.Request.Context(), startTime, endTime)
if err != nil { if err != nil {
response.InternalError(c, "Failed to get usage statistics: "+err.Error()) response.InternalError(c, "Failed to get usage statistics: "+err.Error())
return return
@@ -237,7 +234,7 @@ func (h *UsageHandler) SearchApiKeys(c *gin.Context) {
userID = id userID = id
} }
keys, err := h.apiKeyRepo.SearchApiKeys(c.Request.Context(), userID, keyword, 30) keys, err := h.apiKeyService.SearchApiKeys(c.Request.Context(), userID, keyword, 30)
if err != nil { if err != nil {
response.InternalError(c, "Failed to search API keys: "+err.Error()) response.InternalError(c, "Failed to search API keys: "+err.Error())
return return

View File

@@ -3,8 +3,8 @@ package admin
import ( import (
"strconv" "strconv"
"sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -3,10 +3,10 @@ package handler
import ( import (
"strconv" "strconv"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -1,9 +1,9 @@
package handler package handler
import ( import (
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -10,11 +10,11 @@ import (
"strings" "strings"
"time" "time"
"sub2api/internal/middleware" "github.com/Wei-Shaw/sub2api/internal/middleware"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -6,8 +6,8 @@ import (
"net/http" "net/http"
"time" "time"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -1,7 +1,7 @@
package handler package handler
import ( import (
"sub2api/internal/handler/admin" "github.com/Wei-Shaw/sub2api/internal/handler/admin"
) )
// AdminHandlers contains all admin-related HTTP handlers // AdminHandlers contains all admin-related HTTP handlers

View File

@@ -9,9 +9,9 @@ import (
"net/http" "net/http"
"time" "time"
"sub2api/internal/middleware" "github.com/Wei-Shaw/sub2api/internal/middleware"
"sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -1,9 +1,9 @@
package handler package handler
import ( import (
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -1,8 +1,8 @@
package handler package handler
import ( import (
"sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -1,9 +1,9 @@
package handler package handler
import ( import (
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -4,12 +4,11 @@ import (
"strconv" "strconv"
"time" "time"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"sub2api/internal/repository" "github.com/Wei-Shaw/sub2api/internal/service"
"sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -17,15 +16,13 @@ import (
// UsageHandler handles usage-related requests // UsageHandler handles usage-related requests
type UsageHandler struct { type UsageHandler struct {
usageService *service.UsageService usageService *service.UsageService
usageRepo *repository.UsageLogRepository
apiKeyService *service.ApiKeyService apiKeyService *service.ApiKeyService
} }
// NewUsageHandler creates a new UsageHandler // NewUsageHandler creates a new UsageHandler
func NewUsageHandler(usageService *service.UsageService, usageRepo *repository.UsageLogRepository, apiKeyService *service.ApiKeyService) *UsageHandler { func NewUsageHandler(usageService *service.UsageService, apiKeyService *service.ApiKeyService) *UsageHandler {
return &UsageHandler{ return &UsageHandler{
usageService: usageService, usageService: usageService,
usageRepo: usageRepo,
apiKeyService: apiKeyService, apiKeyService: apiKeyService,
} }
} }
@@ -260,7 +257,7 @@ func (h *UsageHandler) DashboardStats(c *gin.Context) {
return return
} }
stats, err := h.usageRepo.GetUserDashboardStats(c.Request.Context(), user.ID) stats, err := h.usageService.GetUserDashboardStats(c.Request.Context(), user.ID)
if err != nil { if err != nil {
response.InternalError(c, "Failed to get dashboard statistics") response.InternalError(c, "Failed to get dashboard statistics")
return return
@@ -287,7 +284,7 @@ func (h *UsageHandler) DashboardTrend(c *gin.Context) {
startTime, endTime := parseUserTimeRange(c) startTime, endTime := parseUserTimeRange(c)
granularity := c.DefaultQuery("granularity", "day") granularity := c.DefaultQuery("granularity", "day")
trend, err := h.usageRepo.GetUserUsageTrendByUserID(c.Request.Context(), user.ID, startTime, endTime, granularity) trend, err := h.usageService.GetUserUsageTrendByUserID(c.Request.Context(), user.ID, startTime, endTime, granularity)
if err != nil { if err != nil {
response.InternalError(c, "Failed to get usage trend") response.InternalError(c, "Failed to get usage trend")
return return
@@ -318,7 +315,7 @@ func (h *UsageHandler) DashboardModels(c *gin.Context) {
startTime, endTime := parseUserTimeRange(c) startTime, endTime := parseUserTimeRange(c)
stats, err := h.usageRepo.GetUserModelStats(c.Request.Context(), user.ID, startTime, endTime) stats, err := h.usageService.GetUserModelStats(c.Request.Context(), user.ID, startTime, endTime)
if err != nil { if err != nil {
response.InternalError(c, "Failed to get model statistics") response.InternalError(c, "Failed to get model statistics")
return return
@@ -387,7 +384,7 @@ func (h *UsageHandler) DashboardApiKeysUsage(c *gin.Context) {
return return
} }
stats, err := h.usageRepo.GetBatchApiKeyUsageStats(c.Request.Context(), validApiKeyIDs) stats, err := h.usageService.GetBatchApiKeyUsageStats(c.Request.Context(), validApiKeyIDs)
if err != nil { if err != nil {
response.InternalError(c, "Failed to get API key usage stats") response.InternalError(c, "Failed to get API key usage stats")
return return

View File

@@ -1,9 +1,9 @@
package handler package handler
import ( import (
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -1,8 +1,8 @@
package handler package handler
import ( import (
"sub2api/internal/handler/admin" "github.com/Wei-Shaw/sub2api/internal/handler/admin"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/google/wire" "github.com/google/wire"
) )

View File

@@ -1,9 +1,9 @@
package infrastructure package infrastructure
import ( import (
"sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"

View File

@@ -1,7 +1,7 @@
package infrastructure package infrastructure
import ( import (
"sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
) )

View File

@@ -1,7 +1,7 @@
package infrastructure package infrastructure
import ( import (
"sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/google/wire" "github.com/google/wire"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"

View File

@@ -3,9 +3,9 @@ package middleware
import ( import (
"context" "context"
"crypto/subtle" "crypto/subtle"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/service"
"strings" "strings"
"sub2api/internal/model"
"sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -1,7 +1,7 @@
package middleware package middleware
import ( import (
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -3,9 +3,9 @@ package middleware
import ( import (
"context" "context"
"errors" "errors"
"github.com/Wei-Shaw/sub2api/internal/model"
"log" "log"
"strings" "strings"
"sub2api/internal/model"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"

View File

@@ -2,9 +2,9 @@ package middleware
import ( import (
"context" "context"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/service"
"strings" "strings"
"sub2api/internal/model"
"sub2api/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -42,6 +42,7 @@ const (
SettingKeySiteSubtitle = "site_subtitle" // 网站副标题 SettingKeySiteSubtitle = "site_subtitle" // 网站副标题
SettingKeyApiBaseUrl = "api_base_url" // API端点地址用于客户端配置和导入 SettingKeyApiBaseUrl = "api_base_url" // API端点地址用于客户端配置和导入
SettingKeyContactInfo = "contact_info" // 客服联系方式 SettingKeyContactInfo = "contact_info" // 客服联系方式
SettingKeyDocUrl = "doc_url" // 文档链接
// 默认配置 // 默认配置
SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量 SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量
@@ -80,6 +81,7 @@ type SystemSettings struct {
SiteSubtitle string `json:"site_subtitle"` SiteSubtitle string `json:"site_subtitle"`
ApiBaseUrl string `json:"api_base_url"` ApiBaseUrl string `json:"api_base_url"`
ContactInfo string `json:"contact_info"` ContactInfo string `json:"contact_info"`
DocUrl string `json:"doc_url"`
// 默认配置 // 默认配置
DefaultConcurrency int `json:"default_concurrency"` DefaultConcurrency int `json:"default_concurrency"`
@@ -97,5 +99,6 @@ type PublicSettings struct {
SiteSubtitle string `json:"site_subtitle"` SiteSubtitle string `json:"site_subtitle"`
ApiBaseUrl string `json:"api_base_url"` ApiBaseUrl string `json:"api_base_url"`
ContactInfo string `json:"contact_info"` ContactInfo string `json:"contact_info"`
DocUrl string `json:"doc_url"`
Version string `json:"version"` Version string `json:"version"`
} }

View File

@@ -0,0 +1,209 @@
package usagestats
import "time"
// DashboardStats 仪表盘统计
type DashboardStats struct {
// 用户统计
TotalUsers int64 `json:"total_users"`
TodayNewUsers int64 `json:"today_new_users"` // 今日新增用户数
ActiveUsers int64 `json:"active_users"` // 今日有请求的用户数
// API Key 统计
TotalApiKeys int64 `json:"total_api_keys"`
ActiveApiKeys int64 `json:"active_api_keys"` // 状态为 active 的 API Key 数
// 账户统计
TotalAccounts int64 `json:"total_accounts"`
NormalAccounts int64 `json:"normal_accounts"` // 正常账户数 (schedulable=true, status=active)
ErrorAccounts int64 `json:"error_accounts"` // 异常账户数 (status=error)
RateLimitAccounts int64 `json:"ratelimit_accounts"` // 限流账户数
OverloadAccounts int64 `json:"overload_accounts"` // 过载账户数
// 累计 Token 使用统计
TotalRequests int64 `json:"total_requests"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
TotalTokens int64 `json:"total_tokens"`
TotalCost float64 `json:"total_cost"` // 累计标准计费
TotalActualCost float64 `json:"total_actual_cost"` // 累计实际扣除
// 今日 Token 使用统计
TodayRequests int64 `json:"today_requests"`
TodayInputTokens int64 `json:"today_input_tokens"`
TodayOutputTokens int64 `json:"today_output_tokens"`
TodayCacheCreationTokens int64 `json:"today_cache_creation_tokens"`
TodayCacheReadTokens int64 `json:"today_cache_read_tokens"`
TodayTokens int64 `json:"today_tokens"`
TodayCost float64 `json:"today_cost"` // 今日标准计费
TodayActualCost float64 `json:"today_actual_cost"` // 今日实际扣除
// 系统运行统计
AverageDurationMs float64 `json:"average_duration_ms"` // 平均响应时间
// 性能指标
Rpm int64 `json:"rpm"` // 近5分钟平均每分钟请求数
Tpm int64 `json:"tpm"` // 近5分钟平均每分钟Token数
}
// TrendDataPoint represents a single point in trend data
type TrendDataPoint struct {
Date string `json:"date"`
Requests int64 `json:"requests"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheTokens int64 `json:"cache_tokens"`
TotalTokens int64 `json:"total_tokens"`
Cost float64 `json:"cost"` // 标准计费
ActualCost float64 `json:"actual_cost"` // 实际扣除
}
// ModelStat represents usage statistics for a single model
type ModelStat struct {
Model string `json:"model"`
Requests int64 `json:"requests"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
Cost float64 `json:"cost"` // 标准计费
ActualCost float64 `json:"actual_cost"` // 实际扣除
}
// UserUsageTrendPoint represents user usage trend data point
type UserUsageTrendPoint struct {
Date string `json:"date"`
UserID int64 `json:"user_id"`
Email string `json:"email"`
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
Cost float64 `json:"cost"` // 标准计费
ActualCost float64 `json:"actual_cost"` // 实际扣除
}
// ApiKeyUsageTrendPoint represents API key usage trend data point
type ApiKeyUsageTrendPoint struct {
Date string `json:"date"`
ApiKeyID int64 `json:"api_key_id"`
KeyName string `json:"key_name"`
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
}
// UserDashboardStats 用户仪表盘统计
type UserDashboardStats struct {
// API Key 统计
TotalApiKeys int64 `json:"total_api_keys"`
ActiveApiKeys int64 `json:"active_api_keys"`
// 累计 Token 使用统计
TotalRequests int64 `json:"total_requests"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
TotalTokens int64 `json:"total_tokens"`
TotalCost float64 `json:"total_cost"` // 累计标准计费
TotalActualCost float64 `json:"total_actual_cost"` // 累计实际扣除
// 今日 Token 使用统计
TodayRequests int64 `json:"today_requests"`
TodayInputTokens int64 `json:"today_input_tokens"`
TodayOutputTokens int64 `json:"today_output_tokens"`
TodayCacheCreationTokens int64 `json:"today_cache_creation_tokens"`
TodayCacheReadTokens int64 `json:"today_cache_read_tokens"`
TodayTokens int64 `json:"today_tokens"`
TodayCost float64 `json:"today_cost"` // 今日标准计费
TodayActualCost float64 `json:"today_actual_cost"` // 今日实际扣除
// 性能统计
AverageDurationMs float64 `json:"average_duration_ms"`
// 性能指标
Rpm int64 `json:"rpm"` // 近5分钟平均每分钟请求数
Tpm int64 `json:"tpm"` // 近5分钟平均每分钟Token数
}
// UsageLogFilters represents filters for usage log queries
type UsageLogFilters struct {
UserID int64
ApiKeyID int64
StartTime *time.Time
EndTime *time.Time
}
// UsageStats represents usage statistics
type UsageStats struct {
TotalRequests int64 `json:"total_requests"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
TotalCacheTokens int64 `json:"total_cache_tokens"`
TotalTokens int64 `json:"total_tokens"`
TotalCost float64 `json:"total_cost"`
TotalActualCost float64 `json:"total_actual_cost"`
AverageDurationMs float64 `json:"average_duration_ms"`
}
// BatchUserUsageStats represents usage stats for a single user
type BatchUserUsageStats struct {
UserID int64 `json:"user_id"`
TodayActualCost float64 `json:"today_actual_cost"`
TotalActualCost float64 `json:"total_actual_cost"`
}
// BatchApiKeyUsageStats represents usage stats for a single API key
type BatchApiKeyUsageStats struct {
ApiKeyID int64 `json:"api_key_id"`
TodayActualCost float64 `json:"today_actual_cost"`
TotalActualCost float64 `json:"total_actual_cost"`
}
// AccountUsageHistory represents daily usage history for an account
type AccountUsageHistory struct {
Date string `json:"date"`
Label string `json:"label"`
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
Cost float64 `json:"cost"`
ActualCost float64 `json:"actual_cost"`
}
// AccountUsageSummary represents summary statistics for an account
type AccountUsageSummary struct {
Days int `json:"days"`
ActualDaysUsed int `json:"actual_days_used"`
TotalCost float64 `json:"total_cost"`
TotalStandardCost float64 `json:"total_standard_cost"`
TotalRequests int64 `json:"total_requests"`
TotalTokens int64 `json:"total_tokens"`
AvgDailyCost float64 `json:"avg_daily_cost"`
AvgDailyRequests float64 `json:"avg_daily_requests"`
AvgDailyTokens float64 `json:"avg_daily_tokens"`
AvgDurationMs float64 `json:"avg_duration_ms"`
Today *struct {
Date string `json:"date"`
Cost float64 `json:"cost"`
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
} `json:"today"`
HighestCostDay *struct {
Date string `json:"date"`
Label string `json:"label"`
Cost float64 `json:"cost"`
Requests int64 `json:"requests"`
} `json:"highest_cost_day"`
HighestRequestDay *struct {
Date string `json:"date"`
Label string `json:"label"`
Requests int64 `json:"requests"`
Cost float64 `json:"cost"`
} `json:"highest_request_day"`
}
// AccountUsageStatsResponse represents the full usage statistics response for an account
type AccountUsageStatsResponse struct {
History []AccountUsageHistory `json:"history"`
Summary AccountUsageSummary `json:"summary"`
Models []ModelStat `json:"models"`
}

View File

@@ -2,8 +2,8 @@ package repository
import ( import (
"context" "context"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"

View File

@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"time" "time"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
) )

View File

@@ -2,8 +2,8 @@ package repository
import ( import (
"context" "context"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@@ -8,7 +8,7 @@ import (
"strconv" "strconv"
"time" "time"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
) )

View File

@@ -10,8 +10,8 @@ import (
"strings" "strings"
"time" "time"
"sub2api/internal/pkg/oauth" "github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/imroc/req/v3" "github.com/imroc/req/v3"
) )

View File

@@ -9,7 +9,7 @@ import (
"net/url" "net/url"
"time" "time"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
) )
type claudeUsageService struct{} type claudeUsageService struct{}

View File

@@ -5,60 +5,95 @@ import (
"fmt" "fmt"
"time" "time"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
) )
const ( const (
accountConcurrencyKeyPrefix = "concurrency:account:" // Key prefixes for independent slot keys
userConcurrencyKeyPrefix = "concurrency:user:" // Format: concurrency:account:{accountID}:{requestID}
waitQueueKeyPrefix = "concurrency:wait:" accountSlotKeyPrefix = "concurrency:account:"
concurrencyTTL = 5 * time.Minute // Format: concurrency:user:{userID}:{requestID}
userSlotKeyPrefix = "concurrency:user:"
// Wait queue keeps counter format: concurrency:wait:{userID}
waitQueueKeyPrefix = "concurrency:wait:"
// Slot TTL - each slot expires independently
slotTTL = 5 * time.Minute
) )
var ( var (
// acquireScript uses SCAN to count existing slots and creates new slot if under limit
// KEYS[1] = pattern for SCAN (e.g., "concurrency:account:2:*")
// KEYS[2] = full slot key (e.g., "concurrency:account:2:req_xxx")
// ARGV[1] = maxConcurrency
// ARGV[2] = TTL in seconds
acquireScript = redis.NewScript(` acquireScript = redis.NewScript(`
local current = redis.call('GET', KEYS[1]) local pattern = KEYS[1]
if current == false then local slotKey = KEYS[2]
current = 0 local maxConcurrency = tonumber(ARGV[1])
else local ttl = tonumber(ARGV[2])
current = tonumber(current)
end -- Count existing slots using SCAN
if current < tonumber(ARGV[1]) then local cursor = "0"
redis.call('INCR', KEYS[1]) local count = 0
redis.call('EXPIRE', KEYS[1], ARGV[2]) repeat
local result = redis.call('SCAN', cursor, 'MATCH', pattern, 'COUNT', 100)
cursor = result[1]
count = count + #result[2]
until cursor == "0"
-- Check if we can acquire a slot
if count < maxConcurrency then
redis.call('SET', slotKey, '1', 'EX', ttl)
return 1 return 1
end end
return 0 return 0
`) `)
releaseScript = redis.NewScript(` // getCountScript counts slots using SCAN
local current = redis.call('GET', KEYS[1]) // KEYS[1] = pattern for SCAN
if current ~= false and tonumber(current) > 0 then getCountScript = redis.NewScript(`
redis.call('DECR', KEYS[1]) local pattern = KEYS[1]
end local cursor = "0"
return 1 local count = 0
repeat
local result = redis.call('SCAN', cursor, 'MATCH', pattern, 'COUNT', 100)
cursor = result[1]
count = count + #result[2]
until cursor == "0"
return count
`) `)
// incrementWaitScript - only sets TTL on first creation to avoid refreshing
// KEYS[1] = wait queue key
// ARGV[1] = maxWait
// ARGV[2] = TTL in seconds
incrementWaitScript = redis.NewScript(` incrementWaitScript = redis.NewScript(`
local waitKey = KEYS[1] local current = redis.call('GET', KEYS[1])
local maxWait = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local current = redis.call('GET', waitKey)
if current == false then if current == false then
current = 0 current = 0
else else
current = tonumber(current) current = tonumber(current)
end end
if current >= maxWait then
if current >= tonumber(ARGV[1]) then
return 0 return 0
end end
redis.call('INCR', waitKey)
redis.call('EXPIRE', waitKey, ttl) local newVal = redis.call('INCR', KEYS[1])
-- Only set TTL on first creation to avoid refreshing zombie data
if newVal == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
return 1 return 1
`) `)
// decrementWaitScript - same as before
decrementWaitScript = redis.NewScript(` decrementWaitScript = redis.NewScript(`
local current = redis.call('GET', KEYS[1]) local current = redis.call('GET', KEYS[1])
if current ~= false and tonumber(current) > 0 then if current ~= false and tonumber(current) > 0 then
@@ -76,49 +111,86 @@ func NewConcurrencyCache(rdb *redis.Client) ports.ConcurrencyCache {
return &concurrencyCache{rdb: rdb} return &concurrencyCache{rdb: rdb}
} }
func (c *concurrencyCache) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int) (bool, error) { // Helper functions for key generation
key := fmt.Sprintf("%s%d", accountConcurrencyKeyPrefix, accountID) func accountSlotKey(accountID int64, requestID string) string {
result, err := acquireScript.Run(ctx, c.rdb, []string{key}, maxConcurrency, int(concurrencyTTL.Seconds())).Int() return fmt.Sprintf("%s%d:%s", accountSlotKeyPrefix, accountID, requestID)
}
func accountSlotPattern(accountID int64) string {
return fmt.Sprintf("%s%d:*", accountSlotKeyPrefix, accountID)
}
func userSlotKey(userID int64, requestID string) string {
return fmt.Sprintf("%s%d:%s", userSlotKeyPrefix, userID, requestID)
}
func userSlotPattern(userID int64) string {
return fmt.Sprintf("%s%d:*", userSlotKeyPrefix, userID)
}
func waitQueueKey(userID int64) string {
return fmt.Sprintf("%s%d", waitQueueKeyPrefix, userID)
}
// Account slot operations
func (c *concurrencyCache) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
pattern := accountSlotPattern(accountID)
slotKey := accountSlotKey(accountID, requestID)
result, err := acquireScript.Run(ctx, c.rdb, []string{pattern, slotKey}, maxConcurrency, int(slotTTL.Seconds())).Int()
if err != nil { if err != nil {
return false, err return false, err
} }
return result == 1, nil return result == 1, nil
} }
func (c *concurrencyCache) ReleaseAccountSlot(ctx context.Context, accountID int64) error { func (c *concurrencyCache) ReleaseAccountSlot(ctx context.Context, accountID int64, requestID string) error {
key := fmt.Sprintf("%s%d", accountConcurrencyKeyPrefix, accountID) slotKey := accountSlotKey(accountID, requestID)
_, err := releaseScript.Run(ctx, c.rdb, []string{key}).Result() return c.rdb.Del(ctx, slotKey).Err()
return err
} }
func (c *concurrencyCache) GetAccountConcurrency(ctx context.Context, accountID int64) (int, error) { func (c *concurrencyCache) GetAccountConcurrency(ctx context.Context, accountID int64) (int, error) {
key := fmt.Sprintf("%s%d", accountConcurrencyKeyPrefix, accountID) pattern := accountSlotPattern(accountID)
return c.rdb.Get(ctx, key).Int() result, err := getCountScript.Run(ctx, c.rdb, []string{pattern}).Int()
if err != nil {
return 0, err
}
return result, nil
} }
func (c *concurrencyCache) AcquireUserSlot(ctx context.Context, userID int64, maxConcurrency int) (bool, error) { // User slot operations
key := fmt.Sprintf("%s%d", userConcurrencyKeyPrefix, userID)
result, err := acquireScript.Run(ctx, c.rdb, []string{key}, maxConcurrency, int(concurrencyTTL.Seconds())).Int() func (c *concurrencyCache) AcquireUserSlot(ctx context.Context, userID int64, maxConcurrency int, requestID string) (bool, error) {
pattern := userSlotPattern(userID)
slotKey := userSlotKey(userID, requestID)
result, err := acquireScript.Run(ctx, c.rdb, []string{pattern, slotKey}, maxConcurrency, int(slotTTL.Seconds())).Int()
if err != nil { if err != nil {
return false, err return false, err
} }
return result == 1, nil return result == 1, nil
} }
func (c *concurrencyCache) ReleaseUserSlot(ctx context.Context, userID int64) error { func (c *concurrencyCache) ReleaseUserSlot(ctx context.Context, userID int64, requestID string) error {
key := fmt.Sprintf("%s%d", userConcurrencyKeyPrefix, userID) slotKey := userSlotKey(userID, requestID)
_, err := releaseScript.Run(ctx, c.rdb, []string{key}).Result() return c.rdb.Del(ctx, slotKey).Err()
return err
} }
func (c *concurrencyCache) GetUserConcurrency(ctx context.Context, userID int64) (int, error) { func (c *concurrencyCache) GetUserConcurrency(ctx context.Context, userID int64) (int, error) {
key := fmt.Sprintf("%s%d", userConcurrencyKeyPrefix, userID) pattern := userSlotPattern(userID)
return c.rdb.Get(ctx, key).Int() result, err := getCountScript.Run(ctx, c.rdb, []string{pattern}).Int()
if err != nil {
return 0, err
}
return result, nil
} }
// Wait queue operations
func (c *concurrencyCache) IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error) { func (c *concurrencyCache) IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error) {
key := fmt.Sprintf("%s%d", waitQueueKeyPrefix, userID) key := waitQueueKey(userID)
result, err := incrementWaitScript.Run(ctx, c.rdb, []string{key}, maxWait, int(concurrencyTTL.Seconds())).Int() result, err := incrementWaitScript.Run(ctx, c.rdb, []string{key}, maxWait, int(slotTTL.Seconds())).Int()
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -126,7 +198,7 @@ func (c *concurrencyCache) IncrementWaitCount(ctx context.Context, userID int64,
} }
func (c *concurrencyCache) DecrementWaitCount(ctx context.Context, userID int64) error { func (c *concurrencyCache) DecrementWaitCount(ctx context.Context, userID int64) error {
key := fmt.Sprintf("%s%d", waitQueueKeyPrefix, userID) key := waitQueueKey(userID)
_, err := decrementWaitScript.Run(ctx, c.rdb, []string{key}).Result() _, err := decrementWaitScript.Run(ctx, c.rdb, []string{key}).Result()
return err return err
} }

View File

@@ -5,7 +5,7 @@ import (
"encoding/json" "encoding/json"
"time" "time"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
) )

View File

@@ -4,7 +4,7 @@ import (
"context" "context"
"time" "time"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
) )

View File

@@ -9,7 +9,7 @@ import (
"os" "os"
"time" "time"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
) )
type githubReleaseClient struct { type githubReleaseClient struct {

View File

@@ -2,8 +2,8 @@ package repository
import ( import (
"context" "context"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@@ -5,8 +5,8 @@ import (
"net/url" "net/url"
"time" "time"
"sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
) )
// httpUpstreamService is a generic HTTP upstream service that can be used for // httpUpstreamService is a generic HTTP upstream service that can be used for

View File

@@ -6,7 +6,7 @@ import (
"fmt" "fmt"
"time" "time"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
) )

View File

@@ -6,8 +6,8 @@ import (
"net/url" "net/url"
"time" "time"
"sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"github.com/imroc/req/v3" "github.com/imroc/req/v3"
) )

View File

@@ -8,7 +8,7 @@ import (
"strings" "strings"
"time" "time"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
) )
type pricingRemoteClient struct { type pricingRemoteClient struct {

View File

@@ -11,7 +11,7 @@ import (
"net/url" "net/url"
"time" "time"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"golang.org/x/net/proxy" "golang.org/x/net/proxy"
) )

View File

@@ -2,8 +2,8 @@ package repository
import ( import (
"context" "context"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"time" "time"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
) )

View File

@@ -2,8 +2,8 @@ package repository
import ( import (
"context" "context"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"

View File

@@ -2,7 +2,7 @@ package repository
import ( import (
"context" "context"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"

View File

@@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
) )
const turnstileVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" const turnstileVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"

View File

@@ -4,7 +4,7 @@ import (
"context" "context"
"time" "time"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
) )

View File

@@ -2,10 +2,10 @@ package repository
import ( import (
"context" "context"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"sub2api/internal/pkg/usagestats" "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -19,6 +19,30 @@ func NewUsageLogRepository(db *gorm.DB) *UsageLogRepository {
return &UsageLogRepository{db: db} return &UsageLogRepository{db: db}
} }
// getPerformanceStats 获取 RPM 和 TPM近5分钟平均值可选按用户过滤
func (r *UsageLogRepository) getPerformanceStats(ctx context.Context, userID int64) (rpm, tpm int64) {
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
var perfStats struct {
RequestCount int64 `gorm:"column:request_count"`
TokenCount int64 `gorm:"column:token_count"`
}
db := r.db.WithContext(ctx).Model(&model.UsageLog{}).
Select(`
COUNT(*) as request_count,
COALESCE(SUM(input_tokens + output_tokens), 0) as token_count
`).
Where("created_at >= ?", fiveMinutesAgo)
if userID > 0 {
db = db.Where("user_id = ?", userID)
}
db.Scan(&perfStats)
// 返回5分钟平均值
return perfStats.RequestCount / 5, perfStats.TokenCount / 5
}
func (r *UsageLogRepository) Create(ctx context.Context, log *model.UsageLog) error { func (r *UsageLogRepository) Create(ctx context.Context, log *model.UsageLog) error {
return r.db.WithContext(ctx).Create(log).Error return r.db.WithContext(ctx).Create(log).Error
} }
@@ -113,46 +137,7 @@ func (r *UsageLogRepository) GetUserStats(ctx context.Context, userID int64, sta
} }
// DashboardStats 仪表盘统计 // DashboardStats 仪表盘统计
type DashboardStats struct { type DashboardStats = usagestats.DashboardStats
// 用户统计
TotalUsers int64 `json:"total_users"`
TodayNewUsers int64 `json:"today_new_users"` // 今日新增用户数
ActiveUsers int64 `json:"active_users"` // 今日有请求的用户数
// API Key 统计
TotalApiKeys int64 `json:"total_api_keys"`
ActiveApiKeys int64 `json:"active_api_keys"` // 状态为 active 的 API Key 数
// 账户统计
TotalAccounts int64 `json:"total_accounts"`
NormalAccounts int64 `json:"normal_accounts"` // 正常账户数 (schedulable=true, status=active)
ErrorAccounts int64 `json:"error_accounts"` // 异常账户数 (status=error)
RateLimitAccounts int64 `json:"ratelimit_accounts"` // 限流账户数
OverloadAccounts int64 `json:"overload_accounts"` // 过载账户数
// 累计 Token 使用统计
TotalRequests int64 `json:"total_requests"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
TotalTokens int64 `json:"total_tokens"`
TotalCost float64 `json:"total_cost"` // 累计标准计费
TotalActualCost float64 `json:"total_actual_cost"` // 累计实际扣除
// 今日 Token 使用统计
TodayRequests int64 `json:"today_requests"`
TodayInputTokens int64 `json:"today_input_tokens"`
TodayOutputTokens int64 `json:"today_output_tokens"`
TodayCacheCreationTokens int64 `json:"today_cache_creation_tokens"`
TodayCacheReadTokens int64 `json:"today_cache_read_tokens"`
TodayTokens int64 `json:"today_tokens"`
TodayCost float64 `json:"today_cost"` // 今日标准计费
TodayActualCost float64 `json:"today_actual_cost"` // 今日实际扣除
// 系统运行统计
AverageDurationMs float64 `json:"average_duration_ms"` // 平均响应时间
}
func (r *UsageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardStats, error) { func (r *UsageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
var stats DashboardStats var stats DashboardStats
@@ -269,6 +254,9 @@ func (r *UsageLogRepository) GetDashboardStats(ctx context.Context) (*DashboardS
stats.TodayCost = todayStats.TodayCost stats.TodayCost = todayStats.TodayCost
stats.TodayActualCost = todayStats.TodayActualCost stats.TodayActualCost = todayStats.TodayActualCost
// 性能指标RPM 和 TPM最近1分钟全局
stats.Rpm, stats.Tpm = r.getPerformanceStats(ctx, 0)
return &stats, nil return &stats, nil
} }
@@ -398,47 +386,16 @@ func (r *UsageLogRepository) GetAccountWindowStats(ctx context.Context, accountI
} }
// TrendDataPoint represents a single point in trend data // TrendDataPoint represents a single point in trend data
type TrendDataPoint struct { type TrendDataPoint = usagestats.TrendDataPoint
Date string `json:"date"`
Requests int64 `json:"requests"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheTokens int64 `json:"cache_tokens"`
TotalTokens int64 `json:"total_tokens"`
Cost float64 `json:"cost"` // 标准计费
ActualCost float64 `json:"actual_cost"` // 实际扣除
}
// ModelStat represents usage statistics for a single model // ModelStat represents usage statistics for a single model
type ModelStat struct { type ModelStat = usagestats.ModelStat
Model string `json:"model"`
Requests int64 `json:"requests"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
Cost float64 `json:"cost"` // 标准计费
ActualCost float64 `json:"actual_cost"` // 实际扣除
}
// UserUsageTrendPoint represents user usage trend data point // UserUsageTrendPoint represents user usage trend data point
type UserUsageTrendPoint struct { type UserUsageTrendPoint = usagestats.UserUsageTrendPoint
Date string `json:"date"`
UserID int64 `json:"user_id"`
Email string `json:"email"`
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
Cost float64 `json:"cost"` // 标准计费
ActualCost float64 `json:"actual_cost"` // 实际扣除
}
// ApiKeyUsageTrendPoint represents API key usage trend data point // ApiKeyUsageTrendPoint represents API key usage trend data point
type ApiKeyUsageTrendPoint struct { type ApiKeyUsageTrendPoint = usagestats.ApiKeyUsageTrendPoint
Date string `json:"date"`
ApiKeyID int64 `json:"api_key_id"`
KeyName string `json:"key_name"`
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
}
// GetApiKeyUsageTrend returns usage trend data grouped by API key and date // GetApiKeyUsageTrend returns usage trend data grouped by API key and date
func (r *UsageLogRepository) GetApiKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]ApiKeyUsageTrendPoint, error) { func (r *UsageLogRepository) GetApiKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]ApiKeyUsageTrendPoint, error) {
@@ -531,34 +488,7 @@ func (r *UsageLogRepository) GetUserUsageTrend(ctx context.Context, startTime, e
} }
// UserDashboardStats 用户仪表盘统计 // UserDashboardStats 用户仪表盘统计
type UserDashboardStats struct { type UserDashboardStats = usagestats.UserDashboardStats
// API Key 统计
TotalApiKeys int64 `json:"total_api_keys"`
ActiveApiKeys int64 `json:"active_api_keys"`
// 累计 Token 使用统计
TotalRequests int64 `json:"total_requests"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
TotalCacheCreationTokens int64 `json:"total_cache_creation_tokens"`
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
TotalTokens int64 `json:"total_tokens"`
TotalCost float64 `json:"total_cost"` // 累计标准计费
TotalActualCost float64 `json:"total_actual_cost"` // 累计实际扣除
// 今日 Token 使用统计
TodayRequests int64 `json:"today_requests"`
TodayInputTokens int64 `json:"today_input_tokens"`
TodayOutputTokens int64 `json:"today_output_tokens"`
TodayCacheCreationTokens int64 `json:"today_cache_creation_tokens"`
TodayCacheReadTokens int64 `json:"today_cache_read_tokens"`
TodayTokens int64 `json:"today_tokens"`
TodayCost float64 `json:"today_cost"` // 今日标准计费
TodayActualCost float64 `json:"today_actual_cost"` // 今日实际扣除
// 性能统计
AverageDurationMs float64 `json:"average_duration_ms"`
}
// GetUserDashboardStats 获取用户专属的仪表盘统计 // GetUserDashboardStats 获取用户专属的仪表盘统计
func (r *UsageLogRepository) GetUserDashboardStats(ctx context.Context, userID int64) (*UserDashboardStats, error) { func (r *UsageLogRepository) GetUserDashboardStats(ctx context.Context, userID int64) (*UserDashboardStats, error) {
@@ -641,6 +571,9 @@ func (r *UsageLogRepository) GetUserDashboardStats(ctx context.Context, userID i
stats.TodayCost = todayStats.TodayCost stats.TodayCost = todayStats.TodayCost
stats.TodayActualCost = todayStats.TodayActualCost stats.TodayActualCost = todayStats.TodayActualCost
// 性能指标RPM 和 TPM最近1分钟仅统计该用户的请求
stats.Rpm, stats.Tpm = r.getPerformanceStats(ctx, userID)
return &stats, nil return &stats, nil
} }
@@ -705,12 +638,7 @@ func (r *UsageLogRepository) GetUserModelStats(ctx context.Context, userID int64
} }
// UsageLogFilters represents filters for usage log queries // UsageLogFilters represents filters for usage log queries
type UsageLogFilters struct { type UsageLogFilters = usagestats.UsageLogFilters
UserID int64
ApiKeyID int64
StartTime *time.Time
EndTime *time.Time
}
// ListWithFilters lists usage logs with optional filters (for admin) // ListWithFilters lists usage logs with optional filters (for admin)
func (r *UsageLogRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UsageLogFilters) ([]model.UsageLog, *pagination.PaginationResult, error) { func (r *UsageLogRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UsageLogFilters) ([]model.UsageLog, *pagination.PaginationResult, error) {
@@ -758,23 +686,10 @@ func (r *UsageLogRepository) ListWithFilters(ctx context.Context, params paginat
} }
// UsageStats represents usage statistics // UsageStats represents usage statistics
type UsageStats struct { type UsageStats = usagestats.UsageStats
TotalRequests int64 `json:"total_requests"`
TotalInputTokens int64 `json:"total_input_tokens"`
TotalOutputTokens int64 `json:"total_output_tokens"`
TotalCacheTokens int64 `json:"total_cache_tokens"`
TotalTokens int64 `json:"total_tokens"`
TotalCost float64 `json:"total_cost"`
TotalActualCost float64 `json:"total_actual_cost"`
AverageDurationMs float64 `json:"average_duration_ms"`
}
// BatchUserUsageStats represents usage stats for a single user // BatchUserUsageStats represents usage stats for a single user
type BatchUserUsageStats struct { type BatchUserUsageStats = usagestats.BatchUserUsageStats
UserID int64 `json:"user_id"`
TodayActualCost float64 `json:"today_actual_cost"`
TotalActualCost float64 `json:"total_actual_cost"`
}
// GetBatchUserUsageStats gets today and total actual_cost for multiple users // GetBatchUserUsageStats gets today and total actual_cost for multiple users
func (r *UsageLogRepository) GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*BatchUserUsageStats, error) { func (r *UsageLogRepository) GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*BatchUserUsageStats, error) {
@@ -834,11 +749,7 @@ func (r *UsageLogRepository) GetBatchUserUsageStats(ctx context.Context, userIDs
} }
// BatchApiKeyUsageStats represents usage stats for a single API key // BatchApiKeyUsageStats represents usage stats for a single API key
type BatchApiKeyUsageStats struct { type BatchApiKeyUsageStats = usagestats.BatchApiKeyUsageStats
ApiKeyID int64 `json:"api_key_id"`
TodayActualCost float64 `json:"today_actual_cost"`
TotalActualCost float64 `json:"total_actual_cost"`
}
// GetBatchApiKeyUsageStats gets today and total actual_cost for multiple API keys // GetBatchApiKeyUsageStats gets today and total actual_cost for multiple API keys
func (r *UsageLogRepository) GetBatchApiKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*BatchApiKeyUsageStats, error) { func (r *UsageLogRepository) GetBatchApiKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*BatchApiKeyUsageStats, error) {
@@ -1012,53 +923,13 @@ func (r *UsageLogRepository) GetGlobalStats(ctx context.Context, startTime, endT
} }
// AccountUsageHistory represents daily usage history for an account // AccountUsageHistory represents daily usage history for an account
type AccountUsageHistory struct { type AccountUsageHistory = usagestats.AccountUsageHistory
Date string `json:"date"`
Label string `json:"label"`
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
Cost float64 `json:"cost"`
ActualCost float64 `json:"actual_cost"`
}
// AccountUsageSummary represents summary statistics for an account // AccountUsageSummary represents summary statistics for an account
type AccountUsageSummary struct { type AccountUsageSummary = usagestats.AccountUsageSummary
Days int `json:"days"`
ActualDaysUsed int `json:"actual_days_used"`
TotalCost float64 `json:"total_cost"`
TotalStandardCost float64 `json:"total_standard_cost"`
TotalRequests int64 `json:"total_requests"`
TotalTokens int64 `json:"total_tokens"`
AvgDailyCost float64 `json:"avg_daily_cost"`
AvgDailyRequests float64 `json:"avg_daily_requests"`
AvgDailyTokens float64 `json:"avg_daily_tokens"`
AvgDurationMs float64 `json:"avg_duration_ms"`
Today *struct {
Date string `json:"date"`
Cost float64 `json:"cost"`
Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"`
} `json:"today"`
HighestCostDay *struct {
Date string `json:"date"`
Label string `json:"label"`
Cost float64 `json:"cost"`
Requests int64 `json:"requests"`
} `json:"highest_cost_day"`
HighestRequestDay *struct {
Date string `json:"date"`
Label string `json:"label"`
Requests int64 `json:"requests"`
Cost float64 `json:"cost"`
} `json:"highest_request_day"`
}
// AccountUsageStatsResponse represents the full usage statistics response for an account // AccountUsageStatsResponse represents the full usage statistics response for an account
type AccountUsageStatsResponse struct { type AccountUsageStatsResponse = usagestats.AccountUsageStatsResponse
History []AccountUsageHistory `json:"history"`
Summary AccountUsageSummary `json:"summary"`
Models []ModelStat `json:"models"`
}
// GetAccountUsageStats returns comprehensive usage statistics for an account over a time range // GetAccountUsageStats returns comprehensive usage statistics for an account over a time range
func (r *UsageLogRepository) GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*AccountUsageStatsResponse, error) { func (r *UsageLogRepository) GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*AccountUsageStatsResponse, error) {

View File

@@ -2,8 +2,8 @@ package repository
import ( import (
"context" "context"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@@ -4,8 +4,8 @@ import (
"context" "context"
"time" "time"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@@ -1,7 +1,7 @@
package repository package repository
import ( import (
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"github.com/google/wire" "github.com/google/wire"
) )

View File

@@ -1,11 +1,11 @@
package server package server
import ( import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/repository"
"github.com/Wei-Shaw/sub2api/internal/service"
"net/http" "net/http"
"sub2api/internal/config"
"sub2api/internal/handler"
"sub2api/internal/repository"
"sub2api/internal/service"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"

View File

@@ -1,13 +1,13 @@
package server package server
import ( import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/middleware"
"github.com/Wei-Shaw/sub2api/internal/repository"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/web"
"net/http" "net/http"
"sub2api/internal/config"
"sub2api/internal/handler"
"sub2api/internal/middleware"
"sub2api/internal/repository"
"sub2api/internal/service"
"sub2api/internal/web"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -4,9 +4,9 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@@ -14,10 +14,10 @@ import (
"strings" "strings"
"time" "time"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"

View File

@@ -7,8 +7,9 @@ import (
"sync" "sync"
"time" "time"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/Wei-Shaw/sub2api/internal/service/ports"
) )
// usageCache 用于缓存usage数据 // usageCache 用于缓存usage数据
@@ -176,6 +177,14 @@ func (s *AccountUsageService) GetTodayStats(ctx context.Context, accountID int64
}, nil }, nil
} }
func (s *AccountUsageService) GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error) {
stats, err := s.usageLogRepo.GetAccountUsageStats(ctx, accountID, startTime, endTime)
if err != nil {
return nil, fmt.Errorf("get account usage stats failed: %w", err)
}
return stats, nil
}
// fetchOAuthUsage 从Anthropic API获取OAuth账号的使用量 // fetchOAuthUsage 从Anthropic API获取OAuth账号的使用量
func (s *AccountUsageService) fetchOAuthUsage(ctx context.Context, account *model.Account) (*UsageInfo, error) { func (s *AccountUsageService) fetchOAuthUsage(ctx context.Context, account *model.Account) (*UsageInfo, error) {
accessToken := account.GetCredential("access_token") accessToken := account.GetCredential("access_token")

View File

@@ -7,9 +7,9 @@ import (
"log" "log"
"time" "time"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@@ -6,11 +6,11 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"time" "time"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
@@ -455,3 +455,11 @@ func (s *ApiKeyService) canUserBindGroupInternal(user *model.User, group *model.
// 标准类型分组:使用原有逻辑 // 标准类型分组:使用原有逻辑
return user.CanBindGroup(group.ID, group.IsExclusive) return user.CanBindGroup(group.ID, group.IsExclusive)
} }
func (s *ApiKeyService) SearchApiKeys(ctx context.Context, userID int64, keyword string, limit int) ([]model.ApiKey, error) {
keys, err := s.apiKeyRepo.SearchApiKeys(ctx, userID, keyword, limit)
if err != nil {
return nil, fmt.Errorf("search api keys: %w", err)
}
return keys, nil
}

View File

@@ -4,10 +4,10 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/service/ports"
"log" "log"
"sub2api/internal/config"
"sub2api/internal/model"
"sub2api/internal/service/ports"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"

View File

@@ -7,8 +7,8 @@ import (
"log" "log"
"time" "time"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
) )
// 错误定义 // 错误定义

View File

@@ -2,9 +2,9 @@ package service
import ( import (
"fmt" "fmt"
"github.com/Wei-Shaw/sub2api/internal/config"
"log" "log"
"strings" "strings"
"sub2api/internal/config"
) )
// ModelPricing 模型价格配置per-token价格与LiteLLM格式一致 // ModelPricing 模型价格配置per-token价格与LiteLLM格式一致

View File

@@ -2,12 +2,26 @@ package service
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"fmt"
"log" "log"
"time" "time"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
) )
// generateRequestID generates a unique request ID for concurrency slot tracking
// Uses 8 random bytes (16 hex chars) for uniqueness
func generateRequestID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
// Fallback to nanosecond timestamp (extremely rare case)
return fmt.Sprintf("%x", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
const ( const (
// Default extra wait slots beyond concurrency limit // Default extra wait slots beyond concurrency limit
defaultExtraWaitSlots = 20 defaultExtraWaitSlots = 20
@@ -41,7 +55,10 @@ func (s *ConcurrencyService) AcquireAccountSlot(ctx context.Context, accountID i
}, nil }, nil
} }
acquired, err := s.cache.AcquireAccountSlot(ctx, accountID, maxConcurrency) // Generate unique request ID for this slot
requestID := generateRequestID()
acquired, err := s.cache.AcquireAccountSlot(ctx, accountID, maxConcurrency, requestID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -52,8 +69,8 @@ func (s *ConcurrencyService) AcquireAccountSlot(ctx context.Context, accountID i
ReleaseFunc: func() { ReleaseFunc: func() {
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
if err := s.cache.ReleaseAccountSlot(bgCtx, accountID); err != nil { if err := s.cache.ReleaseAccountSlot(bgCtx, accountID, requestID); err != nil {
log.Printf("Warning: failed to release account slot for %d: %v", accountID, err) log.Printf("Warning: failed to release account slot for %d (req=%s): %v", accountID, requestID, err)
} }
}, },
}, nil }, nil
@@ -77,7 +94,10 @@ func (s *ConcurrencyService) AcquireUserSlot(ctx context.Context, userID int64,
}, nil }, nil
} }
acquired, err := s.cache.AcquireUserSlot(ctx, userID, maxConcurrency) // Generate unique request ID for this slot
requestID := generateRequestID()
acquired, err := s.cache.AcquireUserSlot(ctx, userID, maxConcurrency, requestID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -88,8 +108,8 @@ func (s *ConcurrencyService) AcquireUserSlot(ctx context.Context, userID int64,
ReleaseFunc: func() { ReleaseFunc: func() {
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
if err := s.cache.ReleaseUserSlot(bgCtx, userID); err != nil { if err := s.cache.ReleaseUserSlot(bgCtx, userID, requestID); err != nil {
log.Printf("Warning: failed to release user slot for %d: %v", userID, err) log.Printf("Warning: failed to release user slot for %d (req=%s): %v", userID, requestID, err)
} }
}, },
}, nil }, nil
@@ -147,3 +167,20 @@ func CalculateMaxWait(userConcurrency int) int {
} }
return userConcurrency + defaultExtraWaitSlots return userConcurrency + defaultExtraWaitSlots
} }
// GetAccountConcurrencyBatch gets current concurrency counts for multiple accounts
// Returns a map of accountID -> current concurrency count
func (s *ConcurrencyService) GetAccountConcurrencyBatch(ctx context.Context, accountIDs []int64) (map[int64]int, error) {
result := make(map[int64]int)
for _, accountID := range accountIDs {
count, err := s.cache.GetAccountConcurrency(ctx, accountID)
if err != nil {
// If key doesn't exist in Redis, count is 0
count = 0
}
result[accountID] = count
}
return result, nil
}

View File

@@ -0,0 +1,77 @@
package service
import (
"context"
"fmt"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/Wei-Shaw/sub2api/internal/service/ports"
)
// DashboardService provides aggregated statistics for admin dashboard.
type DashboardService struct {
usageRepo ports.UsageLogRepository
}
func NewDashboardService(usageRepo ports.UsageLogRepository) *DashboardService {
return &DashboardService{
usageRepo: usageRepo,
}
}
func (s *DashboardService) GetDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error) {
stats, err := s.usageRepo.GetDashboardStats(ctx)
if err != nil {
return nil, fmt.Errorf("get dashboard stats: %w", err)
}
return stats, nil
}
func (s *DashboardService) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID int64) ([]usagestats.TrendDataPoint, error) {
trend, err := s.usageRepo.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID)
if err != nil {
return nil, fmt.Errorf("get usage trend with filters: %w", err)
}
return trend, nil
}
func (s *DashboardService) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID int64) ([]usagestats.ModelStat, error) {
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, 0)
if err != nil {
return nil, fmt.Errorf("get model stats with filters: %w", err)
}
return stats, nil
}
func (s *DashboardService) GetApiKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.ApiKeyUsageTrendPoint, error) {
trend, err := s.usageRepo.GetApiKeyUsageTrend(ctx, startTime, endTime, granularity, limit)
if err != nil {
return nil, fmt.Errorf("get api key usage trend: %w", err)
}
return trend, nil
}
func (s *DashboardService) GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error) {
trend, err := s.usageRepo.GetUserUsageTrend(ctx, startTime, endTime, granularity, limit)
if err != nil {
return nil, fmt.Errorf("get user usage trend: %w", err)
}
return trend, nil
}
func (s *DashboardService) GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*usagestats.BatchUserUsageStats, error) {
stats, err := s.usageRepo.GetBatchUserUsageStats(ctx, userIDs)
if err != nil {
return nil, fmt.Errorf("get batch user usage stats: %w", err)
}
return stats, nil
}
func (s *DashboardService) GetBatchApiKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchApiKeyUsageStats, error) {
stats, err := s.usageRepo.GetBatchApiKeyUsageStats(ctx, apiKeyIDs)
if err != nil {
return nil, fmt.Errorf("get batch api key usage stats: %w", err)
}
return stats, nil
}

View File

@@ -6,11 +6,11 @@ import (
"crypto/tls" "crypto/tls"
"errors" "errors"
"fmt" "fmt"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/service/ports"
"math/big" "math/big"
"net/smtp" "net/smtp"
"strconv" "strconv"
"sub2api/internal/model"
"sub2api/internal/service/ports"
"time" "time"
) )

View File

@@ -16,10 +16,10 @@ import (
"strings" "strings"
"time" "time"
"sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -358,6 +358,25 @@ func (s *GatewayService) getOAuthToken(ctx context.Context, account *model.Accou
return accessToken, "oauth", nil return accessToken, "oauth", nil
} }
// 重试相关常量
const (
maxRetries = 3 // 最大重试次数
retryDelay = 2 * time.Second // 重试等待时间
)
// shouldRetryUpstreamError 判断是否应该重试上游错误
// OAuth/Setup Token 账号:仅 403 重试
// API Key 账号:未配置的错误码重试
func (s *GatewayService) shouldRetryUpstreamError(account *model.Account, statusCode int) bool {
// OAuth/Setup Token 账号:仅 403 重试
if account.IsOAuth() {
return statusCode == 403
}
// API Key 账号:未配置的错误码重试
return !account.ShouldHandleErrorCode(statusCode)
}
// Forward 转发请求到Claude API // Forward 转发请求到Claude API
func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *model.Account, body []byte) (*ForwardResult, error) { func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *model.Account, body []byte) (*ForwardResult, error) {
startTime := time.Now() startTime := time.Now()
@@ -389,26 +408,51 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *m
return nil, err return nil, err
} }
// 构建上游请求
upstreamReq, err := s.buildUpstreamRequest(ctx, c, account, body, token, tokenType)
if err != nil {
return nil, err
}
// 获取代理URL // 获取代理URL
proxyURL := "" proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil { if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL() proxyURL = account.Proxy.URL()
} }
// 发送请求 // 重试循环
resp, err := s.httpUpstream.Do(upstreamReq, proxyURL) var resp *http.Response
if err != nil { for attempt := 1; attempt <= maxRetries; attempt++ {
return nil, fmt.Errorf("upstream request failed: %w", err) // 构建上游请求(每次重试需要重新构建,因为请求体需要重新读取)
upstreamReq, err := s.buildUpstreamRequest(ctx, c, account, body, token, tokenType)
if err != nil {
return nil, err
}
// 发送请求
resp, err = s.httpUpstream.Do(upstreamReq, proxyURL)
if err != nil {
return nil, fmt.Errorf("upstream request failed: %w", err)
}
// 检查是否需要重试
if resp.StatusCode >= 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) {
if attempt < maxRetries {
log.Printf("Account %d: upstream error %d, retry %d/%d after %v",
account.ID, resp.StatusCode, attempt, maxRetries, retryDelay)
_ = resp.Body.Close()
time.Sleep(retryDelay)
continue
}
// 最后一次尝试也失败,跳出循环处理重试耗尽
break
}
// 不需要重试(成功或不可重试的错误),跳出循环
break
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
// 处理错误响应包括401由后台TokenRefreshService维护token有效性 // 处理重试耗尽的情况
if resp.StatusCode >= 400 && s.shouldRetryUpstreamError(account, resp.StatusCode) {
return s.handleRetryExhaustedError(ctx, resp, c, account)
}
// 处理错误响应(不可重试的错误)
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
return s.handleErrorResponse(ctx, resp, c, account) return s.handleErrorResponse(ctx, resp, c, account)
} }
@@ -570,19 +614,6 @@ func (s *GatewayService) getBetaHeader(body []byte, clientBetaHeader string) str
func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *model.Account) (*ForwardResult, error) { func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *model.Account) (*ForwardResult, error) {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
// apikey 类型账号:检查自定义错误码配置
// 如果启用且错误码不在列表中,返回通用 500 错误(不做任何账号状态处理)
if !account.ShouldHandleErrorCode(resp.StatusCode) {
c.JSON(http.StatusInternalServerError, gin.H{
"type": "error",
"error": gin.H{
"type": "upstream_error",
"message": "Upstream gateway error",
},
})
return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode)
}
// 处理上游错误,标记账号状态 // 处理上游错误,标记账号状态
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body) s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body)
@@ -629,6 +660,34 @@ func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Res
return nil, fmt.Errorf("upstream error: %d", resp.StatusCode) return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
} }
// handleRetryExhaustedError 处理重试耗尽后的错误
// OAuth 403标记账号异常
// API Key 未配置错误码:仅返回错误,不标记账号
func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *http.Response, c *gin.Context, account *model.Account) (*ForwardResult, error) {
body, _ := io.ReadAll(resp.Body)
statusCode := resp.StatusCode
// OAuth/Setup Token 账号的 403标记账号异常
if account.IsOAuth() && statusCode == 403 {
s.rateLimitService.HandleUpstreamError(ctx, account, statusCode, resp.Header, body)
log.Printf("Account %d: marked as error after %d retries for status %d", account.ID, maxRetries, statusCode)
} else {
// API Key 未配置错误码:不标记账号状态
log.Printf("Account %d: upstream error %d after %d retries (not marking account)", account.ID, statusCode, maxRetries)
}
// 返回统一的重试耗尽错误响应
c.JSON(http.StatusBadGateway, gin.H{
"type": "error",
"error": gin.H{
"type": "upstream_error",
"message": "Upstream request failed after retries",
},
})
return nil, fmt.Errorf("upstream error: %d (retries exhausted)", statusCode)
}
// streamingResult 流式响应结果 // streamingResult 流式响应结果
type streamingResult struct { type streamingResult struct {
usage *ClaudeUsage usage *ClaudeUsage

View File

@@ -4,9 +4,9 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@@ -7,11 +7,11 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/Wei-Shaw/sub2api/internal/service/ports"
"log" "log"
"net/http" "net/http"
"regexp" "regexp"
"strconv" "strconv"
"sub2api/internal/service/ports"
"time" "time"
) )

View File

@@ -6,9 +6,9 @@ import (
"log" "log"
"time" "time"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/oauth" "github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
) )
// ClaudeOAuthClient handles HTTP requests for Claude OAuth flows // ClaudeOAuthClient handles HTTP requests for Claude OAuth flows

View File

@@ -15,9 +15,9 @@ import (
"strings" "strings"
"time" "time"
"sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )

View File

@@ -5,9 +5,9 @@ import (
"fmt" "fmt"
"time" "time"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
) )
// OpenAIOAuthService handles OpenAI OAuth authentication flows // OpenAIOAuthService handles OpenAI OAuth authentication flows

View File

@@ -4,8 +4,8 @@ import (
"context" "context"
"time" "time"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
) )
type AccountRepository interface { type AccountRepository interface {

View File

@@ -3,8 +3,8 @@ package ports
import ( import (
"context" "context"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
) )
type ApiKeyRepository interface { type ApiKeyRepository interface {

View File

@@ -3,17 +3,21 @@ package ports
import "context" import "context"
// ConcurrencyCache defines cache operations for concurrency service // ConcurrencyCache defines cache operations for concurrency service
// Uses independent keys per request slot with native Redis TTL for automatic cleanup
type ConcurrencyCache interface { type ConcurrencyCache interface {
// Slot management // Account slot management - each slot is a separate key with independent TTL
AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int) (bool, error) // Key format: concurrency:account:{accountID}:{requestID}
ReleaseAccountSlot(ctx context.Context, accountID int64) error AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error)
ReleaseAccountSlot(ctx context.Context, accountID int64, requestID string) error
GetAccountConcurrency(ctx context.Context, accountID int64) (int, error) GetAccountConcurrency(ctx context.Context, accountID int64) (int, error)
AcquireUserSlot(ctx context.Context, userID int64, maxConcurrency int) (bool, error) // User slot management - each slot is a separate key with independent TTL
ReleaseUserSlot(ctx context.Context, userID int64) error // Key format: concurrency:user:{userID}:{requestID}
AcquireUserSlot(ctx context.Context, userID int64, maxConcurrency int, requestID string) (bool, error)
ReleaseUserSlot(ctx context.Context, userID int64, requestID string) error
GetUserConcurrency(ctx context.Context, userID int64) (int, error) GetUserConcurrency(ctx context.Context, userID int64) (int, error)
// Wait queue // Wait queue - uses counter with TTL set only on creation
IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error) IncrementWaitCount(ctx context.Context, userID int64, maxWait int) (bool, error)
DecrementWaitCount(ctx context.Context, userID int64) error DecrementWaitCount(ctx context.Context, userID int64) error
} }

View File

@@ -3,8 +3,8 @@ package ports
import ( import (
"context" "context"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@@ -3,7 +3,7 @@ package ports
import ( import (
"context" "context"
"sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
) )
// OpenAIOAuthClient interface for OpenAI OAuth operations // OpenAIOAuthClient interface for OpenAI OAuth operations

View File

@@ -3,8 +3,8 @@ package ports
import ( import (
"context" "context"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
) )
type ProxyRepository interface { type ProxyRepository interface {

View File

@@ -3,8 +3,8 @@ package ports
import ( import (
"context" "context"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
) )
type RedeemCodeRepository interface { type RedeemCodeRepository interface {

View File

@@ -3,7 +3,7 @@ package ports
import ( import (
"context" "context"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
) )
type SettingRepository interface { type SettingRepository interface {

View File

@@ -4,9 +4,9 @@ import (
"context" "context"
"time" "time"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"sub2api/internal/pkg/usagestats" "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
) )
type UsageLogRepository interface { type UsageLogRepository interface {
@@ -25,4 +25,25 @@ type UsageLogRepository interface {
GetAccountWindowStats(ctx context.Context, accountID int64, startTime time.Time) (*usagestats.AccountStats, error) GetAccountWindowStats(ctx context.Context, accountID int64, startTime time.Time) (*usagestats.AccountStats, error)
GetAccountTodayStats(ctx context.Context, accountID int64) (*usagestats.AccountStats, error) GetAccountTodayStats(ctx context.Context, accountID int64) (*usagestats.AccountStats, error)
// Admin dashboard stats
GetDashboardStats(ctx context.Context) (*usagestats.DashboardStats, error)
GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID int64) ([]usagestats.TrendDataPoint, error)
GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID int64) ([]usagestats.ModelStat, error)
GetApiKeyUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.ApiKeyUsageTrendPoint, error)
GetUserUsageTrend(ctx context.Context, startTime, endTime time.Time, granularity string, limit int) ([]usagestats.UserUsageTrendPoint, error)
GetBatchUserUsageStats(ctx context.Context, userIDs []int64) (map[int64]*usagestats.BatchUserUsageStats, error)
GetBatchApiKeyUsageStats(ctx context.Context, apiKeyIDs []int64) (map[int64]*usagestats.BatchApiKeyUsageStats, error)
// User dashboard stats
GetUserDashboardStats(ctx context.Context, userID int64) (*usagestats.UserDashboardStats, error)
GetUserUsageTrendByUserID(ctx context.Context, userID int64, startTime, endTime time.Time, granularity string) ([]usagestats.TrendDataPoint, error)
GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.ModelStat, error)
// Admin usage listing/stats
ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]model.UsageLog, *pagination.PaginationResult, error)
GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error)
// Account stats
GetAccountUsageStats(ctx context.Context, accountID int64, startTime, endTime time.Time) (*usagestats.AccountUsageStatsResponse, error)
} }

View File

@@ -3,8 +3,8 @@ package ports
import ( import (
"context" "context"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
) )
type UserRepository interface { type UserRepository interface {

View File

@@ -4,8 +4,8 @@ import (
"context" "context"
"time" "time"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
) )
type UserSubscriptionRepository interface { type UserSubscriptionRepository interface {

View File

@@ -14,8 +14,8 @@ import (
"sync" "sync"
"time" "time"
"sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
) )
// LiteLLMModelPricing LiteLLM价格数据结构 // LiteLLMModelPricing LiteLLM价格数据结构

View File

@@ -4,9 +4,9 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@@ -7,9 +7,9 @@ import (
"strconv" "strconv"
"time" "time"
"sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
) )
// RateLimitService 处理限流和过载状态管理 // RateLimitService 处理限流和过载状态管理

View File

@@ -6,10 +6,10 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service/ports"
"strings" "strings"
"sub2api/internal/model"
"sub2api/internal/pkg/pagination"
"sub2api/internal/service/ports"
"time" "time"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"

View File

@@ -6,10 +6,10 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/service/ports"
"strconv" "strconv"
"sub2api/internal/config"
"sub2api/internal/model"
"sub2api/internal/service/ports"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -54,6 +54,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*model.PublicSe
model.SettingKeySiteSubtitle, model.SettingKeySiteSubtitle,
model.SettingKeyApiBaseUrl, model.SettingKeyApiBaseUrl,
model.SettingKeyContactInfo, model.SettingKeyContactInfo,
model.SettingKeyDocUrl,
} }
settings, err := s.settingRepo.GetMultiple(ctx, keys) settings, err := s.settingRepo.GetMultiple(ctx, keys)
@@ -71,6 +72,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*model.PublicSe
SiteSubtitle: s.getStringOrDefault(settings, model.SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), SiteSubtitle: s.getStringOrDefault(settings, model.SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
ApiBaseUrl: settings[model.SettingKeyApiBaseUrl], ApiBaseUrl: settings[model.SettingKeyApiBaseUrl],
ContactInfo: settings[model.SettingKeyContactInfo], ContactInfo: settings[model.SettingKeyContactInfo],
DocUrl: settings[model.SettingKeyDocUrl],
}, nil }, nil
} }
@@ -106,6 +108,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *model.Sys
updates[model.SettingKeySiteSubtitle] = settings.SiteSubtitle updates[model.SettingKeySiteSubtitle] = settings.SiteSubtitle
updates[model.SettingKeyApiBaseUrl] = settings.ApiBaseUrl updates[model.SettingKeyApiBaseUrl] = settings.ApiBaseUrl
updates[model.SettingKeyContactInfo] = settings.ContactInfo updates[model.SettingKeyContactInfo] = settings.ContactInfo
updates[model.SettingKeyDocUrl] = settings.DocUrl
// 默认配置 // 默认配置
updates[model.SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) updates[model.SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
@@ -210,6 +213,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *model.System
SiteSubtitle: s.getStringOrDefault(settings, model.SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), SiteSubtitle: s.getStringOrDefault(settings, model.SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
ApiBaseUrl: settings[model.SettingKeyApiBaseUrl], ApiBaseUrl: settings[model.SettingKeyApiBaseUrl],
ContactInfo: settings[model.SettingKeyContactInfo], ContactInfo: settings[model.SettingKeyContactInfo],
DocUrl: settings[model.SettingKeyDocUrl],
} }
// 解析整数类型 // 解析整数类型

View File

@@ -7,9 +7,9 @@ import (
"log" "log"
"time" "time"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
) )
var ( var (

View File

@@ -7,9 +7,9 @@ import (
"sync" "sync"
"time" "time"
"sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
) )
// TokenRefreshService OAuth token自动刷新服务 // TokenRefreshService OAuth token自动刷新服务

Some files were not shown because too many files have changed in this diff Show More