diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 729f4df41..2e0957dff 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -750,7 +750,7 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User, } defer releaseChannel() if c.Request.Method == http.MethodPost { - order, err := svc.ReserveProxyBilling(user.ID, channel.ID, strings.TrimPrefix(modelName, "models/"), capability, c.GetHeader("X-Canvas-Scene"), c.GetHeader("X-Idempotency-Key"), proxyRequestVideoSeconds(c.GetHeader("Content-Type"), body)) + order, err := svc.ReserveProxyBillingWithBody(user.ID, channel.ID, strings.TrimPrefix(modelName, "models/"), capability, c.GetHeader("X-Canvas-Scene"), c.GetHeader("X-Idempotency-Key"), proxyRequestVideoSeconds(c.GetHeader("Content-Type"), body), body) if err != nil { failService(c, err) return @@ -816,8 +816,11 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User, fail(c, http.StatusBadGateway, fmt.Errorf("系统渠道响应超过 %dMB 限制", policy.Request.SystemRelayResponseMB)) return } + logErr := logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, capability, protocol, c.Request.Method, path, target, body, c.GetHeader("Content-Type"), status, statusCode, time.Since(startedAt), errorText, concurrencyLimit), responseBody) if status == model.ApiCallStatusSucceeded { - if err := svc.SettleBilling(billingOrderID, ""); err != nil { + if logErr != nil { + _ = svc.MarkBillingUncertain(billingOrderID, "上游成功但调用日志写入失败,费用状态待核对") + } else if err := svc.SettleBilling(billingOrderID, ""); err != nil { _ = svc.MarkBillingUncertain(billingOrderID, "上游成功但积分结算失败:"+err.Error()) } } else if statusCode == 524 { @@ -825,7 +828,6 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User, } else { _ = svc.RefundBilling(billingOrderID, "上游明确返回失败") } - logSystemProxyCall(svc, apiCallLog(user, channel, billingOrderID, capability, protocol, c.Request.Method, path, target, body, c.GetHeader("Content-Type"), status, statusCode, time.Since(startedAt), errorText, concurrencyLimit), responseBody) for _, key := range []string{"Content-Type", "Cache-Control", "Content-Disposition"} { if value := resp.Header.Get(key); value != "" { c.Header(key, value) @@ -870,10 +872,10 @@ func apiCallLog(user *model.User, channel *model.ModelChannel, billingOrderID st } } -func logSystemProxyCall(svc *service.Service, log model.ApiCallLog, responseBody []byte) { +func logSystemProxyCall(svc *service.Service, log model.ApiCallLog, responseBody []byte) error { log.ResponseBody = service.SanitizeAPICallPayload(responseBody, "") svc.EnrichAPICallLog(&log, responseBody) - _ = svc.LogAPICall(log) + return svc.LogAPICall(log) } func readPayloadModel(body []byte) string { diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index ff3e57b3f..4abce0eb3 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -217,20 +217,23 @@ type ModelChannel struct { } type ChannelModel struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ChannelID string `json:"channelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_key_active,priority:1,where:deleted_at IS NULL"` - ModelKey string `json:"modelKey" gorm:"size:120;uniqueIndex:idx_channel_model_key_active,priority:2,where:deleted_at IS NULL"` - DisplayName string `json:"displayName" gorm:"size:160"` - Capability string `json:"capability" gorm:"size:32;index"` - Protocol ChannelInterfaceType `json:"protocol" gorm:"size:32;index"` - BillingMode string `json:"billingMode" gorm:"size:32"` - UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` - PriceConfigured bool `json:"priceConfigured" gorm:"index"` - Enabled bool `json:"enabled" gorm:"index"` - PriceVersion int64 `json:"priceVersion"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + ID string `json:"id" gorm:"primaryKey;size:36"` + ChannelID string `json:"channelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_key_active,priority:1,where:deleted_at IS NULL"` + ModelKey string `json:"modelKey" gorm:"size:120;uniqueIndex:idx_channel_model_key_active,priority:2,where:deleted_at IS NULL"` + DisplayName string `json:"displayName" gorm:"size:160"` + Capability string `json:"capability" gorm:"size:32;index"` + Protocol ChannelInterfaceType `json:"protocol" gorm:"size:32;index"` + BillingMode string `json:"billingMode" gorm:"size:32"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` + PriceConfigured bool `json:"priceConfigured" gorm:"index"` + Enabled bool `json:"enabled" gorm:"index"` + PriceVersion int64 `json:"priceVersion"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` } type ApiCallLog struct { @@ -310,31 +313,41 @@ type CreditLedgerEntry struct { } type BillingOrder struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_billing_user_idempotency,priority:1"` - IdempotencyKey string `json:"idempotencyKey" gorm:"size:160;uniqueIndex:idx_billing_user_idempotency,priority:2"` - TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` - ChannelID string `json:"channelId" gorm:"index;size:36"` - ChannelModelID string `json:"channelModelId" gorm:"index;size:36"` - Model string `json:"model" gorm:"index;size:120"` - Capability string `json:"capability" gorm:"index;size:32"` - Scene string `json:"scene" gorm:"index;size:80"` - BillingMode string `json:"billingMode" gorm:"size:32"` - PriceVersion int64 `json:"priceVersion"` - UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` - MultiplierBasisPoints int64 `json:"multiplierBasisPoints"` - Quantity int64 `json:"quantity"` - AmountMicrocredits int64 `json:"amountMicrocredits"` - Status BillingStatus `json:"status" gorm:"index;size:24"` - ProviderRequestID string `json:"providerRequestId,omitempty" gorm:"index;size:160"` - Error string `json:"error,omitempty" gorm:"size:1000"` - ResolvedBy string `json:"resolvedBy,omitempty" gorm:"index;size:36"` - ResolutionNote string `json:"resolutionNote,omitempty" gorm:"size:500"` - StartedAt *time.Time `json:"startedAt"` - SettledAt *time.Time `json:"settledAt"` - RefundedAt *time.Time `json:"refundedAt"` - CreatedAt time.Time `json:"createdAt" gorm:"index"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_billing_user_idempotency,priority:1"` + IdempotencyKey string `json:"idempotencyKey" gorm:"size:160;uniqueIndex:idx_billing_user_idempotency,priority:2"` + TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` + ChannelID string `json:"channelId" gorm:"index;size:36"` + ChannelModelID string `json:"channelModelId" gorm:"index;size:36"` + Model string `json:"model" gorm:"index;size:120"` + Capability string `json:"capability" gorm:"index;size:32"` + Scene string `json:"scene" gorm:"index;size:80"` + BillingMode string `json:"billingMode" gorm:"size:32"` + PriceVersion int64 `json:"priceVersion"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + MultiplierBasisPoints int64 `json:"multiplierBasisPoints"` + Quantity int64 `json:"quantity"` + AmountMicrocredits int64 `json:"amountMicrocredits"` + ReservedAmountMicrocredits int64 `json:"reservedAmountMicrocredits"` + ActualAmountMicrocredits int64 `json:"actualAmountMicrocredits"` + RefundedAmountMicrocredits int64 `json:"refundedAmountMicrocredits"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + CachedTokens int64 `json:"cachedTokens"` + UsageAvailable bool `json:"usageAvailable"` + Status BillingStatus `json:"status" gorm:"index;size:24"` + ProviderRequestID string `json:"providerRequestId,omitempty" gorm:"index;size:160"` + Error string `json:"error,omitempty" gorm:"size:1000"` + ResolvedBy string `json:"resolvedBy,omitempty" gorm:"index;size:36"` + ResolutionNote string `json:"resolutionNote,omitempty" gorm:"size:500"` + StartedAt *time.Time `json:"startedAt"` + SettledAt *time.Time `json:"settledAt"` + RefundedAt *time.Time `json:"refundedAt"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` + UpdatedAt time.Time `json:"updatedAt"` } type RedeemBatch struct { diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 1d039e88c..cccaa0dc1 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -14,11 +14,13 @@ import ( ) var ( - ErrInsufficientCredits = errors.New("insufficient credits") - ErrRedeemCodeInvalid = errors.New("redeem code invalid") - ErrActiveTaskLimit = errors.New("active task limit reached") - ErrTaskNotRetryable = errors.New("task is not retryable") - ErrBillingStateConflict = errors.New("billing state conflict") + ErrInsufficientCredits = errors.New("insufficient credits") + ErrRedeemCodeInvalid = errors.New("redeem code invalid") + ErrActiveTaskLimit = errors.New("active task limit reached") + ErrTaskNotRetryable = errors.New("task is not retryable") + ErrBillingStateConflict = errors.New("billing state conflict") + ErrBillingUsageUnavailable = errors.New("billing usage unavailable") + ErrBillingUnderreserved = errors.New("billing amount exceeds reservation") ) // 先抢占唯一业务键再更新账户,确保注册和签到奖励在多实例并发下只入账一次。 @@ -265,6 +267,9 @@ func (r *Repository) ReserveBillingOrder(order *model.BillingOrder) error { } func reserveBillingOrder(tx *gorm.DB, order *model.BillingOrder) error { + if order.ReservedAmountMicrocredits <= 0 { + order.ReservedAmountMicrocredits = order.AmountMicrocredits + } account := model.CreditAccount{UserID: order.UserID} if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&account).Error; err != nil { return err @@ -366,6 +371,29 @@ func (r *Repository) TaskHasSuccessfulBillableCall(taskID string) (bool, error) return count > 0, err } +type BillingUsage struct { + InputTokens int64 + OutputTokens int64 + CachedTokens int64 +} + +func (r *Repository) BillingUsage(orderID string) (*BillingUsage, error) { + return billingUsage(r.db, orderID) +} + +func billingUsage(db *gorm.DB, orderID string) (*BillingUsage, error) { + var log model.ApiCallLog + err := db.Where("billing_order_id = ? AND billable = ? AND status = ? AND usage_available = ?", orderID, true, model.ApiCallStatusSucceeded, true). + Order("created_at desc").First(&log).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrBillingUsageUnavailable + } + if err != nil { + return nil, err + } + return &BillingUsage{InputTokens: log.InputTokens, OutputTokens: log.OutputTokens, CachedTokens: log.CachedTokens}, nil +} + func (r *Repository) RecordBillingResolution(id string, actorUserID string, note string) error { return r.db.Model(&model.BillingOrder{}).Where("id = ?", id).Updates(map[string]any{ "resolved_by": actorUserID, "resolution_note": note, "updated_at": time.Now(), @@ -423,6 +451,67 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err if order.Status == model.BillingStatusRefunded { return errors.New("billing order already refunded") } + if order.BillingMode == "token" { + usage, err := billingUsage(tx, id) + if err != nil { + return err + } + reserved := order.ReservedAmountMicrocredits + if reserved <= 0 { + reserved = order.AmountMicrocredits + } + actual, err := tokenUsageAmount(order, usage) + if err != nil { + return err + } + if actual > reserved { + return ErrBillingUnderreserved + } + refund := reserved - actual + updated := tx.Model(&model.CreditAccount{}). + Where("user_id = ? AND reserved_microcredits >= ?", order.UserID, reserved). + Updates(map[string]any{ + "available_microcredits": gorm.Expr("available_microcredits + ?", refund), + "reserved_microcredits": gorm.Expr("reserved_microcredits - ?", reserved), + "version": gorm.Expr("version + 1"), "updated_at": time.Now(), + }) + if updated.Error != nil { + return updated.Error + } + if updated.RowsAffected != 1 { + return errors.New("reserved credit balance is inconsistent") + } + var account model.CreditAccount + if err := tx.First(&account, "user_id = ?", order.UserID).Error; err != nil { + return err + } + now := time.Now() + updates := map[string]any{"status": model.BillingStatusSettled, "settled_at": &now, "updated_at": now, + "actual_amount_microcredits": actual, "refunded_amount_microcredits": refund, + "input_tokens": usage.InputTokens, "output_tokens": usage.OutputTokens, "cached_tokens": usage.CachedTokens, + "usage_available": true} + if providerRequestID != "" { + updates["provider_request_id"] = providerRequestID + } + if err := tx.Model(&order).Updates(updates).Error; err != nil { + return err + } + if err := tx.Create(&model.CreditLedgerEntry{ID: newRepositoryID(), UserID: order.UserID, Type: model.CreditLedgerConsume, + AmountMicrocredits: -actual, ReservedDeltaMicrocredits: -reserved, + AvailableAfterMicrocredits: account.AvailableMicrocredits, ReservedAfterMicrocredits: account.ReservedMicrocredits, + BillingOrderID: order.ID, Model: order.Model, ChannelID: order.ChannelID, Scene: order.Scene}).Error; err != nil { + return err + } + if refund > 0 { + if err := tx.Create(&model.CreditLedgerEntry{ID: newRepositoryID(), UserID: order.UserID, Type: model.CreditLedgerRefund, + AmountMicrocredits: refund, AvailableDeltaMicrocredits: refund, + AvailableAfterMicrocredits: account.AvailableMicrocredits, ReservedAfterMicrocredits: account.ReservedMicrocredits, + BillingOrderID: order.ID, Model: order.Model, ChannelID: order.ChannelID, Scene: order.Scene, Note: "Token 预授权差额退回"}).Error; err != nil { + return err + } + } + return nil + } updated := tx.Model(&model.CreditAccount{}). Where("user_id = ? AND reserved_microcredits >= ?", order.UserID, order.AmountMicrocredits). Updates(map[string]any{ @@ -441,7 +530,7 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err return err } now := time.Now() - orderUpdates := map[string]any{"status": model.BillingStatusSettled, "settled_at": &now, "updated_at": now} + orderUpdates := map[string]any{"status": model.BillingStatusSettled, "actual_amount_microcredits": order.AmountMicrocredits, "settled_at": &now, "updated_at": now} if providerRequestID != "" { orderUpdates["provider_request_id"] = providerRequestID } @@ -495,7 +584,8 @@ func (r *Repository) RefundBillingOrder(id string, errorText string) error { return err } now := time.Now() - if err := tx.Model(&order).Updates(map[string]any{"status": model.BillingStatusRefunded, "error": errorText, "refunded_at": &now, "updated_at": now}).Error; err != nil { + updates := map[string]any{"status": model.BillingStatusRefunded, "error": errorText, "refunded_amount_microcredits": order.AmountMicrocredits, "refunded_at": &now, "updated_at": now} + if err := tx.Model(&order).Updates(updates).Error; err != nil { return err } return tx.Create(&model.CreditLedgerEntry{ @@ -516,6 +606,41 @@ func (r *Repository) RefundBillingOrder(id string, errorText string) error { }) } +func tokenUsageAmount(order model.BillingOrder, usage *BillingUsage) (int64, error) { + if usage == nil { + return 0, ErrBillingUsageUnavailable + } + input := usage.InputTokens - usage.CachedTokens + if input < 0 { + input = 0 + } + inputAmount, ok := safeTokenUsageProduct(input, order.InputTokenPriceMicrocredits) + if !ok { + return 0, errors.New("invalid token usage amount") + } + outputAmount, ok := safeTokenUsageProduct(usage.OutputTokens, order.OutputTokenPriceMicrocredits) + if !ok || inputAmount > 1<<63-1-outputAmount { + return 0, errors.New("invalid token usage amount") + } + cachedAmount, ok := safeTokenUsageProduct(usage.CachedTokens, order.CachedTokenPriceMicrocredits) + base := inputAmount + outputAmount + if !ok || base > 1<<63-1-cachedAmount { + return 0, errors.New("invalid token usage amount") + } + base += cachedAmount + if order.MultiplierBasisPoints <= 0 || base > (1<<63-1-9_999_999_999)/order.MultiplierBasisPoints { + return 0, errors.New("invalid token usage amount") + } + return (base*order.MultiplierBasisPoints + 9_999_999_999) / 10_000_000_000, nil +} + +func safeTokenUsageProduct(tokens int64, price int64) (int64, bool) { + if tokens < 0 || price < 0 || (tokens > 0 && price > (1<<63-1)/tokens) { + return 0, false + } + return tokens * price, true +} + func (r *Repository) AdjustCredits(userID string, actorUserID string, amount int64, note string) (*model.CreditAccount, error) { var account model.CreditAccount err := r.db.Transaction(func(tx *gorm.DB) error { diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index 1ba1a98e8..a1b87d355 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -114,12 +114,15 @@ type PublicModelChannel struct { } type PublicChannelModelPrice struct { - Model string `json:"model"` - DisplayName string `json:"displayName"` - Capability string `json:"capability"` - Protocol model.ChannelInterfaceType `json:"protocol"` - BillingMode string `json:"billingMode"` - UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + Model string `json:"model"` + DisplayName string `json:"displayName"` + Capability string `json:"capability"` + Protocol model.ChannelInterfaceType `json:"protocol"` + BillingMode string `json:"billingMode"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` } func (s *Service) RequireAdmin(user *model.User) error { @@ -260,7 +263,7 @@ func (s *Service) CreateAdminUser(actor *model.User, req CreateAdminUserRequest) return nil, err } return &AdminUser{ - User: *user, + User: *user, AvailableMicrocredits: account.AvailableMicrocredits, ReservedMicrocredits: account.ReservedMicrocredits, }, nil @@ -746,7 +749,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model } models = append(models, item.ModelKey) if item.Enabled && item.PriceConfigured { - modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits}) + modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits}) } } if len(models) == 0 { diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index 0724e543f..7d0269ca8 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -14,14 +14,17 @@ import ( ) type ChannelModelRequest struct { - ModelKey string `json:"modelKey"` - DisplayName string `json:"displayName"` - Capability string `json:"capability"` - Protocol string `json:"protocol"` - BillingMode string `json:"billingMode"` - UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` - PriceConfigured bool `json:"priceConfigured"` - Enabled *bool `json:"enabled"` + ModelKey string `json:"modelKey"` + DisplayName string `json:"displayName"` + Capability string `json:"capability"` + Protocol string `json:"protocol"` + BillingMode string `json:"billingMode"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` + PriceConfigured bool `json:"priceConfigured"` + Enabled *bool `json:"enabled"` } // AdminChannelModelFetchResult 是管理员从上游拉目录后的汇总:models 为去重后的标识,added 为本次新建条数。 @@ -124,15 +127,25 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id if billingMode == "" { billingMode = "fixed_request" } - if billingMode != "fixed_request" && billingMode != "per_second" { - return nil, BadAuthRequest("模型计费方式仅支持按次或按秒") + if billingMode != "fixed_request" && billingMode != "per_second" && billingMode != "token" { + return nil, BadAuthRequest("模型计费方式仅支持按次、按秒或 Token") } if billingMode == "per_second" && capability != "video" { return nil, BadAuthRequest("只有视频模型可以按秒计费") } - if req.UnitPriceMicrocredits < 0 { + if billingMode == "token" && capability != "text" { + return nil, BadAuthRequest("只有文本模型可以按 Token 计费") + } + if req.UnitPriceMicrocredits < 0 || req.InputTokenPriceMicrocredits < 0 || req.OutputTokenPriceMicrocredits < 0 || req.CachedTokenPriceMicrocredits < 0 { return nil, BadAuthRequest("模型积分价格不能小于 0") } + if billingMode == "token" && req.InputTokenPriceMicrocredits == 0 && req.OutputTokenPriceMicrocredits == 0 && req.CachedTokenPriceMicrocredits == 0 { + return nil, BadAuthRequest("Token 计费至少需要配置一项价格") + } + const maxTokenPriceMicrocredits = int64(1_000_000) * CreditScale + if req.InputTokenPriceMicrocredits > maxTokenPriceMicrocredits || req.OutputTokenPriceMicrocredits > maxTokenPriceMicrocredits || req.CachedTokenPriceMicrocredits > maxTokenPriceMicrocredits { + return nil, BadAuthRequest("Token 每百万用量价格不能超过 1,000,000 积分") + } item := &model.ChannelModel{ID: newID(), ChannelID: channelID, Enabled: true, PriceVersion: 1} if id != "" { item, err = s.repo.ChannelModelByID(channelID, id) @@ -157,6 +170,9 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id item.Protocol = protocol item.BillingMode = billingMode item.UnitPriceMicrocredits = req.UnitPriceMicrocredits + item.InputTokenPriceMicrocredits = req.InputTokenPriceMicrocredits + item.OutputTokenPriceMicrocredits = req.OutputTokenPriceMicrocredits + item.CachedTokenPriceMicrocredits = req.CachedTokenPriceMicrocredits item.PriceConfigured = req.PriceConfigured if req.Enabled != nil { item.Enabled = *req.Enabled diff --git a/backend/internal/service/finance.go b/backend/internal/service/finance.go index ea7ab7f40..53ecf8d6b 100644 --- a/backend/internal/service/finance.go +++ b/backend/internal/service/finance.go @@ -99,10 +99,15 @@ type ResolveBillingBatchFailure struct { } type ResolveBillingBatchResult struct { - ResolvedCount int `json:"resolvedCount"` + ResolvedCount int `json:"resolvedCount"` Failed []ResolveBillingBatchFailure `json:"failed"` } +type tokenBillingEstimate struct { + InputTokens int64 + OutputTokens int64 +} + func (s *Service) Wallet(user *model.User, entryType string, page int, limit int) (*WalletSummary, error) { if user == nil { return nil, Unauthorized("请先登录") @@ -444,10 +449,14 @@ func (s *Service) taskBillingOrder(userID string, task *model.Task, input map[st capability = capabilityFromTaskType(task.Type) } scene := firstNonEmpty(strings.TrimSpace(task.Operation), task.Type) - return s.newBillingOrder(userID, task.ID, "task:"+task.ID+":"+newID(), channelID, modelKey, capability, scene, billingQuantity(capability, config["videoSeconds"])) + return s.newBillingOrder(userID, task.ID, "task:"+task.ID+":"+newID(), channelID, modelKey, capability, scene, billingQuantity(capability, config["videoSeconds"]), estimateTaskTokens(input)) } func (s *Service) ReserveProxyBilling(userID string, channelID string, modelKey string, capability string, scene string, idempotencyKey string, quantity int64) (*model.BillingOrder, error) { + return s.ReserveProxyBillingWithBody(userID, channelID, modelKey, capability, scene, idempotencyKey, quantity, nil) +} + +func (s *Service) ReserveProxyBillingWithBody(userID string, channelID string, modelKey string, capability string, scene string, idempotencyKey string, quantity int64, requestBody []byte) (*model.BillingOrder, error) { enabled, err := s.FeatureEnabled(FeatureCredits) if err != nil { return nil, err @@ -458,7 +467,7 @@ func (s *Service) ReserveProxyBilling(userID string, channelID string, modelKey if strings.TrimSpace(idempotencyKey) == "" { idempotencyKey = newID() } - order, err := s.newBillingOrder(userID, "", "proxy:"+idempotencyKey, channelID, modelKey, capability, firstNonEmpty(strings.TrimSpace(scene), "system_proxy"), quantity) + order, err := s.newBillingOrder(userID, "", "proxy:"+idempotencyKey, channelID, modelKey, capability, firstNonEmpty(strings.TrimSpace(scene), "system_proxy"), quantity, estimateProxyTokens(requestBody)) if err != nil { return nil, err } @@ -471,7 +480,7 @@ func (s *Service) ReserveProxyBilling(userID string, channelID string, modelKey return order, nil } -func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey string, channelID string, modelKey string, capability string, scene string, requestedQuantity int64) (*model.BillingOrder, error) { +func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey string, channelID string, modelKey string, capability string, scene string, requestedQuantity int64, tokenEstimate tokenBillingEstimate) (*model.BillingOrder, error) { item, err := s.repo.ChannelModelByKey(channelID, modelKey) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, BadAuthRequest("当前系统渠道模型未配置或已停用") @@ -486,6 +495,7 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s return nil, BadAuthRequest("即梦视频仅支持 5 秒或 10 秒,请调整视频时长") } quantity := int64(1) + amount := int64(0) switch item.BillingMode { case "fixed_request": case "per_second": @@ -496,6 +506,14 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s return nil, BadAuthRequest("视频生成时长无效,无法按秒计费") } quantity = requestedQuantity + case "token": + if item.Capability != "text" || capability != "text" { + return nil, BadAuthRequest("Token 计费仅适用于文本生成") + } + if tokenEstimate.InputTokens <= 0 || tokenEstimate.OutputTokens <= 0 { + return nil, BadAuthRequest("无法估算文本 Token 用量") + } + quantity = tokenEstimate.InputTokens + tokenEstimate.OutputTokens default: return nil, BadAuthRequest("当前模型计费方式暂不支持") } @@ -507,7 +525,11 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s if configured := policy.ModelMultiplierBPS[modelKey]; configured > 0 { multiplierBPS = configured } - amount, err := creditAmount(item.UnitPriceMicrocredits, quantity, multiplierBPS) + if item.BillingMode == "token" { + amount, err = tokenEstimateAmount(item, tokenEstimate, multiplierBPS) + } else { + amount, err = creditAmount(item.UnitPriceMicrocredits, quantity, multiplierBPS) + } if err != nil { return nil, err } @@ -516,10 +538,78 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s ChannelID: channelID, ChannelModelID: item.ID, Model: modelKey, Capability: capability, Scene: truncateRunes(scene, 80), BillingMode: item.BillingMode, PriceVersion: item.PriceVersion, UnitPriceMicrocredits: item.UnitPriceMicrocredits, MultiplierBasisPoints: multiplierBPS, Quantity: quantity, AmountMicrocredits: amount, + ReservedAmountMicrocredits: amount, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, + OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, Status: model.BillingStatusReserved, }, nil } +func estimateTaskTokens(input map[string]any) tokenBillingEstimate { + encoded, _ := json.Marshal(input) + return tokenBillingEstimate{InputTokens: estimatedTokens(encoded), OutputTokens: maxOutputTokens(input)} +} + +func estimateProxyTokens(body []byte) tokenBillingEstimate { + var payload map[string]any + _ = json.Unmarshal(body, &payload) + return tokenBillingEstimate{InputTokens: estimatedTokens(body), OutputTokens: maxOutputTokens(payload)} +} + +func estimatedTokens(value []byte) int64 { + count := int64((len([]rune(string(value))) + 3) / 4) + if count < 1 { + return 1 + } + return count +} + +func maxOutputTokens(payload map[string]any) int64 { + for _, key := range []string{"max_output_tokens", "max_tokens", "maxOutputTokens"} { + value, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(payload[key])), 10, 64) + if err == nil && value > 0 { + if value > 131072 { + return 131072 + } + return value + } + } + if config, ok := payload["config"].(map[string]any); ok { + return maxOutputTokens(config) + } + return 4096 +} + +// Token 单价按每百万 Token 配置;预授权使用输入价估算缓存 Token,真实结算再按 usage 拆分。 +func tokenEstimateAmount(item *model.ChannelModel, estimate tokenBillingEstimate, multiplierBPS int64) (int64, error) { + if item == nil || estimate.InputTokens <= 0 || estimate.OutputTokens <= 0 || multiplierBPS <= 0 { + return 0, errors.New("Token 计费参数无效") + } + inputAmount, ok := safeTokenProduct(estimate.InputTokens, item.InputTokenPriceMicrocredits) + if !ok { + return 0, errors.New("Token 计费金额溢出") + } + outputAmount, ok := safeTokenProduct(estimate.OutputTokens, item.OutputTokenPriceMicrocredits) + if !ok || inputAmount > 1<<63-1-outputAmount { + return 0, errors.New("Token 计费金额溢出") + } + base := inputAmount + outputAmount + if base > (1<<63-1-9_999_999_999)/multiplierBPS { + return 0, errors.New("Token 计费金额溢出") + } + amount := (base*multiplierBPS + 9_999_999_999) / 10_000_000_000 + if amount <= 0 { + return 0, errors.New("Token 计费金额必须大于 0") + } + return amount, nil +} + +func safeTokenProduct(tokens int64, price int64) (int64, bool) { + if tokens < 0 || price < 0 || (tokens > 0 && price > (1<<63-1)/tokens) { + return 0, false + } + return tokens * price, true +} + func billingQuantity(capability string, value any) int64 { if capability != "video" { return 1 diff --git a/web/src/constant/credits.tsx b/web/src/constant/credits.tsx index 462cea338..f3ff70f27 100644 --- a/web/src/constant/credits.tsx +++ b/web/src/constant/credits.tsx @@ -11,7 +11,7 @@ export function CreditSymbol({ className, ...props }: ComponentProps<"span">) { export type ModelCreditCost = { model: string; - billingMode: "fixed_request" | "per_second"; + billingMode: "fixed_request" | "per_second" | "token"; unitPriceMicrocredits: number; }; @@ -27,6 +27,8 @@ export function requestCreditCost(options: { channelMode: string; modelCosts?: M if (options.channelMode !== "remote") return null; const cost = modelCreditCost(options.modelCosts, options.model); if (!cost) return null; + // Token 订单由服务端按请求体预授权并在 usage 返回后结算,前端不展示无依据的固定价格。 + if (cost.billingMode === "token") return null; const quantity = cost.billingMode === "per_second" ? Math.max(1, Math.floor(Math.abs(Number(options.seconds)) || 1)) : Math.max(1, Math.floor(Math.abs(Number(options.count)) || 1)); diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index c8bb75e60..3aa808933 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -20,6 +20,9 @@ type FormValues = { protocol: ModelProtocol; billingMode: ChannelModel["billingMode"]; unitPrice: number; + inputTokenPrice: number; + outputTokenPrice: number; + cachedTokenPrice: number; enabled: boolean; }; @@ -83,13 +86,13 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: const startCreate = () => { setEditing(null); - form.setFieldsValue({ modelKey: "", displayName: "", capability: "text", protocol: "chat-completion", billingMode: "fixed_request", unitPrice: 0, enabled: true }); + form.setFieldsValue({ modelKey: "", displayName: "", capability: "text", protocol: "chat-completion", billingMode: "fixed_request", unitPrice: 0, inputTokenPrice: 0, outputTokenPrice: 0, cachedTokenPrice: 0, enabled: true }); setEditorOpen(true); }; const startEdit = (item: ChannelModel) => { setEditing(item); - form.setFieldsValue({ modelKey: item.modelKey, displayName: item.displayName, capability: item.capability || undefined, protocol: item.protocol, billingMode: item.billingMode, unitPrice: item.unitPriceMicrocredits / 1_000_000, enabled: item.enabled }); + form.setFieldsValue({ modelKey: item.modelKey, displayName: item.displayName, capability: item.capability || undefined, protocol: item.protocol, billingMode: item.billingMode, unitPrice: item.unitPriceMicrocredits / 1_000_000, inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000, outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000, enabled: item.enabled }); setEditorOpen(true); }; @@ -104,6 +107,9 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: protocol: values.protocol, billingMode: values.billingMode, unitPriceMicrocredits: Math.round(values.unitPrice * 1_000_000), + inputTokenPriceMicrocredits: Math.round((values.inputTokenPrice || 0) * 1_000_000), + outputTokenPriceMicrocredits: Math.round((values.outputTokenPrice || 0) * 1_000_000), + cachedTokenPriceMicrocredits: Math.round((values.cachedTokenPrice || 0) * 1_000_000), priceConfigured: true, enabled: values.enabled !== false, }; @@ -151,7 +157,10 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: const handleFormValuesChange = (changed: Partial) => { if (!changed.capability) return; - if (changed.capability !== "video") form.setFieldValue("billingMode", "fixed_request"); + const currentBillingMode = form.getFieldValue("billingMode") as ChannelModel["billingMode"] | undefined; + if ((currentBillingMode === "per_second" && changed.capability !== "video") || (currentBillingMode === "token" && changed.capability !== "text")) { + form.setFieldValue("billingMode", "fixed_request"); + } const current = form.getFieldValue("protocol") as ModelProtocol | undefined; if (modelProtocolCapability(current) !== changed.capability) { form.setFieldValue("protocol", MODEL_PROTOCOLS.find((item) => item.capability === changed.capability)?.value); @@ -173,7 +182,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: }, { title: "能力", dataIndex: "capability", width: 90, render: capabilityLabel }, { title: "请求协议", dataIndex: "protocol", width: 230, render: (value: ModelProtocol) => value ?
{modelProtocolLabel(value)}
{modelProtocolDefinition(value)?.create}
: 待配置 }, - { title: "计费", width: 165, render: (_, item) => (item.priceConfigured ? `${formatCredits(item.unitPriceMicrocredits)} 积分 / ${item.billingMode === "per_second" ? "秒" : "次"}` : 未配置价格) }, + { title: "计费", width: 220, render: (_, item) => (item.priceConfigured ? billingSummary(item) : 未配置价格) }, { title: "版本", dataIndex: "priceVersion", width: 75, render: (value) => `v${value}` }, { title: "状态", dataIndex: "enabled", width: 85, render: (enabled) => (enabled ? 启用 : 停用) }, { @@ -238,11 +247,25 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: - - - - + + {billingMode === "token" ? ( +
+ + + + + + + + + +
+ ) : ( + + + + )} @@ -375,6 +398,19 @@ function capabilityLabel(value: ChannelModel["capability"]) { return { text: "文本", image: "图片", video: "视频", audio: "音频", "": "待配置" }[value]; } +function billingSummary(item: ChannelModel) { + if (item.billingMode !== "token") { + return `${formatCredits(item.unitPriceMicrocredits)} 积分 / ${item.billingMode === "per_second" ? "秒" : "次"}`; + } + return ( +
+
输入 {formatCredits(item.inputTokenPriceMicrocredits)} / 百万
+
输出 {formatCredits(item.outputTokenPriceMicrocredits)} / 百万
+
缓存 {formatCredits(item.cachedTokenPriceMicrocredits)} / 百万
+
+ ); +} + function formatCredits(value: number) { return (value / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 }); } diff --git a/web/src/pages/admin/components/credit-operations-panel.tsx b/web/src/pages/admin/components/credit-operations-panel.tsx index 261d899e5..6faa75bbf 100644 --- a/web/src/pages/admin/components/credit-operations-panel.tsx +++ b/web/src/pages/admin/components/credit-operations-panel.tsx @@ -195,7 +195,17 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference ), }, - { title: "冻结积分", dataIndex: "amountMicrocredits", width: 120, align: "right", render: (value) => {formatCredits(value)} }, + { title: "预授权积分", dataIndex: "amountMicrocredits", width: 120, align: "right", render: (value) => {formatCredits(value)} }, + { + title: "实际结算 / 用量", + width: 190, + render: (_, order) => order.billingMode === "token" ? ( +
+
{order.status === "settled" ? `${formatCredits(order.actualAmountMicrocredits)} 积分` : "待 usage 结算"}
+
输入 {order.inputTokens} · 输出 {order.outputTokens} · 缓存 {order.cachedTokens}
+
+ ) : {order.status === "settled" ? formatCredits(order.actualAmountMicrocredits || order.amountMicrocredits) : "--"}, + }, { title: "状态", dataIndex: "status", @@ -414,7 +424,7 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference setPageSize(nextPageSize); }, }} - scroll={{ x: 1200 }} + scroll={{ x: 1390 }} /> diff --git a/web/src/services/api/wallet.ts b/web/src/services/api/wallet.ts index ff0ba6183..d598bbb19 100644 --- a/web/src/services/api/wallet.ts +++ b/web/src/services/api/wallet.ts @@ -66,8 +66,11 @@ export type ChannelModel = { displayName: string; capability: "text" | "image" | "video" | "audio" | ""; protocol?: import("@/lib/model-protocols").ModelProtocol; - billingMode: "fixed_request" | "per_second"; + billingMode: "fixed_request" | "per_second" | "token"; unitPriceMicrocredits: number; + inputTokenPriceMicrocredits: number; + outputTokenPriceMicrocredits: number; + cachedTokenPriceMicrocredits: number; priceConfigured: boolean; enabled: boolean; priceVersion: number; @@ -161,11 +164,21 @@ export type BillingOrder = { model: string; capability: string; scene: string; - billingMode: "fixed_request" | "per_second"; + billingMode: "fixed_request" | "per_second" | "token"; unitPriceMicrocredits: number; multiplierBasisPoints: number; quantity: number; amountMicrocredits: number; + reservedAmountMicrocredits: number; + actualAmountMicrocredits: number; + refundedAmountMicrocredits: number; + inputTokenPriceMicrocredits: number; + outputTokenPriceMicrocredits: number; + cachedTokenPriceMicrocredits: number; + inputTokens: number; + outputTokens: number; + cachedTokens: number; + usageAvailable: boolean; status: "reserved" | "running" | "settled" | "refunded" | "uncertain"; providerRequestId?: string; error?: string; diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 20ae0b589..b9f5abdfb 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -31,8 +31,11 @@ export type ModelChannel = { displayName?: string; capability: ModelCapability; protocol?: ModelProtocol; - billingMode: "fixed_request" | "per_second"; + billingMode: "fixed_request" | "per_second" | "token"; unitPriceMicrocredits: number; + inputTokenPriceMicrocredits?: number; + outputTokenPriceMicrocredits?: number; + cachedTokenPriceMicrocredits?: number; }>; };