refactor(wallet):重构钱包功能,支持查看冻结信息

This commit is contained in:
qian committed 2026-05-20 12:42:13 +08:00
1 parent 9a058a7bbc
commit e4ec55ccf2
20 files changed
+819 -437

No files matched your search

+1
View File
@@ -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)
}
+19
View File
@@ -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"`
}
@@ -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)
}
+44
View File
@@ -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,
},
},
}
}
+1 -1
View File
@@ -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
}
+2
View File
@@ -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)
+7 -9
View File
@@ -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)
}
+101 -2
View File
@@ -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
}
+32 -12
View File
@@ -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)
+11 -6
View File
@@ -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)
+28
View File
@@ -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
}
+37 -11
View File
@@ -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
+2
View File
@@ -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.
+1 -1
View File
@@ -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)
+11 -10
View File
@@ -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", "")
+6 -6
View File
@@ -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)
{
+30
View File
@@ -20,6 +20,8 @@ const mobileOpen = ref(false)
const userMenuOpen = ref(false)
const consoleAnnouncements = ref<any[]>([])
let announcementTimer: ReturnType<typeof setInterval> | null = null
const walletBalance = ref<string>('')
let walletTimer: ReturnType<typeof setInterval> | 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()
})
</script>
@@ -274,6 +295,15 @@ onBeforeUnmount(() => {
</span>
</router-link>
<!-- Wallet -->
<router-link
to="/tenant/wallet"
class="relative flex h-9 items-center justify-center rounded-xl px-2.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
title="钱包"
>
<Icon name="wallet" size="md" />
<span v-if="walletBalance" class="hidden sm:inline text-xs font-medium text-gray-500 ml-1.5">{{ walletBalance }}</span>
</router-link>
<!-- User Menu -->
<div class="relative" data-user-menu>
<button
-248
View File
@@ -1,248 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import Icon from '@/components/common/Icon.vue'
import request from '@/utils/request'
import { extractApiError } from '@/utils/request'
const router = useRouter()
const loading = ref(false)
const plans = ref<any[]>([])
const currentPlan = ref<any>(null)
const showConfirm = ref(false)
const selectedPlan = ref<any>(null)
const selectedMonths = ref(1)
const confirmLoading = ref(false)
const monthsOptions = [
{ label: '1 个月', value: 1 },
{ label: '3 个月', value: 3 },
{ label: '6 个月', value: 6 },
{ label: '12 个月(优惠)', value: 12 },
]
const currentPlanId = computed(() => currentPlan.value?.plan_id)
async function fetchPlans() {
loading.value = true
try {
const [plansRes, currentRes] = await Promise.all([
request.get('/tenant/plans'),
request.get('/tenant/plan/current').catch(() => ({ data: { data: null } })),
])
const raw = plansRes.data?.data; plans.value = Array.isArray(raw) ? raw : (raw?.data || raw?.list || [])
currentPlan.value = currentRes.data?.data || null
} catch (e) {
// interceptor handles error toast
} finally {
loading.value = false
}
}
function calcPrice(plan: any, months: number) {
if (months >= 12) return Number(plan.yearly_price || 0)
return Number(plan.monthly_price || 0) * months
}
function openConfirm(plan: any) {
selectedPlan.value = plan
selectedMonths.value = 1
showConfirm.value = true
}
async function handleSubscribe() {
confirmLoading.value = true
try {
const res = await request.post('/tenant/orders/create', {
order_type: 'new_plan',
plan_id: selectedPlan.value.id,
months: selectedMonths.value,
})
const order = res.data?.data
if (order?.id) {
// Mock pay immediately
await request.post(`/tenant/orders/${order.id}/pay`)
}
showConfirm.value = false
await fetchPlans()
} catch (e) {
// interceptor handles error toast
} finally {
confirmLoading.value = false
}
}
function isCurrentPlan(plan: any) {
return currentPlan.value && currentPlan.value.plan_id === plan.id
}
onMounted(fetchPlans)
</script>
<template>
<div>
<!-- Page Header -->
<div class="page-header">
<h2 class="page-title">套餐方案</h2>
<p class="page-description">选择适合您团队的方案,如需更多方案,可联系管理员定制</p>
</div>
<!-- Loading -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="i in 3" :key="i" class="card p-6 animate-pulse">
<div class="h-6 bg-gray-200 rounded w-1/2 mb-4"></div>
<div class="h-8 bg-gray-200 rounded w-3/4 mb-6"></div>
<div class="space-y-3">
<div class="h-4 bg-gray-200 rounded"></div>
<div class="h-4 bg-gray-200 rounded w-5/6"></div>
<div class="h-4 bg-gray-200 rounded w-4/6"></div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else-if="plans.length === 0" class="empty-state">
<Icon name="exclamationCircle" size="xl" class="empty-state-icon text-gray-300" />
<p class="empty-state-title">暂无可用套餐</p>
<p class="empty-state-description">请联系管理员配置套餐方案</p>
</div>
<!-- Plan Cards -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="plan in plans"
:key="plan.id"
class="card card-hover relative flex flex-col"
:class="{ 'ring-2 ring-primary-500 shadow-glow': plan.is_recommended }"
>
<!-- Recommended Badge -->
<div
v-if="plan.is_recommended"
class="absolute -top-3 left-1/2 -translate-x-1/2 badge badge-primary"
>
推荐
</div>
<!-- Current Plan Badge -->
<div
v-if="isCurrentPlan(plan)"
class="absolute top-4 right-4 badge badge-success"
>
当前套餐
</div>
<div class="card-body flex-1 flex flex-col">
<!-- Plan Name -->
<h3 class="text-lg font-semibold text-gray-900 mb-1">{{ plan.name }}</h3>
<p class="text-xs text-gray-500 mb-4">{{ plan.description || '' }}</p>
<!-- Price -->
<div class="mb-6">
<div class="flex items-baseline gap-1">
<span class="text-3xl font-bold text-gray-900">¥{{ Number(plan.monthly_price).toFixed(0) }}</span>
<span class="text-sm text-gray-500">/月</span>
</div>
<p v-if="Number(plan.yearly_price) > 0" class="text-xs text-gray-400 mt-1">
年付 ¥{{ Number(plan.yearly_price).toFixed(0) }}(省 ¥{{ (Number(plan.monthly_price) * 12 - Number(plan.yearly_price)).toFixed(0) }})
</p>
</div>
<!-- Features -->
<div class="space-y-2.5 text-sm text-gray-600 flex-1">
<div v-if="plan.monthly_quota_tokens > 0" class="flex items-center gap-2">
<Icon name="check" size="sm" class="text-primary-500 flex-shrink-0" />
<span>{{ plan.monthly_quota_tokens.toLocaleString() }} Tokens/月</span>
</div>
<div v-else class="flex items-center gap-2">
<Icon name="check" size="sm" class="text-primary-500 flex-shrink-0" />
<span>不限 Token 用量</span>
</div>
</div>
</div>
<!-- Action -->
<div class="card-footer">
<button
v-if="isCurrentPlan(plan)"
class="btn btn-secondary w-full"
disabled
>
当前方案
</button>
<button
v-else-if="Number(plan.monthly_price) === 0"
class="btn btn-primary w-full"
@click="openConfirm(plan)"
>
免费开通
</button>
<button
v-else
class="btn btn-primary w-full"
@click="openConfirm(plan)"
>
立即购买
</button>
</div>
</div>
</div>
<!-- Confirm Modal -->
<Teleport to="body">
<transition name="modal">
<div v-if="showConfirm" class="modal-overlay" @click.self="showConfirm = false">
<div class="modal-content w-full max-w-md">
<div class="modal-header">
<h3 class="modal-title">确认订阅</h3>
<button @click="showConfirm = false" class="btn-ghost btn-icon">
<Icon name="x" size="md" />
</button>
</div>
<div class="modal-body">
<div v-if="selectedPlan" class="space-y-4">
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
<span class="font-medium text-gray-900">{{ selectedPlan.name }}</span>
<span class="badge badge-primary">{{ selectedPlan.identifier }}</span>
</div>
<div>
<label class="input-label">订阅时长</label>
<div class="grid grid-cols-2 gap-2 mt-1.5">
<button
v-for="opt in monthsOptions"
:key="opt.value"
class="rounded-xl px-3 py-2 text-sm font-medium border transition-all"
:class="selectedMonths === opt.value
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300'"
@click="selectedMonths = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<div class="flex items-center justify-between p-3 bg-primary-50 rounded-xl">
<span class="text-sm text-gray-600">应付金额</span>
<span class="text-xl font-bold text-primary-600">
¥{{ calcPrice(selectedPlan, selectedMonths).toFixed(2) }}
</span>
</div>
</div>
</div>
<div class="modal-footer">
<button @click="showConfirm = false" class="btn btn-secondary">取消</button>
<button
class="btn btn-primary"
:disabled="confirmLoading"
@click="handleSubscribe"
>
{{ confirmLoading ? '处理中...' : '确认订阅' }}
</button>
</div>
</div>
</div>
</transition>
</Teleport>
</div>
</template>
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import Icon from '@/components/common/Icon.vue'
import request from '@/utils/request'
import { useExport } from '@/composables/useExport'
const loading = ref(false)
const transactions = ref<any[]>([])
const page = ref(1)
const pageSize = 20
const total = ref(0)
const showExportDropdown = ref(false)
const { exporting, exportFile } = useExport({
url: '/tenant/wallet/transactions/export',
})
const txTypeLabel: Record<string, string> = {
recharge: '充值',
consume: '消费',
pre_deduct: '预扣',
settle: '结算',
refund: '退款',
adjust: '调整',
freeze: '冻结',
unfreeze: '解冻',
}
const txTypeBadgeClass: Record<string, string> = {
recharge: 'badge-success',
refund: 'badge-success',
consume: 'badge-danger',
pre_deduct: 'badge-danger',
settle: 'badge-danger',
adjust: 'badge-warning',
freeze: 'badge-gray',
unfreeze: 'badge-gray',
}
function formatAmount(amount: number): string {
if (amount >= 0) return '+$' + amount.toFixed(6)
return '-$' + Math.abs(amount).toFixed(6)
}
async function fetchTransactions() {
loading.value = true
try {
const res: any = await request.get('/tenant/wallet/transactions', {
params: { page: page.value, page_size: pageSize },
})
const raw = res.data?.data
transactions.value = Array.isArray(raw) ? raw : (raw?.data || raw?.list || [])
total.value = raw?.total || 0
} catch {
transactions.value = []
} finally {
loading.value = false
}
}
function prevPage() {
if (page.value > 1) {
page.value--
fetchTransactions()
}
}
function nextPage() {
if (page.value * pageSize < total.value) {
page.value++
fetchTransactions()
}
}
onMounted(fetchTransactions)
</script>
<template>
<div class="space-y-6">
<!-- Page Header -->
<div class="flex items-start justify-between">
<div>
<h1 class="page-title">交易记录</h1>
<p class="page-description">查看所有交易流水明细 · 共 {{ total }} 条记录</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<div class="relative inline-block">
<button class="btn btn-secondary btn-sm" :disabled="exporting" @click="showExportDropdown = !showExportDropdown">
<svg v-if="!exporting" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
<svg v-else class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
导出
</button>
<div v-if="showExportDropdown" class="absolute right-0 mt-2 w-36 bg-white rounded-xl border shadow-lg py-1 z-50">
<div class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 cursor-pointer" @click="exportFile('csv'); showExportDropdown = false">导出 CSV</div>
<div class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 cursor-pointer" @click="exportFile('xlsx'); showExportDropdown = false">导出 Excel</div>
</div>
</div>
<button class="btn btn-ghost btn-sm" @click="fetchTransactions">
<Icon name="refresh" size="sm" />
刷新
</button>
</div>
</div>
<!-- Transactions -->
<div class="card">
<div v-if="loading" class="p-8 flex justify-center">
<div class="spinner h-6 w-6 border-primary-500"></div>
</div>
<div v-else-if="transactions.length > 0" class="table-container border-0 rounded-none">
<table class="table">
<thead>
<tr>
<th>类型</th>
<th>金额</th>
<th>余额</th>
<th>描述</th>
<th>时间</th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions" :key="tx.id">
<td>
<span class="badge" :class="txTypeBadgeClass[tx.type] || 'badge-gray'">
{{ txTypeLabel[tx.type] || tx.type }}
</span>
</td>
<td :class="tx.amount >= 0 ? 'text-emerald-600 font-semibold' : 'text-red-600 font-semibold'">
{{ formatAmount(tx.amount) }}
</td>
<td class="text-gray-700">${{ tx.balance_after?.toFixed(2) ?? '--' }}</td>
<td class="text-gray-500 text-sm">{{ tx.description || '--' }}</td>
<td class="text-gray-500 text-xs">{{ (tx.created_at || '').replace('T', ' ').substring(0, 16) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="empty-state">
<Icon name="creditCard" size="xl" class="empty-state-icon" />
<p class="empty-state-title">暂无交易记录</p>
<p class="empty-state-description">交易记录将在 API 调用和充值后展示</p>
</div>
<!-- Pagination -->
<div v-if="total > pageSize" class="px-6 py-4 border-t border-gray-100 flex items-center justify-between">
<p class="text-sm text-gray-500">
第 {{ page }} / {{ Math.ceil(total / pageSize) }} 页
</p>
<div class="flex items-center gap-2">
<button class="btn btn-secondary btn-sm" :disabled="page <= 1" @click="prevPage">上一页</button>
<button class="btn btn-secondary btn-sm" :disabled="page * pageSize >= total" @click="nextPage">下一页</button>
</div>
</div>
</div>
</div>
</template>
+316 -131
View File
@@ -1,20 +1,10 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import Icon from '@/components/common/Icon.vue'
import request from '@/utils/request'
import { useExport } from '@/composables/useExport'
const loading = ref(false)
const wallet = ref<any>(null)
const transactions = ref<any[]>([])
const page = ref(1)
const pageSize = 20
const total = ref(0)
const showExportDropdown = ref(false)
const { exporting, exportFile } = useExport({
url: '/tenant/wallet/transactions/export',
})
// Recharge
const rechargeAmount = ref<number | null>(null)
@@ -22,32 +12,28 @@ const customAmount = ref('')
const rechargeLoading = ref(false)
const presetAmounts = [10, 50, 100, 500]
const txTypeLabel: Record<string, string> = {
recharge: '充值',
consume: '消费',
pre_deduct: '预扣',
settle: '结算',
refund: '退款',
adjust: '调整',
freeze: '冻结',
unfreeze: '解冻',
}
// Plans
const plans = ref<any[]>([])
const currentPlan = ref<any>(null)
const showConfirm = ref(false)
const selectedPlan = ref<any>(null)
const selectedMonths = ref(1)
const confirmLoading = ref(false)
const txTypeBadgeClass: Record<string, string> = {
recharge: 'badge-success',
refund: 'badge-success',
consume: 'badge-danger',
pre_deduct: 'badge-danger',
settle: 'badge-danger',
adjust: 'badge-warning',
freeze: 'badge-gray',
unfreeze: 'badge-gray',
}
// Frozen items
const showFrozenModal = ref(false)
const frozenItems = ref<any[]>([])
const frozenLoading = ref(false)
let frozenTimer: ReturnType<typeof setInterval> | null = null
function formatAmount(amount: number): string {
if (amount >= 0) return '+$' + amount.toFixed(2)
return '-$' + Math.abs(amount).toFixed(2)
}
const monthsOptions = [
{ label: '1 个月', value: 1 },
{ label: '3 个月', value: 3 },
{ label: '6 个月', value: 6 },
{ label: '12 个月(优惠)', value: 12 },
]
const currentPlanId = computed(() => currentPlan.value?.plan_id)
async function fetchWallet() {
try {
@@ -58,35 +44,23 @@ async function fetchWallet() {
}
}
async function fetchTransactions() {
async function fetchPlans() {
loading.value = true
try {
const res: any = await request.get('/tenant/wallet/transactions', {
params: { page: page.value, page_size: pageSize },
})
const raw = res.data?.data; transactions.value = Array.isArray(raw) ? raw : (raw?.data || raw?.list || [])
total.value = raw?.total || 0
const [plansRes, currentRes] = await Promise.all([
request.get('/tenant/plans'),
request.get('/tenant/plan/current').catch(() => ({ data: { data: null } })),
])
const raw = plansRes.data?.data
plans.value = Array.isArray(raw) ? raw : (raw?.data || raw?.list || [])
currentPlan.value = currentRes.data?.data || null
} catch {
transactions.value = []
// interceptor handles error toast
} finally {
loading.value = false
}
}
function prevPage() {
if (page.value > 1) {
page.value--
fetchTransactions()
}
}
function nextPage() {
if (page.value * pageSize < total.value) {
page.value++
fetchTransactions()
}
}
function selectPresetAmount(amount: number) {
rechargeAmount.value = amount
customAmount.value = ''
@@ -112,17 +86,97 @@ async function handleRecharge() {
rechargeAmount.value = null
customAmount.value = ''
fetchWallet()
fetchTransactions()
} catch (e) {
} catch {
// interceptor handles error toast
} finally {
rechargeLoading.value = false
}
}
function calcPrice(plan: any, months: number) {
if (months >= 12) return Number(plan.yearly_price || 0)
return Number(plan.monthly_price || 0) * months
}
function openConfirm(plan: any) {
selectedPlan.value = plan
selectedMonths.value = 1
showConfirm.value = true
}
async function handleSubscribe() {
confirmLoading.value = true
try {
const res = await request.post('/tenant/orders/create', {
order_type: 'new_plan',
plan_id: selectedPlan.value.id,
months: selectedMonths.value,
})
const order = res.data?.data
if (order?.id) {
await request.post(`/tenant/orders/${order.id}/pay`)
}
showConfirm.value = false
await fetchPlans()
} catch {
// interceptor handles error toast
} finally {
confirmLoading.value = false
}
}
function isCurrentPlan(plan: any) {
return currentPlan.value && currentPlan.value.plan_id === plan.id
}
// Frozen items
async function fetchFrozenItems() {
frozenLoading.value = true
try {
const res: any = await request.get('/tenant/wallet/frozen-items')
frozenItems.value = res.data?.data?.items || []
} catch {
frozenItems.value = []
} finally {
frozenLoading.value = false
}
}
function openFrozenModal() {
showFrozenModal.value = true
fetchFrozenItems()
frozenTimer = setInterval(fetchFrozenItems, 10000)
}
function closeFrozenModal() {
showFrozenModal.value = false
if (frozenTimer) {
clearInterval(frozenTimer)
frozenTimer = null
}
}
function formatTime(unix: number): string {
if (!unix) return '-'
return new Date(unix * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function formatRemaining(seconds: number): string {
if (seconds <= 0) return '即将到期'
const m = Math.floor(seconds / 60)
const s = seconds % 60
return m > 0 ? `${m}分${s}秒` : `${s}秒`
}
onMounted(() => {
fetchWallet()
fetchTransactions()
fetchPlans()
})
onBeforeUnmount(() => {
if (frozenTimer) {
clearInterval(frozenTimer)
}
})
</script>
@@ -131,7 +185,7 @@ onMounted(() => {
<!-- Page Header -->
<div class="page-header">
<h1 class="page-title">钱包</h1>
<p class="page-description">查看余额和交易记录</p>
<p class="page-description">管理余额、充值和套餐方案</p>
</div>
<!-- Wallet Summary -->
@@ -141,9 +195,15 @@ onMounted(() => {
<p class="text-2xl font-bold text-gray-900">${{ wallet?.available_balance?.toFixed(2) ?? '0.00' }}</p>
<p class="text-xs text-gray-400 mt-1">{{ wallet?.currency || 'USD' }}</p>
</div>
<div class="card p-5">
<div
class="card p-5 cursor-pointer hover:-translate-y-0.5 transition-all duration-200 group"
@click="openFrozenModal"
>
<p class="stat-label">冻结余额</p>
<p class="text-2xl font-bold text-amber-600">${{ wallet?.frozen_balance?.toFixed(2) ?? '0.00' }}</p>
<p class="text-2xl font-bold text-amber-600 group-hover:text-amber-700 transition-colors">
${{ wallet?.frozen_balance?.toFixed(2) ?? '0.00' }}
</p>
<p v-if="wallet?.frozen_balance > 0" class="text-xs text-amber-500 mt-1">点击查看明细</p>
</div>
<div class="card p-5">
<p class="stat-label">总余额</p>
@@ -191,82 +251,207 @@ onMounted(() => {
</div>
</div>
<!-- Transactions -->
<!-- Subscription Plans -->
<div class="card">
<div class="card-header">
<div class="flex items-center justify-between w-full">
<div>
<h2 class="font-semibold text-gray-900">交易记录</h2>
<p class="text-sm text-gray-500 mt-0.5">共 {{ total }} 条记录</p>
<h2 class="font-semibold text-gray-900">套餐方案</h2>
<p class="text-sm text-gray-500 mt-0.5">选择适合您团队的方案</p>
</div>
<div class="card-body">
<!-- Loading -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="i in 3" :key="i" class="p-4 border border-gray-200 rounded-xl animate-pulse">
<div class="h-5 bg-gray-200 rounded w-1/2 mb-3"></div>
<div class="h-7 bg-gray-200 rounded w-3/4 mb-4"></div>
<div class="h-4 bg-gray-200 rounded w-full"></div>
</div>
<!-- Export dropdown -->
<div class="relative inline-block">
<button class="btn btn-secondary btn-sm" :disabled="exporting" @click="showExportDropdown = !showExportDropdown">
<svg v-if="!exporting" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/></svg>
<svg v-else class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
导出
</button>
<div v-if="showExportDropdown" class="absolute right-0 mt-2 w-36 bg-white rounded-xl border shadow-lg py-1 z-50">
<div class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 cursor-pointer" @click="exportFile('csv'); showExportDropdown = false">导出 CSV</div>
<div class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 cursor-pointer" @click="exportFile('xlsx'); showExportDropdown = false">导出 Excel</div>
</div>
<!-- Empty -->
<div v-else-if="plans.length === 0" class="py-8 text-center">
<Icon name="exclamationCircle" size="xl" class="text-gray-300 mx-auto mb-3" />
<p class="text-gray-500 text-sm">暂无可用套餐,请联系管理员配置</p>
</div>
<!-- Plan Cards -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="plan in plans"
:key="plan.id"
class="relative flex flex-col p-4 border rounded-xl transition-all hover:shadow-sm"
:class="plan.is_recommended
? 'border-primary-500 bg-primary-50/30 ring-1 ring-primary-500/20'
: 'border-gray-200 hover:border-gray-300'"
>
<!-- Recommended Badge -->
<div
v-if="plan.is_recommended"
class="absolute -top-2.5 left-1/2 -translate-x-1/2"
>
<span class="badge badge-primary text-xs">推荐</span>
</div>
<!-- Current Plan Badge -->
<div
v-if="isCurrentPlan(plan)"
class="absolute top-3 right-3"
>
<span class="badge badge-success text-xs">当前</span>
</div>
<!-- Plan Info -->
<h3 class="text-base font-semibold text-gray-900 mb-1">{{ plan.name }}</h3>
<p class="text-xs text-gray-500 mb-3">{{ plan.description || '' }}</p>
<!-- Price -->
<div class="mb-4">
<div class="flex items-baseline gap-1">
<span class="text-2xl font-bold text-gray-900">¥{{ Number(plan.monthly_price).toFixed(0) }}</span>
<span class="text-xs text-gray-500">/月</span>
</div>
<p v-if="Number(plan.yearly_price) > 0" class="text-xs text-gray-400 mt-1">
年付 ¥{{ Number(plan.yearly_price).toFixed(0) }}(省 ¥{{ (Number(plan.monthly_price) * 12 - Number(plan.yearly_price)).toFixed(0) }})
</p>
</div>
<!-- Features -->
<div class="space-y-1.5 text-sm text-gray-600 flex-1">
<div v-if="plan.monthly_quota_tokens > 0" class="flex items-center gap-1.5">
<Icon name="check" size="sm" class="text-primary-500 flex-shrink-0" />
<span class="text-xs">{{ plan.monthly_quota_tokens.toLocaleString() }} Tokens/月</span>
</div>
<div v-else class="flex items-center gap-1.5">
<Icon name="check" size="sm" class="text-primary-500 flex-shrink-0" />
<span class="text-xs">不限 Token 用量</span>
</div>
</div>
<!-- Action -->
<div class="mt-4 pt-3 border-t border-gray-100">
<button
v-if="isCurrentPlan(plan)"
class="btn btn-secondary w-full btn-sm"
disabled
>
当前方案
</button>
<button
v-else
class="btn btn-primary w-full btn-sm"
@click="openConfirm(plan)"
>
{{ Number(plan.monthly_price) === 0 ? '免费开通' : '立即购买' }}
</button>
</div>
</div>
<button class="btn btn-ghost btn-sm" @click="fetchTransactions">
<Icon name="refresh" size="sm" />
刷新
</button>
</div>
</div>
<div v-if="loading" class="p-8 flex justify-center">
<div class="spinner h-6 w-6 border-primary-500"></div>
</div>
<div v-else-if="transactions.length > 0" class="table-container border-0 rounded-none">
<table class="table">
<thead>
<tr>
<th>类型</th>
<th>金额</th>
<th>余额</th>
<th>描述</th>
<th>时间</th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions" :key="tx.id">
<td>
<span class="badge" :class="txTypeBadgeClass[tx.type] || 'badge-gray'">
{{ txTypeLabel[tx.type] || tx.type }}
</span>
</td>
<td :class="tx.amount >= 0 ? 'text-emerald-600 font-semibold' : 'text-red-600 font-semibold'">
{{ formatAmount(tx.amount) }}
</td>
<td class="text-gray-700">${{ tx.balance_after?.toFixed(2) ?? '--' }}</td>
<td class="text-gray-500 text-sm">{{ tx.description || '--' }}</td>
<td class="text-gray-500 text-xs">{{ (tx.created_at || '').replace('T', ' ').substring(0, 16) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="empty-state">
<Icon name="creditCard" size="xl" class="empty-state-icon" />
<p class="empty-state-title">暂无交易记录</p>
<p class="empty-state-description">交易记录将在 API 调用和充值后展示</p>
</div>
<!-- Pagination -->
<div v-if="total > pageSize" class="px-6 py-4 border-t border-gray-100 flex items-center justify-between">
<p class="text-sm text-gray-500">
第 {{ page }} / {{ Math.ceil(total / pageSize) }} 页
</p>
<div class="flex items-center gap-2">
<button class="btn btn-secondary btn-sm" :disabled="page <= 1" @click="prevPage">上一页</button>
<button class="btn btn-secondary btn-sm" :disabled="page * pageSize >= total" @click="nextPage">下一页</button>
</div>
</div>
</div>
<!-- Confirm Modal -->
<Teleport to="body">
<transition name="modal">
<div v-if="showConfirm" class="modal-overlay" @click.self="showConfirm = false">
<div class="modal-content w-full max-w-md">
<div class="modal-header">
<h3 class="modal-title">确认订阅</h3>
<button @click="showConfirm = false" class="btn-ghost btn-icon">
<Icon name="x" size="md" />
</button>
</div>
<div class="modal-body">
<div v-if="selectedPlan" class="space-y-4">
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
<span class="font-medium text-gray-900">{{ selectedPlan.name }}</span>
<span class="badge badge-primary">{{ selectedPlan.identifier }}</span>
</div>
<div>
<label class="input-label">订阅时长</label>
<div class="grid grid-cols-2 gap-2 mt-1.5">
<button
v-for="opt in monthsOptions"
:key="opt.value"
class="rounded-xl px-3 py-2 text-sm font-medium border transition-all"
:class="selectedMonths === opt.value
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-200 text-gray-600 hover:border-gray-300'"
@click="selectedMonths = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<div class="flex items-center justify-between p-3 bg-primary-50 rounded-xl">
<span class="text-sm text-gray-600">应付金额</span>
<span class="text-xl font-bold text-primary-600">
¥{{ calcPrice(selectedPlan, selectedMonths).toFixed(2) }}
</span>
</div>
</div>
</div>
<div class="modal-footer">
<button @click="showConfirm = false" class="btn btn-secondary">取消</button>
<button
class="btn btn-primary"
:disabled="confirmLoading"
@click="handleSubscribe"
>
{{ confirmLoading ? '处理中...' : '确认订阅' }}
</button>
</div>
</div>
</div>
</transition>
</Teleport>
<!-- Frozen Items Modal -->
<Teleport to="body">
<transition name="modal">
<div v-if="showFrozenModal" class="modal-overlay" @click.self="closeFrozenModal">
<div class="modal-content w-full max-w-lg">
<div class="modal-header">
<h3 class="modal-title">冻结明细</h3>
<button @click="closeFrozenModal" class="btn-ghost btn-icon">
<Icon name="x" size="md" />
</button>
</div>
<div class="modal-body">
<!-- Loading -->
<div v-if="frozenLoading && frozenItems.length === 0" class="space-y-3">
<div v-for="i in 3" :key="i" class="h-12 bg-gray-100 rounded-xl animate-pulse"></div>
</div>
<!-- Empty -->
<div v-else-if="frozenItems.length === 0" class="py-8 text-center">
<Icon name="exclamationCircle" size="xl" class="text-gray-300 mx-auto mb-3" />
<p class="text-gray-500 text-sm">当前没有冻结的资金</p>
</div>
<!-- Items list -->
<div v-else class="space-y-2">
<div v-for="item in frozenItems" :key="item.request_id"
class="flex items-center justify-between p-3 bg-gray-50 rounded-xl">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 truncate">{{ item.model_name || '未知模型' }}</p>
<p class="text-xs text-gray-400 truncate mt-0.5">
{{ item.request_id.substring(0, 16) }}...
<span class="ml-2">{{ formatTime(item.created_at) }}</span>
</p>
</div>
<div class="text-right ml-3 flex-shrink-0">
<p class="text-sm font-semibold text-amber-600">${{ item.amount?.toFixed(4) }}</p>
<p class="text-xs text-gray-400">剩余 {{ formatRemaining(item.remaining) }}</p>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<p class="text-xs text-gray-400 flex-1">自动刷新中 · 每 10 秒更新</p>
<button @click="closeFrozenModal" class="btn btn-secondary btn-sm">关闭</button>
</div>
</div>
</div>
</transition>
</Teleport>
</div>
</template>