From 2e0119ee852532f8aa19588e6401dddaf213aa18 Mon Sep 17 00:00:00 2001 From: qian Date: Tue, 4 Aug 2026 00:48:02 +0800 Subject: [PATCH] =?UTF-8?q?improve(wallet):=E4=BC=98=E5=8C=96=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=90=8E=E5=8F=B0=E7=9A=84=E9=92=B1=E5=8C=85=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin/admin.go | 1 + api/admin/v1/dashboard.go | 14 ++ .../controller/admin/admin_v1_dashboard.go | 3 + internal/logic/admin/dashboard.go | 138 ++++++++++++++++++ internal/middleware/rbac.go | 1 + internal/service/admin.go | 6 + .../src/views/tenant/TenantDetailPage.vue | 111 +++++++++++--- 7 files changed, 250 insertions(+), 24 deletions(-) diff --git a/api/admin/admin.go b/api/admin/admin.go index cb702e7..0a35fb2 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -86,6 +86,7 @@ type IAdminV1 interface { AdminWalletList(ctx context.Context, req *v1.AdminWalletListReq) (res *v1.AdminWalletListRes, err error) AdminWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) (res *v1.AdminWalletInfoRes, err error) AdminWalletAdjust(ctx context.Context, req *v1.AdminWalletAdjustReq) (res *v1.AdminWalletAdjustRes, err error) + AdminWalletOfflineRecharge(ctx context.Context, req *v1.AdminWalletOfflineRechargeReq) (res *v1.AdminWalletOfflineRechargeRes, err error) AdminWalletTransactionList(ctx context.Context, req *v1.AdminWalletTransactionListReq) (res *v1.AdminWalletTransactionListRes, err error) AdminWalletSetWarningThreshold(ctx context.Context, req *v1.AdminWalletSetWarningThresholdReq) (res *v1.AdminWalletSetWarningThresholdRes, err error) AdminUsageLogExport(ctx context.Context, req *v1.AdminUsageLogExportReq) (res *v1.AdminUsageLogExportRes, err error) diff --git a/api/admin/v1/dashboard.go b/api/admin/v1/dashboard.go index 4baed2c..efb020d 100644 --- a/api/admin/v1/dashboard.go +++ b/api/admin/v1/dashboard.go @@ -282,6 +282,20 @@ type AdminWalletAdjustReq struct { type AdminWalletAdjustRes struct{} +type AdminWalletOfflineRechargeReq struct { + g.Meta `path:"/wallets/{tenant_id}/offline-recharge" method:"post" mime:"json" tags:"管理后台-钱包" summary:"线下充值入账"` + TenantID int64 `json:"tenant_id" in:"path" v:"required|min:1" dc:"租户ID"` + Amount float64 `json:"amount" v:"required|min:0.01" dc:"线下入账金额(人民币 CNY),按平台汇率换算为 USD 到账"` + TransactionNo string `json:"transaction_no" dc:"银行转账流水号(选填,用于对账与防重复入账)"` + Description string `json:"description" v:"required" dc:"入账说明(必填,如客户名称、转账原因)"` +} + +type AdminWalletOfflineRechargeRes struct { + CreditedUSD float64 `json:"credited_usd" dc:"实际入账美元金额"` + Rate float64 `json:"rate" dc:"使用的 CNY→USD 汇率"` + Balance float64 `json:"balance" dc:"入账后总余额(USD)"` +} + type AdminWalletTransactionListReq struct { g.Meta `path:"/wallets/{tenant_id}/transactions" method:"get" mime:"json" tags:"管理后台-钱包" summary:"交易流水"` TenantID int64 `json:"tenant_id" in:"path" v:"required|min:1" dc:"租户ID"` diff --git a/internal/controller/admin/admin_v1_dashboard.go b/internal/controller/admin/admin_v1_dashboard.go index d8496b0..ee14b71 100644 --- a/internal/controller/admin/admin_v1_dashboard.go +++ b/internal/controller/admin/admin_v1_dashboard.go @@ -58,3 +58,6 @@ func (c *ControllerV1) AdminTransactionList(ctx context.Context, req *v1.AdminTr func (c *ControllerV1) AdminDashboardModelHourly(ctx context.Context, req *v1.AdminDashboardModelHourlyReq) (res *v1.AdminDashboardModelHourlyRes, err error) { return service.Admin().GetModelHourlyCost(ctx, req) } +func (c *ControllerV1) AdminWalletOfflineRecharge(ctx context.Context, req *v1.AdminWalletOfflineRechargeReq) (res *v1.AdminWalletOfflineRechargeRes, err error) { + return service.Admin().OfflineRecharge(ctx, req) +} diff --git a/internal/logic/admin/dashboard.go b/internal/logic/admin/dashboard.go index e8b20fd..3adf006 100644 --- a/internal/logic/admin/dashboard.go +++ b/internal/logic/admin/dashboard.go @@ -603,6 +603,123 @@ func (s *sAdmin) AdjustBalance(ctx context.Context, req *v1.AdminWalletAdjustReq return &v1.AdminWalletAdjustRes{}, nil } +// OfflineRecharge 线下充值入账(管理后台) +// 场景:用户线下银行转账(人民币 CNY),运营确认到账后按平台汇率换算为 USD 入账。 +// 与 AdjustBalance 的区别:走正规充值链路——余额与累计充值同步累加、触发等级检查、 +// 流水类型为 recharge,并在描述中携带 CNY 快照(原始人民币 + 汇率 + 入账 USD + 转账流水号), +// 供现金对账与开票追溯。 +func (s *sAdmin) OfflineRecharge(ctx context.Context, req *v1.AdminWalletOfflineRechargeReq) (*v1.AdminWalletOfflineRechargeRes, error) { + tenantID := req.TenantID + cnyAmount := billing.NewFromFloat(req.Amount) + if !cnyAmount.IsPositive() { + return nil, common.NewBadRequestError("入账金额必须大于 0") + } + + // 唯一换汇点:CNY→USD 只经 billing.ConvertCNYToUSD(与充值履约 FulfillOrder 同一函数), + // 汇率取一次用于入账与快照,保证换算可重建。 + rate := billing.GetExchangeRateCNYToUSD(ctx) + usdAmount := billing.ConvertCNYToUSD(ctx, req.Amount) + if usdAmount.IsZero() { + return nil, common.NewBadRequestError("按当前汇率换算后到账金额为 0,请检查汇率配置") + } + + // 转账流水号软去重(防重复入账):同一租户下已入账过该流水号则拒绝。 + // 非强原子,配合前端 loading 禁用按钮,拦截人工重复提交足够。 + if req.TransactionNo != "" { + count, err := dao.BilTransactions.Ctx(ctx). + Where("tenant_id", tenantID). + Where("type", "recharge"). + Where("description like ?", "%转账流水号 "+req.TransactionNo).Count() + if err != nil { + return nil, err + } + if count > 0 { + return nil, common.NewBadRequestError("该转账流水号已入账,请勿重复操作") + } + } + + var creditedUsd decimal.Decimal + var balanceAfter decimal.Decimal + + err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + // 钱包存在性校验 + var wallet *struct { + ID int64 `json:"id"` + } + if err := dao.BilWallets.Ctx(ctx). + Where("tenant_id", tenantID). + Fields("id"). + Scan(&wallet); err != nil { + return err + } + if wallet == nil { + return common.NewBadRequestError("钱包不存在") + } + + // 原子入账:余额与累计充值同步累加(与 creditWalletTx 一致), + // 单条 UPDATE 避免 read-modify-write 竞态,并发下不丢更新。 + if _, err := g.DB().Ctx(ctx).Exec(ctx, + "UPDATE bil_wallets SET balance = balance + ?, cumulative_recharge = cumulative_recharge + ?, updated_at = NOW() WHERE id = ?", + usdAmount, usdAmount, wallet.ID); err != nil { + return err + } + + // 读回入账后余额,用于流水快照 + var bal struct { + Balance decimal.Decimal `json:"balance"` + FrozenBalance decimal.Decimal `json:"frozen_balance"` + } + if err := dao.BilWallets.Ctx(ctx). + Where("id", wallet.ID). + Fields("balance, frozen_balance"). + Scan(&bal); err != nil { + return err + } + + // 流水(type=recharge):描述拼装 CNY 快照 + 转账流水号,供现金对账与开票追溯 + desc := fmt.Sprintf("线下充值入账 CNY %.2f × 汇率 %.6f = USD %s", cnyAmount.InexactFloat64(), rate, usdAmount) + if req.Description != "" { + desc = req.Description + ";" + desc + } + if req.TransactionNo != "" { + desc += ";转账流水号 " + req.TransactionNo + } + if _, err := dao.BilTransactions.Ctx(ctx).Insert(do.BilTransactions{ + TenantId: tenantID, + WalletId: wallet.ID, + Type: "recharge", + Amount: usdAmount, + BalanceAfter: bal.Balance, + FrozenAfter: bal.FrozenBalance, + Description: desc, + }); err != nil { + return gerror.Wrapf(err, "record offline recharge transaction") + } + + // 充值后检查租户等级(仅升不降)并重置低余额预警标记 + if err := billing.CheckAndUpgradeLevel(ctx, tenantID); err != nil { + return gerror.Wrapf(err, "check upgrade level after offline recharge") + } + billing.ResetLowBalanceNotified(ctx, tenantID) + + creditedUsd = usdAmount + balanceAfter = bal.Balance + return nil + }) + if err != nil { + return nil, err + } + + // 事务提交后清除钱包两级缓存(进程内 + Redis),避免 300s TTL 内读到旧余额 + billing.InvalidateWallet(ctx, tenantID) + + return &v1.AdminWalletOfflineRechargeRes{ + CreditedUSD: billing.InexactFloat64(creditedUsd), + Rate: rate, + Balance: billing.InexactFloat64(balanceAfter), + }, nil +} + // GetWalletInfo 获取租户钱包信息(管理后台) func (s *sAdmin) GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) (*v1.AdminWalletInfoRes, error) { type walletRow struct { @@ -657,6 +774,27 @@ func (s *sAdmin) GetWalletTransactions(ctx context.Context, req *v1.AdminWalletT return nil, err } + // 批量关联用户名:user_id → tnt_users.username(consume 类型为实际消费用户,其余类型一般为空)。 + // 复用审计模块的批量查询工具,一次 IN 查询解决,避免逐行 N+1。 + if len(records) > 0 { + userKeys := make([]string, 0, len(records)) + seen := make(map[int64]bool, len(records)) + for _, r := range records { + if r.UserId > 0 && !seen[r.UserId] { + seen[r.UserId] = true + userKeys = append(userKeys, fmt.Sprintf("%d:%d", req.TenantID, r.UserId)) + } + } + if len(userKeys) > 0 { + userMap := common.BatchQueryUserNames(ctx, userKeys) + for _, r := range records { + if r.UserId > 0 { + r.Username = userMap[fmt.Sprintf("%d:%d", req.TenantID, r.UserId)] + } + } + } + } + return &v1.AdminWalletTransactionListRes{ List: records, Total: total, diff --git a/internal/middleware/rbac.go b/internal/middleware/rbac.go index 8ee0fdc..e0b0e22 100644 --- a/internal/middleware/rbac.go +++ b/internal/middleware/rbac.go @@ -134,6 +134,7 @@ var adminPermissionRules = []permissionRule{ {method: "GET", path: "/api/admin/wallets", perm: "billing:view"}, {method: "GET", prefix: "/api/admin/wallets/", perm: "billing:view"}, {method: "POST", prefix: "/api/admin/wallets/", suffix: "/adjust", perm: "billing:refund"}, + {method: "POST", prefix: "/api/admin/wallets/", suffix: "/offline-recharge", perm: "billing:refund"}, {method: "PUT", prefix: "/api/admin/wallets/", suffix: "/warning-threshold", perm: "billing:view"}, // ── plan 套餐管理 ── diff --git a/internal/service/admin.go b/internal/service/admin.go index 12fc491..33ff68a 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -162,6 +162,12 @@ type ( GetTenantWallets(ctx context.Context, req *v1.AdminWalletListReq) (*v1.AdminWalletListRes, error) // AdjustBalance 调整租户余额(管理后台) AdjustBalance(ctx context.Context, req *v1.AdminWalletAdjustReq) (*v1.AdminWalletAdjustRes, error) + // OfflineRecharge 线下充值入账(管理后台) + // 场景:用户线下银行转账(人民币 CNY),运营确认到账后按平台汇率换算为 USD 入账。 + // 与 AdjustBalance 的区别:走正规充值链路——余额与累计充值同步累加、触发等级检查、 + // 流水类型为 recharge,并在描述中携带 CNY 快照(原始人民币 + 汇率 + 入账 USD + 转账流水号), + // 供现金对账与开票追溯。 + OfflineRecharge(ctx context.Context, req *v1.AdminWalletOfflineRechargeReq) (*v1.AdminWalletOfflineRechargeRes, error) // GetWalletInfo 获取租户钱包信息(管理后台) GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) (*v1.AdminWalletInfoRes, error) // GetWalletTransactions 获取租户钱包交易流水(管理后台) diff --git a/web/admin/src/views/tenant/TenantDetailPage.vue b/web/admin/src/views/tenant/TenantDetailPage.vue index d7a9804..f7649c2 100644 --- a/web/admin/src/views/tenant/TenantDetailPage.vue +++ b/web/admin/src/views/tenant/TenantDetailPage.vue @@ -448,6 +448,9 @@ const txFilterType = ref('') const showRechargeModal = ref(false) const rechargeForm = reactive({ amount: 0, description: '' }) const rechargeLoading = ref(false) +const showOfflineRechargeModal = ref(false) +const offlineRechargeForm = reactive({ amount: 0, transaction_no: '', description: '' }) +const offlineRechargeLoading = ref(false) const showThresholdModal = ref(false) const thresholdForm = reactive({ threshold: 0 }) const thresholdLoading = ref(false) @@ -470,19 +473,22 @@ const txColumns: TableColumnData[] = [ }, }, { - title: '金额', dataIndex: 'amount', width: 120, + title: '金额', dataIndex: 'amount', width: 140, render({ record }) { const val = parseFloat(record.amount) || 0 const color = val >= 0 ? 'rgb(var(--green-6))' : 'rgb(var(--red-6))' const prefix = val >= 0 ? '+' : '' - return h('span', { style: { fontWeight: 600, color } }, `${prefix}${val.toFixed(4)}`) + return h('span', { style: { fontWeight: 600, color } }, `${prefix}${val.toFixed(6)}`) }, }, { - title: '变动后余额', dataIndex: 'balance_after', width: 120, - render({ record }) { return `$${(parseFloat(record.balance_after) || 0).toFixed(2)}` }, + title: '变动后余额', dataIndex: 'balance_after', width: 140, + render({ record }) { return `$${(parseFloat(record.balance_after) || 0).toFixed(6)}` }, + }, + { + title: '用户', dataIndex: 'username', width: 120, + render({ record }) { return record.username || '--' }, }, - { title: '用户', dataIndex: 'username', width: 120 }, { title: '请求ID', dataIndex: 'request_id', width: 140, ellipsis: true }, { title: '模型', dataIndex: 'model_name', width: 130 }, { title: '描述', dataIndex: 'description', ellipsis: true }, @@ -528,9 +534,9 @@ async function handleRecharge(done: (closed: boolean) => void) { try { await request.post(`/admin/wallets/${tenantId}/adjust`, { amount: rechargeForm.amount, - description: rechargeForm.description || (rechargeForm.amount > 0 ? '管理员充值' : '管理员扣减'), + description: rechargeForm.description || (rechargeForm.amount > 0 ? '管理员增加余额' : '管理员扣减余额'), }) - Message.success(rechargeForm.amount > 0 ? '充值成功' : '扣减成功') + Message.success(rechargeForm.amount > 0 ? '余额已增加' : '余额已扣减') done(true) await fetchWalletDetail() await fetchDetail() @@ -541,6 +547,35 @@ async function handleRecharge(done: (closed: boolean) => void) { } } +function openOfflineRecharge() { + offlineRechargeForm.amount = 0 + offlineRechargeForm.transaction_no = '' + offlineRechargeForm.description = '' + showOfflineRechargeModal.value = true +} + +async function handleOfflineRecharge(done: (closed: boolean) => void) { + if (!offlineRechargeForm.amount || offlineRechargeForm.amount <= 0) { Message.warning('请输入大于 0 的入账金额'); return } + if (!offlineRechargeForm.description.trim()) { Message.warning('请填写入账说明'); return } + offlineRechargeLoading.value = true + try { + const res: any = await request.post(`/admin/wallets/${tenantId}/offline-recharge`, { + amount: offlineRechargeForm.amount, + transaction_no: offlineRechargeForm.transaction_no || undefined, + description: offlineRechargeForm.description, + }) + const data = res.data?.data || {} + Message.success(`入账成功,到账 $${parseFloat(data.credited_usd || 0).toFixed(2)}(汇率 ${data.rate})`) + done(true) + await fetchWalletDetail() + await fetchDetail() + } catch { + return false + } finally { + offlineRechargeLoading.value = false + } +} + function openThreshold() { thresholdForm.threshold = walletInfo.value?.warning_threshold ? parseFloat(walletInfo.value.warning_threshold) : 0 showThresholdModal.value = true @@ -727,22 +762,23 @@ onMounted(() => { - ${{ (walletInfo.balance - walletInfo.frozen_balance).toFixed(2) }} + ${{ (walletInfo.balance - walletInfo.frozen_balance).toFixed(6) }} - ${{ parseFloat(walletInfo.balance).toFixed(2) }} + ${{ parseFloat(walletInfo.balance).toFixed(6) }} - ${{ parseFloat(walletInfo.frozen_balance).toFixed(2) }} + ${{ parseFloat(walletInfo.frozen_balance).toFixed(6) }} - {{ walletInfo.warning_threshold > 0 ? `$${parseFloat(walletInfo.warning_threshold).toFixed(2)}` : '关闭' }} + {{ walletInfo.warning_threshold > 0 ? `$${parseFloat(walletInfo.warning_threshold).toFixed(6)}` : '关闭' }} @@ -750,7 +786,7 @@ onMounted(() => { - - -
- 当前余额:${{ walletInfo ? parseFloat(walletInfo.balance).toFixed(2) : '0.00' }} + + +
+ 手动调整该租户的美元钱包余额(正数增加、负数扣减),用于运营补偿、余额纠错等场景。 +
+ 总余额 ${{ walletInfo ? parseFloat(walletInfo.balance).toFixed(6) : '0.000000' }} + | 可用余额 ${{ walletInfo ? (walletInfo.balance - walletInfo.frozen_balance).toFixed(6) : '0.000000' }} + (扣减上限)
- - + + - - + +
- 提示:输入正数充值余额,输入负数扣减余额 + 提示:扣减金额不能超过可用余额(不含冻结部分);如需人民币线下转账到账,请使用「线下入账」 +
+
+ + + +
+ 用于线下银行转账入账:输入人民币金额,到账按平台汇率换算为美元,并累计到累计充值(影响租户等级)。 +
+ + + + + + + + + + + + + +
+ 到账金额 = 人民币金额 × 平台汇率,精确到 6 位小数