322 lines
11 KiB
Go
322 lines
11 KiB
Go
package billing
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"math/big"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"juhe-factory/api/internal/model"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const (
|
|
TextBillingPerRequest = "per_request"
|
|
TextBillingPerToken = "per_token"
|
|
)
|
|
|
|
type TextPricing struct {
|
|
Mode string `json:"mode"`
|
|
PerRequest string `json:"per_request,omitempty"`
|
|
InputPerM string `json:"input_per_m,omitempty"`
|
|
OutputPerM string `json:"output_per_m,omitempty"`
|
|
}
|
|
|
|
type TextUsage struct {
|
|
Input int64
|
|
Output int64
|
|
}
|
|
|
|
func CreateTextGenerationTask(tx *gorm.DB, task *model.GenerationTask, pricing TextPricing, remark string) error {
|
|
if err := pricing.Validate(); err != nil {
|
|
return err
|
|
}
|
|
task.BillingSnapshot = pricing.MarshalSnapshot()
|
|
if pricing.Mode == TextBillingPerRequest {
|
|
return PrechargeGenerationTask(tx, task, pricing.PerRequest, 1, remark)
|
|
}
|
|
task.EstimatedPoints = "0.00"
|
|
task.PrepaidPoints = "0.00"
|
|
if task.ID == uuid.Nil {
|
|
task.ID = uuid.New()
|
|
}
|
|
return tx.Create(task).Error
|
|
}
|
|
|
|
func ChargeTextRequest(tx *gorm.DB, userID uuid.UUID, referenceID string, pricing TextPricing, remark string) (string, error) {
|
|
if pricing.Mode != TextBillingPerRequest {
|
|
return "", errors.New("文本模型不是按次计费")
|
|
}
|
|
businessID := textCallBusinessID(referenceID, "request")
|
|
idempotencyKey := "generation:hold:" + businessID
|
|
if existing, found, err := debitAmount(tx, idempotencyKey); err != nil {
|
|
return "", err
|
|
} else if found {
|
|
return existing, nil
|
|
}
|
|
if pricing.PerRequest == "0" || pricing.PerRequest == "0.00" || pricing.PerRequest == "0.0000" {
|
|
return "0.00", nil
|
|
}
|
|
_, err := DebitPoints(tx, userID, pricing.PerRequest, "generation_hold", businessID, remark, idempotencyKey)
|
|
return pricing.PerRequest, err
|
|
}
|
|
|
|
func RefundTextGenerationTask(tx *gorm.DB, task *model.GenerationTask, remark string) (bool, error) {
|
|
pricing, err := ParseTextPricingSnapshot(task.BillingSnapshot)
|
|
if err != nil || pricing.Mode == TextBillingPerRequest {
|
|
return RefundGenerationTask(tx, task, remark)
|
|
}
|
|
if task.CostRefunded {
|
|
return false, nil
|
|
}
|
|
refunded, err := RefundTextCalls(tx, task.UserID, task.ID.String(), remark)
|
|
if err == nil {
|
|
task.CostRefunded = true
|
|
}
|
|
return refunded, err
|
|
}
|
|
|
|
func LoadTextPricing(db *gorm.DB, modelID uuid.UUID) (TextPricing, error) {
|
|
var pricing TextPricing
|
|
err := db.Table("models m").
|
|
Select(`coalesce(m.text_billing_mode,'') AS mode,
|
|
coalesce(max(CASE WHEN lower(p.price_key)='default' THEN p.price::text END),'') AS per_request,
|
|
coalesce(max(CASE WHEN lower(p.price_key)='input' THEN p.price::text END),'') AS input_per_m,
|
|
coalesce(max(CASE WHEN lower(p.price_key)='output' THEN p.price::text END),'') AS output_per_m`).
|
|
Joins("LEFT JOIN model_prices p ON p.model_id=m.id").
|
|
Where("m.id=? AND m.model_type='text' AND m.enabled=true AND m.deleted_at IS NULL", modelID).
|
|
Group("m.id,m.text_billing_mode").
|
|
Take(&pricing).Error
|
|
if err != nil {
|
|
return pricing, err
|
|
}
|
|
if err := pricing.Validate(); err != nil {
|
|
return pricing, err
|
|
}
|
|
return pricing, nil
|
|
}
|
|
|
|
func (pricing TextPricing) Validate() error {
|
|
switch pricing.Mode {
|
|
case TextBillingPerRequest:
|
|
if !validNonNegativeDecimal(pricing.PerRequest) {
|
|
return errors.New("文本模型未配置按次价格")
|
|
}
|
|
case TextBillingPerToken:
|
|
if !validNonNegativeDecimal(pricing.InputPerM) || !validNonNegativeDecimal(pricing.OutputPerM) {
|
|
return errors.New("文本模型未完整配置输入、输出 Token 价格")
|
|
}
|
|
default:
|
|
return errors.New("文本模型计费模式无效")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (pricing TextPricing) MarshalSnapshot() json.RawMessage {
|
|
data, _ := json.Marshal(pricing)
|
|
return data
|
|
}
|
|
|
|
func ParseTextPricingSnapshot(value json.RawMessage) (TextPricing, error) {
|
|
var pricing TextPricing
|
|
if len(value) == 0 || string(value) == "{}" {
|
|
return pricing, errors.New("文本模型计费快照缺失")
|
|
}
|
|
if err := json.Unmarshal(value, &pricing); err != nil {
|
|
return pricing, errors.New("文本模型计费快照无效")
|
|
}
|
|
return pricing, pricing.Validate()
|
|
}
|
|
|
|
func CalculateTextPoints(pricing TextPricing, usage TextUsage) (string, error) {
|
|
if pricing.Mode != TextBillingPerToken || usage.Input < 0 || usage.Output < 0 {
|
|
return "", errors.New("Token 计费参数无效")
|
|
}
|
|
inputRate, ok := new(big.Rat).SetString(pricing.InputPerM)
|
|
if !ok || inputRate.Sign() < 0 {
|
|
return "", errors.New("输入 Token 价格无效")
|
|
}
|
|
outputRate, ok := new(big.Rat).SetString(pricing.OutputPerM)
|
|
if !ok || outputRate.Sign() < 0 {
|
|
return "", errors.New("输出 Token 价格无效")
|
|
}
|
|
amount := new(big.Rat).Mul(inputRate, new(big.Rat).SetInt64(usage.Input))
|
|
amount.Add(amount, new(big.Rat).Mul(outputRate, new(big.Rat).SetInt64(usage.Output)))
|
|
amount.Quo(amount, big.NewRat(1000000, 1))
|
|
return ceilPointCents(amount), nil
|
|
}
|
|
|
|
func EstimateTextTokens(value string) int64 {
|
|
var tokens int64
|
|
ascii := 0
|
|
flush := func() {
|
|
if ascii > 0 {
|
|
tokens += int64((ascii + 3) / 4)
|
|
ascii = 0
|
|
}
|
|
}
|
|
for _, r := range value {
|
|
if r <= unicode.MaxASCII {
|
|
ascii++
|
|
} else {
|
|
flush()
|
|
tokens++
|
|
}
|
|
}
|
|
flush()
|
|
if tokens == 0 && value != "" {
|
|
return 1
|
|
}
|
|
return tokens
|
|
}
|
|
|
|
func ReserveTextCall(tx *gorm.DB, userID uuid.UUID, referenceID, callKey string, pricing TextPricing, estimatedInputTokens int64, remark string) (string, error) {
|
|
if pricing.Mode != TextBillingPerToken || estimatedInputTokens < 0 {
|
|
return "", errors.New("Token 预授权参数无效")
|
|
}
|
|
amount, err := CalculateTextReservePoints(pricing, estimatedInputTokens)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
businessID := textCallBusinessID(referenceID, callKey) + ":reserve"
|
|
idempotencyKey := "text:reserve:" + businessID
|
|
if existing, found, err := debitAmount(tx, idempotencyKey); err != nil {
|
|
return "", err
|
|
} else if found {
|
|
return existing, nil
|
|
}
|
|
if amount == "0.00" {
|
|
return amount, nil
|
|
}
|
|
_, err = DebitPoints(tx, userID, amount, "text_token_reserve", businessID, remark+"预授权", idempotencyKey)
|
|
return amount, err
|
|
}
|
|
|
|
func CalculateTextReservePoints(pricing TextPricing, estimatedInputTokens int64) (string, error) {
|
|
if estimatedInputTokens < 0 || estimatedInputTokens > math.MaxInt64/2 {
|
|
return "", errors.New("Token 预授权参数无效")
|
|
}
|
|
return CalculateTextPoints(pricing, TextUsage{Input: estimatedInputTokens, Output: estimatedInputTokens * 2})
|
|
}
|
|
|
|
func SettleTextCall(tx *gorm.DB, userID uuid.UUID, referenceID, callKey string, pricing TextPricing, usage TextUsage, remark string) (string, error) {
|
|
if usage.Input <= 0 && usage.Output <= 0 {
|
|
return "", errors.New("上游未返回 Token 用量,无法完成计费")
|
|
}
|
|
actual, err := CalculateTextPoints(pricing, usage)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
baseID := textCallBusinessID(referenceID, callKey)
|
|
reserveID := baseID + ":reserve"
|
|
if _, found, err := debitAmount(tx, "text:reserve:"+reserveID); err != nil {
|
|
return "", err
|
|
} else if found {
|
|
if _, err := RefundDebit(tx, userID, "text:reserve:"+reserveID, "text:reserve-refund:"+reserveID,
|
|
"text_token_reserve_refund", reserveID, remark+"预授权释放"); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
chargeKey := "generation:hold:" + baseID
|
|
if existing, found, err := debitAmount(tx, chargeKey); err != nil {
|
|
return "", err
|
|
} else if found {
|
|
return existing, nil
|
|
}
|
|
if actual == "0.00" {
|
|
return actual, nil
|
|
}
|
|
_, err = DebitPoints(tx, userID, actual, "generation_hold", baseID, remark, chargeKey)
|
|
return actual, err
|
|
}
|
|
|
|
func RefundTextCalls(tx *gorm.DB, userID uuid.UUID, referenceID, remark string) (bool, error) {
|
|
type debit struct {
|
|
BusinessType string
|
|
BusinessID string
|
|
IdempotencyKey string
|
|
}
|
|
debits := make([]debit, 0)
|
|
if err := tx.Table("point_ledger").
|
|
Select("business_type,business_id,idempotency_key").
|
|
Where("user_id=? AND change_amount<0 AND business_id LIKE ? AND business_type IN ?", userID, referenceID+":text:%", []string{"generation_hold", "text_token_reserve"}).
|
|
Order("id").Find(&debits).Error; err != nil {
|
|
return false, err
|
|
}
|
|
refunded := false
|
|
for _, debit := range debits {
|
|
refundKey := "generation:refund:" + debit.BusinessID
|
|
refundType := "generation_refund"
|
|
if debit.BusinessType == "text_token_reserve" {
|
|
refundKey = "text:reserve-refund:" + debit.BusinessID
|
|
refundType = "text_token_reserve_refund"
|
|
}
|
|
created, err := RefundDebit(tx, userID, debit.IdempotencyKey, refundKey, refundType, debit.BusinessID, remark)
|
|
if err != nil {
|
|
return refunded, err
|
|
}
|
|
refunded = refunded || created
|
|
}
|
|
return refunded, nil
|
|
}
|
|
|
|
func CommitTextReserves(tx *gorm.DB, userID uuid.UUID, referenceID, remark string) (string, error) {
|
|
type reserve struct {
|
|
BusinessID string
|
|
IdempotencyKey string
|
|
}
|
|
items := make([]reserve, 0)
|
|
if err := tx.Table("point_ledger").Select("business_id,idempotency_key").
|
|
Where("user_id=? AND business_type='text_token_reserve' AND business_id LIKE ? AND NOT EXISTS (SELECT 1 FROM point_ledger refund WHERE refund.idempotency_key='text:reserve-refund:'||point_ledger.business_id)", userID, referenceID+":text:%").
|
|
Find(&items).Error; err != nil {
|
|
return "", err
|
|
}
|
|
for _, item := range items {
|
|
baseID := strings.TrimSuffix(item.BusinessID, ":reserve")
|
|
if err := tx.Exec("UPDATE point_ledger SET business_type='generation_hold',business_id=?,idempotency_key=?,remark=? WHERE idempotency_key=?", baseID, "generation:hold:"+baseID, remark, item.IdempotencyKey).Error; err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
var amount string
|
|
if err := tx.Table("point_ledger").Select("coalesce(sum(-change_amount),0)::text").
|
|
Where("user_id=? AND business_type='generation_hold' AND business_id LIKE ?", userID, referenceID+":text:%").Scan(&amount).Error; err != nil {
|
|
return "", err
|
|
}
|
|
return amount, nil
|
|
}
|
|
|
|
func validNonNegativeDecimal(value string) bool {
|
|
number, ok := new(big.Rat).SetString(strings.TrimSpace(value))
|
|
return ok && number.Sign() >= 0
|
|
}
|
|
|
|
func ceilPointCents(value *big.Rat) string {
|
|
if value == nil || value.Sign() <= 0 {
|
|
return "0.00"
|
|
}
|
|
cents := new(big.Rat).Mul(value, big.NewRat(100, 1))
|
|
quotient := new(big.Int).Quo(cents.Num(), cents.Denom())
|
|
if new(big.Int).Mod(cents.Num(), cents.Denom()).Sign() > 0 {
|
|
quotient.Add(quotient, big.NewInt(1))
|
|
}
|
|
return fmt.Sprintf("%d.%02d", new(big.Int).Quo(quotient, big.NewInt(100)), new(big.Int).Mod(quotient, big.NewInt(100)))
|
|
}
|
|
|
|
func textCallBusinessID(referenceID, callKey string) string {
|
|
return referenceID + ":text:" + strings.TrimSpace(callKey)
|
|
}
|
|
|
|
func debitAmount(tx *gorm.DB, idempotencyKey string) (string, bool, error) {
|
|
var amount string
|
|
result := tx.Table("point_ledger").Select("(-change_amount)::text").Where("idempotency_key=? AND change_amount<0", idempotencyKey).Limit(1).Scan(&amount)
|
|
if result.Error != nil {
|
|
return "", false, result.Error
|
|
}
|
|
return amount, strings.TrimSpace(amount) != "", nil
|
|
}
|