Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
c8e2f614
Commit
c8e2f614
authored
Jan 20, 2026
by
cyhhao
Browse files
Merge branch 'main' of github.com:Wei-Shaw/sub2api
parents
c0347cde
c95a8649
Changes
167
Hide whitespace changes
Inline
Side-by-side
backend/internal/service/token_refresh_service.go
View file @
c8e2f614
...
@@ -166,11 +166,25 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc
...
@@ -166,11 +166,25 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc
for
attempt
:=
1
;
attempt
<=
s
.
cfg
.
MaxRetries
;
attempt
++
{
for
attempt
:=
1
;
attempt
<=
s
.
cfg
.
MaxRetries
;
attempt
++
{
newCredentials
,
err
:=
refresher
.
Refresh
(
ctx
,
account
)
newCredentials
,
err
:=
refresher
.
Refresh
(
ctx
,
account
)
if
err
==
nil
{
// 刷新成功,更新账号credentials
// 如果有新凭证,先更新(即使有错误也要保存 token)
if
newCredentials
!=
nil
{
account
.
Credentials
=
newCredentials
account
.
Credentials
=
newCredentials
if
err
:=
s
.
accountRepo
.
Update
(
ctx
,
account
);
err
!=
nil
{
if
saveErr
:=
s
.
accountRepo
.
Update
(
ctx
,
account
);
saveErr
!=
nil
{
return
fmt
.
Errorf
(
"failed to save credentials: %w"
,
err
)
return
fmt
.
Errorf
(
"failed to save credentials: %w"
,
saveErr
)
}
}
if
err
==
nil
{
// Antigravity 账户:如果之前是因为缺少 project_id 而标记为 error,现在成功获取到了,清除错误状态
if
account
.
Platform
==
PlatformAntigravity
&&
account
.
Status
==
StatusError
&&
strings
.
Contains
(
account
.
ErrorMessage
,
"missing_project_id:"
)
{
if
clearErr
:=
s
.
accountRepo
.
ClearError
(
ctx
,
account
.
ID
);
clearErr
!=
nil
{
log
.
Printf
(
"[TokenRefresh] Failed to clear error status for account %d: %v"
,
account
.
ID
,
clearErr
)
}
else
{
log
.
Printf
(
"[TokenRefresh] Account %d: cleared missing_project_id error"
,
account
.
ID
)
}
}
}
// 对所有 OAuth 账号调用缓存失效(InvalidateToken 内部根据平台判断是否需要处理)
// 对所有 OAuth 账号调用缓存失效(InvalidateToken 内部根据平台判断是否需要处理)
if
s
.
cacheInvalidator
!=
nil
&&
account
.
Type
==
AccountTypeOAuth
{
if
s
.
cacheInvalidator
!=
nil
&&
account
.
Type
==
AccountTypeOAuth
{
...
@@ -230,6 +244,7 @@ func isNonRetryableRefreshError(err error) bool {
...
@@ -230,6 +244,7 @@ func isNonRetryableRefreshError(err error) bool {
"invalid_client"
,
// 客户端配置错误
"invalid_client"
,
// 客户端配置错误
"unauthorized_client"
,
// 客户端未授权
"unauthorized_client"
,
// 客户端未授权
"access_denied"
,
// 访问被拒绝
"access_denied"
,
// 访问被拒绝
"missing_project_id"
,
// 缺少 project_id
}
}
for
_
,
needle
:=
range
nonRetryable
{
for
_
,
needle
:=
range
nonRetryable
{
if
strings
.
Contains
(
msg
,
needle
)
{
if
strings
.
Contains
(
msg
,
needle
)
{
...
...
backend/internal/service/usage_cleanup.go
0 → 100644
View file @
c8e2f614
package
service
import
(
"context"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
)
const
(
UsageCleanupStatusPending
=
"pending"
UsageCleanupStatusRunning
=
"running"
UsageCleanupStatusSucceeded
=
"succeeded"
UsageCleanupStatusFailed
=
"failed"
UsageCleanupStatusCanceled
=
"canceled"
)
// UsageCleanupFilters 定义清理任务过滤条件
// 时间范围为必填,其他字段可选
// JSON 序列化用于存储任务参数
//
// start_time/end_time 使用 RFC3339 时间格式
// 以 UTC 或用户时区解析后的时间为准
//
// 说明:
// - nil 表示未设置该过滤条件
// - 过滤条件均为精确匹配
type
UsageCleanupFilters
struct
{
StartTime
time
.
Time
`json:"start_time"`
EndTime
time
.
Time
`json:"end_time"`
UserID
*
int64
`json:"user_id,omitempty"`
APIKeyID
*
int64
`json:"api_key_id,omitempty"`
AccountID
*
int64
`json:"account_id,omitempty"`
GroupID
*
int64
`json:"group_id,omitempty"`
Model
*
string
`json:"model,omitempty"`
Stream
*
bool
`json:"stream,omitempty"`
BillingType
*
int8
`json:"billing_type,omitempty"`
}
// UsageCleanupTask 表示使用记录清理任务
// 状态包含 pending/running/succeeded/failed/canceled
type
UsageCleanupTask
struct
{
ID
int64
Status
string
Filters
UsageCleanupFilters
CreatedBy
int64
DeletedRows
int64
ErrorMsg
*
string
CanceledBy
*
int64
CanceledAt
*
time
.
Time
StartedAt
*
time
.
Time
FinishedAt
*
time
.
Time
CreatedAt
time
.
Time
UpdatedAt
time
.
Time
}
// UsageCleanupRepository 定义清理任务持久层接口
type
UsageCleanupRepository
interface
{
CreateTask
(
ctx
context
.
Context
,
task
*
UsageCleanupTask
)
error
ListTasks
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
UsageCleanupTask
,
*
pagination
.
PaginationResult
,
error
)
// ClaimNextPendingTask 抢占下一条可执行任务:
// - 优先 pending
// - 若 running 超过 staleRunningAfterSeconds(可能由于进程退出/崩溃/超时),允许重新抢占继续执行
ClaimNextPendingTask
(
ctx
context
.
Context
,
staleRunningAfterSeconds
int64
)
(
*
UsageCleanupTask
,
error
)
// GetTaskStatus 查询任务状态;若不存在返回 sql.ErrNoRows
GetTaskStatus
(
ctx
context
.
Context
,
taskID
int64
)
(
string
,
error
)
// UpdateTaskProgress 更新任务进度(deleted_rows)用于断点续跑/展示
UpdateTaskProgress
(
ctx
context
.
Context
,
taskID
int64
,
deletedRows
int64
)
error
// CancelTask 将任务标记为 canceled(仅允许 pending/running)
CancelTask
(
ctx
context
.
Context
,
taskID
int64
,
canceledBy
int64
)
(
bool
,
error
)
MarkTaskSucceeded
(
ctx
context
.
Context
,
taskID
int64
,
deletedRows
int64
)
error
MarkTaskFailed
(
ctx
context
.
Context
,
taskID
int64
,
deletedRows
int64
,
errorMsg
string
)
error
DeleteUsageLogsBatch
(
ctx
context
.
Context
,
filters
UsageCleanupFilters
,
limit
int
)
(
int64
,
error
)
}
backend/internal/service/usage_cleanup_service.go
0 → 100644
View file @
c8e2f614
package
service
import
(
"context"
"database/sql"
"errors"
"fmt"
"log"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
)
const
(
usageCleanupWorkerName
=
"usage_cleanup_worker"
)
// UsageCleanupService 负责创建与执行使用记录清理任务
type
UsageCleanupService
struct
{
repo
UsageCleanupRepository
timingWheel
*
TimingWheelService
dashboard
*
DashboardAggregationService
cfg
*
config
.
Config
running
int32
startOnce
sync
.
Once
stopOnce
sync
.
Once
workerCtx
context
.
Context
workerCancel
context
.
CancelFunc
}
func
NewUsageCleanupService
(
repo
UsageCleanupRepository
,
timingWheel
*
TimingWheelService
,
dashboard
*
DashboardAggregationService
,
cfg
*
config
.
Config
)
*
UsageCleanupService
{
workerCtx
,
workerCancel
:=
context
.
WithCancel
(
context
.
Background
())
return
&
UsageCleanupService
{
repo
:
repo
,
timingWheel
:
timingWheel
,
dashboard
:
dashboard
,
cfg
:
cfg
,
workerCtx
:
workerCtx
,
workerCancel
:
workerCancel
,
}
}
func
describeUsageCleanupFilters
(
filters
UsageCleanupFilters
)
string
{
var
parts
[]
string
parts
=
append
(
parts
,
"start="
+
filters
.
StartTime
.
UTC
()
.
Format
(
time
.
RFC3339
))
parts
=
append
(
parts
,
"end="
+
filters
.
EndTime
.
UTC
()
.
Format
(
time
.
RFC3339
))
if
filters
.
UserID
!=
nil
{
parts
=
append
(
parts
,
fmt
.
Sprintf
(
"user_id=%d"
,
*
filters
.
UserID
))
}
if
filters
.
APIKeyID
!=
nil
{
parts
=
append
(
parts
,
fmt
.
Sprintf
(
"api_key_id=%d"
,
*
filters
.
APIKeyID
))
}
if
filters
.
AccountID
!=
nil
{
parts
=
append
(
parts
,
fmt
.
Sprintf
(
"account_id=%d"
,
*
filters
.
AccountID
))
}
if
filters
.
GroupID
!=
nil
{
parts
=
append
(
parts
,
fmt
.
Sprintf
(
"group_id=%d"
,
*
filters
.
GroupID
))
}
if
filters
.
Model
!=
nil
{
parts
=
append
(
parts
,
"model="
+
strings
.
TrimSpace
(
*
filters
.
Model
))
}
if
filters
.
Stream
!=
nil
{
parts
=
append
(
parts
,
fmt
.
Sprintf
(
"stream=%t"
,
*
filters
.
Stream
))
}
if
filters
.
BillingType
!=
nil
{
parts
=
append
(
parts
,
fmt
.
Sprintf
(
"billing_type=%d"
,
*
filters
.
BillingType
))
}
return
strings
.
Join
(
parts
,
" "
)
}
func
(
s
*
UsageCleanupService
)
Start
()
{
if
s
==
nil
{
return
}
if
s
.
cfg
!=
nil
&&
!
s
.
cfg
.
UsageCleanup
.
Enabled
{
log
.
Printf
(
"[UsageCleanup] not started (disabled)"
)
return
}
if
s
.
repo
==
nil
||
s
.
timingWheel
==
nil
{
log
.
Printf
(
"[UsageCleanup] not started (missing deps)"
)
return
}
interval
:=
s
.
workerInterval
()
s
.
startOnce
.
Do
(
func
()
{
s
.
timingWheel
.
ScheduleRecurring
(
usageCleanupWorkerName
,
interval
,
s
.
runOnce
)
log
.
Printf
(
"[UsageCleanup] started (interval=%s max_range_days=%d batch_size=%d task_timeout=%s)"
,
interval
,
s
.
maxRangeDays
(),
s
.
batchSize
(),
s
.
taskTimeout
())
})
}
func
(
s
*
UsageCleanupService
)
Stop
()
{
if
s
==
nil
{
return
}
s
.
stopOnce
.
Do
(
func
()
{
if
s
.
workerCancel
!=
nil
{
s
.
workerCancel
()
}
if
s
.
timingWheel
!=
nil
{
s
.
timingWheel
.
Cancel
(
usageCleanupWorkerName
)
}
log
.
Printf
(
"[UsageCleanup] stopped"
)
})
}
func
(
s
*
UsageCleanupService
)
ListTasks
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
UsageCleanupTask
,
*
pagination
.
PaginationResult
,
error
)
{
if
s
==
nil
||
s
.
repo
==
nil
{
return
nil
,
nil
,
fmt
.
Errorf
(
"cleanup service not ready"
)
}
return
s
.
repo
.
ListTasks
(
ctx
,
params
)
}
func
(
s
*
UsageCleanupService
)
CreateTask
(
ctx
context
.
Context
,
filters
UsageCleanupFilters
,
createdBy
int64
)
(
*
UsageCleanupTask
,
error
)
{
if
s
==
nil
||
s
.
repo
==
nil
{
return
nil
,
fmt
.
Errorf
(
"cleanup service not ready"
)
}
if
s
.
cfg
!=
nil
&&
!
s
.
cfg
.
UsageCleanup
.
Enabled
{
return
nil
,
infraerrors
.
New
(
http
.
StatusServiceUnavailable
,
"USAGE_CLEANUP_DISABLED"
,
"usage cleanup is disabled"
)
}
if
createdBy
<=
0
{
return
nil
,
infraerrors
.
BadRequest
(
"USAGE_CLEANUP_INVALID_CREATOR"
,
"invalid creator"
)
}
log
.
Printf
(
"[UsageCleanup] create_task requested: operator=%d %s"
,
createdBy
,
describeUsageCleanupFilters
(
filters
))
sanitizeUsageCleanupFilters
(
&
filters
)
if
err
:=
s
.
validateFilters
(
filters
);
err
!=
nil
{
log
.
Printf
(
"[UsageCleanup] create_task rejected: operator=%d err=%v %s"
,
createdBy
,
err
,
describeUsageCleanupFilters
(
filters
))
return
nil
,
err
}
task
:=
&
UsageCleanupTask
{
Status
:
UsageCleanupStatusPending
,
Filters
:
filters
,
CreatedBy
:
createdBy
,
}
if
err
:=
s
.
repo
.
CreateTask
(
ctx
,
task
);
err
!=
nil
{
log
.
Printf
(
"[UsageCleanup] create_task persist failed: operator=%d err=%v %s"
,
createdBy
,
err
,
describeUsageCleanupFilters
(
filters
))
return
nil
,
fmt
.
Errorf
(
"create cleanup task: %w"
,
err
)
}
log
.
Printf
(
"[UsageCleanup] create_task persisted: task=%d operator=%d status=%s deleted_rows=%d %s"
,
task
.
ID
,
createdBy
,
task
.
Status
,
task
.
DeletedRows
,
describeUsageCleanupFilters
(
filters
))
go
s
.
runOnce
()
return
task
,
nil
}
func
(
s
*
UsageCleanupService
)
runOnce
()
{
svc
:=
s
if
svc
==
nil
{
return
}
if
!
atomic
.
CompareAndSwapInt32
(
&
svc
.
running
,
0
,
1
)
{
log
.
Printf
(
"[UsageCleanup] run_once skipped: already_running=true"
)
return
}
defer
atomic
.
StoreInt32
(
&
svc
.
running
,
0
)
parent
:=
context
.
Background
()
if
svc
.
workerCtx
!=
nil
{
parent
=
svc
.
workerCtx
}
ctx
,
cancel
:=
context
.
WithTimeout
(
parent
,
svc
.
taskTimeout
())
defer
cancel
()
task
,
err
:=
svc
.
repo
.
ClaimNextPendingTask
(
ctx
,
int64
(
svc
.
taskTimeout
()
.
Seconds
()))
if
err
!=
nil
{
log
.
Printf
(
"[UsageCleanup] claim pending task failed: %v"
,
err
)
return
}
if
task
==
nil
{
log
.
Printf
(
"[UsageCleanup] run_once done: no_task=true"
)
return
}
log
.
Printf
(
"[UsageCleanup] task claimed: task=%d status=%s created_by=%d deleted_rows=%d %s"
,
task
.
ID
,
task
.
Status
,
task
.
CreatedBy
,
task
.
DeletedRows
,
describeUsageCleanupFilters
(
task
.
Filters
))
svc
.
executeTask
(
ctx
,
task
)
}
func
(
s
*
UsageCleanupService
)
executeTask
(
ctx
context
.
Context
,
task
*
UsageCleanupTask
)
{
if
task
==
nil
{
return
}
batchSize
:=
s
.
batchSize
()
deletedTotal
:=
task
.
DeletedRows
start
:=
time
.
Now
()
log
.
Printf
(
"[UsageCleanup] task started: task=%d batch_size=%d deleted_rows=%d %s"
,
task
.
ID
,
batchSize
,
deletedTotal
,
describeUsageCleanupFilters
(
task
.
Filters
))
var
batchNum
int
for
{
if
ctx
!=
nil
&&
ctx
.
Err
()
!=
nil
{
log
.
Printf
(
"[UsageCleanup] task interrupted: task=%d err=%v"
,
task
.
ID
,
ctx
.
Err
())
return
}
canceled
,
err
:=
s
.
isTaskCanceled
(
ctx
,
task
.
ID
)
if
err
!=
nil
{
s
.
markTaskFailed
(
task
.
ID
,
deletedTotal
,
err
)
return
}
if
canceled
{
log
.
Printf
(
"[UsageCleanup] task canceled: task=%d deleted_rows=%d duration=%s"
,
task
.
ID
,
deletedTotal
,
time
.
Since
(
start
))
return
}
batchNum
++
deleted
,
err
:=
s
.
repo
.
DeleteUsageLogsBatch
(
ctx
,
task
.
Filters
,
batchSize
)
if
err
!=
nil
{
if
errors
.
Is
(
err
,
context
.
Canceled
)
||
errors
.
Is
(
err
,
context
.
DeadlineExceeded
)
{
// 任务被中断(例如服务停止/超时),保持 running 状态,后续通过 stale reclaim 续跑。
log
.
Printf
(
"[UsageCleanup] task interrupted: task=%d err=%v"
,
task
.
ID
,
err
)
return
}
s
.
markTaskFailed
(
task
.
ID
,
deletedTotal
,
err
)
return
}
deletedTotal
+=
deleted
if
deleted
>
0
{
updateCtx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
3
*
time
.
Second
)
if
err
:=
s
.
repo
.
UpdateTaskProgress
(
updateCtx
,
task
.
ID
,
deletedTotal
);
err
!=
nil
{
log
.
Printf
(
"[UsageCleanup] task progress update failed: task=%d deleted_rows=%d err=%v"
,
task
.
ID
,
deletedTotal
,
err
)
}
cancel
()
}
if
batchNum
<=
3
||
batchNum
%
20
==
0
||
deleted
<
int64
(
batchSize
)
{
log
.
Printf
(
"[UsageCleanup] task batch done: task=%d batch=%d deleted=%d deleted_total=%d"
,
task
.
ID
,
batchNum
,
deleted
,
deletedTotal
)
}
if
deleted
==
0
||
deleted
<
int64
(
batchSize
)
{
break
}
}
updateCtx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
5
*
time
.
Second
)
defer
cancel
()
if
err
:=
s
.
repo
.
MarkTaskSucceeded
(
updateCtx
,
task
.
ID
,
deletedTotal
);
err
!=
nil
{
log
.
Printf
(
"[UsageCleanup] update task succeeded failed: task=%d err=%v"
,
task
.
ID
,
err
)
}
else
{
log
.
Printf
(
"[UsageCleanup] task succeeded: task=%d deleted_rows=%d duration=%s"
,
task
.
ID
,
deletedTotal
,
time
.
Since
(
start
))
}
if
s
.
dashboard
!=
nil
{
if
err
:=
s
.
dashboard
.
TriggerRecomputeRange
(
task
.
Filters
.
StartTime
,
task
.
Filters
.
EndTime
);
err
!=
nil
{
log
.
Printf
(
"[UsageCleanup] trigger dashboard recompute failed: task=%d err=%v"
,
task
.
ID
,
err
)
}
else
{
log
.
Printf
(
"[UsageCleanup] trigger dashboard recompute: task=%d start=%s end=%s"
,
task
.
ID
,
task
.
Filters
.
StartTime
.
UTC
()
.
Format
(
time
.
RFC3339
),
task
.
Filters
.
EndTime
.
UTC
()
.
Format
(
time
.
RFC3339
))
}
}
}
func
(
s
*
UsageCleanupService
)
markTaskFailed
(
taskID
int64
,
deletedRows
int64
,
err
error
)
{
msg
:=
strings
.
TrimSpace
(
err
.
Error
())
if
len
(
msg
)
>
500
{
msg
=
msg
[
:
500
]
}
log
.
Printf
(
"[UsageCleanup] task failed: task=%d deleted_rows=%d err=%s"
,
taskID
,
deletedRows
,
msg
)
ctx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
5
*
time
.
Second
)
defer
cancel
()
if
updateErr
:=
s
.
repo
.
MarkTaskFailed
(
ctx
,
taskID
,
deletedRows
,
msg
);
updateErr
!=
nil
{
log
.
Printf
(
"[UsageCleanup] update task failed failed: task=%d err=%v"
,
taskID
,
updateErr
)
}
}
func
(
s
*
UsageCleanupService
)
isTaskCanceled
(
ctx
context
.
Context
,
taskID
int64
)
(
bool
,
error
)
{
if
s
==
nil
||
s
.
repo
==
nil
{
return
false
,
fmt
.
Errorf
(
"cleanup service not ready"
)
}
checkCtx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
2
*
time
.
Second
)
defer
cancel
()
status
,
err
:=
s
.
repo
.
GetTaskStatus
(
checkCtx
,
taskID
)
if
err
!=
nil
{
if
errors
.
Is
(
err
,
sql
.
ErrNoRows
)
{
return
false
,
nil
}
return
false
,
err
}
if
status
==
UsageCleanupStatusCanceled
{
log
.
Printf
(
"[UsageCleanup] task cancel detected: task=%d"
,
taskID
)
}
return
status
==
UsageCleanupStatusCanceled
,
nil
}
func
(
s
*
UsageCleanupService
)
validateFilters
(
filters
UsageCleanupFilters
)
error
{
if
filters
.
StartTime
.
IsZero
()
||
filters
.
EndTime
.
IsZero
()
{
return
infraerrors
.
BadRequest
(
"USAGE_CLEANUP_MISSING_RANGE"
,
"start_date and end_date are required"
)
}
if
filters
.
EndTime
.
Before
(
filters
.
StartTime
)
{
return
infraerrors
.
BadRequest
(
"USAGE_CLEANUP_INVALID_RANGE"
,
"end_date must be after start_date"
)
}
maxDays
:=
s
.
maxRangeDays
()
if
maxDays
>
0
{
delta
:=
filters
.
EndTime
.
Sub
(
filters
.
StartTime
)
if
delta
>
time
.
Duration
(
maxDays
)
*
24
*
time
.
Hour
{
return
infraerrors
.
BadRequest
(
"USAGE_CLEANUP_RANGE_TOO_LARGE"
,
fmt
.
Sprintf
(
"date range exceeds %d days"
,
maxDays
))
}
}
return
nil
}
func
(
s
*
UsageCleanupService
)
CancelTask
(
ctx
context
.
Context
,
taskID
int64
,
canceledBy
int64
)
error
{
if
s
==
nil
||
s
.
repo
==
nil
{
return
fmt
.
Errorf
(
"cleanup service not ready"
)
}
if
s
.
cfg
!=
nil
&&
!
s
.
cfg
.
UsageCleanup
.
Enabled
{
return
infraerrors
.
New
(
http
.
StatusServiceUnavailable
,
"USAGE_CLEANUP_DISABLED"
,
"usage cleanup is disabled"
)
}
if
canceledBy
<=
0
{
return
infraerrors
.
BadRequest
(
"USAGE_CLEANUP_INVALID_CANCELLER"
,
"invalid canceller"
)
}
status
,
err
:=
s
.
repo
.
GetTaskStatus
(
ctx
,
taskID
)
if
err
!=
nil
{
if
errors
.
Is
(
err
,
sql
.
ErrNoRows
)
{
return
infraerrors
.
New
(
http
.
StatusNotFound
,
"USAGE_CLEANUP_TASK_NOT_FOUND"
,
"cleanup task not found"
)
}
return
err
}
log
.
Printf
(
"[UsageCleanup] cancel_task requested: task=%d operator=%d status=%s"
,
taskID
,
canceledBy
,
status
)
if
status
!=
UsageCleanupStatusPending
&&
status
!=
UsageCleanupStatusRunning
{
return
infraerrors
.
New
(
http
.
StatusConflict
,
"USAGE_CLEANUP_CANCEL_CONFLICT"
,
"cleanup task cannot be canceled in current status"
)
}
ok
,
err
:=
s
.
repo
.
CancelTask
(
ctx
,
taskID
,
canceledBy
)
if
err
!=
nil
{
return
err
}
if
!
ok
{
// 状态可能并发改变
return
infraerrors
.
New
(
http
.
StatusConflict
,
"USAGE_CLEANUP_CANCEL_CONFLICT"
,
"cleanup task cannot be canceled in current status"
)
}
log
.
Printf
(
"[UsageCleanup] cancel_task done: task=%d operator=%d"
,
taskID
,
canceledBy
)
return
nil
}
func
sanitizeUsageCleanupFilters
(
filters
*
UsageCleanupFilters
)
{
if
filters
==
nil
{
return
}
if
filters
.
UserID
!=
nil
&&
*
filters
.
UserID
<=
0
{
filters
.
UserID
=
nil
}
if
filters
.
APIKeyID
!=
nil
&&
*
filters
.
APIKeyID
<=
0
{
filters
.
APIKeyID
=
nil
}
if
filters
.
AccountID
!=
nil
&&
*
filters
.
AccountID
<=
0
{
filters
.
AccountID
=
nil
}
if
filters
.
GroupID
!=
nil
&&
*
filters
.
GroupID
<=
0
{
filters
.
GroupID
=
nil
}
if
filters
.
Model
!=
nil
{
model
:=
strings
.
TrimSpace
(
*
filters
.
Model
)
if
model
==
""
{
filters
.
Model
=
nil
}
else
{
filters
.
Model
=
&
model
}
}
if
filters
.
BillingType
!=
nil
&&
*
filters
.
BillingType
<
0
{
filters
.
BillingType
=
nil
}
}
func
(
s
*
UsageCleanupService
)
maxRangeDays
()
int
{
if
s
==
nil
||
s
.
cfg
==
nil
{
return
31
}
if
s
.
cfg
.
UsageCleanup
.
MaxRangeDays
>
0
{
return
s
.
cfg
.
UsageCleanup
.
MaxRangeDays
}
return
31
}
func
(
s
*
UsageCleanupService
)
batchSize
()
int
{
if
s
==
nil
||
s
.
cfg
==
nil
{
return
5000
}
if
s
.
cfg
.
UsageCleanup
.
BatchSize
>
0
{
return
s
.
cfg
.
UsageCleanup
.
BatchSize
}
return
5000
}
func
(
s
*
UsageCleanupService
)
workerInterval
()
time
.
Duration
{
if
s
==
nil
||
s
.
cfg
==
nil
{
return
10
*
time
.
Second
}
if
s
.
cfg
.
UsageCleanup
.
WorkerIntervalSeconds
>
0
{
return
time
.
Duration
(
s
.
cfg
.
UsageCleanup
.
WorkerIntervalSeconds
)
*
time
.
Second
}
return
10
*
time
.
Second
}
func
(
s
*
UsageCleanupService
)
taskTimeout
()
time
.
Duration
{
if
s
==
nil
||
s
.
cfg
==
nil
{
return
30
*
time
.
Minute
}
if
s
.
cfg
.
UsageCleanup
.
TaskTimeoutSeconds
>
0
{
return
time
.
Duration
(
s
.
cfg
.
UsageCleanup
.
TaskTimeoutSeconds
)
*
time
.
Second
}
return
30
*
time
.
Minute
}
backend/internal/service/usage_cleanup_service_test.go
0 → 100644
View file @
c8e2f614
package
service
import
(
"context"
"database/sql"
"errors"
"net/http"
"strings"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/stretchr/testify/require"
)
type
cleanupDeleteResponse
struct
{
deleted
int64
err
error
}
type
cleanupDeleteCall
struct
{
filters
UsageCleanupFilters
limit
int
}
type
cleanupMarkCall
struct
{
taskID
int64
deletedRows
int64
errMsg
string
}
type
cleanupRepoStub
struct
{
mu
sync
.
Mutex
created
[]
*
UsageCleanupTask
createErr
error
listTasks
[]
UsageCleanupTask
listResult
*
pagination
.
PaginationResult
listErr
error
claimQueue
[]
*
UsageCleanupTask
claimErr
error
deleteQueue
[]
cleanupDeleteResponse
deleteCalls
[]
cleanupDeleteCall
markSucceeded
[]
cleanupMarkCall
markFailed
[]
cleanupMarkCall
statusByID
map
[
int64
]
string
statusErr
error
progressCalls
[]
cleanupMarkCall
updateErr
error
cancelCalls
[]
int64
cancelErr
error
cancelResult
*
bool
markFailedErr
error
}
type
dashboardRepoStub
struct
{
recomputeErr
error
}
func
(
s
*
dashboardRepoStub
)
AggregateRange
(
ctx
context
.
Context
,
start
,
end
time
.
Time
)
error
{
return
nil
}
func
(
s
*
dashboardRepoStub
)
RecomputeRange
(
ctx
context
.
Context
,
start
,
end
time
.
Time
)
error
{
return
s
.
recomputeErr
}
func
(
s
*
dashboardRepoStub
)
GetAggregationWatermark
(
ctx
context
.
Context
)
(
time
.
Time
,
error
)
{
return
time
.
Time
{},
nil
}
func
(
s
*
dashboardRepoStub
)
UpdateAggregationWatermark
(
ctx
context
.
Context
,
aggregatedAt
time
.
Time
)
error
{
return
nil
}
func
(
s
*
dashboardRepoStub
)
CleanupAggregates
(
ctx
context
.
Context
,
hourlyCutoff
,
dailyCutoff
time
.
Time
)
error
{
return
nil
}
func
(
s
*
dashboardRepoStub
)
CleanupUsageLogs
(
ctx
context
.
Context
,
cutoff
time
.
Time
)
error
{
return
nil
}
func
(
s
*
dashboardRepoStub
)
EnsureUsageLogsPartitions
(
ctx
context
.
Context
,
now
time
.
Time
)
error
{
return
nil
}
func
(
s
*
cleanupRepoStub
)
CreateTask
(
ctx
context
.
Context
,
task
*
UsageCleanupTask
)
error
{
if
task
==
nil
{
return
nil
}
s
.
mu
.
Lock
()
defer
s
.
mu
.
Unlock
()
if
s
.
createErr
!=
nil
{
return
s
.
createErr
}
if
task
.
ID
==
0
{
task
.
ID
=
int64
(
len
(
s
.
created
)
+
1
)
}
if
task
.
CreatedAt
.
IsZero
()
{
task
.
CreatedAt
=
time
.
Now
()
.
UTC
()
}
if
task
.
UpdatedAt
.
IsZero
()
{
task
.
UpdatedAt
=
task
.
CreatedAt
}
clone
:=
*
task
s
.
created
=
append
(
s
.
created
,
&
clone
)
return
nil
}
func
(
s
*
cleanupRepoStub
)
ListTasks
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
UsageCleanupTask
,
*
pagination
.
PaginationResult
,
error
)
{
s
.
mu
.
Lock
()
defer
s
.
mu
.
Unlock
()
return
s
.
listTasks
,
s
.
listResult
,
s
.
listErr
}
func
(
s
*
cleanupRepoStub
)
ClaimNextPendingTask
(
ctx
context
.
Context
,
staleRunningAfterSeconds
int64
)
(
*
UsageCleanupTask
,
error
)
{
s
.
mu
.
Lock
()
defer
s
.
mu
.
Unlock
()
if
s
.
claimErr
!=
nil
{
return
nil
,
s
.
claimErr
}
if
len
(
s
.
claimQueue
)
==
0
{
return
nil
,
nil
}
task
:=
s
.
claimQueue
[
0
]
s
.
claimQueue
=
s
.
claimQueue
[
1
:
]
if
s
.
statusByID
==
nil
{
s
.
statusByID
=
map
[
int64
]
string
{}
}
s
.
statusByID
[
task
.
ID
]
=
UsageCleanupStatusRunning
return
task
,
nil
}
func
(
s
*
cleanupRepoStub
)
GetTaskStatus
(
ctx
context
.
Context
,
taskID
int64
)
(
string
,
error
)
{
s
.
mu
.
Lock
()
defer
s
.
mu
.
Unlock
()
if
s
.
statusErr
!=
nil
{
return
""
,
s
.
statusErr
}
if
s
.
statusByID
==
nil
{
return
""
,
sql
.
ErrNoRows
}
status
,
ok
:=
s
.
statusByID
[
taskID
]
if
!
ok
{
return
""
,
sql
.
ErrNoRows
}
return
status
,
nil
}
func
(
s
*
cleanupRepoStub
)
UpdateTaskProgress
(
ctx
context
.
Context
,
taskID
int64
,
deletedRows
int64
)
error
{
s
.
mu
.
Lock
()
defer
s
.
mu
.
Unlock
()
s
.
progressCalls
=
append
(
s
.
progressCalls
,
cleanupMarkCall
{
taskID
:
taskID
,
deletedRows
:
deletedRows
})
if
s
.
updateErr
!=
nil
{
return
s
.
updateErr
}
return
nil
}
func
(
s
*
cleanupRepoStub
)
CancelTask
(
ctx
context
.
Context
,
taskID
int64
,
canceledBy
int64
)
(
bool
,
error
)
{
s
.
mu
.
Lock
()
defer
s
.
mu
.
Unlock
()
s
.
cancelCalls
=
append
(
s
.
cancelCalls
,
taskID
)
if
s
.
cancelErr
!=
nil
{
return
false
,
s
.
cancelErr
}
if
s
.
cancelResult
!=
nil
{
ok
:=
*
s
.
cancelResult
if
ok
{
if
s
.
statusByID
==
nil
{
s
.
statusByID
=
map
[
int64
]
string
{}
}
s
.
statusByID
[
taskID
]
=
UsageCleanupStatusCanceled
}
return
ok
,
nil
}
if
s
.
statusByID
==
nil
{
s
.
statusByID
=
map
[
int64
]
string
{}
}
status
:=
s
.
statusByID
[
taskID
]
if
status
!=
UsageCleanupStatusPending
&&
status
!=
UsageCleanupStatusRunning
{
return
false
,
nil
}
s
.
statusByID
[
taskID
]
=
UsageCleanupStatusCanceled
return
true
,
nil
}
func
(
s
*
cleanupRepoStub
)
MarkTaskSucceeded
(
ctx
context
.
Context
,
taskID
int64
,
deletedRows
int64
)
error
{
s
.
mu
.
Lock
()
defer
s
.
mu
.
Unlock
()
s
.
markSucceeded
=
append
(
s
.
markSucceeded
,
cleanupMarkCall
{
taskID
:
taskID
,
deletedRows
:
deletedRows
})
if
s
.
statusByID
==
nil
{
s
.
statusByID
=
map
[
int64
]
string
{}
}
s
.
statusByID
[
taskID
]
=
UsageCleanupStatusSucceeded
return
nil
}
func
(
s
*
cleanupRepoStub
)
MarkTaskFailed
(
ctx
context
.
Context
,
taskID
int64
,
deletedRows
int64
,
errorMsg
string
)
error
{
s
.
mu
.
Lock
()
defer
s
.
mu
.
Unlock
()
s
.
markFailed
=
append
(
s
.
markFailed
,
cleanupMarkCall
{
taskID
:
taskID
,
deletedRows
:
deletedRows
,
errMsg
:
errorMsg
})
if
s
.
statusByID
==
nil
{
s
.
statusByID
=
map
[
int64
]
string
{}
}
s
.
statusByID
[
taskID
]
=
UsageCleanupStatusFailed
if
s
.
markFailedErr
!=
nil
{
return
s
.
markFailedErr
}
return
nil
}
func
(
s
*
cleanupRepoStub
)
DeleteUsageLogsBatch
(
ctx
context
.
Context
,
filters
UsageCleanupFilters
,
limit
int
)
(
int64
,
error
)
{
s
.
mu
.
Lock
()
defer
s
.
mu
.
Unlock
()
s
.
deleteCalls
=
append
(
s
.
deleteCalls
,
cleanupDeleteCall
{
filters
:
filters
,
limit
:
limit
})
if
len
(
s
.
deleteQueue
)
==
0
{
return
0
,
nil
}
resp
:=
s
.
deleteQueue
[
0
]
s
.
deleteQueue
=
s
.
deleteQueue
[
1
:
]
return
resp
.
deleted
,
resp
.
err
}
func
TestUsageCleanupServiceCreateTaskSanitizeFilters
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
start
:=
time
.
Date
(
2024
,
1
,
1
,
0
,
0
,
0
,
0
,
time
.
UTC
)
end
:=
start
.
Add
(
24
*
time
.
Hour
)
userID
:=
int64
(
-
1
)
apiKeyID
:=
int64
(
10
)
model
:=
" gpt-4 "
billingType
:=
int8
(
-
2
)
filters
:=
UsageCleanupFilters
{
StartTime
:
start
,
EndTime
:
end
,
UserID
:
&
userID
,
APIKeyID
:
&
apiKeyID
,
Model
:
&
model
,
BillingType
:
&
billingType
,
}
task
,
err
:=
svc
.
CreateTask
(
context
.
Background
(),
filters
,
9
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
UsageCleanupStatusPending
,
task
.
Status
)
require
.
Nil
(
t
,
task
.
Filters
.
UserID
)
require
.
NotNil
(
t
,
task
.
Filters
.
APIKeyID
)
require
.
Equal
(
t
,
apiKeyID
,
*
task
.
Filters
.
APIKeyID
)
require
.
NotNil
(
t
,
task
.
Filters
.
Model
)
require
.
Equal
(
t
,
"gpt-4"
,
*
task
.
Filters
.
Model
)
require
.
Nil
(
t
,
task
.
Filters
.
BillingType
)
require
.
Equal
(
t
,
int64
(
9
),
task
.
CreatedBy
)
}
func
TestUsageCleanupServiceCreateTaskInvalidCreator
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
filters
:=
UsageCleanupFilters
{
StartTime
:
time
.
Now
(),
EndTime
:
time
.
Now
()
.
Add
(
24
*
time
.
Hour
),
}
_
,
err
:=
svc
.
CreateTask
(
context
.
Background
(),
filters
,
0
)
require
.
Error
(
t
,
err
)
require
.
Equal
(
t
,
"USAGE_CLEANUP_INVALID_CREATOR"
,
infraerrors
.
Reason
(
err
))
}
func
TestUsageCleanupServiceCreateTaskDisabled
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
false
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
filters
:=
UsageCleanupFilters
{
StartTime
:
time
.
Now
(),
EndTime
:
time
.
Now
()
.
Add
(
24
*
time
.
Hour
),
}
_
,
err
:=
svc
.
CreateTask
(
context
.
Background
(),
filters
,
1
)
require
.
Error
(
t
,
err
)
require
.
Equal
(
t
,
http
.
StatusServiceUnavailable
,
infraerrors
.
Code
(
err
))
require
.
Equal
(
t
,
"USAGE_CLEANUP_DISABLED"
,
infraerrors
.
Reason
(
err
))
}
func
TestUsageCleanupServiceCreateTaskRangeTooLarge
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
1
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
start
:=
time
.
Date
(
2024
,
1
,
1
,
0
,
0
,
0
,
0
,
time
.
UTC
)
end
:=
start
.
Add
(
48
*
time
.
Hour
)
filters
:=
UsageCleanupFilters
{
StartTime
:
start
,
EndTime
:
end
}
_
,
err
:=
svc
.
CreateTask
(
context
.
Background
(),
filters
,
1
)
require
.
Error
(
t
,
err
)
require
.
Equal
(
t
,
"USAGE_CLEANUP_RANGE_TOO_LARGE"
,
infraerrors
.
Reason
(
err
))
}
func
TestUsageCleanupServiceCreateTaskMissingRange
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
_
,
err
:=
svc
.
CreateTask
(
context
.
Background
(),
UsageCleanupFilters
{},
1
)
require
.
Error
(
t
,
err
)
require
.
Equal
(
t
,
"USAGE_CLEANUP_MISSING_RANGE"
,
infraerrors
.
Reason
(
err
))
}
func
TestUsageCleanupServiceCreateTaskRepoError
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
createErr
:
errors
.
New
(
"db down"
)}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
filters
:=
UsageCleanupFilters
{
StartTime
:
time
.
Now
(),
EndTime
:
time
.
Now
()
.
Add
(
24
*
time
.
Hour
),
}
_
,
err
:=
svc
.
CreateTask
(
context
.
Background
(),
filters
,
1
)
require
.
Error
(
t
,
err
)
require
.
Contains
(
t
,
err
.
Error
(),
"create cleanup task"
)
}
func
TestUsageCleanupServiceRunOnceSuccess
(
t
*
testing
.
T
)
{
start
:=
time
.
Date
(
2024
,
1
,
1
,
0
,
0
,
0
,
0
,
time
.
UTC
)
end
:=
start
.
Add
(
2
*
time
.
Hour
)
repo
:=
&
cleanupRepoStub
{
claimQueue
:
[]
*
UsageCleanupTask
{
{
ID
:
5
,
Filters
:
UsageCleanupFilters
{
StartTime
:
start
,
EndTime
:
end
}},
},
deleteQueue
:
[]
cleanupDeleteResponse
{
{
deleted
:
2
},
{
deleted
:
2
},
{
deleted
:
1
},
},
}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
BatchSize
:
2
,
TaskTimeoutSeconds
:
30
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
svc
.
runOnce
()
repo
.
mu
.
Lock
()
defer
repo
.
mu
.
Unlock
()
require
.
Len
(
t
,
repo
.
deleteCalls
,
3
)
require
.
Len
(
t
,
repo
.
markSucceeded
,
1
)
require
.
Empty
(
t
,
repo
.
markFailed
)
require
.
Equal
(
t
,
int64
(
5
),
repo
.
markSucceeded
[
0
]
.
taskID
)
require
.
Equal
(
t
,
int64
(
5
),
repo
.
markSucceeded
[
0
]
.
deletedRows
)
require
.
Equal
(
t
,
2
,
repo
.
deleteCalls
[
0
]
.
limit
)
require
.
Equal
(
t
,
start
,
repo
.
deleteCalls
[
0
]
.
filters
.
StartTime
)
require
.
Equal
(
t
,
end
,
repo
.
deleteCalls
[
0
]
.
filters
.
EndTime
)
}
func
TestUsageCleanupServiceRunOnceClaimError
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
claimErr
:
errors
.
New
(
"claim failed"
)}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
svc
.
runOnce
()
repo
.
mu
.
Lock
()
defer
repo
.
mu
.
Unlock
()
require
.
Empty
(
t
,
repo
.
markSucceeded
)
require
.
Empty
(
t
,
repo
.
markFailed
)
}
func
TestUsageCleanupServiceRunOnceAlreadyRunning
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
svc
.
running
=
1
svc
.
runOnce
()
}
func
TestUsageCleanupServiceExecuteTaskFailed
(
t
*
testing
.
T
)
{
longMsg
:=
strings
.
Repeat
(
"x"
,
600
)
repo
:=
&
cleanupRepoStub
{
deleteQueue
:
[]
cleanupDeleteResponse
{
{
err
:
errors
.
New
(
longMsg
)},
},
}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
BatchSize
:
3
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
task
:=
&
UsageCleanupTask
{
ID
:
11
,
Filters
:
UsageCleanupFilters
{
StartTime
:
time
.
Now
(),
EndTime
:
time
.
Now
()
.
Add
(
24
*
time
.
Hour
),
},
}
svc
.
executeTask
(
context
.
Background
(),
task
)
repo
.
mu
.
Lock
()
defer
repo
.
mu
.
Unlock
()
require
.
Len
(
t
,
repo
.
markFailed
,
1
)
require
.
Equal
(
t
,
int64
(
11
),
repo
.
markFailed
[
0
]
.
taskID
)
require
.
Equal
(
t
,
500
,
len
(
repo
.
markFailed
[
0
]
.
errMsg
))
}
func
TestUsageCleanupServiceExecuteTaskProgressError
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
deleteQueue
:
[]
cleanupDeleteResponse
{
{
deleted
:
2
},
{
deleted
:
0
},
},
updateErr
:
errors
.
New
(
"update failed"
),
}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
BatchSize
:
2
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
task
:=
&
UsageCleanupTask
{
ID
:
8
,
Filters
:
UsageCleanupFilters
{
StartTime
:
time
.
Now
()
.
UTC
(),
EndTime
:
time
.
Now
()
.
UTC
()
.
Add
(
time
.
Hour
),
},
}
svc
.
executeTask
(
context
.
Background
(),
task
)
repo
.
mu
.
Lock
()
defer
repo
.
mu
.
Unlock
()
require
.
Len
(
t
,
repo
.
markSucceeded
,
1
)
require
.
Empty
(
t
,
repo
.
markFailed
)
require
.
Len
(
t
,
repo
.
progressCalls
,
1
)
}
func
TestUsageCleanupServiceExecuteTaskDeleteCanceled
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
deleteQueue
:
[]
cleanupDeleteResponse
{
{
err
:
context
.
Canceled
},
},
}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
BatchSize
:
2
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
task
:=
&
UsageCleanupTask
{
ID
:
12
,
Filters
:
UsageCleanupFilters
{
StartTime
:
time
.
Now
()
.
UTC
(),
EndTime
:
time
.
Now
()
.
UTC
()
.
Add
(
time
.
Hour
),
},
}
svc
.
executeTask
(
context
.
Background
(),
task
)
repo
.
mu
.
Lock
()
defer
repo
.
mu
.
Unlock
()
require
.
Empty
(
t
,
repo
.
markSucceeded
)
require
.
Empty
(
t
,
repo
.
markFailed
)
}
func
TestUsageCleanupServiceExecuteTaskContextCanceled
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
BatchSize
:
2
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
task
:=
&
UsageCleanupTask
{
ID
:
9
,
Filters
:
UsageCleanupFilters
{
StartTime
:
time
.
Now
()
.
UTC
(),
EndTime
:
time
.
Now
()
.
UTC
()
.
Add
(
time
.
Hour
),
},
}
ctx
,
cancel
:=
context
.
WithCancel
(
context
.
Background
())
cancel
()
svc
.
executeTask
(
ctx
,
task
)
repo
.
mu
.
Lock
()
defer
repo
.
mu
.
Unlock
()
require
.
Empty
(
t
,
repo
.
markSucceeded
)
require
.
Empty
(
t
,
repo
.
markFailed
)
require
.
Empty
(
t
,
repo
.
deleteCalls
)
}
func
TestUsageCleanupServiceExecuteTaskMarkFailedUpdateError
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
deleteQueue
:
[]
cleanupDeleteResponse
{
{
err
:
errors
.
New
(
"boom"
)},
},
markFailedErr
:
errors
.
New
(
"update failed"
),
}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
BatchSize
:
2
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
task
:=
&
UsageCleanupTask
{
ID
:
13
,
Filters
:
UsageCleanupFilters
{
StartTime
:
time
.
Now
()
.
UTC
(),
EndTime
:
time
.
Now
()
.
UTC
()
.
Add
(
time
.
Hour
),
},
}
svc
.
executeTask
(
context
.
Background
(),
task
)
repo
.
mu
.
Lock
()
defer
repo
.
mu
.
Unlock
()
require
.
Len
(
t
,
repo
.
markFailed
,
1
)
require
.
Equal
(
t
,
int64
(
13
),
repo
.
markFailed
[
0
]
.
taskID
)
}
func
TestUsageCleanupServiceExecuteTaskDashboardRecomputeError
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
deleteQueue
:
[]
cleanupDeleteResponse
{
{
deleted
:
0
},
},
}
dashboard
:=
NewDashboardAggregationService
(
&
dashboardRepoStub
{},
nil
,
&
config
.
Config
{
DashboardAgg
:
config
.
DashboardAggregationConfig
{
Enabled
:
false
},
})
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
BatchSize
:
2
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
dashboard
,
cfg
)
task
:=
&
UsageCleanupTask
{
ID
:
14
,
Filters
:
UsageCleanupFilters
{
StartTime
:
time
.
Now
()
.
UTC
(),
EndTime
:
time
.
Now
()
.
UTC
()
.
Add
(
time
.
Hour
),
},
}
svc
.
executeTask
(
context
.
Background
(),
task
)
repo
.
mu
.
Lock
()
defer
repo
.
mu
.
Unlock
()
require
.
Len
(
t
,
repo
.
markSucceeded
,
1
)
}
func
TestUsageCleanupServiceExecuteTaskDashboardRecomputeSuccess
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
deleteQueue
:
[]
cleanupDeleteResponse
{
{
deleted
:
0
},
},
}
dashboard
:=
NewDashboardAggregationService
(
&
dashboardRepoStub
{},
nil
,
&
config
.
Config
{
DashboardAgg
:
config
.
DashboardAggregationConfig
{
Enabled
:
true
},
})
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
BatchSize
:
2
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
dashboard
,
cfg
)
task
:=
&
UsageCleanupTask
{
ID
:
15
,
Filters
:
UsageCleanupFilters
{
StartTime
:
time
.
Now
()
.
UTC
(),
EndTime
:
time
.
Now
()
.
UTC
()
.
Add
(
time
.
Hour
),
},
}
svc
.
executeTask
(
context
.
Background
(),
task
)
repo
.
mu
.
Lock
()
defer
repo
.
mu
.
Unlock
()
require
.
Len
(
t
,
repo
.
markSucceeded
,
1
)
}
func
TestUsageCleanupServiceExecuteTaskCanceled
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
statusByID
:
map
[
int64
]
string
{
3
:
UsageCleanupStatusCanceled
,
},
}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
BatchSize
:
2
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
task
:=
&
UsageCleanupTask
{
ID
:
3
,
Filters
:
UsageCleanupFilters
{
StartTime
:
time
.
Now
()
.
UTC
(),
EndTime
:
time
.
Now
()
.
UTC
()
.
Add
(
time
.
Hour
),
},
}
svc
.
executeTask
(
context
.
Background
(),
task
)
repo
.
mu
.
Lock
()
defer
repo
.
mu
.
Unlock
()
require
.
Empty
(
t
,
repo
.
deleteCalls
)
require
.
Empty
(
t
,
repo
.
markSucceeded
)
require
.
Empty
(
t
,
repo
.
markFailed
)
}
func
TestUsageCleanupServiceCancelTaskSuccess
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
statusByID
:
map
[
int64
]
string
{
5
:
UsageCleanupStatusPending
,
},
}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
err
:=
svc
.
CancelTask
(
context
.
Background
(),
5
,
9
)
require
.
NoError
(
t
,
err
)
repo
.
mu
.
Lock
()
defer
repo
.
mu
.
Unlock
()
require
.
Equal
(
t
,
UsageCleanupStatusCanceled
,
repo
.
statusByID
[
5
])
require
.
Len
(
t
,
repo
.
cancelCalls
,
1
)
}
func
TestUsageCleanupServiceCancelTaskDisabled
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
false
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
err
:=
svc
.
CancelTask
(
context
.
Background
(),
1
,
2
)
require
.
Error
(
t
,
err
)
require
.
Equal
(
t
,
http
.
StatusServiceUnavailable
,
infraerrors
.
Code
(
err
))
require
.
Equal
(
t
,
"USAGE_CLEANUP_DISABLED"
,
infraerrors
.
Reason
(
err
))
}
func
TestUsageCleanupServiceCancelTaskNotFound
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
err
:=
svc
.
CancelTask
(
context
.
Background
(),
999
,
1
)
require
.
Error
(
t
,
err
)
require
.
Equal
(
t
,
http
.
StatusNotFound
,
infraerrors
.
Code
(
err
))
require
.
Equal
(
t
,
"USAGE_CLEANUP_TASK_NOT_FOUND"
,
infraerrors
.
Reason
(
err
))
}
func
TestUsageCleanupServiceCancelTaskStatusError
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
statusErr
:
errors
.
New
(
"status broken"
)}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
err
:=
svc
.
CancelTask
(
context
.
Background
(),
7
,
1
)
require
.
Error
(
t
,
err
)
require
.
Contains
(
t
,
err
.
Error
(),
"status broken"
)
}
func
TestUsageCleanupServiceCancelTaskConflict
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
statusByID
:
map
[
int64
]
string
{
7
:
UsageCleanupStatusSucceeded
,
},
}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
err
:=
svc
.
CancelTask
(
context
.
Background
(),
7
,
1
)
require
.
Error
(
t
,
err
)
require
.
Equal
(
t
,
http
.
StatusConflict
,
infraerrors
.
Code
(
err
))
require
.
Equal
(
t
,
"USAGE_CLEANUP_CANCEL_CONFLICT"
,
infraerrors
.
Reason
(
err
))
}
func
TestUsageCleanupServiceCancelTaskRepoConflict
(
t
*
testing
.
T
)
{
shouldCancel
:=
false
repo
:=
&
cleanupRepoStub
{
statusByID
:
map
[
int64
]
string
{
7
:
UsageCleanupStatusPending
,
},
cancelResult
:
&
shouldCancel
,
}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
err
:=
svc
.
CancelTask
(
context
.
Background
(),
7
,
1
)
require
.
Error
(
t
,
err
)
require
.
Equal
(
t
,
http
.
StatusConflict
,
infraerrors
.
Code
(
err
))
require
.
Equal
(
t
,
"USAGE_CLEANUP_CANCEL_CONFLICT"
,
infraerrors
.
Reason
(
err
))
}
func
TestUsageCleanupServiceCancelTaskRepoError
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
statusByID
:
map
[
int64
]
string
{
7
:
UsageCleanupStatusPending
,
},
cancelErr
:
errors
.
New
(
"cancel failed"
),
}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
err
:=
svc
.
CancelTask
(
context
.
Background
(),
7
,
1
)
require
.
Error
(
t
,
err
)
require
.
Contains
(
t
,
err
.
Error
(),
"cancel failed"
)
}
func
TestUsageCleanupServiceCancelTaskInvalidCanceller
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
statusByID
:
map
[
int64
]
string
{
7
:
UsageCleanupStatusRunning
,
},
}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
err
:=
svc
.
CancelTask
(
context
.
Background
(),
7
,
0
)
require
.
Error
(
t
,
err
)
require
.
Equal
(
t
,
"USAGE_CLEANUP_INVALID_CANCELLER"
,
infraerrors
.
Reason
(
err
))
}
func
TestUsageCleanupServiceListTasks
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
listTasks
:
[]
UsageCleanupTask
{{
ID
:
1
},
{
ID
:
2
}},
listResult
:
&
pagination
.
PaginationResult
{
Total
:
2
,
Page
:
1
,
PageSize
:
20
,
Pages
:
1
,
},
}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}})
tasks
,
result
,
err
:=
svc
.
ListTasks
(
context
.
Background
(),
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
20
})
require
.
NoError
(
t
,
err
)
require
.
Len
(
t
,
tasks
,
2
)
require
.
Equal
(
t
,
int64
(
2
),
result
.
Total
)
}
func
TestUsageCleanupServiceListTasksNotReady
(
t
*
testing
.
T
)
{
var
nilSvc
*
UsageCleanupService
_
,
_
,
err
:=
nilSvc
.
ListTasks
(
context
.
Background
(),
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
20
})
require
.
Error
(
t
,
err
)
svc
:=
NewUsageCleanupService
(
nil
,
nil
,
nil
,
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}})
_
,
_
,
err
=
svc
.
ListTasks
(
context
.
Background
(),
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
20
})
require
.
Error
(
t
,
err
)
}
func
TestUsageCleanupServiceDefaultsAndLifecycle
(
t
*
testing
.
T
)
{
var
nilSvc
*
UsageCleanupService
require
.
Equal
(
t
,
31
,
nilSvc
.
maxRangeDays
())
require
.
Equal
(
t
,
5000
,
nilSvc
.
batchSize
())
require
.
Equal
(
t
,
10
*
time
.
Second
,
nilSvc
.
workerInterval
())
require
.
Equal
(
t
,
30
*
time
.
Minute
,
nilSvc
.
taskTimeout
())
nilSvc
.
Start
()
nilSvc
.
Stop
()
repo
:=
&
cleanupRepoStub
{}
cfgDisabled
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
false
}}
svcDisabled
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfgDisabled
)
svcDisabled
.
Start
()
svcDisabled
.
Stop
()
timingWheel
,
err
:=
NewTimingWheelService
()
require
.
NoError
(
t
,
err
)
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
WorkerIntervalSeconds
:
5
}}
svc
:=
NewUsageCleanupService
(
repo
,
timingWheel
,
nil
,
cfg
)
require
.
Equal
(
t
,
5
*
time
.
Second
,
svc
.
workerInterval
())
svc
.
Start
()
svc
.
Stop
()
cfgFallback
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
svcFallback
:=
NewUsageCleanupService
(
repo
,
timingWheel
,
nil
,
cfgFallback
)
require
.
Equal
(
t
,
31
,
svcFallback
.
maxRangeDays
())
require
.
Equal
(
t
,
5000
,
svcFallback
.
batchSize
())
require
.
Equal
(
t
,
10
*
time
.
Second
,
svcFallback
.
workerInterval
())
svcMissingDeps
:=
NewUsageCleanupService
(
nil
,
nil
,
nil
,
cfgFallback
)
svcMissingDeps
.
Start
()
}
func
TestSanitizeUsageCleanupFiltersModelEmpty
(
t
*
testing
.
T
)
{
model
:=
" "
apiKeyID
:=
int64
(
-
5
)
accountID
:=
int64
(
-
1
)
groupID
:=
int64
(
-
2
)
filters
:=
UsageCleanupFilters
{
UserID
:
&
apiKeyID
,
APIKeyID
:
&
apiKeyID
,
AccountID
:
&
accountID
,
GroupID
:
&
groupID
,
Model
:
&
model
,
}
sanitizeUsageCleanupFilters
(
&
filters
)
require
.
Nil
(
t
,
filters
.
UserID
)
require
.
Nil
(
t
,
filters
.
APIKeyID
)
require
.
Nil
(
t
,
filters
.
AccountID
)
require
.
Nil
(
t
,
filters
.
GroupID
)
require
.
Nil
(
t
,
filters
.
Model
)
}
func
TestDescribeUsageCleanupFiltersAllFields
(
t
*
testing
.
T
)
{
start
:=
time
.
Date
(
2024
,
2
,
1
,
10
,
0
,
0
,
0
,
time
.
UTC
)
end
:=
start
.
Add
(
2
*
time
.
Hour
)
userID
:=
int64
(
1
)
apiKeyID
:=
int64
(
2
)
accountID
:=
int64
(
3
)
groupID
:=
int64
(
4
)
model
:=
" gpt-4 "
stream
:=
true
billingType
:=
int8
(
2
)
filters
:=
UsageCleanupFilters
{
StartTime
:
start
,
EndTime
:
end
,
UserID
:
&
userID
,
APIKeyID
:
&
apiKeyID
,
AccountID
:
&
accountID
,
GroupID
:
&
groupID
,
Model
:
&
model
,
Stream
:
&
stream
,
BillingType
:
&
billingType
,
}
desc
:=
describeUsageCleanupFilters
(
filters
)
require
.
Equal
(
t
,
"start=2024-02-01T10:00:00Z end=2024-02-01T12:00:00Z user_id=1 api_key_id=2 account_id=3 group_id=4 model=gpt-4 stream=true billing_type=2"
,
desc
)
}
func
TestUsageCleanupServiceIsTaskCanceledNotFound
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}})
canceled
,
err
:=
svc
.
isTaskCanceled
(
context
.
Background
(),
9
)
require
.
NoError
(
t
,
err
)
require
.
False
(
t
,
canceled
)
}
func
TestUsageCleanupServiceIsTaskCanceledError
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
statusErr
:
errors
.
New
(
"status err"
)}
svc
:=
NewUsageCleanupService
(
repo
,
nil
,
nil
,
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}})
_
,
err
:=
svc
.
isTaskCanceled
(
context
.
Background
(),
9
)
require
.
Error
(
t
,
err
)
require
.
Contains
(
t
,
err
.
Error
(),
"status err"
)
}
backend/internal/service/wire.go
View file @
c8e2f614
package
service
package
service
import
(
import
(
"context"
"database/sql"
"database/sql"
"time"
"time"
...
@@ -57,6 +58,13 @@ func ProvideDashboardAggregationService(repo DashboardAggregationRepository, tim
...
@@ -57,6 +58,13 @@ func ProvideDashboardAggregationService(repo DashboardAggregationRepository, tim
return
svc
return
svc
}
}
// ProvideUsageCleanupService 创建并启动使用记录清理任务服务
func
ProvideUsageCleanupService
(
repo
UsageCleanupRepository
,
timingWheel
*
TimingWheelService
,
dashboardAgg
*
DashboardAggregationService
,
cfg
*
config
.
Config
)
*
UsageCleanupService
{
svc
:=
NewUsageCleanupService
(
repo
,
timingWheel
,
dashboardAgg
,
cfg
)
svc
.
Start
()
return
svc
}
// ProvideAccountExpiryService creates and starts AccountExpiryService.
// ProvideAccountExpiryService creates and starts AccountExpiryService.
func
ProvideAccountExpiryService
(
accountRepo
AccountRepository
)
*
AccountExpiryService
{
func
ProvideAccountExpiryService
(
accountRepo
AccountRepository
)
*
AccountExpiryService
{
svc
:=
NewAccountExpiryService
(
accountRepo
,
time
.
Minute
)
svc
:=
NewAccountExpiryService
(
accountRepo
,
time
.
Minute
)
...
@@ -189,6 +197,8 @@ func ProvideOpsScheduledReportService(
...
@@ -189,6 +197,8 @@ func ProvideOpsScheduledReportService(
// ProvideAPIKeyAuthCacheInvalidator 提供 API Key 认证缓存失效能力
// ProvideAPIKeyAuthCacheInvalidator 提供 API Key 认证缓存失效能力
func
ProvideAPIKeyAuthCacheInvalidator
(
apiKeyService
*
APIKeyService
)
APIKeyAuthCacheInvalidator
{
func
ProvideAPIKeyAuthCacheInvalidator
(
apiKeyService
*
APIKeyService
)
APIKeyAuthCacheInvalidator
{
// Start Pub/Sub subscriber for L1 cache invalidation across instances
apiKeyService
.
StartAuthCacheInvalidationSubscriber
(
context
.
Background
())
return
apiKeyService
return
apiKeyService
}
}
...
@@ -248,6 +258,7 @@ var ProviderSet = wire.NewSet(
...
@@ -248,6 +258,7 @@ var ProviderSet = wire.NewSet(
ProvideAccountExpiryService
,
ProvideAccountExpiryService
,
ProvideTimingWheelService
,
ProvideTimingWheelService
,
ProvideDashboardAggregationService
,
ProvideDashboardAggregationService
,
ProvideUsageCleanupService
,
ProvideDeferredService
,
ProvideDeferredService
,
NewAntigravityQuotaFetcher
,
NewAntigravityQuotaFetcher
,
NewUserAttributeService
,
NewUserAttributeService
,
...
...
backend/migrations/006_add_users_allowed_groups_compat.sql
0 → 100644
View file @
c8e2f614
-- 兼容旧库:若尚未创建 user_allowed_groups,则确保 users.allowed_groups 存在,避免 007 迁移回填失败。
DO
$$
BEGIN
IF
to_regclass
(
'public.user_allowed_groups'
)
IS
NULL
THEN
IF
EXISTS
(
SELECT
1
FROM
information_schema
.
tables
WHERE
table_schema
=
'public'
AND
table_name
=
'users'
)
THEN
ALTER
TABLE
users
ADD
COLUMN
IF
NOT
EXISTS
allowed_groups
BIGINT
[]
DEFAULT
NULL
;
END
IF
;
END
IF
;
END
$$
;
backend/migrations/006b_guard_users_allowed_groups.sql
0 → 100644
View file @
c8e2f614
-- 兼容缺失 users.allowed_groups 的老库,确保 007 回填可执行。
DO
$$
BEGIN
IF
EXISTS
(
SELECT
1
FROM
information_schema
.
tables
WHERE
table_schema
=
'public'
AND
table_name
=
'users'
)
THEN
IF
NOT
EXISTS
(
SELECT
1
FROM
information_schema
.
columns
WHERE
table_schema
=
'public'
AND
table_name
=
'users'
AND
column_name
=
'allowed_groups'
)
THEN
IF
NOT
EXISTS
(
SELECT
1
FROM
schema_migrations
WHERE
filename
=
'014_drop_legacy_allowed_groups.sql'
)
THEN
ALTER
TABLE
users
ADD
COLUMN
IF
NOT
EXISTS
allowed_groups
BIGINT
[]
DEFAULT
NULL
;
END
IF
;
END
IF
;
END
IF
;
END
$$
;
backend/migrations/042_add_usage_cleanup_tasks.sql
0 → 100644
View file @
c8e2f614
-- 042_add_usage_cleanup_tasks.sql
-- 使用记录清理任务表
CREATE
TABLE
IF
NOT
EXISTS
usage_cleanup_tasks
(
id
BIGSERIAL
PRIMARY
KEY
,
status
VARCHAR
(
20
)
NOT
NULL
,
filters
JSONB
NOT
NULL
,
created_by
BIGINT
NOT
NULL
REFERENCES
users
(
id
)
ON
DELETE
RESTRICT
,
deleted_rows
BIGINT
NOT
NULL
DEFAULT
0
,
error_message
TEXT
,
started_at
TIMESTAMPTZ
,
finished_at
TIMESTAMPTZ
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
updated_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
CREATE
INDEX
IF
NOT
EXISTS
idx_usage_cleanup_tasks_status_created_at
ON
usage_cleanup_tasks
(
status
,
created_at
DESC
);
CREATE
INDEX
IF
NOT
EXISTS
idx_usage_cleanup_tasks_created_at
ON
usage_cleanup_tasks
(
created_at
DESC
);
backend/migrations/043_add_usage_cleanup_cancel_audit.sql
0 → 100644
View file @
c8e2f614
-- 043_add_usage_cleanup_cancel_audit.sql
-- usage_cleanup_tasks 取消任务审计字段
ALTER
TABLE
usage_cleanup_tasks
ADD
COLUMN
IF
NOT
EXISTS
canceled_by
BIGINT
REFERENCES
users
(
id
)
ON
DELETE
SET
NULL
,
ADD
COLUMN
IF
NOT
EXISTS
canceled_at
TIMESTAMPTZ
;
CREATE
INDEX
IF
NOT
EXISTS
idx_usage_cleanup_tasks_canceled_at
ON
usage_cleanup_tasks
(
canceled_at
DESC
);
config.yaml
View file @
c8e2f614
...
@@ -251,6 +251,27 @@ dashboard_aggregation:
...
@@ -251,6 +251,27 @@ dashboard_aggregation:
# 日聚合保留天数
# 日聚合保留天数
daily_days
:
730
daily_days
:
730
# =============================================================================
# Usage Cleanup Task Configuration
# 使用记录清理任务配置(重启生效)
# =============================================================================
usage_cleanup
:
# Enable cleanup task worker
# 启用清理任务执行器
enabled
:
true
# Max date range (days) per task
# 单次任务最大时间跨度(天)
max_range_days
:
31
# Batch delete size
# 单批删除数量
batch_size
:
5000
# Worker interval (seconds)
# 执行器轮询间隔(秒)
worker_interval_seconds
:
10
# Task execution timeout (seconds)
# 单次任务最大执行时长(秒)
task_timeout_seconds
:
1800
# =============================================================================
# =============================================================================
# Concurrency Wait Configuration
# Concurrency Wait Configuration
# 并发等待配置
# 并发等待配置
...
...
deploy/README.md
View file @
c8e2f614
...
@@ -401,3 +401,60 @@ sudo systemctl status redis
...
@@ -401,3 +401,60 @@ sudo systemctl status redis
2.
**Database connection failed**
: Check PostgreSQL is running and credentials are correct
2.
**Database connection failed**
: Check PostgreSQL is running and credentials are correct
3.
**Redis connection failed**
: Check Redis is running and password is correct
3.
**Redis connection failed**
: Check Redis is running and password is correct
4.
**Permission denied**
: Ensure proper file ownership for binary install
4.
**Permission denied**
: Ensure proper file ownership for binary install
---
## TLS Fingerprint Configuration
Sub2API supports TLS fingerprint simulation to make requests appear as if they come from the official Claude CLI (Node.js client).
> **💡 Tip:** Visit **[tls.sub2api.org](https://tls.sub2api.org/)** to get TLS fingerprint information for different devices and browsers.
### Default Behavior
-
Built-in
`claude_cli_v2`
profile simulates Node.js 20.x + OpenSSL 3.x
-
JA3 Hash:
`1a28e69016765d92e3b381168d68922c`
-
JA4:
`t13d5911h1_a33745022dd6_1f22a2ca17c4`
-
Profile selection:
`accountID % profileCount`
### Configuration
```
yaml
gateway
:
tls_fingerprint
:
enabled
:
true
# Global switch
profiles
:
# Simple profile (uses default cipher suites)
profile_1
:
name
:
"
Profile
1"
# Profile with custom cipher suites (use compact array format)
profile_2
:
name
:
"
Profile
2"
cipher_suites
:
[
4866
,
4867
,
4865
,
49199
,
49195
,
49200
,
49196
]
curves
:
[
29
,
23
,
24
]
point_formats
:
[
0
]
# Another custom profile
profile_3
:
name
:
"
Profile
3"
cipher_suites
:
[
4865
,
4866
,
4867
,
49199
,
49200
]
curves
:
[
29
,
23
,
24
,
25
]
```
### Profile Fields
| Field | Type | Description |
|-------|------|-------------|
|
`name`
| string | Display name (required) |
|
`cipher_suites`
| []uint16 | Cipher suites in decimal. Empty = default |
|
`curves`
| []uint16 | Elliptic curves in decimal. Empty = default |
|
`point_formats`
| []uint8 | EC point formats. Empty = default |
### Common Values Reference
**Cipher Suites (TLS 1.3):**
`4865`
(AES_128_GCM),
`4866`
(AES_256_GCM),
`4867`
(CHACHA20)
**Cipher Suites (TLS 1.2):**
`49195`
,
`49196`
,
`49199`
,
`49200`
(ECDHE variants)
**Curves:**
`29`
(X25519),
`23`
(P-256),
`24`
(P-384),
`25`
(P-521)
deploy/config.example.yaml
View file @
c8e2f614
...
@@ -210,6 +210,19 @@ gateway:
...
@@ -210,6 +210,19 @@ gateway:
outbox_backlog_rebuild_rows
:
10000
outbox_backlog_rebuild_rows
:
10000
# 全量重建周期(秒),0 表示禁用
# 全量重建周期(秒),0 表示禁用
full_rebuild_interval_seconds
:
300
full_rebuild_interval_seconds
:
300
# TLS fingerprint simulation / TLS 指纹伪装
# Default profile "claude_cli_v2" simulates Node.js 20.x
# 默认模板 "claude_cli_v2" 模拟 Node.js 20.x 指纹
tls_fingerprint
:
enabled
:
true
# profiles:
# profile_1:
# name: "Custom Profile 1"
# profile_2:
# name: "Custom Profile 2"
# cipher_suites: [4866, 4867, 4865, 49199, 49195, 49200, 49196]
# curves: [29, 23, 24]
# point_formats: [0]
# =============================================================================
# =============================================================================
# API Key Auth Cache Configuration
# API Key Auth Cache Configuration
...
@@ -292,6 +305,27 @@ dashboard_aggregation:
...
@@ -292,6 +305,27 @@ dashboard_aggregation:
# 日聚合保留天数
# 日聚合保留天数
daily_days
:
730
daily_days
:
730
# =============================================================================
# Usage Cleanup Task Configuration
# 使用记录清理任务配置(重启生效)
# =============================================================================
usage_cleanup
:
# Enable cleanup task worker
# 启用清理任务执行器
enabled
:
true
# Max date range (days) per task
# 单次任务最大时间跨度(天)
max_range_days
:
31
# Batch delete size
# 单批删除数量
batch_size
:
5000
# Worker interval (seconds)
# 执行器轮询间隔(秒)
worker_interval_seconds
:
10
# Task execution timeout (seconds)
# 单次任务最大执行时长(秒)
task_timeout_seconds
:
1800
# =============================================================================
# =============================================================================
# Concurrency Wait Configuration
# Concurrency Wait Configuration
# 并发等待配置
# 并发等待配置
...
...
frontend/src/api/admin/dashboard.ts
View file @
c8e2f614
...
@@ -50,6 +50,7 @@ export interface TrendParams {
...
@@ -50,6 +50,7 @@ export interface TrendParams {
account_id
?:
number
account_id
?:
number
group_id
?:
number
group_id
?:
number
stream
?:
boolean
stream
?:
boolean
billing_type
?:
number
|
null
}
}
export
interface
TrendResponse
{
export
interface
TrendResponse
{
...
@@ -78,6 +79,7 @@ export interface ModelStatsParams {
...
@@ -78,6 +79,7 @@ export interface ModelStatsParams {
account_id
?:
number
account_id
?:
number
group_id
?:
number
group_id
?:
number
stream
?:
boolean
stream
?:
boolean
billing_type
?:
number
|
null
}
}
export
interface
ModelStatsResponse
{
export
interface
ModelStatsResponse
{
...
...
frontend/src/api/admin/groups.ts
View file @
c8e2f614
...
@@ -5,7 +5,7 @@
...
@@ -5,7 +5,7 @@
import
{
apiClient
}
from
'
../client
'
import
{
apiClient
}
from
'
../client
'
import
type
{
import
type
{
Group
,
Admin
Group
,
GroupPlatform
,
GroupPlatform
,
CreateGroupRequest
,
CreateGroupRequest
,
UpdateGroupRequest
,
UpdateGroupRequest
,
...
@@ -31,8 +31,8 @@ export async function list(
...
@@ -31,8 +31,8 @@ export async function list(
options
?:
{
options
?:
{
signal
?:
AbortSignal
signal
?:
AbortSignal
}
}
):
Promise
<
PaginatedResponse
<
Group
>>
{
):
Promise
<
PaginatedResponse
<
Admin
Group
>>
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
Group
>>
(
'
/admin/groups
'
,
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
Admin
Group
>>
(
'
/admin/groups
'
,
{
params
:
{
params
:
{
page
,
page
,
page_size
:
pageSize
,
page_size
:
pageSize
,
...
@@ -48,8 +48,8 @@ export async function list(
...
@@ -48,8 +48,8 @@ export async function list(
* @param platform - Optional platform filter
* @param platform - Optional platform filter
* @returns List of all active groups
* @returns List of all active groups
*/
*/
export
async
function
getAll
(
platform
?:
GroupPlatform
):
Promise
<
Group
[]
>
{
export
async
function
getAll
(
platform
?:
GroupPlatform
):
Promise
<
Admin
Group
[]
>
{
const
{
data
}
=
await
apiClient
.
get
<
Group
[]
>
(
'
/admin/groups/all
'
,
{
const
{
data
}
=
await
apiClient
.
get
<
Admin
Group
[]
>
(
'
/admin/groups/all
'
,
{
params
:
platform
?
{
platform
}
:
undefined
params
:
platform
?
{
platform
}
:
undefined
})
})
return
data
return
data
...
@@ -60,7 +60,7 @@ export async function getAll(platform?: GroupPlatform): Promise<Group[]> {
...
@@ -60,7 +60,7 @@ export async function getAll(platform?: GroupPlatform): Promise<Group[]> {
* @param platform - Platform to filter by
* @param platform - Platform to filter by
* @returns List of groups for the specified platform
* @returns List of groups for the specified platform
*/
*/
export
async
function
getByPlatform
(
platform
:
GroupPlatform
):
Promise
<
Group
[]
>
{
export
async
function
getByPlatform
(
platform
:
GroupPlatform
):
Promise
<
Admin
Group
[]
>
{
return
getAll
(
platform
)
return
getAll
(
platform
)
}
}
...
@@ -69,8 +69,8 @@ export async function getByPlatform(platform: GroupPlatform): Promise<Group[]> {
...
@@ -69,8 +69,8 @@ export async function getByPlatform(platform: GroupPlatform): Promise<Group[]> {
* @param id - Group ID
* @param id - Group ID
* @returns Group details
* @returns Group details
*/
*/
export
async
function
getById
(
id
:
number
):
Promise
<
Group
>
{
export
async
function
getById
(
id
:
number
):
Promise
<
Admin
Group
>
{
const
{
data
}
=
await
apiClient
.
get
<
Group
>
(
`/admin/groups/
${
id
}
`
)
const
{
data
}
=
await
apiClient
.
get
<
Admin
Group
>
(
`/admin/groups/
${
id
}
`
)
return
data
return
data
}
}
...
@@ -79,8 +79,8 @@ export async function getById(id: number): Promise<Group> {
...
@@ -79,8 +79,8 @@ export async function getById(id: number): Promise<Group> {
* @param groupData - Group data
* @param groupData - Group data
* @returns Created group
* @returns Created group
*/
*/
export
async
function
create
(
groupData
:
CreateGroupRequest
):
Promise
<
Group
>
{
export
async
function
create
(
groupData
:
CreateGroupRequest
):
Promise
<
Admin
Group
>
{
const
{
data
}
=
await
apiClient
.
post
<
Group
>
(
'
/admin/groups
'
,
groupData
)
const
{
data
}
=
await
apiClient
.
post
<
Admin
Group
>
(
'
/admin/groups
'
,
groupData
)
return
data
return
data
}
}
...
@@ -90,8 +90,8 @@ export async function create(groupData: CreateGroupRequest): Promise<Group> {
...
@@ -90,8 +90,8 @@ export async function create(groupData: CreateGroupRequest): Promise<Group> {
* @param updates - Fields to update
* @param updates - Fields to update
* @returns Updated group
* @returns Updated group
*/
*/
export
async
function
update
(
id
:
number
,
updates
:
UpdateGroupRequest
):
Promise
<
Group
>
{
export
async
function
update
(
id
:
number
,
updates
:
UpdateGroupRequest
):
Promise
<
Admin
Group
>
{
const
{
data
}
=
await
apiClient
.
put
<
Group
>
(
`/admin/groups/
${
id
}
`
,
updates
)
const
{
data
}
=
await
apiClient
.
put
<
Admin
Group
>
(
`/admin/groups/
${
id
}
`
,
updates
)
return
data
return
data
}
}
...
@@ -111,7 +111,7 @@ export async function deleteGroup(id: number): Promise<{ message: string }> {
...
@@ -111,7 +111,7 @@ export async function deleteGroup(id: number): Promise<{ message: string }> {
* @param status - New status
* @param status - New status
* @returns Updated group
* @returns Updated group
*/
*/
export
async
function
toggleStatus
(
id
:
number
,
status
:
'
active
'
|
'
inactive
'
):
Promise
<
Group
>
{
export
async
function
toggleStatus
(
id
:
number
,
status
:
'
active
'
|
'
inactive
'
):
Promise
<
Admin
Group
>
{
return
update
(
id
,
{
status
})
return
update
(
id
,
{
status
})
}
}
...
...
frontend/src/api/admin/settings.ts
View file @
c8e2f614
...
@@ -23,6 +23,7 @@ export interface SystemSettings {
...
@@ -23,6 +23,7 @@ export interface SystemSettings {
contact_info
:
string
contact_info
:
string
doc_url
:
string
doc_url
:
string
home_content
:
string
home_content
:
string
hide_ccs_import_button
:
boolean
// SMTP settings
// SMTP settings
smtp_host
:
string
smtp_host
:
string
smtp_port
:
number
smtp_port
:
number
...
@@ -72,6 +73,7 @@ export interface UpdateSettingsRequest {
...
@@ -72,6 +73,7 @@ export interface UpdateSettingsRequest {
contact_info
?:
string
contact_info
?:
string
doc_url
?:
string
doc_url
?:
string
home_content
?:
string
home_content
?:
string
hide_ccs_import_button
?:
boolean
smtp_host
?:
string
smtp_host
?:
string
smtp_port
?:
number
smtp_port
?:
number
smtp_username
?:
string
smtp_username
?:
string
...
...
frontend/src/api/admin/usage.ts
View file @
c8e2f614
...
@@ -4,7 +4,7 @@
...
@@ -4,7 +4,7 @@
*/
*/
import
{
apiClient
}
from
'
../client
'
import
{
apiClient
}
from
'
../client
'
import
type
{
UsageLog
,
UsageQueryParams
,
PaginatedResponse
}
from
'
@/types
'
import
type
{
Admin
UsageLog
,
UsageQueryParams
,
PaginatedResponse
}
from
'
@/types
'
// ==================== Types ====================
// ==================== Types ====================
...
@@ -31,6 +31,46 @@ export interface SimpleApiKey {
...
@@ -31,6 +31,46 @@ export interface SimpleApiKey {
user_id
:
number
user_id
:
number
}
}
export
interface
UsageCleanupFilters
{
start_time
:
string
end_time
:
string
user_id
?:
number
api_key_id
?:
number
account_id
?:
number
group_id
?:
number
model
?:
string
|
null
stream
?:
boolean
|
null
billing_type
?:
number
|
null
}
export
interface
UsageCleanupTask
{
id
:
number
status
:
string
filters
:
UsageCleanupFilters
created_by
:
number
deleted_rows
:
number
error_message
?:
string
|
null
canceled_by
?:
number
|
null
canceled_at
?:
string
|
null
started_at
?:
string
|
null
finished_at
?:
string
|
null
created_at
:
string
updated_at
:
string
}
export
interface
CreateUsageCleanupTaskRequest
{
start_date
:
string
end_date
:
string
user_id
?:
number
api_key_id
?:
number
account_id
?:
number
group_id
?:
number
model
?:
string
|
null
stream
?:
boolean
|
null
billing_type
?:
number
|
null
timezone
?:
string
}
export
interface
AdminUsageQueryParams
extends
UsageQueryParams
{
export
interface
AdminUsageQueryParams
extends
UsageQueryParams
{
user_id
?:
number
user_id
?:
number
}
}
...
@@ -45,8 +85,8 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
...
@@ -45,8 +85,8 @@ export interface AdminUsageQueryParams extends UsageQueryParams {
export
async
function
list
(
export
async
function
list
(
params
:
AdminUsageQueryParams
,
params
:
AdminUsageQueryParams
,
options
?:
{
signal
?:
AbortSignal
}
options
?:
{
signal
?:
AbortSignal
}
):
Promise
<
PaginatedResponse
<
UsageLog
>>
{
):
Promise
<
PaginatedResponse
<
Admin
UsageLog
>>
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
UsageLog
>>
(
'
/admin/usage
'
,
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
Admin
UsageLog
>>
(
'
/admin/usage
'
,
{
params
,
params
,
signal
:
options
?.
signal
signal
:
options
?.
signal
})
})
...
@@ -108,11 +148,51 @@ export async function searchApiKeys(userId?: number, keyword?: string): Promise<
...
@@ -108,11 +148,51 @@ export async function searchApiKeys(userId?: number, keyword?: string): Promise<
return
data
return
data
}
}
/**
* List usage cleanup tasks (admin only)
* @param params - Query parameters for pagination
* @returns Paginated list of cleanup tasks
*/
export
async
function
listCleanupTasks
(
params
:
{
page
?:
number
;
page_size
?:
number
},
options
?:
{
signal
?:
AbortSignal
}
):
Promise
<
PaginatedResponse
<
UsageCleanupTask
>>
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
UsageCleanupTask
>>
(
'
/admin/usage/cleanup-tasks
'
,
{
params
,
signal
:
options
?.
signal
})
return
data
}
/**
* Create a usage cleanup task (admin only)
* @param payload - Cleanup task parameters
* @returns Created cleanup task
*/
export
async
function
createCleanupTask
(
payload
:
CreateUsageCleanupTaskRequest
):
Promise
<
UsageCleanupTask
>
{
const
{
data
}
=
await
apiClient
.
post
<
UsageCleanupTask
>
(
'
/admin/usage/cleanup-tasks
'
,
payload
)
return
data
}
/**
* Cancel a usage cleanup task (admin only)
* @param taskId - Task ID to cancel
*/
export
async
function
cancelCleanupTask
(
taskId
:
number
):
Promise
<
{
id
:
number
;
status
:
string
}
>
{
const
{
data
}
=
await
apiClient
.
post
<
{
id
:
number
;
status
:
string
}
>
(
`/admin/usage/cleanup-tasks/
${
taskId
}
/cancel`
)
return
data
}
export
const
adminUsageAPI
=
{
export
const
adminUsageAPI
=
{
list
,
list
,
getStats
,
getStats
,
searchUsers
,
searchUsers
,
searchApiKeys
searchApiKeys
,
listCleanupTasks
,
createCleanupTask
,
cancelCleanupTask
}
}
export
default
adminUsageAPI
export
default
adminUsageAPI
frontend/src/api/admin/users.ts
View file @
c8e2f614
...
@@ -4,7 +4,7 @@
...
@@ -4,7 +4,7 @@
*/
*/
import
{
apiClient
}
from
'
../client
'
import
{
apiClient
}
from
'
../client
'
import
type
{
User
,
UpdateUserRequest
,
PaginatedResponse
}
from
'
@/types
'
import
type
{
Admin
User
,
UpdateUserRequest
,
PaginatedResponse
}
from
'
@/types
'
/**
/**
* List all users with pagination
* List all users with pagination
...
@@ -26,7 +26,7 @@ export async function list(
...
@@ -26,7 +26,7 @@ export async function list(
options
?:
{
options
?:
{
signal
?:
AbortSignal
signal
?:
AbortSignal
}
}
):
Promise
<
PaginatedResponse
<
User
>>
{
):
Promise
<
PaginatedResponse
<
Admin
User
>>
{
// Build params with attribute filters in attr[id]=value format
// Build params with attribute filters in attr[id]=value format
const
params
:
Record
<
string
,
any
>
=
{
const
params
:
Record
<
string
,
any
>
=
{
page
,
page
,
...
@@ -44,8 +44,7 @@ export async function list(
...
@@ -44,8 +44,7 @@ export async function list(
}
}
}
}
}
}
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
AdminUser
>>
(
'
/admin/users
'
,
{
const
{
data
}
=
await
apiClient
.
get
<
PaginatedResponse
<
User
>>
(
'
/admin/users
'
,
{
params
,
params
,
signal
:
options
?.
signal
signal
:
options
?.
signal
})
})
...
@@ -57,8 +56,8 @@ export async function list(
...
@@ -57,8 +56,8 @@ export async function list(
* @param id - User ID
* @param id - User ID
* @returns User details
* @returns User details
*/
*/
export
async
function
getById
(
id
:
number
):
Promise
<
User
>
{
export
async
function
getById
(
id
:
number
):
Promise
<
Admin
User
>
{
const
{
data
}
=
await
apiClient
.
get
<
User
>
(
`/admin/users/
${
id
}
`
)
const
{
data
}
=
await
apiClient
.
get
<
Admin
User
>
(
`/admin/users/
${
id
}
`
)
return
data
return
data
}
}
...
@@ -73,8 +72,8 @@ export async function create(userData: {
...
@@ -73,8 +72,8 @@ export async function create(userData: {
balance
?:
number
balance
?:
number
concurrency
?:
number
concurrency
?:
number
allowed_groups
?:
number
[]
|
null
allowed_groups
?:
number
[]
|
null
}):
Promise
<
User
>
{
}):
Promise
<
Admin
User
>
{
const
{
data
}
=
await
apiClient
.
post
<
User
>
(
'
/admin/users
'
,
userData
)
const
{
data
}
=
await
apiClient
.
post
<
Admin
User
>
(
'
/admin/users
'
,
userData
)
return
data
return
data
}
}
...
@@ -84,8 +83,8 @@ export async function create(userData: {
...
@@ -84,8 +83,8 @@ export async function create(userData: {
* @param updates - Fields to update
* @param updates - Fields to update
* @returns Updated user
* @returns Updated user
*/
*/
export
async
function
update
(
id
:
number
,
updates
:
UpdateUserRequest
):
Promise
<
User
>
{
export
async
function
update
(
id
:
number
,
updates
:
UpdateUserRequest
):
Promise
<
Admin
User
>
{
const
{
data
}
=
await
apiClient
.
put
<
User
>
(
`/admin/users/
${
id
}
`
,
updates
)
const
{
data
}
=
await
apiClient
.
put
<
Admin
User
>
(
`/admin/users/
${
id
}
`
,
updates
)
return
data
return
data
}
}
...
@@ -112,8 +111,8 @@ export async function updateBalance(
...
@@ -112,8 +111,8 @@ export async function updateBalance(
balance
:
number
,
balance
:
number
,
operation
:
'
set
'
|
'
add
'
|
'
subtract
'
=
'
set
'
,
operation
:
'
set
'
|
'
add
'
|
'
subtract
'
=
'
set
'
,
notes
?:
string
notes
?:
string
):
Promise
<
User
>
{
):
Promise
<
Admin
User
>
{
const
{
data
}
=
await
apiClient
.
post
<
User
>
(
`/admin/users/
${
id
}
/balance`
,
{
const
{
data
}
=
await
apiClient
.
post
<
Admin
User
>
(
`/admin/users/
${
id
}
/balance`
,
{
balance
,
balance
,
operation
,
operation
,
notes
:
notes
||
''
notes
:
notes
||
''
...
@@ -127,7 +126,7 @@ export async function updateBalance(
...
@@ -127,7 +126,7 @@ export async function updateBalance(
* @param concurrency - New concurrency limit
* @param concurrency - New concurrency limit
* @returns Updated user
* @returns Updated user
*/
*/
export
async
function
updateConcurrency
(
id
:
number
,
concurrency
:
number
):
Promise
<
User
>
{
export
async
function
updateConcurrency
(
id
:
number
,
concurrency
:
number
):
Promise
<
Admin
User
>
{
return
update
(
id
,
{
concurrency
})
return
update
(
id
,
{
concurrency
})
}
}
...
@@ -137,7 +136,7 @@ export async function updateConcurrency(id: number, concurrency: number): Promis
...
@@ -137,7 +136,7 @@ export async function updateConcurrency(id: number, concurrency: number): Promis
* @param status - New status
* @param status - New status
* @returns Updated user
* @returns Updated user
*/
*/
export
async
function
toggleStatus
(
id
:
number
,
status
:
'
active
'
|
'
disabled
'
):
Promise
<
User
>
{
export
async
function
toggleStatus
(
id
:
number
,
status
:
'
active
'
|
'
disabled
'
):
Promise
<
Admin
User
>
{
return
update
(
id
,
{
status
})
return
update
(
id
,
{
status
})
}
}
...
...
frontend/src/components/account/AccountTestModal.vue
View file @
c8e2f614
...
@@ -292,8 +292,11 @@ const loadAvailableModels = async () => {
...
@@ -292,8 +292,11 @@ const loadAvailableModels = async () => {
if
(
availableModels
.
value
.
length
>
0
)
{
if
(
availableModels
.
value
.
length
>
0
)
{
if
(
props
.
account
.
platform
===
'
gemini
'
)
{
if
(
props
.
account
.
platform
===
'
gemini
'
)
{
const
preferred
=
const
preferred
=
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.0-flash
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-flash
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-pro
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-2.5-pro
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-pro
'
)
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-flash-preview
'
)
||
availableModels
.
value
.
find
((
m
)
=>
m
.
id
===
'
gemini-3-pro-preview
'
)
selectedModelId
.
value
=
preferred
?.
id
||
availableModels
.
value
[
0
].
id
selectedModelId
.
value
=
preferred
?.
id
||
availableModels
.
value
[
0
].
id
}
else
{
}
else
{
// Try to select Sonnet as default, otherwise use first model
// Try to select Sonnet as default, otherwise use first model
...
...
frontend/src/components/account/BulkEditAccountModal.vue
View file @
c8e2f614
...
@@ -648,7 +648,7 @@ import { ref, watch, computed } from 'vue'
...
@@ -648,7 +648,7 @@ import { ref, watch, computed } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
Proxy
,
Group
}
from
'
@/types
'
import
type
{
Proxy
,
Admin
Group
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
...
@@ -659,7 +659,7 @@ interface Props {
...
@@ -659,7 +659,7 @@ interface Props {
show
:
boolean
show
:
boolean
accountIds
:
number
[]
accountIds
:
number
[]
proxies
:
Proxy
[]
proxies
:
Proxy
[]
groups
:
Group
[]
groups
:
Admin
Group
[]
}
}
const
props
=
defineProps
<
Props
>
()
const
props
=
defineProps
<
Props
>
()
...
...
frontend/src/components/account/CreateAccountModal.vue
View file @
c8e2f614
...
@@ -1191,6 +1191,190 @@
...
@@ -1191,6 +1191,190 @@
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<!--
Quota
Control
Section
(
Anthropic
OAuth
/
SetupToken
only
)
-->
<
div
v
-
if
=
"
form.platform === 'anthropic' && accountCategory === 'oauth-based'
"
class
=
"
border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4
"
>
<
div
class
=
"
mb-3
"
>
<
h3
class
=
"
input-label mb-0 text-base font-semibold
"
>
{{
t
(
'
admin.accounts.quotaControl.title
'
)
}}
<
/h3
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.hint
'
)
}}
<
/p
>
<
/div
>
<!--
Window
Cost
Limit
-->
<
div
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-600
"
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
windowCostEnabled = !windowCostEnabled
"
:
class
=
"
[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
windowCostEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
windowCostEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
div
v
-
if
=
"
windowCostEnabled
"
class
=
"
grid grid-cols-2 gap-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.limit
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
span
class
=
"
absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400
"
>
$
<
/span
>
<
input
v
-
model
.
number
=
"
windowCostLimit
"
type
=
"
number
"
min
=
"
0
"
step
=
"
1
"
class
=
"
input pl-7
"
:
placeholder
=
"
t('admin.accounts.quotaControl.windowCost.limitPlaceholder')
"
/>
<
/div
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.limitHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.stickyReserve
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
span
class
=
"
absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400
"
>
$
<
/span
>
<
input
v
-
model
.
number
=
"
windowCostStickyReserve
"
type
=
"
number
"
min
=
"
0
"
step
=
"
1
"
class
=
"
input pl-7
"
:
placeholder
=
"
t('admin.accounts.quotaControl.windowCost.stickyReservePlaceholder')
"
/>
<
/div
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.windowCost.stickyReserveHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<!--
Session
Limit
-->
<
div
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-600
"
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
sessionLimitEnabled = !sessionLimitEnabled
"
:
class
=
"
[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
sessionLimitEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
sessionLimitEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
div
v
-
if
=
"
sessionLimitEnabled
"
class
=
"
grid grid-cols-2 gap-4
"
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.maxSessions
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
maxSessions
"
type
=
"
number
"
min
=
"
1
"
step
=
"
1
"
class
=
"
input
"
:
placeholder
=
"
t('admin.accounts.quotaControl.sessionLimit.maxSessionsPlaceholder')
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.maxSessionsHint
'
)
}}
<
/p
>
<
/div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.idleTimeout
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
input
v
-
model
.
number
=
"
sessionIdleTimeout
"
type
=
"
number
"
min
=
"
1
"
step
=
"
1
"
class
=
"
input pr-12
"
:
placeholder
=
"
t('admin.accounts.quotaControl.sessionLimit.idleTimeoutPlaceholder')
"
/>
<
span
class
=
"
absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
common.minutes
'
)
}}
<
/span
>
<
/div
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionLimit.idleTimeoutHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<!--
TLS
Fingerprint
-->
<
div
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.quotaControl.tlsFingerprint.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.tlsFingerprint.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
tlsFingerprintEnabled = !tlsFingerprintEnabled
"
:
class
=
"
[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
tlsFingerprintEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
tlsFingerprintEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
/div
>
<!--
Session
ID
Masking
-->
<
div
class
=
"
rounded-lg border border-gray-200 p-4 dark:border-dark-600
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
div
>
<
label
class
=
"
input-label mb-0
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionIdMasking.label
'
)
}}
<
/label
>
<
p
class
=
"
mt-1 text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.quotaControl.sessionIdMasking.hint
'
)
}}
<
/p
>
<
/div
>
<
button
type
=
"
button
"
@
click
=
"
sessionIdMaskingEnabled = !sessionIdMaskingEnabled
"
:
class
=
"
[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
sessionIdMaskingEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]
"
>
<
span
:
class
=
"
[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
sessionIdMaskingEnabled ? 'translate-x-5' : 'translate-x-0'
]
"
/>
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.proxy
'
)
}}
<
/label
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.proxy
'
)
}}
<
/label
>
<
ProxySelector
v
-
model
=
"
form.proxy_id
"
:
proxies
=
"
proxies
"
/>
<
ProxySelector
v
-
model
=
"
form.proxy_id
"
:
proxies
=
"
proxies
"
/>
...
@@ -1214,7 +1398,7 @@
...
@@ -1214,7 +1398,7 @@
<
/div
>
<
/div
>
<
div
>
<
div
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.billingRateMultiplier
'
)
}}
<
/label
>
<
label
class
=
"
input-label
"
>
{{
t
(
'
admin.accounts.billingRateMultiplier
'
)
}}
<
/label
>
<
input
v
-
model
.
number
=
"
form.rate_multiplier
"
type
=
"
number
"
min
=
"
0
"
step
=
"
0.01
"
class
=
"
input
"
/>
<
input
v
-
model
.
number
=
"
form.rate_multiplier
"
type
=
"
number
"
min
=
"
0
"
step
=
"
0.
0
01
"
class
=
"
input
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.billingRateMultiplierHint
'
)
}}
<
/p
>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.billingRateMultiplierHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
...
@@ -1632,7 +1816,7 @@ import {
...
@@ -1632,7 +1816,7 @@ import {
import
{
useOpenAIOAuth
}
from
'
@/composables/useOpenAIOAuth
'
import
{
useOpenAIOAuth
}
from
'
@/composables/useOpenAIOAuth
'
import
{
useGeminiOAuth
}
from
'
@/composables/useGeminiOAuth
'
import
{
useGeminiOAuth
}
from
'
@/composables/useGeminiOAuth
'
import
{
useAntigravityOAuth
}
from
'
@/composables/useAntigravityOAuth
'
import
{
useAntigravityOAuth
}
from
'
@/composables/useAntigravityOAuth
'
import
type
{
Proxy
,
Group
,
AccountPlatform
,
AccountType
}
from
'
@/types
'
import
type
{
Proxy
,
Admin
Group
,
AccountPlatform
,
AccountType
}
from
'
@/types
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
import
ProxySelector
from
'
@/components/common/ProxySelector.vue
'
...
@@ -1678,7 +1862,7 @@ const apiKeyHint = computed(() => {
...
@@ -1678,7 +1862,7 @@ const apiKeyHint = computed(() => {
interface
Props
{
interface
Props
{
show
:
boolean
show
:
boolean
proxies
:
Proxy
[]
proxies
:
Proxy
[]
groups
:
Group
[]
groups
:
Admin
Group
[]
}
}
const
props
=
defineProps
<
Props
>
()
const
props
=
defineProps
<
Props
>
()
...
@@ -1763,6 +1947,16 @@ const geminiAIStudioOAuthEnabled = ref(false)
...
@@ -1763,6 +1947,16 @@ const geminiAIStudioOAuthEnabled = ref(false)
const
showAdvancedOAuth
=
ref
(
false
)
const
showAdvancedOAuth
=
ref
(
false
)
const
showGeminiHelpDialog
=
ref
(
false
)
const
showGeminiHelpDialog
=
ref
(
false
)
// Quota control state (Anthropic OAuth/SetupToken only)
const
windowCostEnabled
=
ref
(
false
)
const
windowCostLimit
=
ref
<
number
|
null
>
(
null
)
const
windowCostStickyReserve
=
ref
<
number
|
null
>
(
null
)
const
sessionLimitEnabled
=
ref
(
false
)
const
maxSessions
=
ref
<
number
|
null
>
(
null
)
const
sessionIdleTimeout
=
ref
<
number
|
null
>
(
null
)
const
tlsFingerprintEnabled
=
ref
(
false
)
const
sessionIdMaskingEnabled
=
ref
(
false
)
// Gemini tier selection (used as fallback when auto-detection is unavailable/fails)
// Gemini tier selection (used as fallback when auto-detection is unavailable/fails)
const
geminiTierGoogleOne
=
ref
<
'
google_one_free
'
|
'
google_ai_pro
'
|
'
google_ai_ultra
'
>
(
'
google_one_free
'
)
const
geminiTierGoogleOne
=
ref
<
'
google_one_free
'
|
'
google_ai_pro
'
|
'
google_ai_ultra
'
>
(
'
google_one_free
'
)
const
geminiTierGcp
=
ref
<
'
gcp_standard
'
|
'
gcp_enterprise
'
>
(
'
gcp_standard
'
)
const
geminiTierGcp
=
ref
<
'
gcp_standard
'
|
'
gcp_enterprise
'
>
(
'
gcp_standard
'
)
...
@@ -2140,6 +2334,15 @@ const resetForm = () => {
...
@@ -2140,6 +2334,15 @@ const resetForm = () => {
customErrorCodeInput
.
value
=
null
customErrorCodeInput
.
value
=
null
interceptWarmupRequests
.
value
=
false
interceptWarmupRequests
.
value
=
false
autoPauseOnExpired
.
value
=
true
autoPauseOnExpired
.
value
=
true
// Reset quota control state
windowCostEnabled
.
value
=
false
windowCostLimit
.
value
=
null
windowCostStickyReserve
.
value
=
null
sessionLimitEnabled
.
value
=
false
maxSessions
.
value
=
null
sessionIdleTimeout
.
value
=
null
tlsFingerprintEnabled
.
value
=
false
sessionIdMaskingEnabled
.
value
=
false
tempUnschedEnabled
.
value
=
false
tempUnschedEnabled
.
value
=
false
tempUnschedRules
.
value
=
[]
tempUnschedRules
.
value
=
[]
geminiOAuthType
.
value
=
'
code_assist
'
geminiOAuthType
.
value
=
'
code_assist
'
...
@@ -2407,7 +2610,32 @@ const handleAnthropicExchange = async (authCode: string) => {
...
@@ -2407,7 +2610,32 @@ const handleAnthropicExchange = async (authCode: string) => {
...
proxyConfig
...
proxyConfig
}
)
}
)
const
extra
=
oauth
.
buildExtraInfo
(
tokenInfo
)
// Build extra with quota control settings
const
baseExtra
=
oauth
.
buildExtraInfo
(
tokenInfo
)
||
{
}
const
extra
:
Record
<
string
,
unknown
>
=
{
...
baseExtra
}
// Add window cost limit settings
if
(
windowCostEnabled
.
value
&&
windowCostLimit
.
value
!=
null
&&
windowCostLimit
.
value
>
0
)
{
extra
.
window_cost_limit
=
windowCostLimit
.
value
extra
.
window_cost_sticky_reserve
=
windowCostStickyReserve
.
value
??
10
}
// Add session limit settings
if
(
sessionLimitEnabled
.
value
&&
maxSessions
.
value
!=
null
&&
maxSessions
.
value
>
0
)
{
extra
.
max_sessions
=
maxSessions
.
value
extra
.
session_idle_timeout_minutes
=
sessionIdleTimeout
.
value
??
5
}
// Add TLS fingerprint settings
if
(
tlsFingerprintEnabled
.
value
)
{
extra
.
enable_tls_fingerprint
=
true
}
// Add session ID masking settings
if
(
sessionIdMaskingEnabled
.
value
)
{
extra
.
session_id_masking_enabled
=
true
}
const
credentials
=
{
const
credentials
=
{
...
tokenInfo
,
...
tokenInfo
,
...(
interceptWarmupRequests
.
value
?
{
intercept_warmup_requests
:
true
}
:
{
}
)
...(
interceptWarmupRequests
.
value
?
{
intercept_warmup_requests
:
true
}
:
{
}
)
...
@@ -2475,7 +2703,32 @@ const handleCookieAuth = async (sessionKey: string) => {
...
@@ -2475,7 +2703,32 @@ const handleCookieAuth = async (sessionKey: string) => {
...
proxyConfig
...
proxyConfig
}
)
}
)
const
extra
=
oauth
.
buildExtraInfo
(
tokenInfo
)
// Build extra with quota control settings
const
baseExtra
=
oauth
.
buildExtraInfo
(
tokenInfo
)
||
{
}
const
extra
:
Record
<
string
,
unknown
>
=
{
...
baseExtra
}
// Add window cost limit settings
if
(
windowCostEnabled
.
value
&&
windowCostLimit
.
value
!=
null
&&
windowCostLimit
.
value
>
0
)
{
extra
.
window_cost_limit
=
windowCostLimit
.
value
extra
.
window_cost_sticky_reserve
=
windowCostStickyReserve
.
value
??
10
}
// Add session limit settings
if
(
sessionLimitEnabled
.
value
&&
maxSessions
.
value
!=
null
&&
maxSessions
.
value
>
0
)
{
extra
.
max_sessions
=
maxSessions
.
value
extra
.
session_idle_timeout_minutes
=
sessionIdleTimeout
.
value
??
5
}
// Add TLS fingerprint settings
if
(
tlsFingerprintEnabled
.
value
)
{
extra
.
enable_tls_fingerprint
=
true
}
// Add session ID masking settings
if
(
sessionIdMaskingEnabled
.
value
)
{
extra
.
session_id_masking_enabled
=
true
}
const
accountName
=
keys
.
length
>
1
?
`${form.name
}
#${i + 1
}
`
:
form
.
name
const
accountName
=
keys
.
length
>
1
?
`${form.name
}
#${i + 1
}
`
:
form
.
name
// Merge interceptWarmupRequests into credentials
// Merge interceptWarmupRequests into credentials
...
...
Prev
1
…
3
4
5
6
7
8
9
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment