1 parent
4721c1e97f
commit
cd85a45936
11 files changed
+402
-89
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user