Files
ttstd-infinite-canvas/backend/internal/app/finance.go
huiku 7466119867 feat(image): 以插件声明统一图片参数、尺寸选择与定价合同 (#760)
* feat(image): 图片参数 - 统一插件声明配置与生成合同

* docs(image): 图片参数 - 说明声明合同与验证边界

* fix(plugins): 图片声明 - 保留内联素材支持并同步制品文档

* fix(image): 参数合同 - 对齐审批意图和尺寸预检调用方

* fix(image): 调用链 - 保留公开意图并接通独立分辨率

* fix(image): 图片参数 - 补齐分辨率接线与目录价格投影
2026-10-13 19:18:21 +08:00

835 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"yingce/backend/internal/kernel"
"yingce/backend/internal/model"
"yingce/backend/internal/repository"
"gorm.io/gorm"
)
const CreditScale int64 = 1_000_000
type WalletSummary struct {
Account model.CreditAccount `json:"account"`
Entries []model.CreditLedgerEntry `json:"entries"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"pageSize"`
Policy PublicCreditPolicy `json:"policy"`
}
type RedeemBatchPage struct {
Batches []model.RedeemBatch `json:"batches"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"pageSize"`
}
type AdminRedeemCodeDetail struct {
ID string `json:"id"`
Code string `json:"code,omitempty"`
CodeSuffix string `json:"codeSuffix"`
Status string `json:"status"`
RedeemedBy string `json:"redeemedBy,omitempty"`
RedeemedUsername string `json:"redeemedUsername,omitempty"`
RedeemedDisplayName string `json:"redeemedDisplayName,omitempty"`
RedeemedAt *time.Time `json:"redeemedAt"`
RedeemedIP string `json:"redeemedIp,omitempty"`
ExpiresAt *time.Time `json:"expiresAt"`
AmountMicrocredits int64 `json:"amountMicrocredits"`
}
type AdminRedeemCodePage struct {
Batch model.RedeemBatch `json:"batch"`
Codes []AdminRedeemCodeDetail `json:"codes"`
PlaintextAvailable bool `json:"plaintextAvailable"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"pageSize"`
}
type BillingOrderPage struct {
Orders []model.BillingOrder `json:"orders"`
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"pageSize"`
}
type CreateRedeemBatchRequest struct {
AmountMicrocredits int64 `json:"amountMicrocredits"`
Count int `json:"count"`
Note string `json:"note"`
ExpiresAt *time.Time `json:"expiresAt"`
}
type CreateRedeemBatchResult struct {
Batch model.RedeemBatch `json:"batch"`
Codes []string `json:"codes"`
}
type AdminCreditAdjustmentRequest struct {
AmountMicrocredits int64 `json:"amountMicrocredits"`
Note string `json:"note"`
}
type ResolveBillingRequest struct {
Action string `json:"action"`
Note string `json:"note"`
}
type ResolveBillingBatchRequest struct {
IDs []string `json:"ids"`
Action string `json:"action"`
Note string `json:"note"`
}
type ResolveBillingBatchFailure struct {
ID string `json:"id"`
Message string `json:"message"`
}
type ResolveBillingBatchResult struct {
ResolvedCount int `json:"resolvedCount"`
Failed []ResolveBillingBatchFailure `json:"failed"`
}
type tokenBillingEstimate struct {
InputTokens int64
OutputTokens int64
Video *VideoTokenEstimate
Err error
}
func (s *Service) Wallet(user *model.User, entryType string, page int, limit int) (*WalletSummary, error) {
if user == nil {
return nil, Unauthorized("请先登录")
}
if err := s.RequireFeature(FeatureCredits); err != nil {
return nil, err
}
if page <= 0 {
page = 1
}
if limit <= 0 || limit > 100 {
limit = 30
}
account, err := s.repo.CreditAccount(user.ID)
if err != nil {
return nil, err
}
entries, total, err := s.repo.CreditLedger(user.ID, strings.TrimSpace(entryType), limit, (page-1)*limit)
if err != nil {
return nil, err
}
policy, err := s.publicCreditPolicy(user.ID)
if err != nil {
return nil, err
}
return &WalletSummary{Account: *account, Entries: entries, Total: total, Page: page, Limit: limit, Policy: policy}, nil
}
func (s *Service) RedeemCredits(user *model.User, code string, redeemedIP string) (*model.CreditAccount, error) {
if user == nil {
return nil, Unauthorized("请先登录")
}
if err := s.RequireFeature(FeatureCredits); err != nil {
return nil, err
}
code = strings.ToLower(strings.TrimSpace(code))
if len(code) != 32 {
return nil, BadAuthRequest("兑换码无效或已使用")
}
account, err := s.repo.RedeemCode(user.ID, hashRedeemCode(code), truncateRunes(strings.TrimSpace(redeemedIP), 64))
if errors.Is(err, repository.ErrRedeemCodeInvalid) {
return nil, BadAuthRequest("兑换码无效或已使用")
}
return account, err
}
func (s *Service) AdminCreateRedeemBatch(actor *model.User, req CreateRedeemBatchRequest) (*CreateRedeemBatchResult, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
if req.AmountMicrocredits <= 0 {
return nil, BadAuthRequest("兑换码积分必须大于 0")
}
if req.Count <= 0 || req.Count > 5000 {
return nil, BadAuthRequest("单批兑换码数量需为 1-5000")
}
if req.ExpiresAt != nil && !req.ExpiresAt.After(time.Now()) {
return nil, BadAuthRequest("兑换码过期时间必须晚于当前时间")
}
batch := model.RedeemBatch{ID: newID(), AmountMicrocredits: req.AmountMicrocredits, Count: req.Count, Note: truncateRunes(strings.TrimSpace(req.Note), 500), CreatedBy: actor.ID, ExpiresAt: req.ExpiresAt}
codes := make([]string, 0, req.Count)
items := make([]model.RedeemCode, 0, req.Count)
for range req.Count {
plain, err := newRedeemCode()
if err != nil {
return nil, err
}
codes = append(codes, plain)
items = append(items, model.RedeemCode{
ID: newID(), BatchID: batch.ID, CodeHash: hashRedeemCode(plain), CodeSuffix: plain[len(plain)-4:],
AmountMicrocredits: req.AmountMicrocredits, Status: model.RedeemCodeUnused, ExpiresAt: req.ExpiresAt,
})
}
encodedCodes, err := json.Marshal(codes)
if err != nil {
return nil, err
}
batch.CodesCipher, err = s.encryptSettingSecret(string(encodedCodes))
if err != nil {
return nil, err
}
// SQLite 只有一个写入器;批次生成串行进入短事务,避免并发生成占满连接池拖住全站读取。
s.redeemBatchMu.Lock()
defer s.redeemBatchMu.Unlock()
if err := s.repo.CreateRedeemBatch(&batch, items); err != nil {
return nil, err
}
if err := s.appendAdminAudit(actor, "redeem_batch.create", "redeem_batch", batch.ID, "创建兑换码批次", map[string]any{"count": batch.Count, "amountMicrocredits": batch.AmountMicrocredits}); err != nil {
return nil, err
}
return &CreateRedeemBatchResult{Batch: batch, Codes: codes}, nil
}
func (s *Service) AdminRedeemCodePage(actor *model.User, batchID string, status string, page int, limit int) (*AdminRedeemCodePage, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
batch, err := s.repo.RedeemBatch(strings.TrimSpace(batchID))
if err != nil {
return nil, err
}
page, limit = normalizeAdminPage(page, limit)
rows, total, err := s.repo.AdminRedeemCodes(batch.ID, strings.TrimSpace(status), limit, (page-1)*limit)
if err != nil {
return nil, err
}
plainCodes, err := s.redeemBatchPlainCodes(batch.CodesCipher)
if err != nil {
return nil, err
}
plainByHash := make(map[string]string, len(plainCodes))
for _, code := range plainCodes {
plainByHash[hashRedeemCode(code)] = code
}
now := time.Now()
details := make([]AdminRedeemCodeDetail, 0, len(rows))
for _, row := range rows {
status := string(row.Status)
if row.Status == model.RedeemCodeUnused && row.ExpiresAt != nil && !row.ExpiresAt.After(now) {
status = "expired"
}
details = append(details, AdminRedeemCodeDetail{
ID: row.ID, Code: plainByHash[row.CodeHash], CodeSuffix: row.CodeSuffix, Status: status,
RedeemedBy: row.RedeemedBy, RedeemedUsername: row.RedeemedUsername, RedeemedDisplayName: row.RedeemedDisplayName,
RedeemedAt: row.RedeemedAt, RedeemedIP: row.RedeemedIP, ExpiresAt: row.ExpiresAt, AmountMicrocredits: row.AmountMicrocredits,
})
}
batch.CodesCipher = ""
return &AdminRedeemCodePage{Batch: *batch, Codes: details, PlaintextAvailable: len(plainCodes) > 0, Total: total, Page: page, Limit: limit}, nil
}
func (s *Service) redeemBatchPlainCodes(ciphertext string) ([]string, error) {
if strings.TrimSpace(ciphertext) == "" {
return nil, nil
}
encoded, err := s.decryptSettingSecret(ciphertext)
if err != nil {
return nil, fmt.Errorf("兑换码批次密文无法解密:%w", err)
}
var codes []string
if err := json.Unmarshal([]byte(encoded), &codes); err != nil {
return nil, errors.New("兑换码批次密文内容无效")
}
return codes, nil
}
func (s *Service) AdminRedeemBatchPage(actor *model.User, query AdminListQuery) (*RedeemBatchPage, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
page, limit := normalizeAdminPage(query.Page, query.Limit)
items, total, err := s.repo.AdminRedeemBatches(query.Keyword, query.Status, limit, (page-1)*limit)
if err != nil {
return nil, err
}
return &RedeemBatchPage{Batches: items, Total: total, Page: page, Limit: limit}, nil
}
func (s *Service) AdminAdjustCredits(actor *model.User, userID string, req AdminCreditAdjustmentRequest) (*model.CreditAccount, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
if req.AmountMicrocredits == 0 {
return nil, BadAuthRequest("调账积分不能为 0")
}
note := strings.TrimSpace(req.Note)
if note == "" {
return nil, BadAuthRequest("请填写调账原因")
}
if _, err := s.repo.User(userID); err != nil {
return nil, err
}
account, err := s.repo.AdjustCredits(userID, actor.ID, req.AmountMicrocredits, truncateRunes(note, 500))
if errors.Is(err, repository.ErrInsufficientCredits) {
return nil, BadAuthRequest("用户可用积分不足,不能执行本次扣减")
}
if err != nil {
return nil, err
}
if err := s.appendAdminAudit(actor, "credits.adjust", "user", userID, "管理员调整用户积分", map[string]any{"amountMicrocredits": req.AmountMicrocredits, "note": truncateRunes(note, 500)}); err != nil {
return nil, err
}
return account, nil
}
func (s *Service) AdminBillingOrderPage(actor *model.User, query AdminListQuery) (*BillingOrderPage, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
page, limit := normalizeAdminPage(query.Page, query.Limit)
items, total, err := s.repo.AdminBillingOrders(query.Status, query.Keyword, limit, (page-1)*limit)
if err != nil {
return nil, err
}
return &BillingOrderPage{Orders: items, Total: total, Page: page, Limit: limit}, nil
}
func (s *Service) ResolveBillingOrder(actor *model.User, id string, req ResolveBillingRequest) (*model.BillingOrder, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
note := strings.TrimSpace(req.Note)
if note == "" {
return nil, BadAuthRequest("请填写核对依据")
}
action := strings.TrimSpace(req.Action)
if action != "settle" && action != "refund" {
return nil, BadAuthRequest("请选择结算或退款")
}
return s.resolveBillingOrder(actor, strings.TrimSpace(id), action, note)
}
func (s *Service) ResolveBillingOrders(actor *model.User, req ResolveBillingBatchRequest) (*ResolveBillingBatchResult, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
note := strings.TrimSpace(req.Note)
if note == "" {
return nil, BadAuthRequest("请填写核对依据")
}
action := strings.TrimSpace(req.Action)
if action != "settle" && action != "refund" {
return nil, BadAuthRequest("请选择结算或退款")
}
seen := make(map[string]struct{}, len(req.IDs))
ids := make([]string, 0, len(req.IDs))
for _, rawID := range req.IDs {
id := strings.TrimSpace(rawID)
if id == "" {
return nil, BadAuthRequest("计费订单 ID 无效")
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, BadAuthRequest("请选择要处理的计费订单")
}
if len(ids) > 100 {
return nil, BadAuthRequest("单次最多处理 100 条计费订单")
}
// 批量资金操作逐单提交并明确返回失败项,避免部分成功时给出整体成功的错误反馈。
result := &ResolveBillingBatchResult{Failed: make([]ResolveBillingBatchFailure, 0)}
for _, id := range ids {
if _, err := s.resolveBillingOrder(actor, id, action, note); err != nil {
result.Failed = append(result.Failed, ResolveBillingBatchFailure{ID: id, Message: err.Error()})
continue
}
result.ResolvedCount++
}
return result, nil
}
func (s *Service) resolveBillingOrder(actor *model.User, id string, action string, note string) (*model.BillingOrder, error) {
order, err := s.repo.BillingOrder(id)
if err != nil {
return nil, err
}
if order.Status != model.BillingStatusUncertain && order.Status != model.BillingStatusRunning && order.Status != model.BillingStatusReserved {
return nil, BadAuthRequest("当前订单不需要人工核对")
}
switch action {
case "settle":
err = s.SettleBilling(id, order.ProviderRequestID)
case "refund":
err = s.RefundBilling(id, note)
}
if err != nil {
return nil, err
}
if err := s.repo.RecordBillingResolution(id, actor.ID, truncateRunes(note, 500)); err != nil {
return nil, err
}
if err := s.appendAdminAudit(actor, "billing.resolve", "user", order.UserID, "人工核对用户计费订单", map[string]any{"billingOrderId": id, "action": action, "note": truncateRunes(note, 500)}); err != nil {
return nil, err
}
return s.repo.BillingOrder(id)
}
func (s *Service) AdminDisableRedeemBatch(actor *model.User, batchID string) (int64, error) {
if err := s.RequireAdmin(actor); err != nil {
return 0, err
}
if _, err := s.repo.RedeemBatch(strings.TrimSpace(batchID)); err != nil {
return 0, err
}
count, err := s.repo.DisableRedeemBatch(batchID, time.Now())
if err != nil {
return 0, err
}
if count == 0 {
return 0, BadAuthRequest("该批次没有可禁用的兑换码")
}
if err := s.appendAdminAudit(actor, "redeem_batch.disable", "redeem_batch", batchID, "禁用批次内全部未使用兑换码", map[string]any{"disabledCount": count}); err != nil {
return 0, err
}
return count, nil
}
func (s *Service) AdminDisableRedeemCode(actor *model.User, batchID string, codeID string) error {
if err := s.RequireAdmin(actor); err != nil {
return err
}
disabled, err := s.repo.DisableRedeemCode(batchID, codeID, time.Now())
if err != nil {
return err
}
if !disabled {
return BadAuthRequest("兑换码不存在、已使用、已禁用或已过期")
}
return s.appendAdminAudit(actor, "redeem_code.disable", "redeem_code", codeID, "禁用单个兑换码", map[string]any{"batchId": batchID})
}
func (s *Service) taskBillingOrder(userID string, task *model.Task, input map[string]any) (*model.BillingOrder, error) {
enabled, err := s.FeatureEnabled(FeatureCredits)
if err != nil {
return nil, err
}
if !enabled {
return nil, nil
}
if task.LogicalModelID != "" {
return s.newLogicalModelBillingOrder(userID, task, input)
}
config, _ := input["config"].(map[string]any)
if config == nil {
return nil, nil
}
channelID := strings.TrimSpace(fmt.Sprint(config["channelId"]))
if channelID == "" {
channelID = systemChannelIDFromBaseURL(fmt.Sprint(config["baseUrl"]))
}
if channelID == "" {
return nil, nil
}
modelKey := strings.TrimPrefix(strings.TrimSpace(fmt.Sprint(config["model"])), "models/")
capability := normalizeCapability(fmt.Sprint(input["mode"]))
if capability == "" {
capability = capabilityFromTaskType(task.Type)
}
scene := firstNonEmpty(strings.TrimSpace(task.Operation), task.Type)
intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation)
priceTierID, _ := config["priceTierId"].(string)
return s.newBillingOrderWithPriceTier(userID, task.ID, "task:"+task.ID+":"+newID(), channelID, modelKey, capability, scene, requestedBillingQuantity(capability, config), estimateTaskBillingTokens(input, capability), strings.TrimSpace(priceTierID), intent)
}
func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, input map[string]any) (*model.BillingOrder, error) {
logicalModel, err := s.repo.LogicalModel(task.LogicalModelID)
if err != nil || (!logicalModel.Enabled && logicalModel.ArchivedAt == nil) || logicalModel.ActiveRevisionID != task.LogicalModelRevisionID {
return nil, BadAuthRequest("所选模型计费配置已失效,请重新选择")
}
route, err := s.repo.LogicalModelRoute(task.RouteID)
if err != nil || route.LogicalModelRevisionID != task.LogicalModelRevisionID || route.ChannelModelID != task.ChannelModelID {
return nil, BadAuthRequest("所选模型供应线路已更新,请重新选择")
}
channelModel, err := s.repo.ChannelModel(task.ChannelModelID)
if err != nil {
return nil, BadAuthRequest("所选模型供应线路已更新,请重新选择")
}
config, _ := input["config"].(map[string]any)
capability := normalizeCapability(fmt.Sprint(input["mode"]))
if capability == "" {
capability = capabilityFromTaskType(task.Type)
}
if logicalModel.PricePolicy == "channel" {
intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation)
priceTierID, _ := config["priceTierId"].(string)
order, priceErr := s.newBillingOrderWithPriceTier(userID, task.ID, "task:"+task.ID+":"+newID(), channelModel.ChannelID, channelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), requestedBillingQuantity(capability, config), estimateTaskBillingTokens(input, capability), strings.TrimSpace(priceTierID), intent)
if priceErr != nil {
return nil, priceErr
}
// 用户账单只显示前台模型,供应线路仍保留在内部归属字段中。
order.Model = logicalModel.Code
return order, nil
}
if logicalModel.PricePolicy != "unified" {
return nil, BadAuthRequest("当前模型价格策略无效")
}
quantity := int64(1)
tokenEstimate := estimateTaskBillingTokens(input, capability)
amount := int64(0)
switch logicalModel.BillingMode {
case "fixed_request":
amount = logicalModel.UnitPriceMicrocredits
case "per_second":
quantity = requestedBillingQuantity(capability, config)
if (capability != "video" && capability != "audio") || quantity <= 0 {
return nil, BadAuthRequest("当前模型按时长计费,但请求未提供有效时长")
}
amount, err = creditAmount(logicalModel.UnitPriceMicrocredits, quantity, 10_000)
case "token":
if channelModel.Capability != capability || !supportsTokenBilling(capability, channelModel.Protocol) {
return nil, BadAuthRequest("当前供应线路不支持前台模型的 Token 计费方式")
}
if capability == "video" && (logicalModel.InputPriceMicrocredits != 0 || logicalModel.CachedPriceMicrocredits != 0) {
return nil, BadAuthRequest("视频 Token 仅按视频用量定价,请将输入与缓存价格设为 0")
}
pricing := &model.ChannelModel{InputTokenPriceMicrocredits: logicalModel.InputPriceMicrocredits, OutputTokenPriceMicrocredits: logicalModel.OutputPriceMicrocredits, CachedTokenPriceMicrocredits: logicalModel.CachedPriceMicrocredits}
amount, err = tokenEstimateAmount(pricing, tokenEstimate, 10_000)
quantity = tokenEstimate.InputTokens + tokenEstimate.OutputTokens
default:
return nil, BadAuthRequest("当前模型计费方式暂不支持")
}
if err != nil {
return nil, err
}
if amount < 0 {
return nil, BadAuthRequest("当前模型尚未配置有效的用户价格")
}
revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID)
if err != nil {
return nil, err
}
var videoFormulaTokens int64
if logicalModel.BillingMode == "token" && tokenEstimate.Video != nil {
videoFormulaTokens = tokenEstimate.Video.FormulaTokens
}
order := &model.BillingOrder{
ID: newID(), UserID: userID, IdempotencyKey: "task:" + task.ID + ":" + newID(), TaskID: task.ID,
ChannelID: channelModel.ChannelID, ChannelModelID: channelModel.ID, Model: logicalModel.Code, Capability: capability,
Scene: truncateRunes(firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), 80), BillingMode: logicalModel.BillingMode, PriceVersion: int64(revision.Version),
UnitPriceMicrocredits: logicalModel.UnitPriceMicrocredits, MultiplierBasisPoints: 10_000, Quantity: quantity, AmountMicrocredits: amount,
ReservedAmountMicrocredits: amount, InputTokenPriceMicrocredits: logicalModel.InputPriceMicrocredits,
OutputTokenPriceMicrocredits: logicalModel.OutputPriceMicrocredits, CachedTokenPriceMicrocredits: logicalModel.CachedPriceMicrocredits,
VideoFormulaTokens: videoFormulaTokens,
Status: model.BillingStatusReserved,
}
intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation)
priceTierID, _ := config["priceTierId"].(string)
costTier, err := s.channelModelPriceTierForBilling(*channelModel, priceTierID, capability, intent)
if err != nil {
return nil, err
}
snapshotCreditCost(order, costTier, requestedBillingQuantity(capability, config), tokenEstimate)
return order, nil
}
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
}
if !enabled {
return nil, nil
}
if strings.TrimSpace(idempotencyKey) == "" {
idempotencyKey = newID()
}
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
}
if err := s.repo.ReserveBillingOrder(order); err != nil {
if errors.Is(err, repository.ErrInsufficientCredits) {
return nil, BadAuthRequest("积分不足,请先使用兑换码充值")
}
return nil, err
}
return order, nil
}
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) {
return s.newBillingOrderWithPriceTier(userID, taskID, idempotencyKey, channelID, modelKey, capability, scene, requestedQuantity, tokenEstimate, "")
}
func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, idempotencyKey string, channelID string, modelKey string, capability string, scene string, requestedQuantity int64, tokenEstimate tokenBillingEstimate, priceTierID string, intents ...ModelRequestIntent) (*model.BillingOrder, error) {
item, err := s.repo.ChannelModelByKey(channelID, modelKey)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, BadAuthRequest("当前模型暂时不可用,请重新选择")
}
if err != nil {
return nil, err
}
tier, err := s.channelModelPriceTierForBilling(*item, priceTierID, capability, intents...)
if err != nil {
return nil, err
}
if tier == nil {
return nil, BadAuthRequest("当前模型尚未配置所选规格的用户积分价格")
}
quantity := int64(1)
amount := int64(0)
switch tier.BillingMode {
case "fixed_request":
case "per_second":
if (item.Capability != "video" && item.Capability != "audio") || capability != item.Capability {
return nil, BadAuthRequest("按秒计费仅适用于视频或音频生成")
}
if requestedQuantity <= 0 {
return nil, BadAuthRequest("生成时长无效,无法按秒计费")
}
quantity = requestedQuantity
case "token":
if tokenEstimate.Err != nil {
return nil, tokenEstimate.Err
}
if !supportsTokenBilling(item.Capability, item.Protocol) || item.Capability != capability {
return nil, BadAuthRequest("Token 计费仅支持文本和视频生成")
}
if capability == "text" && (tokenEstimate.InputTokens <= 0 || tokenEstimate.OutputTokens <= 0) {
return nil, BadAuthRequest("无法估算文本 Token 用量")
}
if capability == "video" && tokenEstimate.OutputTokens <= 0 {
return nil, BadAuthRequest("无法计算视频 Token 用量,请检查时长与尺寸")
}
if capability == "video" && (tier.InputTokenPriceMicrocredits != 0 || tier.CachedTokenPriceMicrocredits != 0) {
return nil, BadAuthRequest("视频 Token 仅按视频用量定价,请将输入与缓存价格设为 0")
}
quantity = tokenEstimate.InputTokens + tokenEstimate.OutputTokens
default:
return nil, BadAuthRequest("当前模型计费方式暂不支持")
}
policy, err := s.creditPolicy()
if err != nil {
return nil, err
}
multiplierBPS := policy.DefaultMultiplierBPS
if configured := policy.ModelMultiplierBPS[modelKey]; configured > 0 {
multiplierBPS = configured
}
if tier.BillingMode == "token" {
amount, err = tokenEstimateAmount(&model.ChannelModel{InputTokenPriceMicrocredits: tier.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits}, tokenEstimate, multiplierBPS)
} else {
amount, err = creditAmount(tier.UnitPriceMicrocredits, quantity, multiplierBPS)
}
if err != nil {
return nil, err
}
var videoFormulaTokens int64
if tier.BillingMode == "token" && tokenEstimate.Video != nil {
videoFormulaTokens = tokenEstimate.Video.FormulaTokens
}
order := &model.BillingOrder{
ID: newID(), UserID: userID, IdempotencyKey: idempotencyKey, TaskID: taskID,
ChannelID: channelID, ChannelModelID: item.ID, PriceTierID: tier.ID, PriceTierVersion: tier.PriceVersion, PriceSelectorJSON: tier.SelectorJSON, Model: modelKey, Capability: capability,
Scene: truncateRunes(scene, 80), BillingMode: tier.BillingMode, PriceVersion: item.PriceVersion,
UnitPriceMicrocredits: tier.UnitPriceMicrocredits, MultiplierBasisPoints: multiplierBPS, Quantity: quantity, AmountMicrocredits: amount,
ReservedAmountMicrocredits: amount, InputTokenPriceMicrocredits: tier.InputTokenPriceMicrocredits,
OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits,
VideoFormulaTokens: videoFormulaTokens,
Status: model.BillingStatusReserved,
}
snapshotCreditCost(order, tier, requestedQuantity, tokenEstimate)
return order, nil
}
func (s *Service) channelModelPriceTierForBilling(channelModel model.ChannelModel, priceTierID string, capability string, intents ...ModelRequestIntent) (*model.ChannelModelPriceTier, error) {
intent := ModelRequestIntent{Capability: capability, Options: map[string]any{}}
if len(intents) > 0 {
intent = intents[0]
if normalizeCapability(intent.Capability) == "" {
intent.Capability = capability
}
}
var specs []CapabilitySpec
if normalizeCapability(capability) == "image" && strings.TrimSpace(channelModel.CapabilityConfigJSON) != "" {
spec, err := channelModelCapabilitySpec(channelModel, s.protocolRegistry())
if err != nil {
return nil, err
}
specs = append(specs, spec)
intent = normalizeImageSizeIntent(spec, intent)
}
if priceTierID != "" {
for index := range channelModel.PriceTiers {
tier := &channelModel.PriceTiers[index]
if tier.ID == priceTierID && tier.Enabled && tier.PriceConfigured {
if len(specs) > 0 && specs[0].ImageParameters != nil {
selector := model.DecodeSKUSelector(tier.SelectorJSON)
if err := validateImageParameterPriceSelector(specs[0].ImageParameters, selector); err != nil {
return nil, err
}
if matched, _ := matchSKUSelector(selector, imagePriceSelectorForParameters(specs[0].ImageParameters, intent)); !matched {
return nil, BadAuthRequest("价格档与实际图片参数不一致")
}
}
return tier, nil
}
}
return nil, nil
}
return channelModelPriceTierForIntent(channelModel, intent, specs...), nil
}
func estimateTaskTokens(input map[string]any) tokenBillingEstimate {
encoded, _ := json.Marshal(input)
return tokenBillingEstimate{InputTokens: estimatedTokens(encoded), OutputTokens: maxOutputTokens(input)}
}
func estimateTaskBillingTokens(input map[string]any, capability string) tokenBillingEstimate {
if capability == "video" {
return estimateArkVideoTokens(input)
}
return estimateTaskTokens(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 estimate.Err != nil {
return 0, estimate.Err
}
if item == nil || estimate.InputTokens < 0 || estimate.OutputTokens <= 0 || multiplierBPS <= 0 {
return 0, errors.New("Token 计费参数无效")
}
amount, err := kernel.TokenBillingAmount(multiplierBPS,
kernel.TokenBillingTerm{Tokens: estimate.InputTokens, PriceMicrocredits: item.InputTokenPriceMicrocredits},
kernel.TokenBillingTerm{Tokens: estimate.OutputTokens, PriceMicrocredits: item.OutputTokenPriceMicrocredits},
)
if errors.Is(err, kernel.ErrTokenBillingOverflow) {
return 0, errors.New("Token 计费金额溢出")
}
if err != nil {
return 0, errors.New("Token 计费参数无效")
}
return amount, nil
}
func billingQuantity(capability string, value any) int64 {
if capability != "video" && capability != "audio" {
return 1
}
quantity, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(value)), 10, 64)
if err != nil || quantity <= 0 {
return 0
}
return quantity
}
// requestedBillingQuantity is the quantity reserved before a provider has
// returned the generated media. Audio output length is not known at request
// time, so an audio per-second order uses one second as the minimum
// preauthorization unless the caller explicitly supplied audioSeconds.
// Settlement must replace this estimate with the measured output duration.
func requestedBillingQuantity(capability string, config map[string]any) int64 {
if capability == "audio" {
if config == nil {
return 1
}
value, present := config["audioSeconds"]
if !present || strings.TrimSpace(fmt.Sprint(value)) == "" {
return 1
}
return billingQuantity(capability, value)
}
if config == nil {
return billingQuantity(capability, nil)
}
return billingQuantity(capability, config["videoSeconds"])
}
func (s *Service) MarkBillingRunning(orderID string) error {
return s.taskBilling().MarkBillingRunning(orderID)
}
func (s *Service) SettleBilling(orderID string, providerRequestID string) error {
return s.taskBilling().SettleBilling(orderID, providerRequestID)
}
func (s *Service) RefundBilling(orderID string, errorText string) error {
return s.taskBilling().RefundBilling(orderID, errorText)
}
func (s *Service) MarkBillingUncertain(orderID string, errorText string) error {
return s.taskBilling().MarkBillingUncertain(orderID, errorText)
}
func (s *Service) BillingFailureRequiresReview(orderID string, taskID string, err error) bool {
return s.taskBilling().BillingFailureRequiresReview(orderID, taskID, err)
}
func newRedeemCode() (string, error) {
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", err
}
raw[6] = (raw[6] & 0x0f) | 0x40
raw[8] = (raw[8] & 0x3f) | 0x80
return hex.EncodeToString(raw[:]), nil
}
func hashRedeemCode(code string) string {
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(code))))
return hex.EncodeToString(sum[:])
}