Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions backend/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -21503,6 +21503,8 @@ const docTemplate = `{
"UsageLedgerResponse": {
"type": "object",
"required": [
"balanceAfterNanousd",
"balanceAfterUSD",
"billedCurrency",
"billedNanousd",
"billedUSD",
Expand Down Expand Up @@ -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"
},
Expand Down Expand Up @@ -21660,6 +21672,8 @@ const docTemplate = `{
"UsageLogResponse": {
"type": "object",
"required": [
"balanceAfterNanousd",
"balanceAfterUSD",
"billedCurrency",
"billedNanousd",
"billedUSD",
Expand Down Expand Up @@ -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"
},
Expand Down
24 changes: 24 additions & 0 deletions backend/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -21496,6 +21496,8 @@
"UsageLedgerResponse": {
"type": "object",
"required": [
"balanceAfterNanousd",
"balanceAfterUSD",
"billedCurrency",
"billedNanousd",
"billedUSD",
Expand Down Expand Up @@ -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"
},
Expand Down Expand Up @@ -21653,6 +21665,8 @@
"UsageLogResponse": {
"type": "object",
"required": [
"balanceAfterNanousd",
"balanceAfterUSD",
"billedCurrency",
"billedNanousd",
"billedUSD",
Expand Down Expand Up @@ -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"
},
Expand Down
20 changes: 20 additions & 0 deletions backend/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -7276,6 +7284,8 @@ definitions:
userID:
type: integer
required:
- balanceAfterNanousd
- balanceAfterUSD
- billedCurrency
- billedNanousd
- billedUSD
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -7394,6 +7412,8 @@ definitions:
username:
type: string
required:
- balanceAfterNanousd
- balanceAfterUSD
- billedCurrency
- billedNanousd
- billedUSD
Expand Down
1 change: 1 addition & 0 deletions backend/internal/domain/billing/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ type UsageLedger struct {
ServiceTier string
BilledCurrency string
BilledNanousd int64
BalanceAfterNanousd *int64
PricingSnapshotJSON string
CreatedAt time.Time
UpdatedAt time.Time
Expand Down
1 change: 1 addition & 0 deletions backend/internal/infra/persistence/models/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down
60 changes: 45 additions & 15 deletions backend/internal/infra/persistence/postgres/billing/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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).
Expand All @@ -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 {
Expand Down Expand Up @@ -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).
Expand All @@ -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"`
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading