Files
sub2api/backend/internal/service/usage_log.go
erio 7535e312e0 feat(channels): add custom account stats pricing rules
Allow channels to configure independent model pricing for account
statistics cost calculation, decoupled from user billing.

Backend:
- Migration 101: channels.apply_pricing_to_account_stats toggle,
  channel_account_stats_pricing_rules/model_pricing tables,
  usage_logs.account_stats_cost column
- resolveAccountStatsCost: match rules by group/account, then channel
  pricing, fallback to original formula when unconfigured
- Integrate into both GatewayService.recordUsageCore and
  OpenAIGatewayService.RecordUsage
- Update 8 account stats SQL queries to use
  COALESCE(account_stats_cost, total_cost) * account_rate_multiplier
- 23 unit tests for matching, pricing lookup, and cost calculation

Frontend:
- Channel edit dialog: toggle + custom rules UI with group/account
  multi-select and pricing entry cards
- API types and i18n (zh/en)
2026-04-14 09:22:12 +08:00

200 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"fmt"
"strings"
"time"
)
const (
BillingTypeBalance int8 = 0 // 钱包余额
BillingTypeSubscription int8 = 1 // 订阅套餐
)
type RequestType int16
const (
RequestTypeUnknown RequestType = 0
RequestTypeSync RequestType = 1
RequestTypeStream RequestType = 2
RequestTypeWSV2 RequestType = 3
)
func (t RequestType) IsValid() bool {
switch t {
case RequestTypeUnknown, RequestTypeSync, RequestTypeStream, RequestTypeWSV2:
return true
default:
return false
}
}
func (t RequestType) Normalize() RequestType {
if t.IsValid() {
return t
}
return RequestTypeUnknown
}
func (t RequestType) String() string {
switch t.Normalize() {
case RequestTypeSync:
return "sync"
case RequestTypeStream:
return "stream"
case RequestTypeWSV2:
return "ws_v2"
default:
return "unknown"
}
}
func RequestTypeFromInt16(v int16) RequestType {
return RequestType(v).Normalize()
}
func ParseUsageRequestType(value string) (RequestType, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "unknown":
return RequestTypeUnknown, nil
case "sync":
return RequestTypeSync, nil
case "stream":
return RequestTypeStream, nil
case "ws_v2":
return RequestTypeWSV2, nil
default:
return RequestTypeUnknown, fmt.Errorf("invalid request_type, allowed values: unknown, sync, stream, ws_v2")
}
}
func RequestTypeFromLegacy(stream bool, openAIWSMode bool) RequestType {
if openAIWSMode {
return RequestTypeWSV2
}
if stream {
return RequestTypeStream
}
return RequestTypeSync
}
func ApplyLegacyRequestFields(requestType RequestType, fallbackStream bool, fallbackOpenAIWSMode bool) (stream bool, openAIWSMode bool) {
switch requestType.Normalize() {
case RequestTypeSync:
return false, false
case RequestTypeStream:
return true, false
case RequestTypeWSV2:
return true, true
default:
return fallbackStream, fallbackOpenAIWSMode
}
}
type UsageLog struct {
ID int64
UserID int64
APIKeyID int64
AccountID int64
RequestID string
Model string
// RequestedModel is the client-requested model name recorded for stable user/admin display.
// Empty should be treated as Model for backward compatibility with historical rows.
RequestedModel string
// UpstreamModel is the actual model sent to the upstream provider after mapping.
// Nil means no mapping was applied (requested model was used as-is).
UpstreamModel *string
// ChannelID 渠道 ID
ChannelID *int64
// ModelMappingChain 模型映射链,如 "a→b→c"
ModelMappingChain *string
// BillingTier 计费层级标签per_request/image 模式)
BillingTier *string
// BillingMode 计费模式token/image
BillingMode *string
// ServiceTier records the OpenAI service tier used for billing, e.g. "priority" / "flex".
ServiceTier *string
// ReasoningEffort is the request's reasoning effort level.
// OpenAI: "low" / "medium" / "high" / "xhigh"; Claude: "low" / "medium" / "high" / "max".
// Nil means not provided / not applicable.
ReasoningEffort *string
// InboundEndpoint is the client-facing API endpoint path, e.g. /v1/chat/completions.
InboundEndpoint *string
// UpstreamEndpoint is the normalized upstream endpoint path, e.g. /v1/responses.
UpstreamEndpoint *string
GroupID *int64
SubscriptionID *int64
InputTokens int
OutputTokens int
CacheCreationTokens int
CacheReadTokens int
CacheCreation5mTokens int `gorm:"column:cache_creation_5m_tokens"`
CacheCreation1hTokens int `gorm:"column:cache_creation_1h_tokens"`
ImageOutputTokens int
ImageOutputCost float64
InputCost float64
OutputCost float64
CacheCreationCost float64
CacheReadCost float64
TotalCost float64
ActualCost float64
RateMultiplier float64
// AccountRateMultiplier 账号计费倍率快照nil 表示历史数据,按 1.0 处理)
AccountRateMultiplier *float64
// AccountStatsCost 账号统计定价预计算费用nil = 使用默认公式 total_cost × account_rate_multiplier
AccountStatsCost *float64
BillingType int8
RequestType RequestType
Stream bool
OpenAIWSMode bool
DurationMs *int
FirstTokenMs *int
UserAgent *string
IPAddress *string
// Cache TTL Override 标记(管理员强制替换了缓存 TTL 计费)
CacheTTLOverridden bool
// 图片生成字段
ImageCount int
ImageSize *string
MediaType *string
CreatedAt time.Time
User *User
APIKey *APIKey
Account *Account
Group *Group
Subscription *UserSubscription
}
func (u *UsageLog) TotalTokens() int {
return u.InputTokens + u.OutputTokens + u.CacheCreationTokens + u.CacheReadTokens
}
func (u *UsageLog) EffectiveRequestType() RequestType {
if u == nil {
return RequestTypeUnknown
}
if normalized := u.RequestType.Normalize(); normalized != RequestTypeUnknown {
return normalized
}
return RequestTypeFromLegacy(u.Stream, u.OpenAIWSMode)
}
func (u *UsageLog) SyncRequestTypeAndLegacyFields() {
if u == nil {
return
}
requestType := u.EffectiveRequestType()
u.RequestType = requestType
u.Stream, u.OpenAIWSMode = ApplyLegacyRequestFields(requestType, u.Stream, u.OpenAIWSMode)
}