mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-07 08:50:22 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2857fa2ef7 | ||
|
|
e681431454 | ||
|
|
5b568aa9d4 | ||
|
|
471943269c | ||
|
|
28a5e2f0e6 | ||
|
|
b4c22ce6ce | ||
|
|
5248097f90 | ||
|
|
8e2c22d0bd | ||
|
|
be56a282f2 | ||
|
|
5f4eb9f9d0 | ||
|
|
d1cd5c0a73 | ||
|
|
5429c74c10 | ||
|
|
fe1d46a8ea |
2
.github/workflows/security-scan.yml
vendored
2
.github/workflows/security-scan.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
working-directory: backend
|
||||
run: |
|
||||
go install github.com/securego/gosec/v2/cmd/gosec@latest
|
||||
gosec -severity high -confidence high ./...
|
||||
gosec -conf .gosec.json -severity high -confidence high ./...
|
||||
|
||||
frontend-security:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
5
backend/.gosec.json
Normal file
5
backend/.gosec.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"global": {
|
||||
"exclude": "G704"
|
||||
}
|
||||
}
|
||||
@@ -341,7 +341,7 @@ func (h *AccountHandler) listAccountsFiltered(ctx context.Context, platform, acc
|
||||
pageSize := dataPageCap
|
||||
var out []service.Account
|
||||
for {
|
||||
items, total, err := h.adminService.ListAccounts(ctx, page, pageSize, platform, accountType, status, search)
|
||||
items, total, err := h.adminService.ListAccounts(ctx, page, pageSize, platform, accountType, status, search, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -156,7 +156,12 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
search = search[:100]
|
||||
}
|
||||
|
||||
accounts, total, err := h.adminService.ListAccounts(c.Request.Context(), page, pageSize, platform, accountType, status, search)
|
||||
var groupID int64
|
||||
if groupIDStr := c.Query("group"); groupIDStr != "" {
|
||||
groupID, _ = strconv.ParseInt(groupIDStr, 10, 64)
|
||||
}
|
||||
|
||||
accounts, total, err := h.adminService.ListAccounts(c.Request.Context(), page, pageSize, platform, accountType, status, search, groupID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
@@ -1429,7 +1434,7 @@ func (h *AccountHandler) BatchRefreshTier(c *gin.Context) {
|
||||
accounts := make([]*service.Account, 0)
|
||||
|
||||
if len(req.AccountIDs) == 0 {
|
||||
allAccounts, _, err := h.adminService.ListAccounts(ctx, 1, 10000, "gemini", "oauth", "", "")
|
||||
allAccounts, _, err := h.adminService.ListAccounts(ctx, 1, 10000, "gemini", "oauth", "", "", 0)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
|
||||
@@ -166,7 +166,7 @@ func (s *stubAdminService) GetGroupAPIKeys(ctx context.Context, groupID int64, p
|
||||
return s.apiKeys, int64(len(s.apiKeys)), nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string) ([]service.Account, int64, error) {
|
||||
func (s *stubAdminService) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]service.Account, int64, error) {
|
||||
return s.accounts, int64(len(s.accounts)), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -435,10 +435,10 @@ func (r *accountRepository) Delete(ctx context.Context, id int64) error {
|
||||
}
|
||||
|
||||
func (r *accountRepository) List(ctx context.Context, params pagination.PaginationParams) ([]service.Account, *pagination.PaginationResult, error) {
|
||||
return r.ListWithFilters(ctx, params, "", "", "", "")
|
||||
return r.ListWithFilters(ctx, params, "", "", "", "", 0)
|
||||
}
|
||||
|
||||
func (r *accountRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string) ([]service.Account, *pagination.PaginationResult, error) {
|
||||
func (r *accountRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64) ([]service.Account, *pagination.PaginationResult, error) {
|
||||
q := r.client.Account.Query()
|
||||
|
||||
if platform != "" {
|
||||
@@ -458,6 +458,9 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati
|
||||
if search != "" {
|
||||
q = q.Where(dbaccount.NameContainsFold(search))
|
||||
}
|
||||
if groupID > 0 {
|
||||
q = q.Where(dbaccount.HasAccountGroupsWith(dbaccountgroup.GroupIDEQ(groupID)))
|
||||
}
|
||||
|
||||
total, err := q.Count(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -238,7 +238,7 @@ func (s *AccountRepoSuite) TestListWithFilters() {
|
||||
|
||||
tt.setup(client)
|
||||
|
||||
accounts, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, tt.platform, tt.accType, tt.status, tt.search)
|
||||
accounts, _, err := repo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, tt.platform, tt.accType, tt.status, tt.search, 0)
|
||||
s.Require().NoError(err)
|
||||
s.Require().Len(accounts, tt.wantCount)
|
||||
if tt.validate != nil {
|
||||
@@ -305,7 +305,7 @@ func (s *AccountRepoSuite) TestPreload_And_VirtualFields() {
|
||||
s.Require().Len(got.Groups, 1, "expected Groups to be populated")
|
||||
s.Require().Equal(group.ID, got.Groups[0].ID)
|
||||
|
||||
accounts, page, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", "", "", "acc")
|
||||
accounts, page, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", "", "", "acc", 0)
|
||||
s.Require().NoError(err, "ListWithFilters")
|
||||
s.Require().Equal(int64(1), page.Total)
|
||||
s.Require().Len(accounts, 1)
|
||||
|
||||
@@ -936,7 +936,7 @@ func (s *stubAccountRepo) List(ctx context.Context, params pagination.Pagination
|
||||
return nil, nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (s *stubAccountRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string) ([]service.Account, *pagination.PaginationResult, error) {
|
||||
func (s *stubAccountRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64) ([]service.Account, *pagination.PaginationResult, error) {
|
||||
return nil, nil, errors.New("not implemented")
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ type AccountRepository interface {
|
||||
Delete(ctx context.Context, id int64) error
|
||||
|
||||
List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error)
|
||||
ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string) ([]Account, *pagination.PaginationResult, error)
|
||||
ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64) ([]Account, *pagination.PaginationResult, error)
|
||||
ListByGroup(ctx context.Context, groupID int64) ([]Account, error)
|
||||
ListActive(ctx context.Context) ([]Account, error)
|
||||
ListByPlatform(ctx context.Context, platform string) ([]Account, error)
|
||||
|
||||
@@ -75,7 +75,7 @@ func (s *accountRepoStub) List(ctx context.Context, params pagination.Pagination
|
||||
panic("unexpected List call")
|
||||
}
|
||||
|
||||
func (s *accountRepoStub) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string) ([]Account, *pagination.PaginationResult, error) {
|
||||
func (s *accountRepoStub) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64) ([]Account, *pagination.PaginationResult, error) {
|
||||
panic("unexpected ListWithFilters call")
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ type AdminService interface {
|
||||
UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error
|
||||
|
||||
// Account management
|
||||
ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string) ([]Account, int64, error)
|
||||
ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]Account, int64, error)
|
||||
GetAccount(ctx context.Context, id int64) (*Account, error)
|
||||
GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error)
|
||||
CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error)
|
||||
@@ -1021,9 +1021,9 @@ func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []
|
||||
}
|
||||
|
||||
// Account management implementations
|
||||
func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string) ([]Account, int64, error) {
|
||||
func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64) ([]Account, int64, error) {
|
||||
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
||||
accounts, result, err := s.accountRepo.ListWithFilters(ctx, params, platform, accountType, status, search)
|
||||
accounts, result, err := s.accountRepo.ListWithFilters(ctx, params, platform, accountType, status, search, groupID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ type accountRepoStubForAdminList struct {
|
||||
listWithFiltersErr error
|
||||
}
|
||||
|
||||
func (s *accountRepoStubForAdminList) ListWithFilters(_ context.Context, params pagination.PaginationParams, platform, accountType, status, search string) ([]Account, *pagination.PaginationResult, error) {
|
||||
func (s *accountRepoStubForAdminList) ListWithFilters(_ context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64) ([]Account, *pagination.PaginationResult, error) {
|
||||
s.listWithFiltersCalls++
|
||||
s.listWithFiltersParams = params
|
||||
s.listWithFiltersPlatform = platform
|
||||
@@ -168,7 +168,7 @@ func TestAdminService_ListAccounts_WithSearch(t *testing.T) {
|
||||
}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
|
||||
accounts, total, err := svc.ListAccounts(context.Background(), 1, 20, PlatformGemini, AccountTypeOAuth, StatusActive, "acc")
|
||||
accounts, total, err := svc.ListAccounts(context.Background(), 1, 20, PlatformGemini, AccountTypeOAuth, StatusActive, "acc", 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(10), total)
|
||||
require.Equal(t, []Account{{ID: 1, Name: "acc"}}, accounts)
|
||||
|
||||
@@ -87,7 +87,7 @@ func (m *mockAccountRepoForPlatform) Delete(ctx context.Context, id int64) error
|
||||
func (m *mockAccountRepoForPlatform) List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
func (m *mockAccountRepoForPlatform) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string) ([]Account, *pagination.PaginationResult, error) {
|
||||
func (m *mockAccountRepoForPlatform) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64) ([]Account, *pagination.PaginationResult, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
func (m *mockAccountRepoForPlatform) ListByGroup(ctx context.Context, groupID int64) ([]Account, error) {
|
||||
|
||||
@@ -74,7 +74,7 @@ func (m *mockAccountRepoForGemini) Delete(ctx context.Context, id int64) error
|
||||
func (m *mockAccountRepoForGemini) List(ctx context.Context, params pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
func (m *mockAccountRepoForGemini) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string) ([]Account, *pagination.PaginationResult, error) {
|
||||
func (m *mockAccountRepoForGemini) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64) ([]Account, *pagination.PaginationResult, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
func (m *mockAccountRepoForGemini) ListByGroup(ctx context.Context, groupID int64) ([]Account, error) {
|
||||
|
||||
@@ -24,7 +24,7 @@ func (s *OpsService) listAllAccountsForOps(ctx context.Context, platformFilter s
|
||||
accounts, pageInfo, err := s.accountRepo.ListWithFilters(ctx, pagination.PaginationParams{
|
||||
Page: page,
|
||||
PageSize: opsAccountsPageSize,
|
||||
}, platformFilter, "", "", "")
|
||||
}, platformFilter, "", "", "", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -381,10 +381,31 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 尝试从响应头解析重置时间(Anthropic)
|
||||
// 2. Anthropic 平台:尝试解析 per-window 头(5h / 7d),选择实际触发的窗口
|
||||
if result := calculateAnthropic429ResetTime(headers); result != nil {
|
||||
if err := s.accountRepo.SetRateLimited(ctx, account.ID, result.resetAt); err != nil {
|
||||
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新 session window:优先使用 5h-reset 头精确计算,否则从 resetAt 反推
|
||||
windowEnd := result.resetAt
|
||||
if result.fiveHourReset != nil {
|
||||
windowEnd = *result.fiveHourReset
|
||||
}
|
||||
windowStart := windowEnd.Add(-5 * time.Hour)
|
||||
if err := s.accountRepo.UpdateSessionWindow(ctx, account.ID, &windowStart, &windowEnd, "rejected"); err != nil {
|
||||
slog.Warn("rate_limit_update_session_window_failed", "account_id", account.ID, "error", err)
|
||||
}
|
||||
|
||||
slog.Info("anthropic_account_rate_limited", "account_id", account.ID, "reset_at", result.resetAt, "reset_in", time.Until(result.resetAt).Truncate(time.Second))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 尝试从响应头解析重置时间(Anthropic 聚合头,向后兼容)
|
||||
resetTimestamp := headers.Get("anthropic-ratelimit-unified-reset")
|
||||
|
||||
// 3. 如果响应头没有,尝试从响应体解析(OpenAI usage_limit_reached, Gemini)
|
||||
// 4. 如果响应头没有,尝试从响应体解析(OpenAI usage_limit_reached, Gemini)
|
||||
if resetTimestamp == "" {
|
||||
switch account.Platform {
|
||||
case PlatformOpenAI:
|
||||
@@ -497,6 +518,112 @@ func (s *RateLimitService) calculateOpenAI429ResetTime(headers http.Header) *tim
|
||||
return nil
|
||||
}
|
||||
|
||||
// anthropic429Result holds the parsed Anthropic 429 rate-limit information.
|
||||
type anthropic429Result struct {
|
||||
resetAt time.Time // The correct reset time to use for SetRateLimited
|
||||
fiveHourReset *time.Time // 5h window reset timestamp (for session window calculation), nil if not available
|
||||
}
|
||||
|
||||
// calculateAnthropic429ResetTime parses Anthropic's per-window rate-limit headers
|
||||
// to determine which window (5h or 7d) actually triggered the 429.
|
||||
//
|
||||
// Headers used:
|
||||
// - anthropic-ratelimit-unified-5h-utilization / anthropic-ratelimit-unified-5h-surpassed-threshold
|
||||
// - anthropic-ratelimit-unified-5h-reset
|
||||
// - anthropic-ratelimit-unified-7d-utilization / anthropic-ratelimit-unified-7d-surpassed-threshold
|
||||
// - anthropic-ratelimit-unified-7d-reset
|
||||
//
|
||||
// Returns nil when the per-window headers are absent (caller should fall back to
|
||||
// the aggregated anthropic-ratelimit-unified-reset header).
|
||||
func calculateAnthropic429ResetTime(headers http.Header) *anthropic429Result {
|
||||
reset5hStr := headers.Get("anthropic-ratelimit-unified-5h-reset")
|
||||
reset7dStr := headers.Get("anthropic-ratelimit-unified-7d-reset")
|
||||
|
||||
if reset5hStr == "" && reset7dStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var reset5h, reset7d *time.Time
|
||||
if ts, err := strconv.ParseInt(reset5hStr, 10, 64); err == nil {
|
||||
t := time.Unix(ts, 0)
|
||||
reset5h = &t
|
||||
}
|
||||
if ts, err := strconv.ParseInt(reset7dStr, 10, 64); err == nil {
|
||||
t := time.Unix(ts, 0)
|
||||
reset7d = &t
|
||||
}
|
||||
|
||||
is5hExceeded := isAnthropicWindowExceeded(headers, "5h")
|
||||
is7dExceeded := isAnthropicWindowExceeded(headers, "7d")
|
||||
|
||||
slog.Info("anthropic_429_window_analysis",
|
||||
"is_5h_exceeded", is5hExceeded,
|
||||
"is_7d_exceeded", is7dExceeded,
|
||||
"reset_5h", reset5hStr,
|
||||
"reset_7d", reset7dStr,
|
||||
)
|
||||
|
||||
// Select the correct reset time based on which window(s) are exceeded.
|
||||
var chosen *time.Time
|
||||
switch {
|
||||
case is5hExceeded && is7dExceeded:
|
||||
// Both exceeded → prefer 7d (longer cooldown), fall back to 5h
|
||||
chosen = reset7d
|
||||
if chosen == nil {
|
||||
chosen = reset5h
|
||||
}
|
||||
case is5hExceeded:
|
||||
chosen = reset5h
|
||||
case is7dExceeded:
|
||||
chosen = reset7d
|
||||
default:
|
||||
// Neither flag clearly exceeded — pick the sooner reset as best guess
|
||||
chosen = pickSooner(reset5h, reset7d)
|
||||
}
|
||||
|
||||
if chosen == nil {
|
||||
return nil
|
||||
}
|
||||
return &anthropic429Result{resetAt: *chosen, fiveHourReset: reset5h}
|
||||
}
|
||||
|
||||
// isAnthropicWindowExceeded checks whether a given Anthropic rate-limit window
|
||||
// (e.g. "5h" or "7d") has been exceeded, using utilization and surpassed-threshold headers.
|
||||
func isAnthropicWindowExceeded(headers http.Header, window string) bool {
|
||||
prefix := "anthropic-ratelimit-unified-" + window + "-"
|
||||
|
||||
// Check surpassed-threshold first (most explicit signal)
|
||||
if st := headers.Get(prefix + "surpassed-threshold"); strings.EqualFold(st, "true") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Fall back to utilization >= 1.0
|
||||
if utilStr := headers.Get(prefix + "utilization"); utilStr != "" {
|
||||
if util, err := strconv.ParseFloat(utilStr, 64); err == nil && util >= 1.0-1e-9 {
|
||||
// Use a small epsilon to handle floating point: treat 0.9999999... as >= 1.0
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// pickSooner returns whichever of the two time pointers is earlier.
|
||||
// If only one is non-nil, it is returned. If both are nil, returns nil.
|
||||
func pickSooner(a, b *time.Time) *time.Time {
|
||||
switch {
|
||||
case a != nil && b != nil:
|
||||
if a.Before(*b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
case a != nil:
|
||||
return a
|
||||
default:
|
||||
return b
|
||||
}
|
||||
}
|
||||
|
||||
// parseOpenAIRateLimitResetTime 解析 OpenAI 格式的 429 响应,返回重置时间的 Unix 时间戳
|
||||
// OpenAI 的 usage_limit_reached 错误格式:
|
||||
//
|
||||
|
||||
202
backend/internal/service/ratelimit_service_anthropic_test.go
Normal file
202
backend/internal/service/ratelimit_service_anthropic_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCalculateAnthropic429ResetTime_Only5hExceeded(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
headers.Set("anthropic-ratelimit-unified-5h-utilization", "1.02")
|
||||
headers.Set("anthropic-ratelimit-unified-5h-reset", "1770998400")
|
||||
headers.Set("anthropic-ratelimit-unified-7d-utilization", "0.32")
|
||||
headers.Set("anthropic-ratelimit-unified-7d-reset", "1771549200")
|
||||
|
||||
result := calculateAnthropic429ResetTime(headers)
|
||||
assertAnthropicResult(t, result, 1770998400)
|
||||
|
||||
if result.fiveHourReset == nil || !result.fiveHourReset.Equal(time.Unix(1770998400, 0)) {
|
||||
t.Errorf("expected fiveHourReset=1770998400, got %v", result.fiveHourReset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAnthropic429ResetTime_Only7dExceeded(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
headers.Set("anthropic-ratelimit-unified-5h-utilization", "0.50")
|
||||
headers.Set("anthropic-ratelimit-unified-5h-reset", "1770998400")
|
||||
headers.Set("anthropic-ratelimit-unified-7d-utilization", "1.05")
|
||||
headers.Set("anthropic-ratelimit-unified-7d-reset", "1771549200")
|
||||
|
||||
result := calculateAnthropic429ResetTime(headers)
|
||||
assertAnthropicResult(t, result, 1771549200)
|
||||
|
||||
// fiveHourReset should still be populated for session window calculation
|
||||
if result.fiveHourReset == nil || !result.fiveHourReset.Equal(time.Unix(1770998400, 0)) {
|
||||
t.Errorf("expected fiveHourReset=1770998400, got %v", result.fiveHourReset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAnthropic429ResetTime_BothExceeded(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
headers.Set("anthropic-ratelimit-unified-5h-utilization", "1.10")
|
||||
headers.Set("anthropic-ratelimit-unified-5h-reset", "1770998400")
|
||||
headers.Set("anthropic-ratelimit-unified-7d-utilization", "1.02")
|
||||
headers.Set("anthropic-ratelimit-unified-7d-reset", "1771549200")
|
||||
|
||||
result := calculateAnthropic429ResetTime(headers)
|
||||
assertAnthropicResult(t, result, 1771549200)
|
||||
}
|
||||
|
||||
func TestCalculateAnthropic429ResetTime_NoPerWindowHeaders(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
headers.Set("anthropic-ratelimit-unified-reset", "1771549200")
|
||||
|
||||
result := calculateAnthropic429ResetTime(headers)
|
||||
if result != nil {
|
||||
t.Errorf("expected nil result when no per-window headers, got resetAt=%v", result.resetAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAnthropic429ResetTime_NoHeaders(t *testing.T) {
|
||||
result := calculateAnthropic429ResetTime(http.Header{})
|
||||
if result != nil {
|
||||
t.Errorf("expected nil result for empty headers, got resetAt=%v", result.resetAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAnthropic429ResetTime_SurpassedThreshold(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
headers.Set("anthropic-ratelimit-unified-5h-surpassed-threshold", "true")
|
||||
headers.Set("anthropic-ratelimit-unified-5h-reset", "1770998400")
|
||||
headers.Set("anthropic-ratelimit-unified-7d-surpassed-threshold", "false")
|
||||
headers.Set("anthropic-ratelimit-unified-7d-reset", "1771549200")
|
||||
|
||||
result := calculateAnthropic429ResetTime(headers)
|
||||
assertAnthropicResult(t, result, 1770998400)
|
||||
}
|
||||
|
||||
func TestCalculateAnthropic429ResetTime_UtilizationExactlyOne(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
headers.Set("anthropic-ratelimit-unified-5h-utilization", "1.0")
|
||||
headers.Set("anthropic-ratelimit-unified-5h-reset", "1770998400")
|
||||
headers.Set("anthropic-ratelimit-unified-7d-utilization", "0.5")
|
||||
headers.Set("anthropic-ratelimit-unified-7d-reset", "1771549200")
|
||||
|
||||
result := calculateAnthropic429ResetTime(headers)
|
||||
assertAnthropicResult(t, result, 1770998400)
|
||||
}
|
||||
|
||||
func TestCalculateAnthropic429ResetTime_NeitherExceeded_UsesShorter(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
headers.Set("anthropic-ratelimit-unified-5h-utilization", "0.95")
|
||||
headers.Set("anthropic-ratelimit-unified-5h-reset", "1770998400") // sooner
|
||||
headers.Set("anthropic-ratelimit-unified-7d-utilization", "0.80")
|
||||
headers.Set("anthropic-ratelimit-unified-7d-reset", "1771549200") // later
|
||||
|
||||
result := calculateAnthropic429ResetTime(headers)
|
||||
assertAnthropicResult(t, result, 1770998400)
|
||||
}
|
||||
|
||||
func TestCalculateAnthropic429ResetTime_Only5hResetHeader(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
headers.Set("anthropic-ratelimit-unified-5h-utilization", "1.05")
|
||||
headers.Set("anthropic-ratelimit-unified-5h-reset", "1770998400")
|
||||
|
||||
result := calculateAnthropic429ResetTime(headers)
|
||||
assertAnthropicResult(t, result, 1770998400)
|
||||
}
|
||||
|
||||
func TestCalculateAnthropic429ResetTime_Only7dResetHeader(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
headers.Set("anthropic-ratelimit-unified-7d-utilization", "1.03")
|
||||
headers.Set("anthropic-ratelimit-unified-7d-reset", "1771549200")
|
||||
|
||||
result := calculateAnthropic429ResetTime(headers)
|
||||
assertAnthropicResult(t, result, 1771549200)
|
||||
|
||||
if result.fiveHourReset != nil {
|
||||
t.Errorf("expected fiveHourReset=nil when no 5h headers, got %v", result.fiveHourReset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAnthropicWindowExceeded(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headers http.Header
|
||||
window string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "utilization above 1.0",
|
||||
headers: makeHeader("anthropic-ratelimit-unified-5h-utilization", "1.02"),
|
||||
window: "5h",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "utilization exactly 1.0",
|
||||
headers: makeHeader("anthropic-ratelimit-unified-5h-utilization", "1.0"),
|
||||
window: "5h",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "utilization below 1.0",
|
||||
headers: makeHeader("anthropic-ratelimit-unified-5h-utilization", "0.99"),
|
||||
window: "5h",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "surpassed-threshold true",
|
||||
headers: makeHeader("anthropic-ratelimit-unified-7d-surpassed-threshold", "true"),
|
||||
window: "7d",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "surpassed-threshold True (case insensitive)",
|
||||
headers: makeHeader("anthropic-ratelimit-unified-7d-surpassed-threshold", "True"),
|
||||
window: "7d",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "surpassed-threshold false",
|
||||
headers: makeHeader("anthropic-ratelimit-unified-7d-surpassed-threshold", "false"),
|
||||
window: "7d",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "no headers",
|
||||
headers: http.Header{},
|
||||
window: "5h",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := isAnthropicWindowExceeded(tc.headers, tc.window)
|
||||
if got != tc.expected {
|
||||
t.Errorf("expected %v, got %v", tc.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertAnthropicResult is a test helper that verifies the result is non-nil and
|
||||
// has the expected resetAt unix timestamp.
|
||||
func assertAnthropicResult(t *testing.T, result *anthropic429Result, wantUnix int64) {
|
||||
t.Helper()
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
return // unreachable, but satisfies staticcheck SA5011
|
||||
}
|
||||
want := time.Unix(wantUnix, 0)
|
||||
if !result.resetAt.Equal(want) {
|
||||
t.Errorf("expected resetAt=%v, got %v", want, result.resetAt)
|
||||
}
|
||||
}
|
||||
|
||||
func makeHeader(key, value string) http.Header {
|
||||
h := http.Header{}
|
||||
h.Set(key, value)
|
||||
return h
|
||||
}
|
||||
@@ -158,6 +158,7 @@ services:
|
||||
- POSTGRES_USER=${POSTGRES_USER:-sub2api}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-sub2api}
|
||||
- PGDATA=/var/lib/postgresql/data
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
networks:
|
||||
- sub2api-network
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function list(
|
||||
platform?: string
|
||||
type?: string
|
||||
status?: string
|
||||
group?: string
|
||||
search?: string
|
||||
},
|
||||
options?: {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.allGroups', { count: groups.length }) }}
|
||||
{{ t('admin.accounts.groupCountTotal', { count: groups.length }) }}
|
||||
</span>
|
||||
<button
|
||||
@click="showPopover = false"
|
||||
|
||||
@@ -10,16 +10,21 @@
|
||||
<Select :model-value="filters.platform" class="w-40" :options="pOpts" @update:model-value="updatePlatform" @change="$emit('change')" />
|
||||
<Select :model-value="filters.type" class="w-40" :options="tOpts" @update:model-value="updateType" @change="$emit('change')" />
|
||||
<Select :model-value="filters.status" class="w-40" :options="sOpts" @update:model-value="updateStatus" @change="$emit('change')" />
|
||||
<Select :model-value="filters.group" class="w-40" :options="gOpts" @update:model-value="updateGroup" @change="$emit('change')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue'
|
||||
const props = defineProps(['searchQuery', 'filters']); const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); const { t } = useI18n()
|
||||
import type { AdminGroup } from '@/types'
|
||||
const props = defineProps<{ searchQuery: string; filters: Record<string, any>; groups?: AdminGroup[] }>()
|
||||
const emit = defineEmits(['update:searchQuery', 'update:filters', 'change']); const { t } = useI18n()
|
||||
const updatePlatform = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, platform: value }) }
|
||||
const updateType = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, type: value }) }
|
||||
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
|
||||
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
|
||||
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
|
||||
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }])
|
||||
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }])
|
||||
const gOpts = computed(() => [{ value: '', label: t('admin.accounts.allGroups') }, ...(props.groups || []).map(g => ({ value: String(g.id), label: g.name }))])
|
||||
</script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="stat-label truncate">{{ title }}</p>
|
||||
<div class="mt-1 flex items-baseline gap-2">
|
||||
<p class="stat-value">{{ formattedValue }}</p>
|
||||
<p class="stat-value" :title="String(formattedValue)">{{ formattedValue }}</p>
|
||||
<span v-if="change !== undefined" :class="['stat-trend', trendClass]">
|
||||
<Icon
|
||||
v-if="changeType !== 'neutral'"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="sidebar-header">
|
||||
<!-- Custom Logo or Default Logo -->
|
||||
<div class="flex h-9 w-9 items-center justify-center overflow-hidden rounded-xl shadow-glow">
|
||||
<img :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
|
||||
<img v-if="settingsLoaded" :src="siteLogo || '/logo.png'" alt="Logo" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="!sidebarCollapsed" class="flex flex-col">
|
||||
@@ -167,6 +167,7 @@ const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
const siteName = computed(() => appStore.siteName)
|
||||
const siteLogo = computed(() => appStore.siteLogo)
|
||||
const siteVersion = computed(() => appStore.siteVersion)
|
||||
const settingsLoaded = computed(() => appStore.publicSettingsLoaded)
|
||||
|
||||
// SVG Icon Components
|
||||
const DashboardIcon = {
|
||||
|
||||
@@ -1335,6 +1335,7 @@ export default {
|
||||
allPlatforms: 'All Platforms',
|
||||
allTypes: 'All Types',
|
||||
allStatus: 'All Status',
|
||||
allGroups: 'All Groups',
|
||||
oauthType: 'OAuth',
|
||||
setupToken: 'Setup Token',
|
||||
apiKey: 'API Key',
|
||||
@@ -1344,7 +1345,7 @@ export default {
|
||||
schedulableEnabled: 'Scheduling enabled',
|
||||
schedulableDisabled: 'Scheduling disabled',
|
||||
failedToToggleSchedulable: 'Failed to toggle scheduling status',
|
||||
allGroups: '{count} groups total',
|
||||
groupCountTotal: '{count} groups total',
|
||||
platforms: {
|
||||
anthropic: 'Anthropic',
|
||||
claude: 'Claude',
|
||||
|
||||
@@ -1426,6 +1426,7 @@ export default {
|
||||
allPlatforms: '全部平台',
|
||||
allTypes: '全部类型',
|
||||
allStatus: '全部状态',
|
||||
allGroups: '全部分组',
|
||||
oauthType: 'OAuth',
|
||||
// Schedulable toggle
|
||||
schedulable: '参与调度',
|
||||
@@ -1433,7 +1434,7 @@ export default {
|
||||
schedulableEnabled: '调度已开启',
|
||||
schedulableDisabled: '调度已关闭',
|
||||
failedToToggleSchedulable: '切换调度状态失败',
|
||||
allGroups: '共 {count} 个分组',
|
||||
groupCountTotal: '共 {count} 个分组',
|
||||
columns: {
|
||||
name: '名称',
|
||||
platformType: '平台/类型',
|
||||
|
||||
@@ -243,7 +243,7 @@
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@apply text-2xl font-bold text-gray-900 dark:text-white;
|
||||
@apply text-2xl font-bold text-gray-900 dark:text-white truncate;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<AccountTableFilters
|
||||
v-model:searchQuery="params.search"
|
||||
:filters="params"
|
||||
:groups="groups"
|
||||
@update:filters="(newFilters) => Object.assign(params, newFilters)"
|
||||
@change="debouncedReload"
|
||||
@update:searchQuery="debouncedReload"
|
||||
@@ -439,7 +440,7 @@ const isColumnVisible = (key: string) => !hiddenColumns.has(key)
|
||||
|
||||
const { items: accounts, loading, params, pagination, load, reload, debouncedReload, handlePageChange, handlePageSizeChange } = useTableLoader<Account, any>({
|
||||
fetchFn: adminAPI.accounts.list,
|
||||
initialParams: { platform: '', type: '', status: '', search: '' }
|
||||
initialParams: { platform: '', type: '', status: '', group: '', search: '' }
|
||||
})
|
||||
|
||||
const isAnyModalOpen = computed(() => {
|
||||
|
||||
Reference in New Issue
Block a user