Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
6901b64f
Commit
6901b64f
authored
Jan 17, 2026
by
cyhhao
Browse files
merge: sync upstream changes
parents
32c47b15
dae0d532
Changes
189
Hide whitespace changes
Inline
Side-by-side
backend/ent/usagelog/usagelog.go
View file @
6901b64f
...
...
@@ -54,6 +54,8 @@ const (
FieldActualCost
=
"actual_cost"
// FieldRateMultiplier holds the string denoting the rate_multiplier field in the database.
FieldRateMultiplier
=
"rate_multiplier"
// FieldAccountRateMultiplier holds the string denoting the account_rate_multiplier field in the database.
FieldAccountRateMultiplier
=
"account_rate_multiplier"
// FieldBillingType holds the string denoting the billing_type field in the database.
FieldBillingType
=
"billing_type"
// FieldStream holds the string denoting the stream field in the database.
...
...
@@ -144,6 +146,7 @@ var Columns = []string{
FieldTotalCost
,
FieldActualCost
,
FieldRateMultiplier
,
FieldAccountRateMultiplier
,
FieldBillingType
,
FieldStream
,
FieldDurationMs
,
...
...
@@ -320,6 +323,11 @@ func ByRateMultiplier(opts ...sql.OrderTermOption) OrderOption {
return
sql
.
OrderByField
(
FieldRateMultiplier
,
opts
...
)
.
ToFunc
()
}
// ByAccountRateMultiplier orders the results by the account_rate_multiplier field.
func
ByAccountRateMultiplier
(
opts
...
sql
.
OrderTermOption
)
OrderOption
{
return
sql
.
OrderByField
(
FieldAccountRateMultiplier
,
opts
...
)
.
ToFunc
()
}
// ByBillingType orders the results by the billing_type field.
func
ByBillingType
(
opts
...
sql
.
OrderTermOption
)
OrderOption
{
return
sql
.
OrderByField
(
FieldBillingType
,
opts
...
)
.
ToFunc
()
...
...
backend/ent/usagelog/where.go
View file @
6901b64f
...
...
@@ -155,6 +155,11 @@ func RateMultiplier(v float64) predicate.UsageLog {
return
predicate
.
UsageLog
(
sql
.
FieldEQ
(
FieldRateMultiplier
,
v
))
}
// AccountRateMultiplier applies equality check predicate on the "account_rate_multiplier" field. It's identical to AccountRateMultiplierEQ.
func
AccountRateMultiplier
(
v
float64
)
predicate
.
UsageLog
{
return
predicate
.
UsageLog
(
sql
.
FieldEQ
(
FieldAccountRateMultiplier
,
v
))
}
// BillingType applies equality check predicate on the "billing_type" field. It's identical to BillingTypeEQ.
func
BillingType
(
v
int8
)
predicate
.
UsageLog
{
return
predicate
.
UsageLog
(
sql
.
FieldEQ
(
FieldBillingType
,
v
))
...
...
@@ -970,6 +975,56 @@ func RateMultiplierLTE(v float64) predicate.UsageLog {
return
predicate
.
UsageLog
(
sql
.
FieldLTE
(
FieldRateMultiplier
,
v
))
}
// AccountRateMultiplierEQ applies the EQ predicate on the "account_rate_multiplier" field.
func
AccountRateMultiplierEQ
(
v
float64
)
predicate
.
UsageLog
{
return
predicate
.
UsageLog
(
sql
.
FieldEQ
(
FieldAccountRateMultiplier
,
v
))
}
// AccountRateMultiplierNEQ applies the NEQ predicate on the "account_rate_multiplier" field.
func
AccountRateMultiplierNEQ
(
v
float64
)
predicate
.
UsageLog
{
return
predicate
.
UsageLog
(
sql
.
FieldNEQ
(
FieldAccountRateMultiplier
,
v
))
}
// AccountRateMultiplierIn applies the In predicate on the "account_rate_multiplier" field.
func
AccountRateMultiplierIn
(
vs
...
float64
)
predicate
.
UsageLog
{
return
predicate
.
UsageLog
(
sql
.
FieldIn
(
FieldAccountRateMultiplier
,
vs
...
))
}
// AccountRateMultiplierNotIn applies the NotIn predicate on the "account_rate_multiplier" field.
func
AccountRateMultiplierNotIn
(
vs
...
float64
)
predicate
.
UsageLog
{
return
predicate
.
UsageLog
(
sql
.
FieldNotIn
(
FieldAccountRateMultiplier
,
vs
...
))
}
// AccountRateMultiplierGT applies the GT predicate on the "account_rate_multiplier" field.
func
AccountRateMultiplierGT
(
v
float64
)
predicate
.
UsageLog
{
return
predicate
.
UsageLog
(
sql
.
FieldGT
(
FieldAccountRateMultiplier
,
v
))
}
// AccountRateMultiplierGTE applies the GTE predicate on the "account_rate_multiplier" field.
func
AccountRateMultiplierGTE
(
v
float64
)
predicate
.
UsageLog
{
return
predicate
.
UsageLog
(
sql
.
FieldGTE
(
FieldAccountRateMultiplier
,
v
))
}
// AccountRateMultiplierLT applies the LT predicate on the "account_rate_multiplier" field.
func
AccountRateMultiplierLT
(
v
float64
)
predicate
.
UsageLog
{
return
predicate
.
UsageLog
(
sql
.
FieldLT
(
FieldAccountRateMultiplier
,
v
))
}
// AccountRateMultiplierLTE applies the LTE predicate on the "account_rate_multiplier" field.
func
AccountRateMultiplierLTE
(
v
float64
)
predicate
.
UsageLog
{
return
predicate
.
UsageLog
(
sql
.
FieldLTE
(
FieldAccountRateMultiplier
,
v
))
}
// AccountRateMultiplierIsNil applies the IsNil predicate on the "account_rate_multiplier" field.
func
AccountRateMultiplierIsNil
()
predicate
.
UsageLog
{
return
predicate
.
UsageLog
(
sql
.
FieldIsNull
(
FieldAccountRateMultiplier
))
}
// AccountRateMultiplierNotNil applies the NotNil predicate on the "account_rate_multiplier" field.
func
AccountRateMultiplierNotNil
()
predicate
.
UsageLog
{
return
predicate
.
UsageLog
(
sql
.
FieldNotNull
(
FieldAccountRateMultiplier
))
}
// BillingTypeEQ applies the EQ predicate on the "billing_type" field.
func
BillingTypeEQ
(
v
int8
)
predicate
.
UsageLog
{
return
predicate
.
UsageLog
(
sql
.
FieldEQ
(
FieldBillingType
,
v
))
...
...
backend/ent/usagelog_create.go
View file @
6901b64f
...
...
@@ -267,6 +267,20 @@ func (_c *UsageLogCreate) SetNillableRateMultiplier(v *float64) *UsageLogCreate
return
_c
}
// SetAccountRateMultiplier sets the "account_rate_multiplier" field.
func
(
_c
*
UsageLogCreate
)
SetAccountRateMultiplier
(
v
float64
)
*
UsageLogCreate
{
_c
.
mutation
.
SetAccountRateMultiplier
(
v
)
return
_c
}
// SetNillableAccountRateMultiplier sets the "account_rate_multiplier" field if the given value is not nil.
func
(
_c
*
UsageLogCreate
)
SetNillableAccountRateMultiplier
(
v
*
float64
)
*
UsageLogCreate
{
if
v
!=
nil
{
_c
.
SetAccountRateMultiplier
(
*
v
)
}
return
_c
}
// SetBillingType sets the "billing_type" field.
func
(
_c
*
UsageLogCreate
)
SetBillingType
(
v
int8
)
*
UsageLogCreate
{
_c
.
mutation
.
SetBillingType
(
v
)
...
...
@@ -712,6 +726,10 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) {
_spec
.
SetField
(
usagelog
.
FieldRateMultiplier
,
field
.
TypeFloat64
,
value
)
_node
.
RateMultiplier
=
value
}
if
value
,
ok
:=
_c
.
mutation
.
AccountRateMultiplier
();
ok
{
_spec
.
SetField
(
usagelog
.
FieldAccountRateMultiplier
,
field
.
TypeFloat64
,
value
)
_node
.
AccountRateMultiplier
=
&
value
}
if
value
,
ok
:=
_c
.
mutation
.
BillingType
();
ok
{
_spec
.
SetField
(
usagelog
.
FieldBillingType
,
field
.
TypeInt8
,
value
)
_node
.
BillingType
=
value
...
...
@@ -1215,6 +1233,30 @@ func (u *UsageLogUpsert) AddRateMultiplier(v float64) *UsageLogUpsert {
return
u
}
// SetAccountRateMultiplier sets the "account_rate_multiplier" field.
func
(
u
*
UsageLogUpsert
)
SetAccountRateMultiplier
(
v
float64
)
*
UsageLogUpsert
{
u
.
Set
(
usagelog
.
FieldAccountRateMultiplier
,
v
)
return
u
}
// UpdateAccountRateMultiplier sets the "account_rate_multiplier" field to the value that was provided on create.
func
(
u
*
UsageLogUpsert
)
UpdateAccountRateMultiplier
()
*
UsageLogUpsert
{
u
.
SetExcluded
(
usagelog
.
FieldAccountRateMultiplier
)
return
u
}
// AddAccountRateMultiplier adds v to the "account_rate_multiplier" field.
func
(
u
*
UsageLogUpsert
)
AddAccountRateMultiplier
(
v
float64
)
*
UsageLogUpsert
{
u
.
Add
(
usagelog
.
FieldAccountRateMultiplier
,
v
)
return
u
}
// ClearAccountRateMultiplier clears the value of the "account_rate_multiplier" field.
func
(
u
*
UsageLogUpsert
)
ClearAccountRateMultiplier
()
*
UsageLogUpsert
{
u
.
SetNull
(
usagelog
.
FieldAccountRateMultiplier
)
return
u
}
// SetBillingType sets the "billing_type" field.
func
(
u
*
UsageLogUpsert
)
SetBillingType
(
v
int8
)
*
UsageLogUpsert
{
u
.
Set
(
usagelog
.
FieldBillingType
,
v
)
...
...
@@ -1795,6 +1837,34 @@ func (u *UsageLogUpsertOne) UpdateRateMultiplier() *UsageLogUpsertOne {
})
}
// SetAccountRateMultiplier sets the "account_rate_multiplier" field.
func
(
u
*
UsageLogUpsertOne
)
SetAccountRateMultiplier
(
v
float64
)
*
UsageLogUpsertOne
{
return
u
.
Update
(
func
(
s
*
UsageLogUpsert
)
{
s
.
SetAccountRateMultiplier
(
v
)
})
}
// AddAccountRateMultiplier adds v to the "account_rate_multiplier" field.
func
(
u
*
UsageLogUpsertOne
)
AddAccountRateMultiplier
(
v
float64
)
*
UsageLogUpsertOne
{
return
u
.
Update
(
func
(
s
*
UsageLogUpsert
)
{
s
.
AddAccountRateMultiplier
(
v
)
})
}
// UpdateAccountRateMultiplier sets the "account_rate_multiplier" field to the value that was provided on create.
func
(
u
*
UsageLogUpsertOne
)
UpdateAccountRateMultiplier
()
*
UsageLogUpsertOne
{
return
u
.
Update
(
func
(
s
*
UsageLogUpsert
)
{
s
.
UpdateAccountRateMultiplier
()
})
}
// ClearAccountRateMultiplier clears the value of the "account_rate_multiplier" field.
func
(
u
*
UsageLogUpsertOne
)
ClearAccountRateMultiplier
()
*
UsageLogUpsertOne
{
return
u
.
Update
(
func
(
s
*
UsageLogUpsert
)
{
s
.
ClearAccountRateMultiplier
()
})
}
// SetBillingType sets the "billing_type" field.
func
(
u
*
UsageLogUpsertOne
)
SetBillingType
(
v
int8
)
*
UsageLogUpsertOne
{
return
u
.
Update
(
func
(
s
*
UsageLogUpsert
)
{
...
...
@@ -2566,6 +2636,34 @@ func (u *UsageLogUpsertBulk) UpdateRateMultiplier() *UsageLogUpsertBulk {
})
}
// SetAccountRateMultiplier sets the "account_rate_multiplier" field.
func
(
u
*
UsageLogUpsertBulk
)
SetAccountRateMultiplier
(
v
float64
)
*
UsageLogUpsertBulk
{
return
u
.
Update
(
func
(
s
*
UsageLogUpsert
)
{
s
.
SetAccountRateMultiplier
(
v
)
})
}
// AddAccountRateMultiplier adds v to the "account_rate_multiplier" field.
func
(
u
*
UsageLogUpsertBulk
)
AddAccountRateMultiplier
(
v
float64
)
*
UsageLogUpsertBulk
{
return
u
.
Update
(
func
(
s
*
UsageLogUpsert
)
{
s
.
AddAccountRateMultiplier
(
v
)
})
}
// UpdateAccountRateMultiplier sets the "account_rate_multiplier" field to the value that was provided on create.
func
(
u
*
UsageLogUpsertBulk
)
UpdateAccountRateMultiplier
()
*
UsageLogUpsertBulk
{
return
u
.
Update
(
func
(
s
*
UsageLogUpsert
)
{
s
.
UpdateAccountRateMultiplier
()
})
}
// ClearAccountRateMultiplier clears the value of the "account_rate_multiplier" field.
func
(
u
*
UsageLogUpsertBulk
)
ClearAccountRateMultiplier
()
*
UsageLogUpsertBulk
{
return
u
.
Update
(
func
(
s
*
UsageLogUpsert
)
{
s
.
ClearAccountRateMultiplier
()
})
}
// SetBillingType sets the "billing_type" field.
func
(
u
*
UsageLogUpsertBulk
)
SetBillingType
(
v
int8
)
*
UsageLogUpsertBulk
{
return
u
.
Update
(
func
(
s
*
UsageLogUpsert
)
{
...
...
backend/ent/usagelog_update.go
View file @
6901b64f
...
...
@@ -415,6 +415,33 @@ func (_u *UsageLogUpdate) AddRateMultiplier(v float64) *UsageLogUpdate {
return
_u
}
// SetAccountRateMultiplier sets the "account_rate_multiplier" field.
func
(
_u
*
UsageLogUpdate
)
SetAccountRateMultiplier
(
v
float64
)
*
UsageLogUpdate
{
_u
.
mutation
.
ResetAccountRateMultiplier
()
_u
.
mutation
.
SetAccountRateMultiplier
(
v
)
return
_u
}
// SetNillableAccountRateMultiplier sets the "account_rate_multiplier" field if the given value is not nil.
func
(
_u
*
UsageLogUpdate
)
SetNillableAccountRateMultiplier
(
v
*
float64
)
*
UsageLogUpdate
{
if
v
!=
nil
{
_u
.
SetAccountRateMultiplier
(
*
v
)
}
return
_u
}
// AddAccountRateMultiplier adds value to the "account_rate_multiplier" field.
func
(
_u
*
UsageLogUpdate
)
AddAccountRateMultiplier
(
v
float64
)
*
UsageLogUpdate
{
_u
.
mutation
.
AddAccountRateMultiplier
(
v
)
return
_u
}
// ClearAccountRateMultiplier clears the value of the "account_rate_multiplier" field.
func
(
_u
*
UsageLogUpdate
)
ClearAccountRateMultiplier
()
*
UsageLogUpdate
{
_u
.
mutation
.
ClearAccountRateMultiplier
()
return
_u
}
// SetBillingType sets the "billing_type" field.
func
(
_u
*
UsageLogUpdate
)
SetBillingType
(
v
int8
)
*
UsageLogUpdate
{
_u
.
mutation
.
ResetBillingType
()
...
...
@@ -807,6 +834,15 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if
value
,
ok
:=
_u
.
mutation
.
AddedRateMultiplier
();
ok
{
_spec
.
AddField
(
usagelog
.
FieldRateMultiplier
,
field
.
TypeFloat64
,
value
)
}
if
value
,
ok
:=
_u
.
mutation
.
AccountRateMultiplier
();
ok
{
_spec
.
SetField
(
usagelog
.
FieldAccountRateMultiplier
,
field
.
TypeFloat64
,
value
)
}
if
value
,
ok
:=
_u
.
mutation
.
AddedAccountRateMultiplier
();
ok
{
_spec
.
AddField
(
usagelog
.
FieldAccountRateMultiplier
,
field
.
TypeFloat64
,
value
)
}
if
_u
.
mutation
.
AccountRateMultiplierCleared
()
{
_spec
.
ClearField
(
usagelog
.
FieldAccountRateMultiplier
,
field
.
TypeFloat64
)
}
if
value
,
ok
:=
_u
.
mutation
.
BillingType
();
ok
{
_spec
.
SetField
(
usagelog
.
FieldBillingType
,
field
.
TypeInt8
,
value
)
}
...
...
@@ -1406,6 +1442,33 @@ func (_u *UsageLogUpdateOne) AddRateMultiplier(v float64) *UsageLogUpdateOne {
return
_u
}
// SetAccountRateMultiplier sets the "account_rate_multiplier" field.
func
(
_u
*
UsageLogUpdateOne
)
SetAccountRateMultiplier
(
v
float64
)
*
UsageLogUpdateOne
{
_u
.
mutation
.
ResetAccountRateMultiplier
()
_u
.
mutation
.
SetAccountRateMultiplier
(
v
)
return
_u
}
// SetNillableAccountRateMultiplier sets the "account_rate_multiplier" field if the given value is not nil.
func
(
_u
*
UsageLogUpdateOne
)
SetNillableAccountRateMultiplier
(
v
*
float64
)
*
UsageLogUpdateOne
{
if
v
!=
nil
{
_u
.
SetAccountRateMultiplier
(
*
v
)
}
return
_u
}
// AddAccountRateMultiplier adds value to the "account_rate_multiplier" field.
func
(
_u
*
UsageLogUpdateOne
)
AddAccountRateMultiplier
(
v
float64
)
*
UsageLogUpdateOne
{
_u
.
mutation
.
AddAccountRateMultiplier
(
v
)
return
_u
}
// ClearAccountRateMultiplier clears the value of the "account_rate_multiplier" field.
func
(
_u
*
UsageLogUpdateOne
)
ClearAccountRateMultiplier
()
*
UsageLogUpdateOne
{
_u
.
mutation
.
ClearAccountRateMultiplier
()
return
_u
}
// SetBillingType sets the "billing_type" field.
func
(
_u
*
UsageLogUpdateOne
)
SetBillingType
(
v
int8
)
*
UsageLogUpdateOne
{
_u
.
mutation
.
ResetBillingType
()
...
...
@@ -1828,6 +1891,15 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err
if
value
,
ok
:=
_u
.
mutation
.
AddedRateMultiplier
();
ok
{
_spec
.
AddField
(
usagelog
.
FieldRateMultiplier
,
field
.
TypeFloat64
,
value
)
}
if
value
,
ok
:=
_u
.
mutation
.
AccountRateMultiplier
();
ok
{
_spec
.
SetField
(
usagelog
.
FieldAccountRateMultiplier
,
field
.
TypeFloat64
,
value
)
}
if
value
,
ok
:=
_u
.
mutation
.
AddedAccountRateMultiplier
();
ok
{
_spec
.
AddField
(
usagelog
.
FieldAccountRateMultiplier
,
field
.
TypeFloat64
,
value
)
}
if
_u
.
mutation
.
AccountRateMultiplierCleared
()
{
_spec
.
ClearField
(
usagelog
.
FieldAccountRateMultiplier
,
field
.
TypeFloat64
)
}
if
value
,
ok
:=
_u
.
mutation
.
BillingType
();
ok
{
_spec
.
SetField
(
usagelog
.
FieldBillingType
,
field
.
TypeInt8
,
value
)
}
...
...
backend/internal/config/config.go
View file @
6901b64f
...
...
@@ -19,7 +19,9 @@ const (
RunModeSimple
=
"simple"
)
const
DefaultCSPPolicy
=
"default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
// DefaultCSPPolicy is the default Content-Security-Policy with nonce support
// __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware
const
DefaultCSPPolicy
=
"default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
// 连接池隔离策略常量
// 用于控制上游 HTTP 连接池的隔离粒度,影响连接复用和资源消耗
...
...
@@ -232,6 +234,10 @@ type GatewayConfig struct {
// ConcurrencySlotTTLMinutes: 并发槽位过期时间(分钟)
// 应大于最长 LLM 请求时间,防止请求完成前槽位过期
ConcurrencySlotTTLMinutes
int
`mapstructure:"concurrency_slot_ttl_minutes"`
// SessionIdleTimeoutMinutes: 会话空闲超时时间(分钟),默认 5 分钟
// 用于 Anthropic OAuth/SetupToken 账号的会话数量限制功能
// 空闲超过此时间的会话将被自动释放
SessionIdleTimeoutMinutes
int
`mapstructure:"session_idle_timeout_minutes"`
// StreamDataIntervalTimeout: 流数据间隔超时(秒),0表示禁用
StreamDataIntervalTimeout
int
`mapstructure:"stream_data_interval_timeout"`
...
...
backend/internal/handler/admin/account_handler.go
View file @
6901b64f
...
...
@@ -44,6 +44,7 @@ type AccountHandler struct {
accountTestService
*
service
.
AccountTestService
concurrencyService
*
service
.
ConcurrencyService
crsSyncService
*
service
.
CRSSyncService
sessionLimitCache
service
.
SessionLimitCache
}
// NewAccountHandler creates a new admin account handler
...
...
@@ -58,6 +59,7 @@ func NewAccountHandler(
accountTestService
*
service
.
AccountTestService
,
concurrencyService
*
service
.
ConcurrencyService
,
crsSyncService
*
service
.
CRSSyncService
,
sessionLimitCache
service
.
SessionLimitCache
,
)
*
AccountHandler
{
return
&
AccountHandler
{
adminService
:
adminService
,
...
...
@@ -70,6 +72,7 @@ func NewAccountHandler(
accountTestService
:
accountTestService
,
concurrencyService
:
concurrencyService
,
crsSyncService
:
crsSyncService
,
sessionLimitCache
:
sessionLimitCache
,
}
}
...
...
@@ -84,6 +87,7 @@ type CreateAccountRequest struct {
ProxyID
*
int64
`json:"proxy_id"`
Concurrency
int
`json:"concurrency"`
Priority
int
`json:"priority"`
RateMultiplier
*
float64
`json:"rate_multiplier"`
GroupIDs
[]
int64
`json:"group_ids"`
ExpiresAt
*
int64
`json:"expires_at"`
AutoPauseOnExpired
*
bool
`json:"auto_pause_on_expired"`
...
...
@@ -101,6 +105,7 @@ type UpdateAccountRequest struct {
ProxyID
*
int64
`json:"proxy_id"`
Concurrency
*
int
`json:"concurrency"`
Priority
*
int
`json:"priority"`
RateMultiplier
*
float64
`json:"rate_multiplier"`
Status
string
`json:"status" binding:"omitempty,oneof=active inactive"`
GroupIDs
*
[]
int64
`json:"group_ids"`
ExpiresAt
*
int64
`json:"expires_at"`
...
...
@@ -115,6 +120,7 @@ type BulkUpdateAccountsRequest struct {
ProxyID
*
int64
`json:"proxy_id"`
Concurrency
*
int
`json:"concurrency"`
Priority
*
int
`json:"priority"`
RateMultiplier
*
float64
`json:"rate_multiplier"`
Status
string
`json:"status" binding:"omitempty,oneof=active inactive error"`
Schedulable
*
bool
`json:"schedulable"`
GroupIDs
*
[]
int64
`json:"group_ids"`
...
...
@@ -127,6 +133,9 @@ type BulkUpdateAccountsRequest struct {
type
AccountWithConcurrency
struct
{
*
dto
.
Account
CurrentConcurrency
int
`json:"current_concurrency"`
// 以下字段仅对 Anthropic OAuth/SetupToken 账号有效,且仅在启用相应功能时返回
CurrentWindowCost
*
float64
`json:"current_window_cost,omitempty"`
// 当前窗口费用
ActiveSessions
*
int
`json:"active_sessions,omitempty"`
// 当前活跃会话数
}
// List handles listing all accounts with pagination
...
...
@@ -161,13 +170,89 @@ func (h *AccountHandler) List(c *gin.Context) {
concurrencyCounts
=
make
(
map
[
int64
]
int
)
}
// 识别需要查询窗口费用和会话数的账号(Anthropic OAuth/SetupToken 且启用了相应功能)
windowCostAccountIDs
:=
make
([]
int64
,
0
)
sessionLimitAccountIDs
:=
make
([]
int64
,
0
)
for
i
:=
range
accounts
{
acc
:=
&
accounts
[
i
]
if
acc
.
IsAnthropicOAuthOrSetupToken
()
{
if
acc
.
GetWindowCostLimit
()
>
0
{
windowCostAccountIDs
=
append
(
windowCostAccountIDs
,
acc
.
ID
)
}
if
acc
.
GetMaxSessions
()
>
0
{
sessionLimitAccountIDs
=
append
(
sessionLimitAccountIDs
,
acc
.
ID
)
}
}
}
// 并行获取窗口费用和活跃会话数
var
windowCosts
map
[
int64
]
float64
var
activeSessions
map
[
int64
]
int
// 获取活跃会话数(批量查询)
if
len
(
sessionLimitAccountIDs
)
>
0
&&
h
.
sessionLimitCache
!=
nil
{
activeSessions
,
_
=
h
.
sessionLimitCache
.
GetActiveSessionCountBatch
(
c
.
Request
.
Context
(),
sessionLimitAccountIDs
)
if
activeSessions
==
nil
{
activeSessions
=
make
(
map
[
int64
]
int
)
}
}
// 获取窗口费用(并行查询)
if
len
(
windowCostAccountIDs
)
>
0
{
windowCosts
=
make
(
map
[
int64
]
float64
)
var
mu
sync
.
Mutex
g
,
gctx
:=
errgroup
.
WithContext
(
c
.
Request
.
Context
())
g
.
SetLimit
(
10
)
// 限制并发数
for
i
:=
range
accounts
{
acc
:=
&
accounts
[
i
]
if
!
acc
.
IsAnthropicOAuthOrSetupToken
()
||
acc
.
GetWindowCostLimit
()
<=
0
{
continue
}
accCopy
:=
acc
// 闭包捕获
g
.
Go
(
func
()
error
{
var
startTime
time
.
Time
if
accCopy
.
SessionWindowStart
!=
nil
{
startTime
=
*
accCopy
.
SessionWindowStart
}
else
{
startTime
=
time
.
Now
()
.
Add
(
-
5
*
time
.
Hour
)
}
stats
,
err
:=
h
.
accountUsageService
.
GetAccountWindowStats
(
gctx
,
accCopy
.
ID
,
startTime
)
if
err
==
nil
&&
stats
!=
nil
{
mu
.
Lock
()
windowCosts
[
accCopy
.
ID
]
=
stats
.
StandardCost
// 使用标准费用
mu
.
Unlock
()
}
return
nil
// 不返回错误,允许部分失败
})
}
_
=
g
.
Wait
()
}
// Build response with concurrency info
result
:=
make
([]
AccountWithConcurrency
,
len
(
accounts
))
for
i
:=
range
accounts
{
result
[
i
]
=
AccountWithConcurrency
{
Account
:
dto
.
AccountFromService
(
&
accounts
[
i
]),
CurrentConcurrency
:
concurrencyCounts
[
accounts
[
i
]
.
ID
],
acc
:=
&
accounts
[
i
]
item
:=
AccountWithConcurrency
{
Account
:
dto
.
AccountFromService
(
acc
),
CurrentConcurrency
:
concurrencyCounts
[
acc
.
ID
],
}
// 添加窗口费用(仅当启用时)
if
windowCosts
!=
nil
{
if
cost
,
ok
:=
windowCosts
[
acc
.
ID
];
ok
{
item
.
CurrentWindowCost
=
&
cost
}
}
// 添加活跃会话数(仅当启用时)
if
activeSessions
!=
nil
{
if
count
,
ok
:=
activeSessions
[
acc
.
ID
];
ok
{
item
.
ActiveSessions
=
&
count
}
}
result
[
i
]
=
item
}
response
.
Paginated
(
c
,
result
,
total
,
page
,
pageSize
)
...
...
@@ -199,6 +284,10 @@ func (h *AccountHandler) Create(c *gin.Context) {
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
if
req
.
RateMultiplier
!=
nil
&&
*
req
.
RateMultiplier
<
0
{
response
.
BadRequest
(
c
,
"rate_multiplier must be >= 0"
)
return
}
// 确定是否跳过混合渠道检查
skipCheck
:=
req
.
ConfirmMixedChannelRisk
!=
nil
&&
*
req
.
ConfirmMixedChannelRisk
...
...
@@ -213,6 +302,7 @@ func (h *AccountHandler) Create(c *gin.Context) {
ProxyID
:
req
.
ProxyID
,
Concurrency
:
req
.
Concurrency
,
Priority
:
req
.
Priority
,
RateMultiplier
:
req
.
RateMultiplier
,
GroupIDs
:
req
.
GroupIDs
,
ExpiresAt
:
req
.
ExpiresAt
,
AutoPauseOnExpired
:
req
.
AutoPauseOnExpired
,
...
...
@@ -258,6 +348,10 @@ func (h *AccountHandler) Update(c *gin.Context) {
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
if
req
.
RateMultiplier
!=
nil
&&
*
req
.
RateMultiplier
<
0
{
response
.
BadRequest
(
c
,
"rate_multiplier must be >= 0"
)
return
}
// 确定是否跳过混合渠道检查
skipCheck
:=
req
.
ConfirmMixedChannelRisk
!=
nil
&&
*
req
.
ConfirmMixedChannelRisk
...
...
@@ -271,6 +365,7 @@ func (h *AccountHandler) Update(c *gin.Context) {
ProxyID
:
req
.
ProxyID
,
Concurrency
:
req
.
Concurrency
,
// 指针类型,nil 表示未提供
Priority
:
req
.
Priority
,
// 指针类型,nil 表示未提供
RateMultiplier
:
req
.
RateMultiplier
,
Status
:
req
.
Status
,
GroupIDs
:
req
.
GroupIDs
,
ExpiresAt
:
req
.
ExpiresAt
,
...
...
@@ -652,6 +747,10 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
if
req
.
RateMultiplier
!=
nil
&&
*
req
.
RateMultiplier
<
0
{
response
.
BadRequest
(
c
,
"rate_multiplier must be >= 0"
)
return
}
// 确定是否跳过混合渠道检查
skipCheck
:=
req
.
ConfirmMixedChannelRisk
!=
nil
&&
*
req
.
ConfirmMixedChannelRisk
...
...
@@ -660,6 +759,7 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
req
.
ProxyID
!=
nil
||
req
.
Concurrency
!=
nil
||
req
.
Priority
!=
nil
||
req
.
RateMultiplier
!=
nil
||
req
.
Status
!=
""
||
req
.
Schedulable
!=
nil
||
req
.
GroupIDs
!=
nil
||
...
...
@@ -677,6 +777,7 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
ProxyID
:
req
.
ProxyID
,
Concurrency
:
req
.
Concurrency
,
Priority
:
req
.
Priority
,
RateMultiplier
:
req
.
RateMultiplier
,
Status
:
req
.
Status
,
Schedulable
:
req
.
Schedulable
,
GroupIDs
:
req
.
GroupIDs
,
...
...
backend/internal/handler/admin/dashboard_handler.go
View file @
6901b64f
...
...
@@ -186,13 +186,16 @@ func (h *DashboardHandler) GetRealtimeMetrics(c *gin.Context) {
// GetUsageTrend handles getting usage trend data
// GET /api/v1/admin/dashboard/trend
// Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour), user_id, api_key_id
// Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour), user_id, api_key_id
, model, account_id, group_id, stream
func
(
h
*
DashboardHandler
)
GetUsageTrend
(
c
*
gin
.
Context
)
{
startTime
,
endTime
:=
parseTimeRange
(
c
)
granularity
:=
c
.
DefaultQuery
(
"granularity"
,
"day"
)
// Parse optional filter params
var
userID
,
apiKeyID
int64
var
userID
,
apiKeyID
,
accountID
,
groupID
int64
var
model
string
var
stream
*
bool
if
userIDStr
:=
c
.
Query
(
"user_id"
);
userIDStr
!=
""
{
if
id
,
err
:=
strconv
.
ParseInt
(
userIDStr
,
10
,
64
);
err
==
nil
{
userID
=
id
...
...
@@ -203,8 +206,26 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
apiKeyID
=
id
}
}
if
accountIDStr
:=
c
.
Query
(
"account_id"
);
accountIDStr
!=
""
{
if
id
,
err
:=
strconv
.
ParseInt
(
accountIDStr
,
10
,
64
);
err
==
nil
{
accountID
=
id
}
}
if
groupIDStr
:=
c
.
Query
(
"group_id"
);
groupIDStr
!=
""
{
if
id
,
err
:=
strconv
.
ParseInt
(
groupIDStr
,
10
,
64
);
err
==
nil
{
groupID
=
id
}
}
if
modelStr
:=
c
.
Query
(
"model"
);
modelStr
!=
""
{
model
=
modelStr
}
if
streamStr
:=
c
.
Query
(
"stream"
);
streamStr
!=
""
{
if
streamVal
,
err
:=
strconv
.
ParseBool
(
streamStr
);
err
==
nil
{
stream
=
&
streamVal
}
}
trend
,
err
:=
h
.
dashboardService
.
GetUsageTrendWithFilters
(
c
.
Request
.
Context
(),
startTime
,
endTime
,
granularity
,
userID
,
apiKeyID
)
trend
,
err
:=
h
.
dashboardService
.
GetUsageTrendWithFilters
(
c
.
Request
.
Context
(),
startTime
,
endTime
,
granularity
,
userID
,
apiKeyID
,
accountID
,
groupID
,
model
,
stream
)
if
err
!=
nil
{
response
.
Error
(
c
,
500
,
"Failed to get usage trend"
)
return
...
...
@@ -220,12 +241,14 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
// GetModelStats handles getting model usage statistics
// GET /api/v1/admin/dashboard/models
// Query params: start_date, end_date (YYYY-MM-DD), user_id, api_key_id
// Query params: start_date, end_date (YYYY-MM-DD), user_id, api_key_id
, account_id, group_id, stream
func
(
h
*
DashboardHandler
)
GetModelStats
(
c
*
gin
.
Context
)
{
startTime
,
endTime
:=
parseTimeRange
(
c
)
// Parse optional filter params
var
userID
,
apiKeyID
int64
var
userID
,
apiKeyID
,
accountID
,
groupID
int64
var
stream
*
bool
if
userIDStr
:=
c
.
Query
(
"user_id"
);
userIDStr
!=
""
{
if
id
,
err
:=
strconv
.
ParseInt
(
userIDStr
,
10
,
64
);
err
==
nil
{
userID
=
id
...
...
@@ -236,8 +259,23 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
apiKeyID
=
id
}
}
if
accountIDStr
:=
c
.
Query
(
"account_id"
);
accountIDStr
!=
""
{
if
id
,
err
:=
strconv
.
ParseInt
(
accountIDStr
,
10
,
64
);
err
==
nil
{
accountID
=
id
}
}
if
groupIDStr
:=
c
.
Query
(
"group_id"
);
groupIDStr
!=
""
{
if
id
,
err
:=
strconv
.
ParseInt
(
groupIDStr
,
10
,
64
);
err
==
nil
{
groupID
=
id
}
}
if
streamStr
:=
c
.
Query
(
"stream"
);
streamStr
!=
""
{
if
streamVal
,
err
:=
strconv
.
ParseBool
(
streamStr
);
err
==
nil
{
stream
=
&
streamVal
}
}
stats
,
err
:=
h
.
dashboardService
.
GetModelStatsWithFilters
(
c
.
Request
.
Context
(),
startTime
,
endTime
,
userID
,
apiKeyID
)
stats
,
err
:=
h
.
dashboardService
.
GetModelStatsWithFilters
(
c
.
Request
.
Context
(),
startTime
,
endTime
,
userID
,
apiKeyID
,
accountID
,
groupID
,
stream
)
if
err
!=
nil
{
response
.
Error
(
c
,
500
,
"Failed to get model statistics"
)
return
...
...
backend/internal/handler/admin/group_handler.go
View file @
6901b64f
...
...
@@ -40,6 +40,9 @@ type CreateGroupRequest struct {
ImagePrice4K
*
float64
`json:"image_price_4k"`
ClaudeCodeOnly
bool
`json:"claude_code_only"`
FallbackGroupID
*
int64
`json:"fallback_group_id"`
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting
map
[
string
][]
int64
`json:"model_routing"`
ModelRoutingEnabled
bool
`json:"model_routing_enabled"`
}
// UpdateGroupRequest represents update group request
...
...
@@ -60,6 +63,9 @@ type UpdateGroupRequest struct {
ImagePrice4K
*
float64
`json:"image_price_4k"`
ClaudeCodeOnly
*
bool
`json:"claude_code_only"`
FallbackGroupID
*
int64
`json:"fallback_group_id"`
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting
map
[
string
][]
int64
`json:"model_routing"`
ModelRoutingEnabled
*
bool
`json:"model_routing_enabled"`
}
// List handles listing all groups with pagination
...
...
@@ -149,20 +155,22 @@ func (h *GroupHandler) Create(c *gin.Context) {
}
group
,
err
:=
h
.
adminService
.
CreateGroup
(
c
.
Request
.
Context
(),
&
service
.
CreateGroupInput
{
Name
:
req
.
Name
,
Description
:
req
.
Description
,
Platform
:
req
.
Platform
,
RateMultiplier
:
req
.
RateMultiplier
,
IsExclusive
:
req
.
IsExclusive
,
SubscriptionType
:
req
.
SubscriptionType
,
DailyLimitUSD
:
req
.
DailyLimitUSD
,
WeeklyLimitUSD
:
req
.
WeeklyLimitUSD
,
MonthlyLimitUSD
:
req
.
MonthlyLimitUSD
,
ImagePrice1K
:
req
.
ImagePrice1K
,
ImagePrice2K
:
req
.
ImagePrice2K
,
ImagePrice4K
:
req
.
ImagePrice4K
,
ClaudeCodeOnly
:
req
.
ClaudeCodeOnly
,
FallbackGroupID
:
req
.
FallbackGroupID
,
Name
:
req
.
Name
,
Description
:
req
.
Description
,
Platform
:
req
.
Platform
,
RateMultiplier
:
req
.
RateMultiplier
,
IsExclusive
:
req
.
IsExclusive
,
SubscriptionType
:
req
.
SubscriptionType
,
DailyLimitUSD
:
req
.
DailyLimitUSD
,
WeeklyLimitUSD
:
req
.
WeeklyLimitUSD
,
MonthlyLimitUSD
:
req
.
MonthlyLimitUSD
,
ImagePrice1K
:
req
.
ImagePrice1K
,
ImagePrice2K
:
req
.
ImagePrice2K
,
ImagePrice4K
:
req
.
ImagePrice4K
,
ClaudeCodeOnly
:
req
.
ClaudeCodeOnly
,
FallbackGroupID
:
req
.
FallbackGroupID
,
ModelRouting
:
req
.
ModelRouting
,
ModelRoutingEnabled
:
req
.
ModelRoutingEnabled
,
})
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
...
...
@@ -188,21 +196,23 @@ func (h *GroupHandler) Update(c *gin.Context) {
}
group
,
err
:=
h
.
adminService
.
UpdateGroup
(
c
.
Request
.
Context
(),
groupID
,
&
service
.
UpdateGroupInput
{
Name
:
req
.
Name
,
Description
:
req
.
Description
,
Platform
:
req
.
Platform
,
RateMultiplier
:
req
.
RateMultiplier
,
IsExclusive
:
req
.
IsExclusive
,
Status
:
req
.
Status
,
SubscriptionType
:
req
.
SubscriptionType
,
DailyLimitUSD
:
req
.
DailyLimitUSD
,
WeeklyLimitUSD
:
req
.
WeeklyLimitUSD
,
MonthlyLimitUSD
:
req
.
MonthlyLimitUSD
,
ImagePrice1K
:
req
.
ImagePrice1K
,
ImagePrice2K
:
req
.
ImagePrice2K
,
ImagePrice4K
:
req
.
ImagePrice4K
,
ClaudeCodeOnly
:
req
.
ClaudeCodeOnly
,
FallbackGroupID
:
req
.
FallbackGroupID
,
Name
:
req
.
Name
,
Description
:
req
.
Description
,
Platform
:
req
.
Platform
,
RateMultiplier
:
req
.
RateMultiplier
,
IsExclusive
:
req
.
IsExclusive
,
Status
:
req
.
Status
,
SubscriptionType
:
req
.
SubscriptionType
,
DailyLimitUSD
:
req
.
DailyLimitUSD
,
WeeklyLimitUSD
:
req
.
WeeklyLimitUSD
,
MonthlyLimitUSD
:
req
.
MonthlyLimitUSD
,
ImagePrice1K
:
req
.
ImagePrice1K
,
ImagePrice2K
:
req
.
ImagePrice2K
,
ImagePrice4K
:
req
.
ImagePrice4K
,
ClaudeCodeOnly
:
req
.
ClaudeCodeOnly
,
FallbackGroupID
:
req
.
FallbackGroupID
,
ModelRouting
:
req
.
ModelRouting
,
ModelRoutingEnabled
:
req
.
ModelRoutingEnabled
,
})
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
...
...
backend/internal/handler/admin/ops_alerts_handler.go
View file @
6901b64f
...
...
@@ -7,8 +7,10 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
...
...
@@ -18,8 +20,6 @@ var validOpsAlertMetricTypes = []string{
"success_rate"
,
"error_rate"
,
"upstream_error_rate"
,
"p95_latency_ms"
,
"p99_latency_ms"
,
"cpu_usage_percent"
,
"memory_usage_percent"
,
"concurrency_queue_depth"
,
...
...
@@ -372,8 +372,135 @@ func (h *OpsHandler) DeleteAlertRule(c *gin.Context) {
response
.
Success
(
c
,
gin
.
H
{
"deleted"
:
true
})
}
// GetAlertEvent returns a single ops alert event.
// GET /api/v1/admin/ops/alert-events/:id
func
(
h
*
OpsHandler
)
GetAlertEvent
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
}
if
err
:=
h
.
opsService
.
RequireMonitoringEnabled
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
id
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
||
id
<=
0
{
response
.
BadRequest
(
c
,
"Invalid event ID"
)
return
}
ev
,
err
:=
h
.
opsService
.
GetAlertEventByID
(
c
.
Request
.
Context
(),
id
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
ev
)
}
// UpdateAlertEventStatus updates an ops alert event status.
// PUT /api/v1/admin/ops/alert-events/:id/status
func
(
h
*
OpsHandler
)
UpdateAlertEventStatus
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
}
if
err
:=
h
.
opsService
.
RequireMonitoringEnabled
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
id
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
||
id
<=
0
{
response
.
BadRequest
(
c
,
"Invalid event ID"
)
return
}
var
payload
struct
{
Status
string
`json:"status"`
}
if
err
:=
c
.
ShouldBindJSON
(
&
payload
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request body"
)
return
}
payload
.
Status
=
strings
.
TrimSpace
(
payload
.
Status
)
if
payload
.
Status
==
""
{
response
.
BadRequest
(
c
,
"Invalid status"
)
return
}
if
payload
.
Status
!=
service
.
OpsAlertStatusResolved
&&
payload
.
Status
!=
service
.
OpsAlertStatusManualResolved
{
response
.
BadRequest
(
c
,
"Invalid status"
)
return
}
var
resolvedAt
*
time
.
Time
if
payload
.
Status
==
service
.
OpsAlertStatusResolved
||
payload
.
Status
==
service
.
OpsAlertStatusManualResolved
{
now
:=
time
.
Now
()
.
UTC
()
resolvedAt
=
&
now
}
if
err
:=
h
.
opsService
.
UpdateAlertEventStatus
(
c
.
Request
.
Context
(),
id
,
payload
.
Status
,
resolvedAt
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"updated"
:
true
})
}
// ListAlertEvents lists recent ops alert events.
// GET /api/v1/admin/ops/alert-events
// CreateAlertSilence creates a scoped silence for ops alerts.
// POST /api/v1/admin/ops/alert-silences
func
(
h
*
OpsHandler
)
CreateAlertSilence
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
}
if
err
:=
h
.
opsService
.
RequireMonitoringEnabled
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
var
payload
struct
{
RuleID
int64
`json:"rule_id"`
Platform
string
`json:"platform"`
GroupID
*
int64
`json:"group_id"`
Region
*
string
`json:"region"`
Until
string
`json:"until"`
Reason
string
`json:"reason"`
}
if
err
:=
c
.
ShouldBindJSON
(
&
payload
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request body"
)
return
}
until
,
err
:=
time
.
Parse
(
time
.
RFC3339
,
strings
.
TrimSpace
(
payload
.
Until
))
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid until"
)
return
}
createdBy
:=
(
*
int64
)(
nil
)
if
subject
,
ok
:=
middleware
.
GetAuthSubjectFromContext
(
c
);
ok
{
uid
:=
subject
.
UserID
createdBy
=
&
uid
}
silence
:=
&
service
.
OpsAlertSilence
{
RuleID
:
payload
.
RuleID
,
Platform
:
strings
.
TrimSpace
(
payload
.
Platform
),
GroupID
:
payload
.
GroupID
,
Region
:
payload
.
Region
,
Until
:
until
,
Reason
:
strings
.
TrimSpace
(
payload
.
Reason
),
CreatedBy
:
createdBy
,
}
created
,
err
:=
h
.
opsService
.
CreateAlertSilence
(
c
.
Request
.
Context
(),
silence
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
created
)
}
func
(
h
*
OpsHandler
)
ListAlertEvents
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
...
...
@@ -384,7 +511,7 @@ func (h *OpsHandler) ListAlertEvents(c *gin.Context) {
return
}
limit
:=
10
0
limit
:=
2
0
if
raw
:=
strings
.
TrimSpace
(
c
.
Query
(
"limit"
));
raw
!=
""
{
n
,
err
:=
strconv
.
Atoi
(
raw
)
if
err
!=
nil
||
n
<=
0
{
...
...
@@ -400,6 +527,49 @@ func (h *OpsHandler) ListAlertEvents(c *gin.Context) {
Severity
:
strings
.
TrimSpace
(
c
.
Query
(
"severity"
)),
}
if
v
:=
strings
.
TrimSpace
(
c
.
Query
(
"email_sent"
));
v
!=
""
{
vv
:=
strings
.
ToLower
(
v
)
switch
vv
{
case
"true"
,
"1"
:
b
:=
true
filter
.
EmailSent
=
&
b
case
"false"
,
"0"
:
b
:=
false
filter
.
EmailSent
=
&
b
default
:
response
.
BadRequest
(
c
,
"Invalid email_sent"
)
return
}
}
// Cursor pagination: both params must be provided together.
rawTS
:=
strings
.
TrimSpace
(
c
.
Query
(
"before_fired_at"
))
rawID
:=
strings
.
TrimSpace
(
c
.
Query
(
"before_id"
))
if
(
rawTS
==
""
)
!=
(
rawID
==
""
)
{
response
.
BadRequest
(
c
,
"before_fired_at and before_id must be provided together"
)
return
}
if
rawTS
!=
""
{
ts
,
err
:=
time
.
Parse
(
time
.
RFC3339Nano
,
rawTS
)
if
err
!=
nil
{
if
t2
,
err2
:=
time
.
Parse
(
time
.
RFC3339
,
rawTS
);
err2
==
nil
{
ts
=
t2
}
else
{
response
.
BadRequest
(
c
,
"Invalid before_fired_at"
)
return
}
}
filter
.
BeforeFiredAt
=
&
ts
}
if
rawID
!=
""
{
id
,
err
:=
strconv
.
ParseInt
(
rawID
,
10
,
64
)
if
err
!=
nil
||
id
<=
0
{
response
.
BadRequest
(
c
,
"Invalid before_id"
)
return
}
filter
.
BeforeID
=
&
id
}
// Optional global filter support (platform/group/time range).
if
platform
:=
strings
.
TrimSpace
(
c
.
Query
(
"platform"
));
platform
!=
""
{
filter
.
Platform
=
platform
...
...
backend/internal/handler/admin/ops_handler.go
View file @
6901b64f
...
...
@@ -19,6 +19,57 @@ type OpsHandler struct {
opsService
*
service
.
OpsService
}
// GetErrorLogByID returns ops error log detail.
// GET /api/v1/admin/ops/errors/:id
func
(
h
*
OpsHandler
)
GetErrorLogByID
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
}
if
err
:=
h
.
opsService
.
RequireMonitoringEnabled
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
idStr
:=
strings
.
TrimSpace
(
c
.
Param
(
"id"
))
id
,
err
:=
strconv
.
ParseInt
(
idStr
,
10
,
64
)
if
err
!=
nil
||
id
<=
0
{
response
.
BadRequest
(
c
,
"Invalid error id"
)
return
}
detail
,
err
:=
h
.
opsService
.
GetErrorLogByID
(
c
.
Request
.
Context
(),
id
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
detail
)
}
const
(
opsListViewErrors
=
"errors"
opsListViewExcluded
=
"excluded"
opsListViewAll
=
"all"
)
func
parseOpsViewParam
(
c
*
gin
.
Context
)
string
{
if
c
==
nil
{
return
""
}
v
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
c
.
Query
(
"view"
)))
switch
v
{
case
""
,
opsListViewErrors
:
return
opsListViewErrors
case
opsListViewExcluded
:
return
opsListViewExcluded
case
opsListViewAll
:
return
opsListViewAll
default
:
return
opsListViewErrors
}
}
func
NewOpsHandler
(
opsService
*
service
.
OpsService
)
*
OpsHandler
{
return
&
OpsHandler
{
opsService
:
opsService
}
}
...
...
@@ -47,16 +98,26 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
return
}
filter
:=
&
service
.
OpsErrorLogFilter
{
Page
:
page
,
PageSize
:
pageSize
,
}
filter
:=
&
service
.
OpsErrorLogFilter
{
Page
:
page
,
PageSize
:
pageSize
}
if
!
startTime
.
IsZero
()
{
filter
.
StartTime
=
&
startTime
}
if
!
endTime
.
IsZero
()
{
filter
.
EndTime
=
&
endTime
}
filter
.
View
=
parseOpsViewParam
(
c
)
filter
.
Phase
=
strings
.
TrimSpace
(
c
.
Query
(
"phase"
))
filter
.
Owner
=
strings
.
TrimSpace
(
c
.
Query
(
"error_owner"
))
filter
.
Source
=
strings
.
TrimSpace
(
c
.
Query
(
"error_source"
))
filter
.
Query
=
strings
.
TrimSpace
(
c
.
Query
(
"q"
))
filter
.
UserQuery
=
strings
.
TrimSpace
(
c
.
Query
(
"user_query"
))
// Force request errors: client-visible status >= 400.
// buildOpsErrorLogsWhere already applies this for non-upstream phase.
if
strings
.
EqualFold
(
strings
.
TrimSpace
(
filter
.
Phase
),
"upstream"
)
{
filter
.
Phase
=
""
}
if
platform
:=
strings
.
TrimSpace
(
c
.
Query
(
"platform"
));
platform
!=
""
{
filter
.
Platform
=
platform
...
...
@@ -77,11 +138,19 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
}
filter
.
AccountID
=
&
id
}
if
phase
:=
strings
.
TrimSpace
(
c
.
Query
(
"phase"
));
phase
!=
""
{
filter
.
Phase
=
phase
}
if
q
:=
strings
.
TrimSpace
(
c
.
Query
(
"q"
));
q
!=
""
{
filter
.
Query
=
q
if
v
:=
strings
.
TrimSpace
(
c
.
Query
(
"resolved"
));
v
!=
""
{
switch
strings
.
ToLower
(
v
)
{
case
"1"
,
"true"
,
"yes"
:
b
:=
true
filter
.
Resolved
=
&
b
case
"0"
,
"false"
,
"no"
:
b
:=
false
filter
.
Resolved
=
&
b
default
:
response
.
BadRequest
(
c
,
"Invalid resolved"
)
return
}
}
if
statusCodesStr
:=
strings
.
TrimSpace
(
c
.
Query
(
"status_codes"
));
statusCodesStr
!=
""
{
parts
:=
strings
.
Split
(
statusCodesStr
,
","
)
...
...
@@ -106,13 +175,120 @@ func (h *OpsHandler) GetErrorLogs(c *gin.Context) {
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Paginated
(
c
,
result
.
Errors
,
int64
(
result
.
Total
),
result
.
Page
,
result
.
PageSize
)
}
// ListRequestErrors lists client-visible request errors.
// GET /api/v1/admin/ops/request-errors
func
(
h
*
OpsHandler
)
ListRequestErrors
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
}
if
err
:=
h
.
opsService
.
RequireMonitoringEnabled
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
if
pageSize
>
500
{
pageSize
=
500
}
startTime
,
endTime
,
err
:=
parseOpsTimeRange
(
c
,
"1h"
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
filter
:=
&
service
.
OpsErrorLogFilter
{
Page
:
page
,
PageSize
:
pageSize
}
if
!
startTime
.
IsZero
()
{
filter
.
StartTime
=
&
startTime
}
if
!
endTime
.
IsZero
()
{
filter
.
EndTime
=
&
endTime
}
filter
.
View
=
parseOpsViewParam
(
c
)
filter
.
Phase
=
strings
.
TrimSpace
(
c
.
Query
(
"phase"
))
filter
.
Owner
=
strings
.
TrimSpace
(
c
.
Query
(
"error_owner"
))
filter
.
Source
=
strings
.
TrimSpace
(
c
.
Query
(
"error_source"
))
filter
.
Query
=
strings
.
TrimSpace
(
c
.
Query
(
"q"
))
filter
.
UserQuery
=
strings
.
TrimSpace
(
c
.
Query
(
"user_query"
))
// Force request errors: client-visible status >= 400.
// buildOpsErrorLogsWhere already applies this for non-upstream phase.
if
strings
.
EqualFold
(
strings
.
TrimSpace
(
filter
.
Phase
),
"upstream"
)
{
filter
.
Phase
=
""
}
if
platform
:=
strings
.
TrimSpace
(
c
.
Query
(
"platform"
));
platform
!=
""
{
filter
.
Platform
=
platform
}
if
v
:=
strings
.
TrimSpace
(
c
.
Query
(
"group_id"
));
v
!=
""
{
id
,
err
:=
strconv
.
ParseInt
(
v
,
10
,
64
)
if
err
!=
nil
||
id
<=
0
{
response
.
BadRequest
(
c
,
"Invalid group_id"
)
return
}
filter
.
GroupID
=
&
id
}
if
v
:=
strings
.
TrimSpace
(
c
.
Query
(
"account_id"
));
v
!=
""
{
id
,
err
:=
strconv
.
ParseInt
(
v
,
10
,
64
)
if
err
!=
nil
||
id
<=
0
{
response
.
BadRequest
(
c
,
"Invalid account_id"
)
return
}
filter
.
AccountID
=
&
id
}
if
v
:=
strings
.
TrimSpace
(
c
.
Query
(
"resolved"
));
v
!=
""
{
switch
strings
.
ToLower
(
v
)
{
case
"1"
,
"true"
,
"yes"
:
b
:=
true
filter
.
Resolved
=
&
b
case
"0"
,
"false"
,
"no"
:
b
:=
false
filter
.
Resolved
=
&
b
default
:
response
.
BadRequest
(
c
,
"Invalid resolved"
)
return
}
}
if
statusCodesStr
:=
strings
.
TrimSpace
(
c
.
Query
(
"status_codes"
));
statusCodesStr
!=
""
{
parts
:=
strings
.
Split
(
statusCodesStr
,
","
)
out
:=
make
([]
int
,
0
,
len
(
parts
))
for
_
,
part
:=
range
parts
{
p
:=
strings
.
TrimSpace
(
part
)
if
p
==
""
{
continue
}
n
,
err
:=
strconv
.
Atoi
(
p
)
if
err
!=
nil
||
n
<
0
{
response
.
BadRequest
(
c
,
"Invalid status_codes"
)
return
}
out
=
append
(
out
,
n
)
}
filter
.
StatusCodes
=
out
}
result
,
err
:=
h
.
opsService
.
GetErrorLogs
(
c
.
Request
.
Context
(),
filter
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Paginated
(
c
,
result
.
Errors
,
int64
(
result
.
Total
),
result
.
Page
,
result
.
PageSize
)
}
// GetErrorLogByID returns a single error log detail.
// GET /api/v1/admin/ops/errors/:id
func
(
h
*
OpsHandler
)
GetErrorLogByID
(
c
*
gin
.
Context
)
{
// GetRequestError returns request error detail.
// GET /api/v1/admin/ops/request-errors/:id
func
(
h
*
OpsHandler
)
GetRequestError
(
c
*
gin
.
Context
)
{
// same storage; just proxy to existing detail
h
.
GetErrorLogByID
(
c
)
}
// ListRequestErrorUpstreamErrors lists upstream error logs correlated to a request error.
// GET /api/v1/admin/ops/request-errors/:id/upstream-errors
func
(
h
*
OpsHandler
)
ListRequestErrorUpstreamErrors
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
...
...
@@ -129,15 +305,306 @@ func (h *OpsHandler) GetErrorLogByID(c *gin.Context) {
return
}
// Load request error to get correlation keys.
detail
,
err
:=
h
.
opsService
.
GetErrorLogByID
(
c
.
Request
.
Context
(),
id
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
detail
)
// Correlate by request_id/client_request_id.
requestID
:=
strings
.
TrimSpace
(
detail
.
RequestID
)
clientRequestID
:=
strings
.
TrimSpace
(
detail
.
ClientRequestID
)
if
requestID
==
""
&&
clientRequestID
==
""
{
response
.
Paginated
(
c
,
[]
*
service
.
OpsErrorLog
{},
0
,
1
,
10
)
return
}
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
if
pageSize
>
500
{
pageSize
=
500
}
// Keep correlation window wide enough so linked upstream errors
// are discoverable even when UI defaults to 1h elsewhere.
startTime
,
endTime
,
err
:=
parseOpsTimeRange
(
c
,
"30d"
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
filter
:=
&
service
.
OpsErrorLogFilter
{
Page
:
page
,
PageSize
:
pageSize
}
if
!
startTime
.
IsZero
()
{
filter
.
StartTime
=
&
startTime
}
if
!
endTime
.
IsZero
()
{
filter
.
EndTime
=
&
endTime
}
filter
.
View
=
"all"
filter
.
Phase
=
"upstream"
filter
.
Owner
=
"provider"
filter
.
Source
=
strings
.
TrimSpace
(
c
.
Query
(
"error_source"
))
filter
.
Query
=
strings
.
TrimSpace
(
c
.
Query
(
"q"
))
if
platform
:=
strings
.
TrimSpace
(
c
.
Query
(
"platform"
));
platform
!=
""
{
filter
.
Platform
=
platform
}
// Prefer exact match on request_id; if missing, fall back to client_request_id.
if
requestID
!=
""
{
filter
.
RequestID
=
requestID
}
else
{
filter
.
ClientRequestID
=
clientRequestID
}
result
,
err
:=
h
.
opsService
.
GetErrorLogs
(
c
.
Request
.
Context
(),
filter
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
// If client asks for details, expand each upstream error log to include upstream response fields.
includeDetail
:=
strings
.
TrimSpace
(
c
.
Query
(
"include_detail"
))
if
includeDetail
==
"1"
||
strings
.
EqualFold
(
includeDetail
,
"true"
)
||
strings
.
EqualFold
(
includeDetail
,
"yes"
)
{
details
:=
make
([]
*
service
.
OpsErrorLogDetail
,
0
,
len
(
result
.
Errors
))
for
_
,
item
:=
range
result
.
Errors
{
if
item
==
nil
{
continue
}
d
,
err
:=
h
.
opsService
.
GetErrorLogByID
(
c
.
Request
.
Context
(),
item
.
ID
)
if
err
!=
nil
||
d
==
nil
{
continue
}
details
=
append
(
details
,
d
)
}
response
.
Paginated
(
c
,
details
,
int64
(
result
.
Total
),
result
.
Page
,
result
.
PageSize
)
return
}
response
.
Paginated
(
c
,
result
.
Errors
,
int64
(
result
.
Total
),
result
.
Page
,
result
.
PageSize
)
}
// RetryRequestErrorClient retries the client request based on stored request body.
// POST /api/v1/admin/ops/request-errors/:id/retry-client
func
(
h
*
OpsHandler
)
RetryRequestErrorClient
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
}
if
err
:=
h
.
opsService
.
RequireMonitoringEnabled
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
subject
,
ok
:=
middleware
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
||
subject
.
UserID
<=
0
{
response
.
Error
(
c
,
http
.
StatusUnauthorized
,
"Unauthorized"
)
return
}
idStr
:=
strings
.
TrimSpace
(
c
.
Param
(
"id"
))
id
,
err
:=
strconv
.
ParseInt
(
idStr
,
10
,
64
)
if
err
!=
nil
||
id
<=
0
{
response
.
BadRequest
(
c
,
"Invalid error id"
)
return
}
result
,
err
:=
h
.
opsService
.
RetryError
(
c
.
Request
.
Context
(),
subject
.
UserID
,
id
,
service
.
OpsRetryModeClient
,
nil
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
result
)
}
// RetryRequestErrorUpstreamEvent retries a specific upstream attempt using captured upstream_request_body.
// POST /api/v1/admin/ops/request-errors/:id/upstream-errors/:idx/retry
func
(
h
*
OpsHandler
)
RetryRequestErrorUpstreamEvent
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
}
if
err
:=
h
.
opsService
.
RequireMonitoringEnabled
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
subject
,
ok
:=
middleware
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
||
subject
.
UserID
<=
0
{
response
.
Error
(
c
,
http
.
StatusUnauthorized
,
"Unauthorized"
)
return
}
idStr
:=
strings
.
TrimSpace
(
c
.
Param
(
"id"
))
id
,
err
:=
strconv
.
ParseInt
(
idStr
,
10
,
64
)
if
err
!=
nil
||
id
<=
0
{
response
.
BadRequest
(
c
,
"Invalid error id"
)
return
}
idxStr
:=
strings
.
TrimSpace
(
c
.
Param
(
"idx"
))
idx
,
err
:=
strconv
.
Atoi
(
idxStr
)
if
err
!=
nil
||
idx
<
0
{
response
.
BadRequest
(
c
,
"Invalid upstream idx"
)
return
}
result
,
err
:=
h
.
opsService
.
RetryUpstreamEvent
(
c
.
Request
.
Context
(),
subject
.
UserID
,
id
,
idx
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
result
)
}
// ResolveRequestError toggles resolved status.
// PUT /api/v1/admin/ops/request-errors/:id/resolve
func
(
h
*
OpsHandler
)
ResolveRequestError
(
c
*
gin
.
Context
)
{
h
.
UpdateErrorResolution
(
c
)
}
// ListUpstreamErrors lists independent upstream errors.
// GET /api/v1/admin/ops/upstream-errors
func
(
h
*
OpsHandler
)
ListUpstreamErrors
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
}
if
err
:=
h
.
opsService
.
RequireMonitoringEnabled
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
if
pageSize
>
500
{
pageSize
=
500
}
startTime
,
endTime
,
err
:=
parseOpsTimeRange
(
c
,
"1h"
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
filter
:=
&
service
.
OpsErrorLogFilter
{
Page
:
page
,
PageSize
:
pageSize
}
if
!
startTime
.
IsZero
()
{
filter
.
StartTime
=
&
startTime
}
if
!
endTime
.
IsZero
()
{
filter
.
EndTime
=
&
endTime
}
filter
.
View
=
parseOpsViewParam
(
c
)
filter
.
Phase
=
"upstream"
filter
.
Owner
=
"provider"
filter
.
Source
=
strings
.
TrimSpace
(
c
.
Query
(
"error_source"
))
filter
.
Query
=
strings
.
TrimSpace
(
c
.
Query
(
"q"
))
if
platform
:=
strings
.
TrimSpace
(
c
.
Query
(
"platform"
));
platform
!=
""
{
filter
.
Platform
=
platform
}
if
v
:=
strings
.
TrimSpace
(
c
.
Query
(
"group_id"
));
v
!=
""
{
id
,
err
:=
strconv
.
ParseInt
(
v
,
10
,
64
)
if
err
!=
nil
||
id
<=
0
{
response
.
BadRequest
(
c
,
"Invalid group_id"
)
return
}
filter
.
GroupID
=
&
id
}
if
v
:=
strings
.
TrimSpace
(
c
.
Query
(
"account_id"
));
v
!=
""
{
id
,
err
:=
strconv
.
ParseInt
(
v
,
10
,
64
)
if
err
!=
nil
||
id
<=
0
{
response
.
BadRequest
(
c
,
"Invalid account_id"
)
return
}
filter
.
AccountID
=
&
id
}
if
v
:=
strings
.
TrimSpace
(
c
.
Query
(
"resolved"
));
v
!=
""
{
switch
strings
.
ToLower
(
v
)
{
case
"1"
,
"true"
,
"yes"
:
b
:=
true
filter
.
Resolved
=
&
b
case
"0"
,
"false"
,
"no"
:
b
:=
false
filter
.
Resolved
=
&
b
default
:
response
.
BadRequest
(
c
,
"Invalid resolved"
)
return
}
}
if
statusCodesStr
:=
strings
.
TrimSpace
(
c
.
Query
(
"status_codes"
));
statusCodesStr
!=
""
{
parts
:=
strings
.
Split
(
statusCodesStr
,
","
)
out
:=
make
([]
int
,
0
,
len
(
parts
))
for
_
,
part
:=
range
parts
{
p
:=
strings
.
TrimSpace
(
part
)
if
p
==
""
{
continue
}
n
,
err
:=
strconv
.
Atoi
(
p
)
if
err
!=
nil
||
n
<
0
{
response
.
BadRequest
(
c
,
"Invalid status_codes"
)
return
}
out
=
append
(
out
,
n
)
}
filter
.
StatusCodes
=
out
}
result
,
err
:=
h
.
opsService
.
GetErrorLogs
(
c
.
Request
.
Context
(),
filter
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Paginated
(
c
,
result
.
Errors
,
int64
(
result
.
Total
),
result
.
Page
,
result
.
PageSize
)
}
// GetUpstreamError returns upstream error detail.
// GET /api/v1/admin/ops/upstream-errors/:id
func
(
h
*
OpsHandler
)
GetUpstreamError
(
c
*
gin
.
Context
)
{
h
.
GetErrorLogByID
(
c
)
}
// RetryUpstreamError retries upstream error using the original account_id.
// POST /api/v1/admin/ops/upstream-errors/:id/retry
func
(
h
*
OpsHandler
)
RetryUpstreamError
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
}
if
err
:=
h
.
opsService
.
RequireMonitoringEnabled
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
subject
,
ok
:=
middleware
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
||
subject
.
UserID
<=
0
{
response
.
Error
(
c
,
http
.
StatusUnauthorized
,
"Unauthorized"
)
return
}
idStr
:=
strings
.
TrimSpace
(
c
.
Param
(
"id"
))
id
,
err
:=
strconv
.
ParseInt
(
idStr
,
10
,
64
)
if
err
!=
nil
||
id
<=
0
{
response
.
BadRequest
(
c
,
"Invalid error id"
)
return
}
result
,
err
:=
h
.
opsService
.
RetryError
(
c
.
Request
.
Context
(),
subject
.
UserID
,
id
,
service
.
OpsRetryModeUpstream
,
nil
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
result
)
}
// ResolveUpstreamError toggles resolved status.
// PUT /api/v1/admin/ops/upstream-errors/:id/resolve
func
(
h
*
OpsHandler
)
ResolveUpstreamError
(
c
*
gin
.
Context
)
{
h
.
UpdateErrorResolution
(
c
)
}
// ==================== Existing endpoints ====================
// ListRequestDetails returns a request-level list (success + error) for drill-down.
// GET /api/v1/admin/ops/requests
func
(
h
*
OpsHandler
)
ListRequestDetails
(
c
*
gin
.
Context
)
{
...
...
@@ -242,6 +709,11 @@ func (h *OpsHandler) ListRequestDetails(c *gin.Context) {
type
opsRetryRequest
struct
{
Mode
string
`json:"mode"`
PinnedAccountID
*
int64
`json:"pinned_account_id"`
Force
bool
`json:"force"`
}
type
opsResolveRequest
struct
{
Resolved
bool
`json:"resolved"`
}
// RetryErrorRequest retries a failed request using stored request_body.
...
...
@@ -278,6 +750,16 @@ func (h *OpsHandler) RetryErrorRequest(c *gin.Context) {
req
.
Mode
=
service
.
OpsRetryModeClient
}
// Force flag is currently a UI-level acknowledgement. Server may still enforce safety constraints.
_
=
req
.
Force
// Legacy endpoint safety: only allow retrying the client request here.
// Upstream retries must go through the split endpoints.
if
strings
.
EqualFold
(
strings
.
TrimSpace
(
req
.
Mode
),
service
.
OpsRetryModeUpstream
)
{
response
.
BadRequest
(
c
,
"upstream retry is not supported on this endpoint"
)
return
}
result
,
err
:=
h
.
opsService
.
RetryError
(
c
.
Request
.
Context
(),
subject
.
UserID
,
id
,
req
.
Mode
,
req
.
PinnedAccountID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
...
...
@@ -287,6 +769,81 @@ func (h *OpsHandler) RetryErrorRequest(c *gin.Context) {
response
.
Success
(
c
,
result
)
}
// ListRetryAttempts lists retry attempts for an error log.
// GET /api/v1/admin/ops/errors/:id/retries
func
(
h
*
OpsHandler
)
ListRetryAttempts
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
}
if
err
:=
h
.
opsService
.
RequireMonitoringEnabled
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
idStr
:=
strings
.
TrimSpace
(
c
.
Param
(
"id"
))
id
,
err
:=
strconv
.
ParseInt
(
idStr
,
10
,
64
)
if
err
!=
nil
||
id
<=
0
{
response
.
BadRequest
(
c
,
"Invalid error id"
)
return
}
limit
:=
50
if
v
:=
strings
.
TrimSpace
(
c
.
Query
(
"limit"
));
v
!=
""
{
n
,
err
:=
strconv
.
Atoi
(
v
)
if
err
!=
nil
||
n
<=
0
{
response
.
BadRequest
(
c
,
"Invalid limit"
)
return
}
limit
=
n
}
items
,
err
:=
h
.
opsService
.
ListRetryAttemptsByErrorID
(
c
.
Request
.
Context
(),
id
,
limit
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
items
)
}
// UpdateErrorResolution allows manual resolve/unresolve.
// PUT /api/v1/admin/ops/errors/:id/resolve
func
(
h
*
OpsHandler
)
UpdateErrorResolution
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
}
if
err
:=
h
.
opsService
.
RequireMonitoringEnabled
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
subject
,
ok
:=
middleware
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
||
subject
.
UserID
<=
0
{
response
.
Error
(
c
,
http
.
StatusUnauthorized
,
"Unauthorized"
)
return
}
idStr
:=
strings
.
TrimSpace
(
c
.
Param
(
"id"
))
id
,
err
:=
strconv
.
ParseInt
(
idStr
,
10
,
64
)
if
err
!=
nil
||
id
<=
0
{
response
.
BadRequest
(
c
,
"Invalid error id"
)
return
}
var
req
opsResolveRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
uid
:=
subject
.
UserID
if
err
:=
h
.
opsService
.
UpdateErrorResolution
(
c
.
Request
.
Context
(),
id
,
req
.
Resolved
,
&
uid
,
nil
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"ok"
:
true
})
}
func
parseOpsTimeRange
(
c
*
gin
.
Context
,
defaultRange
string
)
(
time
.
Time
,
time
.
Time
,
error
)
{
startStr
:=
strings
.
TrimSpace
(
c
.
Query
(
"start_time"
))
endStr
:=
strings
.
TrimSpace
(
c
.
Query
(
"end_time"
))
...
...
@@ -358,6 +915,10 @@ func parseOpsDuration(v string) (time.Duration, bool) {
return
6
*
time
.
Hour
,
true
case
"24h"
:
return
24
*
time
.
Hour
,
true
case
"7d"
:
return
7
*
24
*
time
.
Hour
,
true
case
"30d"
:
return
30
*
24
*
time
.
Hour
,
true
default
:
return
0
,
false
}
...
...
backend/internal/handler/admin/proxy_handler.go
View file @
6901b64f
...
...
@@ -196,6 +196,28 @@ func (h *ProxyHandler) Delete(c *gin.Context) {
response
.
Success
(
c
,
gin
.
H
{
"message"
:
"Proxy deleted successfully"
})
}
// BatchDelete handles batch deleting proxies
// POST /api/v1/admin/proxies/batch-delete
func
(
h
*
ProxyHandler
)
BatchDelete
(
c
*
gin
.
Context
)
{
type
BatchDeleteRequest
struct
{
IDs
[]
int64
`json:"ids" binding:"required,min=1"`
}
var
req
BatchDeleteRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
result
,
err
:=
h
.
adminService
.
BatchDeleteProxies
(
c
.
Request
.
Context
(),
req
.
IDs
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
result
)
}
// Test handles testing proxy connectivity
// POST /api/v1/admin/proxies/:id/test
func
(
h
*
ProxyHandler
)
Test
(
c
*
gin
.
Context
)
{
...
...
@@ -243,19 +265,17 @@ func (h *ProxyHandler) GetProxyAccounts(c *gin.Context) {
return
}
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
accounts
,
total
,
err
:=
h
.
adminService
.
GetProxyAccounts
(
c
.
Request
.
Context
(),
proxyID
,
page
,
pageSize
)
accounts
,
err
:=
h
.
adminService
.
GetProxyAccounts
(
c
.
Request
.
Context
(),
proxyID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
out
:=
make
([]
dto
.
Account
,
0
,
len
(
accounts
))
out
:=
make
([]
dto
.
Proxy
Account
Summary
,
0
,
len
(
accounts
))
for
i
:=
range
accounts
{
out
=
append
(
out
,
*
dto
.
AccountFromService
(
&
accounts
[
i
]))
out
=
append
(
out
,
*
dto
.
Proxy
Account
Summary
FromService
(
&
accounts
[
i
]))
}
response
.
Paginated
(
c
,
out
,
total
,
page
,
pageSize
)
response
.
Success
(
c
,
out
)
}
// BatchCreateProxyItem represents a single proxy in batch create request
...
...
backend/internal/handler/dto/mappers.go
View file @
6901b64f
...
...
@@ -73,25 +73,27 @@ func GroupFromServiceShallow(g *service.Group) *Group {
return
nil
}
return
&
Group
{
ID
:
g
.
ID
,
Name
:
g
.
Name
,
Description
:
g
.
Description
,
Platform
:
g
.
Platform
,
RateMultiplier
:
g
.
RateMultiplier
,
IsExclusive
:
g
.
IsExclusive
,
Status
:
g
.
Status
,
SubscriptionType
:
g
.
SubscriptionType
,
DailyLimitUSD
:
g
.
DailyLimitUSD
,
WeeklyLimitUSD
:
g
.
WeeklyLimitUSD
,
MonthlyLimitUSD
:
g
.
MonthlyLimitUSD
,
ImagePrice1K
:
g
.
ImagePrice1K
,
ImagePrice2K
:
g
.
ImagePrice2K
,
ImagePrice4K
:
g
.
ImagePrice4K
,
ClaudeCodeOnly
:
g
.
ClaudeCodeOnly
,
FallbackGroupID
:
g
.
FallbackGroupID
,
CreatedAt
:
g
.
CreatedAt
,
UpdatedAt
:
g
.
UpdatedAt
,
AccountCount
:
g
.
AccountCount
,
ID
:
g
.
ID
,
Name
:
g
.
Name
,
Description
:
g
.
Description
,
Platform
:
g
.
Platform
,
RateMultiplier
:
g
.
RateMultiplier
,
IsExclusive
:
g
.
IsExclusive
,
Status
:
g
.
Status
,
SubscriptionType
:
g
.
SubscriptionType
,
DailyLimitUSD
:
g
.
DailyLimitUSD
,
WeeklyLimitUSD
:
g
.
WeeklyLimitUSD
,
MonthlyLimitUSD
:
g
.
MonthlyLimitUSD
,
ImagePrice1K
:
g
.
ImagePrice1K
,
ImagePrice2K
:
g
.
ImagePrice2K
,
ImagePrice4K
:
g
.
ImagePrice4K
,
ClaudeCodeOnly
:
g
.
ClaudeCodeOnly
,
FallbackGroupID
:
g
.
FallbackGroupID
,
ModelRouting
:
g
.
ModelRouting
,
ModelRoutingEnabled
:
g
.
ModelRoutingEnabled
,
CreatedAt
:
g
.
CreatedAt
,
UpdatedAt
:
g
.
UpdatedAt
,
AccountCount
:
g
.
AccountCount
,
}
}
...
...
@@ -114,7 +116,7 @@ func AccountFromServiceShallow(a *service.Account) *Account {
if
a
==
nil
{
return
nil
}
return
&
Account
{
out
:=
&
Account
{
ID
:
a
.
ID
,
Name
:
a
.
Name
,
Notes
:
a
.
Notes
,
...
...
@@ -125,6 +127,7 @@ func AccountFromServiceShallow(a *service.Account) *Account {
ProxyID
:
a
.
ProxyID
,
Concurrency
:
a
.
Concurrency
,
Priority
:
a
.
Priority
,
RateMultiplier
:
a
.
BillingRateMultiplier
(),
Status
:
a
.
Status
,
ErrorMessage
:
a
.
ErrorMessage
,
LastUsedAt
:
a
.
LastUsedAt
,
...
...
@@ -143,6 +146,24 @@ func AccountFromServiceShallow(a *service.Account) *Account {
SessionWindowStatus
:
a
.
SessionWindowStatus
,
GroupIDs
:
a
.
GroupIDs
,
}
// 提取 5h 窗口费用控制和会话数量控制配置(仅 Anthropic OAuth/SetupToken 账号有效)
if
a
.
IsAnthropicOAuthOrSetupToken
()
{
if
limit
:=
a
.
GetWindowCostLimit
();
limit
>
0
{
out
.
WindowCostLimit
=
&
limit
}
if
reserve
:=
a
.
GetWindowCostStickyReserve
();
reserve
>
0
{
out
.
WindowCostStickyReserve
=
&
reserve
}
if
maxSessions
:=
a
.
GetMaxSessions
();
maxSessions
>
0
{
out
.
MaxSessions
=
&
maxSessions
}
if
idleTimeout
:=
a
.
GetSessionIdleTimeoutMinutes
();
idleTimeout
>
0
{
out
.
SessionIdleTimeoutMin
=
&
idleTimeout
}
}
return
out
}
func
AccountFromService
(
a
*
service
.
Account
)
*
Account
{
...
...
@@ -212,8 +233,29 @@ func ProxyWithAccountCountFromService(p *service.ProxyWithAccountCount) *ProxyWi
return
nil
}
return
&
ProxyWithAccountCount
{
Proxy
:
*
ProxyFromService
(
&
p
.
Proxy
),
AccountCount
:
p
.
AccountCount
,
Proxy
:
*
ProxyFromService
(
&
p
.
Proxy
),
AccountCount
:
p
.
AccountCount
,
LatencyMs
:
p
.
LatencyMs
,
LatencyStatus
:
p
.
LatencyStatus
,
LatencyMessage
:
p
.
LatencyMessage
,
IPAddress
:
p
.
IPAddress
,
Country
:
p
.
Country
,
CountryCode
:
p
.
CountryCode
,
Region
:
p
.
Region
,
City
:
p
.
City
,
}
}
func
ProxyAccountSummaryFromService
(
a
*
service
.
ProxyAccountSummary
)
*
ProxyAccountSummary
{
if
a
==
nil
{
return
nil
}
return
&
ProxyAccountSummary
{
ID
:
a
.
ID
,
Name
:
a
.
Name
,
Platform
:
a
.
Platform
,
Type
:
a
.
Type
,
Notes
:
a
.
Notes
,
}
}
...
...
@@ -279,6 +321,7 @@ func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, inclu
TotalCost
:
l
.
TotalCost
,
ActualCost
:
l
.
ActualCost
,
RateMultiplier
:
l
.
RateMultiplier
,
AccountRateMultiplier
:
l
.
AccountRateMultiplier
,
BillingType
:
l
.
BillingType
,
Stream
:
l
.
Stream
,
DurationMs
:
l
.
DurationMs
,
...
...
backend/internal/handler/dto/types.go
View file @
6901b64f
...
...
@@ -58,6 +58,10 @@ type Group struct {
ClaudeCodeOnly
bool
`json:"claude_code_only"`
FallbackGroupID
*
int64
`json:"fallback_group_id"`
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting
map
[
string
][]
int64
`json:"model_routing"`
ModelRoutingEnabled
bool
`json:"model_routing_enabled"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
...
...
@@ -76,6 +80,7 @@ type Account struct {
ProxyID
*
int64
`json:"proxy_id"`
Concurrency
int
`json:"concurrency"`
Priority
int
`json:"priority"`
RateMultiplier
float64
`json:"rate_multiplier"`
Status
string
`json:"status"`
ErrorMessage
string
`json:"error_message"`
LastUsedAt
*
time
.
Time
`json:"last_used_at"`
...
...
@@ -97,6 +102,16 @@ type Account struct {
SessionWindowEnd
*
time
.
Time
`json:"session_window_end"`
SessionWindowStatus
string
`json:"session_window_status"`
// 5h窗口费用控制(仅 Anthropic OAuth/SetupToken 账号有效)
// 从 extra 字段提取,方便前端显示和编辑
WindowCostLimit
*
float64
`json:"window_cost_limit,omitempty"`
WindowCostStickyReserve
*
float64
`json:"window_cost_sticky_reserve,omitempty"`
// 会话数量控制(仅 Anthropic OAuth/SetupToken 账号有效)
// 从 extra 字段提取,方便前端显示和编辑
MaxSessions
*
int
`json:"max_sessions,omitempty"`
SessionIdleTimeoutMin
*
int
`json:"session_idle_timeout_minutes,omitempty"`
Proxy
*
Proxy
`json:"proxy,omitempty"`
AccountGroups
[]
AccountGroup
`json:"account_groups,omitempty"`
...
...
@@ -129,7 +144,23 @@ type Proxy struct {
type
ProxyWithAccountCount
struct
{
Proxy
AccountCount
int64
`json:"account_count"`
AccountCount
int64
`json:"account_count"`
LatencyMs
*
int64
`json:"latency_ms,omitempty"`
LatencyStatus
string
`json:"latency_status,omitempty"`
LatencyMessage
string
`json:"latency_message,omitempty"`
IPAddress
string
`json:"ip_address,omitempty"`
Country
string
`json:"country,omitempty"`
CountryCode
string
`json:"country_code,omitempty"`
Region
string
`json:"region,omitempty"`
City
string
`json:"city,omitempty"`
}
type
ProxyAccountSummary
struct
{
ID
int64
`json:"id"`
Name
string
`json:"name"`
Platform
string
`json:"platform"`
Type
string
`json:"type"`
Notes
*
string
`json:"notes,omitempty"`
}
type
RedeemCode
struct
{
...
...
@@ -169,13 +200,14 @@ type UsageLog struct {
CacheCreation5mTokens
int
`json:"cache_creation_5m_tokens"`
CacheCreation1hTokens
int
`json:"cache_creation_1h_tokens"`
InputCost
float64
`json:"input_cost"`
OutputCost
float64
`json:"output_cost"`
CacheCreationCost
float64
`json:"cache_creation_cost"`
CacheReadCost
float64
`json:"cache_read_cost"`
TotalCost
float64
`json:"total_cost"`
ActualCost
float64
`json:"actual_cost"`
RateMultiplier
float64
`json:"rate_multiplier"`
InputCost
float64
`json:"input_cost"`
OutputCost
float64
`json:"output_cost"`
CacheCreationCost
float64
`json:"cache_creation_cost"`
CacheReadCost
float64
`json:"cache_read_cost"`
TotalCost
float64
`json:"total_cost"`
ActualCost
float64
`json:"actual_cost"`
RateMultiplier
float64
`json:"rate_multiplier"`
AccountRateMultiplier
*
float64
`json:"account_rate_multiplier"`
BillingType
int8
`json:"billing_type"`
Stream
bool
`json:"stream"`
...
...
backend/internal/handler/gateway_handler.go
View file @
6901b64f
...
...
@@ -185,7 +185,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
lastFailoverStatus
:=
0
for
{
selection
,
err
:=
h
.
gatewayService
.
SelectAccountWithLoadAwareness
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionKey
,
reqModel
,
failedAccountIDs
)
selection
,
err
:=
h
.
gatewayService
.
SelectAccountWithLoadAwareness
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionKey
,
reqModel
,
failedAccountIDs
,
""
)
// Gemini 不使用会话限制
if
err
!=
nil
{
if
len
(
failedAccountIDs
)
==
0
{
h
.
handleStreamingAwareError
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"No available accounts: "
+
err
.
Error
(),
streamStarted
)
...
...
@@ -320,7 +320,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
for
{
// 选择支持该模型的账号
selection
,
err
:=
h
.
gatewayService
.
SelectAccountWithLoadAwareness
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionKey
,
reqModel
,
failedAccountIDs
)
selection
,
err
:=
h
.
gatewayService
.
SelectAccountWithLoadAwareness
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionKey
,
reqModel
,
failedAccountIDs
,
parsedReq
.
MetadataUserID
)
if
err
!=
nil
{
if
len
(
failedAccountIDs
)
==
0
{
h
.
handleStreamingAwareError
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"No available accounts: "
+
err
.
Error
(),
streamStarted
)
...
...
backend/internal/handler/gemini_v1beta_handler.go
View file @
6901b64f
...
...
@@ -226,7 +226,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
lastFailoverStatus
:=
0
for
{
selection
,
err
:=
h
.
gatewayService
.
SelectAccountWithLoadAwareness
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionKey
,
modelName
,
failedAccountIDs
)
selection
,
err
:=
h
.
gatewayService
.
SelectAccountWithLoadAwareness
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionKey
,
modelName
,
failedAccountIDs
,
""
)
// Gemini 不使用会话限制
if
err
!=
nil
{
if
len
(
failedAccountIDs
)
==
0
{
googleError
(
c
,
http
.
StatusServiceUnavailable
,
"No available Gemini accounts: "
+
err
.
Error
())
...
...
backend/internal/handler/ops_error_logger.go
View file @
6901b64f
...
...
@@ -544,6 +544,11 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
body
:=
w
.
buf
.
Bytes
()
parsed
:=
parseOpsErrorResponse
(
body
)
// Skip logging if the error should be filtered based on settings
if
shouldSkipOpsErrorLog
(
c
.
Request
.
Context
(),
ops
,
parsed
.
Message
,
string
(
body
),
c
.
Request
.
URL
.
Path
)
{
return
}
apiKey
,
_
:=
middleware2
.
GetAPIKeyFromContext
(
c
)
clientRequestID
,
_
:=
c
.
Request
.
Context
()
.
Value
(
ctxkey
.
ClientRequestID
)
.
(
string
)
...
...
@@ -832,28 +837,30 @@ func normalizeOpsErrorType(errType string, code string) string {
func
classifyOpsPhase
(
errType
,
message
,
code
string
)
string
{
msg
:=
strings
.
ToLower
(
message
)
// Standardized phases: request|auth|routing|upstream|network|internal
// Map billing/concurrency/response => request; scheduling => routing.
switch
strings
.
TrimSpace
(
code
)
{
case
"INSUFFICIENT_BALANCE"
,
"USAGE_LIMIT_EXCEEDED"
,
"SUBSCRIPTION_NOT_FOUND"
,
"SUBSCRIPTION_INVALID"
:
return
"
billing
"
return
"
request
"
}
switch
errType
{
case
"authentication_error"
:
return
"auth"
case
"billing_error"
,
"subscription_error"
:
return
"
billing
"
return
"
request
"
case
"rate_limit_error"
:
if
strings
.
Contains
(
msg
,
"concurrency"
)
||
strings
.
Contains
(
msg
,
"pending"
)
||
strings
.
Contains
(
msg
,
"queue"
)
{
return
"
concurrency
"
return
"
request
"
}
return
"upstream"
case
"invalid_request_error"
:
return
"re
sponse
"
return
"re
quest
"
case
"upstream_error"
,
"overloaded_error"
:
return
"upstream"
case
"api_error"
:
if
strings
.
Contains
(
msg
,
"no available accounts"
)
{
return
"
schedul
ing"
return
"
rout
ing"
}
return
"internal"
default
:
...
...
@@ -914,34 +921,38 @@ func classifyOpsIsBusinessLimited(errType, phase, code string, status int, messa
}
func
classifyOpsErrorOwner
(
phase
string
,
message
string
)
string
{
// Standardized owners: client|provider|platform
switch
phase
{
case
"upstream"
,
"network"
:
return
"provider"
case
"
billing"
,
"concurrency"
,
"auth"
,
"response
"
:
case
"
request"
,
"auth
"
:
return
"client"
case
"routing"
,
"internal"
:
return
"platform"
default
:
if
strings
.
Contains
(
strings
.
ToLower
(
message
),
"upstream"
)
{
return
"provider"
}
return
"
sub2api
"
return
"
platform
"
}
}
func
classifyOpsErrorSource
(
phase
string
,
message
string
)
string
{
// Standardized sources: client_request|upstream_http|gateway
switch
phase
{
case
"upstream"
:
return
"upstream_http"
case
"network"
:
return
"
upstream_network
"
case
"
billing
"
:
return
"
billing
"
case
"
concurrency
"
:
return
"
concurrenc
y"
return
"
gateway
"
case
"
request"
,
"auth
"
:
return
"
client_request
"
case
"
routing"
,
"internal
"
:
return
"
gatewa
y"
default
:
if
strings
.
Contains
(
strings
.
ToLower
(
message
),
"upstream"
)
{
return
"upstream_http"
}
return
"
internal
"
return
"
gateway
"
}
}
...
...
@@ -963,3 +974,42 @@ func truncateString(s string, max int) string {
func
strconvItoa
(
v
int
)
string
{
return
strconv
.
Itoa
(
v
)
}
// shouldSkipOpsErrorLog determines if an error should be skipped from logging based on settings.
// Returns true for errors that should be filtered according to OpsAdvancedSettings.
func
shouldSkipOpsErrorLog
(
ctx
context
.
Context
,
ops
*
service
.
OpsService
,
message
,
body
,
requestPath
string
)
bool
{
if
ops
==
nil
{
return
false
}
// Get advanced settings to check filter configuration
settings
,
err
:=
ops
.
GetOpsAdvancedSettings
(
ctx
)
if
err
!=
nil
||
settings
==
nil
{
// If we can't get settings, don't skip (fail open)
return
false
}
msgLower
:=
strings
.
ToLower
(
message
)
bodyLower
:=
strings
.
ToLower
(
body
)
// Check if count_tokens errors should be ignored
if
settings
.
IgnoreCountTokensErrors
&&
strings
.
Contains
(
requestPath
,
"/count_tokens"
)
{
return
true
}
// Check if context canceled errors should be ignored (client disconnects)
if
settings
.
IgnoreContextCanceled
{
if
strings
.
Contains
(
msgLower
,
"context canceled"
)
||
strings
.
Contains
(
bodyLower
,
"context canceled"
)
{
return
true
}
}
// Check if "no available accounts" errors should be ignored
if
settings
.
IgnoreNoAvailableAccounts
{
if
strings
.
Contains
(
msgLower
,
"no available accounts"
)
||
strings
.
Contains
(
bodyLower
,
"no available accounts"
)
{
return
true
}
}
return
false
}
backend/internal/pkg/usagestats/account_stats.go
View file @
6901b64f
package
usagestats
// AccountStats 账号使用统计
//
// cost: 账号口径费用(使用 total_cost * account_rate_multiplier)
// standard_cost: 标准费用(使用 total_cost,不含倍率)
// user_cost: 用户/API Key 口径费用(使用 actual_cost,受分组倍率影响)
type
AccountStats
struct
{
Requests
int64
`json:"requests"`
Tokens
int64
`json:"tokens"`
Cost
float64
`json:"cost"`
Requests
int64
`json:"requests"`
Tokens
int64
`json:"tokens"`
Cost
float64
`json:"cost"`
StandardCost
float64
`json:"standard_cost"`
UserCost
float64
`json:"user_cost"`
}
backend/internal/pkg/usagestats/usage_log_types.go
View file @
6901b64f
...
...
@@ -147,14 +147,15 @@ type UsageLogFilters struct {
// UsageStats represents usage statistics
type
UsageStats
struct
{
TotalRequests
int64
`json:"total_requests"`
TotalInputTokens
int64
`json:"total_input_tokens"`
TotalOutputTokens
int64
`json:"total_output_tokens"`
TotalCacheTokens
int64
`json:"total_cache_tokens"`
TotalTokens
int64
`json:"total_tokens"`
TotalCost
float64
`json:"total_cost"`
TotalActualCost
float64
`json:"total_actual_cost"`
AverageDurationMs
float64
`json:"average_duration_ms"`
TotalRequests
int64
`json:"total_requests"`
TotalInputTokens
int64
`json:"total_input_tokens"`
TotalOutputTokens
int64
`json:"total_output_tokens"`
TotalCacheTokens
int64
`json:"total_cache_tokens"`
TotalTokens
int64
`json:"total_tokens"`
TotalCost
float64
`json:"total_cost"`
TotalActualCost
float64
`json:"total_actual_cost"`
TotalAccountCost
*
float64
`json:"total_account_cost,omitempty"`
AverageDurationMs
float64
`json:"average_duration_ms"`
}
// BatchUserUsageStats represents usage stats for a single user
...
...
@@ -177,25 +178,29 @@ type AccountUsageHistory struct {
Label
string
`json:"label"`
Requests
int64
`json:"requests"`
Tokens
int64
`json:"tokens"`
Cost
float64
`json:"cost"`
ActualCost
float64
`json:"actual_cost"`
Cost
float64
`json:"cost"`
// 标准计费(total_cost)
ActualCost
float64
`json:"actual_cost"`
// 账号口径费用(total_cost * account_rate_multiplier)
UserCost
float64
`json:"user_cost"`
// 用户口径费用(actual_cost,受分组倍率影响)
}
// AccountUsageSummary represents summary statistics for an account
type
AccountUsageSummary
struct
{
Days
int
`json:"days"`
ActualDaysUsed
int
`json:"actual_days_used"`
TotalCost
float64
`json:"total_cost"`
TotalCost
float64
`json:"total_cost"`
// 账号口径费用
TotalUserCost
float64
`json:"total_user_cost"`
// 用户口径费用
TotalStandardCost
float64
`json:"total_standard_cost"`
TotalRequests
int64
`json:"total_requests"`
TotalTokens
int64
`json:"total_tokens"`
AvgDailyCost
float64
`json:"avg_daily_cost"`
AvgDailyCost
float64
`json:"avg_daily_cost"`
// 账号口径日均
AvgDailyUserCost
float64
`json:"avg_daily_user_cost"`
AvgDailyRequests
float64
`json:"avg_daily_requests"`
AvgDailyTokens
float64
`json:"avg_daily_tokens"`
AvgDurationMs
float64
`json:"avg_duration_ms"`
Today
*
struct
{
Date
string
`json:"date"`
Cost
float64
`json:"cost"`
UserCost
float64
`json:"user_cost"`
Requests
int64
`json:"requests"`
Tokens
int64
`json:"tokens"`
}
`json:"today"`
...
...
@@ -203,6 +208,7 @@ type AccountUsageSummary struct {
Date
string
`json:"date"`
Label
string
`json:"label"`
Cost
float64
`json:"cost"`
UserCost
float64
`json:"user_cost"`
Requests
int64
`json:"requests"`
}
`json:"highest_cost_day"`
HighestRequestDay
*
struct
{
...
...
@@ -210,6 +216,7 @@ type AccountUsageSummary struct {
Label
string
`json:"label"`
Requests
int64
`json:"requests"`
Cost
float64
`json:"cost"`
UserCost
float64
`json:"user_cost"`
}
`json:"highest_request_day"`
}
...
...
backend/internal/repository/account_repo.go
View file @
6901b64f
...
...
@@ -80,6 +80,10 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account
SetSchedulable
(
account
.
Schedulable
)
.
SetAutoPauseOnExpired
(
account
.
AutoPauseOnExpired
)
if
account
.
RateMultiplier
!=
nil
{
builder
.
SetRateMultiplier
(
*
account
.
RateMultiplier
)
}
if
account
.
ProxyID
!=
nil
{
builder
.
SetProxyID
(
*
account
.
ProxyID
)
}
...
...
@@ -291,6 +295,10 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account
SetSchedulable
(
account
.
Schedulable
)
.
SetAutoPauseOnExpired
(
account
.
AutoPauseOnExpired
)
if
account
.
RateMultiplier
!=
nil
{
builder
.
SetRateMultiplier
(
*
account
.
RateMultiplier
)
}
if
account
.
ProxyID
!=
nil
{
builder
.
SetProxyID
(
*
account
.
ProxyID
)
}
else
{
...
...
@@ -786,6 +794,46 @@ func (r *accountRepository) SetAntigravityQuotaScopeLimit(ctx context.Context, i
return
nil
}
func
(
r
*
accountRepository
)
SetModelRateLimit
(
ctx
context
.
Context
,
id
int64
,
scope
string
,
resetAt
time
.
Time
)
error
{
if
scope
==
""
{
return
nil
}
now
:=
time
.
Now
()
.
UTC
()
payload
:=
map
[
string
]
string
{
"rate_limited_at"
:
now
.
Format
(
time
.
RFC3339
),
"rate_limit_reset_at"
:
resetAt
.
UTC
()
.
Format
(
time
.
RFC3339
),
}
raw
,
err
:=
json
.
Marshal
(
payload
)
if
err
!=
nil
{
return
err
}
path
:=
"{model_rate_limits,"
+
scope
+
"}"
client
:=
clientFromContext
(
ctx
,
r
.
client
)
result
,
err
:=
client
.
ExecContext
(
ctx
,
"UPDATE accounts SET extra = jsonb_set(COALESCE(extra, '{}'::jsonb), $1::text[], $2::jsonb, true), updated_at = NOW() WHERE id = $3 AND deleted_at IS NULL"
,
path
,
raw
,
id
,
)
if
err
!=
nil
{
return
err
}
affected
,
err
:=
result
.
RowsAffected
()
if
err
!=
nil
{
return
err
}
if
affected
==
0
{
return
service
.
ErrAccountNotFound
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountChanged
,
&
id
,
nil
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue model rate limit failed: account=%d err=%v"
,
id
,
err
)
}
return
nil
}
func
(
r
*
accountRepository
)
SetOverloaded
(
ctx
context
.
Context
,
id
int64
,
until
time
.
Time
)
error
{
_
,
err
:=
r
.
client
.
Account
.
Update
()
.
Where
(
dbaccount
.
IDEQ
(
id
))
.
...
...
@@ -877,6 +925,30 @@ func (r *accountRepository) ClearAntigravityQuotaScopes(ctx context.Context, id
return
nil
}
func
(
r
*
accountRepository
)
ClearModelRateLimits
(
ctx
context
.
Context
,
id
int64
)
error
{
client
:=
clientFromContext
(
ctx
,
r
.
client
)
result
,
err
:=
client
.
ExecContext
(
ctx
,
"UPDATE accounts SET extra = COALESCE(extra, '{}'::jsonb) - 'model_rate_limits', updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL"
,
id
,
)
if
err
!=
nil
{
return
err
}
affected
,
err
:=
result
.
RowsAffected
()
if
err
!=
nil
{
return
err
}
if
affected
==
0
{
return
service
.
ErrAccountNotFound
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountChanged
,
&
id
,
nil
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue clear model rate limit failed: account=%d err=%v"
,
id
,
err
)
}
return
nil
}
func
(
r
*
accountRepository
)
UpdateSessionWindow
(
ctx
context
.
Context
,
id
int64
,
start
,
end
*
time
.
Time
,
status
string
)
error
{
builder
:=
r
.
client
.
Account
.
Update
()
.
Where
(
dbaccount
.
IDEQ
(
id
))
.
...
...
@@ -999,6 +1071,11 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
args
=
append
(
args
,
*
updates
.
Priority
)
idx
++
}
if
updates
.
RateMultiplier
!=
nil
{
setClauses
=
append
(
setClauses
,
"rate_multiplier = $"
+
itoa
(
idx
))
args
=
append
(
args
,
*
updates
.
RateMultiplier
)
idx
++
}
if
updates
.
Status
!=
nil
{
setClauses
=
append
(
setClauses
,
"status = $"
+
itoa
(
idx
))
args
=
append
(
args
,
*
updates
.
Status
)
...
...
@@ -1347,6 +1424,8 @@ func accountEntityToService(m *dbent.Account) *service.Account {
return
nil
}
rateMultiplier
:=
m
.
RateMultiplier
return
&
service
.
Account
{
ID
:
m
.
ID
,
Name
:
m
.
Name
,
...
...
@@ -1358,6 +1437,7 @@ func accountEntityToService(m *dbent.Account) *service.Account {
ProxyID
:
m
.
ProxyID
,
Concurrency
:
m
.
Concurrency
,
Priority
:
m
.
Priority
,
RateMultiplier
:
&
rateMultiplier
,
Status
:
m
.
Status
,
ErrorMessage
:
derefString
(
m
.
ErrorMessage
),
LastUsedAt
:
m
.
LastUsedAt
,
...
...
backend/internal/repository/api_key_repo.go
View file @
6901b64f
...
...
@@ -136,6 +136,8 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
group
.
FieldImagePrice4k
,
group
.
FieldClaudeCodeOnly
,
group
.
FieldFallbackGroupID
,
group
.
FieldModelRoutingEnabled
,
group
.
FieldModelRouting
,
)
})
.
Only
(
ctx
)
...
...
@@ -422,6 +424,8 @@ func groupEntityToService(g *dbent.Group) *service.Group {
DefaultValidityDays
:
g
.
DefaultValidityDays
,
ClaudeCodeOnly
:
g
.
ClaudeCodeOnly
,
FallbackGroupID
:
g
.
FallbackGroupID
,
ModelRouting
:
g
.
ModelRouting
,
ModelRoutingEnabled
:
g
.
ModelRoutingEnabled
,
CreatedAt
:
g
.
CreatedAt
,
UpdatedAt
:
g
.
UpdatedAt
,
}
...
...
Prev
1
2
3
4
5
6
…
10
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment