From e4ec55ccf2f84db28f83f70b45a3c304f6e0575a Mon Sep 17 00:00:00 2001 From: qian Date: Wed, 20 May 2026 12:42:13 +0800 Subject: [PATCH] =?UTF-8?q?refactor(wallet):=E9=87=8D=E6=9E=84=E9=92=B1?= =?UTF-8?q?=E5=8C=85=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E5=86=BB=E7=BB=93=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/tenant/tenant.go | 1 + api/tenant/v1/wallet_tenant.go | 19 + .../tenant_v1_tenant_wallet_frozen_items.go | 12 + internal/handler/relay/task.go | 44 ++ internal/logic/billing/provider.go | 2 +- internal/logic/billing/settlement.go | 2 + internal/logic/billing/task_billing.go | 16 +- internal/logic/billing/wallet.go | 103 +++- internal/logic/payment/fulfill.go | 44 +- internal/logic/task/async_polling.go | 17 +- internal/logic/tenant/billing.go | 28 ++ internal/logic/tenant/redemption.go | 48 +- internal/service/tenant.go | 2 + relay/common/task_billing.go | 2 +- relay/handler/task_handler.go | 21 +- web/tenant/src/router/tenant.ts | 12 +- web/tenant/src/views/Layout.vue | 30 ++ web/tenant/src/views/finance/PlansPage.vue | 248 ---------- .../src/views/finance/TransactionsPage.vue | 158 +++++++ web/tenant/src/views/finance/WalletPage.vue | 447 +++++++++++++----- 20 files changed, 819 insertions(+), 437 deletions(-) create mode 100644 internal/controller/tenant/tenant_v1_tenant_wallet_frozen_items.go delete mode 100644 web/tenant/src/views/finance/PlansPage.vue create mode 100644 web/tenant/src/views/finance/TransactionsPage.vue diff --git a/api/tenant/tenant.go b/api/tenant/tenant.go index a3d90c2..7c6e4a2 100644 --- a/api/tenant/tenant.go +++ b/api/tenant/tenant.go @@ -166,4 +166,5 @@ type ITenantV1 interface { TenantWallet(ctx context.Context, req *v1.TenantWalletReq) (res *v1.TenantWalletRes, err error) TenantWalletTransactions(ctx context.Context, req *v1.TenantWalletTransactionsReq) (res *v1.TenantWalletTransactionsRes, err error) TenantWalletTransactionsExport(ctx context.Context, req *v1.TenantWalletTransactionsExportReq) (res *v1.TenantWalletTransactionsExportRes, err error) + TenantWalletFrozenItems(ctx context.Context, req *v1.TenantWalletFrozenItemsReq) (res *v1.TenantWalletFrozenItemsRes, err error) } diff --git a/api/tenant/v1/wallet_tenant.go b/api/tenant/v1/wallet_tenant.go index eb8bc92..357307c 100644 --- a/api/tenant/v1/wallet_tenant.go +++ b/api/tenant/v1/wallet_tenant.go @@ -36,3 +36,22 @@ type TenantWalletTransactionsExportReq struct { } type TenantWalletTransactionsExportRes struct{} + +// TenantWalletFrozenItemsReq 冻结明细请求 +type TenantWalletFrozenItemsReq struct { + g.Meta `path:"/wallet/frozen-items" method:"get" mime:"json" tags:"租户控制台-钱包" summary:"冻结明细"` +} + +// TenantWalletFrozenItemsRes 冻结明细响应 +type TenantWalletFrozenItemsRes struct { + Items []FrozenItemDetail `json:"items"` +} + +// FrozenItemDetail 单个冻结项 +type FrozenItemDetail struct { + RequestID string `json:"request_id"` + ModelName string `json:"model_name"` + Amount float64 `json:"amount"` + CreatedAt int64 `json:"created_at"` + Remaining int64 `json:"remaining"` +} diff --git a/internal/controller/tenant/tenant_v1_tenant_wallet_frozen_items.go b/internal/controller/tenant/tenant_v1_tenant_wallet_frozen_items.go new file mode 100644 index 0000000..01dc052 --- /dev/null +++ b/internal/controller/tenant/tenant_v1_tenant_wallet_frozen_items.go @@ -0,0 +1,12 @@ +package tenant + +import ( + "context" + + v1 "github.com/qianfree/team-api/api/tenant/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) TenantWalletFrozenItems(ctx context.Context, req *v1.TenantWalletFrozenItemsReq) (res *v1.TenantWalletFrozenItemsRes, err error) { + return service.Tenant().WalletFrozenItems(ctx, req) +} diff --git a/internal/handler/relay/task.go b/internal/handler/relay/task.go index 004bb8a..559bf03 100644 --- a/internal/handler/relay/task.go +++ b/internal/handler/relay/task.go @@ -13,6 +13,7 @@ import ( "github.com/qianfree/team-api/internal/logic/task" "github.com/qianfree/team-api/internal/middleware" relay_common "github.com/qianfree/team-api/relay/common" + relay_constant "github.com/qianfree/team-api/relay/constant" relay_handler "github.com/qianfree/team-api/relay/handler" ) @@ -75,6 +76,9 @@ func HandleTaskSubmit(r *ghttp.Request) { rc, taskDataProvider, taskBillingProvider, channelMeta, ) + // 构建转发路径追踪(任务提交阶段只有一次渠道选择) + rc.ForwardingTrace = buildTaskForwardingTrace(r.URL.Path, body, channelMeta, capture.StatusCode()) + // 异步记录审计日志 go recordTaskSubmitAudit(r, rc, capture, body) } @@ -205,6 +209,7 @@ func HandleMjSubmit(r *ghttp.Request) { rc, taskDataProvider, taskBillingProvider, channelMeta, ) + rc.ForwardingTrace = buildTaskForwardingTrace(r.URL.Path, body, channelMeta, capture.StatusCode()) go recordTaskSubmitAudit(r, rc, capture, body) } @@ -325,5 +330,44 @@ func recordTaskSubmitAudit(r *ghttp.Request, rc *relay_handler.TaskRelayContext, ResponseHeaders: capture.ResponseHeaders(), TaskID: rc.TaskID, TaskStatus: "SUBMITTED", + ForwardingTrace: rc.ForwardingTrace, }) } + +// buildTaskForwardingTrace 从渠道选择结果构建转发路径追踪 +func buildTaskForwardingTrace(path string, body []byte, channelMeta *relay_common.ChannelMeta, statusCode int) *relay_common.ForwardingTrace { + var modelName string + var req struct { + Model string `json:"model"` + } + if json.Unmarshal(body, &req) == nil { + modelName = req.Model + } + + providerName := "" + if pt := relay_constant.ProviderType(channelMeta.ChannelType); pt > 0 { + providerName = pt.String() + } + + return &relay_common.ForwardingTrace{ + EntryPath: path, + EntryFormat: "openai", + RequestedModel: modelName, + UpstreamModel: channelMeta.UpstreamModelName, + ModelMapped: channelMeta.IsModelMapped, + TotalAttempts: 1, + Hops: []relay_common.ForwardingHop{ + { + Attempt: 1, + ChannelID: channelMeta.ChannelID, + ChannelName: channelMeta.ChannelName, + ChannelType: channelMeta.ChannelType, + Provider: providerName, + BaseURL: channelMeta.BaseURL, + UpstreamModel: channelMeta.UpstreamModelName, + ModelMapped: channelMeta.IsModelMapped, + Success: statusCode == 200, + }, + }, + } +} diff --git a/internal/logic/billing/provider.go b/internal/logic/billing/provider.go index 581930b..da24c2c 100644 --- a/internal/logic/billing/provider.go +++ b/internal/logic/billing/provider.go @@ -20,7 +20,7 @@ func (b *BillingProviderImpl) PreDeduct(ctx context.Context, tenantID int64, mod return 0, err } - ok, err := PreDeduct(ctx, tenantID, amount, requestID) + ok, err := PreDeduct(ctx, tenantID, amount, requestID, modelName) if !ok { return 0, err } diff --git a/internal/logic/billing/settlement.go b/internal/logic/billing/settlement.go index 1fd31f6..f4d3a90 100644 --- a/internal/logic/billing/settlement.go +++ b/internal/logic/billing/settlement.go @@ -71,6 +71,7 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, // 清除钱包缓存(内存 + Redis) walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID)) InvalidateWalletRedis(ctx, tenantID) + CleanupPreDeduct(ctx, tenantID, requestID) // 5. 创建计费记录(获取定价快照) pricingResult, _ := GetModelPrice(ctx, tenantID, modelName) @@ -146,6 +147,7 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID)) InvalidateWalletRedis(ctx, tenantID) + CleanupPreDeduct(ctx, tenantID, requestID) // 5. 获取定价信息 pricingResult, _ := GetModelPrice(ctx, tenantID, modelName) diff --git a/internal/logic/billing/task_billing.go b/internal/logic/billing/task_billing.go index 3263222..29ec9b0 100644 --- a/internal/logic/billing/task_billing.go +++ b/internal/logic/billing/task_billing.go @@ -67,8 +67,8 @@ func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID } // PreDeductTask 预扣任务费用 -func (b *TaskBillingProviderImpl) PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost float64) (float64, error) { - ok, err := PreDeduct(ctx, tenantID, estimatedCost, requestID) +func (b *TaskBillingProviderImpl) PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost float64, modelName string) (float64, error) { + ok, err := PreDeduct(ctx, tenantID, estimatedCost, requestID, modelName) if !ok { return 0, fmt.Errorf("pre-deduct task failed: %w", err) } @@ -97,6 +97,8 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI walletCache.Delete(ctx, fmt.Sprintf("%d", tenantID)) InvalidateWalletRedis(ctx, tenantID) + CleanupPreDeduct(ctx, tenantID, requestID) + CleanupPreDeduct(ctx, tenantID, requestID+"_adjust") // 2. 获取定价 + 构建 CostBreakdown pricing, _ := GetModelPrice(ctx, tenantID, modelName) @@ -176,12 +178,8 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI result.RateMultiplier = pricing.DiscountRatio } - // 5. 差额处理 - if diff > 0.001 { - if ok, err := PreDeduct(ctx, tenantID, diff, requestID+"_adjust"); !ok { - return result, fmt.Errorf("settle task adjust pre-deduct: %w", err) - } - } else if diff < -0.001 { + // 5. 差额处理:实际 < 预扣时退还差额(实际 > 预扣时已在步骤 1 扣完,无需额外操作) + if diff < -0.001 { if err := SettleFailed(ctx, tenantID, requestID+"_adjust", -diff); err != nil { return result, fmt.Errorf("settle task adjust refund: %w", err) } @@ -247,7 +245,7 @@ func (b *TaskBillingProviderImpl) AdjustTaskBilling(ctx context.Context, tenantI if diff > 0 { // 需要补扣 - ok, err := PreDeduct(ctx, tenantID, diff, requestID+"_adjust") + ok, err := PreDeduct(ctx, tenantID, diff, requestID+"_adjust", "") if !ok { return preDeductAmount, fmt.Errorf("adjust task billing: %w", err) } diff --git a/internal/logic/billing/wallet.go b/internal/logic/billing/wallet.go index c3affe7..f226a9f 100644 --- a/internal/logic/billing/wallet.go +++ b/internal/logic/billing/wallet.go @@ -8,6 +8,7 @@ import ( "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" "github.com/qianfree/team-api/internal/dao" lcommon "github.com/qianfree/team-api/internal/logic/common" @@ -123,7 +124,7 @@ func CheckBalance(ctx context.Context, tenantID int64, amount float64) error { // PreDeduct 预扣费用(Redis Lua 原子操作) // 冻结指定金额,返回预扣记录 ID 用于后续结算 -func PreDeduct(ctx context.Context, tenantID int64, amount float64, requestID string) (bool, error) { +func PreDeduct(ctx context.Context, tenantID int64, amount float64, requestID string, modelName string) (bool, error) { if amount <= 0 { return true, nil } @@ -140,6 +141,9 @@ func PreDeduct(ctx context.Context, tenantID int64, amount float64, requestID st // ARGV[1] = amount // ARGV[2] = request_id // ARGV[3] = ttl (PreDeductMaxAge) + // ARGV[4] = tenant_id + // ARGV[5] = model_name + // ARGV[6] = created_at (unix timestamp) luaScript := ` local wallet_key = KEYS[1] local prededuct_key = KEYS[2] @@ -167,6 +171,9 @@ end redis.call("HINCRBYFLOAT", wallet_key, "frozen_balance", amount) redis.call("HSET", prededuct_key, "amount", amount) redis.call("HSET", prededuct_key, "tenant_id", ARGV[4]) +redis.call("HSET", prededuct_key, "model_name", ARGV[5]) +redis.call("HSET", prededuct_key, "created_at", ARGV[6]) +redis.call("SADD", "prededuct_active:" .. ARGV[4], request_id) redis.call("EXPIRE", prededuct_key, ttl) return 1 ` @@ -176,7 +183,7 @@ return 1 result, err := g.Redis().Do(ctx, "EVAL", luaScript, 2, walletRedisKey, predeductRedisKey, - amount, requestID, PreDeductMaxAge, tenantID) + amount, requestID, PreDeductMaxAge, tenantID, modelName, time.Now().Unix()) if err != nil { // Redis 不可用,降级到 DB 直接扣减 return preDeductDB(ctx, tenantID, amount, requestID) @@ -258,12 +265,14 @@ func UnfreezePreDeduct(ctx context.Context, tenantID int64, requestID string, am predeductRedisKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, requestID) walletRedisKey := fmt.Sprintf("wallet:%d", tenantID) + activeSetKey := fmt.Sprintf("prededuct_active:%d", tenantID) // 先尝试 Redis 解冻 _, err := g.Redis().Do(ctx, "DEL", predeductRedisKey) if err == nil { // 解冻 g.Redis().Do(ctx, "HINCRBYFLOAT", walletRedisKey, "frozen_balance", -amount) + g.Redis().Do(ctx, "SREM", activeSetKey, requestID) go unfreezeSyncDB(tenantID, amount) return } @@ -392,3 +401,93 @@ func InvalidateWalletRedis(ctx context.Context, tenantID int64) { walletRedisKey := fmt.Sprintf("wallet:%d", tenantID) g.Redis().Do(ctx, "DEL", walletRedisKey) } + +// CleanupPreDeduct 清理预扣记录(结算成功后调用) +func CleanupPreDeduct(ctx context.Context, tenantID int64, requestID string) { + predeductRedisKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, requestID) + activeSetKey := fmt.Sprintf("prededuct_active:%d", tenantID) + g.Redis().Do(ctx, "DEL", predeductRedisKey) + g.Redis().Do(ctx, "SREM", activeSetKey, requestID) +} + +// FrozenItem 单个冻结项 +type FrozenItem struct { + RequestID string `json:"request_id"` + ModelName string `json:"model_name"` + Amount float64 `json:"amount"` + CreatedAt int64 `json:"created_at"` + Remaining int64 `json:"remaining"` +} + +// GetFrozenItems 获取租户当前所有冻结项 +func GetFrozenItems(ctx context.Context, tenantID int64) ([]FrozenItem, error) { + activeSetKey := fmt.Sprintf("prededuct_active:%d", tenantID) + + members, err := g.Redis().Do(ctx, "SMEMBERS", activeSetKey) + if err != nil || members.IsNil() { + return []FrozenItem{}, nil + } + + requestIDs := members.Strings() + var items []FrozenItem + var staleIDs []string + + for _, reqID := range requestIDs { + predeductKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, reqID) + + exists, _ := g.Redis().Do(ctx, "EXISTS", predeductKey) + if exists.Int64() == 0 { + staleIDs = append(staleIDs, reqID) + continue + } + + data, err := g.Redis().Do(ctx, "HGETALL", predeductKey) + if err != nil || data.IsNil() { + staleIDs = append(staleIDs, reqID) + continue + } + + m := data.Map() + + ttl, _ := g.Redis().Do(ctx, "TTL", predeductKey) + remaining := ttl.Int64() + if remaining < 0 { + remaining = 0 + } + + var amount float64 + if v, ok := m["amount"]; ok { + amount = gconv.Float64(v) + } + + var modelName string + if v, ok := m["model_name"]; ok { + modelName = gconv.String(v) + } + + var createdAt int64 + if v, ok := m["created_at"]; ok { + createdAt = gconv.Int64(v) + } + + items = append(items, FrozenItem{ + RequestID: reqID, + ModelName: modelName, + Amount: amount, + CreatedAt: createdAt, + Remaining: remaining, + }) + } + + // 清理过期条目(TTL 已过但仍在 set 中的残留) + if len(staleIDs) > 0 { + args := make([]any, 0, len(staleIDs)+1) + args = append(args, activeSetKey) + for _, id := range staleIDs { + args = append(args, id) + } + g.Redis().Do(ctx, "SREM", args...) + } + + return items, nil +} diff --git a/internal/logic/payment/fulfill.go b/internal/logic/payment/fulfill.go index 35e74e4..b27028f 100644 --- a/internal/logic/payment/fulfill.go +++ b/internal/logic/payment/fulfill.go @@ -5,6 +5,7 @@ import ( "fmt" do "github.com/qianfree/team-api/internal/model/do" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" @@ -131,22 +132,41 @@ func creditWallet(ctx context.Context, tenantID int64, amount float64, descripti return gerror.Newf("wallet not found for tenant %d", tenantID) } - _, err = g.DB().Exec(ctx, - "UPDATE bil_wallets SET balance = balance + ?, updated_at = NOW() WHERE id = ?", - amount, w.ID) + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + _, err := tx.Ctx(ctx).Exec( + "UPDATE bil_wallets SET balance = balance + ?, updated_at = NOW() WHERE id = ?", + amount, w.ID) + if err != nil { + return err + } + + var balance struct { + Balance float64 `json:"balance"` + FrozenBalance float64 `json:"frozen_balance"` + } + err = tx.Model("bil_wallets").Ctx(ctx). + Where("id", w.ID). + Fields("balance, frozen_balance"). + Scan(&balance) + if err != nil { + return err + } + + _, err = tx.Model("bil_transactions").Ctx(ctx).Insert(do.BilTransactions{ + TenantId: tenantID, + WalletId: w.ID, + Type: "recharge", + Amount: amount, + BalanceAfter: balance.Balance, + FrozenAfter: balance.FrozenBalance, + Description: description, + }) + return err + }) if err != nil { return err } - // 记录流水 - dao.BilTransactions.Ctx(ctx).Insert(do.BilTransactions{ - TenantId: tenantID, - WalletId: w.ID, - Type: "recharge", - Amount: amount, - Description: description, - }) - // 清除 Redis 钱包缓存,下次预扣时从 DB 重新同步 billing.InvalidateWalletRedis(ctx, tenantID) diff --git a/internal/logic/task/async_polling.go b/internal/logic/task/async_polling.go index b2c4aee..5277fd3 100644 --- a/internal/logic/task/async_polling.go +++ b/internal/logic/task/async_polling.go @@ -102,9 +102,10 @@ func handleTimedOutTasks(ctx context.Context) { // 退还预扣费用 if t.PreDeductAmount > 0 { taskBilling := billing.NewTaskBillingProvider() - if err := taskBilling.SettleTaskFailed(ctx, t.TenantID, t.PublicTaskID, t.PreDeductAmount); err != nil { + if err := taskBilling.SettleTaskFailed(ctx, t.TenantID, t.RequestID, t.PreDeductAmount); err != nil { g.Log().Warningf(ctx, "poll: refund timed-out task %s: %v", t.PublicTaskID, err) } + billing.CleanupPreDeduct(ctx, t.TenantID, t.RequestID+"_adjust") } g.Log().Infof(ctx, "poll: task %s timed out", t.PublicTaskID) @@ -127,12 +128,13 @@ func handleUnsettledTasks(ctx context.Context) { // 无法恢复,直接退还预扣 g.Log().Warningf(ctx, "poll: unsettled task %s has invalid private_data, refunding", t.PublicTaskID) taskBilling := billing.NewTaskBillingProvider() - if err := taskBilling.SettleTaskFailed(ctx, t.TenantID, t.PublicTaskID, t.PreDeductAmount); err != nil { + if err := taskBilling.SettleTaskFailed(ctx, t.TenantID, t.RequestID, t.PreDeductAmount); err != nil { g.Log().Errorf(ctx, "poll: refund unsettled task %s: %v", t.PublicTaskID, err) } else { t.BillingSettled = true DefaultAsyncProvider.UpdateTask(ctx, t) } + billing.CleanupPreDeduct(ctx, t.TenantID, t.RequestID+"_adjust") continue } @@ -144,7 +146,7 @@ func handleUnsettledTasks(ctx context.Context) { } taskBilling := billing.NewTaskBillingProvider() _, err := taskBilling.SettleTaskSuccess(ctx, t.TenantID, t.UserID, t.ApiKeyID, t.ChannelID, - t.ModelName, t.PublicTaskID, actualCost, t.PreDeductAmount, + t.ModelName, t.RequestID, actualCost, t.PreDeductAmount, 0, 0, pd.BillingContext.Ratios) if err != nil { g.Log().Warningf(ctx, "poll: retry settle task %s: %v", t.PublicTaskID, err) @@ -157,13 +159,14 @@ func handleUnsettledTasks(ctx context.Context) { } else { // 失败任务:退还预扣 taskBilling := billing.NewTaskBillingProvider() - if err := taskBilling.SettleTaskFailed(ctx, t.TenantID, t.PublicTaskID, t.PreDeductAmount); err != nil { + if err := taskBilling.SettleTaskFailed(ctx, t.TenantID, t.RequestID, t.PreDeductAmount); err != nil { g.Log().Warningf(ctx, "poll: retry refund task %s: %v", t.PublicTaskID, err) } else { t.BillingSettled = true DefaultAsyncProvider.UpdateTask(ctx, t) g.Log().Infof(ctx, "poll: retried refund for task %s", t.PublicTaskID) } + billing.CleanupPreDeduct(ctx, t.TenantID, t.RequestID+"_adjust") } } } @@ -305,13 +308,14 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co } task.ActualCost = actualCost - settleResult, err = taskBilling.SettleTaskSuccess(ctx, task.TenantID, task.UserID, task.ApiKeyID, task.ChannelID, task.ModelName, task.PublicTaskID, actualCost, task.PreDeductAmount, taskInfo.TotalTokens, taskInfo.CompletionTokens, pd.BillingContext.Ratios) + settleResult, err = taskBilling.SettleTaskSuccess(ctx, task.TenantID, task.UserID, task.ApiKeyID, task.ChannelID, task.ModelName, task.RequestID, actualCost, task.PreDeductAmount, taskInfo.TotalTokens, taskInfo.CompletionTokens, pd.BillingContext.Ratios) if err != nil { g.Log().Warningf(ctx, "poll: settle task %s: %v", task.PublicTaskID, err) } else { task.BillingSettled = true DefaultAsyncProvider.UpdateTask(ctx, task) } + billing.CleanupPreDeduct(ctx, task.TenantID, task.RequestID+"_adjust") } g.Log().Infof(ctx, "poll: task %s completed", task.PublicTaskID) @@ -325,12 +329,13 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co // 退还预扣费用 if task.PreDeductAmount > 0 && !task.BillingSettled { taskBilling := billing.NewTaskBillingProvider() - if err := taskBilling.SettleTaskFailed(ctx, task.TenantID, task.PublicTaskID, task.PreDeductAmount); err != nil { + if err := taskBilling.SettleTaskFailed(ctx, task.TenantID, task.RequestID, task.PreDeductAmount); err != nil { g.Log().Warningf(ctx, "poll: refund failed task %s: %v", task.PublicTaskID, err) } else { task.BillingSettled = true DefaultAsyncProvider.UpdateTask(ctx, task) } + billing.CleanupPreDeduct(ctx, task.TenantID, task.RequestID+"_adjust") } g.Log().Infof(ctx, "poll: task %s failed: %s", task.PublicTaskID, task.FailReason) diff --git a/internal/logic/tenant/billing.go b/internal/logic/tenant/billing.go index 87bab6d..5d76878 100644 --- a/internal/logic/tenant/billing.go +++ b/internal/logic/tenant/billing.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/logic/billing" "github.com/qianfree/team-api/internal/logic/common" do "github.com/qianfree/team-api/internal/model/do" "strconv" @@ -537,3 +538,30 @@ func (s *sTenant) ExportWalletTransactions(ctx context.Context, req *v1.TenantWa } }) } + +// WalletFrozenItems 获取冻结明细 +func (s *sTenant) WalletFrozenItems(ctx context.Context, req *v1.TenantWalletFrozenItemsReq) (*v1.TenantWalletFrozenItemsRes, error) { + role := ctxUserRole(ctx) + if role != "owner" && role != "admin" { + return nil, common.NewForbiddenError("需要 owner 或 admin 权限") + } + tenantID := ctxTenantID(ctx) + + items, err := billing.GetFrozenItems(ctx, tenantID) + if err != nil { + return nil, err + } + + details := make([]v1.FrozenItemDetail, 0, len(items)) + for _, item := range items { + details = append(details, v1.FrozenItemDetail{ + RequestID: item.RequestID, + ModelName: item.ModelName, + Amount: item.Amount, + CreatedAt: item.CreatedAt, + Remaining: item.Remaining, + }) + } + + return &v1.TenantWalletFrozenItemsRes{Items: details}, nil +} diff --git a/internal/logic/tenant/redemption.go b/internal/logic/tenant/redemption.go index 7a86a32..8876c4a 100644 --- a/internal/logic/tenant/redemption.go +++ b/internal/logic/tenant/redemption.go @@ -7,6 +7,7 @@ import ( do "github.com/qianfree/team-api/internal/model/do" "time" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" @@ -133,18 +134,43 @@ func creditWalletForRedemption(ctx context.Context, tenantID int64, amount float return 0 } - g.DB().Exec(ctx, - "UPDATE bil_wallets SET balance = balance + ?, updated_at = ? WHERE id = ?", - amount, time.Now(), w.ID) + var txID int64 + err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + _, err := tx.Ctx(ctx).Exec( + "UPDATE bil_wallets SET balance = balance + ?, updated_at = NOW() WHERE id = ?", + amount, w.ID) + if err != nil { + return err + } - txID, err := dao.BilTransactions.Ctx(ctx).InsertAndGetId(do.BilTransactions{ - TenantId: tenantID, - WalletId: w.ID, - Type: "recharge", - Amount: amount, - RelatedId: redemptionID, - RelatedType: "redemption", - Description: fmt.Sprintf("兑换码充值"), + var balance struct { + Balance float64 `json:"balance"` + FrozenBalance float64 `json:"frozen_balance"` + } + err = tx.Model("bil_wallets").Ctx(ctx). + Where("id", w.ID). + Fields("balance, frozen_balance"). + Scan(&balance) + if err != nil { + return err + } + + id, err := tx.Model("bil_transactions").Ctx(ctx).InsertAndGetId(do.BilTransactions{ + TenantId: tenantID, + WalletId: w.ID, + Type: "recharge", + Amount: amount, + BalanceAfter: balance.Balance, + FrozenAfter: balance.FrozenBalance, + RelatedId: redemptionID, + RelatedType: "redemption", + Description: "兑换码充值", + }) + if err != nil { + return err + } + txID = id + return nil }) if err != nil { return 0 diff --git a/internal/service/tenant.go b/internal/service/tenant.go index 99751c2..04151c7 100644 --- a/internal/service/tenant.go +++ b/internal/service/tenant.go @@ -65,6 +65,8 @@ type ( ExportUsageLogs(ctx context.Context, req *v1.TenantUsageLogsExportReq) (*v1.TenantUsageLogsExportRes, error) // ExportWalletTransactions exports the tenant wallet transactions as CSV or Excel. ExportWalletTransactions(ctx context.Context, req *v1.TenantWalletTransactionsExportReq) (*v1.TenantWalletTransactionsExportRes, error) + // WalletFrozenItems 获取冻结明细 + WalletFrozenItems(ctx context.Context, req *v1.TenantWalletFrozenItemsReq) (*v1.TenantWalletFrozenItemsRes, error) // Dashboard returns the tenant dashboard statistics. Dashboard(ctx context.Context, req *v1.TenantDashboardReq) (*v1.TenantDashboardRes, error) // TokenTrends returns daily token usage for the past N days. diff --git a/relay/common/task_billing.go b/relay/common/task_billing.go index 30efb6c..053c11f 100644 --- a/relay/common/task_billing.go +++ b/relay/common/task_billing.go @@ -9,7 +9,7 @@ type TaskBillingProvider interface { EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]float64) (float64, error) // PreDeductTask 预扣任务费用 - PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost float64) (float64, error) + PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost float64, modelName string) (float64, error) // SettleTaskSuccess 任务成功结算(含计费快照) SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount float64, totalTokens, completionTokens int, ratios map[string]float64) (*SettlementResult, error) diff --git a/relay/handler/task_handler.go b/relay/handler/task_handler.go index 60740d5..0f15cbd 100644 --- a/relay/handler/task_handler.go +++ b/relay/handler/task_handler.go @@ -24,15 +24,16 @@ import ( // TaskRelayContext 异步任务 relay 上下文 type TaskRelayContext struct { - TenantID int64 - UserID int64 - ApiKeyID int64 - ProjectID int64 - TaskID string // 由 HandleTaskSubmit 在创建任务后设置 - RequestID string - Writer http.ResponseWriter - Scope string - ClientIP string + TenantID int64 + UserID int64 + ApiKeyID int64 + ProjectID int64 + TaskID string // 由 HandleTaskSubmit 在创建任务后设置 + RequestID string + Writer http.ResponseWriter + Scope string + ClientIP string + ForwardingTrace *common.ForwardingTrace } // HandleTaskSubmit 异步任务提交管线 @@ -116,7 +117,7 @@ func HandleTaskSubmit( } g.Log().Debugf(ctx, "HandleTaskSubmit: estimatedCost=%.4f", estimatedCost) - preDeductAmount, err := billingProvider.PreDeductTask(ctx, rc.TenantID, rc.RequestID, estimatedCost) + preDeductAmount, err := billingProvider.PreDeductTask(ctx, rc.TenantID, rc.RequestID, estimatedCost, modelName) if err != nil { g.Log().Warningf(ctx, "HandleTaskSubmit: pre-deduct failed, model=%s, err=%v", modelName, err) writeTaskError(rc.Writer, http.StatusPaymentRequired, "insufficient balance", "") diff --git a/web/tenant/src/router/tenant.ts b/web/tenant/src/router/tenant.ts index 620abdd..84124d5 100644 --- a/web/tenant/src/router/tenant.ts +++ b/web/tenant/src/router/tenant.ts @@ -134,22 +134,22 @@ const tenantRoutes: RouteRecordRaw[] = [ meta: { title: '钱包', icon: 'creditCard', sort: 40, roles: ['owner', 'admin'] }, }, { - path: 'plans', - name: 'TenantPlans', - component: () => import('@/views/finance/PlansPage.vue'), - meta: { title: '套餐方案', icon: 'gift', sort: 41, roles: ['owner', 'admin'] }, + path: 'transactions', + name: 'TenantTransactions', + component: () => import('@/views/finance/TransactionsPage.vue'), + meta: { title: '交易记录', icon: 'document', sort: 41, roles: ['owner', 'admin'] }, }, { path: 'orders', name: 'TenantOrders', component: () => import('@/views/finance/OrdersPage.vue'), - meta: { title: '订单记录', icon: 'document', sort: 42, roles: ['owner', 'admin'] }, + meta: { title: '订单记录', icon: 'receipt', sort: 42, roles: ['owner', 'admin'] }, }, { path: 'redeem', name: 'TenantRedeem', component: () => import('@/views/finance/RedeemPage.vue'), - meta: { title: '兑换码', icon: 'receipt', sort: 43, roles: ['owner', 'admin'] }, + meta: { title: '兑换码', icon: 'gift', sort: 43, roles: ['owner', 'admin'] }, }, // 沟通 (50-59) { diff --git a/web/tenant/src/views/Layout.vue b/web/tenant/src/views/Layout.vue index b4965ba..30dd6c2 100644 --- a/web/tenant/src/views/Layout.vue +++ b/web/tenant/src/views/Layout.vue @@ -20,6 +20,8 @@ const mobileOpen = ref(false) const userMenuOpen = ref(false) const consoleAnnouncements = ref([]) let announcementTimer: ReturnType | null = null +const walletBalance = ref('') +let walletTimer: ReturnType | null = null const { unreadCount, startPolling: startNotificationPolling, stopPolling: stopNotificationPolling, setOnNewNotification } = useNotificationCount() setOnNewNotification((newCount: number) => { if (newCount > 0) { @@ -129,12 +131,27 @@ async function fetchAnnouncements() { } } +async function fetchWalletBalance() { + try { + const res = await request.get('/tenant/wallet') + const w = res.data?.data + if (w) { + const bal = w.available_balance ?? w.balance ?? 0 + walletBalance.value = bal >= 1 ? '$' + bal.toFixed(0) : bal > 0 ? '$' + bal.toFixed(2) : '$0' + } + } catch { + // silently ignore + } +} + onMounted(async () => { authStore.loadFromStorage() document.addEventListener('click', handleClickOutside) fetchAnnouncements() announcementTimer = setInterval(fetchAnnouncements, 10 * 60 * 1000) startNotificationPolling() + fetchWalletBalance() + walletTimer = setInterval(fetchWalletBalance, 60000) await fetchPublicSettings() if (publicSettings.demo_mode) { @@ -150,6 +167,10 @@ onBeforeUnmount(() => { clearInterval(announcementTimer) announcementTimer = null } + if (walletTimer) { + clearInterval(walletTimer) + walletTimer = null + } unmountWatermark() }) @@ -274,6 +295,15 @@ onBeforeUnmount(() => { + + + + +
-
+

冻结余额

-

${{ wallet?.frozen_balance?.toFixed(2) ?? '0.00' }}

+

+ ${{ wallet?.frozen_balance?.toFixed(2) ?? '0.00' }} +

+

点击查看明细

总余额

@@ -191,82 +251,207 @@ onMounted(() => {
- +
-
-
-

交易记录

-

共 {{ total }} 条记录

+

套餐方案

+

选择适合您团队的方案

+
+
+ +
+
+
+
+
- -
- -
-
导出 CSV
-
导出 Excel
+
+ + +
+ +

暂无可用套餐,请联系管理员配置

+
+ + +
+
+ +
+ 推荐 +
+ + +
+ 当前 +
+ + +

{{ plan.name }}

+

{{ plan.description || '' }}

+ + +
+
+ ¥{{ Number(plan.monthly_price).toFixed(0) }} + /月 +
+

+ 年付 ¥{{ Number(plan.yearly_price).toFixed(0) }}(省 ¥{{ (Number(plan.monthly_price) * 12 - Number(plan.yearly_price)).toFixed(0) }}) +

+
+ + +
+
+ + {{ plan.monthly_quota_tokens.toLocaleString() }} Tokens/月 +
+
+ + 不限 Token 用量 +
+
+ + +
+ +
- -
-
- -
-
-
- -
- - - - - - - - - - - - - - - - - - - -
类型金额余额描述时间
- - {{ txTypeLabel[tx.type] || tx.type }} - - - {{ formatAmount(tx.amount) }} - ${{ tx.balance_after?.toFixed(2) ?? '--' }}{{ tx.description || '--' }}{{ (tx.created_at || '').replace('T', ' ').substring(0, 16) }}
-
- -
- -

暂无交易记录

-

交易记录将在 API 调用和充值后展示

-
- - -
-

- 第 {{ page }} / {{ Math.ceil(total / pageSize) }} 页 -

-
- -
+ + + + + + + + + + + + + +