From e464da852cc99a080783948ce63761af74b39aca Mon Sep 17 00:00:00 2001 From: qian Date: Tue, 4 Aug 2026 00:18:10 +0800 Subject: [PATCH 01/29] =?UTF-8?q?feat(payment):=E6=94=AF=E4=BB=98=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/tenant/v1/order_tenant.go | 1 + internal/logic/common/settings_registry.go | 2 ++ internal/logic/tenant/order.go | 2 ++ .../src/views/system/settings/PaymentTab.vue | 18 ++++++++++++ web/tenant/src/views/finance/WalletPage.vue | 29 +++++++++++++++++++ 5 files changed, 52 insertions(+) diff --git a/api/tenant/v1/order_tenant.go b/api/tenant/v1/order_tenant.go index 0f6a447..8b3397a 100644 --- a/api/tenant/v1/order_tenant.go +++ b/api/tenant/v1/order_tenant.go @@ -119,6 +119,7 @@ type TenantPaymentInfoRes struct { AmountDiscount map[int]float64 `json:"amount_discount,omitempty"` MinTopUp int `json:"min_topup,omitempty"` Currency string `json:"currency,omitempty"` + PaymentNotice string `json:"payment_notice,omitempty"` } // TenantOrderExportReq 导出订单列表请求 diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 76ad4f0..673c249 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -158,6 +158,8 @@ var Registry = []SettingDef{ Label: "支付回调基础URL", Description: "为空则使用请求 Host"}, {Key: "payment_exchange_rate_cny_to_usd", Type: SettingTypeFloat, Default: "0.14", Category: "payment", Label: "CNY → USD 兑换比例", Description: "1 人民币兑换多少美元(USD → CNY 自动取倒数,确保往返闭合)", Validation: "min:0.001,max:100"}, + {Key: "payment_notice", Type: SettingTypeString, Default: "", Category: "payment", + Label: "支付说明", Description: "展示在租户端钱包充值页的说明文案,支持纯文本或 HTML 代码(如提示充值后不支持退款、指定渠道折扣活动等)。纯文本换行需用
,富文本请直接粘贴 HTML"}, // ── Performance ── {Key: "global_qps_limit", Type: SettingTypeInt, Default: "10000", Category: "performance", diff --git a/internal/logic/tenant/order.go b/internal/logic/tenant/order.go index e94a9d1..16903cf 100644 --- a/internal/logic/tenant/order.go +++ b/internal/logic/tenant/order.go @@ -309,6 +309,8 @@ func (s *sTenant) PaymentInfo(ctx context.Context, req *v1.TenantPaymentInfoReq) res.MinTopUp = int(settings.MinTopUp) res.Currency = settings.Currency } + // 支付说明由管理员在「系统设置-支付配置」配置,支持纯文本或 HTML,原样下发由前端渲染 + res.PaymentNotice = lcommon.Config().GetString(ctx, "payment_notice") return res, nil } diff --git a/web/admin/src/views/system/settings/PaymentTab.vue b/web/admin/src/views/system/settings/PaymentTab.vue index 2560092..0c6490f 100644 --- a/web/admin/src/views/system/settings/PaymentTab.vue +++ b/web/admin/src/views/system/settings/PaymentTab.vue @@ -86,6 +86,24 @@ function updateCnyToUsd(usdToCny: number | undefined) { + + +
+
支付说明
+
+ + + +
+
diff --git a/web/tenant/src/views/finance/WalletPage.vue b/web/tenant/src/views/finance/WalletPage.vue index 75d2f57..be5347d 100644 --- a/web/tenant/src/views/finance/WalletPage.vue +++ b/web/tenant/src/views/finance/WalletPage.vue @@ -412,6 +412,13 @@ onBeforeUnmount(() => { + + +
+ + +
+
@@ -800,6 +807,28 @@ onBeforeUnmount(() => { flex-shrink: 0; color: #d97706; } +/* 支付说明(管理员配置,支持 HTML) */ +.payment-notice { + display: flex; + align-items: flex-start; + gap: 0.5rem; + border-radius: 0.75rem; + background: #fffbeb; + border: 1px solid rgba(252, 211, 77, 0.6); + padding: 0.75rem 1rem; + color: #92400e; +} +.payment-notice-content { + flex: 1; + min-width: 0; + font-size: 0.8125rem; + line-height: 1.6; +} +.payment-notice-content :deep(p) { margin: 0 0 0.5rem; } +.payment-notice-content :deep(p:last-child) { margin-bottom: 0; } +.payment-notice-content :deep(a) { color: inherit; text-decoration: underline; } +.payment-notice-content :deep(strong) { font-weight: 600; } + .wallet-card { background: rgba(255, 255, 255, 0.94); } From 2e0119ee852532f8aa19588e6401dddaf213aa18 Mon Sep 17 00:00:00 2001 From: qian Date: Tue, 4 Aug 2026 00:48:02 +0800 Subject: [PATCH 02/29] =?UTF-8?q?improve(wallet):=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0=E7=9A=84=E9=92=B1=E5=8C=85?= =?UTF-8?q?=E5=8A=9F=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 位小数
From 6ca17262988000cf0c0ec90c7b4c3e028a046752 Mon Sep 17 00:00:00 2001 From: chengqian Date: Tue, 4 Aug 2026 14:35:58 +0800 Subject: [PATCH 03/29] =?UTF-8?q?fear(wallet):=E7=AE=A1=E7=90=86=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E5=9C=A8=E7=A7=9F=E6=88=B7=E9=92=B1=E5=8C=85=E9=87=8C?= =?UTF-8?q?=E9=9D=A2=E6=98=BE=E7=A4=BA=E8=A2=AB=E5=86=BB=E7=BB=93=E7=9A=84?= =?UTF-8?q?=E8=B5=84=E9=87=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin/admin.go | 2 + api/admin/v1/dashboard.go | 36 ++++ .../controller/admin/admin_v1_dashboard.go | 6 + internal/logic/admin/dashboard.go | 177 ++++++++++++++++ internal/logic/admin/wallet_frozen_test.go | 97 +++++++++ internal/middleware/rbac.go | 1 + internal/service/admin.go | 7 + .../src/views/tenant/TenantDetailPage.vue | 189 +++++++++++++++--- 8 files changed, 485 insertions(+), 30 deletions(-) create mode 100644 internal/logic/admin/wallet_frozen_test.go diff --git a/api/admin/admin.go b/api/admin/admin.go index 0a35fb2..cbc1274 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -88,6 +88,8 @@ type IAdminV1 interface { 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) + AdminWalletFrozenItemList(ctx context.Context, req *v1.AdminWalletFrozenItemListReq) (res *v1.AdminWalletFrozenItemListRes, err error) + AdminWalletFrozenRelease(ctx context.Context, req *v1.AdminWalletFrozenReleaseReq) (res *v1.AdminWalletFrozenReleaseRes, 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) AdminBillingRecordExport(ctx context.Context, req *v1.AdminBillingRecordExportReq) (res *v1.AdminBillingRecordExportRes, err error) diff --git a/api/admin/v1/dashboard.go b/api/admin/v1/dashboard.go index efb020d..48e4f63 100644 --- a/api/admin/v1/dashboard.go +++ b/api/admin/v1/dashboard.go @@ -325,6 +325,42 @@ type AdminWalletTransactionListRes struct { PageSize int `json:"page_size"` } +// AdminWalletFrozenItemListReq 冻结明细列表请求(预扣追踪表中 status=frozen 的记录) +type AdminWalletFrozenItemListReq struct { + g.Meta `path:"/wallets/{tenant_id}/frozen-items" method:"get" mime:"json" tags:"管理后台-钱包" summary:"冻结明细列表"` + TenantID int64 `json:"tenant_id" in:"path" v:"required|min:1" dc:"租户ID"` +} + +// AdminWalletFrozenItem 单笔冻结明细 +type AdminWalletFrozenItem struct { + RequestID string `json:"request_id" dc:"请求ID"` + ModelName string `json:"model_name" dc:"模型名称"` + Amount float64 `json:"amount" dc:"冻结金额(USD)"` + CreatedAt string `json:"created_at" dc:"冻结时间"` + AgeSeconds int64 `json:"age_seconds" dc:"已冻结时长(秒)"` + Releasable bool `json:"releasable" dc:"是否可释放(false 时 block_reason 说明原因)"` + NeedForce bool `json:"need_force" dc:"是否需要强制释放(冻结时长过短,可能仍有请求进行中)"` + BlockReason string `json:"block_reason" dc:"不可释放原因(如关联异步任务进行中)"` + TaskStatus string `json:"task_status" dc:"关联异步任务状态(无关联时为空)"` +} + +type AdminWalletFrozenItemListRes struct { + List []*AdminWalletFrozenItem `json:"list"` +} + +// AdminWalletFrozenReleaseReq 按笔释放冻结请求 +type AdminWalletFrozenReleaseReq struct { + g.Meta `path:"/wallets/{tenant_id}/frozen-items/release" method:"post" mime:"json" tags:"管理后台-钱包" summary:"释放单笔冻结"` + TenantID int64 `json:"tenant_id" in:"path" v:"required|min:1" dc:"租户ID"` + RequestID string `json:"request_id" v:"required" dc:"要释放的冻结项请求ID"` + Force bool `json:"force" dc:"强制释放(冻结时长不足保护期时必须显式传 true)"` + Reason string `json:"reason" v:"required" dc:"释放原因(必填,写入审计)"` +} + +type AdminWalletFrozenReleaseRes struct { + ReleasedAmount float64 `json:"released_amount" dc:"实际释放金额(USD)"` +} + type AdminWalletSetWarningThresholdReq struct { g.Meta `path:"/wallets/{tenant_id}/warning-threshold" method:"put" 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 ee14b71..158b8e7 100644 --- a/internal/controller/admin/admin_v1_dashboard.go +++ b/internal/controller/admin/admin_v1_dashboard.go @@ -61,3 +61,9 @@ func (c *ControllerV1) AdminDashboardModelHourly(ctx context.Context, req *v1.Ad func (c *ControllerV1) AdminWalletOfflineRecharge(ctx context.Context, req *v1.AdminWalletOfflineRechargeReq) (res *v1.AdminWalletOfflineRechargeRes, err error) { return service.Admin().OfflineRecharge(ctx, req) } +func (c *ControllerV1) AdminWalletFrozenItemList(ctx context.Context, req *v1.AdminWalletFrozenItemListReq) (res *v1.AdminWalletFrozenItemListRes, err error) { + return service.Admin().GetWalletFrozenItems(ctx, req) +} +func (c *ControllerV1) AdminWalletFrozenRelease(ctx context.Context, req *v1.AdminWalletFrozenReleaseReq) (res *v1.AdminWalletFrozenReleaseRes, err error) { + return service.Admin().ReleaseWalletFrozenItem(ctx, req) +} diff --git a/internal/logic/admin/dashboard.go b/internal/logic/admin/dashboard.go index 3adf006..f3b431f 100644 --- a/internal/logic/admin/dashboard.go +++ b/internal/logic/admin/dashboard.go @@ -833,6 +833,183 @@ func (s *sAdmin) SetWarningThreshold(ctx context.Context, req *v1.AdminWalletSet return &v1.AdminWalletSetWarningThresholdRes{}, nil } +// frozenReleaseMinAge 手动释放冻结的保护期。冻结时长低于此值的预扣大概率仍有请求在途 +// (长流式/realtime 会话),释放会使该请求失去超扣保护,必须显式 force 并由前端二次确认。 +const frozenReleaseMinAge = 10 * time.Minute + +// frozenTaskInfo 冻结项关联的异步任务信息(用于释放护栏判断) +type frozenTaskInfo struct { + Status string `json:"status"` + BillingSettled bool `json:"billing_settled"` +} + +// isTaskTerminal 异步任务是否已到终态 +func isTaskTerminal(status string) bool { + return status == "SUCCESS" || status == "FAILURE" +} + +// frozenItemGuard 计算单笔冻结项的释放护栏结论。 +// 返回:是否可释放、是否需要强制、拦截原因。 +func frozenItemGuard(task *frozenTaskInfo, age time.Duration) (releasable bool, needForce bool, blockReason string) { + if task != nil { + if !isTaskTerminal(task.Status) { + // 任务仍在推进:预扣由任务结算流程负责(成功结算/失败退款/30分钟超时退款), + // 手动释放会让后续结算认领不到冻结、track 终态错乱 + return false, false, "关联异步任务进行中,任务完成或超时后将自动结算/退款" + } + if !task.BillingSettled { + // 终态但未结算:轮询器 15 秒内会重试结算/退款,手动释放只会与之竞争 + return false, false, "关联异步任务待结算,系统将自动重试,请稍后刷新" + } + // 终态且已结算但 track 仍 frozen:结算认领异常留下的真孤儿,允许释放 + } + if age < frozenReleaseMinAge { + return true, true, "" + } + return true, false, "" +} + +// queryFrozenTaskInfos 批量查询冻结项关联的异步任务(request_id 剥离 _adjust 后缀后关联 tsk_model_tasks) +func queryFrozenTaskInfos(ctx context.Context, tenantID int64, requestIDs []string) map[string]*frozenTaskInfo { + result := make(map[string]*frozenTaskInfo) + if len(requestIDs) == 0 { + return result + } + baseIDs := make([]string, 0, len(requestIDs)) + seen := make(map[string]bool, len(requestIDs)) + for _, id := range requestIDs { + base := strings.TrimSuffix(id, "_adjust") + if !seen[base] { + seen[base] = true + baseIDs = append(baseIDs, base) + } + } + + type taskRow struct { + RequestId string `json:"request_id"` + Status string `json:"status"` + BillingSettled bool `json:"billing_settled"` + } + var rows []taskRow + if err := dao.TskModelTasks.Ctx(ctx). + Where("tenant_id", tenantID). + WhereIn("request_id", baseIDs). + Fields("request_id, status, billing_settled"). + Scan(&rows); err != nil { + g.Log().Warningf(ctx, "query frozen task infos: tenant=%d: %v", tenantID, err) + return result + } + for _, r := range rows { + result[r.RequestId] = &frozenTaskInfo{Status: r.Status, BillingSettled: r.BillingSettled} + } + return result +} + +// GetWalletFrozenItems 获取租户钱包冻结明细(管理后台)。 +// 数据源为 DB 预扣追踪表(释放操作的权威依据),而非 Redis 明细缓存。 +func (s *sAdmin) GetWalletFrozenItems(ctx context.Context, req *v1.AdminWalletFrozenItemListReq) (*v1.AdminWalletFrozenItemListRes, error) { + type trackRow struct { + RequestId string `json:"request_id"` + ModelName string `json:"model_name"` + Amount decimal.Decimal `json:"amount"` + CreatedAt *gtime.Time `json:"created_at"` + } + var rows []trackRow + err := dao.BilPredeductTracks.Ctx(ctx). + Where("tenant_id", req.TenantID). + Where("status", "frozen"). + Fields("request_id, model_name, amount, created_at"). + OrderAsc("created_at"). + Scan(&rows) + if err != nil { + return nil, err + } + + requestIDs := make([]string, 0, len(rows)) + for _, r := range rows { + requestIDs = append(requestIDs, r.RequestId) + } + taskInfos := queryFrozenTaskInfos(ctx, req.TenantID, requestIDs) + + now := time.Now() + items := make([]*v1.AdminWalletFrozenItem, 0, len(rows)) + for _, r := range rows { + age := now.Sub(r.CreatedAt.Time) + task := taskInfos[strings.TrimSuffix(r.RequestId, "_adjust")] + releasable, needForce, blockReason := frozenItemGuard(task, age) + taskStatus := "" + if task != nil { + taskStatus = task.Status + } + items = append(items, &v1.AdminWalletFrozenItem{ + RequestID: r.RequestId, + ModelName: r.ModelName, + Amount: billing.InexactFloat64(r.Amount), + CreatedAt: r.CreatedAt.String(), + AgeSeconds: int64(age.Seconds()), + Releasable: releasable, + NeedForce: needForce, + BlockReason: blockReason, + TaskStatus: taskStatus, + }) + } + + return &v1.AdminWalletFrozenItemListRes{List: items}, nil +} + +// ReleaseWalletFrozenItem 按笔释放冻结(管理后台运维逃生舱)。 +// 释放走 billing.UnfreezePreDeduct 的 status='frozen' 原子 claim 路径:与并发结算竞争安全、 +// 幂等、逐笔精确,绝不直接改写 bil_wallets.frozen_balance 汇总值。 +func (s *sAdmin) ReleaseWalletFrozenItem(ctx context.Context, req *v1.AdminWalletFrozenReleaseReq) (*v1.AdminWalletFrozenReleaseRes, error) { + type trackRow struct { + Amount decimal.Decimal `json:"amount"` + Status string `json:"status"` + CreatedAt *gtime.Time `json:"created_at"` + } + var track *trackRow + err := dao.BilPredeductTracks.Ctx(ctx). + Where("tenant_id", req.TenantID). + Where("request_id", req.RequestID). + Fields("amount, status, created_at"). + Scan(&track) + if err != nil { + return nil, err + } + if track == nil { + return nil, common.NewNotFoundError("冻结项") + } + if track.Status != "frozen" { + return nil, common.NewBadRequestError(fmt.Sprintf("该冻结项已处理(当前状态:%s),无需释放", track.Status)) + } + + // 护栏一:关联异步任务的预扣由任务结算流程负责,禁止手动释放(force 也不放行) + task := queryFrozenTaskInfos(ctx, req.TenantID, []string{req.RequestID})[strings.TrimSuffix(req.RequestID, "_adjust")] + age := time.Since(track.CreatedAt.Time) + releasable, needForce, blockReason := frozenItemGuard(task, age) + if !releasable { + return nil, common.NewBadRequestError(blockReason) + } + + // 护栏二:保护期内(可能仍有请求在途)必须显式强制释放 + if needForce && !req.Force { + return nil, common.NewBadRequestError(fmt.Sprintf( + "该笔冻结仅 %.0f 分钟,对应请求可能仍在进行中(长流式/实时会话),释放将使其失去超扣保护;确认后请使用强制释放", + age.Minutes())) + } + + if err := billing.UnfreezePreDeduct(ctx, req.TenantID, req.RequestID); err != nil { + return nil, err + } + + // 业务日志补充释放原因与操作上下文(HTTP 层审计由 OperationLog 中间件记录) + g.Log().Infof(ctx, "admin release frozen prededuct: tenant=%d request=%s amount=%s age=%s force=%v reason=%s", + req.TenantID, req.RequestID, track.Amount, age.Round(time.Second), req.Force, req.Reason) + + return &v1.AdminWalletFrozenReleaseRes{ + ReleasedAmount: billing.InexactFloat64(track.Amount), + }, nil +} + // GetAllTransactions 获取所有租户交易流水(管理后台) func (s *sAdmin) GetAllTransactions(ctx context.Context, req *v1.AdminTransactionListReq) (*v1.AdminTransactionListRes, error) { page, pageSize := common.NormalizePagination(req.Page, req.PageSize) diff --git a/internal/logic/admin/wallet_frozen_test.go b/internal/logic/admin/wallet_frozen_test.go new file mode 100644 index 0000000..10cd791 --- /dev/null +++ b/internal/logic/admin/wallet_frozen_test.go @@ -0,0 +1,97 @@ +package admin + +import ( + "testing" + "time" +) + +// TestFrozenItemGuard 覆盖手动释放冻结的护栏矩阵: +// 任务关联(非终态/终态未结算/终态已结算)×冻结时长(保护期内/外)。 +func TestFrozenItemGuard(t *testing.T) { + t.Parallel() + + oldAge := frozenReleaseMinAge + time.Minute + youngAge := frozenReleaseMinAge - time.Minute + + cases := []struct { + name string + task *frozenTaskInfo + age time.Duration + wantReleasable bool + wantNeedForce bool + wantBlocked bool + }{ + { + name: "无任务关联+超过保护期→直接可释放", + task: nil, age: oldAge, + wantReleasable: true, wantNeedForce: false, + }, + { + name: "无任务关联+保护期内→需强制", + task: nil, age: youngAge, + wantReleasable: true, wantNeedForce: true, + }, + { + name: "任务进行中→拦截(force 也不放行)", + task: &frozenTaskInfo{Status: "IN_PROGRESS"}, age: oldAge, + wantReleasable: false, wantBlocked: true, + }, + { + name: "任务已提交→拦截", + task: &frozenTaskInfo{Status: "SUBMITTED"}, age: youngAge, + wantReleasable: false, wantBlocked: true, + }, + { + name: "任务终态但未结算→拦截(轮询器将自动重试)", + task: &frozenTaskInfo{Status: "FAILURE", BillingSettled: false}, age: oldAge, + wantReleasable: false, wantBlocked: true, + }, + { + name: "任务终态且已结算+超过保护期→真孤儿,可释放", + task: &frozenTaskInfo{Status: "SUCCESS", BillingSettled: true}, age: oldAge, + wantReleasable: true, wantNeedForce: false, + }, + { + name: "任务终态且已结算+保护期内→需强制", + task: &frozenTaskInfo{Status: "SUCCESS", BillingSettled: true}, age: youngAge, + wantReleasable: true, wantNeedForce: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + releasable, needForce, blockReason := frozenItemGuard(tc.task, tc.age) + if releasable != tc.wantReleasable { + t.Errorf("releasable = %v, want %v", releasable, tc.wantReleasable) + } + if needForce != tc.wantNeedForce { + t.Errorf("needForce = %v, want %v", needForce, tc.wantNeedForce) + } + if tc.wantBlocked && blockReason == "" { + t.Errorf("blockReason 为空,预期给出拦截原因") + } + if !tc.wantBlocked && blockReason != "" { + t.Errorf("blockReason = %q,预期为空", blockReason) + } + }) + } +} + +// TestIsTaskTerminal 任务终态判断 +func TestIsTaskTerminal(t *testing.T) { + t.Parallel() + + terminal := []string{"SUCCESS", "FAILURE"} + nonTerminal := []string{"NOT_START", "SUBMITTED", "IN_PROGRESS", "QUEUED", ""} + + for _, s := range terminal { + if !isTaskTerminal(s) { + t.Errorf("isTaskTerminal(%q) = false, want true", s) + } + } + for _, s := range nonTerminal { + if isTaskTerminal(s) { + t.Errorf("isTaskTerminal(%q) = true, want false", s) + } + } +} diff --git a/internal/middleware/rbac.go b/internal/middleware/rbac.go index e0b0e22..9674cd7 100644 --- a/internal/middleware/rbac.go +++ b/internal/middleware/rbac.go @@ -135,6 +135,7 @@ var adminPermissionRules = []permissionRule{ {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: "POST", prefix: "/api/admin/wallets/", suffix: "/frozen-items/release", 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 33ff68a..99a9300 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -174,6 +174,13 @@ type ( GetWalletTransactions(ctx context.Context, req *v1.AdminWalletTransactionListReq) (*v1.AdminWalletTransactionListRes, error) // SetWarningThreshold 设置租户钱包预警阈值(管理后台) SetWarningThreshold(ctx context.Context, req *v1.AdminWalletSetWarningThresholdReq) (*v1.AdminWalletSetWarningThresholdRes, error) + // GetWalletFrozenItems 获取租户钱包冻结明细(管理后台)。 + // 数据源为 DB 预扣追踪表(释放操作的权威依据),而非 Redis 明细缓存。 + GetWalletFrozenItems(ctx context.Context, req *v1.AdminWalletFrozenItemListReq) (*v1.AdminWalletFrozenItemListRes, error) + // ReleaseWalletFrozenItem 按笔释放冻结(管理后台运维逃生舱)。 + // 释放走 billing.UnfreezePreDeduct 的 status='frozen' 原子 claim 路径:与并发结算竞争安全、 + // 幂等、逐笔精确,绝不直接改写 bil_wallets.frozen_balance 汇总值。 + ReleaseWalletFrozenItem(ctx context.Context, req *v1.AdminWalletFrozenReleaseReq) (*v1.AdminWalletFrozenReleaseRes, error) // GetAllTransactions 获取所有租户交易流水(管理后台) GetAllTransactions(ctx context.Context, req *v1.AdminTransactionListReq) (*v1.AdminTransactionListRes, error) // GetDashboardChannelHealth 获取渠道健康概览(最不健康的5个活跃渠道) diff --git a/web/admin/src/views/tenant/TenantDetailPage.vue b/web/admin/src/views/tenant/TenantDetailPage.vue index f7649c2..0d66e95 100644 --- a/web/admin/src/views/tenant/TenantDetailPage.vue +++ b/web/admin/src/views/tenant/TenantDetailPage.vue @@ -6,6 +6,7 @@ import type { TableColumnData } from '@arco-design/web-vue' import PageHeader from '@/components/PageHeader.vue' import TableStats from '@/components/TableStats.vue' import request from '@/utils/request' +import { hasPermission } from '@/utils/permission' const route = useRoute() const router = useRouter() @@ -433,7 +434,11 @@ function onTabChange(key: string) { fetchTenantGroups() } if (key === 'wallet') { - fetchWalletDetail() + fetchWalletInfo() + fetchFrozenItems() + } + if (key === 'transactions') { + fetchWalletTransactions() } } @@ -519,7 +524,79 @@ async function fetchWalletTransactions() { } async function fetchWalletDetail() { - await Promise.all([fetchWalletInfo(), fetchWalletTransactions()]) + await Promise.all([fetchWalletInfo(), fetchWalletTransactions(), fetchFrozenItems()]) +} + +// === 冻结明细(预扣追踪,按笔释放)=== +const frozenItems = ref([]) +const frozenLoading = ref(false) +const showReleaseModal = ref(false) +const releaseTarget = ref(null) +const releaseForm = reactive({ reason: '' }) +const releaseLoading = ref(false) + +function formatFrozenAge(seconds: number): string { + if (seconds < 60) return `${seconds} 秒` + if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟` + return `${Math.floor(seconds / 3600)} 小时 ${Math.floor((seconds % 3600) / 60)} 分` +} + +const frozenColumns: TableColumnData[] = [ + { title: '请求ID', dataIndex: 'request_id', width: 200, ellipsis: true, tooltip: true }, + { + title: '模型', dataIndex: 'model_name', width: 140, + render({ record }) { return record.model_name || '--' }, + }, + { + title: '冻结金额', dataIndex: 'amount', width: 130, + render({ record }) { + return h('span', { style: { color: 'rgb(var(--orange-6))', fontWeight: 600 } }, `$${(parseFloat(record.amount) || 0).toFixed(6)}`) + }, + }, + { title: '冻结时间', dataIndex: 'created_at', width: 170 }, + { + title: '已冻结时长', dataIndex: 'age_seconds', width: 110, + render({ record }) { return formatFrozenAge(record.age_seconds) }, + }, + { title: '状态', slotName: 'frozenStatus' }, + { title: '操作', slotName: 'frozenAction', width: 110 }, +] + +async function fetchFrozenItems() { + frozenLoading.value = true + try { + const res: any = await request.get(`/admin/wallets/${tenantId}/frozen-items`) + frozenItems.value = res.data?.data?.list || [] + } catch { + } finally { + frozenLoading.value = false + } +} + +function openRelease(item: any) { + releaseTarget.value = item + releaseForm.reason = '' + showReleaseModal.value = true +} + +async function handleRelease(done: (closed: boolean) => void) { + if (!releaseForm.reason.trim()) { Message.warning('请填写释放原因'); return } + releaseLoading.value = true + try { + const res: any = await request.post(`/admin/wallets/${tenantId}/frozen-items/release`, { + request_id: releaseTarget.value.request_id, + force: !!releaseTarget.value.need_force, + reason: releaseForm.reason.trim(), + }) + const amt = parseFloat(res.data?.data?.released_amount || 0) + Message.success(`已释放冻结 $${amt.toFixed(6)}`) + done(true) + await fetchWalletDetail() + } catch { + return false + } finally { + releaseLoading.value = false + } } function openRecharge() { @@ -783,40 +860,69 @@ onMounted(() => { - - + + - - + + 以下金额处于预扣冻结中。正常情况下请求结束即自动释放,异常滞留最长约 2 小时由系统自动清理;仅当用户反馈冻结长时间未释放时才需手动处理。 + + + + + + + + + + + + + + @@ -1060,6 +1166,29 @@ onMounted(() => { + + + + +
From 2e37402940f9656d7240e7449302b2f80201a523 Mon Sep 17 00:00:00 2001 From: chengqian Date: Tue, 4 Aug 2026 16:15:58 +0800 Subject: [PATCH 04/29] =?UTF-8?q?improve(ui):=E4=BC=98=E5=8C=96=E7=A7=9F?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E7=9A=84=E7=99=BB=E5=BD=95=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/tenant/src/views/auth/LoginPage.vue | 63 ++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/web/tenant/src/views/auth/LoginPage.vue b/web/tenant/src/views/auth/LoginPage.vue index bb97c2f..cf550b6 100644 --- a/web/tenant/src/views/auth/LoginPage.vue +++ b/web/tenant/src/views/auth/LoginPage.vue @@ -291,19 +291,26 @@ async function handleOAuthLogin(provider: string) { -
+