fix(api): Token 账单 - 按真实用量结算预授权差额 (#145)

Closes #145
This commit is contained in:
ddCat committed 2026-08-05 17:06:07 +08:00
1 parent 4721c1e97f
commit cd85a45936
11 files changed
+402 -89

No files matched your search

+7 -5
View File
@@ -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 {
+52 -39
View File
@@ -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 {
+132 -7
View File
@@ -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 {
+11 -8
View File
@@ -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 {
+27 -11
View File
@@ -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
+95 -5
View File
@@ -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