feat(admin): 添加临时不可调度功能
当账号触发特定错误码和关键词匹配时,自动临时禁用调度: 后端: - 新增 TempUnschedCache Redis 缓存层 - RateLimitService 支持规则匹配和状态管理 - 添加 GET/DELETE /accounts/:id/temp-unschedulable API - 数据库迁移添加 temp_unschedulable_until/reason 字段 前端: - 账号状态指示器显示临时不可调度状态 - 新增 TempUnschedStatusModal 详情弹窗 - 创建/编辑账号时支持配置规则和预设模板 - 完整的中英文国际化支持
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
@@ -69,42 +71,45 @@ func NewAccountHandler(
|
||||
|
||||
// CreateAccountRequest represents create account request
|
||||
type CreateAccountRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
Type string `json:"type" binding:"required,oneof=oauth setup-token apikey"`
|
||||
Credentials map[string]any `json:"credentials" binding:"required"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
Priority int `json:"priority"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
Type string `json:"type" binding:"required,oneof=oauth setup-token apikey"`
|
||||
Credentials map[string]any `json:"credentials" binding:"required"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
Priority int `json:"priority"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
||||
}
|
||||
|
||||
// UpdateAccountRequest represents update account request
|
||||
// 使用指针类型来区分"未提供"和"设置为0"
|
||||
type UpdateAccountRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type" binding:"omitempty,oneof=oauth setup-token apikey"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
Priority *int `json:"priority"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
GroupIDs *[]int64 `json:"group_ids"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type" binding:"omitempty,oneof=oauth setup-token apikey"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
Priority *int `json:"priority"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
GroupIDs *[]int64 `json:"group_ids"`
|
||||
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
||||
}
|
||||
|
||||
// BulkUpdateAccountsRequest represents the payload for bulk editing accounts
|
||||
type BulkUpdateAccountsRequest struct {
|
||||
AccountIDs []int64 `json:"account_ids" binding:"required,min=1"`
|
||||
Name string `json:"name"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
Priority *int `json:"priority"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive error"`
|
||||
GroupIDs *[]int64 `json:"group_ids"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
AccountIDs []int64 `json:"account_ids" binding:"required,min=1"`
|
||||
Name string `json:"name"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
Priority *int `json:"priority"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive error"`
|
||||
GroupIDs *[]int64 `json:"group_ids"`
|
||||
Credentials map[string]any `json:"credentials"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
||||
}
|
||||
|
||||
// AccountWithConcurrency extends Account with real-time concurrency info
|
||||
@@ -179,18 +184,40 @@ func (h *AccountHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 确定是否跳过混合渠道检查
|
||||
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
||||
|
||||
account, err := h.adminService.CreateAccount(c.Request.Context(), &service.CreateAccountInput{
|
||||
Name: req.Name,
|
||||
Platform: req.Platform,
|
||||
Type: req.Type,
|
||||
Credentials: req.Credentials,
|
||||
Extra: req.Extra,
|
||||
ProxyID: req.ProxyID,
|
||||
Concurrency: req.Concurrency,
|
||||
Priority: req.Priority,
|
||||
GroupIDs: req.GroupIDs,
|
||||
Name: req.Name,
|
||||
Platform: req.Platform,
|
||||
Type: req.Type,
|
||||
Credentials: req.Credentials,
|
||||
Extra: req.Extra,
|
||||
ProxyID: req.ProxyID,
|
||||
Concurrency: req.Concurrency,
|
||||
Priority: req.Priority,
|
||||
GroupIDs: req.GroupIDs,
|
||||
SkipMixedChannelCheck: skipCheck,
|
||||
})
|
||||
if err != nil {
|
||||
// 检查是否为混合渠道错误
|
||||
var mixedErr *service.MixedChannelError
|
||||
if errors.As(err, &mixedErr) {
|
||||
// 返回特殊错误码要求确认
|
||||
c.JSON(409, gin.H{
|
||||
"error": "mixed_channel_warning",
|
||||
"message": mixedErr.Error(),
|
||||
"details": gin.H{
|
||||
"group_id": mixedErr.GroupID,
|
||||
"group_name": mixedErr.GroupName,
|
||||
"current_platform": mixedErr.CurrentPlatform,
|
||||
"other_platform": mixedErr.OtherPlatform,
|
||||
},
|
||||
"require_confirmation": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
@@ -213,18 +240,40 @@ func (h *AccountHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 确定是否跳过混合渠道检查
|
||||
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
||||
|
||||
account, err := h.adminService.UpdateAccount(c.Request.Context(), accountID, &service.UpdateAccountInput{
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Credentials: req.Credentials,
|
||||
Extra: req.Extra,
|
||||
ProxyID: req.ProxyID,
|
||||
Concurrency: req.Concurrency, // 指针类型,nil 表示未提供
|
||||
Priority: req.Priority, // 指针类型,nil 表示未提供
|
||||
Status: req.Status,
|
||||
GroupIDs: req.GroupIDs,
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Credentials: req.Credentials,
|
||||
Extra: req.Extra,
|
||||
ProxyID: req.ProxyID,
|
||||
Concurrency: req.Concurrency, // 指针类型,nil 表示未提供
|
||||
Priority: req.Priority, // 指针类型,nil 表示未提供
|
||||
Status: req.Status,
|
||||
GroupIDs: req.GroupIDs,
|
||||
SkipMixedChannelCheck: skipCheck,
|
||||
})
|
||||
if err != nil {
|
||||
// 检查是否为混合渠道错误
|
||||
var mixedErr *service.MixedChannelError
|
||||
if errors.As(err, &mixedErr) {
|
||||
// 返回特殊错误码要求确认
|
||||
c.JSON(409, gin.H{
|
||||
"error": "mixed_channel_warning",
|
||||
"message": mixedErr.Error(),
|
||||
"details": gin.H{
|
||||
"group_id": mixedErr.GroupID,
|
||||
"group_name": mixedErr.GroupName,
|
||||
"current_platform": mixedErr.CurrentPlatform,
|
||||
"other_platform": mixedErr.OtherPlatform,
|
||||
},
|
||||
"require_confirmation": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
@@ -568,6 +617,9 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 确定是否跳过混合渠道检查
|
||||
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
||||
|
||||
hasUpdates := req.Name != "" ||
|
||||
req.ProxyID != nil ||
|
||||
req.Concurrency != nil ||
|
||||
@@ -583,15 +635,16 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
|
||||
}
|
||||
|
||||
result, err := h.adminService.BulkUpdateAccounts(c.Request.Context(), &service.BulkUpdateAccountsInput{
|
||||
AccountIDs: req.AccountIDs,
|
||||
Name: req.Name,
|
||||
ProxyID: req.ProxyID,
|
||||
Concurrency: req.Concurrency,
|
||||
Priority: req.Priority,
|
||||
Status: req.Status,
|
||||
GroupIDs: req.GroupIDs,
|
||||
Credentials: req.Credentials,
|
||||
Extra: req.Extra,
|
||||
AccountIDs: req.AccountIDs,
|
||||
Name: req.Name,
|
||||
ProxyID: req.ProxyID,
|
||||
Concurrency: req.Concurrency,
|
||||
Priority: req.Priority,
|
||||
Status: req.Status,
|
||||
GroupIDs: req.GroupIDs,
|
||||
Credentials: req.Credentials,
|
||||
Extra: req.Extra,
|
||||
SkipMixedChannelCheck: skipCheck,
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -781,6 +834,49 @@ func (h *AccountHandler) ClearRateLimit(c *gin.Context) {
|
||||
response.Success(c, gin.H{"message": "Rate limit cleared successfully"})
|
||||
}
|
||||
|
||||
// GetTempUnschedulable handles getting temporary unschedulable status
|
||||
// GET /api/v1/admin/accounts/:id/temp-unschedulable
|
||||
func (h *AccountHandler) GetTempUnschedulable(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
|
||||
state, err := h.rateLimitService.GetTempUnschedStatus(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if state == nil || state.UntilUnix <= time.Now().Unix() {
|
||||
response.Success(c, gin.H{"active": false})
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"active": true,
|
||||
"state": state,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearTempUnschedulable handles clearing temporary unschedulable status
|
||||
// DELETE /api/v1/admin/accounts/:id/temp-unschedulable
|
||||
func (h *AccountHandler) ClearTempUnschedulable(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.rateLimitService.ClearTempUnschedulable(c.Request.Context(), accountID); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"message": "Temp unschedulable cleared successfully"})
|
||||
}
|
||||
|
||||
// GetTodayStats handles getting account today statistics
|
||||
// GET /api/v1/admin/accounts/:id/today-stats
|
||||
func (h *AccountHandler) GetTodayStats(c *gin.Context) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package dto provides mapping utilities for converting between service layer and HTTP handler DTOs.
|
||||
package dto
|
||||
|
||||
import "github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -27,11 +26,11 @@ func UserFromService(u *service.User) *User {
|
||||
return nil
|
||||
}
|
||||
out := UserFromServiceShallow(u)
|
||||
if len(u.APIKeys) > 0 {
|
||||
out.APIKeys = make([]APIKey, 0, len(u.APIKeys))
|
||||
for i := range u.APIKeys {
|
||||
k := u.APIKeys[i]
|
||||
out.APIKeys = append(out.APIKeys, *APIKeyFromService(&k))
|
||||
if len(u.ApiKeys) > 0 {
|
||||
out.ApiKeys = make([]ApiKey, 0, len(u.ApiKeys))
|
||||
for i := range u.ApiKeys {
|
||||
k := u.ApiKeys[i]
|
||||
out.ApiKeys = append(out.ApiKeys, *ApiKeyFromService(&k))
|
||||
}
|
||||
}
|
||||
if len(u.Subscriptions) > 0 {
|
||||
@@ -44,11 +43,11 @@ func UserFromService(u *service.User) *User {
|
||||
return out
|
||||
}
|
||||
|
||||
func APIKeyFromService(k *service.APIKey) *APIKey {
|
||||
func ApiKeyFromService(k *service.ApiKey) *ApiKey {
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
return &APIKey{
|
||||
return &ApiKey{
|
||||
ID: k.ID,
|
||||
UserID: k.UserID,
|
||||
Key: k.Key,
|
||||
@@ -104,28 +103,30 @@ func AccountFromServiceShallow(a *service.Account) *Account {
|
||||
return nil
|
||||
}
|
||||
return &Account{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
Platform: a.Platform,
|
||||
Type: a.Type,
|
||||
Credentials: a.Credentials,
|
||||
Extra: a.Extra,
|
||||
ProxyID: a.ProxyID,
|
||||
Concurrency: a.Concurrency,
|
||||
Priority: a.Priority,
|
||||
Status: a.Status,
|
||||
ErrorMessage: a.ErrorMessage,
|
||||
LastUsedAt: a.LastUsedAt,
|
||||
CreatedAt: a.CreatedAt,
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
Schedulable: a.Schedulable,
|
||||
RateLimitedAt: a.RateLimitedAt,
|
||||
RateLimitResetAt: a.RateLimitResetAt,
|
||||
OverloadUntil: a.OverloadUntil,
|
||||
SessionWindowStart: a.SessionWindowStart,
|
||||
SessionWindowEnd: a.SessionWindowEnd,
|
||||
SessionWindowStatus: a.SessionWindowStatus,
|
||||
GroupIDs: a.GroupIDs,
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
Platform: a.Platform,
|
||||
Type: a.Type,
|
||||
Credentials: a.Credentials,
|
||||
Extra: a.Extra,
|
||||
ProxyID: a.ProxyID,
|
||||
Concurrency: a.Concurrency,
|
||||
Priority: a.Priority,
|
||||
Status: a.Status,
|
||||
ErrorMessage: a.ErrorMessage,
|
||||
LastUsedAt: a.LastUsedAt,
|
||||
CreatedAt: a.CreatedAt,
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
Schedulable: a.Schedulable,
|
||||
RateLimitedAt: a.RateLimitedAt,
|
||||
RateLimitResetAt: a.RateLimitResetAt,
|
||||
OverloadUntil: a.OverloadUntil,
|
||||
TempUnschedulableUntil: a.TempUnschedulableUntil,
|
||||
TempUnschedulableReason: a.TempUnschedulableReason,
|
||||
SessionWindowStart: a.SessionWindowStart,
|
||||
SessionWindowEnd: a.SessionWindowEnd,
|
||||
SessionWindowStatus: a.SessionWindowStatus,
|
||||
GroupIDs: a.GroupIDs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +222,7 @@ func UsageLogFromService(l *service.UsageLog) *UsageLog {
|
||||
return &UsageLog{
|
||||
ID: l.ID,
|
||||
UserID: l.UserID,
|
||||
APIKeyID: l.APIKeyID,
|
||||
ApiKeyID: l.ApiKeyID,
|
||||
AccountID: l.AccountID,
|
||||
RequestID: l.RequestID,
|
||||
Model: l.Model,
|
||||
@@ -246,7 +247,7 @@ func UsageLogFromService(l *service.UsageLog) *UsageLog {
|
||||
FirstTokenMs: l.FirstTokenMs,
|
||||
CreatedAt: l.CreatedAt,
|
||||
User: UserFromServiceShallow(l.User),
|
||||
APIKey: APIKeyFromService(l.APIKey),
|
||||
ApiKey: ApiKeyFromService(l.ApiKey),
|
||||
Account: AccountFromService(l.Account),
|
||||
Group: GroupFromServiceShallow(l.Group),
|
||||
Subscription: UserSubscriptionFromService(l.Subscription),
|
||||
|
||||
@@ -15,11 +15,11 @@ type User struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
APIKeys []APIKey `json:"api_keys,omitempty"`
|
||||
ApiKeys []ApiKey `json:"api_keys,omitempty"`
|
||||
Subscriptions []UserSubscription `json:"subscriptions,omitempty"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
type ApiKey struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Key string `json:"key"`
|
||||
@@ -76,6 +76,9 @@ type Account struct {
|
||||
RateLimitResetAt *time.Time `json:"rate_limit_reset_at"`
|
||||
OverloadUntil *time.Time `json:"overload_until"`
|
||||
|
||||
TempUnschedulableUntil *time.Time `json:"temp_unschedulable_until"`
|
||||
TempUnschedulableReason string `json:"temp_unschedulable_reason"`
|
||||
|
||||
SessionWindowStart *time.Time `json:"session_window_start"`
|
||||
SessionWindowEnd *time.Time `json:"session_window_end"`
|
||||
SessionWindowStatus string `json:"session_window_status"`
|
||||
@@ -136,7 +139,7 @@ type RedeemCode struct {
|
||||
type UsageLog struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
APIKeyID int64 `json:"api_key_id"`
|
||||
ApiKeyID int64 `json:"api_key_id"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
RequestID string `json:"request_id"`
|
||||
Model string `json:"model"`
|
||||
@@ -168,7 +171,7 @@ type UsageLog struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
User *User `json:"user,omitempty"`
|
||||
APIKey *APIKey `json:"api_key,omitempty"`
|
||||
ApiKey *ApiKey `json:"api_key,omitempty"`
|
||||
Account *Account `json:"account,omitempty"`
|
||||
Group *Group `json:"group,omitempty"`
|
||||
Subscription *UserSubscription `json:"subscription,omitempty"`
|
||||
|
||||
@@ -43,6 +43,11 @@ type accountRepository struct {
|
||||
sql sqlExecutor // 原生 SQL 执行接口
|
||||
}
|
||||
|
||||
type tempUnschedSnapshot struct {
|
||||
until *time.Time
|
||||
reason string
|
||||
}
|
||||
|
||||
// NewAccountRepository 创建账户仓储实例。
|
||||
// 这是对外暴露的构造函数,返回接口类型以便于依赖注入。
|
||||
func NewAccountRepository(client *dbent.Client, sqlDB *sql.DB) service.AccountRepository {
|
||||
@@ -165,6 +170,11 @@ func (r *accountRepository) GetByIDs(ctx context.Context, ids []int64) ([]*servi
|
||||
accountIDs = append(accountIDs, acc.ID)
|
||||
}
|
||||
|
||||
tempUnschedMap, err := r.loadTempUnschedStates(ctx, accountIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groupsByAccount, groupIDsByAccount, accountGroupsByAccount, err := r.loadAccountGroups(ctx, accountIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -191,6 +201,10 @@ func (r *accountRepository) GetByIDs(ctx context.Context, ids []int64) ([]*servi
|
||||
if ags, ok := accountGroupsByAccount[entAcc.ID]; ok {
|
||||
out.AccountGroups = ags
|
||||
}
|
||||
if snap, ok := tempUnschedMap[entAcc.ID]; ok {
|
||||
out.TempUnschedulableUntil = snap.until
|
||||
out.TempUnschedulableReason = snap.reason
|
||||
}
|
||||
outByID[entAcc.ID] = out
|
||||
}
|
||||
|
||||
@@ -550,6 +564,7 @@ func (r *accountRepository) ListSchedulable(ctx context.Context) ([]service.Acco
|
||||
Where(
|
||||
dbaccount.StatusEQ(service.StatusActive),
|
||||
dbaccount.SchedulableEQ(true),
|
||||
tempUnschedulablePredicate(),
|
||||
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
|
||||
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
|
||||
).
|
||||
@@ -575,6 +590,7 @@ func (r *accountRepository) ListSchedulableByPlatform(ctx context.Context, platf
|
||||
dbaccount.PlatformEQ(platform),
|
||||
dbaccount.StatusEQ(service.StatusActive),
|
||||
dbaccount.SchedulableEQ(true),
|
||||
tempUnschedulablePredicate(),
|
||||
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
|
||||
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
|
||||
).
|
||||
@@ -607,6 +623,7 @@ func (r *accountRepository) ListSchedulableByPlatforms(ctx context.Context, plat
|
||||
dbaccount.PlatformIn(platforms...),
|
||||
dbaccount.StatusEQ(service.StatusActive),
|
||||
dbaccount.SchedulableEQ(true),
|
||||
tempUnschedulablePredicate(),
|
||||
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
|
||||
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
|
||||
).
|
||||
@@ -648,6 +665,31 @@ func (r *accountRepository) SetOverloaded(ctx context.Context, id int64, until t
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *accountRepository) SetTempUnschedulable(ctx context.Context, id int64, until time.Time, reason string) error {
|
||||
_, err := r.sql.ExecContext(ctx, `
|
||||
UPDATE accounts
|
||||
SET temp_unschedulable_until = $1,
|
||||
temp_unschedulable_reason = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
AND deleted_at IS NULL
|
||||
AND (temp_unschedulable_until IS NULL OR temp_unschedulable_until < $1)
|
||||
`, until, reason, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *accountRepository) ClearTempUnschedulable(ctx context.Context, id int64) error {
|
||||
_, err := r.sql.ExecContext(ctx, `
|
||||
UPDATE accounts
|
||||
SET temp_unschedulable_until = NULL,
|
||||
temp_unschedulable_reason = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND deleted_at IS NULL
|
||||
`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *accountRepository) ClearRateLimit(ctx context.Context, id int64) error {
|
||||
_, err := r.client.Account.Update().
|
||||
Where(dbaccount.IDEQ(id)).
|
||||
@@ -808,6 +850,7 @@ func (r *accountRepository) queryAccountsByGroup(ctx context.Context, groupID in
|
||||
now := time.Now()
|
||||
preds = append(preds,
|
||||
dbaccount.SchedulableEQ(true),
|
||||
tempUnschedulablePredicate(),
|
||||
dbaccount.Or(dbaccount.OverloadUntilIsNil(), dbaccount.OverloadUntilLTE(now)),
|
||||
dbaccount.Or(dbaccount.RateLimitResetAtIsNil(), dbaccount.RateLimitResetAtLTE(now)),
|
||||
)
|
||||
@@ -869,6 +912,10 @@ func (r *accountRepository) accountsToService(ctx context.Context, accounts []*d
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tempUnschedMap, err := r.loadTempUnschedStates(ctx, accountIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupsByAccount, groupIDsByAccount, accountGroupsByAccount, err := r.loadAccountGroups(ctx, accountIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -894,12 +941,68 @@ func (r *accountRepository) accountsToService(ctx context.Context, accounts []*d
|
||||
if ags, ok := accountGroupsByAccount[acc.ID]; ok {
|
||||
out.AccountGroups = ags
|
||||
}
|
||||
if snap, ok := tempUnschedMap[acc.ID]; ok {
|
||||
out.TempUnschedulableUntil = snap.until
|
||||
out.TempUnschedulableReason = snap.reason
|
||||
}
|
||||
outAccounts = append(outAccounts, *out)
|
||||
}
|
||||
|
||||
return outAccounts, nil
|
||||
}
|
||||
|
||||
func tempUnschedulablePredicate() dbpredicate.Account {
|
||||
return dbpredicate.Account(func(s *entsql.Selector) {
|
||||
col := s.C("temp_unschedulable_until")
|
||||
s.Where(entsql.Or(
|
||||
entsql.IsNull(col),
|
||||
entsql.LTE(col, entsql.Expr("NOW()")),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
func (r *accountRepository) loadTempUnschedStates(ctx context.Context, accountIDs []int64) (map[int64]tempUnschedSnapshot, error) {
|
||||
out := make(map[int64]tempUnschedSnapshot)
|
||||
if len(accountIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
rows, err := r.sql.QueryContext(ctx, `
|
||||
SELECT id, temp_unschedulable_until, temp_unschedulable_reason
|
||||
FROM accounts
|
||||
WHERE id = ANY($1)
|
||||
`, pq.Array(accountIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var until sql.NullTime
|
||||
var reason sql.NullString
|
||||
if err := rows.Scan(&id, &until, &reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var untilPtr *time.Time
|
||||
if until.Valid {
|
||||
tmp := until.Time
|
||||
untilPtr = &tmp
|
||||
}
|
||||
if reason.Valid {
|
||||
out[id] = tempUnschedSnapshot{until: untilPtr, reason: reason.String}
|
||||
} else {
|
||||
out[id] = tempUnschedSnapshot{until: untilPtr, reason: ""}
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *accountRepository) loadProxies(ctx context.Context, proxyIDs []int64) (map[int64]*service.Proxy, error) {
|
||||
proxyMap := make(map[int64]*service.Proxy)
|
||||
if len(proxyIDs) == 0 {
|
||||
|
||||
91
backend/internal/repository/temp_unsched_cache.go
Normal file
91
backend/internal/repository/temp_unsched_cache.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const tempUnschedPrefix = "temp_unsched:account:"
|
||||
|
||||
var tempUnschedSetScript = redis.NewScript(`
|
||||
local key = KEYS[1]
|
||||
local new_until = tonumber(ARGV[1])
|
||||
local new_value = ARGV[2]
|
||||
local new_ttl = tonumber(ARGV[3])
|
||||
|
||||
local existing = redis.call('GET', key)
|
||||
if existing then
|
||||
local ok, existing_data = pcall(cjson.decode, existing)
|
||||
if ok and existing_data and existing_data.until_unix then
|
||||
local existing_until = tonumber(existing_data.until_unix)
|
||||
if existing_until and new_until <= existing_until then
|
||||
return 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
redis.call('SET', key, new_value, 'EX', new_ttl)
|
||||
return 1
|
||||
`)
|
||||
|
||||
type tempUnschedCache struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func NewTempUnschedCache(rdb *redis.Client) service.TempUnschedCache {
|
||||
return &tempUnschedCache{rdb: rdb}
|
||||
}
|
||||
|
||||
// SetTempUnsched 设置临时不可调度状态(只延长不缩短)
|
||||
func (c *tempUnschedCache) SetTempUnsched(ctx context.Context, accountID int64, state *service.TempUnschedState) error {
|
||||
key := fmt.Sprintf("%s%d", tempUnschedPrefix, accountID)
|
||||
|
||||
stateJSON, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal state: %w", err)
|
||||
}
|
||||
|
||||
ttl := time.Until(time.Unix(state.UntilUnix, 0))
|
||||
if ttl <= 0 {
|
||||
return nil // 已过期,不设置
|
||||
}
|
||||
|
||||
ttlSeconds := int(ttl.Seconds())
|
||||
if ttlSeconds < 1 {
|
||||
ttlSeconds = 1
|
||||
}
|
||||
|
||||
_, err = tempUnschedSetScript.Run(ctx, c.rdb, []string{key}, state.UntilUnix, string(stateJSON), ttlSeconds).Result()
|
||||
return err
|
||||
}
|
||||
|
||||
// GetTempUnsched 获取临时不可调度状态
|
||||
func (c *tempUnschedCache) GetTempUnsched(ctx context.Context, accountID int64) (*service.TempUnschedState, error) {
|
||||
key := fmt.Sprintf("%s%d", tempUnschedPrefix, accountID)
|
||||
|
||||
val, err := c.rdb.Get(ctx, key).Result()
|
||||
if err == redis.Nil {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var state service.TempUnschedState
|
||||
if err := json.Unmarshal([]byte(val), &state); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal state: %w", err)
|
||||
}
|
||||
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
// DeleteTempUnsched 删除临时不可调度状态
|
||||
func (c *tempUnschedCache) DeleteTempUnsched(ctx context.Context, accountID int64) error {
|
||||
key := fmt.Sprintf("%s%d", tempUnschedPrefix, accountID)
|
||||
return c.rdb.Del(ctx, key).Err()
|
||||
}
|
||||
@@ -19,9 +19,6 @@ func RegisterAdminRoutes(
|
||||
// 仪表盘
|
||||
registerDashboardRoutes(admin, h)
|
||||
|
||||
// 运维监控
|
||||
registerOpsRoutes(admin, h)
|
||||
|
||||
// 用户管理
|
||||
registerUserManagementRoutes(admin, h)
|
||||
|
||||
@@ -70,35 +67,10 @@ func registerDashboardRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
dashboard.GET("/realtime", h.Admin.Dashboard.GetRealtimeMetrics)
|
||||
dashboard.GET("/trend", h.Admin.Dashboard.GetUsageTrend)
|
||||
dashboard.GET("/models", h.Admin.Dashboard.GetModelStats)
|
||||
dashboard.GET("/api-keys-trend", h.Admin.Dashboard.GetAPIKeyUsageTrend)
|
||||
dashboard.GET("/api-keys-trend", h.Admin.Dashboard.GetApiKeyUsageTrend)
|
||||
dashboard.GET("/users-trend", h.Admin.Dashboard.GetUserUsageTrend)
|
||||
dashboard.POST("/users-usage", h.Admin.Dashboard.GetBatchUsersUsage)
|
||||
dashboard.POST("/api-keys-usage", h.Admin.Dashboard.GetBatchAPIKeysUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
ops := admin.Group("/ops")
|
||||
{
|
||||
ops.GET("/metrics", h.Admin.Ops.GetMetrics)
|
||||
ops.GET("/metrics/history", h.Admin.Ops.ListMetricsHistory)
|
||||
ops.GET("/errors", h.Admin.Ops.GetErrorLogs)
|
||||
ops.GET("/error-logs", h.Admin.Ops.ListErrorLogs)
|
||||
|
||||
// Dashboard routes
|
||||
dashboard := ops.Group("/dashboard")
|
||||
{
|
||||
dashboard.GET("/overview", h.Admin.Ops.GetDashboardOverview)
|
||||
dashboard.GET("/providers", h.Admin.Ops.GetProviderHealth)
|
||||
dashboard.GET("/latency-histogram", h.Admin.Ops.GetLatencyHistogram)
|
||||
dashboard.GET("/errors/distribution", h.Admin.Ops.GetErrorDistribution)
|
||||
}
|
||||
|
||||
// WebSocket routes
|
||||
ws := ops.Group("/ws")
|
||||
{
|
||||
ws.GET("/qps", h.Admin.Ops.QPSWSHandler)
|
||||
}
|
||||
dashboard.POST("/api-keys-usage", h.Admin.Dashboard.GetBatchApiKeysUsage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +123,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
accounts.GET("/:id/usage", h.Admin.Account.GetUsage)
|
||||
accounts.GET("/:id/today-stats", h.Admin.Account.GetTodayStats)
|
||||
accounts.POST("/:id/clear-rate-limit", h.Admin.Account.ClearRateLimit)
|
||||
accounts.GET("/:id/temp-unschedulable", h.Admin.Account.GetTempUnschedulable)
|
||||
accounts.DELETE("/:id/temp-unschedulable", h.Admin.Account.ClearTempUnschedulable)
|
||||
accounts.POST("/:id/schedulable", h.Admin.Account.SetSchedulable)
|
||||
accounts.GET("/:id/models", h.Admin.Account.GetAvailableModels)
|
||||
accounts.POST("/batch", h.Admin.Account.BatchCreate)
|
||||
@@ -231,12 +205,12 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
{
|
||||
adminSettings.GET("", h.Admin.Setting.GetSettings)
|
||||
adminSettings.PUT("", h.Admin.Setting.UpdateSettings)
|
||||
adminSettings.POST("/test-smtp", h.Admin.Setting.TestSMTPConnection)
|
||||
adminSettings.POST("/test-smtp", h.Admin.Setting.TestSmtpConnection)
|
||||
adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail)
|
||||
// Admin API Key 管理
|
||||
adminSettings.GET("/admin-api-key", h.Admin.Setting.GetAdminAPIKey)
|
||||
adminSettings.POST("/admin-api-key/regenerate", h.Admin.Setting.RegenerateAdminAPIKey)
|
||||
adminSettings.DELETE("/admin-api-key", h.Admin.Setting.DeleteAdminAPIKey)
|
||||
adminSettings.GET("/admin-api-key", h.Admin.Setting.GetAdminApiKey)
|
||||
adminSettings.POST("/admin-api-key/regenerate", h.Admin.Setting.RegenerateAdminApiKey)
|
||||
adminSettings.DELETE("/admin-api-key", h.Admin.Setting.DeleteAdminApiKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +250,7 @@ func registerUsageRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
usage.GET("", h.Admin.Usage.List)
|
||||
usage.GET("/stats", h.Admin.Usage.Stats)
|
||||
usage.GET("/search-users", h.Admin.Usage.SearchUsers)
|
||||
usage.GET("/search-api-keys", h.Admin.Usage.SearchAPIKeys)
|
||||
usage.GET("/search-api-keys", h.Admin.Usage.SearchApiKeys)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
@@ -19,7 +20,7 @@ type AdminService interface {
|
||||
UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error)
|
||||
DeleteUser(ctx context.Context, id int64) error
|
||||
UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error)
|
||||
GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]APIKey, int64, error)
|
||||
GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]ApiKey, int64, error)
|
||||
GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error)
|
||||
|
||||
// Group management
|
||||
@@ -30,7 +31,7 @@ type AdminService interface {
|
||||
CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error)
|
||||
UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error)
|
||||
DeleteGroup(ctx context.Context, id int64) error
|
||||
GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error)
|
||||
GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]ApiKey, int64, error)
|
||||
|
||||
// Account management
|
||||
ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string) ([]Account, int64, error)
|
||||
@@ -65,7 +66,7 @@ type AdminService interface {
|
||||
ExpireRedeemCode(ctx context.Context, id int64) (*RedeemCode, error)
|
||||
}
|
||||
|
||||
// CreateUserInput represents the input for creating a new user
|
||||
// Input types for admin operations
|
||||
type CreateUserInput struct {
|
||||
Email string
|
||||
Password string
|
||||
@@ -122,18 +123,22 @@ type CreateAccountInput struct {
|
||||
Concurrency int
|
||||
Priority int
|
||||
GroupIDs []int64
|
||||
// SkipMixedChannelCheck skips the mixed channel risk check when binding groups.
|
||||
// This should only be set when the caller has explicitly confirmed the risk.
|
||||
SkipMixedChannelCheck bool
|
||||
}
|
||||
|
||||
type UpdateAccountInput struct {
|
||||
Name string
|
||||
Type string // Account type: oauth, setup-token, apikey
|
||||
Credentials map[string]any
|
||||
Extra map[string]any
|
||||
ProxyID *int64
|
||||
Concurrency *int // 使用指针区分"未提供"和"设置为0"
|
||||
Priority *int // 使用指针区分"未提供"和"设置为0"
|
||||
Status string
|
||||
GroupIDs *[]int64
|
||||
Name string
|
||||
Type string // Account type: oauth, setup-token, apikey
|
||||
Credentials map[string]any
|
||||
Extra map[string]any
|
||||
ProxyID *int64
|
||||
Concurrency *int // 使用指针区分"未提供"和"设置为0"
|
||||
Priority *int // 使用指针区分"未提供"和"设置为0"
|
||||
Status string
|
||||
GroupIDs *[]int64
|
||||
SkipMixedChannelCheck bool // 跳过混合渠道检查(用户已确认风险)
|
||||
}
|
||||
|
||||
// BulkUpdateAccountsInput describes the payload for bulk updating accounts.
|
||||
@@ -147,6 +152,9 @@ type BulkUpdateAccountsInput struct {
|
||||
GroupIDs *[]int64
|
||||
Credentials map[string]any
|
||||
Extra map[string]any
|
||||
// SkipMixedChannelCheck skips the mixed channel risk check when binding groups.
|
||||
// This should only be set when the caller has explicitly confirmed the risk.
|
||||
SkipMixedChannelCheck bool
|
||||
}
|
||||
|
||||
// BulkUpdateAccountResult captures the result for a single account update.
|
||||
@@ -220,7 +228,7 @@ type adminServiceImpl struct {
|
||||
groupRepo GroupRepository
|
||||
accountRepo AccountRepository
|
||||
proxyRepo ProxyRepository
|
||||
apiKeyRepo APIKeyRepository
|
||||
apiKeyRepo ApiKeyRepository
|
||||
redeemCodeRepo RedeemCodeRepository
|
||||
billingCacheService *BillingCacheService
|
||||
proxyProber ProxyExitInfoProber
|
||||
@@ -232,7 +240,7 @@ func NewAdminService(
|
||||
groupRepo GroupRepository,
|
||||
accountRepo AccountRepository,
|
||||
proxyRepo ProxyRepository,
|
||||
apiKeyRepo APIKeyRepository,
|
||||
apiKeyRepo ApiKeyRepository,
|
||||
redeemCodeRepo RedeemCodeRepository,
|
||||
billingCacheService *BillingCacheService,
|
||||
proxyProber ProxyExitInfoProber,
|
||||
@@ -430,7 +438,7 @@ func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64,
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]APIKey, int64, error) {
|
||||
func (s *adminServiceImpl) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int) ([]ApiKey, int64, error) {
|
||||
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
||||
keys, result, err := s.apiKeyRepo.ListByUserID(ctx, userID, params)
|
||||
if err != nil {
|
||||
@@ -583,7 +591,7 @@ func (s *adminServiceImpl) DeleteGroup(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error) {
|
||||
func (s *adminServiceImpl) GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]ApiKey, int64, error) {
|
||||
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
||||
keys, result, err := s.apiKeyRepo.ListByGroupID(ctx, groupID, params)
|
||||
if err != nil {
|
||||
@@ -620,6 +628,29 @@ func (s *adminServiceImpl) GetAccountsByIDs(ctx context.Context, ids []int64) ([
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) {
|
||||
// 绑定分组
|
||||
groupIDs := input.GroupIDs
|
||||
// 如果没有指定分组,自动绑定对应平台的默认分组
|
||||
if len(groupIDs) == 0 {
|
||||
defaultGroupName := input.Platform + "-default"
|
||||
groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform)
|
||||
if err == nil {
|
||||
for _, g := range groups {
|
||||
if g.Name == defaultGroupName {
|
||||
groupIDs = []int64{g.ID}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查混合渠道风险(除非用户已确认)
|
||||
if len(groupIDs) > 0 && !input.SkipMixedChannelCheck {
|
||||
if err := s.checkMixedChannelRisk(ctx, 0, input.Platform, groupIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
account := &Account{
|
||||
Name: input.Name,
|
||||
Platform: input.Platform,
|
||||
@@ -636,22 +667,6 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
|
||||
}
|
||||
|
||||
// 绑定分组
|
||||
groupIDs := input.GroupIDs
|
||||
// 如果没有指定分组,自动绑定对应平台的默认分组
|
||||
if len(groupIDs) == 0 {
|
||||
defaultGroupName := input.Platform + "-default"
|
||||
groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform)
|
||||
if err == nil {
|
||||
for _, g := range groups {
|
||||
if g.Name == defaultGroupName {
|
||||
groupIDs = []int64{g.ID}
|
||||
log.Printf("[CreateAccount] Auto-binding account %d to default group %s (ID: %d)", account.ID, defaultGroupName, g.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(groupIDs) > 0 {
|
||||
if err := s.accountRepo.BindGroups(ctx, account.ID, groupIDs); err != nil {
|
||||
return nil, err
|
||||
@@ -702,6 +717,13 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
return nil, fmt.Errorf("get group: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查混合渠道风险(除非用户已确认)
|
||||
if !input.SkipMixedChannelCheck {
|
||||
if err := s.checkMixedChannelRisk(ctx, account.ID, account.Platform, *input.GroupIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.accountRepo.Update(ctx, account); err != nil {
|
||||
@@ -730,6 +752,20 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Preload account platforms for mixed channel risk checks if group bindings are requested.
|
||||
platformByID := map[int64]string{}
|
||||
if input.GroupIDs != nil && !input.SkipMixedChannelCheck {
|
||||
accounts, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, account := range accounts {
|
||||
if account != nil {
|
||||
platformByID[account.ID] = account.Platform
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare bulk updates for columns and JSONB fields.
|
||||
repoUpdates := AccountBulkUpdate{
|
||||
Credentials: input.Credentials,
|
||||
@@ -761,6 +797,29 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
|
||||
entry := BulkUpdateAccountResult{AccountID: accountID}
|
||||
|
||||
if input.GroupIDs != nil {
|
||||
// 检查混合渠道风险(除非用户已确认)
|
||||
if !input.SkipMixedChannelCheck {
|
||||
platform := platformByID[accountID]
|
||||
if platform == "" {
|
||||
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
entry.Success = false
|
||||
entry.Error = err.Error()
|
||||
result.Failed++
|
||||
result.Results = append(result.Results, entry)
|
||||
continue
|
||||
}
|
||||
platform = account.Platform
|
||||
}
|
||||
if err := s.checkMixedChannelRisk(ctx, accountID, platform, *input.GroupIDs); err != nil {
|
||||
entry.Success = false
|
||||
entry.Error = err.Error()
|
||||
result.Failed++
|
||||
result.Results = append(result.Results, entry)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.accountRepo.BindGroups(ctx, accountID, *input.GroupIDs); err != nil {
|
||||
entry.Success = false
|
||||
entry.Error = err.Error()
|
||||
@@ -1005,3 +1064,77 @@ func (s *adminServiceImpl) TestProxy(ctx context.Context, id int64) (*ProxyTestR
|
||||
Country: exitInfo.Country,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkMixedChannelRisk 检查分组中是否存在混合渠道(Antigravity + Anthropic)
|
||||
// 如果存在混合,返回错误提示用户确认
|
||||
func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error {
|
||||
// 判断当前账号的渠道类型(基于 platform 字段,而不是 type 字段)
|
||||
currentPlatform := getAccountPlatform(currentAccountPlatform)
|
||||
if currentPlatform == "" {
|
||||
// 不是 Antigravity 或 Anthropic,无需检查
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查每个分组中的其他账号
|
||||
for _, groupID := range groupIDs {
|
||||
accounts, err := s.accountRepo.ListByGroup(ctx, groupID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get accounts in group %d: %w", groupID, err)
|
||||
}
|
||||
|
||||
// 检查是否存在不同渠道的账号
|
||||
for _, account := range accounts {
|
||||
if currentAccountID > 0 && account.ID == currentAccountID {
|
||||
continue // 跳过当前账号
|
||||
}
|
||||
|
||||
otherPlatform := getAccountPlatform(account.Platform)
|
||||
if otherPlatform == "" {
|
||||
continue // 不是 Antigravity 或 Anthropic,跳过
|
||||
}
|
||||
|
||||
// 检测混合渠道
|
||||
if currentPlatform != otherPlatform {
|
||||
group, _ := s.groupRepo.GetByID(ctx, groupID)
|
||||
groupName := fmt.Sprintf("Group %d", groupID)
|
||||
if group != nil {
|
||||
groupName = group.Name
|
||||
}
|
||||
|
||||
return &MixedChannelError{
|
||||
GroupID: groupID,
|
||||
GroupName: groupName,
|
||||
CurrentPlatform: currentPlatform,
|
||||
OtherPlatform: otherPlatform,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAccountPlatform 根据账号 platform 判断混合渠道检查用的平台标识
|
||||
func getAccountPlatform(accountPlatform string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(accountPlatform)) {
|
||||
case PlatformAntigravity:
|
||||
return "Antigravity"
|
||||
case PlatformAnthropic, "claude":
|
||||
return "Anthropic"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// MixedChannelError 混合渠道错误
|
||||
type MixedChannelError struct {
|
||||
GroupID int64
|
||||
GroupName string
|
||||
CurrentPlatform string
|
||||
OtherPlatform string
|
||||
}
|
||||
|
||||
func (e *MixedChannelError) Error() string {
|
||||
return fmt.Sprintf("mixed_channel_warning: Group '%s' contains both %s and %s accounts. Using mixed channels in the same context may cause thinking block signature validation issues, which will fallback to non-thinking mode for historical messages.",
|
||||
e.GroupName, e.CurrentPlatform, e.OtherPlatform)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -18,6 +19,7 @@ type RateLimitService struct {
|
||||
usageRepo UsageLogRepository
|
||||
cfg *config.Config
|
||||
geminiQuotaService *GeminiQuotaService
|
||||
tempUnschedCache TempUnschedCache
|
||||
usageCacheMu sync.RWMutex
|
||||
usageCache map[int64]*geminiUsageCacheEntry
|
||||
}
|
||||
@@ -31,12 +33,13 @@ type geminiUsageCacheEntry struct {
|
||||
const geminiPrecheckCacheTTL = time.Minute
|
||||
|
||||
// NewRateLimitService 创建RateLimitService实例
|
||||
func NewRateLimitService(accountRepo AccountRepository, usageRepo UsageLogRepository, cfg *config.Config, geminiQuotaService *GeminiQuotaService) *RateLimitService {
|
||||
func NewRateLimitService(accountRepo AccountRepository, usageRepo UsageLogRepository, cfg *config.Config, geminiQuotaService *GeminiQuotaService, tempUnschedCache TempUnschedCache) *RateLimitService {
|
||||
return &RateLimitService{
|
||||
accountRepo: accountRepo,
|
||||
usageRepo: usageRepo,
|
||||
cfg: cfg,
|
||||
geminiQuotaService: geminiQuotaService,
|
||||
tempUnschedCache: tempUnschedCache,
|
||||
usageCache: make(map[int64]*geminiUsageCacheEntry),
|
||||
}
|
||||
}
|
||||
@@ -51,32 +54,39 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
|
||||
return false
|
||||
}
|
||||
|
||||
tempMatched := s.tryTempUnschedulable(ctx, account, statusCode, responseBody)
|
||||
|
||||
switch statusCode {
|
||||
case 401:
|
||||
// 认证失败:停止调度,记录错误
|
||||
s.handleAuthError(ctx, account, "Authentication failed (401): invalid or expired credentials")
|
||||
return true
|
||||
shouldDisable = true
|
||||
case 402:
|
||||
// 支付要求:余额不足或计费问题,停止调度
|
||||
s.handleAuthError(ctx, account, "Payment required (402): insufficient balance or billing issue")
|
||||
return true
|
||||
shouldDisable = true
|
||||
case 403:
|
||||
// 禁止访问:停止调度,记录错误
|
||||
s.handleAuthError(ctx, account, "Access forbidden (403): account may be suspended or lack permissions")
|
||||
return true
|
||||
shouldDisable = true
|
||||
case 429:
|
||||
s.handle429(ctx, account, headers)
|
||||
return false
|
||||
shouldDisable = false
|
||||
case 529:
|
||||
s.handle529(ctx, account)
|
||||
return false
|
||||
shouldDisable = false
|
||||
default:
|
||||
// 其他5xx错误:记录但不停止调度
|
||||
if statusCode >= 500 {
|
||||
log.Printf("Account %d received upstream error %d", account.ID, statusCode)
|
||||
}
|
||||
return false
|
||||
shouldDisable = false
|
||||
}
|
||||
|
||||
if tempMatched {
|
||||
return true
|
||||
}
|
||||
return shouldDisable
|
||||
}
|
||||
|
||||
// PreCheckUsage proactively checks local quota before dispatching a request.
|
||||
@@ -287,3 +297,183 @@ func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Acc
|
||||
func (s *RateLimitService) ClearRateLimit(ctx context.Context, accountID int64) error {
|
||||
return s.accountRepo.ClearRateLimit(ctx, accountID)
|
||||
}
|
||||
|
||||
func (s *RateLimitService) ClearTempUnschedulable(ctx context.Context, accountID int64) error {
|
||||
if err := s.accountRepo.ClearTempUnschedulable(ctx, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.tempUnschedCache != nil {
|
||||
if err := s.tempUnschedCache.DeleteTempUnsched(ctx, accountID); err != nil {
|
||||
log.Printf("DeleteTempUnsched failed for account %d: %v", accountID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RateLimitService) GetTempUnschedStatus(ctx context.Context, accountID int64) (*TempUnschedState, error) {
|
||||
now := time.Now().Unix()
|
||||
if s.tempUnschedCache != nil {
|
||||
state, err := s.tempUnschedCache.GetTempUnsched(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if state != nil && state.UntilUnix > now {
|
||||
return state, nil
|
||||
}
|
||||
}
|
||||
|
||||
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if account.TempUnschedulableUntil == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if account.TempUnschedulableUntil.Unix() <= now {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
state := &TempUnschedState{
|
||||
UntilUnix: account.TempUnschedulableUntil.Unix(),
|
||||
}
|
||||
|
||||
if account.TempUnschedulableReason != "" {
|
||||
var parsed TempUnschedState
|
||||
if err := json.Unmarshal([]byte(account.TempUnschedulableReason), &parsed); err == nil {
|
||||
if parsed.UntilUnix == 0 {
|
||||
parsed.UntilUnix = state.UntilUnix
|
||||
}
|
||||
state = &parsed
|
||||
} else {
|
||||
state.ErrorMessage = account.TempUnschedulableReason
|
||||
}
|
||||
}
|
||||
|
||||
if s.tempUnschedCache != nil {
|
||||
if err := s.tempUnschedCache.SetTempUnsched(ctx, accountID, state); err != nil {
|
||||
log.Printf("SetTempUnsched failed for account %d: %v", accountID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *RateLimitService) HandleTempUnschedulable(ctx context.Context, account *Account, statusCode int, responseBody []byte) bool {
|
||||
if account == nil {
|
||||
return false
|
||||
}
|
||||
if !account.ShouldHandleErrorCode(statusCode) {
|
||||
return false
|
||||
}
|
||||
return s.tryTempUnschedulable(ctx, account, statusCode, responseBody)
|
||||
}
|
||||
|
||||
const tempUnschedBodyMaxBytes = 64 << 10
|
||||
const tempUnschedMessageMaxBytes = 2048
|
||||
|
||||
func (s *RateLimitService) tryTempUnschedulable(ctx context.Context, account *Account, statusCode int, responseBody []byte) bool {
|
||||
if account == nil {
|
||||
return false
|
||||
}
|
||||
if !account.IsTempUnschedulableEnabled() {
|
||||
return false
|
||||
}
|
||||
rules := account.GetTempUnschedulableRules()
|
||||
if len(rules) == 0 {
|
||||
return false
|
||||
}
|
||||
if statusCode <= 0 || len(responseBody) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
body := responseBody
|
||||
if len(body) > tempUnschedBodyMaxBytes {
|
||||
body = body[:tempUnschedBodyMaxBytes]
|
||||
}
|
||||
bodyLower := strings.ToLower(string(body))
|
||||
|
||||
for idx, rule := range rules {
|
||||
if rule.ErrorCode != statusCode || len(rule.Keywords) == 0 {
|
||||
continue
|
||||
}
|
||||
matchedKeyword := matchTempUnschedKeyword(bodyLower, rule.Keywords)
|
||||
if matchedKeyword == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if s.triggerTempUnschedulable(ctx, account, rule, idx, statusCode, matchedKeyword, responseBody) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func matchTempUnschedKeyword(bodyLower string, keywords []string) string {
|
||||
if bodyLower == "" {
|
||||
return ""
|
||||
}
|
||||
for _, keyword := range keywords {
|
||||
k := strings.TrimSpace(keyword)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(bodyLower, strings.ToLower(k)) {
|
||||
return k
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *RateLimitService) triggerTempUnschedulable(ctx context.Context, account *Account, rule TempUnschedulableRule, ruleIndex int, statusCode int, matchedKeyword string, responseBody []byte) bool {
|
||||
if account == nil {
|
||||
return false
|
||||
}
|
||||
if rule.DurationMinutes <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
until := now.Add(time.Duration(rule.DurationMinutes) * time.Minute)
|
||||
|
||||
state := &TempUnschedState{
|
||||
UntilUnix: until.Unix(),
|
||||
TriggeredAtUnix: now.Unix(),
|
||||
StatusCode: statusCode,
|
||||
MatchedKeyword: matchedKeyword,
|
||||
RuleIndex: ruleIndex,
|
||||
ErrorMessage: truncateTempUnschedMessage(responseBody, tempUnschedMessageMaxBytes),
|
||||
}
|
||||
|
||||
reason := ""
|
||||
if raw, err := json.Marshal(state); err == nil {
|
||||
reason = string(raw)
|
||||
}
|
||||
if reason == "" {
|
||||
reason = strings.TrimSpace(state.ErrorMessage)
|
||||
}
|
||||
|
||||
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, reason); err != nil {
|
||||
log.Printf("SetTempUnschedulable failed for account %d: %v", account.ID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
if s.tempUnschedCache != nil {
|
||||
if err := s.tempUnschedCache.SetTempUnsched(ctx, account.ID, state); err != nil {
|
||||
log.Printf("SetTempUnsched cache failed for account %d: %v", account.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Account %d temp unschedulable until %v (rule %d, code %d)", account.ID, until, ruleIndex, statusCode)
|
||||
return true
|
||||
}
|
||||
|
||||
func truncateTempUnschedMessage(body []byte, maxBytes int) string {
|
||||
if maxBytes <= 0 || len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(body) > maxBytes {
|
||||
body = body[:maxBytes]
|
||||
}
|
||||
return strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
22
backend/internal/service/temp_unsched.go
Normal file
22
backend/internal/service/temp_unsched.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// TempUnschedState 临时不可调度状态
|
||||
type TempUnschedState struct {
|
||||
UntilUnix int64 `json:"until_unix"` // 解除时间(Unix 时间戳)
|
||||
TriggeredAtUnix int64 `json:"triggered_at_unix"` // 触发时间(Unix 时间戳)
|
||||
StatusCode int `json:"status_code"` // 触发的错误码
|
||||
MatchedKeyword string `json:"matched_keyword"` // 匹配的关键词
|
||||
RuleIndex int `json:"rule_index"` // 触发的规则索引
|
||||
ErrorMessage string `json:"error_message"` // 错误消息
|
||||
}
|
||||
|
||||
// TempUnschedCache 临时不可调度缓存接口
|
||||
type TempUnschedCache interface {
|
||||
SetTempUnsched(ctx context.Context, accountID int64, state *TempUnschedState) error
|
||||
GetTempUnsched(ctx context.Context, accountID int64) (*TempUnschedState, error)
|
||||
DeleteTempUnsched(ctx context.Context, accountID int64) error
|
||||
}
|
||||
15
backend/migrations/020_add_temp_unschedulable.sql
Normal file
15
backend/migrations/020_add_temp_unschedulable.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- 020_add_temp_unschedulable.sql
|
||||
-- 添加临时不可调度功能相关字段
|
||||
|
||||
-- 添加临时不可调度状态解除时间字段
|
||||
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS temp_unschedulable_until timestamptz;
|
||||
|
||||
-- 添加临时不可调度原因字段(用于排障和审计)
|
||||
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS temp_unschedulable_reason text;
|
||||
|
||||
-- 添加索引以优化调度查询性能
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_temp_unschedulable_until ON accounts(temp_unschedulable_until) WHERE deleted_at IS NULL;
|
||||
|
||||
-- 添加注释说明字段用途
|
||||
COMMENT ON COLUMN accounts.temp_unschedulable_until IS '临时不可调度状态解除时间,当触发临时不可调度规则时设置(基于错误码或错误描述关键词)';
|
||||
COMMENT ON COLUMN accounts.temp_unschedulable_reason IS '临时不可调度原因,记录触发临时不可调度的具体原因(用于排障和审计)';
|
||||
@@ -12,7 +12,8 @@ import type {
|
||||
AccountUsageInfo,
|
||||
WindowStats,
|
||||
ClaudeModel,
|
||||
AccountUsageStatsResponse
|
||||
AccountUsageStatsResponse,
|
||||
TempUnschedulableStatus
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
@@ -170,6 +171,30 @@ export async function clearRateLimit(id: number): Promise<{ message: string }> {
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get temporary unschedulable status
|
||||
* @param id - Account ID
|
||||
* @returns Status with detail state if active
|
||||
*/
|
||||
export async function getTempUnschedulableStatus(id: number): Promise<TempUnschedulableStatus> {
|
||||
const { data } = await apiClient.get<TempUnschedulableStatus>(
|
||||
`/admin/accounts/${id}/temp-unschedulable`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset temporary unschedulable status
|
||||
* @param id - Account ID
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function resetTempUnschedulable(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(
|
||||
`/admin/accounts/${id}/temp-unschedulable`
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth authorization URL
|
||||
* @param endpoint - API endpoint path
|
||||
@@ -332,6 +357,8 @@ export const accountsAPI = {
|
||||
getUsage,
|
||||
getTodayStats,
|
||||
clearRateLimit,
|
||||
getTempUnschedulableStatus,
|
||||
resetTempUnschedulable,
|
||||
setSchedulable,
|
||||
getAvailableModels,
|
||||
generateAuthUrl,
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Main Status Badge -->
|
||||
<span :class="['badge text-xs', statusClass]">
|
||||
<button
|
||||
v-if="isTempUnschedulable"
|
||||
type="button"
|
||||
:class="['badge text-xs', statusClass, 'cursor-pointer']"
|
||||
:title="t('admin.accounts.tempUnschedulable.viewDetails')"
|
||||
@click="handleTempUnschedClick"
|
||||
>
|
||||
{{ statusText }}
|
||||
</button>
|
||||
<span v-else :class="['badge text-xs', statusClass]">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
|
||||
@@ -83,26 +92,25 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tier Indicator -->
|
||||
<span
|
||||
v-if="tierDisplay"
|
||||
class="inline-flex items-center rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
{{ tierDisplay }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Account } from '@/types'
|
||||
import { formatTime } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
account: Account
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'show-temp-unsched', account: Account): void
|
||||
}>()
|
||||
|
||||
// Computed: is rate limited (429)
|
||||
const isRateLimited = computed(() => {
|
||||
if (!props.account.rate_limit_reset_at) return false
|
||||
@@ -115,6 +123,12 @@ const isOverloaded = computed(() => {
|
||||
return new Date(props.account.overload_until) > new Date()
|
||||
})
|
||||
|
||||
// Computed: is temp unschedulable
|
||||
const isTempUnschedulable = computed(() => {
|
||||
if (!props.account.temp_unschedulable_until) return false
|
||||
return new Date(props.account.temp_unschedulable_until) > new Date()
|
||||
})
|
||||
|
||||
// Computed: has error status
|
||||
const hasError = computed(() => {
|
||||
return props.account.status === 'error'
|
||||
@@ -122,6 +136,12 @@ const hasError = computed(() => {
|
||||
|
||||
// Computed: status badge class
|
||||
const statusClass = computed(() => {
|
||||
if (hasError.value) {
|
||||
return 'badge-danger'
|
||||
}
|
||||
if (isTempUnschedulable.value) {
|
||||
return 'badge-warning'
|
||||
}
|
||||
if (!props.account.schedulable || isRateLimited.value || isOverloaded.value) {
|
||||
return 'badge-gray'
|
||||
}
|
||||
@@ -139,32 +159,24 @@ const statusClass = computed(() => {
|
||||
|
||||
// Computed: status text
|
||||
const statusText = computed(() => {
|
||||
if (hasError.value) {
|
||||
return t('common.error')
|
||||
}
|
||||
if (isTempUnschedulable.value) {
|
||||
return t('admin.accounts.status.tempUnschedulable')
|
||||
}
|
||||
if (!props.account.schedulable) {
|
||||
return 'Paused'
|
||||
return t('admin.accounts.status.paused')
|
||||
}
|
||||
if (isRateLimited.value || isOverloaded.value) {
|
||||
return 'Limited'
|
||||
return t('admin.accounts.status.limited')
|
||||
}
|
||||
return props.account.status
|
||||
})
|
||||
|
||||
// Computed: tier display
|
||||
const tierDisplay = computed(() => {
|
||||
const credentials = props.account.credentials as Record<string, any> | undefined
|
||||
const tierId = credentials?.tier_id
|
||||
if (!tierId || tierId === 'unknown') return null
|
||||
|
||||
const tierMap: Record<string, string> = {
|
||||
'free': 'Free',
|
||||
'payg': 'Pay-as-you-go',
|
||||
'pay-as-you-go': 'Pay-as-you-go',
|
||||
'enterprise': 'Enterprise',
|
||||
'LEGACY': 'Legacy',
|
||||
'PRO': 'Pro',
|
||||
'ULTRA': 'Ultra'
|
||||
}
|
||||
|
||||
return tierMap[tierId] || tierId
|
||||
})
|
||||
const handleTempUnschedClick = () => {
|
||||
if (!isTempUnschedulable.value) return
|
||||
emit('show-temp-unsched', props.account)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -1065,7 +1065,7 @@
|
||||
<!-- Manual input -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="customErrorCodeInput"
|
||||
v-model.number="customErrorCodeInput"
|
||||
type="number"
|
||||
min="100"
|
||||
max="599"
|
||||
@@ -1304,6 +1304,175 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Temp Unschedulable Rules -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.tempUnschedulable.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="tempUnschedEnabled = !tempUnschedEnabled"
|
||||
: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',
|
||||
tempUnschedEnabled ? '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',
|
||||
tempUnschedEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tempUnschedEnabled" class="space-y-3">
|
||||
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.tempUnschedulable.notice') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in tempUnschedPresets"
|
||||
:key="preset.label"
|
||||
type="button"
|
||||
@click="addTempUnschedRule(preset.rule)"
|
||||
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
|
||||
>
|
||||
+ {{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tempUnschedRules.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="(rule, index) in tempUnschedRules"
|
||||
:key="index"
|
||||
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
|
||||
>
|
||||
<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.tempUnschedulable.ruleIndex', { index: index + 1 }) }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="index === 0"
|
||||
@click="moveTempUnschedRule(index, -1)"
|
||||
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="index === tempUnschedRules.length - 1"
|
||||
@click="moveTempUnschedRule(index, 1)"
|
||||
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeTempUnschedRule(index)"
|
||||
class="rounded p-1 text-red-500 transition-colors hover:text-red-600"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.errorCode') }}</label>
|
||||
<input
|
||||
v-model.number="rule.error_code"
|
||||
type="number"
|
||||
min="100"
|
||||
max="599"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.tempUnschedulable.errorCodePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.durationMinutes') }}</label>
|
||||
<input
|
||||
v-model.number="rule.duration_minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.tempUnschedulable.durationPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.keywords') }}</label>
|
||||
<input
|
||||
v-model="rule.keywords"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.tempUnschedulable.keywordsPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.tempUnschedulable.keywordsHint') }}</p>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.description') }}</label>
|
||||
<input
|
||||
v-model="rule.description"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.tempUnschedulable.descriptionPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="addTempUnschedRule()"
|
||||
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-sm text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ t('admin.accounts.tempUnschedulable.addRule') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intercept Warmup Requests (Anthropic only) -->
|
||||
<div
|
||||
v-if="form.platform === 'anthropic'"
|
||||
@@ -1619,6 +1788,13 @@ interface ModelMapping {
|
||||
to: string
|
||||
}
|
||||
|
||||
interface TempUnschedRuleForm {
|
||||
error_code: number | null
|
||||
keywords: string
|
||||
duration_minutes: number | null
|
||||
description: string
|
||||
}
|
||||
|
||||
// State
|
||||
const step = ref(1)
|
||||
const submitting = ref(false)
|
||||
@@ -1634,6 +1810,8 @@ const selectedErrorCodes = ref<number[]>([])
|
||||
const customErrorCodeInput = ref<number | null>(null)
|
||||
const interceptWarmupRequests = ref(false)
|
||||
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
|
||||
const geminiAIStudioOAuthEnabled = ref(false)
|
||||
const showAdvancedOAuth = ref(false)
|
||||
@@ -1654,6 +1832,35 @@ const geminiHelpLinks = {
|
||||
|
||||
// Computed: current preset mappings based on platform
|
||||
const presetMappings = computed(() => getPresetMappingsByPlatform(form.platform))
|
||||
const tempUnschedPresets = computed(() => [
|
||||
{
|
||||
label: t('admin.accounts.tempUnschedulable.presets.overloadLabel'),
|
||||
rule: {
|
||||
error_code: 529,
|
||||
keywords: 'overloaded, too many',
|
||||
duration_minutes: 60,
|
||||
description: t('admin.accounts.tempUnschedulable.presets.overloadDesc')
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('admin.accounts.tempUnschedulable.presets.rateLimitLabel'),
|
||||
rule: {
|
||||
error_code: 429,
|
||||
keywords: 'rate limit, too many requests',
|
||||
duration_minutes: 10,
|
||||
description: t('admin.accounts.tempUnschedulable.presets.rateLimitDesc')
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('admin.accounts.tempUnschedulable.presets.unavailableLabel'),
|
||||
rule: {
|
||||
error_code: 503,
|
||||
keywords: 'unavailable, maintenance',
|
||||
duration_minutes: 30,
|
||||
description: t('admin.accounts.tempUnschedulable.presets.unavailableDesc')
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
@@ -1828,6 +2035,89 @@ const removeErrorCode = (code: number) => {
|
||||
}
|
||||
}
|
||||
|
||||
const addTempUnschedRule = (preset?: TempUnschedRuleForm) => {
|
||||
if (preset) {
|
||||
tempUnschedRules.value.push({ ...preset })
|
||||
return
|
||||
}
|
||||
tempUnschedRules.value.push({
|
||||
error_code: null,
|
||||
keywords: '',
|
||||
duration_minutes: 30,
|
||||
description: ''
|
||||
})
|
||||
}
|
||||
|
||||
const removeTempUnschedRule = (index: number) => {
|
||||
tempUnschedRules.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const moveTempUnschedRule = (index: number, direction: number) => {
|
||||
const target = index + direction
|
||||
if (target < 0 || target >= tempUnschedRules.value.length) return
|
||||
const rules = tempUnschedRules.value
|
||||
const current = rules[index]
|
||||
rules[index] = rules[target]
|
||||
rules[target] = current
|
||||
}
|
||||
|
||||
const buildTempUnschedRules = (rules: TempUnschedRuleForm[]) => {
|
||||
const out: Array<{
|
||||
error_code: number
|
||||
keywords: string[]
|
||||
duration_minutes: number
|
||||
description: string
|
||||
}> = []
|
||||
|
||||
for (const rule of rules) {
|
||||
const errorCode = Number(rule.error_code)
|
||||
const duration = Number(rule.duration_minutes)
|
||||
const keywords = splitTempUnschedKeywords(rule.keywords)
|
||||
if (!Number.isFinite(errorCode) || errorCode < 100 || errorCode > 599) {
|
||||
continue
|
||||
}
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
continue
|
||||
}
|
||||
if (keywords.length === 0) {
|
||||
continue
|
||||
}
|
||||
out.push({
|
||||
error_code: Math.trunc(errorCode),
|
||||
keywords,
|
||||
duration_minutes: Math.trunc(duration),
|
||||
description: rule.description.trim()
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const applyTempUnschedConfig = (credentials: Record<string, unknown>) => {
|
||||
if (!tempUnschedEnabled.value) {
|
||||
delete credentials.temp_unschedulable_enabled
|
||||
delete credentials.temp_unschedulable_rules
|
||||
return true
|
||||
}
|
||||
|
||||
const rules = buildTempUnschedRules(tempUnschedRules.value)
|
||||
if (rules.length === 0) {
|
||||
appStore.showError(t('admin.accounts.tempUnschedulable.rulesInvalid'))
|
||||
return false
|
||||
}
|
||||
|
||||
credentials.temp_unschedulable_enabled = true
|
||||
credentials.temp_unschedulable_rules = rules
|
||||
return true
|
||||
}
|
||||
|
||||
const splitTempUnschedKeywords = (value: string) => {
|
||||
return value
|
||||
.split(/[,;]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
}
|
||||
|
||||
// Methods
|
||||
const resetForm = () => {
|
||||
step.value = 1
|
||||
@@ -1850,6 +2140,8 @@ const resetForm = () => {
|
||||
selectedErrorCodes.value = []
|
||||
customErrorCodeInput.value = null
|
||||
interceptWarmupRequests.value = false
|
||||
tempUnschedEnabled.value = false
|
||||
tempUnschedRules.value = []
|
||||
geminiOAuthType.value = 'code_assist'
|
||||
oauth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
@@ -1910,6 +2202,10 @@ const handleSubmit = async () => {
|
||||
credentials.intercept_warmup_requests = true
|
||||
}
|
||||
|
||||
if (!applyTempUnschedConfig(credentials)) {
|
||||
return
|
||||
}
|
||||
|
||||
form.credentials = credentials
|
||||
|
||||
submitting.value = true
|
||||
@@ -1956,6 +2252,9 @@ const createAccountAndFinish = async (
|
||||
credentials: Record<string, unknown>,
|
||||
extra?: Record<string, unknown>
|
||||
) => {
|
||||
if (!applyTempUnschedConfig(credentials)) {
|
||||
return
|
||||
}
|
||||
await adminAPI.accounts.create({
|
||||
name: form.name,
|
||||
platform,
|
||||
@@ -2024,7 +2323,8 @@ const handleGeminiExchange = async (authCode: string) => {
|
||||
if (!tokenInfo) return
|
||||
|
||||
const credentials = geminiOAuth.buildCredentials(tokenInfo)
|
||||
await createAccountAndFinish('gemini', 'oauth', credentials)
|
||||
const extra = geminiOAuth.buildExtraInfo(tokenInfo)
|
||||
await createAccountAndFinish('gemini', 'oauth', credentials, extra)
|
||||
} catch (error: any) {
|
||||
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(geminiOAuth.error.value)
|
||||
@@ -2131,6 +2431,14 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
return
|
||||
}
|
||||
|
||||
const tempUnschedPayload = tempUnschedEnabled.value
|
||||
? buildTempUnschedRules(tempUnschedRules.value)
|
||||
: []
|
||||
if (tempUnschedEnabled.value && tempUnschedPayload.length === 0) {
|
||||
appStore.showError(t('admin.accounts.tempUnschedulable.rulesInvalid'))
|
||||
return
|
||||
}
|
||||
|
||||
const endpoint =
|
||||
addMethod.value === 'oauth'
|
||||
? '/admin/accounts/cookie-auth'
|
||||
@@ -2156,6 +2464,10 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
...tokenInfo,
|
||||
...(interceptWarmupRequests.value ? { intercept_warmup_requests: true } : {})
|
||||
}
|
||||
if (tempUnschedEnabled.value) {
|
||||
credentials.temp_unschedulable_enabled = true
|
||||
credentials.temp_unschedulable_rules = tempUnschedPayload
|
||||
}
|
||||
|
||||
await adminAPI.accounts.create({
|
||||
name: accountName,
|
||||
|
||||
@@ -293,7 +293,7 @@
|
||||
<!-- Manual input -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="customErrorCodeInput"
|
||||
v-model.number="customErrorCodeInput"
|
||||
type="number"
|
||||
min="100"
|
||||
max="599"
|
||||
@@ -373,6 +373,175 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Temp Unschedulable Rules -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.tempUnschedulable.title') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.tempUnschedulable.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="tempUnschedEnabled = !tempUnschedEnabled"
|
||||
: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',
|
||||
tempUnschedEnabled ? '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',
|
||||
tempUnschedEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tempUnschedEnabled" class="space-y-3">
|
||||
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.tempUnschedulable.notice') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in tempUnschedPresets"
|
||||
:key="preset.label"
|
||||
type="button"
|
||||
@click="addTempUnschedRule(preset.rule)"
|
||||
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
|
||||
>
|
||||
+ {{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tempUnschedRules.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="(rule, index) in tempUnschedRules"
|
||||
:key="index"
|
||||
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
|
||||
>
|
||||
<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.tempUnschedulable.ruleIndex', { index: index + 1 }) }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="index === 0"
|
||||
@click="moveTempUnschedRule(index, -1)"
|
||||
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="index === tempUnschedRules.length - 1"
|
||||
@click="moveTempUnschedRule(index, 1)"
|
||||
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeTempUnschedRule(index)"
|
||||
class="rounded p-1 text-red-500 transition-colors hover:text-red-600"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.errorCode') }}</label>
|
||||
<input
|
||||
v-model.number="rule.error_code"
|
||||
type="number"
|
||||
min="100"
|
||||
max="599"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.tempUnschedulable.errorCodePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.durationMinutes') }}</label>
|
||||
<input
|
||||
v-model.number="rule.duration_minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.tempUnschedulable.durationPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.keywords') }}</label>
|
||||
<input
|
||||
v-model="rule.keywords"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.tempUnschedulable.keywordsPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.tempUnschedulable.keywordsHint') }}</p>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="input-label">{{ t('admin.accounts.tempUnschedulable.description') }}</label>
|
||||
<input
|
||||
v-model="rule.description"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.tempUnschedulable.descriptionPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="addTempUnschedRule()"
|
||||
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-sm text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ t('admin.accounts.tempUnschedulable.addRule') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Intercept Warmup Requests (Anthropic only) -->
|
||||
<div
|
||||
v-if="account?.platform === 'anthropic'"
|
||||
@@ -563,6 +732,13 @@ interface ModelMapping {
|
||||
to: string
|
||||
}
|
||||
|
||||
interface TempUnschedRuleForm {
|
||||
error_code: number | null
|
||||
keywords: string
|
||||
duration_minutes: number | null
|
||||
description: string
|
||||
}
|
||||
|
||||
// State
|
||||
const submitting = ref(false)
|
||||
const editBaseUrl = ref('https://api.anthropic.com')
|
||||
@@ -575,9 +751,40 @@ const selectedErrorCodes = ref<number[]>([])
|
||||
const customErrorCodeInput = ref<number | null>(null)
|
||||
const interceptWarmupRequests = ref(false)
|
||||
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
|
||||
// Computed: current preset mappings based on platform
|
||||
const presetMappings = computed(() => getPresetMappingsByPlatform(props.account?.platform || 'anthropic'))
|
||||
const tempUnschedPresets = computed(() => [
|
||||
{
|
||||
label: t('admin.accounts.tempUnschedulable.presets.overloadLabel'),
|
||||
rule: {
|
||||
error_code: 529,
|
||||
keywords: 'overloaded, too many',
|
||||
duration_minutes: 60,
|
||||
description: t('admin.accounts.tempUnschedulable.presets.overloadDesc')
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('admin.accounts.tempUnschedulable.presets.rateLimitLabel'),
|
||||
rule: {
|
||||
error_code: 429,
|
||||
keywords: 'rate limit, too many requests',
|
||||
duration_minutes: 10,
|
||||
description: t('admin.accounts.tempUnschedulable.presets.rateLimitDesc')
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('admin.accounts.tempUnschedulable.presets.unavailableLabel'),
|
||||
rule: {
|
||||
error_code: 503,
|
||||
keywords: 'unavailable, maintenance',
|
||||
duration_minutes: 30,
|
||||
description: t('admin.accounts.tempUnschedulable.presets.unavailableDesc')
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
// Computed: default base URL based on platform
|
||||
const defaultBaseUrl = computed(() => {
|
||||
@@ -620,6 +827,8 @@ watch(
|
||||
const extra = newAccount.extra as Record<string, unknown> | undefined
|
||||
mixedScheduling.value = extra?.mixed_scheduling === true
|
||||
|
||||
loadTempUnschedRules(credentials)
|
||||
|
||||
// Initialize API Key fields for apikey type
|
||||
if (newAccount.type === 'apikey' && newAccount.credentials) {
|
||||
const credentials = newAccount.credentials as Record<string, unknown>
|
||||
@@ -736,6 +945,130 @@ const removeErrorCode = (code: number) => {
|
||||
}
|
||||
}
|
||||
|
||||
const addTempUnschedRule = (preset?: TempUnschedRuleForm) => {
|
||||
if (preset) {
|
||||
tempUnschedRules.value.push({ ...preset })
|
||||
return
|
||||
}
|
||||
tempUnschedRules.value.push({
|
||||
error_code: null,
|
||||
keywords: '',
|
||||
duration_minutes: 30,
|
||||
description: ''
|
||||
})
|
||||
}
|
||||
|
||||
const removeTempUnschedRule = (index: number) => {
|
||||
tempUnschedRules.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const moveTempUnschedRule = (index: number, direction: number) => {
|
||||
const target = index + direction
|
||||
if (target < 0 || target >= tempUnschedRules.value.length) return
|
||||
const rules = tempUnschedRules.value
|
||||
const current = rules[index]
|
||||
rules[index] = rules[target]
|
||||
rules[target] = current
|
||||
}
|
||||
|
||||
const buildTempUnschedRules = (rules: TempUnschedRuleForm[]) => {
|
||||
const out: Array<{
|
||||
error_code: number
|
||||
keywords: string[]
|
||||
duration_minutes: number
|
||||
description: string
|
||||
}> = []
|
||||
|
||||
for (const rule of rules) {
|
||||
const errorCode = Number(rule.error_code)
|
||||
const duration = Number(rule.duration_minutes)
|
||||
const keywords = splitTempUnschedKeywords(rule.keywords)
|
||||
if (!Number.isFinite(errorCode) || errorCode < 100 || errorCode > 599) {
|
||||
continue
|
||||
}
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
continue
|
||||
}
|
||||
if (keywords.length === 0) {
|
||||
continue
|
||||
}
|
||||
out.push({
|
||||
error_code: Math.trunc(errorCode),
|
||||
keywords,
|
||||
duration_minutes: Math.trunc(duration),
|
||||
description: rule.description.trim()
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const applyTempUnschedConfig = (credentials: Record<string, unknown>) => {
|
||||
if (!tempUnschedEnabled.value) {
|
||||
delete credentials.temp_unschedulable_enabled
|
||||
delete credentials.temp_unschedulable_rules
|
||||
return true
|
||||
}
|
||||
|
||||
const rules = buildTempUnschedRules(tempUnschedRules.value)
|
||||
if (rules.length === 0) {
|
||||
appStore.showError(t('admin.accounts.tempUnschedulable.rulesInvalid'))
|
||||
return false
|
||||
}
|
||||
|
||||
credentials.temp_unschedulable_enabled = true
|
||||
credentials.temp_unschedulable_rules = rules
|
||||
return true
|
||||
}
|
||||
|
||||
function loadTempUnschedRules(credentials?: Record<string, unknown>) {
|
||||
tempUnschedEnabled.value = credentials?.temp_unschedulable_enabled === true
|
||||
const rawRules = credentials?.temp_unschedulable_rules
|
||||
if (!Array.isArray(rawRules)) {
|
||||
tempUnschedRules.value = []
|
||||
return
|
||||
}
|
||||
|
||||
tempUnschedRules.value = rawRules.map((rule) => {
|
||||
const entry = rule as Record<string, unknown>
|
||||
return {
|
||||
error_code: toPositiveNumber(entry.error_code),
|
||||
keywords: formatTempUnschedKeywords(entry.keywords),
|
||||
duration_minutes: toPositiveNumber(entry.duration_minutes),
|
||||
description: typeof entry.description === 'string' ? entry.description : ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function formatTempUnschedKeywords(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
.join(', ')
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const splitTempUnschedKeywords = (value: string) => {
|
||||
return value
|
||||
.split(/[,;]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
}
|
||||
|
||||
function toPositiveNumber(value: unknown) {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num) || num <= 0) {
|
||||
return null
|
||||
}
|
||||
return Math.trunc(num)
|
||||
}
|
||||
|
||||
// Methods
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
@@ -788,6 +1121,11 @@ const handleSubmit = async () => {
|
||||
newCredentials.intercept_warmup_requests = true
|
||||
}
|
||||
|
||||
if (!applyTempUnschedConfig(newCredentials)) {
|
||||
submitting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
updatePayload.credentials = newCredentials
|
||||
} else {
|
||||
// For oauth/setup-token types, only update intercept_warmup_requests if changed
|
||||
@@ -800,6 +1138,11 @@ const handleSubmit = async () => {
|
||||
delete newCredentials.intercept_warmup_requests
|
||||
}
|
||||
|
||||
if (!applyTempUnschedConfig(newCredentials)) {
|
||||
submitting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
updatePayload.credentials = newCredentials
|
||||
}
|
||||
|
||||
|
||||
249
frontend/src/components/account/TempUnschedStatusModal.vue
Normal file
249
frontend/src/components/account/TempUnschedStatusModal.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.accounts.tempUnschedulable.statusTitle')"
|
||||
width="normal"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<svg class="h-6 w-6 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isActive" class="rounded-lg border border-gray-200 p-4 text-sm text-gray-500 dark:border-dark-600 dark:text-gray-400">
|
||||
{{ t('admin.accounts.tempUnschedulable.notActive') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div class="rounded-lg border border-gray-200 p-4 dark:border-dark-600">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.tempUnschedulable.accountName') }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ account?.name || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.tempUnschedulable.triggeredAt') }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ triggeredAtText }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.tempUnschedulable.until') }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ untilText }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.tempUnschedulable.remaining') }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ remainingText }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.tempUnschedulable.errorCode') }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ state?.status_code || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.tempUnschedulable.matchedKeyword') }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ state?.matched_keyword || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.tempUnschedulable.ruleOrder') }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ ruleIndexDisplay }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.tempUnschedulable.errorMessage') }}
|
||||
</p>
|
||||
<div class="mt-2 rounded bg-gray-50 p-2 text-xs text-gray-700 dark:bg-dark-700 dark:text-gray-300">
|
||||
{{ state?.error_message || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class="btn btn-secondary" @click="handleClose">
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!isActive || resetting"
|
||||
@click="handleReset"
|
||||
>
|
||||
<svg
|
||||
v-if="resetting"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{{ t('admin.accounts.tempUnschedulable.reset') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, TempUnschedulableStatus } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
account: Account | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const resetting = ref(false)
|
||||
const status = ref<TempUnschedulableStatus | null>(null)
|
||||
|
||||
const state = computed(() => status.value?.state || null)
|
||||
|
||||
const isActive = computed(() => {
|
||||
if (!status.value?.active || !state.value) return false
|
||||
return state.value.until_unix * 1000 > Date.now()
|
||||
})
|
||||
|
||||
const ruleIndexDisplay = computed(() => {
|
||||
if (!state.value) return '-'
|
||||
return state.value.rule_index + 1
|
||||
})
|
||||
|
||||
const triggeredAtText = computed(() => {
|
||||
if (!state.value?.triggered_at_unix) return '-'
|
||||
return formatDateTime(new Date(state.value.triggered_at_unix * 1000))
|
||||
})
|
||||
|
||||
const untilText = computed(() => {
|
||||
if (!state.value?.until_unix) return '-'
|
||||
return formatDateTime(new Date(state.value.until_unix * 1000))
|
||||
})
|
||||
|
||||
const remainingText = computed(() => {
|
||||
if (!state.value) return '-'
|
||||
const remainingMs = state.value.until_unix * 1000 - Date.now()
|
||||
if (remainingMs <= 0) {
|
||||
return t('admin.accounts.tempUnschedulable.expired')
|
||||
}
|
||||
const minutes = Math.ceil(remainingMs / 60000)
|
||||
if (minutes < 60) {
|
||||
return t('admin.accounts.tempUnschedulable.remainingMinutes', { minutes })
|
||||
}
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const rest = minutes % 60
|
||||
if (rest === 0) {
|
||||
return t('admin.accounts.tempUnschedulable.remainingHours', { hours })
|
||||
}
|
||||
return t('admin.accounts.tempUnschedulable.remainingHoursMinutes', { hours, minutes: rest })
|
||||
})
|
||||
|
||||
const loadStatus = async () => {
|
||||
if (!props.account) return
|
||||
loading.value = true
|
||||
try {
|
||||
status.value = await adminAPI.accounts.getTempUnschedulableStatus(props.account.id)
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.tempUnschedulable.failedToLoad'))
|
||||
status.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleReset = async () => {
|
||||
if (!props.account) return
|
||||
resetting.value = true
|
||||
try {
|
||||
await adminAPI.accounts.resetTempUnschedulable(props.account.id)
|
||||
appStore.showSuccess(t('admin.accounts.tempUnschedulable.resetSuccess'))
|
||||
emit('reset')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.accounts.tempUnschedulable.resetFailed'))
|
||||
} finally {
|
||||
resetting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.show, props.account?.id],
|
||||
([visible]) => {
|
||||
if (visible && props.account) {
|
||||
loadStatus()
|
||||
return
|
||||
}
|
||||
status.value = null
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -9,4 +9,5 @@ export { default as UsageProgressBar } from './UsageProgressBar.vue'
|
||||
export { default as AccountStatsModal } from './AccountStatsModal.vue'
|
||||
export { default as AccountTestModal } from './AccountTestModal.vue'
|
||||
export { default as AccountTodayStatsCell } from './AccountTodayStatsCell.vue'
|
||||
export { default as TempUnschedStatusModal } from './TempUnschedStatusModal.vue'
|
||||
export { default as SyncFromCrsModal } from './SyncFromCrsModal.vue'
|
||||
|
||||
@@ -330,6 +330,27 @@ export interface GeminiCredentials {
|
||||
expires_at?: string
|
||||
}
|
||||
|
||||
export interface TempUnschedulableRule {
|
||||
error_code: number
|
||||
keywords: string[]
|
||||
duration_minutes: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface TempUnschedulableState {
|
||||
until_unix: number
|
||||
triggered_at_unix: number
|
||||
status_code: number
|
||||
matched_keyword: string
|
||||
rule_index: number
|
||||
error_message: string
|
||||
}
|
||||
|
||||
export interface TempUnschedulableStatus {
|
||||
active: boolean
|
||||
state?: TempUnschedulableState
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: number
|
||||
name: string
|
||||
@@ -355,6 +376,8 @@ export interface Account {
|
||||
rate_limited_at: string | null
|
||||
rate_limit_reset_at: string | null
|
||||
overload_until: string | null
|
||||
temp_unschedulable_until: string | null
|
||||
temp_unschedulable_reason: string | null
|
||||
|
||||
// Session window fields (5-hour window)
|
||||
session_window_start: string | null
|
||||
@@ -376,6 +399,12 @@ export interface UsageProgress {
|
||||
window_stats?: WindowStats | null // 窗口期统计(从窗口开始到当前的使用量)
|
||||
}
|
||||
|
||||
// Antigravity 单个模型的配额信息
|
||||
export interface AntigravityModelQuota {
|
||||
utilization: number // 使用率 0-100
|
||||
reset_time: string // 重置时间 ISO8601
|
||||
}
|
||||
|
||||
export interface AccountUsageInfo {
|
||||
updated_at: string | null
|
||||
five_hour: UsageProgress | null
|
||||
@@ -383,6 +412,7 @@ export interface AccountUsageInfo {
|
||||
seven_day_sonnet: UsageProgress | null
|
||||
gemini_pro_daily?: UsageProgress | null
|
||||
gemini_flash_daily?: UsageProgress | null
|
||||
antigravity_quota?: Record<string, AntigravityModelQuota> | null
|
||||
}
|
||||
|
||||
// OpenAI Codex usage snapshot (from response headers)
|
||||
@@ -418,6 +448,7 @@ export interface CreateAccountRequest {
|
||||
concurrency?: number
|
||||
priority?: number
|
||||
group_ids?: number[]
|
||||
confirm_mixed_channel_risk?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateAccountRequest {
|
||||
@@ -430,6 +461,7 @@ export interface UpdateAccountRequest {
|
||||
priority?: number
|
||||
status?: 'active' | 'inactive'
|
||||
group_ids?: number[]
|
||||
confirm_mixed_channel_risk?: boolean
|
||||
}
|
||||
|
||||
export interface CreateProxyRequest {
|
||||
@@ -619,7 +651,7 @@ export interface UserUsageTrendPoint {
|
||||
actual_cost: number // 实际扣除
|
||||
}
|
||||
|
||||
export interface APIKeyUsageTrendPoint {
|
||||
export interface ApiKeyUsageTrendPoint {
|
||||
date: string
|
||||
api_key_id: number
|
||||
key_name: string
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ row }">
|
||||
<AccountStatusIndicator :account="row" />
|
||||
<AccountStatusIndicator :account="row" @show-temp-unsched="handleShowTempUnsched" />
|
||||
</template>
|
||||
|
||||
<template #cell-schedulable="{ row }">
|
||||
@@ -400,6 +400,14 @@
|
||||
<!-- Account Stats Modal -->
|
||||
<AccountStatsModal :show="showStatsModal" :account="statsAccount" @close="closeStatsModal" />
|
||||
|
||||
<!-- Temp Unschedulable Status Modal -->
|
||||
<TempUnschedStatusModal
|
||||
:show="showTempUnschedModal"
|
||||
:account="tempUnschedAccount"
|
||||
@close="closeTempUnschedModal"
|
||||
@reset="handleTempUnschedReset"
|
||||
/>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
@@ -512,6 +520,7 @@ import {
|
||||
BulkEditAccountModal,
|
||||
ReAuthAccountModal,
|
||||
AccountStatsModal,
|
||||
TempUnschedStatusModal,
|
||||
SyncFromCrsModal
|
||||
} from '@/components/account'
|
||||
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
|
||||
@@ -604,6 +613,7 @@ const showDeleteDialog = ref(false)
|
||||
const showBulkDeleteDialog = ref(false)
|
||||
const showTestModal = ref(false)
|
||||
const showStatsModal = ref(false)
|
||||
const showTempUnschedModal = ref(false)
|
||||
const showCrsSyncModal = ref(false)
|
||||
const showBulkEditModal = ref(false)
|
||||
const editingAccount = ref<Account | null>(null)
|
||||
@@ -611,6 +621,7 @@ const reAuthAccount = ref<Account | null>(null)
|
||||
const deletingAccount = ref<Account | null>(null)
|
||||
const testingAccount = ref<Account | null>(null)
|
||||
const statsAccount = ref<Account | null>(null)
|
||||
const tempUnschedAccount = ref<Account | null>(null)
|
||||
const togglingSchedulable = ref<number | null>(null)
|
||||
const bulkDeleting = ref(false)
|
||||
|
||||
@@ -775,6 +786,21 @@ const closeReAuthModal = () => {
|
||||
reAuthAccount.value = null
|
||||
}
|
||||
|
||||
// Temp unschedulable modal
|
||||
const handleShowTempUnsched = (account: Account) => {
|
||||
tempUnschedAccount.value = account
|
||||
showTempUnschedModal.value = true
|
||||
}
|
||||
|
||||
const closeTempUnschedModal = () => {
|
||||
showTempUnschedModal.value = false
|
||||
tempUnschedAccount.value = null
|
||||
}
|
||||
|
||||
const handleTempUnschedReset = () => {
|
||||
loadAccounts()
|
||||
}
|
||||
|
||||
// Token refresh
|
||||
const handleRefreshToken = async (account: Account) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user