Commit fb99ceac authored by 墨颜's avatar 墨颜
Browse files

feat(计费): 支持账号计费倍率快照与统计展示

- 新增 accounts.rate_multiplier(默认 1.0,允许 0)
- 使用 usage_logs.account_rate_multiplier 记录倍率快照,避免历史回算
- 统计/导出/管理端展示账号口径费用(total_cost * account_rate_multiplier)
parent 55fced39
...@@ -80,6 +80,10 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account ...@@ -80,6 +80,10 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account
SetSchedulable(account.Schedulable). SetSchedulable(account.Schedulable).
SetAutoPauseOnExpired(account.AutoPauseOnExpired) SetAutoPauseOnExpired(account.AutoPauseOnExpired)
if account.RateMultiplier != nil {
builder.SetRateMultiplier(*account.RateMultiplier)
}
if account.ProxyID != nil { if account.ProxyID != nil {
builder.SetProxyID(*account.ProxyID) builder.SetProxyID(*account.ProxyID)
} }
...@@ -291,6 +295,10 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account ...@@ -291,6 +295,10 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account
SetSchedulable(account.Schedulable). SetSchedulable(account.Schedulable).
SetAutoPauseOnExpired(account.AutoPauseOnExpired) SetAutoPauseOnExpired(account.AutoPauseOnExpired)
if account.RateMultiplier != nil {
builder.SetRateMultiplier(*account.RateMultiplier)
}
if account.ProxyID != nil { if account.ProxyID != nil {
builder.SetProxyID(*account.ProxyID) builder.SetProxyID(*account.ProxyID)
} else { } else {
...@@ -999,6 +1007,11 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates ...@@ -999,6 +1007,11 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
args = append(args, *updates.Priority) args = append(args, *updates.Priority)
idx++ idx++
} }
if updates.RateMultiplier != nil {
setClauses = append(setClauses, "rate_multiplier = $"+itoa(idx))
args = append(args, *updates.RateMultiplier)
idx++
}
if updates.Status != nil { if updates.Status != nil {
setClauses = append(setClauses, "status = $"+itoa(idx)) setClauses = append(setClauses, "status = $"+itoa(idx))
args = append(args, *updates.Status) args = append(args, *updates.Status)
...@@ -1347,6 +1360,8 @@ func accountEntityToService(m *dbent.Account) *service.Account { ...@@ -1347,6 +1360,8 @@ func accountEntityToService(m *dbent.Account) *service.Account {
return nil return nil
} }
rateMultiplier := m.RateMultiplier
return &service.Account{ return &service.Account{
ID: m.ID, ID: m.ID,
Name: m.Name, Name: m.Name,
...@@ -1358,6 +1373,7 @@ func accountEntityToService(m *dbent.Account) *service.Account { ...@@ -1358,6 +1373,7 @@ func accountEntityToService(m *dbent.Account) *service.Account {
ProxyID: m.ProxyID, ProxyID: m.ProxyID,
Concurrency: m.Concurrency, Concurrency: m.Concurrency,
Priority: m.Priority, Priority: m.Priority,
RateMultiplier: &rateMultiplier,
Status: m.Status, Status: m.Status,
ErrorMessage: derefString(m.ErrorMessage), ErrorMessage: derefString(m.ErrorMessage),
LastUsedAt: m.LastUsedAt, LastUsedAt: m.LastUsedAt,
......
...@@ -22,7 +22,7 @@ import ( ...@@ -22,7 +22,7 @@ import (
"github.com/lib/pq" "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 { type usageLogRepository struct {
client *dbent.Client client *dbent.Client
...@@ -105,6 +105,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) ...@@ -105,6 +105,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
total_cost, total_cost,
actual_cost, actual_cost,
rate_multiplier, rate_multiplier,
account_rate_multiplier,
billing_type, billing_type,
stream, stream,
duration_ms, duration_ms,
...@@ -120,7 +121,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) ...@@ -120,7 +121,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
$8, $9, $10, $11, $8, $9, $10, $11,
$12, $13, $12, $13,
$14, $15, $16, $17, $18, $19, $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 ON CONFLICT (request_id, api_key_id) DO NOTHING
RETURNING id, created_at RETURNING id, created_at
...@@ -160,6 +161,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) ...@@ -160,6 +161,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog)
log.TotalCost, log.TotalCost,
log.ActualCost, log.ActualCost,
rateMultiplier, rateMultiplier,
log.AccountRateMultiplier,
log.BillingType, log.BillingType,
log.Stream, log.Stream,
duration, duration,
...@@ -835,7 +837,9 @@ func (r *usageLogRepository) GetAccountTodayStats(ctx context.Context, accountID ...@@ -835,7 +837,9 @@ func (r *usageLogRepository) GetAccountTodayStats(ctx context.Context, accountID
SELECT SELECT
COUNT(*) as requests, COUNT(*) as requests,
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as tokens, 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 FROM usage_logs
WHERE account_id = $1 AND created_at >= $2 WHERE account_id = $1 AND created_at >= $2
` `
...@@ -849,6 +853,8 @@ func (r *usageLogRepository) GetAccountTodayStats(ctx context.Context, accountID ...@@ -849,6 +853,8 @@ func (r *usageLogRepository) GetAccountTodayStats(ctx context.Context, accountID
&stats.Requests, &stats.Requests,
&stats.Tokens, &stats.Tokens,
&stats.Cost, &stats.Cost,
&stats.StandardCost,
&stats.UserCost,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
...@@ -861,7 +867,9 @@ func (r *usageLogRepository) GetAccountWindowStats(ctx context.Context, accountI ...@@ -861,7 +867,9 @@ func (r *usageLogRepository) GetAccountWindowStats(ctx context.Context, accountI
SELECT SELECT
COUNT(*) as requests, COUNT(*) as requests,
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as tokens, 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 FROM usage_logs
WHERE account_id = $1 AND created_at >= $2 WHERE account_id = $1 AND created_at >= $2
` `
...@@ -875,6 +883,8 @@ func (r *usageLogRepository) GetAccountWindowStats(ctx context.Context, accountI ...@@ -875,6 +883,8 @@ func (r *usageLogRepository) GetAccountWindowStats(ctx context.Context, accountI
&stats.Requests, &stats.Requests,
&stats.Tokens, &stats.Tokens,
&stats.Cost, &stats.Cost,
&stats.StandardCost,
&stats.UserCost,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
...@@ -1454,7 +1464,13 @@ func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, start ...@@ -1454,7 +1464,13 @@ func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, start
// GetModelStatsWithFilters returns model statistics with optional user/api_key filters // 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) { 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 SELECT
model, model,
COUNT(*) as requests, COUNT(*) as requests,
...@@ -1462,10 +1478,10 @@ func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, start ...@@ -1462,10 +1478,10 @@ func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, start
COALESCE(SUM(output_tokens), 0) as output_tokens, 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(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as total_tokens,
COALESCE(SUM(total_cost), 0) as cost, COALESCE(SUM(total_cost), 0) as cost,
COALESCE(SUM(actual_cost), 0) as actual_cost %s
FROM usage_logs FROM usage_logs
WHERE created_at >= $1 AND created_at < $2 WHERE created_at >= $1 AND created_at < $2
` `, actualCostExpr)
args := []any{startTime, endTime} args := []any{startTime, endTime}
if userID > 0 { if userID > 0 {
...@@ -1587,12 +1603,14 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us ...@@ -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(cache_creation_tokens + cache_read_tokens), 0) as total_cache_tokens,
COALESCE(SUM(total_cost), 0) as total_cost, COALESCE(SUM(total_cost), 0) as total_cost,
COALESCE(SUM(actual_cost), 0) as total_actual_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 COALESCE(AVG(duration_ms), 0) as avg_duration_ms
FROM usage_logs FROM usage_logs
%s %s
`, buildWhere(conditions)) `, buildWhere(conditions))
stats := &UsageStats{} stats := &UsageStats{}
var totalAccountCost float64
if err := scanSingleRow( if err := scanSingleRow(
ctx, ctx,
r.sql, r.sql,
...@@ -1604,10 +1622,14 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us ...@@ -1604,10 +1622,14 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us
&stats.TotalCacheTokens, &stats.TotalCacheTokens,
&stats.TotalCost, &stats.TotalCost,
&stats.TotalActualCost, &stats.TotalActualCost,
&totalAccountCost,
&stats.AverageDurationMs, &stats.AverageDurationMs,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
if filters.AccountID > 0 {
stats.TotalAccountCost = &totalAccountCost
}
stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens stats.TotalTokens = stats.TotalInputTokens + stats.TotalOutputTokens + stats.TotalCacheTokens
return stats, nil return stats, nil
} }
...@@ -1634,7 +1656,8 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID ...@@ -1634,7 +1656,8 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID
COUNT(*) as requests, COUNT(*) as requests,
COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as tokens, COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) as tokens,
COALESCE(SUM(total_cost), 0) as cost, 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 FROM usage_logs
WHERE account_id = $1 AND created_at >= $2 AND created_at < $3 WHERE account_id = $1 AND created_at >= $2 AND created_at < $3
GROUP BY date GROUP BY date
...@@ -1661,7 +1684,8 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID ...@@ -1661,7 +1684,8 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID
var tokens int64 var tokens int64
var cost float64 var cost float64
var actualCost 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 return nil, err
} }
t, _ := time.Parse("2006-01-02", date) t, _ := time.Parse("2006-01-02", date)
...@@ -1672,19 +1696,21 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID ...@@ -1672,19 +1696,21 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID
Tokens: tokens, Tokens: tokens,
Cost: cost, Cost: cost,
ActualCost: actualCost, ActualCost: actualCost,
UserCost: userCost,
}) })
} }
if err = rows.Err(); err != nil { if err = rows.Err(); err != nil {
return nil, err return nil, err
} }
var totalActualCost, totalStandardCost float64 var totalAccountCost, totalUserCost, totalStandardCost float64
var totalRequests, totalTokens int64 var totalRequests, totalTokens int64
var highestCostDay, highestRequestDay *AccountUsageHistory var highestCostDay, highestRequestDay *AccountUsageHistory
for i := range history { for i := range history {
h := &history[i] h := &history[i]
totalActualCost += h.ActualCost totalAccountCost += h.ActualCost
totalUserCost += h.UserCost
totalStandardCost += h.Cost totalStandardCost += h.Cost
totalRequests += h.Requests totalRequests += h.Requests
totalTokens += h.Tokens totalTokens += h.Tokens
...@@ -1711,11 +1737,13 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID ...@@ -1711,11 +1737,13 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID
summary := AccountUsageSummary{ summary := AccountUsageSummary{
Days: daysCount, Days: daysCount,
ActualDaysUsed: actualDaysUsed, ActualDaysUsed: actualDaysUsed,
TotalCost: totalActualCost, TotalCost: totalAccountCost,
TotalUserCost: totalUserCost,
TotalStandardCost: totalStandardCost, TotalStandardCost: totalStandardCost,
TotalRequests: totalRequests, TotalRequests: totalRequests,
TotalTokens: totalTokens, TotalTokens: totalTokens,
AvgDailyCost: totalActualCost / float64(actualDaysUsed), AvgDailyCost: totalAccountCost / float64(actualDaysUsed),
AvgDailyUserCost: totalUserCost / float64(actualDaysUsed),
AvgDailyRequests: float64(totalRequests) / float64(actualDaysUsed), AvgDailyRequests: float64(totalRequests) / float64(actualDaysUsed),
AvgDailyTokens: float64(totalTokens) / float64(actualDaysUsed), AvgDailyTokens: float64(totalTokens) / float64(actualDaysUsed),
AvgDurationMs: avgDuration, AvgDurationMs: avgDuration,
...@@ -1727,11 +1755,13 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID ...@@ -1727,11 +1755,13 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID
summary.Today = &struct { summary.Today = &struct {
Date string `json:"date"` Date string `json:"date"`
Cost float64 `json:"cost"` Cost float64 `json:"cost"`
UserCost float64 `json:"user_cost"`
Requests int64 `json:"requests"` Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"` Tokens int64 `json:"tokens"`
}{ }{
Date: history[i].Date, Date: history[i].Date,
Cost: history[i].ActualCost, Cost: history[i].ActualCost,
UserCost: history[i].UserCost,
Requests: history[i].Requests, Requests: history[i].Requests,
Tokens: history[i].Tokens, Tokens: history[i].Tokens,
} }
...@@ -1744,11 +1774,13 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID ...@@ -1744,11 +1774,13 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID
Date string `json:"date"` Date string `json:"date"`
Label string `json:"label"` Label string `json:"label"`
Cost float64 `json:"cost"` Cost float64 `json:"cost"`
UserCost float64 `json:"user_cost"`
Requests int64 `json:"requests"` Requests int64 `json:"requests"`
}{ }{
Date: highestCostDay.Date, Date: highestCostDay.Date,
Label: highestCostDay.Label, Label: highestCostDay.Label,
Cost: highestCostDay.ActualCost, Cost: highestCostDay.ActualCost,
UserCost: highestCostDay.UserCost,
Requests: highestCostDay.Requests, Requests: highestCostDay.Requests,
} }
} }
...@@ -1759,11 +1791,13 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID ...@@ -1759,11 +1791,13 @@ func (r *usageLogRepository) GetAccountUsageStats(ctx context.Context, accountID
Label string `json:"label"` Label string `json:"label"`
Requests int64 `json:"requests"` Requests int64 `json:"requests"`
Cost float64 `json:"cost"` Cost float64 `json:"cost"`
UserCost float64 `json:"user_cost"`
}{ }{
Date: highestRequestDay.Date, Date: highestRequestDay.Date,
Label: highestRequestDay.Label, Label: highestRequestDay.Label,
Requests: highestRequestDay.Requests, Requests: highestRequestDay.Requests,
Cost: highestRequestDay.ActualCost, Cost: highestRequestDay.ActualCost,
UserCost: highestRequestDay.UserCost,
} }
} }
...@@ -1994,36 +2028,37 @@ func (r *usageLogRepository) loadSubscriptions(ctx context.Context, ids []int64) ...@@ -1994,36 +2028,37 @@ func (r *usageLogRepository) loadSubscriptions(ctx context.Context, ids []int64)
func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, error) { func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, error) {
var ( var (
id int64 id int64
userID int64 userID int64
apiKeyID int64 apiKeyID int64
accountID int64 accountID int64
requestID sql.NullString requestID sql.NullString
model string model string
groupID sql.NullInt64 groupID sql.NullInt64
subscriptionID sql.NullInt64 subscriptionID sql.NullInt64
inputTokens int inputTokens int
outputTokens int outputTokens int
cacheCreationTokens int cacheCreationTokens int
cacheReadTokens int cacheReadTokens int
cacheCreation5m int cacheCreation5m int
cacheCreation1h int cacheCreation1h int
inputCost float64 inputCost float64
outputCost float64 outputCost float64
cacheCreationCost float64 cacheCreationCost float64
cacheReadCost float64 cacheReadCost float64
totalCost float64 totalCost float64
actualCost float64 actualCost float64
rateMultiplier float64 rateMultiplier float64
billingType int16 accountRateMultiplier sql.NullFloat64
stream bool billingType int16
durationMs sql.NullInt64 stream bool
firstTokenMs sql.NullInt64 durationMs sql.NullInt64
userAgent sql.NullString firstTokenMs sql.NullInt64
ipAddress sql.NullString userAgent sql.NullString
imageCount int ipAddress sql.NullString
imageSize sql.NullString imageCount int
createdAt time.Time imageSize sql.NullString
createdAt time.Time
) )
if err := scanner.Scan( if err := scanner.Scan(
...@@ -2048,6 +2083,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e ...@@ -2048,6 +2083,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
&totalCost, &totalCost,
&actualCost, &actualCost,
&rateMultiplier, &rateMultiplier,
&accountRateMultiplier,
&billingType, &billingType,
&stream, &stream,
&durationMs, &durationMs,
...@@ -2080,6 +2116,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e ...@@ -2080,6 +2116,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
TotalCost: totalCost, TotalCost: totalCost,
ActualCost: actualCost, ActualCost: actualCost,
RateMultiplier: rateMultiplier, RateMultiplier: rateMultiplier,
AccountRateMultiplier: nullFloat64Ptr(accountRateMultiplier),
BillingType: int8(billingType), BillingType: int8(billingType),
Stream: stream, Stream: stream,
ImageCount: imageCount, ImageCount: imageCount,
...@@ -2186,6 +2223,14 @@ func nullInt(v *int) sql.NullInt64 { ...@@ -2186,6 +2223,14 @@ func nullInt(v *int) sql.NullInt64 {
return sql.NullInt64{Int64: int64(*v), Valid: true} 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 { func nullString(v *string) sql.NullString {
if v == nil || *v == "" { if v == nil || *v == "" {
return sql.NullString{} return sql.NullString{}
......
...@@ -11,6 +11,7 @@ import ( ...@@ -11,6 +11,7 @@ import (
dbent "github.com/Wei-Shaw/sub2api/ent" dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "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/pkg/usagestats"
"github.com/Wei-Shaw/sub2api/internal/service" "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
...@@ -95,6 +96,34 @@ func (s *UsageLogRepoSuite) TestGetByID_NotFound() { ...@@ -95,6 +96,34 @@ func (s *UsageLogRepoSuite) TestGetByID_NotFound() {
s.Require().Error(err, "expected error for non-existent ID") 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 --- // --- Delete ---
func (s *UsageLogRepoSuite) TestDelete() { func (s *UsageLogRepoSuite) TestDelete() {
...@@ -403,12 +432,49 @@ func (s *UsageLogRepoSuite) TestGetAccountTodayStats() { ...@@ -403,12 +432,49 @@ func (s *UsageLogRepoSuite) TestGetAccountTodayStats() {
apiKey := mustCreateApiKey(s.T(), s.client, &service.APIKey{UserID: user.ID, Key: "sk-acctoday", Name: "k"}) 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"}) 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) stats, err := s.repo.GetAccountTodayStats(s.ctx, account.ID)
s.Require().NoError(err, "GetAccountTodayStats") s.Require().NoError(err, "GetAccountTodayStats")
s.Require().Equal(int64(1), stats.Requests) s.Require().Equal(int64(2), stats.Requests)
s.Require().Equal(int64(30), stats.Tokens) 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() { func (s *UsageLogRepoSuite) TestDashboardAggregationConsistency() {
...@@ -416,8 +482,8 @@ func (s *UsageLogRepoSuite) TestDashboardAggregationConsistency() { ...@@ -416,8 +482,8 @@ func (s *UsageLogRepoSuite) TestDashboardAggregationConsistency() {
// 使用固定的时间偏移确保 hour1 和 hour2 在同一天且都在过去 // 使用固定的时间偏移确保 hour1 和 hour2 在同一天且都在过去
// 选择当天 02:00 和 03:00 作为测试时间点(基于 now 的日期) // 选择当天 02:00 和 03:00 作为测试时间点(基于 now 的日期)
dayStart := truncateToDayUTC(now) dayStart := truncateToDayUTC(now)
hour1 := dayStart.Add(2 * time.Hour) // 当天 02:00 hour1 := dayStart.Add(2 * time.Hour) // 当天 02:00
hour2 := dayStart.Add(3 * time.Hour) // 当天 03:00 hour2 := dayStart.Add(3 * time.Hour) // 当天 03:00
// 如果当前时间早于 hour2,则使用昨天的时间 // 如果当前时间早于 hour2,则使用昨天的时间
if now.Before(hour2.Add(time.Hour)) { if now.Before(hour2.Add(time.Hour)) {
dayStart = dayStart.Add(-24 * time.Hour) dayStart = dayStart.Add(-24 * time.Hour)
......
...@@ -239,9 +239,10 @@ func TestAPIContracts(t *testing.T) { ...@@ -239,9 +239,10 @@ func TestAPIContracts(t *testing.T) {
"cache_creation_cost": 0, "cache_creation_cost": 0,
"cache_read_cost": 0, "cache_read_cost": 0,
"total_cost": 0.5, "total_cost": 0.5,
"actual_cost": 0.5, "actual_cost": 0.5,
"rate_multiplier": 1, "rate_multiplier": 1,
"billing_type": 0, "account_rate_multiplier": null,
"billing_type": 0,
"stream": true, "stream": true,
"duration_ms": 100, "duration_ms": 100,
"first_token_ms": 50, "first_token_ms": 50,
...@@ -262,11 +263,11 @@ func TestAPIContracts(t *testing.T) { ...@@ -262,11 +263,11 @@ func TestAPIContracts(t *testing.T) {
name: "GET /api/v1/admin/settings", name: "GET /api/v1/admin/settings",
setup: func(t *testing.T, deps *contractDeps) { setup: func(t *testing.T, deps *contractDeps) {
t.Helper() t.Helper()
deps.settingRepo.SetAll(map[string]string{ deps.settingRepo.SetAll(map[string]string{
service.SettingKeyRegistrationEnabled: "true", service.SettingKeyRegistrationEnabled: "true",
service.SettingKeyEmailVerifyEnabled: "false", service.SettingKeyEmailVerifyEnabled: "false",
service.SettingKeySMTPHost: "smtp.example.com", service.SettingKeySMTPHost: "smtp.example.com",
service.SettingKeySMTPPort: "587", service.SettingKeySMTPPort: "587",
service.SettingKeySMTPUsername: "user", service.SettingKeySMTPUsername: "user",
service.SettingKeySMTPPassword: "secret", service.SettingKeySMTPPassword: "secret",
...@@ -285,15 +286,15 @@ func TestAPIContracts(t *testing.T) { ...@@ -285,15 +286,15 @@ func TestAPIContracts(t *testing.T) {
service.SettingKeyContactInfo: "support", service.SettingKeyContactInfo: "support",
service.SettingKeyDocURL: "https://docs.example.com", service.SettingKeyDocURL: "https://docs.example.com",
service.SettingKeyDefaultConcurrency: "5", service.SettingKeyDefaultConcurrency: "5",
service.SettingKeyDefaultBalance: "1.25", service.SettingKeyDefaultBalance: "1.25",
service.SettingKeyOpsMonitoringEnabled: "false", service.SettingKeyOpsMonitoringEnabled: "false",
service.SettingKeyOpsRealtimeMonitoringEnabled: "true", service.SettingKeyOpsRealtimeMonitoringEnabled: "true",
service.SettingKeyOpsQueryModeDefault: "auto", service.SettingKeyOpsQueryModeDefault: "auto",
service.SettingKeyOpsMetricsIntervalSeconds: "60", service.SettingKeyOpsMetricsIntervalSeconds: "60",
}) })
}, },
method: http.MethodGet, method: http.MethodGet,
path: "/api/v1/admin/settings", path: "/api/v1/admin/settings",
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
......
...@@ -9,16 +9,19 @@ import ( ...@@ -9,16 +9,19 @@ import (
) )
type Account struct { type Account struct {
ID int64 ID int64
Name string Name string
Notes *string Notes *string
Platform string Platform string
Type string Type string
Credentials map[string]any Credentials map[string]any
Extra map[string]any Extra map[string]any
ProxyID *int64 ProxyID *int64
Concurrency int Concurrency int
Priority int Priority int
// RateMultiplier 账号计费倍率(>=0,允许 0 表示该账号计费为 0)。
// 使用指针用于兼容旧版本调度缓存(Redis)中缺字段的情况:nil 表示按 1.0 处理。
RateMultiplier *float64
Status string Status string
ErrorMessage string ErrorMessage string
LastUsedAt *time.Time LastUsedAt *time.Time
...@@ -57,6 +60,20 @@ func (a *Account) IsActive() bool { ...@@ -57,6 +60,20 @@ func (a *Account) IsActive() bool {
return a.Status == StatusActive 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 { func (a *Account) IsSchedulable() bool {
if !a.IsActive() || !a.Schedulable { if !a.IsActive() || !a.Schedulable {
return false return false
......
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())
}
...@@ -63,14 +63,15 @@ type AccountRepository interface { ...@@ -63,14 +63,15 @@ type AccountRepository interface {
// AccountBulkUpdate describes the fields that can be updated in a bulk operation. // AccountBulkUpdate describes the fields that can be updated in a bulk operation.
// Nil pointers mean "do not change". // Nil pointers mean "do not change".
type AccountBulkUpdate struct { type AccountBulkUpdate struct {
Name *string Name *string
ProxyID *int64 ProxyID *int64
Concurrency *int Concurrency *int
Priority *int Priority *int
Status *string RateMultiplier *float64
Schedulable *bool Status *string
Credentials map[string]any Schedulable *bool
Extra map[string]any Credentials map[string]any
Extra map[string]any
} }
// CreateAccountRequest 创建账号请求 // CreateAccountRequest 创建账号请求
......
...@@ -96,10 +96,16 @@ func NewUsageCache() *UsageCache { ...@@ -96,10 +96,16 @@ func NewUsageCache() *UsageCache {
} }
// WindowStats 窗口期统计 // WindowStats 窗口期统计
//
// cost: 账号口径费用(total_cost * account_rate_multiplier)
// standard_cost: 标准费用(total_cost,不含倍率)
// user_cost: 用户/API Key 口径费用(actual_cost,受分组倍率影响)
type WindowStats struct { type WindowStats struct {
Requests int64 `json:"requests"` Requests int64 `json:"requests"`
Tokens int64 `json:"tokens"` Tokens int64 `json:"tokens"`
Cost float64 `json:"cost"` Cost float64 `json:"cost"`
StandardCost float64 `json:"standard_cost"`
UserCost float64 `json:"user_cost"`
} }
// UsageProgress 使用量进度 // UsageProgress 使用量进度
...@@ -377,9 +383,11 @@ func (s *AccountUsageService) addWindowStats(ctx context.Context, account *Accou ...@@ -377,9 +383,11 @@ func (s *AccountUsageService) addWindowStats(ctx context.Context, account *Accou
} }
windowStats = &WindowStats{ windowStats = &WindowStats{
Requests: stats.Requests, Requests: stats.Requests,
Tokens: stats.Tokens, Tokens: stats.Tokens,
Cost: stats.Cost, Cost: stats.Cost,
StandardCost: stats.StandardCost,
UserCost: stats.UserCost,
} }
// 缓存窗口统计(1 分钟) // 缓存窗口统计(1 分钟)
...@@ -403,9 +411,11 @@ func (s *AccountUsageService) GetTodayStats(ctx context.Context, accountID int64 ...@@ -403,9 +411,11 @@ func (s *AccountUsageService) GetTodayStats(ctx context.Context, accountID int64
} }
return &WindowStats{ return &WindowStats{
Requests: stats.Requests, Requests: stats.Requests,
Tokens: stats.Tokens, Tokens: stats.Tokens,
Cost: stats.Cost, Cost: stats.Cost,
StandardCost: stats.StandardCost,
UserCost: stats.UserCost,
}, nil }, nil
} }
......
...@@ -136,6 +136,7 @@ type CreateAccountInput struct { ...@@ -136,6 +136,7 @@ type CreateAccountInput struct {
ProxyID *int64 ProxyID *int64
Concurrency int Concurrency int
Priority int Priority int
RateMultiplier *float64 // 账号计费倍率(>=0,允许 0)
GroupIDs []int64 GroupIDs []int64
ExpiresAt *int64 ExpiresAt *int64
AutoPauseOnExpired *bool AutoPauseOnExpired *bool
...@@ -151,8 +152,9 @@ type UpdateAccountInput struct { ...@@ -151,8 +152,9 @@ type UpdateAccountInput struct {
Credentials map[string]any Credentials map[string]any
Extra map[string]any Extra map[string]any
ProxyID *int64 ProxyID *int64
Concurrency *int // 使用指针区分"未提供"和"设置为0" Concurrency *int // 使用指针区分"未提供"和"设置为0"
Priority *int // 使用指针区分"未提供"和"设置为0" Priority *int // 使用指针区分"未提供"和"设置为0"
RateMultiplier *float64 // 账号计费倍率(>=0,允许 0)
Status string Status string
GroupIDs *[]int64 GroupIDs *[]int64
ExpiresAt *int64 ExpiresAt *int64
...@@ -162,16 +164,17 @@ type UpdateAccountInput struct { ...@@ -162,16 +164,17 @@ type UpdateAccountInput struct {
// BulkUpdateAccountsInput describes the payload for bulk updating accounts. // BulkUpdateAccountsInput describes the payload for bulk updating accounts.
type BulkUpdateAccountsInput struct { type BulkUpdateAccountsInput struct {
AccountIDs []int64 AccountIDs []int64
Name string Name string
ProxyID *int64 ProxyID *int64
Concurrency *int Concurrency *int
Priority *int Priority *int
Status string RateMultiplier *float64 // 账号计费倍率(>=0,允许 0)
Schedulable *bool Status string
GroupIDs *[]int64 Schedulable *bool
Credentials map[string]any GroupIDs *[]int64
Extra map[string]any Credentials map[string]any
Extra map[string]any
// SkipMixedChannelCheck skips the mixed channel risk check when binding groups. // SkipMixedChannelCheck skips the mixed channel risk check when binding groups.
// This should only be set when the caller has explicitly confirmed the risk. // This should only be set when the caller has explicitly confirmed the risk.
SkipMixedChannelCheck bool SkipMixedChannelCheck bool
...@@ -817,6 +820,12 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou ...@@ -817,6 +820,12 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
} else { } else {
account.AutoPauseOnExpired = true 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 { if err := s.accountRepo.Create(ctx, account); err != nil {
return nil, err return nil, err
} }
...@@ -869,6 +878,12 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U ...@@ -869,6 +878,12 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
if input.Priority != nil { if input.Priority != nil {
account.Priority = *input.Priority 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 != "" { if input.Status != "" {
account.Status = input.Status account.Status = input.Status
} }
...@@ -942,6 +957,12 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp ...@@ -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. // Prepare bulk updates for columns and JSONB fields.
repoUpdates := AccountBulkUpdate{ repoUpdates := AccountBulkUpdate{
Credentials: input.Credentials, Credentials: input.Credentials,
...@@ -959,6 +980,9 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp ...@@ -959,6 +980,9 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
if input.Priority != nil { if input.Priority != nil {
repoUpdates.Priority = input.Priority repoUpdates.Priority = input.Priority
} }
if input.RateMultiplier != nil {
repoUpdates.RateMultiplier = input.RateMultiplier
}
if input.Status != "" { if input.Status != "" {
repoUpdates.Status = &input.Status repoUpdates.Status = &input.Status
} }
......
...@@ -2618,30 +2618,32 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu ...@@ -2618,30 +2618,32 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
if result.ImageSize != "" { if result.ImageSize != "" {
imageSize = &result.ImageSize imageSize = &result.ImageSize
} }
accountRateMultiplier := account.BillingRateMultiplier()
usageLog := &UsageLog{ usageLog := &UsageLog{
UserID: user.ID, UserID: user.ID,
APIKeyID: apiKey.ID, APIKeyID: apiKey.ID,
AccountID: account.ID, AccountID: account.ID,
RequestID: result.RequestID, RequestID: result.RequestID,
Model: result.Model, Model: result.Model,
InputTokens: result.Usage.InputTokens, InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens, OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens, CacheCreationTokens: result.Usage.CacheCreationInputTokens,
CacheReadTokens: result.Usage.CacheReadInputTokens, CacheReadTokens: result.Usage.CacheReadInputTokens,
InputCost: cost.InputCost, InputCost: cost.InputCost,
OutputCost: cost.OutputCost, OutputCost: cost.OutputCost,
CacheCreationCost: cost.CacheCreationCost, CacheCreationCost: cost.CacheCreationCost,
CacheReadCost: cost.CacheReadCost, CacheReadCost: cost.CacheReadCost,
TotalCost: cost.TotalCost, TotalCost: cost.TotalCost,
ActualCost: cost.ActualCost, ActualCost: cost.ActualCost,
RateMultiplier: multiplier, RateMultiplier: multiplier,
BillingType: billingType, AccountRateMultiplier: &accountRateMultiplier,
Stream: result.Stream, BillingType: billingType,
DurationMs: &durationMs, Stream: result.Stream,
FirstTokenMs: result.FirstTokenMs, DurationMs: &durationMs,
ImageCount: result.ImageCount, FirstTokenMs: result.FirstTokenMs,
ImageSize: imageSize, ImageCount: result.ImageCount,
CreatedAt: time.Now(), ImageSize: imageSize,
CreatedAt: time.Now(),
} }
// 添加 UserAgent // 添加 UserAgent
......
...@@ -1432,28 +1432,30 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec ...@@ -1432,28 +1432,30 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
// Create usage log // Create usage log
durationMs := int(result.Duration.Milliseconds()) durationMs := int(result.Duration.Milliseconds())
accountRateMultiplier := account.BillingRateMultiplier()
usageLog := &UsageLog{ usageLog := &UsageLog{
UserID: user.ID, UserID: user.ID,
APIKeyID: apiKey.ID, APIKeyID: apiKey.ID,
AccountID: account.ID, AccountID: account.ID,
RequestID: result.RequestID, RequestID: result.RequestID,
Model: result.Model, Model: result.Model,
InputTokens: actualInputTokens, InputTokens: actualInputTokens,
OutputTokens: result.Usage.OutputTokens, OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens, CacheCreationTokens: result.Usage.CacheCreationInputTokens,
CacheReadTokens: result.Usage.CacheReadInputTokens, CacheReadTokens: result.Usage.CacheReadInputTokens,
InputCost: cost.InputCost, InputCost: cost.InputCost,
OutputCost: cost.OutputCost, OutputCost: cost.OutputCost,
CacheCreationCost: cost.CacheCreationCost, CacheCreationCost: cost.CacheCreationCost,
CacheReadCost: cost.CacheReadCost, CacheReadCost: cost.CacheReadCost,
TotalCost: cost.TotalCost, TotalCost: cost.TotalCost,
ActualCost: cost.ActualCost, ActualCost: cost.ActualCost,
RateMultiplier: multiplier, RateMultiplier: multiplier,
BillingType: billingType, AccountRateMultiplier: &accountRateMultiplier,
Stream: result.Stream, BillingType: billingType,
DurationMs: &durationMs, Stream: result.Stream,
FirstTokenMs: result.FirstTokenMs, DurationMs: &durationMs,
CreatedAt: time.Now(), FirstTokenMs: result.FirstTokenMs,
CreatedAt: time.Now(),
} }
// 添加 UserAgent // 添加 UserAgent
......
...@@ -33,6 +33,8 @@ type UsageLog struct { ...@@ -33,6 +33,8 @@ type UsageLog struct {
TotalCost float64 TotalCost float64
ActualCost float64 ActualCost float64
RateMultiplier float64 RateMultiplier float64
// AccountRateMultiplier 账号计费倍率快照(nil 表示历史数据,按 1.0 处理)
AccountRateMultiplier *float64
BillingType int8 BillingType int8
Stream bool Stream bool
......
-- 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);
...@@ -16,6 +16,7 @@ export interface AdminUsageStatsResponse { ...@@ -16,6 +16,7 @@ export interface AdminUsageStatsResponse {
total_tokens: number total_tokens: number
total_cost: number total_cost: number
total_actual_cost: number total_actual_cost: number
total_account_cost?: number
average_duration_ms: number average_duration_ms: number
} }
......
...@@ -73,11 +73,12 @@ ...@@ -73,11 +73,12 @@
</p> </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.accumulatedCost') }} {{ t('admin.accounts.stats.accumulatedCost') }}
<span class="text-gray-400 dark:text-gray-500" <span class="text-gray-400 dark:text-gray-500">
>({{ t('admin.accounts.stats.standardCost') }}: ${{ ({{ t('usage.userBilled') }}: ${{ formatCost(stats.summary.total_user_cost) }} ·
{{ t('admin.accounts.stats.standardCost') }}: ${{
formatCost(stats.summary.total_standard_cost) formatCost(stats.summary.total_standard_cost)
}})</span }})
> </span>
</p> </p>
</div> </div>
...@@ -121,12 +122,15 @@ ...@@ -121,12 +122,15 @@
<p class="text-2xl font-bold text-gray-900 dark:text-white"> <p class="text-2xl font-bold text-gray-900 dark:text-white">
${{ formatCost(stats.summary.avg_daily_cost) }} ${{ formatCost(stats.summary.avg_daily_cost) }}
</p> </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', { t('admin.accounts.stats.basedOnActualDays', {
days: stats.summary.actual_days_used 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> </p>
</div> </div>
...@@ -189,13 +193,17 @@ ...@@ -189,13 +193,17 @@
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
t('admin.accounts.stats.cost')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white" <span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.cost || 0) }}</span >${{ formatCost(stats.summary.today?.cost || 0) }}</span
> >
</div> </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) }}</span
>
</div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests') t('admin.accounts.stats.requests')
...@@ -240,13 +248,17 @@ ...@@ -240,13 +248,17 @@
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
t('admin.accounts.stats.cost')
}}</span>
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400" <span class="text-sm font-semibold text-orange-600 dark:text-orange-400"
>${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span >${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span
> >
</div> </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) }}</span
>
</div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests') t('admin.accounts.stats.requests')
...@@ -291,13 +303,17 @@ ...@@ -291,13 +303,17 @@
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
t('admin.accounts.stats.cost')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white" <span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span >${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span
> >
</div> </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) }}</span
>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -397,13 +413,17 @@ ...@@ -397,13 +413,17 @@
}}</span> }}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ <span class="text-xs text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}</span>
t('admin.accounts.stats.todayCost')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white" <span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.cost || 0) }}</span >${{ formatCost(stats.summary.today?.cost || 0) }}</span
> >
</div> </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) }}</span
>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -517,14 +537,24 @@ const trendChartData = computed(() => { ...@@ -517,14 +537,24 @@ const trendChartData = computed(() => {
labels: stats.value.history.map((h) => h.label), labels: stats.value.history.map((h) => h.label),
datasets: [ datasets: [
{ {
label: t('admin.accounts.stats.cost') + ' (USD)', label: t('usage.accountBilled') + ' (USD)',
data: stats.value.history.map((h) => h.cost), data: stats.value.history.map((h) => h.actual_cost),
borderColor: '#3b82f6', borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)', backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true, fill: true,
tension: 0.3, tension: 0.3,
yAxisID: 'y' 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'), label: t('admin.accounts.stats.requests'),
data: stats.value.history.map((h) => h.requests), data: stats.value.history.map((h) => h.requests),
...@@ -602,7 +632,7 @@ const lineChartOptions = computed(() => ({ ...@@ -602,7 +632,7 @@ const lineChartOptions = computed(() => ({
}, },
title: { title: {
display: true, display: true,
text: t('admin.accounts.stats.cost') + ' (USD)', text: t('usage.accountBilled') + ' (USD)',
color: '#3b82f6', color: '#3b82f6',
font: { font: {
size: 11 size: 11
......
...@@ -32,15 +32,20 @@ ...@@ -32,15 +32,20 @@
formatTokens(stats.tokens) formatTokens(stats.tokens)
}}</span> }}</span>
</div> </div>
<!-- Cost --> <!-- Cost (Account) -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400" <span class="text-gray-500 dark:text-gray-400">{{ t('usage.accountBilled') }}:</span>
>{{ t('admin.accounts.stats.cost') }}:</span
>
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{ <span class="font-medium text-emerald-600 dark:text-emerald-400">{{
formatCurrency(stats.cost) formatCurrency(stats.cost)
}}</span> }}</span>
</div> </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> </div>
<!-- No data --> <!-- No data -->
......
...@@ -459,7 +459,7 @@ ...@@ -459,7 +459,7 @@
</div> </div>
<!-- Concurrency & Priority --> <!-- 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>
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<label <label
...@@ -516,6 +516,36 @@ ...@@ -516,6 +516,36 @@
aria-labelledby="bulk-edit-priority-label" aria-labelledby="bulk-edit-priority-label"
/> />
</div> </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> </div>
<!-- Status --> <!-- Status -->
...@@ -655,6 +685,7 @@ const enableInterceptWarmup = ref(false) ...@@ -655,6 +685,7 @@ const enableInterceptWarmup = ref(false)
const enableProxy = ref(false) const enableProxy = ref(false)
const enableConcurrency = ref(false) const enableConcurrency = ref(false)
const enablePriority = ref(false) const enablePriority = ref(false)
const enableRateMultiplier = ref(false)
const enableStatus = ref(false) const enableStatus = ref(false)
const enableGroups = ref(false) const enableGroups = ref(false)
...@@ -670,6 +701,7 @@ const interceptWarmupRequests = ref(false) ...@@ -670,6 +701,7 @@ const interceptWarmupRequests = ref(false)
const proxyId = ref<number | null>(null) const proxyId = ref<number | null>(null)
const concurrency = ref(1) const concurrency = ref(1)
const priority = ref(1) const priority = ref(1)
const rateMultiplier = ref(1)
const status = ref<'active' | 'inactive'>('active') const status = ref<'active' | 'inactive'>('active')
const groupIds = ref<number[]>([]) const groupIds = ref<number[]>([])
...@@ -863,6 +895,10 @@ const buildUpdatePayload = (): Record<string, unknown> | null => { ...@@ -863,6 +895,10 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
updates.priority = priority.value updates.priority = priority.value
} }
if (enableRateMultiplier.value) {
updates.rate_multiplier = rateMultiplier.value
}
if (enableStatus.value) { if (enableStatus.value) {
updates.status = status.value updates.status = status.value
} }
...@@ -923,6 +959,7 @@ const handleSubmit = async () => { ...@@ -923,6 +959,7 @@ const handleSubmit = async () => {
enableProxy.value || enableProxy.value ||
enableConcurrency.value || enableConcurrency.value ||
enablePriority.value || enablePriority.value ||
enableRateMultiplier.value ||
enableStatus.value || enableStatus.value ||
enableGroups.value enableGroups.value
...@@ -977,6 +1014,7 @@ watch( ...@@ -977,6 +1014,7 @@ watch(
enableProxy.value = false enableProxy.value = false
enableConcurrency.value = false enableConcurrency.value = false
enablePriority.value = false enablePriority.value = false
enableRateMultiplier.value = false
enableStatus.value = false enableStatus.value = false
enableGroups.value = false enableGroups.value = false
...@@ -991,6 +1029,7 @@ watch( ...@@ -991,6 +1029,7 @@ watch(
proxyId.value = null proxyId.value = null
concurrency.value = 1 concurrency.value = 1
priority.value = 1 priority.value = 1
rateMultiplier.value = 1
status.value = 'active' status.value = 'active'
groupIds.value = [] groupIds.value = []
} }
......
...@@ -1196,7 +1196,7 @@ ...@@ -1196,7 +1196,7 @@
<ProxySelector v-model="form.proxy_id" :proxies="proxies" /> <ProxySelector v-model="form.proxy_id" :proxies="proxies" />
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4 lg:grid-cols-3">
<div> <div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label> <label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" min="1" class="input" /> <input v-model.number="form.concurrency" type="number" min="1" class="input" />
...@@ -1212,6 +1212,11 @@ ...@@ -1212,6 +1212,11 @@
/> />
<p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p> <p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
</div> </div>
<div>
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.01" class="input" />
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
</div>
</div> </div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label> <label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
...@@ -1832,6 +1837,7 @@ const form = reactive({ ...@@ -1832,6 +1837,7 @@ const form = reactive({
proxy_id: null as number | null, proxy_id: null as number | null,
concurrency: 10, concurrency: 10,
priority: 1, priority: 1,
rate_multiplier: 1,
group_ids: [] as number[], group_ids: [] as number[],
expires_at: null as number | null expires_at: null as number | null
}) })
...@@ -2119,6 +2125,7 @@ const resetForm = () => { ...@@ -2119,6 +2125,7 @@ const resetForm = () => {
form.proxy_id = null form.proxy_id = null
form.concurrency = 10 form.concurrency = 10
form.priority = 1 form.priority = 1
form.rate_multiplier = 1
form.group_ids = [] form.group_ids = []
form.expires_at = null form.expires_at = null
accountCategory.value = 'oauth-based' accountCategory.value = 'oauth-based'
...@@ -2272,6 +2279,7 @@ const createAccountAndFinish = async ( ...@@ -2272,6 +2279,7 @@ const createAccountAndFinish = async (
proxy_id: form.proxy_id, proxy_id: form.proxy_id,
concurrency: form.concurrency, concurrency: form.concurrency,
priority: form.priority, priority: form.priority,
rate_multiplier: form.rate_multiplier,
group_ids: form.group_ids, group_ids: form.group_ids,
expires_at: form.expires_at, expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value auto_pause_on_expired: autoPauseOnExpired.value
...@@ -2490,6 +2498,7 @@ const handleCookieAuth = async (sessionKey: string) => { ...@@ -2490,6 +2498,7 @@ const handleCookieAuth = async (sessionKey: string) => {
proxy_id: form.proxy_id, proxy_id: form.proxy_id,
concurrency: form.concurrency, concurrency: form.concurrency,
priority: form.priority, priority: form.priority,
rate_multiplier: form.rate_multiplier,
group_ids: form.group_ids, group_ids: form.group_ids,
expires_at: form.expires_at, expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value auto_pause_on_expired: autoPauseOnExpired.value
......
...@@ -549,7 +549,7 @@ ...@@ -549,7 +549,7 @@
<ProxySelector v-model="form.proxy_id" :proxies="proxies" /> <ProxySelector v-model="form.proxy_id" :proxies="proxies" />
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4 lg:grid-cols-3">
<div> <div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label> <label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" min="1" class="input" /> <input v-model.number="form.concurrency" type="number" min="1" class="input" />
...@@ -564,6 +564,11 @@ ...@@ -564,6 +564,11 @@
data-tour="account-form-priority" data-tour="account-form-priority"
/> />
</div> </div>
<div>
<label class="input-label">{{ t('admin.accounts.billingRateMultiplier') }}</label>
<input v-model.number="form.rate_multiplier" type="number" min="0" step="0.01" class="input" />
<p class="input-hint">{{ t('admin.accounts.billingRateMultiplierHint') }}</p>
</div>
</div> </div>
<div class="border-t border-gray-200 pt-4 dark:border-dark-600"> <div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label> <label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
...@@ -807,6 +812,7 @@ const form = reactive({ ...@@ -807,6 +812,7 @@ const form = reactive({
proxy_id: null as number | null, proxy_id: null as number | null,
concurrency: 1, concurrency: 1,
priority: 1, priority: 1,
rate_multiplier: 1,
status: 'active' as 'active' | 'inactive', status: 'active' as 'active' | 'inactive',
group_ids: [] as number[], group_ids: [] as number[],
expires_at: null as number | null expires_at: null as number | null
...@@ -834,6 +840,7 @@ watch( ...@@ -834,6 +840,7 @@ watch(
form.proxy_id = newAccount.proxy_id form.proxy_id = newAccount.proxy_id
form.concurrency = newAccount.concurrency form.concurrency = newAccount.concurrency
form.priority = newAccount.priority form.priority = newAccount.priority
form.rate_multiplier = newAccount.rate_multiplier ?? 1
form.status = newAccount.status as 'active' | 'inactive' form.status = newAccount.status as 'active' | 'inactive'
form.group_ids = newAccount.group_ids || [] form.group_ids = newAccount.group_ids || []
form.expires_at = newAccount.expires_at ?? null form.expires_at = newAccount.expires_at ?? null
......
...@@ -15,7 +15,13 @@ ...@@ -15,7 +15,13 @@
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> <span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatTokens }} {{ formatTokens }}
</span> </span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> ${{ formatCost }} </span> <span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> A ${{ formatAccountCost }} </span>
<span
v-if="windowStats?.user_cost != null"
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
>
U ${{ formatUserCost }}
</span>
</div> </div>
</div> </div>
...@@ -149,8 +155,13 @@ const formatTokens = computed(() => { ...@@ -149,8 +155,13 @@ const formatTokens = computed(() => {
return t.toString() return t.toString()
}) })
const formatCost = computed(() => { const formatAccountCost = computed(() => {
if (!props.windowStats) return '0.00' if (!props.windowStats) return '0.00'
return props.windowStats.cost.toFixed(2) return props.windowStats.cost.toFixed(2)
}) })
const formatUserCost = computed(() => {
if (!props.windowStats || props.windowStats.user_cost == null) return '0.00'
return props.windowStats.user_cost.toFixed(2)
})
</script> </script>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment