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
2a5ef6d3
Commit
2a5ef6d3
authored
Jan 15, 2026
by
shaw
Browse files
Merge PR #279: feat(计费): 账号计费倍率快照与账号口径费用统计
parents
99cbfa15
ec24a3c3
Changes
51
Hide whitespace changes
Inline
Side-by-side
backend/internal/repository/account_repo.go
View file @
2a5ef6d3
...
...
@@ -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
{
...
...
@@ -999,6 +1007,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 +1360,8 @@ func accountEntityToService(m *dbent.Account) *service.Account {
return
nil
}
rateMultiplier
:=
m
.
RateMultiplier
return
&
service
.
Account
{
ID
:
m
.
ID
,
Name
:
m
.
Name
,
...
...
@@ -1358,6 +1373,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/ops_repo_preagg.go
View file @
2a5ef6d3
...
...
@@ -71,7 +71,9 @@ usage_agg AS (
error_base AS (
SELECT
date_trunc('hour', created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS bucket_start,
platform AS platform,
-- platform is NULL for some early-phase errors (e.g. before routing); map to a sentinel
-- value so platform-level GROUPING SETS don't collide with the overall (platform=NULL) row.
COALESCE(platform, 'unknown') AS platform,
group_id AS group_id,
is_business_limited AS is_business_limited,
error_owner AS error_owner,
...
...
backend/internal/repository/usage_log_repo.go
View file @
2a5ef6d3
...
...
@@ -22,7 +22,7 @@ import (
"github.com/lib/pq"
)
const
usageLogSelectColumns
=
"id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, billing_type, stream, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, created_at"
const
usageLogSelectColumns
=
"id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier,
account_rate_multiplier,
billing_type, stream, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, created_at"
type
usageLogRepository
struct
{
client
*
dbent
.
Client
...
...
@@ -105,6 +105,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
total_cost,
actual_cost,
rate_multiplier,
account_rate_multiplier,
billing_type,
stream,
duration_ms,
...
...
@@ -120,7 +121,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
$8, $9, $10, $11,
$12, $13,
$14, $15, $16, $17, $18, $19,
$20, $21, $22, $23, $24, $25, $26, $27, $28, $29
$20, $21, $22, $23, $24, $25, $26, $27, $28, $29
, $30
)
ON CONFLICT (request_id, api_key_id) DO NOTHING
RETURNING id, created_at
...
...
@@ -160,6 +161,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
log
.
TotalCost
,
log
.
ActualCost
,
rateMultiplier
,
log
.
AccountRateMultiplier
,
log
.
BillingType
,
log
.
Stream
,
duration
,
...
...
@@ -835,7 +837,9 @@ func (r *usageLogRepository) GetAccountTodayStats(ctx context.Context, accountID
SELECT
COUNT(*) as requests,
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as tokens,
COALESCE(SUM(actual_cost), 0) as cost
COALESCE(SUM(total_cost * COALESCE(account_rate_multiplier, 1)), 0) as cost,
COALESCE(SUM(total_cost), 0) as standard_cost,
COALESCE(SUM(actual_cost), 0) as user_cost
FROM usage_logs
WHERE account_id = $1 AND created_at >= $2
`
...
...
@@ -849,6 +853,8 @@ func (r *usageLogRepository) GetAccountTodayStats(ctx context.Context, accountID
&
stats
.
Requests
,
&
stats
.
Tokens
,
&
stats
.
Cost
,
&
stats
.
StandardCost
,
&
stats
.
UserCost
,
);
err
!=
nil
{
return
nil
,
err
}
...
...
@@ -861,7 +867,9 @@ func (r *usageLogRepository) GetAccountWindowStats(ctx context.Context, accountI
SELECT
COUNT(*) as requests,
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as tokens,
COALESCE(SUM(actual_cost), 0) as cost
COALESCE(SUM(total_cost * COALESCE(account_rate_multiplier, 1)), 0) as cost,
COALESCE(SUM(total_cost), 0) as standard_cost,
COALESCE(SUM(actual_cost), 0) as user_cost
FROM usage_logs
WHERE account_id = $1 AND created_at >= $2
`
...
...
@@ -875,6 +883,8 @@ func (r *usageLogRepository) GetAccountWindowStats(ctx context.Context, accountI
&
stats
.
Requests
,
&
stats
.
Tokens
,
&
stats
.
Cost
,
&
stats
.
StandardCost
,
&
stats
.
UserCost
,
);
err
!=
nil
{
return
nil
,
err
}
...
...
@@ -1454,7 +1464,13 @@ func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, start
// GetModelStatsWithFilters returns model statistics with optional user/api_key filters
func
(
r
*
usageLogRepository
)
GetModelStatsWithFilters
(
ctx
context
.
Context
,
startTime
,
endTime
time
.
Time
,
userID
,
apiKeyID
,
accountID
int64
)
(
results
[]
ModelStat
,
err
error
)
{
query
:=
`
actualCostExpr
:=
"COALESCE(SUM(actual_cost), 0) as actual_cost"
// 当仅按 account_id 聚合时,实际费用使用账号倍率(total_cost * account_rate_multiplier)。
if
accountID
>
0
&&
userID
==
0
&&
apiKeyID
==
0
{
actualCostExpr
=
"COALESCE(SUM(total_cost * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost"
}
query
:=
fmt
.
Sprintf
(
`
SELECT
model,
COUNT(*) as requests,
...
...
@@ -1462,10 +1478,10 @@ func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, start
COALESCE(SUM(output_tokens), 0) as output_tokens,
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens,
COALESCE(SUM(total_cost), 0) as cost,
COALESCE(SUM(actual_cost), 0) as actual_cost
%s
FROM usage_logs
WHERE created_at >= $1 AND created_at < $2
`
`
,
actualCostExpr
)
args
:=
[]
any
{
startTime
,
endTime
}
if
userID
>
0
{
...
...
@@ -1587,12 +1603,14 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us
COALESCE(SUM(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens,
COALESCE(SUM(total_cost), 0) as total_cost,
COALESCE(SUM(actual_cost), 0) as total_actual_cost,
COALESCE(SUM(total_cost * COALESCE(account_rate_multiplier, 1)), 0) as total_account_cost,
COALESCE(AVG(duration_ms), 0) as avg_duration_ms
FROM usage_logs
%s
`
,
buildWhere
(
conditions
))
stats
:=
&
UsageStats
{}
var
totalAccountCost
float64
if
err
:=
scanSingleRow
(
ctx
,
r
.
sql
,
...
...
@@ -1604,10 +1622,14 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us
&
stats
.
TotalCacheTokens
,
&
stats
.
TotalCost
,
&
stats
.
TotalActualCost
,
&
totalAccountCost
,
&
stats
.
AverageDurationMs
,
);
err
!=
nil
{
return
nil
,
err
}
if
filters
.
AccountID
>
0
{
stats
.
TotalAccountCost
=
&
totalAccountCost
}
stats
.
TotalTokens
=
stats
.
TotalInputTokens
+
stats
.
TotalOutputTokens
+
stats
.
TotalCacheTokens
return
stats
,
nil
}
...
...
@@ -1634,7 +1656,8 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID
COUNT(*) as requests,
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as tokens,
COALESCE(SUM(total_cost), 0) as cost,
COALESCE(SUM(actual_cost), 0) as actual_cost
COALESCE(SUM(total_cost * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost,
COALESCE(SUM(actual_cost), 0) as user_cost
FROM usage_logs
WHERE account_id = $1 AND created_at >= $2 AND created_at < $3
GROUP BY date
...
...
@@ -1661,7 +1684,8 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID
var
tokens
int64
var
cost
float64
var
actualCost
float64
if
err
=
rows
.
Scan
(
&
date
,
&
requests
,
&
tokens
,
&
cost
,
&
actualCost
);
err
!=
nil
{
var
userCost
float64
if
err
=
rows
.
Scan
(
&
date
,
&
requests
,
&
tokens
,
&
cost
,
&
actualCost
,
&
userCost
);
err
!=
nil
{
return
nil
,
err
}
t
,
_
:=
time
.
Parse
(
"2006-01-02"
,
date
)
...
...
@@ -1672,19 +1696,21 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID
Tokens
:
tokens
,
Cost
:
cost
,
ActualCost
:
actualCost
,
UserCost
:
userCost
,
})
}
if
err
=
rows
.
Err
();
err
!=
nil
{
return
nil
,
err
}
var
totalAc
tual
Cost
,
totalStandardCost
float64
var
totalAc
countCost
,
totalUser
Cost
,
totalStandardCost
float64
var
totalRequests
,
totalTokens
int64
var
highestCostDay
,
highestRequestDay
*
AccountUsageHistory
for
i
:=
range
history
{
h
:=
&
history
[
i
]
totalActualCost
+=
h
.
ActualCost
totalAccountCost
+=
h
.
ActualCost
totalUserCost
+=
h
.
UserCost
totalStandardCost
+=
h
.
Cost
totalRequests
+=
h
.
Requests
totalTokens
+=
h
.
Tokens
...
...
@@ -1711,11 +1737,13 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID
summary
:=
AccountUsageSummary
{
Days
:
daysCount
,
ActualDaysUsed
:
actualDaysUsed
,
TotalCost
:
totalActualCost
,
TotalCost
:
totalAccountCost
,
TotalUserCost
:
totalUserCost
,
TotalStandardCost
:
totalStandardCost
,
TotalRequests
:
totalRequests
,
TotalTokens
:
totalTokens
,
AvgDailyCost
:
totalActualCost
/
float64
(
actualDaysUsed
),
AvgDailyCost
:
totalAccountCost
/
float64
(
actualDaysUsed
),
AvgDailyUserCost
:
totalUserCost
/
float64
(
actualDaysUsed
),
AvgDailyRequests
:
float64
(
totalRequests
)
/
float64
(
actualDaysUsed
),
AvgDailyTokens
:
float64
(
totalTokens
)
/
float64
(
actualDaysUsed
),
AvgDurationMs
:
avgDuration
,
...
...
@@ -1727,11 +1755,13 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID
summary
.
Today
=
&
struct
{
Date
string
`json:"date"`
Cost
float64
`json:"cost"`
UserCost
float64
`json:"user_cost"`
Requests
int64
`json:"requests"`
Tokens
int64
`json:"tokens"`
}{
Date
:
history
[
i
]
.
Date
,
Cost
:
history
[
i
]
.
ActualCost
,
UserCost
:
history
[
i
]
.
UserCost
,
Requests
:
history
[
i
]
.
Requests
,
Tokens
:
history
[
i
]
.
Tokens
,
}
...
...
@@ -1744,11 +1774,13 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID
Date
string
`json:"date"`
Label
string
`json:"label"`
Cost
float64
`json:"cost"`
UserCost
float64
`json:"user_cost"`
Requests
int64
`json:"requests"`
}{
Date
:
highestCostDay
.
Date
,
Label
:
highestCostDay
.
Label
,
Cost
:
highestCostDay
.
ActualCost
,
UserCost
:
highestCostDay
.
UserCost
,
Requests
:
highestCostDay
.
Requests
,
}
}
...
...
@@ -1759,11 +1791,13 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID
Label
string
`json:"label"`
Requests
int64
`json:"requests"`
Cost
float64
`json:"cost"`
UserCost
float64
`json:"user_cost"`
}{
Date
:
highestRequestDay
.
Date
,
Label
:
highestRequestDay
.
Label
,
Requests
:
highestRequestDay
.
Requests
,
Cost
:
highestRequestDay
.
ActualCost
,
UserCost
:
highestRequestDay
.
UserCost
,
}
}
...
...
@@ -1994,36 +2028,37 @@ func (r *usageLogRepository) loadSubscriptions(ctx context.Context, ids []int64)
func
scanUsageLog
(
scanner
interface
{
Scan
(
...
any
)
error
})
(
*
service
.
UsageLog
,
error
)
{
var
(
id
int64
userID
int64
apiKeyID
int64
accountID
int64
requestID
sql
.
NullString
model
string
groupID
sql
.
NullInt64
subscriptionID
sql
.
NullInt64
inputTokens
int
outputTokens
int
cacheCreationTokens
int
cacheReadTokens
int
cacheCreation5m
int
cacheCreation1h
int
inputCost
float64
outputCost
float64
cacheCreationCost
float64
cacheReadCost
float64
totalCost
float64
actualCost
float64
rateMultiplier
float64
billingType
int16
stream
bool
durationMs
sql
.
NullInt64
firstTokenMs
sql
.
NullInt64
userAgent
sql
.
NullString
ipAddress
sql
.
NullString
imageCount
int
imageSize
sql
.
NullString
createdAt
time
.
Time
id
int64
userID
int64
apiKeyID
int64
accountID
int64
requestID
sql
.
NullString
model
string
groupID
sql
.
NullInt64
subscriptionID
sql
.
NullInt64
inputTokens
int
outputTokens
int
cacheCreationTokens
int
cacheReadTokens
int
cacheCreation5m
int
cacheCreation1h
int
inputCost
float64
outputCost
float64
cacheCreationCost
float64
cacheReadCost
float64
totalCost
float64
actualCost
float64
rateMultiplier
float64
accountRateMultiplier
sql
.
NullFloat64
billingType
int16
stream
bool
durationMs
sql
.
NullInt64
firstTokenMs
sql
.
NullInt64
userAgent
sql
.
NullString
ipAddress
sql
.
NullString
imageCount
int
imageSize
sql
.
NullString
createdAt
time
.
Time
)
if
err
:=
scanner
.
Scan
(
...
...
@@ -2048,6 +2083,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
&
totalCost
,
&
actualCost
,
&
rateMultiplier
,
&
accountRateMultiplier
,
&
billingType
,
&
stream
,
&
durationMs
,
...
...
@@ -2080,6 +2116,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
TotalCost
:
totalCost
,
ActualCost
:
actualCost
,
RateMultiplier
:
rateMultiplier
,
AccountRateMultiplier
:
nullFloat64Ptr
(
accountRateMultiplier
),
BillingType
:
int8
(
billingType
),
Stream
:
stream
,
ImageCount
:
imageCount
,
...
...
@@ -2186,6 +2223,14 @@ func nullInt(v *int) sql.NullInt64 {
return
sql
.
NullInt64
{
Int64
:
int64
(
*
v
),
Valid
:
true
}
}
func
nullFloat64Ptr
(
v
sql
.
NullFloat64
)
*
float64
{
if
!
v
.
Valid
{
return
nil
}
out
:=
v
.
Float64
return
&
out
}
func
nullString
(
v
*
string
)
sql
.
NullString
{
if
v
==
nil
||
*
v
==
""
{
return
sql
.
NullString
{}
...
...
backend/internal/repository/usage_log_repo_integration_test.go
View file @
2a5ef6d3
...
...
@@ -11,6 +11,7 @@ import (
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/suite"
...
...
@@ -95,6 +96,34 @@ func (s *UsageLogRepoSuite) TestGetByID_NotFound() {
s
.
Require
()
.
Error
(
err
,
"expected error for non-existent ID"
)
}
func
(
s
*
UsageLogRepoSuite
)
TestGetByID_ReturnsAccountRateMultiplier
()
{
user
:=
mustCreateUser
(
s
.
T
(),
s
.
client
,
&
service
.
User
{
Email
:
"getbyid-mult@test.com"
})
apiKey
:=
mustCreateApiKey
(
s
.
T
(),
s
.
client
,
&
service
.
APIKey
{
UserID
:
user
.
ID
,
Key
:
"sk-getbyid-mult"
,
Name
:
"k"
})
account
:=
mustCreateAccount
(
s
.
T
(),
s
.
client
,
&
service
.
Account
{
Name
:
"acc-getbyid-mult"
})
m
:=
0.5
log
:=
&
service
.
UsageLog
{
UserID
:
user
.
ID
,
APIKeyID
:
apiKey
.
ID
,
AccountID
:
account
.
ID
,
RequestID
:
uuid
.
New
()
.
String
(),
Model
:
"claude-3"
,
InputTokens
:
10
,
OutputTokens
:
20
,
TotalCost
:
1.0
,
ActualCost
:
2.0
,
AccountRateMultiplier
:
&
m
,
CreatedAt
:
timezone
.
Today
()
.
Add
(
2
*
time
.
Hour
),
}
_
,
err
:=
s
.
repo
.
Create
(
s
.
ctx
,
log
)
s
.
Require
()
.
NoError
(
err
)
got
,
err
:=
s
.
repo
.
GetByID
(
s
.
ctx
,
log
.
ID
)
s
.
Require
()
.
NoError
(
err
)
s
.
Require
()
.
NotNil
(
got
.
AccountRateMultiplier
)
s
.
Require
()
.
InEpsilon
(
0.5
,
*
got
.
AccountRateMultiplier
,
0.0001
)
}
// --- Delete ---
func
(
s
*
UsageLogRepoSuite
)
TestDelete
()
{
...
...
@@ -403,12 +432,49 @@ func (s *UsageLogRepoSuite) TestGetAccountTodayStats() {
apiKey
:=
mustCreateApiKey
(
s
.
T
(),
s
.
client
,
&
service
.
APIKey
{
UserID
:
user
.
ID
,
Key
:
"sk-acctoday"
,
Name
:
"k"
})
account
:=
mustCreateAccount
(
s
.
T
(),
s
.
client
,
&
service
.
Account
{
Name
:
"acc-today"
})
s
.
createUsageLog
(
user
,
apiKey
,
account
,
10
,
20
,
0.5
,
time
.
Now
())
createdAt
:=
timezone
.
Today
()
.
Add
(
1
*
time
.
Hour
)
m1
:=
1.5
m2
:=
0.0
_
,
err
:=
s
.
repo
.
Create
(
s
.
ctx
,
&
service
.
UsageLog
{
UserID
:
user
.
ID
,
APIKeyID
:
apiKey
.
ID
,
AccountID
:
account
.
ID
,
RequestID
:
uuid
.
New
()
.
String
(),
Model
:
"claude-3"
,
InputTokens
:
10
,
OutputTokens
:
20
,
TotalCost
:
1.0
,
ActualCost
:
2.0
,
AccountRateMultiplier
:
&
m1
,
CreatedAt
:
createdAt
,
})
s
.
Require
()
.
NoError
(
err
)
_
,
err
=
s
.
repo
.
Create
(
s
.
ctx
,
&
service
.
UsageLog
{
UserID
:
user
.
ID
,
APIKeyID
:
apiKey
.
ID
,
AccountID
:
account
.
ID
,
RequestID
:
uuid
.
New
()
.
String
(),
Model
:
"claude-3"
,
InputTokens
:
5
,
OutputTokens
:
5
,
TotalCost
:
0.5
,
ActualCost
:
1.0
,
AccountRateMultiplier
:
&
m2
,
CreatedAt
:
createdAt
,
})
s
.
Require
()
.
NoError
(
err
)
stats
,
err
:=
s
.
repo
.
GetAccountTodayStats
(
s
.
ctx
,
account
.
ID
)
s
.
Require
()
.
NoError
(
err
,
"GetAccountTodayStats"
)
s
.
Require
()
.
Equal
(
int64
(
1
),
stats
.
Requests
)
s
.
Require
()
.
Equal
(
int64
(
30
),
stats
.
Tokens
)
s
.
Require
()
.
Equal
(
int64
(
2
),
stats
.
Requests
)
s
.
Require
()
.
Equal
(
int64
(
40
),
stats
.
Tokens
)
// account cost = SUM(total_cost * account_rate_multiplier)
s
.
Require
()
.
InEpsilon
(
1.5
,
stats
.
Cost
,
0.0001
)
// standard cost = SUM(total_cost)
s
.
Require
()
.
InEpsilon
(
1.5
,
stats
.
StandardCost
,
0.0001
)
// user cost = SUM(actual_cost)
s
.
Require
()
.
InEpsilon
(
3.0
,
stats
.
UserCost
,
0.0001
)
}
func
(
s
*
UsageLogRepoSuite
)
TestDashboardAggregationConsistency
()
{
...
...
@@ -416,8 +482,8 @@ func (s *UsageLogRepoSuite) TestDashboardAggregationConsistency() {
// 使用固定的时间偏移确保 hour1 和 hour2 在同一天且都在过去
// 选择当天 02:00 和 03:00 作为测试时间点(基于 now 的日期)
dayStart
:=
truncateToDayUTC
(
now
)
hour1
:=
dayStart
.
Add
(
2
*
time
.
Hour
)
// 当天 02:00
hour2
:=
dayStart
.
Add
(
3
*
time
.
Hour
)
// 当天 03:00
hour1
:=
dayStart
.
Add
(
2
*
time
.
Hour
)
// 当天 02:00
hour2
:=
dayStart
.
Add
(
3
*
time
.
Hour
)
// 当天 03:00
// 如果当前时间早于 hour2,则使用昨天的时间
if
now
.
Before
(
hour2
.
Add
(
time
.
Hour
))
{
dayStart
=
dayStart
.
Add
(
-
24
*
time
.
Hour
)
...
...
backend/internal/server/api_contract_test.go
View file @
2a5ef6d3
...
...
@@ -239,9 +239,10 @@ func TestAPIContracts(t *testing.T) {
"cache_creation_cost": 0,
"cache_read_cost": 0,
"total_cost": 0.5,
"actual_cost": 0.5,
"rate_multiplier": 1,
"billing_type": 0,
"actual_cost": 0.5,
"rate_multiplier": 1,
"account_rate_multiplier": null,
"billing_type": 0,
"stream": true,
"duration_ms": 100,
"first_token_ms": 50,
...
...
@@ -262,11 +263,11 @@ func TestAPIContracts(t *testing.T) {
name
:
"GET /api/v1/admin/settings"
,
setup
:
func
(
t
*
testing
.
T
,
deps
*
contractDeps
)
{
t
.
Helper
()
deps
.
settingRepo
.
SetAll
(
map
[
string
]
string
{
service
.
SettingKeyRegistrationEnabled
:
"true"
,
service
.
SettingKeyEmailVerifyEnabled
:
"false"
,
deps
.
settingRepo
.
SetAll
(
map
[
string
]
string
{
service
.
SettingKeyRegistrationEnabled
:
"true"
,
service
.
SettingKeyEmailVerifyEnabled
:
"false"
,
service
.
SettingKeySMTPHost
:
"smtp.example.com"
,
service
.
SettingKeySMTPHost
:
"smtp.example.com"
,
service
.
SettingKeySMTPPort
:
"587"
,
service
.
SettingKeySMTPUsername
:
"user"
,
service
.
SettingKeySMTPPassword
:
"secret"
,
...
...
@@ -285,15 +286,15 @@ func TestAPIContracts(t *testing.T) {
service
.
SettingKeyContactInfo
:
"support"
,
service
.
SettingKeyDocURL
:
"https://docs.example.com"
,
service
.
SettingKeyDefaultConcurrency
:
"5"
,
service
.
SettingKeyDefaultBalance
:
"1.25"
,
service
.
SettingKeyDefaultConcurrency
:
"5"
,
service
.
SettingKeyDefaultBalance
:
"1.25"
,
service
.
SettingKeyOpsMonitoringEnabled
:
"false"
,
service
.
SettingKeyOpsRealtimeMonitoringEnabled
:
"true"
,
service
.
SettingKeyOpsQueryModeDefault
:
"auto"
,
service
.
SettingKeyOpsMetricsIntervalSeconds
:
"60"
,
})
},
service
.
SettingKeyOpsMonitoringEnabled
:
"false"
,
service
.
SettingKeyOpsRealtimeMonitoringEnabled
:
"true"
,
service
.
SettingKeyOpsQueryModeDefault
:
"auto"
,
service
.
SettingKeyOpsMetricsIntervalSeconds
:
"60"
,
})
},
method
:
http
.
MethodGet
,
path
:
"/api/v1/admin/settings"
,
wantStatus
:
http
.
StatusOK
,
...
...
backend/internal/service/account.go
View file @
2a5ef6d3
...
...
@@ -9,16 +9,19 @@ import (
)
type
Account
struct
{
ID
int64
Name
string
Notes
*
string
Platform
string
Type
string
Credentials
map
[
string
]
any
Extra
map
[
string
]
any
ProxyID
*
int64
Concurrency
int
Priority
int
ID
int64
Name
string
Notes
*
string
Platform
string
Type
string
Credentials
map
[
string
]
any
Extra
map
[
string
]
any
ProxyID
*
int64
Concurrency
int
Priority
int
// RateMultiplier 账号计费倍率(>=0,允许 0 表示该账号计费为 0)。
// 使用指针用于兼容旧版本调度缓存(Redis)中缺字段的情况:nil 表示按 1.0 处理。
RateMultiplier
*
float64
Status
string
ErrorMessage
string
LastUsedAt
*
time
.
Time
...
...
@@ -57,6 +60,20 @@ func (a *Account) IsActive() bool {
return
a
.
Status
==
StatusActive
}
// BillingRateMultiplier 返回账号计费倍率。
// - nil 表示未配置/旧缓存缺字段,按 1.0 处理
// - 允许 0,表示该账号计费为 0
// - 负数属于非法数据,出于安全考虑按 1.0 处理
func
(
a
*
Account
)
BillingRateMultiplier
()
float64
{
if
a
==
nil
||
a
.
RateMultiplier
==
nil
{
return
1.0
}
if
*
a
.
RateMultiplier
<
0
{
return
1.0
}
return
*
a
.
RateMultiplier
}
func
(
a
*
Account
)
IsSchedulable
()
bool
{
if
!
a
.
IsActive
()
||
!
a
.
Schedulable
{
return
false
...
...
backend/internal/service/account_billing_rate_multiplier_test.go
0 → 100644
View file @
2a5ef6d3
package
service
import
(
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func
TestAccount_BillingRateMultiplier_DefaultsToOneWhenNil
(
t
*
testing
.
T
)
{
var
a
Account
require
.
NoError
(
t
,
json
.
Unmarshal
([]
byte
(
`{"id":1,"name":"acc","status":"active"}`
),
&
a
))
require
.
Nil
(
t
,
a
.
RateMultiplier
)
require
.
Equal
(
t
,
1.0
,
a
.
BillingRateMultiplier
())
}
func
TestAccount_BillingRateMultiplier_AllowsZero
(
t
*
testing
.
T
)
{
v
:=
0.0
a
:=
Account
{
RateMultiplier
:
&
v
}
require
.
Equal
(
t
,
0.0
,
a
.
BillingRateMultiplier
())
}
func
TestAccount_BillingRateMultiplier_NegativeFallsBackToOne
(
t
*
testing
.
T
)
{
v
:=
-
1.0
a
:=
Account
{
RateMultiplier
:
&
v
}
require
.
Equal
(
t
,
1.0
,
a
.
BillingRateMultiplier
())
}
backend/internal/service/account_service.go
View file @
2a5ef6d3
...
...
@@ -63,14 +63,15 @@ type AccountRepository interface {
// AccountBulkUpdate describes the fields that can be updated in a bulk operation.
// Nil pointers mean "do not change".
type
AccountBulkUpdate
struct
{
Name
*
string
ProxyID
*
int64
Concurrency
*
int
Priority
*
int
Status
*
string
Schedulable
*
bool
Credentials
map
[
string
]
any
Extra
map
[
string
]
any
Name
*
string
ProxyID
*
int64
Concurrency
*
int
Priority
*
int
RateMultiplier
*
float64
Status
*
string
Schedulable
*
bool
Credentials
map
[
string
]
any
Extra
map
[
string
]
any
}
// CreateAccountRequest 创建账号请求
...
...
backend/internal/service/account_usage_service.go
View file @
2a5ef6d3
...
...
@@ -96,10 +96,16 @@ func NewUsageCache() *UsageCache {
}
// WindowStats 窗口期统计
//
// cost: 账号口径费用(total_cost * account_rate_multiplier)
// standard_cost: 标准费用(total_cost,不含倍率)
// user_cost: 用户/API Key 口径费用(actual_cost,受分组倍率影响)
type
WindowStats
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"`
}
// UsageProgress 使用量进度
...
...
@@ -377,9 +383,11 @@ func (s *AccountUsageService) addWindowStats(ctx context.Context, account *Accou
}
windowStats
=
&
WindowStats
{
Requests
:
stats
.
Requests
,
Tokens
:
stats
.
Tokens
,
Cost
:
stats
.
Cost
,
Requests
:
stats
.
Requests
,
Tokens
:
stats
.
Tokens
,
Cost
:
stats
.
Cost
,
StandardCost
:
stats
.
StandardCost
,
UserCost
:
stats
.
UserCost
,
}
// 缓存窗口统计(1 分钟)
...
...
@@ -403,9 +411,11 @@ func (s *AccountUsageService) GetTodayStats(ctx context.Context, accountID int64
}
return
&
WindowStats
{
Requests
:
stats
.
Requests
,
Tokens
:
stats
.
Tokens
,
Cost
:
stats
.
Cost
,
Requests
:
stats
.
Requests
,
Tokens
:
stats
.
Tokens
,
Cost
:
stats
.
Cost
,
StandardCost
:
stats
.
StandardCost
,
UserCost
:
stats
.
UserCost
,
},
nil
}
...
...
backend/internal/service/admin_service.go
View file @
2a5ef6d3
...
...
@@ -136,6 +136,7 @@ type CreateAccountInput struct {
ProxyID
*
int64
Concurrency
int
Priority
int
RateMultiplier
*
float64
// 账号计费倍率(>=0,允许 0)
GroupIDs
[]
int64
ExpiresAt
*
int64
AutoPauseOnExpired
*
bool
...
...
@@ -151,8 +152,9 @@ type UpdateAccountInput struct {
Credentials
map
[
string
]
any
Extra
map
[
string
]
any
ProxyID
*
int64
Concurrency
*
int
// 使用指针区分"未提供"和"设置为0"
Priority
*
int
// 使用指针区分"未提供"和"设置为0"
Concurrency
*
int
// 使用指针区分"未提供"和"设置为0"
Priority
*
int
// 使用指针区分"未提供"和"设置为0"
RateMultiplier
*
float64
// 账号计费倍率(>=0,允许 0)
Status
string
GroupIDs
*
[]
int64
ExpiresAt
*
int64
...
...
@@ -162,16 +164,17 @@ type UpdateAccountInput struct {
// BulkUpdateAccountsInput describes the payload for bulk updating accounts.
type
BulkUpdateAccountsInput
struct
{
AccountIDs
[]
int64
Name
string
ProxyID
*
int64
Concurrency
*
int
Priority
*
int
Status
string
Schedulable
*
bool
GroupIDs
*
[]
int64
Credentials
map
[
string
]
any
Extra
map
[
string
]
any
AccountIDs
[]
int64
Name
string
ProxyID
*
int64
Concurrency
*
int
Priority
*
int
RateMultiplier
*
float64
// 账号计费倍率(>=0,允许 0)
Status
string
Schedulable
*
bool
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
...
...
@@ -817,6 +820,12 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
}
else
{
account
.
AutoPauseOnExpired
=
true
}
if
input
.
RateMultiplier
!=
nil
{
if
*
input
.
RateMultiplier
<
0
{
return
nil
,
errors
.
New
(
"rate_multiplier must be >= 0"
)
}
account
.
RateMultiplier
=
input
.
RateMultiplier
}
if
err
:=
s
.
accountRepo
.
Create
(
ctx
,
account
);
err
!=
nil
{
return
nil
,
err
}
...
...
@@ -869,6 +878,12 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
if
input
.
Priority
!=
nil
{
account
.
Priority
=
*
input
.
Priority
}
if
input
.
RateMultiplier
!=
nil
{
if
*
input
.
RateMultiplier
<
0
{
return
nil
,
errors
.
New
(
"rate_multiplier must be >= 0"
)
}
account
.
RateMultiplier
=
input
.
RateMultiplier
}
if
input
.
Status
!=
""
{
account
.
Status
=
input
.
Status
}
...
...
@@ -942,6 +957,12 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
}
}
if
input
.
RateMultiplier
!=
nil
{
if
*
input
.
RateMultiplier
<
0
{
return
nil
,
errors
.
New
(
"rate_multiplier must be >= 0"
)
}
}
// Prepare bulk updates for columns and JSONB fields.
repoUpdates
:=
AccountBulkUpdate
{
Credentials
:
input
.
Credentials
,
...
...
@@ -959,6 +980,9 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
if
input
.
Priority
!=
nil
{
repoUpdates
.
Priority
=
input
.
Priority
}
if
input
.
RateMultiplier
!=
nil
{
repoUpdates
.
RateMultiplier
=
input
.
RateMultiplier
}
if
input
.
Status
!=
""
{
repoUpdates
.
Status
=
&
input
.
Status
}
...
...
backend/internal/service/gateway_multiplatform_test.go
View file @
2a5ef6d3
...
...
@@ -1211,6 +1211,72 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
require
.
Nil
(
t
,
result
)
require
.
Contains
(
t
,
err
.
Error
(),
"no available accounts"
)
})
t
.
Run
(
"过滤不可调度账号-限流账号被跳过"
,
func
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
resetAt
:=
now
.
Add
(
10
*
time
.
Minute
)
repo
:=
&
mockAccountRepoForPlatform
{
accounts
:
[]
Account
{
{
ID
:
1
,
Platform
:
PlatformAnthropic
,
Priority
:
1
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
5
,
RateLimitResetAt
:
&
resetAt
},
{
ID
:
2
,
Platform
:
PlatformAnthropic
,
Priority
:
2
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
5
},
},
accountsByID
:
map
[
int64
]
*
Account
{},
}
for
i
:=
range
repo
.
accounts
{
repo
.
accountsByID
[
repo
.
accounts
[
i
]
.
ID
]
=
&
repo
.
accounts
[
i
]
}
cache
:=
&
mockGatewayCacheForPlatform
{}
cfg
:=
testConfig
()
cfg
.
Gateway
.
Scheduling
.
LoadBatchEnabled
=
false
svc
:=
&
GatewayService
{
accountRepo
:
repo
,
cache
:
cache
,
cfg
:
cfg
,
concurrencyService
:
nil
,
}
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
""
,
"claude-3-5-sonnet-20241022"
,
nil
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
.
Account
)
require
.
Equal
(
t
,
int64
(
2
),
result
.
Account
.
ID
,
"应跳过限流账号,选择可用账号"
)
})
t
.
Run
(
"过滤不可调度账号-过载账号被跳过"
,
func
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
overloadUntil
:=
now
.
Add
(
10
*
time
.
Minute
)
repo
:=
&
mockAccountRepoForPlatform
{
accounts
:
[]
Account
{
{
ID
:
1
,
Platform
:
PlatformAnthropic
,
Priority
:
1
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
5
,
OverloadUntil
:
&
overloadUntil
},
{
ID
:
2
,
Platform
:
PlatformAnthropic
,
Priority
:
2
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
5
},
},
accountsByID
:
map
[
int64
]
*
Account
{},
}
for
i
:=
range
repo
.
accounts
{
repo
.
accountsByID
[
repo
.
accounts
[
i
]
.
ID
]
=
&
repo
.
accounts
[
i
]
}
cache
:=
&
mockGatewayCacheForPlatform
{}
cfg
:=
testConfig
()
cfg
.
Gateway
.
Scheduling
.
LoadBatchEnabled
=
false
svc
:=
&
GatewayService
{
accountRepo
:
repo
,
cache
:
cache
,
cfg
:
cfg
,
concurrencyService
:
nil
,
}
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
""
,
"claude-3-5-sonnet-20241022"
,
nil
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
.
Account
)
require
.
Equal
(
t
,
int64
(
2
),
result
.
Account
.
ID
,
"应跳过过载账号,选择可用账号"
)
})
}
func
TestGatewayService_GroupResolution_ReusesContextGroup
(
t
*
testing
.
T
)
{
...
...
backend/internal/service/gateway_service.go
View file @
2a5ef6d3
...
...
@@ -511,6 +511,12 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if
isExcluded
(
acc
.
ID
)
{
continue
}
// Scheduler snapshots can be temporarily stale (bucket rebuild is throttled);
// re-check schedulability here so recently rate-limited/overloaded accounts
// are not selected again before the bucket is rebuilt.
if
!
acc
.
IsSchedulable
()
{
continue
}
if
!
s
.
isAccountAllowedForPlatform
(
acc
,
platform
,
useMixed
)
{
continue
}
...
...
@@ -893,6 +899,11 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
if
_
,
excluded
:=
excludedIDs
[
acc
.
ID
];
excluded
{
continue
}
// Scheduler snapshots can be temporarily stale; re-check schedulability here to
// avoid selecting accounts that were recently rate-limited/overloaded.
if
!
acc
.
IsSchedulable
()
{
continue
}
if
!
acc
.
IsSchedulableForModel
(
requestedModel
)
{
continue
}
...
...
@@ -977,6 +988,11 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
if
_
,
excluded
:=
excludedIDs
[
acc
.
ID
];
excluded
{
continue
}
// Scheduler snapshots can be temporarily stale; re-check schedulability here to
// avoid selecting accounts that were recently rate-limited/overloaded.
if
!
acc
.
IsSchedulable
()
{
continue
}
// 过滤:原生平台直接通过,antigravity 需要启用混合调度
if
acc
.
Platform
==
PlatformAntigravity
&&
!
acc
.
IsMixedSchedulingEnabled
()
{
continue
...
...
@@ -2618,30 +2634,32 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
if
result
.
ImageSize
!=
""
{
imageSize
=
&
result
.
ImageSize
}
accountRateMultiplier
:=
account
.
BillingRateMultiplier
()
usageLog
:=
&
UsageLog
{
UserID
:
user
.
ID
,
APIKeyID
:
apiKey
.
ID
,
AccountID
:
account
.
ID
,
RequestID
:
result
.
RequestID
,
Model
:
result
.
Model
,
InputTokens
:
result
.
Usage
.
InputTokens
,
OutputTokens
:
result
.
Usage
.
OutputTokens
,
CacheCreationTokens
:
result
.
Usage
.
CacheCreationInputTokens
,
CacheReadTokens
:
result
.
Usage
.
CacheReadInputTokens
,
InputCost
:
cost
.
InputCost
,
OutputCost
:
cost
.
OutputCost
,
CacheCreationCost
:
cost
.
CacheCreationCost
,
CacheReadCost
:
cost
.
CacheReadCost
,
TotalCost
:
cost
.
TotalCost
,
ActualCost
:
cost
.
ActualCost
,
RateMultiplier
:
multiplier
,
BillingType
:
billingType
,
Stream
:
result
.
Stream
,
DurationMs
:
&
durationMs
,
FirstTokenMs
:
result
.
FirstTokenMs
,
ImageCount
:
result
.
ImageCount
,
ImageSize
:
imageSize
,
CreatedAt
:
time
.
Now
(),
UserID
:
user
.
ID
,
APIKeyID
:
apiKey
.
ID
,
AccountID
:
account
.
ID
,
RequestID
:
result
.
RequestID
,
Model
:
result
.
Model
,
InputTokens
:
result
.
Usage
.
InputTokens
,
OutputTokens
:
result
.
Usage
.
OutputTokens
,
CacheCreationTokens
:
result
.
Usage
.
CacheCreationInputTokens
,
CacheReadTokens
:
result
.
Usage
.
CacheReadInputTokens
,
InputCost
:
cost
.
InputCost
,
OutputCost
:
cost
.
OutputCost
,
CacheCreationCost
:
cost
.
CacheCreationCost
,
CacheReadCost
:
cost
.
CacheReadCost
,
TotalCost
:
cost
.
TotalCost
,
ActualCost
:
cost
.
ActualCost
,
RateMultiplier
:
multiplier
,
AccountRateMultiplier
:
&
accountRateMultiplier
,
BillingType
:
billingType
,
Stream
:
result
.
Stream
,
DurationMs
:
&
durationMs
,
FirstTokenMs
:
result
.
FirstTokenMs
,
ImageCount
:
result
.
ImageCount
,
ImageSize
:
imageSize
,
CreatedAt
:
time
.
Now
(),
}
// 添加 UserAgent
...
...
backend/internal/service/openai_gateway_service.go
View file @
2a5ef6d3
...
...
@@ -186,6 +186,11 @@ func (s *OpenAIGatewayService) SelectAccountForModelWithExclusions(ctx context.C
if
_
,
excluded
:=
excludedIDs
[
acc
.
ID
];
excluded
{
continue
}
// Scheduler snapshots can be temporarily stale; re-check schedulability here to
// avoid selecting accounts that were recently rate-limited/overloaded.
if
!
acc
.
IsSchedulable
()
{
continue
}
// Check model support
if
requestedModel
!=
""
&&
!
acc
.
IsModelSupported
(
requestedModel
)
{
continue
...
...
@@ -332,6 +337,12 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex
if
isExcluded
(
acc
.
ID
)
{
continue
}
// Scheduler snapshots can be temporarily stale (bucket rebuild is throttled);
// re-check schedulability here so recently rate-limited/overloaded accounts
// are not selected again before the bucket is rebuilt.
if
!
acc
.
IsSchedulable
()
{
continue
}
if
requestedModel
!=
""
&&
!
acc
.
IsModelSupported
(
requestedModel
)
{
continue
}
...
...
@@ -1432,28 +1443,30 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
// Create usage log
durationMs
:=
int
(
result
.
Duration
.
Milliseconds
())
accountRateMultiplier
:=
account
.
BillingRateMultiplier
()
usageLog
:=
&
UsageLog
{
UserID
:
user
.
ID
,
APIKeyID
:
apiKey
.
ID
,
AccountID
:
account
.
ID
,
RequestID
:
result
.
RequestID
,
Model
:
result
.
Model
,
InputTokens
:
actualInputTokens
,
OutputTokens
:
result
.
Usage
.
OutputTokens
,
CacheCreationTokens
:
result
.
Usage
.
CacheCreationInputTokens
,
CacheReadTokens
:
result
.
Usage
.
CacheReadInputTokens
,
InputCost
:
cost
.
InputCost
,
OutputCost
:
cost
.
OutputCost
,
CacheCreationCost
:
cost
.
CacheCreationCost
,
CacheReadCost
:
cost
.
CacheReadCost
,
TotalCost
:
cost
.
TotalCost
,
ActualCost
:
cost
.
ActualCost
,
RateMultiplier
:
multiplier
,
BillingType
:
billingType
,
Stream
:
result
.
Stream
,
DurationMs
:
&
durationMs
,
FirstTokenMs
:
result
.
FirstTokenMs
,
CreatedAt
:
time
.
Now
(),
UserID
:
user
.
ID
,
APIKeyID
:
apiKey
.
ID
,
AccountID
:
account
.
ID
,
RequestID
:
result
.
RequestID
,
Model
:
result
.
Model
,
InputTokens
:
actualInputTokens
,
OutputTokens
:
result
.
Usage
.
OutputTokens
,
CacheCreationTokens
:
result
.
Usage
.
CacheCreationInputTokens
,
CacheReadTokens
:
result
.
Usage
.
CacheReadInputTokens
,
InputCost
:
cost
.
InputCost
,
OutputCost
:
cost
.
OutputCost
,
CacheCreationCost
:
cost
.
CacheCreationCost
,
CacheReadCost
:
cost
.
CacheReadCost
,
TotalCost
:
cost
.
TotalCost
,
ActualCost
:
cost
.
ActualCost
,
RateMultiplier
:
multiplier
,
AccountRateMultiplier
:
&
accountRateMultiplier
,
BillingType
:
billingType
,
Stream
:
result
.
Stream
,
DurationMs
:
&
durationMs
,
FirstTokenMs
:
result
.
FirstTokenMs
,
CreatedAt
:
time
.
Now
(),
}
// 添加 UserAgent
...
...
backend/internal/service/openai_gateway_service_test.go
View file @
2a5ef6d3
...
...
@@ -3,6 +3,7 @@ package service
import
(
"bufio"
"bytes"
"context"
"errors"
"io"
"net/http"
...
...
@@ -15,6 +16,129 @@ import (
"github.com/gin-gonic/gin"
)
type
stubOpenAIAccountRepo
struct
{
AccountRepository
accounts
[]
Account
}
func
(
r
stubOpenAIAccountRepo
)
ListSchedulableByGroupIDAndPlatform
(
ctx
context
.
Context
,
groupID
int64
,
platform
string
)
([]
Account
,
error
)
{
return
append
([]
Account
(
nil
),
r
.
accounts
...
),
nil
}
func
(
r
stubOpenAIAccountRepo
)
ListSchedulableByPlatform
(
ctx
context
.
Context
,
platform
string
)
([]
Account
,
error
)
{
return
append
([]
Account
(
nil
),
r
.
accounts
...
),
nil
}
type
stubConcurrencyCache
struct
{
ConcurrencyCache
}
func
(
c
stubConcurrencyCache
)
AcquireAccountSlot
(
ctx
context
.
Context
,
accountID
int64
,
maxConcurrency
int
,
requestID
string
)
(
bool
,
error
)
{
return
true
,
nil
}
func
(
c
stubConcurrencyCache
)
ReleaseAccountSlot
(
ctx
context
.
Context
,
accountID
int64
,
requestID
string
)
error
{
return
nil
}
func
(
c
stubConcurrencyCache
)
GetAccountsLoadBatch
(
ctx
context
.
Context
,
accounts
[]
AccountWithConcurrency
)
(
map
[
int64
]
*
AccountLoadInfo
,
error
)
{
out
:=
make
(
map
[
int64
]
*
AccountLoadInfo
,
len
(
accounts
))
for
_
,
acc
:=
range
accounts
{
out
[
acc
.
ID
]
=
&
AccountLoadInfo
{
AccountID
:
acc
.
ID
,
LoadRate
:
0
}
}
return
out
,
nil
}
func
TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulable
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
resetAt
:=
now
.
Add
(
10
*
time
.
Minute
)
groupID
:=
int64
(
1
)
rateLimited
:=
Account
{
ID
:
1
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeAPIKey
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
1
,
Priority
:
0
,
RateLimitResetAt
:
&
resetAt
,
}
available
:=
Account
{
ID
:
2
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeAPIKey
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
1
,
Priority
:
1
,
}
svc
:=
&
OpenAIGatewayService
{
accountRepo
:
stubOpenAIAccountRepo
{
accounts
:
[]
Account
{
rateLimited
,
available
}},
concurrencyService
:
NewConcurrencyService
(
stubConcurrencyCache
{}),
}
selection
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
context
.
Background
(),
&
groupID
,
""
,
"gpt-5.2"
,
nil
)
if
err
!=
nil
{
t
.
Fatalf
(
"SelectAccountWithLoadAwareness error: %v"
,
err
)
}
if
selection
==
nil
||
selection
.
Account
==
nil
{
t
.
Fatalf
(
"expected selection with account"
)
}
if
selection
.
Account
.
ID
!=
available
.
ID
{
t
.
Fatalf
(
"expected account %d, got %d"
,
available
.
ID
,
selection
.
Account
.
ID
)
}
if
selection
.
ReleaseFunc
!=
nil
{
selection
.
ReleaseFunc
()
}
}
func
TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulableWhenNoConcurrencyService
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
resetAt
:=
now
.
Add
(
10
*
time
.
Minute
)
groupID
:=
int64
(
1
)
rateLimited
:=
Account
{
ID
:
1
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeAPIKey
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
1
,
Priority
:
0
,
RateLimitResetAt
:
&
resetAt
,
}
available
:=
Account
{
ID
:
2
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeAPIKey
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
1
,
Priority
:
1
,
}
svc
:=
&
OpenAIGatewayService
{
accountRepo
:
stubOpenAIAccountRepo
{
accounts
:
[]
Account
{
rateLimited
,
available
}},
// concurrencyService is nil, forcing the non-load-batch selection path.
}
selection
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
context
.
Background
(),
&
groupID
,
""
,
"gpt-5.2"
,
nil
)
if
err
!=
nil
{
t
.
Fatalf
(
"SelectAccountWithLoadAwareness error: %v"
,
err
)
}
if
selection
==
nil
||
selection
.
Account
==
nil
{
t
.
Fatalf
(
"expected selection with account"
)
}
if
selection
.
Account
.
ID
!=
available
.
ID
{
t
.
Fatalf
(
"expected account %d, got %d"
,
available
.
ID
,
selection
.
Account
.
ID
)
}
if
selection
.
ReleaseFunc
!=
nil
{
selection
.
ReleaseFunc
()
}
}
func
TestOpenAIStreamingTimeout
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
cfg
:=
&
config
.
Config
{
...
...
backend/internal/service/usage_log.go
View file @
2a5ef6d3
...
...
@@ -33,6 +33,8 @@ type UsageLog struct {
TotalCost
float64
ActualCost
float64
RateMultiplier
float64
// AccountRateMultiplier 账号计费倍率快照(nil 表示历史数据,按 1.0 处理)
AccountRateMultiplier
*
float64
BillingType
int8
Stream
bool
...
...
backend/migrations/037_add_account_rate_multiplier.sql
0 → 100644
View file @
2a5ef6d3
-- Add account billing rate multiplier and per-usage snapshot.
--
-- accounts.rate_multiplier: 账号计费倍率(>=0,允许 0 表示该账号计费为 0)。
-- usage_logs.account_rate_multiplier: 每条 usage log 的账号倍率快照,用于实现
-- “倍率调整仅影响之后请求”,并支持同一天分段倍率加权统计。
--
-- 注意:usage_logs.account_rate_multiplier 不做回填、不设置 NOT NULL。
-- 老数据为 NULL 时,统计口径按 1.0 处理(COALESCE)。
ALTER
TABLE
IF
EXISTS
accounts
ADD
COLUMN
IF
NOT
EXISTS
rate_multiplier
DECIMAL
(
10
,
4
)
NOT
NULL
DEFAULT
1
.
0
;
ALTER
TABLE
IF
EXISTS
usage_logs
ADD
COLUMN
IF
NOT
EXISTS
account_rate_multiplier
DECIMAL
(
10
,
4
);
frontend/src/api/admin/usage.ts
View file @
2a5ef6d3
...
...
@@ -16,6 +16,7 @@ export interface AdminUsageStatsResponse {
total_tokens
:
number
total_cost
:
number
total_actual_cost
:
number
total_account_cost
?:
number
average_duration_ms
:
number
}
...
...
frontend/src/components/account/AccountStatsModal.vue
View file @
2a5ef6d3
...
...
@@ -73,11 +73,12 @@
</p>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.accumulatedCost
'
)
}}
<span
class=
"text-gray-400 dark:text-gray-500"
>
(
{{
t
(
'
admin.accounts.stats.standardCost
'
)
}}
: $
{{
<span
class=
"text-gray-400 dark:text-gray-500"
>
(
{{
t
(
'
usage.userBilled
'
)
}}
: $
{{
formatCost
(
stats
.
summary
.
total_user_cost
)
}}
·
{{
t
(
'
admin.accounts.stats.standardCost
'
)
}}
: $
{{
formatCost
(
stats
.
summary
.
total_standard_cost
)
}}
)
</span
>
}}
)
</span
>
</p>
</div>
...
...
@@ -121,12 +122,15 @@
<p
class=
"text-2xl font-bold text-gray-900 dark:text-white"
>
$
{{
formatCost
(
stats
.
summary
.
avg_daily_cost
)
}}
</p>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
<p
class=
"mt-1 text-xs text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.basedOnActualDays
'
,
{
days
:
stats
.
summary
.
actual_days_used
}
)
}}
<
span
class
=
"
text-gray-400 dark:text-gray-500
"
>
({{
t
(
'
usage.userBilled
'
)
}}
:
$
{{
formatCost
(
stats
.
summary
.
avg_daily_user_cost
)
}}
)
<
/span
>
<
/p
>
<
/div
>
...
...
@@ -189,13 +193,17 @@
<
/div
>
<
div
class
=
"
space-y-2
"
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
<
/span
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.accountBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.userBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
user_cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.requests
'
)
...
...
@@ -240,13 +248,17 @@
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
<
/span
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.accountBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-orange-600 dark:text-orange-400
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_cost_day
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.userBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_cost_day
?.
user_cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.requests
'
)
...
...
@@ -291,13 +303,17 @@
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
<
/span
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.accountBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_request_day
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.userBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
highest_request_day
?.
user_cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
...
...
@@ -397,13 +413,17 @@
}}
<
/span
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
admin.accounts.stats.todayCost
'
)
}}
<
/span
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.accountBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
div
class
=
"
flex items-center justify-between
"
>
<
span
class
=
"
text-xs text-gray-500 dark:text-gray-400
"
>
{{
t
(
'
usage.userBilled
'
)
}}
<
/span
>
<
span
class
=
"
text-sm font-semibold text-gray-900 dark:text-white
"
>
$
{{
formatCost
(
stats
.
summary
.
today
?.
user_cost
||
0
)
}}
<
/spa
n
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
...
...
@@ -517,14 +537,24 @@ const trendChartData = computed(() => {
labels
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
label
),
datasets
:
[
{
label
:
t
(
'
admin
.account
s.stats.cost
'
)
+
'
(USD)
'
,
data
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
cost
),
label
:
t
(
'
usage
.account
Billed
'
)
+
'
(USD)
'
,
data
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
actual_
cost
),
borderColor
:
'
#3b82f6
'
,
backgroundColor
:
'
rgba(59, 130, 246, 0.1)
'
,
fill
:
true
,
tension
:
0.3
,
yAxisID
:
'
y
'
}
,
{
label
:
t
(
'
usage.userBilled
'
)
+
'
(USD)
'
,
data
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
user_cost
),
borderColor
:
'
#10b981
'
,
backgroundColor
:
'
rgba(16, 185, 129, 0.08)
'
,
fill
:
false
,
tension
:
0.3
,
borderDash
:
[
5
,
5
],
yAxisID
:
'
y
'
}
,
{
label
:
t
(
'
admin.accounts.stats.requests
'
),
data
:
stats
.
value
.
history
.
map
((
h
)
=>
h
.
requests
),
...
...
@@ -602,7 +632,7 @@ const lineChartOptions = computed(() => ({
}
,
title
:
{
display
:
true
,
text
:
t
(
'
admin
.account
s.stats.cost
'
)
+
'
(USD)
'
,
text
:
t
(
'
usage
.account
Billed
'
)
+
'
(USD)
'
,
color
:
'
#3b82f6
'
,
font
:
{
size
:
11
...
...
frontend/src/components/account/AccountTodayStatsCell.vue
View file @
2a5ef6d3
...
...
@@ -32,15 +32,20 @@
formatTokens
(
stats
.
tokens
)
}}
</span>
</div>
<!-- Cost -->
<!-- Cost
(Account)
-->
<div
class=
"flex items-center gap-1"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
admin.accounts.stats.cost
'
)
}}
:
</span
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.accountBilled
'
)
}}
:
</span>
<span
class=
"font-medium text-emerald-600 dark:text-emerald-400"
>
{{
formatCurrency
(
stats
.
cost
)
}}
</span>
</div>
<!-- Cost (User/API Key) -->
<div
v-if=
"stats.user_cost != null"
class=
"flex items-center gap-1"
>
<span
class=
"text-gray-500 dark:text-gray-400"
>
{{
t
(
'
usage.userBilled
'
)
}}
:
</span>
<span
class=
"font-medium text-gray-700 dark:text-gray-300"
>
{{
formatCurrency
(
stats
.
user_cost
)
}}
</span>
</div>
</div>
<!-- No data -->
...
...
frontend/src/components/account/BulkEditAccountModal.vue
View file @
2a5ef6d3
...
...
@@ -459,7 +459,7 @@
<
/div
>
<!--
Concurrency
&
Priority
-->
<
div
class
=
"
grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600
"
>
<
div
class
=
"
grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600
lg:grid-cols-3
"
>
<
div
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
<
label
...
...
@@ -516,6 +516,36 @@
aria
-
labelledby
=
"
bulk-edit-priority-label
"
/>
<
/div
>
<
div
>
<
div
class
=
"
mb-3 flex items-center justify-between
"
>
<
label
id
=
"
bulk-edit-rate-multiplier-label
"
class
=
"
input-label mb-0
"
for
=
"
bulk-edit-rate-multiplier-enabled
"
>
{{
t
(
'
admin.accounts.billingRateMultiplier
'
)
}}
<
/label
>
<
input
v
-
model
=
"
enableRateMultiplier
"
id
=
"
bulk-edit-rate-multiplier-enabled
"
type
=
"
checkbox
"
aria
-
controls
=
"
bulk-edit-rate-multiplier
"
class
=
"
rounded border-gray-300 text-primary-600 focus:ring-primary-500
"
/>
<
/div
>
<
input
v
-
model
.
number
=
"
rateMultiplier
"
id
=
"
bulk-edit-rate-multiplier
"
type
=
"
number
"
min
=
"
0
"
step
=
"
0.01
"
:
disabled
=
"
!enableRateMultiplier
"
class
=
"
input
"
:
class
=
"
!enableRateMultiplier && 'cursor-not-allowed opacity-50'
"
aria
-
labelledby
=
"
bulk-edit-rate-multiplier-label
"
/>
<
p
class
=
"
input-hint
"
>
{{
t
(
'
admin.accounts.billingRateMultiplierHint
'
)
}}
<
/p
>
<
/div
>
<
/div
>
<!--
Status
-->
...
...
@@ -655,6 +685,7 @@ const enableInterceptWarmup = ref(false)
const
enableProxy
=
ref
(
false
)
const
enableConcurrency
=
ref
(
false
)
const
enablePriority
=
ref
(
false
)
const
enableRateMultiplier
=
ref
(
false
)
const
enableStatus
=
ref
(
false
)
const
enableGroups
=
ref
(
false
)
...
...
@@ -670,6 +701,7 @@ const interceptWarmupRequests = ref(false)
const
proxyId
=
ref
<
number
|
null
>
(
null
)
const
concurrency
=
ref
(
1
)
const
priority
=
ref
(
1
)
const
rateMultiplier
=
ref
(
1
)
const
status
=
ref
<
'
active
'
|
'
inactive
'
>
(
'
active
'
)
const
groupIds
=
ref
<
number
[]
>
([])
...
...
@@ -863,6 +895,10 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
updates
.
priority
=
priority
.
value
}
if
(
enableRateMultiplier
.
value
)
{
updates
.
rate_multiplier
=
rateMultiplier
.
value
}
if
(
enableStatus
.
value
)
{
updates
.
status
=
status
.
value
}
...
...
@@ -923,6 +959,7 @@ const handleSubmit = async () => {
enableProxy
.
value
||
enableConcurrency
.
value
||
enablePriority
.
value
||
enableRateMultiplier
.
value
||
enableStatus
.
value
||
enableGroups
.
value
...
...
@@ -977,6 +1014,7 @@ watch(
enableProxy
.
value
=
false
enableConcurrency
.
value
=
false
enablePriority
.
value
=
false
enableRateMultiplier
.
value
=
false
enableStatus
.
value
=
false
enableGroups
.
value
=
false
...
...
@@ -991,6 +1029,7 @@ watch(
proxyId
.
value
=
null
concurrency
.
value
=
1
priority
.
value
=
1
rateMultiplier
.
value
=
1
status
.
value
=
'
active
'
groupIds
.
value
=
[]
}
...
...
Prev
1
2
3
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