初始化
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"juhe-factory/api/internal/model"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PrechargeGenerationTask creates a task and reserves its full quoted cost in
|
||||
// the same transaction. A zero price is allowed only when the model has an
|
||||
// explicit zero-price record; callers must reject a missing price record.
|
||||
func PrechargeGenerationTask(tx *gorm.DB, task *model.GenerationTask, unitPrice string, quantity int, remark string) error {
|
||||
price, err := strconv.ParseFloat(unitPrice, 64)
|
||||
if err != nil || price < 0 || quantity <= 0 {
|
||||
return errors.New("生成任务价格无效")
|
||||
}
|
||||
var amount string
|
||||
if err := tx.Raw("SELECT round(?::numeric * ?::numeric,2)::text", unitPrice, quantity).Scan(&amount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if amount == "" {
|
||||
return errors.New("生成任务价格无效")
|
||||
}
|
||||
task.EstimatedPoints = amount
|
||||
task.PrepaidPoints = amount
|
||||
if task.ID == uuid.Nil {
|
||||
task.ID = uuid.New()
|
||||
}
|
||||
|
||||
if amountValue, _ := strconv.ParseFloat(amount, 64); amountValue > 0 {
|
||||
if _, err := DebitPoints(tx, task.UserID, amount, "generation_hold", task.ID.String(), remark, "generation:hold:"+task.ID.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Create(task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefundGenerationTask refunds a prepaid task exactly once. The caller must
|
||||
// hold a row lock on task inside the same transaction.
|
||||
func RefundGenerationTask(tx *gorm.DB, task *model.GenerationTask, remark string) (bool, error) {
|
||||
amount, _ := strconv.ParseFloat(task.PrepaidPoints, 64)
|
||||
if task.CostRefunded || amount <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
refunded, err := RefundDebit(tx, task.UserID, "generation:hold:"+task.ID.String(), "generation:refund:"+task.ID.String(),
|
||||
"generation_refund", task.ID.String(), remark)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
task.CostRefunded = true
|
||||
return refunded, nil
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// 积分账务模块,负责积分入账、扣减、退款及批次分配记录维护。
|
||||
package billing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrInsufficientPoints = errors.New("积分余额不足,请前往充值")
|
||||
|
||||
// pointGrant 表示参与扣减的可用积分批次。
|
||||
type pointGrant struct {
|
||||
ID uuid.UUID
|
||||
AvailableAmount string
|
||||
}
|
||||
|
||||
// pointAllocation 表示原扣减流水关联的积分批次和分配金额。
|
||||
type pointAllocation struct {
|
||||
GrantID uuid.UUID
|
||||
Amount string
|
||||
}
|
||||
|
||||
// parsePointCents 将最多两位小数的积分字符串转换为整数分值。
|
||||
func parsePointCents(value string) (int64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, errors.New("积分数量为空")
|
||||
}
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) > 2 || strings.HasPrefix(parts[0], "-") {
|
||||
return 0, fmt.Errorf("无效积分数量 %q", value)
|
||||
}
|
||||
whole, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("无效积分数量 %q", value)
|
||||
}
|
||||
fraction := ""
|
||||
if len(parts) == 2 {
|
||||
fraction = parts[1]
|
||||
}
|
||||
if len(fraction) > 2 {
|
||||
if strings.Trim(fraction[2:], "0") != "" {
|
||||
return 0, fmt.Errorf("积分数量最多保留两位小数")
|
||||
}
|
||||
fraction = fraction[:2]
|
||||
}
|
||||
fraction += strings.Repeat("0", 2-len(fraction))
|
||||
fractionValue, err := strconv.ParseInt(fraction, 10, 64)
|
||||
if err != nil || whole > (int64(^uint64(0)>>1)-fractionValue)/100 {
|
||||
return 0, fmt.Errorf("无效积分数量 %q", value)
|
||||
}
|
||||
return whole*100 + fractionValue, nil
|
||||
}
|
||||
|
||||
// formatPointCents 将整数分值格式化为两位小数的积分字符串。
|
||||
func formatPointCents(value int64) string {
|
||||
return fmt.Sprintf("%d.%02d", value/100, value%100)
|
||||
}
|
||||
|
||||
// lockUser 锁定用户积分余额,避免并发账务操作导致余额和批次不一致。
|
||||
func lockUser(tx *gorm.DB, userID uuid.UUID, requireActive bool) (string, error) {
|
||||
var balance string
|
||||
userQuery := "SELECT point_balance::text FROM web_users WHERE id=? FOR UPDATE"
|
||||
if requireActive {
|
||||
userQuery = "SELECT point_balance::text FROM web_users WHERE id=? AND enabled=true AND deleted_at IS NULL FOR UPDATE"
|
||||
}
|
||||
if err := tx.Raw(userQuery, userID).Scan(&balance).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
if balance == "" {
|
||||
return "", gorm.ErrRecordNotFound
|
||||
}
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
// CreditPoints 创建永久有效的积分批次,并原子写入用户余额和积分流水。
|
||||
func CreditPoints(tx *gorm.DB, userID uuid.UUID, sourceType, sourceID, points, remark string) (string, bool, error) {
|
||||
amount, err := parsePointCents(points)
|
||||
if err != nil || amount <= 0 {
|
||||
return "", false, errors.New("入账积分必须大于零")
|
||||
}
|
||||
balance, err := lockUser(tx, userID, true)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
grantID := uuid.New()
|
||||
result := tx.Exec(`INSERT INTO point_grants(id,user_id,source_type,source_id,granted_amount,available_amount)
|
||||
VALUES(?,?,?,?,?::numeric,?::numeric) ON CONFLICT(source_type,source_id) DO NOTHING`,
|
||||
grantID, userID, sourceType, sourceID, points, points)
|
||||
if result.Error != nil {
|
||||
return "", false, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return balance, false, nil
|
||||
}
|
||||
if err := tx.Raw(`UPDATE web_users SET point_balance=point_balance+?::numeric
|
||||
WHERE id=? RETURNING point_balance::text`, points, userID).Scan(&balance).Error; err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if err := tx.Exec(`INSERT INTO point_ledger(user_id,change_amount,balance_after,business_type,business_id,remark,idempotency_key)
|
||||
VALUES(?,?::numeric,?::numeric,?,?,?,?)`, userID, points, balance, sourceType, sourceID, remark, sourceType+":"+sourceID).Error; err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return balance, true, nil
|
||||
}
|
||||
|
||||
// CreditRedemptionPoints 将兑换码对应积分以永久有效批次计入用户账户。
|
||||
func CreditRedemptionPoints(tx *gorm.DB, userID, codeID uuid.UUID, points string) (string, bool, error) {
|
||||
return CreditPoints(tx, userID, "redemption", codeID.String(), points, "兑换码兑换")
|
||||
}
|
||||
|
||||
// DebitPoints 按批次创建顺序扣减积分,并记录分配明细供后续退款恢复。
|
||||
func DebitPoints(tx *gorm.DB, userID uuid.UUID, points, businessType, businessID, remark, idempotencyKey string) (string, error) {
|
||||
requested, err := parsePointCents(points)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
balance, err := lockUser(tx, userID, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if requested == 0 {
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
var grants []pointGrant
|
||||
if err := tx.Raw(`SELECT id,available_amount::text AS available_amount
|
||||
FROM point_grants WHERE user_id=? AND available_amount>0
|
||||
ORDER BY created_at,id FOR UPDATE`, userID).Scan(&grants).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
remaining := requested
|
||||
type usedGrant struct {
|
||||
id uuid.UUID
|
||||
amount int64
|
||||
}
|
||||
used := make([]usedGrant, 0, len(grants))
|
||||
for _, grant := range grants {
|
||||
available, parseErr := parsePointCents(grant.AvailableAmount)
|
||||
if parseErr != nil {
|
||||
return "", parseErr
|
||||
}
|
||||
use := available
|
||||
if use > remaining {
|
||||
use = remaining
|
||||
}
|
||||
if use > 0 {
|
||||
used = append(used, usedGrant{id: grant.ID, amount: use})
|
||||
remaining -= use
|
||||
}
|
||||
if remaining == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if remaining > 0 {
|
||||
return "", ErrInsufficientPoints
|
||||
}
|
||||
for _, allocation := range used {
|
||||
if err := tx.Exec(`UPDATE point_grants SET available_amount=available_amount-?::numeric WHERE id=?`,
|
||||
formatPointCents(allocation.amount), allocation.id).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := tx.Raw(`UPDATE web_users SET point_balance=point_balance-?::numeric
|
||||
WHERE id=? RETURNING point_balance::text`, points, userID).Scan(&balance).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
var ledgerID int64
|
||||
if err := tx.Raw(`INSERT INTO point_ledger(user_id,change_amount,balance_after,business_type,business_id,remark,idempotency_key)
|
||||
VALUES(?,(-?::numeric),?::numeric,?,?,?,?) RETURNING id`,
|
||||
userID, points, balance, businessType, businessID, remark, idempotencyKey).Scan(&ledgerID).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, allocation := range used {
|
||||
if err := tx.Exec(`INSERT INTO point_ledger_allocations(ledger_id,grant_id,allocation_type,amount)
|
||||
VALUES(?,?,'consume',?::numeric)`, ledgerID, allocation.id, formatPointCents(allocation.amount)).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
// RefundDebit 将原扣减记录恢复到对应的永久积分批次,并写入退款流水。
|
||||
func RefundDebit(tx *gorm.DB, userID uuid.UUID, originalKey, refundKey, businessType, businessID, remark string) (bool, error) {
|
||||
balance, err := lockUser(tx, userID, false)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var existing int64
|
||||
if err := tx.Raw("SELECT count(*) FROM point_ledger WHERE idempotency_key=?", refundKey).Scan(&existing).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if existing > 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var allocations []pointAllocation
|
||||
if err := tx.Raw(`SELECT a.grant_id,a.amount::text AS amount
|
||||
FROM point_ledger original
|
||||
JOIN point_ledger_allocations a ON a.ledger_id=original.id AND a.allocation_type='consume'
|
||||
JOIN point_grants g ON g.id=a.grant_id
|
||||
WHERE original.idempotency_key=? ORDER BY g.created_at,g.id FOR UPDATE OF g`, originalKey).Scan(&allocations).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(allocations) == 0 {
|
||||
return false, errors.New("未找到原积分扣减分配记录")
|
||||
}
|
||||
restored := int64(0)
|
||||
restoredAllocations := make([]pointAllocation, 0, len(allocations))
|
||||
for _, allocation := range allocations {
|
||||
amount, parseErr := parsePointCents(allocation.Amount)
|
||||
if parseErr != nil {
|
||||
return false, parseErr
|
||||
}
|
||||
result := tx.Exec(`UPDATE point_grants SET available_amount=available_amount+?::numeric
|
||||
WHERE id=?`, allocation.Amount, allocation.GrantID)
|
||||
if result.Error != nil {
|
||||
return false, result.Error
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
restored += amount
|
||||
restoredAllocations = append(restoredAllocations, allocation)
|
||||
}
|
||||
}
|
||||
if restored > 0 {
|
||||
if err := tx.Raw(`UPDATE web_users SET point_balance=point_balance+?::numeric
|
||||
WHERE id=? RETURNING point_balance::text`, formatPointCents(restored), userID).Scan(&balance).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
var refundLedgerID int64
|
||||
if err := tx.Raw(`INSERT INTO point_ledger(user_id,change_amount,balance_after,business_type,business_id,remark,idempotency_key)
|
||||
VALUES(?,?::numeric,?::numeric,?,?,?,?) RETURNING id`, userID, formatPointCents(restored), balance,
|
||||
businessType, businessID, remark, refundKey).Scan(&refundLedgerID).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, allocation := range restoredAllocations {
|
||||
if err := tx.Exec(`INSERT INTO point_ledger_allocations(ledger_id,grant_id,allocation_type,amount)
|
||||
VALUES(?,?,'refund',?::numeric)`, refundLedgerID, allocation.GrantID, allocation.Amount).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// 积分账务单元测试,验证积分精度解析和非法输入处理。
|
||||
package billing
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestParsePointCents 验证积分字符串能够稳定转换为整数分值。
|
||||
func TestParsePointCents(t *testing.T) {
|
||||
tests := map[string]int64{
|
||||
"0": 0,
|
||||
"1": 100,
|
||||
"1.2": 120,
|
||||
"1.23": 123,
|
||||
"1.2300": 123,
|
||||
"999.99": 99999,
|
||||
}
|
||||
for input, expected := range tests {
|
||||
actual, err := parsePointCents(input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", input, err)
|
||||
}
|
||||
if actual != expected {
|
||||
t.Fatalf("parse %q: expected %d, got %d", input, expected, actual)
|
||||
}
|
||||
}
|
||||
for _, input := range []string{"", "-1", "1.234", "abc"} {
|
||||
if _, err := parsePointCents(input); err == nil {
|
||||
t.Fatalf("expected %q to be rejected", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCalculateTextPointsRoundsUpToPointCent(t *testing.T) {
|
||||
pricing := TextPricing{Mode: TextBillingPerToken, InputPerM: "100.0000", OutputPerM: "300.0000"}
|
||||
amount, err := CalculateTextPoints(pricing, TextUsage{Input: 120, Output: 30})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if amount != "0.03" {
|
||||
t.Fatalf("expected 0.03 points, got %s", amount)
|
||||
}
|
||||
amount, err = CalculateTextPoints(pricing, TextUsage{Input: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if amount != "0.01" {
|
||||
t.Fatalf("expected minimum positive charge 0.01, got %s", amount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextPricingSnapshot(t *testing.T) {
|
||||
pricing := TextPricing{Mode: TextBillingPerToken, InputPerM: "1250.0000", OutputPerM: "2500.0000"}
|
||||
parsed, err := ParseTextPricingSnapshot(pricing.MarshalSnapshot())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed != pricing {
|
||||
t.Fatalf("unexpected snapshot: %s", json.RawMessage(pricing.MarshalSnapshot()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextPricingSnapshotRejectsLegacyThousandTokenRates(t *testing.T) {
|
||||
_, err := ParseTextPricingSnapshot(json.RawMessage(`{"mode":"per_token","input_per_k":"0.1000","output_per_k":"0.3000","max_output_tokens":1000}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected legacy thousand-token snapshot to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateTextPointsUsesMillionTokens(t *testing.T) {
|
||||
pricing := TextPricing{Mode: TextBillingPerToken, InputPerM: "10", OutputPerM: "20"}
|
||||
amount, err := CalculateTextPoints(pricing, TextUsage{Input: 1_000_000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if amount != "10.00" {
|
||||
t.Fatalf("expected 10.00 points for one million input tokens, got %s", amount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateTextReservePointsUsesDoubleInputAsEstimatedOutput(t *testing.T) {
|
||||
pricing := TextPricing{Mode: TextBillingPerToken, InputPerM: "10", OutputPerM: "20"}
|
||||
amount, err := CalculateTextReservePoints(pricing, 1_000_000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if amount != "50.00" {
|
||||
t.Fatalf("expected 50.00 points for input plus double-input output estimate, got %s", amount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateTextTokens(t *testing.T) {
|
||||
if got := EstimateTextTokens("中文ABCD"); got != 3 {
|
||||
t.Fatalf("expected 3 estimated tokens, got %d", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user