Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
773 lines
24 KiB
Go
773 lines
24 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
dbaccount "github.com/Wei-Shaw/sub2api/ent/account"
|
|
dbapikey "github.com/Wei-Shaw/sub2api/ent/apikey"
|
|
dbgroup "github.com/Wei-Shaw/sub2api/ent/group"
|
|
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
|
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
|
dbusersub "github.com/Wei-Shaw/sub2api/ent/usersubscription"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
)
|
|
|
|
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at"
|
|
|
|
func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) {
|
|
query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1"
|
|
rows, err := r.sql.QueryContext(ctx, query, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() {
|
|
// 保持主错误优先;仅在无错误时回传 Close 失败。
|
|
// 同时清空返回值,避免误用不完整结果。
|
|
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
|
err = closeErr
|
|
log = nil
|
|
}
|
|
}()
|
|
if !rows.Next() {
|
|
if err = rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, service.ErrUsageLogNotFound
|
|
}
|
|
log, err = scanUsageLog(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return log, nil
|
|
}
|
|
|
|
func (r *usageLogRepository) ListByUser(ctx context.Context, userID int64, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
|
return r.listUsageLogsWithPagination(ctx, "WHERE user_id = $1", []any{userID}, params)
|
|
}
|
|
|
|
func (r *usageLogRepository) ListByAPIKey(ctx context.Context, apiKeyID int64, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
|
return r.listUsageLogsWithPagination(ctx, "WHERE api_key_id = $1", []any{apiKeyID}, params)
|
|
}
|
|
|
|
func (r *usageLogRepository) ListByAccount(ctx context.Context, accountID int64, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
|
return r.listUsageLogsWithPagination(ctx, "WHERE account_id = $1", []any{accountID}, params)
|
|
}
|
|
|
|
func (r *usageLogRepository) ListByUserAndTimeRange(ctx context.Context, userID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
|
query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE user_id = $1 AND created_at >= $2 AND created_at < $3 ORDER BY id DESC LIMIT 10000"
|
|
logs, err := r.queryUsageLogs(ctx, query, userID, startTime, endTime)
|
|
return logs, nil, err
|
|
}
|
|
|
|
func (r *usageLogRepository) ListByAPIKeyAndTimeRange(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
|
query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE api_key_id = $1 AND created_at >= $2 AND created_at < $3 ORDER BY id DESC LIMIT 10000"
|
|
logs, err := r.queryUsageLogs(ctx, query, apiKeyID, startTime, endTime)
|
|
return logs, nil, err
|
|
}
|
|
|
|
func (r *usageLogRepository) ListByAccountAndTimeRange(ctx context.Context, accountID int64, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
|
query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE account_id = $1 AND created_at >= $2 AND created_at < $3 ORDER BY id DESC LIMIT 10000"
|
|
logs, err := r.queryUsageLogs(ctx, query, accountID, startTime, endTime)
|
|
return logs, nil, err
|
|
}
|
|
|
|
func (r *usageLogRepository) ListByModelAndTimeRange(ctx context.Context, modelName string, startTime, endTime time.Time) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
|
query := fmt.Sprintf("SELECT %s FROM usage_logs WHERE %s = $1 AND created_at >= $2 AND created_at < $3 ORDER BY id DESC LIMIT 10000", usageLogSelectColumns, rawUsageLogModelColumn)
|
|
logs, err := r.queryUsageLogs(ctx, query, modelName, startTime, endTime)
|
|
return logs, nil, err
|
|
}
|
|
|
|
func (r *usageLogRepository) Delete(ctx context.Context, id int64) error {
|
|
_, err := r.sql.ExecContext(ctx, "DELETE FROM usage_logs WHERE id = $1", id)
|
|
return err
|
|
}
|
|
|
|
// UsageLogFilters represents filters for usage log queries
|
|
type UsageLogFilters = usagestats.UsageLogFilters
|
|
|
|
// ListWithFilters lists usage logs with optional filters (for admin)
|
|
func (r *usageLogRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
|
conditions := make([]string, 0, 9)
|
|
args := make([]any, 0, 9)
|
|
|
|
if filters.UserID > 0 {
|
|
conditions = append(conditions, fmt.Sprintf("user_id = $%d", len(args)+1))
|
|
args = append(args, filters.UserID)
|
|
}
|
|
if filters.APIKeyID > 0 {
|
|
conditions = append(conditions, fmt.Sprintf("api_key_id = $%d", len(args)+1))
|
|
args = append(args, filters.APIKeyID)
|
|
}
|
|
if filters.AccountID > 0 {
|
|
conditions = append(conditions, fmt.Sprintf("account_id = $%d", len(args)+1))
|
|
args = append(args, filters.AccountID)
|
|
}
|
|
if filters.GroupID > 0 {
|
|
conditions = append(conditions, fmt.Sprintf("group_id = $%d", len(args)+1))
|
|
args = append(args, filters.GroupID)
|
|
}
|
|
if requestID := strings.TrimSpace(filters.RequestID); requestID != "" {
|
|
conditions = append(conditions, fmt.Sprintf("request_id = $%d", len(args)+1))
|
|
args = append(args, requestID)
|
|
}
|
|
conditions, args = appendUsageLogModelWhereCondition(conditions, args, filters.Model, filters.ModelFilterSource)
|
|
conditions, args = appendRequestTypeOrStreamWhereCondition(conditions, args, filters.RequestType, filters.Stream)
|
|
if filters.BillingType != nil {
|
|
conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1))
|
|
args = append(args, int16(*filters.BillingType))
|
|
}
|
|
conditions, args = appendUsageLogBillingModeWhereCondition(conditions, args, filters.BillingMode)
|
|
if filters.UpstreamModelMismatch != nil {
|
|
conditions = append(conditions, upstreamModelMismatchCondition("upstream_model_mismatch", *filters.UpstreamModelMismatch))
|
|
}
|
|
if filters.StartTime != nil {
|
|
conditions = append(conditions, fmt.Sprintf("created_at >= $%d", len(args)+1))
|
|
args = append(args, *filters.StartTime)
|
|
}
|
|
if filters.EndTime != nil {
|
|
conditions = append(conditions, fmt.Sprintf("created_at < $%d", len(args)+1))
|
|
args = append(args, *filters.EndTime)
|
|
}
|
|
|
|
whereClause := buildWhere(conditions)
|
|
var (
|
|
logs []service.UsageLog
|
|
page *pagination.PaginationResult
|
|
err error
|
|
)
|
|
if shouldUseFastUsageLogTotal(filters) {
|
|
logs, page, err = r.listUsageLogsWithFastPagination(ctx, whereClause, args, params)
|
|
} else {
|
|
logs, page, err = r.listUsageLogsWithPagination(ctx, whereClause, args, params)
|
|
}
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if err := r.hydrateUsageLogAssociations(ctx, logs); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return logs, page, nil
|
|
}
|
|
|
|
func upstreamModelMismatchCondition(column string, mismatch bool) string {
|
|
if mismatch {
|
|
return column + " IS TRUE"
|
|
}
|
|
return column + " IS FALSE"
|
|
}
|
|
|
|
func shouldUseFastUsageLogTotal(filters UsageLogFilters) bool {
|
|
if filters.ExactTotal {
|
|
return false
|
|
}
|
|
// 强选择过滤下记录集通常较小,保留精确总数。
|
|
return filters.UserID == 0 && filters.APIKeyID == 0 && filters.AccountID == 0
|
|
}
|
|
|
|
func (r *usageLogRepository) listUsageLogsWithPagination(ctx context.Context, whereClause string, args []any, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
|
countQuery := "SELECT COUNT(*) FROM usage_logs " + whereClause
|
|
var total int64
|
|
if err := scanSingleRow(ctx, r.sql, countQuery, args, &total); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
limitPos := len(args) + 1
|
|
offsetPos := len(args) + 2
|
|
listArgs := append(append([]any{}, args...), params.Limit(), params.Offset())
|
|
query := fmt.Sprintf("SELECT %s FROM usage_logs %s ORDER BY %s LIMIT $%d OFFSET $%d", usageLogSelectColumns, whereClause, usageLogOrderBy(params), limitPos, offsetPos)
|
|
logs, err := r.queryUsageLogs(ctx, query, listArgs...)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return logs, paginationResultFromTotal(total, params), nil
|
|
}
|
|
|
|
func (r *usageLogRepository) listUsageLogsWithFastPagination(ctx context.Context, whereClause string, args []any, params pagination.PaginationParams) ([]service.UsageLog, *pagination.PaginationResult, error) {
|
|
limit := params.Limit()
|
|
offset := params.Offset()
|
|
|
|
limitPos := len(args) + 1
|
|
offsetPos := len(args) + 2
|
|
listArgs := append(append([]any{}, args...), limit+1, offset)
|
|
query := fmt.Sprintf("SELECT %s FROM usage_logs %s ORDER BY %s LIMIT $%d OFFSET $%d", usageLogSelectColumns, whereClause, usageLogOrderBy(params), limitPos, offsetPos)
|
|
|
|
logs, err := r.queryUsageLogs(ctx, query, listArgs...)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
hasMore := false
|
|
if len(logs) > limit {
|
|
hasMore = true
|
|
logs = logs[:limit]
|
|
}
|
|
|
|
total := int64(offset) + int64(len(logs))
|
|
if hasMore {
|
|
// 只保证“还有下一页”,避免对超大表做全量 COUNT(*)。
|
|
total = int64(offset) + int64(limit) + 1
|
|
}
|
|
|
|
return logs, paginationResultFromTotal(total, params), nil
|
|
}
|
|
|
|
func usageLogOrderBy(params pagination.PaginationParams) string {
|
|
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
|
|
sortOrder := strings.ToUpper(params.NormalizedSortOrder(pagination.SortOrderDesc))
|
|
|
|
var column string
|
|
switch sortBy {
|
|
case "model":
|
|
column = "COALESCE(NULLIF(TRIM(requested_model), ''), model)"
|
|
case "created_at":
|
|
column = "created_at"
|
|
default:
|
|
column = "id"
|
|
}
|
|
|
|
if column == "id" {
|
|
return fmt.Sprintf("id %s", sortOrder)
|
|
}
|
|
return fmt.Sprintf("%s %s, id %s", column, sortOrder, sortOrder)
|
|
}
|
|
|
|
func (r *usageLogRepository) queryUsageLogs(ctx context.Context, query string, args ...any) (logs []service.UsageLog, err error) {
|
|
rows, err := r.sql.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() {
|
|
// 保持主错误优先;仅在无错误时回传 Close 失败。
|
|
// 同时清空返回值,避免误用不完整结果。
|
|
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
|
err = closeErr
|
|
logs = nil
|
|
}
|
|
}()
|
|
|
|
logs = make([]service.UsageLog, 0)
|
|
for rows.Next() {
|
|
var log *service.UsageLog
|
|
log, err = scanUsageLog(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
logs = append(logs, *log)
|
|
}
|
|
if err = rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return logs, nil
|
|
}
|
|
|
|
func (r *usageLogRepository) hydrateUsageLogAssociations(ctx context.Context, logs []service.UsageLog) error {
|
|
// 关联数据使用 Ent 批量加载,避免把复杂 SQL 继续膨胀。
|
|
if len(logs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
ids := collectUsageLogIDs(logs)
|
|
users, err := r.loadUsers(ctx, ids.userIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
apiKeys, err := r.loadAPIKeys(ctx, ids.apiKeyIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
accounts, err := r.loadAccounts(ctx, ids.accountIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
groups, err := r.loadGroups(ctx, ids.groupIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
subs, err := r.loadSubscriptions(ctx, ids.subscriptionIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for i := range logs {
|
|
if user, ok := users[logs[i].UserID]; ok {
|
|
logs[i].User = user
|
|
}
|
|
if key, ok := apiKeys[logs[i].APIKeyID]; ok {
|
|
logs[i].APIKey = key
|
|
}
|
|
if acc, ok := accounts[logs[i].AccountID]; ok {
|
|
logs[i].Account = acc
|
|
}
|
|
if logs[i].GroupID != nil {
|
|
if group, ok := groups[*logs[i].GroupID]; ok {
|
|
logs[i].Group = group
|
|
}
|
|
}
|
|
if logs[i].SubscriptionID != nil {
|
|
if sub, ok := subs[*logs[i].SubscriptionID]; ok {
|
|
logs[i].Subscription = sub
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type usageLogIDs struct {
|
|
userIDs []int64
|
|
apiKeyIDs []int64
|
|
accountIDs []int64
|
|
groupIDs []int64
|
|
subscriptionIDs []int64
|
|
}
|
|
|
|
func collectUsageLogIDs(logs []service.UsageLog) usageLogIDs {
|
|
idSet := func() map[int64]struct{} { return make(map[int64]struct{}) }
|
|
|
|
userIDs := idSet()
|
|
apiKeyIDs := idSet()
|
|
accountIDs := idSet()
|
|
groupIDs := idSet()
|
|
subscriptionIDs := idSet()
|
|
|
|
for i := range logs {
|
|
userIDs[logs[i].UserID] = struct{}{}
|
|
apiKeyIDs[logs[i].APIKeyID] = struct{}{}
|
|
accountIDs[logs[i].AccountID] = struct{}{}
|
|
if logs[i].GroupID != nil {
|
|
groupIDs[*logs[i].GroupID] = struct{}{}
|
|
}
|
|
if logs[i].SubscriptionID != nil {
|
|
subscriptionIDs[*logs[i].SubscriptionID] = struct{}{}
|
|
}
|
|
}
|
|
|
|
return usageLogIDs{
|
|
userIDs: setToSlice(userIDs),
|
|
apiKeyIDs: setToSlice(apiKeyIDs),
|
|
accountIDs: setToSlice(accountIDs),
|
|
groupIDs: setToSlice(groupIDs),
|
|
subscriptionIDs: setToSlice(subscriptionIDs),
|
|
}
|
|
}
|
|
|
|
func (r *usageLogRepository) loadUsers(ctx context.Context, ids []int64) (map[int64]*service.User, error) {
|
|
out := make(map[int64]*service.User)
|
|
if len(ids) == 0 {
|
|
return out, nil
|
|
}
|
|
// 无条件穿透软删除:ids 来自调用方已按 user_id 筛选的日志行;普通用户路径强制 UserID=本人(本人必为活跃用户),不会借此解析他人已删身份;仅 admin 路径可借此显示已删用户。
|
|
models, err := r.client.User.Query().Where(dbuser.IDIn(ids...)).All(mixins.SkipSoftDelete(ctx))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, m := range models {
|
|
out[m.ID] = userEntityToService(m)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *usageLogRepository) loadAPIKeys(ctx context.Context, ids []int64) (map[int64]*service.APIKey, error) {
|
|
out := make(map[int64]*service.APIKey)
|
|
if len(ids) == 0 {
|
|
return out, nil
|
|
}
|
|
models, err := r.client.APIKey.Query().Where(dbapikey.IDIn(ids...)).All(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, m := range models {
|
|
out[m.ID] = apiKeyEntityToService(m)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *usageLogRepository) loadAccounts(ctx context.Context, ids []int64) (map[int64]*service.Account, error) {
|
|
out := make(map[int64]*service.Account)
|
|
if len(ids) == 0 {
|
|
return out, nil
|
|
}
|
|
models, err := r.client.Account.Query().Where(dbaccount.IDIn(ids...)).All(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, m := range models {
|
|
out[m.ID] = accountEntityToService(m)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *usageLogRepository) loadGroups(ctx context.Context, ids []int64) (map[int64]*service.Group, error) {
|
|
out := make(map[int64]*service.Group)
|
|
if len(ids) == 0 {
|
|
return out, nil
|
|
}
|
|
models, err := r.client.Group.Query().Where(dbgroup.IDIn(ids...)).All(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, m := range models {
|
|
out[m.ID] = groupEntityToService(m)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *usageLogRepository) loadSubscriptions(ctx context.Context, ids []int64) (map[int64]*service.UserSubscription, error) {
|
|
out := make(map[int64]*service.UserSubscription)
|
|
if len(ids) == 0 {
|
|
return out, nil
|
|
}
|
|
models, err := r.client.UserSubscription.Query().Where(dbusersub.IDIn(ids...)).All(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, m := range models {
|
|
out[m.ID] = userSubscriptionEntityToService(m)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, error) {
|
|
var (
|
|
id int64
|
|
userID int64
|
|
apiKeyID int64
|
|
accountID int64
|
|
requestID sql.NullString
|
|
model string
|
|
requestedModel sql.NullString
|
|
upstreamModel sql.NullString
|
|
upstreamResponseModel sql.NullString
|
|
upstreamModelMismatch sql.NullBool
|
|
groupID sql.NullInt64
|
|
subscriptionID sql.NullInt64
|
|
inputTokens int
|
|
outputTokens int
|
|
cacheCreationTokens int
|
|
cacheReadTokens int
|
|
cacheCreation5m int
|
|
cacheCreation1h int
|
|
imageOutputTokens int
|
|
imageOutputCost float64
|
|
imageInputTokens int
|
|
imageInputCost float64
|
|
inputCost float64
|
|
outputCost float64
|
|
cacheCreationCost float64
|
|
cacheReadCost float64
|
|
totalCost float64
|
|
actualCost float64
|
|
rateMultiplier float64
|
|
accountRateMultiplier sql.NullFloat64
|
|
billingType int16
|
|
requestTypeRaw int16
|
|
stream bool
|
|
openaiWSMode bool
|
|
durationMs sql.NullInt64
|
|
firstTokenMs sql.NullInt64
|
|
userAgent sql.NullString
|
|
ipAddress sql.NullString
|
|
imageCount int
|
|
imageSize sql.NullString
|
|
imageInputSize sql.NullString
|
|
imageOutputSize sql.NullString
|
|
imageSizeSource sql.NullString
|
|
imageSizeBreakdown sql.NullString
|
|
videoCount int
|
|
videoResolution sql.NullString
|
|
videoDurationSeconds sql.NullInt64
|
|
serviceTier sql.NullString
|
|
reasoningEffort sql.NullString
|
|
inboundEndpoint sql.NullString
|
|
upstreamEndpoint sql.NullString
|
|
cacheTTLOverridden bool
|
|
longContextBillingApplied bool
|
|
channelID sql.NullInt64
|
|
modelMappingChain sql.NullString
|
|
billingTier sql.NullString
|
|
billingMode sql.NullString
|
|
accountStatsCost sql.NullFloat64
|
|
sessionID sql.NullString
|
|
createdAt time.Time
|
|
)
|
|
|
|
if err := scanner.Scan(
|
|
&id,
|
|
&userID,
|
|
&apiKeyID,
|
|
&accountID,
|
|
&requestID,
|
|
&model,
|
|
&requestedModel,
|
|
&upstreamModel,
|
|
&upstreamResponseModel,
|
|
&upstreamModelMismatch,
|
|
&groupID,
|
|
&subscriptionID,
|
|
&inputTokens,
|
|
&outputTokens,
|
|
&cacheCreationTokens,
|
|
&cacheReadTokens,
|
|
&cacheCreation5m,
|
|
&cacheCreation1h,
|
|
&imageOutputTokens,
|
|
&imageOutputCost,
|
|
&imageInputTokens,
|
|
&imageInputCost,
|
|
&inputCost,
|
|
&outputCost,
|
|
&cacheCreationCost,
|
|
&cacheReadCost,
|
|
&totalCost,
|
|
&actualCost,
|
|
&rateMultiplier,
|
|
&accountRateMultiplier,
|
|
&billingType,
|
|
&requestTypeRaw,
|
|
&stream,
|
|
&openaiWSMode,
|
|
&durationMs,
|
|
&firstTokenMs,
|
|
&userAgent,
|
|
&ipAddress,
|
|
&imageCount,
|
|
&imageSize,
|
|
&imageInputSize,
|
|
&imageOutputSize,
|
|
&imageSizeSource,
|
|
&imageSizeBreakdown,
|
|
&videoCount,
|
|
&videoResolution,
|
|
&videoDurationSeconds,
|
|
&serviceTier,
|
|
&reasoningEffort,
|
|
&inboundEndpoint,
|
|
&upstreamEndpoint,
|
|
&cacheTTLOverridden,
|
|
&longContextBillingApplied,
|
|
&channelID,
|
|
&modelMappingChain,
|
|
&billingTier,
|
|
&billingMode,
|
|
&accountStatsCost,
|
|
&sessionID,
|
|
&createdAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
log := &service.UsageLog{
|
|
ID: id,
|
|
UserID: userID,
|
|
APIKeyID: apiKeyID,
|
|
AccountID: accountID,
|
|
Model: model,
|
|
RequestedModel: coalesceTrimmedString(requestedModel, model),
|
|
InputTokens: inputTokens,
|
|
OutputTokens: outputTokens,
|
|
CacheCreationTokens: cacheCreationTokens,
|
|
CacheReadTokens: cacheReadTokens,
|
|
CacheCreation5mTokens: cacheCreation5m,
|
|
CacheCreation1hTokens: cacheCreation1h,
|
|
ImageOutputTokens: imageOutputTokens,
|
|
ImageOutputCost: imageOutputCost,
|
|
ImageInputTokens: imageInputTokens,
|
|
ImageInputCost: imageInputCost,
|
|
InputCost: inputCost,
|
|
OutputCost: outputCost,
|
|
CacheCreationCost: cacheCreationCost,
|
|
CacheReadCost: cacheReadCost,
|
|
TotalCost: totalCost,
|
|
ActualCost: actualCost,
|
|
RateMultiplier: rateMultiplier,
|
|
AccountRateMultiplier: nullFloat64Ptr(accountRateMultiplier),
|
|
BillingType: int8(billingType),
|
|
RequestType: service.RequestTypeFromInt16(requestTypeRaw),
|
|
ImageCount: imageCount,
|
|
VideoCount: videoCount,
|
|
CacheTTLOverridden: cacheTTLOverridden,
|
|
LongContextBillingApplied: longContextBillingApplied,
|
|
CreatedAt: createdAt,
|
|
}
|
|
// 先回填 legacy 字段,再基于 legacy + request_type 计算最终请求类型,保证历史数据兼容。
|
|
log.Stream = stream
|
|
log.OpenAIWSMode = openaiWSMode
|
|
log.RequestType = log.EffectiveRequestType()
|
|
log.Stream, log.OpenAIWSMode = service.ApplyLegacyRequestFields(log.RequestType, stream, openaiWSMode)
|
|
|
|
if requestID.Valid {
|
|
log.RequestID = requestID.String
|
|
}
|
|
if groupID.Valid {
|
|
value := groupID.Int64
|
|
log.GroupID = &value
|
|
}
|
|
if subscriptionID.Valid {
|
|
value := subscriptionID.Int64
|
|
log.SubscriptionID = &value
|
|
}
|
|
if durationMs.Valid {
|
|
value := int(durationMs.Int64)
|
|
log.DurationMs = &value
|
|
}
|
|
if firstTokenMs.Valid {
|
|
value := int(firstTokenMs.Int64)
|
|
log.FirstTokenMs = &value
|
|
}
|
|
if userAgent.Valid {
|
|
log.UserAgent = &userAgent.String
|
|
}
|
|
if ipAddress.Valid {
|
|
log.IPAddress = &ipAddress.String
|
|
}
|
|
if imageSize.Valid {
|
|
log.ImageSize = &imageSize.String
|
|
}
|
|
if imageInputSize.Valid {
|
|
log.ImageInputSize = &imageInputSize.String
|
|
}
|
|
if imageOutputSize.Valid {
|
|
log.ImageOutputSize = &imageOutputSize.String
|
|
}
|
|
if imageSizeSource.Valid {
|
|
log.ImageSizeSource = &imageSizeSource.String
|
|
}
|
|
log.ImageSizeBreakdown = stringIntMapFromNullJSON(imageSizeBreakdown)
|
|
if videoResolution.Valid {
|
|
log.VideoResolution = &videoResolution.String
|
|
}
|
|
if videoDurationSeconds.Valid {
|
|
value := int(videoDurationSeconds.Int64)
|
|
log.VideoDurationSeconds = &value
|
|
}
|
|
if serviceTier.Valid {
|
|
log.ServiceTier = &serviceTier.String
|
|
}
|
|
if reasoningEffort.Valid {
|
|
log.ReasoningEffort = &reasoningEffort.String
|
|
}
|
|
if inboundEndpoint.Valid {
|
|
log.InboundEndpoint = &inboundEndpoint.String
|
|
}
|
|
if upstreamEndpoint.Valid {
|
|
log.UpstreamEndpoint = &upstreamEndpoint.String
|
|
}
|
|
if upstreamModel.Valid {
|
|
log.UpstreamModel = &upstreamModel.String
|
|
}
|
|
if upstreamResponseModel.Valid {
|
|
log.UpstreamResponseModel = &upstreamResponseModel.String
|
|
}
|
|
if upstreamModelMismatch.Valid {
|
|
value := upstreamModelMismatch.Bool
|
|
log.UpstreamModelMismatch = &value
|
|
}
|
|
if channelID.Valid {
|
|
value := channelID.Int64
|
|
log.ChannelID = &value
|
|
}
|
|
if modelMappingChain.Valid {
|
|
log.ModelMappingChain = &modelMappingChain.String
|
|
}
|
|
if billingTier.Valid {
|
|
log.BillingTier = &billingTier.String
|
|
}
|
|
if billingMode.Valid {
|
|
log.BillingMode = &billingMode.String
|
|
}
|
|
if accountStatsCost.Valid {
|
|
log.AccountStatsCost = &accountStatsCost.Float64
|
|
}
|
|
if sessionID.Valid {
|
|
log.SessionID = &sessionID.String
|
|
}
|
|
|
|
return log, nil
|
|
}
|
|
|
|
func nullInt64(v *int64) sql.NullInt64 {
|
|
if v == nil {
|
|
return sql.NullInt64{}
|
|
}
|
|
return sql.NullInt64{Int64: *v, Valid: true}
|
|
}
|
|
|
|
func nullInt(v *int) sql.NullInt64 {
|
|
if v == nil {
|
|
return 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{}
|
|
}
|
|
return sql.NullString{String: *v, Valid: true}
|
|
}
|
|
|
|
func nullBool(v *bool) sql.NullBool {
|
|
if v == nil {
|
|
return sql.NullBool{}
|
|
}
|
|
return sql.NullBool{Bool: *v, Valid: true}
|
|
}
|
|
|
|
func nullStringIntMapJSON(v map[string]int) any {
|
|
if len(v) == 0 {
|
|
return nil
|
|
}
|
|
payload, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return string(payload)
|
|
}
|
|
|
|
func stringIntMapFromNullJSON(v sql.NullString) map[string]int {
|
|
if !v.Valid || strings.TrimSpace(v.String) == "" {
|
|
return nil
|
|
}
|
|
var out map[string]int
|
|
if err := json.Unmarshal([]byte(v.String), &out); err != nil {
|
|
return nil
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func coalesceTrimmedString(v sql.NullString, fallback string) string {
|
|
if v.Valid && strings.TrimSpace(v.String) != "" {
|
|
return v.String
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func setToSlice(set map[int64]struct{}) []int64 {
|
|
out := make([]int64, 0, len(set))
|
|
for id := range set {
|
|
out = append(out, id)
|
|
}
|
|
return out
|
|
}
|