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

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

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 {

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 {

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 {

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 {

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

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

View File

@@ -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));

View File

@@ -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<FormValues>) => {
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 ? <div><div className="text-xs font-medium">{modelProtocolLabel(value)}</div><div className="truncate text-[var(--fs-tiny)] text-foreground/45">{modelProtocolDefinition(value)?.create}</div></div> : <Tag color="orange">待配置</Tag> },
{ title: "计费", width: 165, render: (_, item) => (item.priceConfigured ? `${formatCredits(item.unitPriceMicrocredits)} 积分 / ${item.billingMode === "per_second" ? "秒" : "次"}` : <Tag color="orange">未配置价格</Tag>) },
{ title: "计费", width: 220, render: (_, item) => (item.priceConfigured ? billingSummary(item) : <Tag color="orange">未配置价格</Tag>) },
{ title: "版本", dataIndex: "priceVersion", width: 75, render: (value) => `v${value}` },
{ title: "状态", dataIndex: "enabled", width: 85, render: (enabled) => (enabled ? <Tag color="green">启用</Tag> : <Tag>停用</Tag>) },
{
@@ -238,11 +247,25 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
<ProtocolCardPicker capability={modelCapability} />
</Form.Item>
<Form.Item name="billingMode" label="计费方式" rules={[{ required: true }]}>
<Segmented block options={[{ label: "按次计费", value: "fixed_request" }, { label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" }]} />
</Form.Item>
<Form.Item name="unitPrice" label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"} rules={[{ required: true, message: "请输入积分价格" }]}>
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
<Segmented block options={[{ label: "按次计费", value: "fixed_request" }, { label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" }, { label: "Token 计费", value: "token", disabled: modelCapability !== "text" }]} />
</Form.Item>
{billingMode === "token" ? (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<Form.Item name="inputTokenPrice" label="输入 / 百万 Token" rules={[{ required: true, message: "请输入输入价格" }]}>
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<Form.Item name="outputTokenPrice" label="输出 / 百万 Token" rules={[{ required: true, message: "请输入输出价格" }]}>
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<Form.Item name="cachedTokenPrice" label="缓存 / 百万 Token" rules={[{ required: true, message: "请输入缓存价格" }]}>
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
</div>
) : (
<Form.Item name="unitPrice" label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"} rules={[{ required: true, message: "请输入积分价格" }]}>
<InputNumber style={{ width: "100%" }} min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
)}
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
@@ -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 (
<div className="text-xs leading-5">
<div>输入 {formatCredits(item.inputTokenPriceMicrocredits)} / 百万</div>
<div>输出 {formatCredits(item.outputTokenPriceMicrocredits)} / 百万</div>
<div>缓存 {formatCredits(item.cachedTokenPriceMicrocredits)} / 百万</div>
</div>
);
}
function formatCredits(value: number) {
return (value / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 });
}

View File

@@ -195,7 +195,17 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference
</div>
),
},
{ title: "冻结积分", dataIndex: "amountMicrocredits", width: 120, align: "right", render: (value) => <span className="font-medium tabular-nums">{formatCredits(value)}</span> },
{ title: "预授权积分", dataIndex: "amountMicrocredits", width: 120, align: "right", render: (value) => <span className="font-medium tabular-nums">{formatCredits(value)}</span> },
{
title: "实际结算 / 用量",
width: 190,
render: (_, order) => order.billingMode === "token" ? (
<div className="text-xs leading-5">
<div className="font-medium tabular-nums">{order.status === "settled" ? `${formatCredits(order.actualAmountMicrocredits)} 积分` : "待 usage 结算"}</div>
<div className="text-foreground/50">输入 {order.inputTokens} · 输出 {order.outputTokens} · 缓存 {order.cachedTokens}</div>
</div>
) : <span className="tabular-nums">{order.status === "settled" ? formatCredits(order.actualAmountMicrocredits || order.amountMicrocredits) : "--"}</span>,
},
{
title: "状态",
dataIndex: "status",
@@ -414,7 +424,7 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference
setPageSize(nextPageSize);
},
}}
scroll={{ x: 1200 }}
scroll={{ x: 1390 }}
/>
</TableSurface>
</section>

View File

@@ -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;

View File

@@ -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;
}>;
};