feat(api): 添加多媒体内容支持并优化钱包计费系统

- 在 API 接口定义中为 video_url 和 audio_url 类型添加 mime_type 字段
- 实现 Google Gemini 客户端对视频和音频内容的支持,包括媒体类型检测和数据传输
- 添加 Gemini 客户端测试用例验证多媒体内容转换功能
- 重构 Playground 页面的媒体上传逻辑以支持 MIME 类型传递
- 实现钱包计费预留机制,确保任务执行前余额充足
- 添加钱包冻结余额管理,防止并发操作导致的超扣问题
- 实现计费预留释放逻辑,处理任务失败或取消情况下的资金返还
- 优化数据库事务处理,确保计费操作的原子性和一致性
- 添加数据库集成测试验证迁移脚本执行流程
- 统一 Google Gemini 相关模型提供商标识符映射
This commit is contained in:
2026-05-22 23:46:08 +08:00
parent af9b281d34
commit 8ad5b06c18
12 changed files with 1104 additions and 82 deletions
+68 -25
View File
@@ -3,13 +3,13 @@ package store
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type TaskListFilter struct {
@@ -687,14 +687,15 @@ func (s *Store) SettleTaskBilling(ctx context.Context, task GatewayTask) error {
if currency == "" || currency == "mixed" {
currency = "resource"
}
metadata, _ := json.Marshal(map[string]any{
metadataMap := map[string]any{
"taskId": task.ID,
"kind": task.Kind,
"model": task.Model,
"resolvedModel": task.ResolvedModel,
"billings": task.Billings,
"billingSummary": task.BillingSummary,
})
}
metadata, _ := json.Marshal(metadataMap)
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `
INSERT INTO gateway_wallet_accounts (
@@ -706,42 +707,85 @@ ON CONFLICT (gateway_user_id, currency) DO NOTHING`,
return err
}
var exists bool
var accountID string
var balanceBefore float64
var frozenBefore float64
var gatewayTenantID string
if err := tx.QueryRow(ctx, `
SELECT id::text, balance::float8, frozen_balance::float8, COALESCE(gateway_tenant_id::text, '')
FROM gateway_wallet_accounts
WHERE gateway_user_id = $1::uuid
AND currency = $2
FOR UPDATE`, task.GatewayUserID, currency).Scan(&accountID, &balanceBefore, &frozenBefore, &gatewayTenantID); err != nil {
return err
}
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM gateway_wallet_transactions t
JOIN gateway_wallet_accounts a ON a.id = t.account_id
WHERE a.gateway_user_id = $1::uuid
AND a.currency = $2
AND t.idempotency_key = $3
)`, task.GatewayUserID, currency, billingIdempotencyKey(task.ID)).Scan(&exists); err != nil {
FROM gateway_wallet_transactions
WHERE account_id = $1::uuid
AND idempotency_key = $2
)`, accountID, billingIdempotencyKey(task.ID)).Scan(&exists); err != nil {
return err
}
if exists {
return nil
}
var accountID string
var balanceBefore float64
var gatewayTenantID string
if err := tx.QueryRow(ctx, `
SELECT id::text, balance::float8, COALESCE(gateway_tenant_id::text, '')
FROM gateway_wallet_accounts
WHERE gateway_user_id = $1::uuid
AND currency = $2
FOR UPDATE`, task.GatewayUserID, currency).Scan(&accountID, &balanceBefore, &gatewayTenantID); err != nil {
amount := roundMoney(task.FinalChargeAmount)
reservationKey, reservedAmount, err := activeWalletReservation(ctx, tx, accountID, task.ID)
if err != nil {
return err
}
amount := roundMoney(task.FinalChargeAmount)
reservedAmount = roundMoney(reservedAmount)
spendableForTask := roundMoney(balanceBefore - frozenBefore + reservedAmount)
if spendableForTask+0.000001 < amount {
return fmt.Errorf("%w: required %.6f %s, available %.6f", ErrInsufficientWalletBalance, amount, currency, spendableForTask)
}
balanceAfter := roundMoney(balanceBefore - amount)
frozenAfter := roundMoney(frozenBefore - reservedAmount)
if frozenAfter < 0 {
frozenAfter = 0
}
if _, err := tx.Exec(ctx, `
UPDATE gateway_wallet_accounts
SET balance = $2,
total_spent = total_spent + $3,
frozen_balance = $4,
updated_at = now()
WHERE id = $1::uuid`, accountID, balanceAfter, amount); err != nil {
WHERE id = $1::uuid`, accountID, balanceAfter, amount, frozenAfter); err != nil {
return err
}
_, err := tx.Exec(ctx, `
if reservedAmount > 0 {
releaseMetadata, _ := json.Marshal(map[string]any{
"taskId": task.ID,
"reason": "task_billing_settled",
"reserved": reservedAmount,
"frozenBefore": roundMoney(frozenBefore),
"frozenAfter": frozenAfter,
})
if _, err := tx.Exec(ctx, `
INSERT INTO gateway_wallet_transactions (
account_id, gateway_tenant_id, gateway_user_id, direction, transaction_type,
amount, balance_before, balance_after, idempotency_key, reference_type, reference_id, metadata
)
VALUES (
$1::uuid, NULLIF($2, '')::uuid, $3::uuid, 'credit', 'release',
$4, $5, $6, $7, 'gateway_task', $8, $9::jsonb
)
ON CONFLICT (account_id, idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING`,
accountID, firstNonEmpty(gatewayTenantID, task.GatewayTenantID), task.GatewayUserID, reservedAmount, roundMoney(balanceBefore), roundMoney(balanceBefore), billingReservationReleaseIdempotencyKey(reservationKey), task.ID, string(releaseMetadata)); err != nil {
return err
}
}
billingMetadata := mergeObjects(metadataMap, map[string]any{
"reservedAmount": reservedAmount,
"frozenBefore": roundMoney(frozenBefore),
"frozenAfter": frozenAfter,
})
metadata, _ = json.Marshal(billingMetadata)
if _, err := tx.Exec(ctx, `
INSERT INTO gateway_wallet_transactions (
account_id, gateway_tenant_id, gateway_user_id, direction, transaction_type,
amount, balance_before, balance_after, idempotency_key, reference_type, reference_id, metadata
@@ -750,11 +794,10 @@ VALUES (
$1::uuid, NULLIF($2, '')::uuid, $3::uuid, 'debit', 'task_billing',
$4, $5, $6, $7, 'gateway_task', $8, $9::jsonb
)`,
accountID, firstNonEmpty(gatewayTenantID, task.GatewayTenantID), task.GatewayUserID, amount, roundMoney(balanceBefore), balanceAfter, billingIdempotencyKey(task.ID), task.ID, string(metadata))
if pgErr, ok := err.(*pgconn.PgError); ok && pgErr.Code == "23505" {
return nil
accountID, firstNonEmpty(gatewayTenantID, task.GatewayTenantID), task.GatewayUserID, amount, roundMoney(balanceBefore), balanceAfter, billingIdempotencyKey(task.ID), task.ID, string(metadata)); err != nil {
return err
}
return err
return nil
})
}