Compare commits

...

5 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
114 changed files with 487 additions and 326 deletions

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"
) )

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/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/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/response"
"sub2api/internal/pkg/timezone" "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"
) )

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/service"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -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,
}) })
} }

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,11 +4,11 @@ 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/pkg/usagestats" "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"
) )

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,11 +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/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/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

@@ -44,8 +44,8 @@ type DashboardStats struct {
AverageDurationMs float64 `json:"average_duration_ms"` // 平均响应时间 AverageDurationMs float64 `json:"average_duration_ms"` // 平均响应时间
// 性能指标 // 性能指标
Rpm int64 `json:"rpm"` // 最近1分钟请求数 Rpm int64 `json:"rpm"` // 近5分钟平均每分钟请求数
Tpm int64 `json:"tpm"` // 最近1分钟Token数 Tpm int64 `json:"tpm"` // 近5分钟平均每分钟Token数
} }
// TrendDataPoint represents a single point in trend data // TrendDataPoint represents a single point in trend data
@@ -121,8 +121,8 @@ type UserDashboardStats struct {
AverageDurationMs float64 `json:"average_duration_ms"` AverageDurationMs float64 `json:"average_duration_ms"`
// 性能指标 // 性能指标
Rpm int64 `json:"rpm"` // 最近1分钟请求数 Rpm int64 `json:"rpm"` // 近5分钟平均每分钟请求数
Tpm int64 `json:"tpm"` // 最近1分钟Token数 Tpm int64 `json:"tpm"` // 近5分钟平均每分钟Token数
} }
// UsageLogFilters represents filters for usage log queries // UsageLogFilters represents filters for usage log queries

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,9 +19,9 @@ func NewUsageLogRepository(db *gorm.DB) *UsageLogRepository {
return &UsageLogRepository{db: db} return &UsageLogRepository{db: db}
} }
// getPerformanceStats 获取 RPM 和 TPM可选按用户过滤 // getPerformanceStats 获取 RPM 和 TPM近5分钟平均值可选按用户过滤)
func (r *UsageLogRepository) getPerformanceStats(ctx context.Context, userID int64) (rpm, tpm int64) { func (r *UsageLogRepository) getPerformanceStats(ctx context.Context, userID int64) (rpm, tpm int64) {
oneMinuteAgo := time.Now().Add(-1 * time.Minute) fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
var perfStats struct { var perfStats struct {
RequestCount int64 `gorm:"column:request_count"` RequestCount int64 `gorm:"column:request_count"`
TokenCount int64 `gorm:"column:token_count"` TokenCount int64 `gorm:"column:token_count"`
@@ -32,14 +32,15 @@ func (r *UsageLogRepository) getPerformanceStats(ctx context.Context, userID int
COUNT(*) as request_count, COUNT(*) as request_count,
COALESCE(SUM(input_tokens + output_tokens), 0) as token_count COALESCE(SUM(input_tokens + output_tokens), 0) as token_count
`). `).
Where("created_at >= ?", oneMinuteAgo) Where("created_at >= ?", fiveMinutesAgo)
if userID > 0 { if userID > 0 {
db = db.Where("user_id = ?", userID) db = db.Where("user_id = ?", userID)
} }
db.Scan(&perfStats) db.Scan(&perfStats)
return perfStats.RequestCount, perfStats.TokenCount // 返回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 {

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,9 +7,9 @@ import (
"sync" "sync"
"time" "time"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"sub2api/internal/pkg/usagestats" "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
) )
// usageCache 用于缓存usage数据 // usageCache 用于缓存usage数据

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"

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

View File

@@ -5,8 +5,8 @@ import (
"fmt" "fmt"
"time" "time"
"sub2api/internal/pkg/usagestats" "github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"sub2api/internal/service/ports" "github.com/Wei-Shaw/sub2api/internal/service/ports"
) )
// DashboardService provides aggregated statistics for admin dashboard. // DashboardService provides aggregated statistics for admin dashboard.

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"
) )

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 {

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自动刷新服务

View File

@@ -5,7 +5,7 @@ import (
"strconv" "strconv"
"time" "time"
"sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
) )
// TokenRefresher 定义平台特定的token刷新策略接口 // TokenRefresher 定义平台特定的token刷新策略接口

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