diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 198625cf..bbc029f0 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -21503,6 +21503,8 @@ const docTemplate = `{ "UsageLedgerResponse": { "type": "object", "required": [ + "balanceAfterNanousd", + "balanceAfterUSD", "billedCurrency", "billedNanousd", "billedUSD", @@ -21535,6 +21537,16 @@ const docTemplate = `{ "userID" ], "properties": { + "balanceAfterNanousd": { + "type": "integer", + "x-nullable": true, + "x-omitempty": false + }, + "balanceAfterUSD": { + "type": "number", + "x-nullable": true, + "x-omitempty": false + }, "billedCurrency": { "type": "string" }, @@ -21660,6 +21672,8 @@ const docTemplate = `{ "UsageLogResponse": { "type": "object", "required": [ + "balanceAfterNanousd", + "balanceAfterUSD", "billedCurrency", "billedNanousd", "billedUSD", @@ -21694,6 +21708,16 @@ const docTemplate = `{ "username" ], "properties": { + "balanceAfterNanousd": { + "type": "integer", + "x-nullable": true, + "x-omitempty": false + }, + "balanceAfterUSD": { + "type": "number", + "x-nullable": true, + "x-omitempty": false + }, "billedCurrency": { "type": "string" }, diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 73e21056..3b8b3eeb 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -21496,6 +21496,8 @@ "UsageLedgerResponse": { "type": "object", "required": [ + "balanceAfterNanousd", + "balanceAfterUSD", "billedCurrency", "billedNanousd", "billedUSD", @@ -21528,6 +21530,16 @@ "userID" ], "properties": { + "balanceAfterNanousd": { + "type": "integer", + "x-nullable": true, + "x-omitempty": false + }, + "balanceAfterUSD": { + "type": "number", + "x-nullable": true, + "x-omitempty": false + }, "billedCurrency": { "type": "string" }, @@ -21653,6 +21665,8 @@ "UsageLogResponse": { "type": "object", "required": [ + "balanceAfterNanousd", + "balanceAfterUSD", "billedCurrency", "billedNanousd", "billedUSD", @@ -21687,6 +21701,16 @@ "username" ], "properties": { + "balanceAfterNanousd": { + "type": "integer", + "x-nullable": true, + "x-omitempty": false + }, + "balanceAfterUSD": { + "type": "number", + "x-nullable": true, + "x-omitempty": false + }, "billedCurrency": { "type": "string" }, diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index e3e3de2c..f4f98e6b 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -7215,6 +7215,14 @@ definitions: type: object UsageLedgerResponse: properties: + balanceAfterNanousd: + type: integer + x-nullable: true + x-omitempty: false + balanceAfterUSD: + type: number + x-nullable: true + x-omitempty: false billedCurrency: type: string billedNanousd: @@ -7276,6 +7284,8 @@ definitions: userID: type: integer required: + - balanceAfterNanousd + - balanceAfterUSD - billedCurrency - billedNanousd - billedUSD @@ -7329,6 +7339,14 @@ definitions: type: object UsageLogResponse: properties: + balanceAfterNanousd: + type: integer + x-nullable: true + x-omitempty: false + balanceAfterUSD: + type: number + x-nullable: true + x-omitempty: false billedCurrency: type: string billedNanousd: @@ -7394,6 +7412,8 @@ definitions: username: type: string required: + - balanceAfterNanousd + - balanceAfterUSD - billedCurrency - billedNanousd - billedUSD diff --git a/backend/internal/domain/billing/types.go b/backend/internal/domain/billing/types.go index 367c0899..17f76a3c 100644 --- a/backend/internal/domain/billing/types.go +++ b/backend/internal/domain/billing/types.go @@ -310,6 +310,7 @@ type UsageLedger struct { ServiceTier string BilledCurrency string BilledNanousd int64 + BalanceAfterNanousd *int64 PricingSnapshotJSON string CreatedAt time.Time UpdatedAt time.Time diff --git a/backend/internal/infra/persistence/models/billing.go b/backend/internal/infra/persistence/models/billing.go index 5f62df9b..aa480658 100644 --- a/backend/internal/infra/persistence/models/billing.go +++ b/backend/internal/infra/persistence/models/billing.go @@ -250,6 +250,7 @@ type UsageLedger struct { ServiceTier string `gorm:"size:32;not null;default:'';comment:计费服务等级"` BilledCurrency string `gorm:"size:16;not null;default:'USD';comment:计费币种"` BilledNanousd int64 `gorm:"not null;default:0;comment:账单金额(纳美元)"` + BalanceAfterNanousd *int64 `gorm:"comment:调用结算后按量余额(纳美元)"` PricingSnapshotJSON string `gorm:"type:text;not null;default:'';comment:计费快照JSON"` } diff --git a/backend/internal/infra/persistence/postgres/billing/repository.go b/backend/internal/infra/persistence/postgres/billing/repository.go index 0686f568..e9f23bcc 100644 --- a/backend/internal/infra/persistence/postgres/billing/repository.go +++ b/backend/internal/infra/persistence/postgres/billing/repository.go @@ -529,13 +529,9 @@ func (r *Repo) AddUsageAndSettleBalance(ctx context.Context, usage *domainbillin if usage.IsFreeModel || chargeNanousd <= 0 { chargeNanousd = 0 } - var account *model.BillingAccount - if chargeNanousd > 0 || reservation != nil { - var err error - account, err = getOrCreateBillingAccountForUpdate(tx, usage.UserID) - if err != nil { - return err - } + account, err := getOrCreateBillingAccountForUpdate(tx, usage.UserID) + if err != nil { + return err } reservationRow, alreadySettled, err := getUsageReservationForSettlement(tx, usage.UserID, reservation) if err != nil { @@ -548,12 +544,13 @@ func (r *Repo) AddUsageAndSettleBalance(ctx context.Context, usage *domainbillin return restoreSettledUsageLedger(tx, reservationRow.UsageLedgerID, usage) } + nextBalance := account.BalanceNanousd - chargeNanousd + record.BalanceAfterNanousd = &nextBalance if err := tx.Create(&record).Error; err != nil { return translateError(err) } if chargeNanousd > 0 { // 上游已产生真实用量时必须完整入账;余额可以转负,后续调用由原子预算预留拦截。 - nextBalance := account.BalanceNanousd - chargeNanousd if err := tx.Model(account).Updates(map[string]interface{}{ "balance_nanousd": nextBalance, "currency": "USD", @@ -654,8 +651,10 @@ func (r *Repo) AddPeriodUsageAndSettleOverage( reservedCreditNanousd = reservationRow.PeriodCreditNanousd } reservationDeltaNanousd := overageNanousd - reservedBalanceNanousd + nextBalance := account.BalanceNanousd - overageNanousd ledger := *usage + ledger.BalanceAfterNanousd = &nextBalance ledger.PricingSnapshotJSON = withPeriodSettlementSnapshot(ledger.PricingSnapshotJSON, map[string]interface{}{ "period_credit_nanousd": periodCreditNanousd, "period_used_before_nanousd": usedBeforeNanousd, @@ -673,7 +672,6 @@ func (r *Repo) AddPeriodUsageAndSettleOverage( } if overageNanousd > 0 { // 超出周期额度的真实用量必须完整入账;预留仅限制并发风险,不改变最终扣费金额。 - nextBalance := account.BalanceNanousd - overageNanousd if err := tx.Model(account).Updates(map[string]interface{}{ "balance_nanousd": nextBalance, "currency": "USD", @@ -1348,7 +1346,7 @@ func (r *Repo) UpsertModelPricing(ctx context.Context, item *domainbilling.Model // ListUsageByUser 分页查询账本。 func (r *Repo) ListUsageByUser(ctx context.Context, userID uint, filter repository.UsageListFilter, offset int, limit int) ([]domainbilling.UsageLedger, int64, error) { - items := make([]model.UsageLedger, 0) + items := make([]usageLedgerListRow, 0) var total int64 query := r.db.WithContext(ctx).Model(&model.UsageLedger{}).Where("user_id = ?", userID) if search := strings.TrimSpace(filter.Query); search != "" { @@ -1376,7 +1374,7 @@ func (r *Repo) ListUsageByUser(ctx context.Context, userID uint, filter reposito case "latency_desc": order = "latency_ms DESC, id DESC" } - if err := query. + if err := selectUsageLedgerRows(query). Order(order). Offset(offset). Limit(limit). @@ -1385,14 +1383,14 @@ func (r *Repo) ListUsageByUser(ctx context.Context, userID uint, filter reposito } results := make([]domainbilling.UsageLedger, 0, len(items)) for _, item := range items { - results = append(results, toDomainUsageLedger(item)) + results = append(results, toDomainUsageLedgerRow(item)) } return results, total, nil } // ListUsageLogs 分页查询管理员调用日志。 func (r *Repo) ListUsageLogs(ctx context.Context, filter repository.UsageLogListFilter, offset int, limit int) ([]domainbilling.UsageLedger, int64, error) { - items := make([]model.UsageLedger, 0) + items := make([]usageLedgerListRow, 0) var total int64 query := r.db.WithContext(ctx).Model(&model.UsageLedger{}) if filter.UserID > 0 { @@ -1440,7 +1438,7 @@ func (r *Repo) ListUsageLogs(ctx context.Context, filter repository.UsageLogList case "latency_desc": order = "latency_ms DESC, id DESC" } - if err := query. + if err := selectUsageLedgerRows(query). Order(order). Offset(offset). Limit(limit). @@ -1449,11 +1447,41 @@ func (r *Repo) ListUsageLogs(ctx context.Context, filter repository.UsageLogList } results := make([]domainbilling.UsageLedger, 0, len(items)) for _, item := range items { - results = append(results, toDomainUsageLedger(item)) + results = append(results, toDomainUsageLedgerRow(item)) } return results, total, nil } +type usageLedgerListRow struct { + model.UsageLedger + ResolvedBalanceAfterNanousd *int64 `gorm:"column:resolved_balance_after_nanousd"` +} + +func selectUsageLedgerRows(query *gorm.DB) *gorm.DB { + return query.Select( + `billing_usage_ledgers.*, + COALESCE( + billing_usage_ledgers.balance_after_nanousd, + ( + SELECT balance_after_nanousd + FROM billing_balance_transactions + WHERE ref_type = ? + AND type = ? + AND ref_id = billing_usage_ledgers.id + ORDER BY id DESC + LIMIT 1 + ) + ) AS resolved_balance_after_nanousd`, + "usage_ledger", + domainbilling.BalanceTransactionTypeUsage, + ) +} + +func toDomainUsageLedgerRow(item usageLedgerListRow) domainbilling.UsageLedger { + item.UsageLedger.BalanceAfterNanousd = item.ResolvedBalanceAfterNanousd + return toDomainUsageLedger(item.UsageLedger) +} + type usageStatisticsMetricRow struct { RecordCount int64 `gorm:"column:record_count"` InputTokens int64 `gorm:"column:input_tokens"` @@ -2081,6 +2109,7 @@ func toModelUsageLedger(usage *domainbilling.UsageLedger) model.UsageLedger { ServiceTier: usage.ServiceTier, BilledCurrency: usage.BilledCurrency, BilledNanousd: usage.BilledNanousd, + BalanceAfterNanousd: usage.BalanceAfterNanousd, PricingSnapshotJSON: usage.PricingSnapshotJSON, } } @@ -2112,6 +2141,7 @@ func toDomainUsageLedger(item model.UsageLedger) domainbilling.UsageLedger { ServiceTier: item.ServiceTier, BilledCurrency: item.BilledCurrency, BilledNanousd: item.BilledNanousd, + BalanceAfterNanousd: item.BalanceAfterNanousd, PricingSnapshotJSON: item.PricingSnapshotJSON, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, diff --git a/backend/internal/infra/persistence/postgres/billing/repository_sqlite_test.go b/backend/internal/infra/persistence/postgres/billing/repository_sqlite_test.go index 7585c410..c32e9ca6 100644 --- a/backend/internal/infra/persistence/postgres/billing/repository_sqlite_test.go +++ b/backend/internal/infra/persistence/postgres/billing/repository_sqlite_test.go @@ -54,6 +54,15 @@ func TestUsageQueriesUseSQLitePortableExpressions(t *testing.T) { if err := db.Create(&entries).Error; err != nil { t.Fatalf("create usage ledgers: %v", err) } + if err := db.Create(&model.BalanceTransaction{ + UserID: 1, + Type: domainbilling.BalanceTransactionTypeUsage, + BalanceAfterNanousd: 420, + RefType: "usage_ledger", + RefID: entries[1].ID, + }).Error; err != nil { + t.Fatalf("create historical usage balance transaction: %v", err) + } logs, total, err := repo.ListUsageLogs(ctx, repository.UsageLogListFilter{ UserID: 1, @@ -65,6 +74,17 @@ func TestUsageQueriesUseSQLitePortableExpressions(t *testing.T) { if total != 1 || len(logs) != 1 || logs[0].PlatformModelName != "call-test" { t.Fatalf("expected one call-mode usage log, total=%d logs=%v", total, logs) } + if logs[0].BalanceAfterNanousd == nil || *logs[0].BalanceAfterNanousd != 420 { + t.Fatalf("expected historical balance fallback 420, got %v", logs[0].BalanceAfterNanousd) + } + + userLogs, userTotal, err := repo.ListUsageByUser(ctx, 1, repository.UsageListFilter{Query: "call-test"}, 0, 10) + if err != nil { + t.Fatalf("ListUsageByUser() error = %v", err) + } + if userTotal != 1 || len(userLogs) != 1 || userLogs[0].BalanceAfterNanousd == nil || *userLogs[0].BalanceAfterNanousd != 420 { + t.Fatalf("expected user usage balance fallback 420, total=%d logs=%v", userTotal, userLogs) + } monthly, err := repo.ListMonthlyUsageByUser(ctx, 1, 1) if err != nil { @@ -379,6 +399,9 @@ func TestAddUsageAndSettleBalanceLeavesFreeModelBalanceUnchanged(t *testing.T) { if err := db.Where("user_id = ? AND platform_model_name = ?", 1, "free-model").First(&ledger).Error; err != nil { t.Fatalf("load usage ledger: %v", err) } + if ledger.BalanceAfterNanousd == nil || *ledger.BalanceAfterNanousd != 100 { + t.Fatalf("ledger balance after = %v, want 100", ledger.BalanceAfterNanousd) + } var transactionCount int64 if err := db.Model(&model.BalanceTransaction{}).Where("user_id = ?", 1).Count(&transactionCount).Error; err != nil { t.Fatalf("count balance transactions: %v", err) @@ -412,6 +435,9 @@ func assertUsageSettlement( if err := db.Where("user_id = ? AND platform_model_name = ?", userID, platformModelName).First(&ledger).Error; err != nil { t.Fatalf("load usage ledger: %v", err) } + if ledger.BalanceAfterNanousd == nil || *ledger.BalanceAfterNanousd != wantBalance { + t.Fatalf("ledger balance after = %v, want %d", ledger.BalanceAfterNanousd, wantBalance) + } var transaction model.BalanceTransaction if err := db.Where("user_id = ? AND type = ? AND ref_id = ?", userID, domainbilling.BalanceTransactionTypeUsage, ledger.ID).First(&transaction).Error; err != nil { @@ -797,6 +823,9 @@ func TestAddPeriodUsageAndSettleOverageSplitsCreditAndBalance(t *testing.T) { if ledger.BilledNanousd != 500 { t.Fatalf("billed nanousd = %d, want 500", ledger.BilledNanousd) } + if ledger.BalanceAfterNanousd == nil || *ledger.BalanceAfterNanousd != 200 { + t.Fatalf("ledger balance after = %v, want 200", ledger.BalanceAfterNanousd) + } var snapshot map[string]interface{} if err := json.Unmarshal([]byte(ledger.PricingSnapshotJSON), &snapshot); err != nil { t.Fatalf("decode pricing snapshot: %v", err) @@ -956,6 +985,13 @@ func TestAddPeriodUsageAndSettleOverageUsesBillingAtForPeriodBoundary(t *testing if refreshed.BalanceNanousd != 500 { t.Fatalf("balance = %d, want unchanged 500", refreshed.BalanceNanousd) } + var ledger model.UsageLedger + if err := db.Where("platform_model_name = ?", "gpt-current-period").First(&ledger).Error; err != nil { + t.Fatalf("load current period usage: %v", err) + } + if ledger.BalanceAfterNanousd == nil || *ledger.BalanceAfterNanousd != 500 { + t.Fatalf("ledger balance after = %v, want unchanged 500", ledger.BalanceAfterNanousd) + } } func TestValidateRedeemableCodeAllowsUsageCodeInPeriodModeOnly(t *testing.T) { diff --git a/backend/internal/transport/http/admin/dto.go b/backend/internal/transport/http/admin/dto.go index 1470e32e..98d2831e 100644 --- a/backend/internal/transport/http/admin/dto.go +++ b/backend/internal/transport/http/admin/dto.go @@ -282,6 +282,8 @@ type UsageLogResponse struct { BilledCurrency string `json:"billedCurrency"` BilledNanousd int64 `json:"billedNanousd"` BilledUSD float64 `json:"billedUSD"` + BalanceAfterNanousd *int64 `json:"balanceAfterNanousd" extensions:"x-nullable,!x-omitempty"` + BalanceAfterUSD *float64 `json:"balanceAfterUSD" extensions:"x-nullable,!x-omitempty"` PricingSnapshotJSON string `json:"pricingSnapshotJSON"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` @@ -792,12 +794,22 @@ func toUsageLogResponse(item domainbilling.UsageLedger, label appadmin.UserLabel BilledCurrency: item.BilledCurrency, BilledNanousd: item.BilledNanousd, BilledUSD: float64(item.BilledNanousd) / 1_000_000_000, + BalanceAfterNanousd: item.BalanceAfterNanousd, + BalanceAfterUSD: nullableBalanceNanousdToUSD(item.BalanceAfterNanousd), PricingSnapshotJSON: item.PricingSnapshotJSON, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, } } +func nullableBalanceNanousdToUSD(value *int64) *float64 { + if value == nil { + return nil + } + converted := float64(*value) / 1_000_000_000 + return &converted +} + func toUsageStatisticsMetricsResponse(item domainbilling.UsageStatisticsMetrics) UsageStatisticsMetricsResponse { totalTokens := item.InputTokens + item.CacheReadTokens + item.CacheWriteTokens + item.OutputTokens + item.ReasoningTokens return UsageStatisticsMetricsResponse{ diff --git a/backend/internal/transport/http/billing/dto.go b/backend/internal/transport/http/billing/dto.go index 0d783889..db48a2fa 100644 --- a/backend/internal/transport/http/billing/dto.go +++ b/backend/internal/transport/http/billing/dto.go @@ -273,6 +273,8 @@ type UsageLedgerResponse struct { BilledCurrency string `json:"billedCurrency"` BilledNanousd int64 `json:"billedNanousd"` BilledUSD float64 `json:"billedUSD"` + BalanceAfterNanousd *int64 `json:"balanceAfterNanousd" extensions:"x-nullable,!x-omitempty"` + BalanceAfterUSD *float64 `json:"balanceAfterUSD" extensions:"x-nullable,!x-omitempty"` PricingSnapshotJSON string `json:"pricingSnapshotJSON"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` @@ -965,6 +967,8 @@ func toUsageLedgerResponse(u domainbilling.UsageLedger) UsageLedgerResponse { BilledCurrency: u.BilledCurrency, BilledNanousd: u.BilledNanousd, BilledUSD: nanousdToUSD(u.BilledNanousd), + BalanceAfterNanousd: u.BalanceAfterNanousd, + BalanceAfterUSD: nullableSignedNanousdToUSD(u.BalanceAfterNanousd), PricingSnapshotJSON: sanitizeUsagePricingSnapshotJSON(u.PricingSnapshotJSON), CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, @@ -1187,3 +1191,11 @@ func nanousdToUSD(value int64) float64 { func signedNanousdToUSD(value int64) float64 { return float64(value) / 1000000000 } + +func nullableSignedNanousdToUSD(value *int64) *float64 { + if value == nil { + return nil + } + converted := signedNanousdToUSD(*value) + return &converted +} diff --git a/backend/internal/transport/http/billing/dto_test.go b/backend/internal/transport/http/billing/dto_test.go index 0d86a86e..3bce470d 100644 --- a/backend/internal/transport/http/billing/dto_test.go +++ b/backend/internal/transport/http/billing/dto_test.go @@ -134,3 +134,19 @@ func TestNanousdToUSDClampsNegativeNonBalanceAmount(t *testing.T) { t.Fatalf("expected negative non-balance amount to be clamped, got %v", got) } } + +func TestUsageLedgerResponsePreservesBalanceSnapshot(t *testing.T) { + balanceNanousd := int64(-1_250_000_000) + response := toUsageLedgerResponse(domainbilling.UsageLedger{BalanceAfterNanousd: &balanceNanousd}) + if response.BalanceAfterNanousd == nil || *response.BalanceAfterNanousd != balanceNanousd { + t.Fatalf("expected raw balance snapshot %d, got %v", balanceNanousd, response.BalanceAfterNanousd) + } + if response.BalanceAfterUSD == nil || *response.BalanceAfterUSD != -1.25 { + t.Fatalf("expected USD balance snapshot -1.25, got %v", response.BalanceAfterUSD) + } + + response = toUsageLedgerResponse(domainbilling.UsageLedger{}) + if response.BalanceAfterNanousd != nil || response.BalanceAfterUSD != nil { + t.Fatalf("expected unavailable balance snapshot to remain nil, got %+v", response) + } +} diff --git a/frontend/features/admin/components/sections/logs/admin-logs.tsx b/frontend/features/admin/components/sections/logs/admin-logs.tsx index ccbff317..85e58b6d 100644 --- a/frontend/features/admin/components/sections/logs/admin-logs.tsx +++ b/frontend/features/admin/components/sections/logs/admin-logs.tsx @@ -57,6 +57,7 @@ import type { AdminUsageLogDTO, AdminUserAuthEventDTO, } from "@/features/admin/api/admin.types"; +import { getAdminBillingConfig } from "@/features/admin/api/billing"; import { cleanupAdminLogs, type AdminLogCleanupType, @@ -80,8 +81,19 @@ import { } from "@/features/admin/hooks/use-admin-logs"; import { cn } from "@/lib/utils"; import { resolveAccessToken } from "@/shared/auth/resolve-access-token"; +import { formatBillingBalance } from "@/features/admin/utils/account-display"; import { resolveAdminErrorMessage } from "@/features/admin/utils/admin-error"; -import { billingRateMultiplierNote, cacheWriteBillingLabel, cacheWriteBillingNote, type BillingDisplayLabels } from "@/shared/lib/billing-display"; +import { + billingRateMultiplierNote, + cacheWriteBillingLabel, + cacheWriteBillingNote, + formatBillingDisplayCompactAmountFromUSD, + formatBillingDisplayPreciseAmountFromUSD, + formatBillingDisplayUnitPriceFromUSD, + normalizeBillingDisplayCurrency, + type BillingDisplayLabels, + type BillingDisplayOptions, +} from "@/shared/lib/billing-display"; import { ModelSelect, type ModelSelectOption } from "@/shared/components/model-select"; type LogDetail = @@ -147,6 +159,10 @@ function formatCount(value: number | null | undefined, locale: string): string { return new Intl.NumberFormat(locale).format(value ?? 0); } +function formatUsageBalance(value: number | null | undefined, billingDisplay: BillingDisplayOptions): string { + return value === null || value === undefined ? "-" : formatBillingBalance(value, billingDisplay); +} + function usageTotalTokens(item: AdminUsageLogDTO): number { return item.inputTokens + item.cacheReadTokens + item.cacheWriteTokens + item.outputTokens + item.reasoningTokens; } @@ -250,21 +266,12 @@ function useUsageBillingLabels(): UsageBillingLabels { ); } -function formatUsageCost(value: number): string { - if (!Number.isFinite(value) || value <= 0) return "$0"; - if (value < 0.000001) return "< $0.000001"; - return `$${value.toLocaleString("en-US", { - minimumFractionDigits: 0, - maximumFractionDigits: 6, - })}`; +function formatUsageCost(value: number, billingDisplay: BillingDisplayOptions): string { + return formatBillingDisplayCompactAmountFromUSD(value, billingDisplay); } -function formatTooltipUsageCost(value: number): string { - if (!Number.isFinite(value) || value <= 0) return "$0.000000"; - return `$${value.toLocaleString("en-US", { - minimumFractionDigits: 6, - maximumFractionDigits: 6, - })}`; +function formatTooltipUsageCost(value: number, billingDisplay: BillingDisplayOptions): string { + return formatBillingDisplayPreciseAmountFromUSD(value, billingDisplay); } function formatMoneyCents(value: number | null | undefined, currency: string): string { @@ -285,12 +292,8 @@ function formatMoneyCents(value: number | null | undefined, currency: string): s } } -function formatTooltipUnitPrice(value: number): string { - if (!Number.isFinite(value) || value <= 0) return "$0.00"; - return `$${value.toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; +function formatTooltipUnitPrice(value: number, billingDisplay: BillingDisplayOptions): string { + return formatBillingDisplayUnitPriceFromUSD(value, billingDisplay); } function nanousdToUSD(value: number): number { @@ -418,43 +421,69 @@ function UsageBillingTieredTable({ ); } -function usageFormulaLine(label: string, tokens: number, rateNanousd: number, billedNanousd: number): UsageBillingTooltipLine { +function usageFormulaLine( + label: string, + tokens: number, + rateNanousd: number, + billedNanousd: number, + billingDisplay: BillingDisplayOptions, +): UsageBillingTooltipLine { return { type: "row", left: label, - right: `${formatFormulaTokenCount(tokens)} tokens * ${formatTooltipUnitPrice(nanousdToUSD(rateNanousd))} / 1M = ${formatTooltipUsageCost(nanousdToUSD(billedNanousd))}`, + right: `${formatFormulaTokenCount(tokens)} tokens * ${formatTooltipUnitPrice(nanousdToUSD(rateNanousd), billingDisplay)} / 1M = ${formatTooltipUsageCost(nanousdToUSD(billedNanousd), billingDisplay)}`, }; } -function usageCountFormulaLine(label: string, count: number, unit: string, rateUnit: string, rateNanousd: number, billedNanousd: number): UsageBillingTooltipLine { +function usageCountFormulaLine( + label: string, + count: number, + unit: string, + rateUnit: string, + rateNanousd: number, + billedNanousd: number, + billingDisplay: BillingDisplayOptions, +): UsageBillingTooltipLine { const safeCount = Number.isFinite(count) && count > 0 ? count : 0; return { type: "row", left: label, - right: `${safeCount.toLocaleString("en-US")} ${unit} * ${formatTooltipUnitPrice(nanousdToUSD(rateNanousd))} / ${rateUnit} = ${formatTooltipUsageCost(nanousdToUSD(billedNanousd))}`, + right: `${safeCount.toLocaleString("en-US")} ${unit} * ${formatTooltipUnitPrice(nanousdToUSD(rateNanousd), billingDisplay)} / ${rateUnit} = ${formatTooltipUsageCost(nanousdToUSD(billedNanousd), billingDisplay)}`, }; } -function usageTieredTableRow(item: string, tokens: number, rateNanousd: number, billedNanousd: number): UsageBillingTieredTableRow { +function usageTieredTableRow( + item: string, + tokens: number, + rateNanousd: number, + billedNanousd: number, + billingDisplay: BillingDisplayOptions, +): UsageBillingTieredTableRow { const safeTokens = Number.isFinite(tokens) && tokens > 0 ? tokens : 0; const safeBilled = Number.isFinite(billedNanousd) && billedNanousd > 0 ? billedNanousd : 0; return { item, tokens: formatFormulaTokenCount(safeTokens), - unitPrice: `${formatTooltipUnitPrice(nanousdToUSD(rateNanousd))} / 1M`, - amount: formatTooltipUsageCost(nanousdToUSD(safeBilled)), + unitPrice: `${formatTooltipUnitPrice(nanousdToUSD(rateNanousd), billingDisplay)} / 1M`, + amount: formatTooltipUsageCost(nanousdToUSD(safeBilled), billingDisplay), }; } -function usageTotalLine(item: AdminUsageLogDTO, labels: UsageBillingLabels): UsageBillingTooltipLine { +function usageTotalLine(item: AdminUsageLogDTO, labels: UsageBillingLabels, billingDisplay: BillingDisplayOptions): UsageBillingTooltipLine { return { type: "row", left: labels.total, - right: item.isFreeModel ? `$0.000000 (${labels.freeModelNoBilling})` : formatTooltipUsageCost(nanousdToUSD(item.billedNanousd)), + right: item.isFreeModel + ? `${formatTooltipUsageCost(0, billingDisplay)} (${labels.freeModelNoBilling})` + : formatTooltipUsageCost(nanousdToUSD(item.billedNanousd), billingDisplay), }; } -function buildUsageBillingTooltipLines(item: AdminUsageLogDTO, labels: UsageBillingLabels): UsageBillingTooltipLine[] { +function buildUsageBillingTooltipLines( + item: AdminUsageLogDTO, + labels: UsageBillingLabels, + billingDisplay: BillingDisplayOptions, +): UsageBillingTooltipLine[] { const snapshot = parseUsagePricingSnapshot(item.pricingSnapshotJSON); const pricingMode = normalizePricingMode(snapshot.pricing_mode); const inputRate = readUsageSnapshotNumber(snapshot, "input_nanousd_per_m_tokens"); @@ -462,7 +491,7 @@ function buildUsageBillingTooltipLines(item: AdminUsageLogDTO, labels: UsageBill const cacheReadRate = readUsageSnapshotNumber(snapshot, "cache_read_nanousd_per_m_tokens"); const cacheWriteRate = readUsageSnapshotNumber(snapshot, "cache_write_nanousd_per_m_tokens"); const billedOutputTokens = item.outputTokens + item.reasoningTokens; - const totalLine = usageTotalLine(item, labels); + const totalLine = usageTotalLine(item, labels, billingDisplay); const cacheWriteLabel = cacheWriteBillingLabel(snapshot, labels.billingDisplay); const cacheWriteNote = cacheWriteBillingNote(snapshot, labels.billingDisplay); const rateMultiplierNote = billingRateMultiplierNote(snapshot, labels.billingDisplay); @@ -471,7 +500,7 @@ function buildUsageBillingTooltipLines(item: AdminUsageLogDTO, labels: UsageBill const callRate = readUsageSnapshotNumber(snapshot, "call_nanousd_per_call"); const callBilled = resolveCountBilledNanousd(snapshot, "call_billed_nanousd", item.callCount, callRate); return [ - usageCountFormulaLine(labels.perCall, item.callCount, labels.callUnit, labels.callUnit, callRate, callBilled), + usageCountFormulaLine(labels.perCall, item.callCount, labels.callUnit, labels.callUnit, callRate, callBilled, billingDisplay), { type: "divider" }, totalLine, ]; @@ -481,7 +510,7 @@ function buildUsageBillingTooltipLines(item: AdminUsageLogDTO, labels: UsageBill const durationRate = readUsageSnapshotNumber(snapshot, "duration_nanousd_per_second"); const durationBilled = resolveCountBilledNanousd(snapshot, "duration_billed_nanousd", item.durationSeconds, durationRate); return [ - usageCountFormulaLine(labels.perSecond, item.durationSeconds, labels.secondUnit, labels.secondUnit, durationRate, durationBilled), + usageCountFormulaLine(labels.perSecond, item.durationSeconds, labels.secondUnit, labels.secondUnit, durationRate, durationBilled, billingDisplay), { type: "divider" }, totalLine, ]; @@ -502,13 +531,15 @@ function buildUsageBillingTooltipLines(item: AdminUsageLogDTO, labels: UsageBill type: "tiered-table", rangeLabel: formatTieredRangeLabel(snapshot.tiered_from_tokens, snapshot.tiered_up_to_tokens, labels), rows: [ - usageTieredTableRow(labels.input, item.inputTokens, inputRate, readUsageSnapshotNumber(snapshot, "input_billed_nanousd")), - usageTieredTableRow(labels.output, billedOutputTokens, outputRate, readUsageSnapshotNumber(snapshot, "output_billed_nanousd")), - usageTieredTableRow(labels.cacheRead, item.cacheReadTokens, cacheReadRate, readUsageSnapshotNumber(snapshot, "cache_read_billed_nanousd")), - usageTieredTableRow(cacheWriteLabel, item.cacheWriteTokens, cacheWriteRate, readUsageSnapshotNumber(snapshot, "cache_write_billed_nanousd")), + usageTieredTableRow(labels.input, item.inputTokens, inputRate, readUsageSnapshotNumber(snapshot, "input_billed_nanousd"), billingDisplay), + usageTieredTableRow(labels.output, billedOutputTokens, outputRate, readUsageSnapshotNumber(snapshot, "output_billed_nanousd"), billingDisplay), + usageTieredTableRow(labels.cacheRead, item.cacheReadTokens, cacheReadRate, readUsageSnapshotNumber(snapshot, "cache_read_billed_nanousd"), billingDisplay), + usageTieredTableRow(cacheWriteLabel, item.cacheWriteTokens, cacheWriteRate, readUsageSnapshotNumber(snapshot, "cache_write_billed_nanousd"), billingDisplay), ], totalLabel: labels.total, - totalAmount: item.isFreeModel ? `$0.000000 (${labels.freeModelNoBilling})` : formatTooltipUsageCost(nanousdToUSD(item.billedNanousd)), + totalAmount: item.isFreeModel + ? `${formatTooltipUsageCost(0, billingDisplay)} (${labels.freeModelNoBilling})` + : formatTooltipUsageCost(nanousdToUSD(item.billedNanousd), billingDisplay), }); return lines; } @@ -518,10 +549,10 @@ function buildUsageBillingTooltipLines(item: AdminUsageLogDTO, labels: UsageBill const cacheWriteBilled = resolveTokenBilledNanousd(snapshot, "cache_write_billed_nanousd", item.cacheWriteTokens, cacheWriteRate); const outputBilled = resolveTokenBilledNanousd(snapshot, "output_billed_nanousd", billedOutputTokens, outputRate); const lines: UsageBillingTooltipLine[] = [ - usageFormulaLine(labels.input, item.inputTokens, inputRate, inputBilled), - usageFormulaLine(labels.output, billedOutputTokens, outputRate, outputBilled), - usageFormulaLine(labels.cacheRead, item.cacheReadTokens, cacheReadRate, cacheReadBilled), - usageFormulaLine(cacheWriteLabel, item.cacheWriteTokens, cacheWriteRate, cacheWriteBilled), + usageFormulaLine(labels.input, item.inputTokens, inputRate, inputBilled, billingDisplay), + usageFormulaLine(labels.output, billedOutputTokens, outputRate, outputBilled, billingDisplay), + usageFormulaLine(labels.cacheRead, item.cacheReadTokens, cacheReadRate, cacheReadBilled, billingDisplay), + usageFormulaLine(cacheWriteLabel, item.cacheWriteTokens, cacheWriteRate, cacheWriteBilled, billingDisplay), { type: "divider" }, totalLine, ]; @@ -593,14 +624,22 @@ function UsageLogTokenCell({ item, locale }: { item: AdminUsageLogDTO; locale: s ); } -function UsageLogCostCell({ item, labels }: { item: AdminUsageLogDTO; labels: UsageBillingLabels }) { - const lines = buildUsageBillingTooltipLines(item, labels); +function UsageLogCostCell({ + item, + labels, + billingDisplay, +}: { + item: AdminUsageLogDTO; + labels: UsageBillingLabels; + billingDisplay: BillingDisplayOptions; +}) { + const lines = buildUsageBillingTooltipLines(item, labels, billingDisplay); return ( - {item.isFreeModel ? labels.freeModelNoBilling : formatUsageCost(item.billedUSD)} + {item.isFreeModel ? labels.freeModelNoBilling : formatUsageCost(item.billedUSD, billingDisplay)} @@ -660,7 +699,15 @@ function DetailBlock({ title, children }: { title: string; children: React.React ); } -function LogDetailSheet({ detail: rawDetail, onClose }: { detail: LogDetail | null; onClose: () => void }) { +function LogDetailSheet({ + detail: rawDetail, + billingDisplay, + onClose, +}: { + detail: LogDetail | null; + billingDisplay: BillingDisplayOptions; + onClose: () => void; +}) { const locale = useLocale(); const t = useTranslations("adminLogs.detail"); const usageLabels = useUsageBillingLabels(); @@ -809,7 +856,8 @@ function LogDetailSheet({ detail: rawDetail, onClose }: { detail: LogDetail | nu - + + @@ -861,7 +909,7 @@ function LogDetailSheet({ detail: rawDetail, onClose }: { detail: LogDetail | nu - + @@ -1165,7 +1213,13 @@ function AuthLogTable({ onOpenDetail }: { onOpenDetail: (item: AdminUserAuthEven ); } -function UsageLogTable({ onOpenDetail }: { onOpenDetail: (item: AdminUsageLogDTO) => void }) { +function UsageLogTable({ + billingDisplay, + onOpenDetail, +}: { + billingDisplay: BillingDisplayOptions; + onOpenDetail: (item: AdminUsageLogDTO) => void; +}) { const locale = useLocale(); const t = useTranslations("adminLogs"); const usageLabels = useUsageBillingLabels(); @@ -1245,13 +1299,14 @@ function UsageLogTable({ onOpenDetail }: { onOpenDetail: (item: AdminUsageLogDTO {t("columns.model")} Token {t("columns.billing")} + {t("columns.balanceAfter")} {t("columns.latency")} {t("columns.time")} - {logs.loading && logs.logs.length === 0 ? : null} - {logs.logs.length > 0 ? : null} + {logs.loading && logs.logs.length === 0 ? : null} + {logs.logs.length > 0 ? : null} {logs.logs.length > 0 ? virtualRows.rows.map(({ item }) => ( onOpenDetail(item)}> {item.id} @@ -1266,13 +1321,16 @@ function UsageLogTable({ onOpenDetail }: { onOpenDetail: (item: AdminUsageLogDTO - + + + {formatUsageBalance(item.balanceAfterUSD, billingDisplay)} + {formatCount(item.latencyMS, locale)} ms {formatDateTime(item.createdAt, locale)} )) : null} - {logs.logs.length > 0 ? : null} - {!logs.loading && logs.logs.length === 0 ? {t("usage.empty")} : null} + {logs.logs.length > 0 ? : null} + {!logs.loading && logs.logs.length === 0 ? {t("usage.empty")} : null} @@ -1765,6 +1823,10 @@ export function AdminLogsPage() { const t = useTranslations("adminLogs"); const [detail, setDetail] = React.useState(null); const [cleanupOpen, setCleanupOpen] = React.useState(false); + const [billingDisplay, setBillingDisplay] = React.useState({ + currency: "USD", + usdToCnyRate: null, + }); const [cleanupRevisions, setCleanupRevisions] = React.useState>({ audit: 0, auth: 0, @@ -1774,6 +1836,29 @@ export function AdminLogsPage() { system: 0, }); + React.useEffect(() => { + let cancelled = false; + void (async () => { + try { + const token = await resolveAccessToken(); + if (!token) return; + const result = await getAdminBillingConfig(token); + if (cancelled) return; + setBillingDisplay({ + currency: normalizeBillingDisplayCurrency(result.config.displayCurrency), + usdToCnyRate: result.config.usdToCNYRate ?? null, + }); + } catch { + if (!cancelled) { + setBillingDisplay({ currency: "USD", usdToCnyRate: null }); + } + } + })(); + return () => { + cancelled = true; + }; + }, []); + const handleCleanupSuccess = React.useCallback((type: AdminLogCleanupType) => { setCleanupRevisions((current) => ({ ...current, @@ -1814,7 +1899,11 @@ export function AdminLogsPage() { setDetail({ kind: "auth", item })} /> - setDetail({ kind: "usage", item })} /> + setDetail({ kind: "usage", item })} + /> setDetail({ kind: "order", item })} /> @@ -1824,7 +1913,7 @@ export function AdminLogsPage() { - setDetail(null)} /> + setDetail(null)} /> string; }; +function formatBalanceAfter(value: number | null | undefined, billingDisplay: BillingDisplayOptions): string { + return value === null || value === undefined ? "-" : formatAccountBalance(value, billingDisplay); +} + function useBillingTooltipLabels(): BillingTooltipLabels { const t = useTranslations("settings.subscriptionPage.billingTooltip"); return React.useMemo( @@ -613,13 +618,14 @@ export function SubscriptionUsageLog({ {t("columns.model")} {t("columns.baseBilling")} {t("columns.serviceBilling")} + {t("columns.balanceAfter")} {t("columns.latency")} - {initialLoading ? : null} - {!loading && rows.length === 0 ? {t("empty")} : null} - {showRows ? : null} + {initialLoading ? : null} + {!loading && rows.length === 0 ? {t("empty")} : null} + {showRows ? : null} {showRows ? virtualRows.rows.map(({ item: row }) => ( @@ -635,11 +641,14 @@ export function SubscriptionUsageLog({ + + {formatBalanceAfter(row.item.balanceAfterUSD, billingDisplay)} + {formatLatency(row.item.latencyMS)} )) : null} - {showRows ? : null} + {showRows ? : null} diff --git a/frontend/i18n/messages/en-US/admin-logs.json b/frontend/i18n/messages/en-US/admin-logs.json index 6f602321..a4551a02 100644 --- a/frontend/i18n/messages/en-US/admin-logs.json +++ b/frontend/i18n/messages/en-US/admin-logs.json @@ -104,6 +104,7 @@ "caller": "Caller", "model": "Model", "billing": "Billing", + "balanceAfter": "Balance after settlement", "latency": "Latency", "orderNo": "Order no.", "type": "Type", @@ -329,6 +330,7 @@ "bindingCode": "Binding code", "protocol": "Protocol", "billing": "Billing", + "balanceAfter": "Balance after settlement", "totalTokens": "Total tokens", "reasoning": "Reasoning", "callCount": "Call count", diff --git a/frontend/i18n/messages/en-US/settings.json b/frontend/i18n/messages/en-US/settings.json index 5c54f3f4..83337b6a 100644 --- a/frontend/i18n/messages/en-US/settings.json +++ b/frontend/i18n/messages/en-US/settings.json @@ -541,6 +541,7 @@ "model": "Model", "baseBilling": "Base billing", "serviceBilling": "Service billing", + "balanceAfter": "Balance after settlement", "latency": "Latency" }, "empty": "No usage logs." diff --git a/frontend/i18n/messages/zh-CN/admin-logs.json b/frontend/i18n/messages/zh-CN/admin-logs.json index a27e9105..985a5051 100644 --- a/frontend/i18n/messages/zh-CN/admin-logs.json +++ b/frontend/i18n/messages/zh-CN/admin-logs.json @@ -104,6 +104,7 @@ "caller": "调用人", "model": "模型", "billing": "计费", + "balanceAfter": "结算后余额", "latency": "延迟", "orderNo": "订单号", "type": "类型", @@ -329,6 +330,7 @@ "bindingCode": "绑定编码", "protocol": "协议", "billing": "计费", + "balanceAfter": "结算后余额", "totalTokens": "总 Token", "reasoning": "推理", "callCount": "调用次数", diff --git a/frontend/i18n/messages/zh-CN/settings.json b/frontend/i18n/messages/zh-CN/settings.json index e97895af..19f76d7d 100644 --- a/frontend/i18n/messages/zh-CN/settings.json +++ b/frontend/i18n/messages/zh-CN/settings.json @@ -541,6 +541,7 @@ "model": "模型", "baseBilling": "基础计费", "serviceBilling": "服务计费", + "balanceAfter": "结算后余额", "latency": "耗时" }, "empty": "暂无调用日志" diff --git a/packages/api-contract/src/types.generated.ts b/packages/api-contract/src/types.generated.ts index 2523395a..37aa3d7a 100644 --- a/packages/api-contract/src/types.generated.ts +++ b/packages/api-contract/src/types.generated.ts @@ -3224,6 +3224,8 @@ export interface UsageLedgerListResponseDoc { export interface UsageLedgerResponse { cacheWrite1hTokens: number; cacheWrite5mTokens: number; + balanceAfterNanousd: number | null; + balanceAfterUSD: number | null; billedCurrency: string; billedNanousd: number; billedUSD: number; @@ -3265,6 +3267,8 @@ export interface UsageLogListResponseDoc { export interface UsageLogResponse { cacheWrite1hTokens: number; cacheWrite5mTokens: number; + balanceAfterNanousd: number | null; + balanceAfterUSD: number | null; billedCurrency: string; billedNanousd: number; billedUSD: number;