improve(wallet):优化管理后台的钱包功能

This commit is contained in:
qian committed 2026-08-04 00:48:02 +08:00
1 parent e464da852c
commit 2e0119ee85
7 files changed
+250 -24

No files matched your search

+1
View File
@@ -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)
+14
View File
@@ -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"`
@@ -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)
}
+138
View File
@@ -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,
+1
View File
@@ -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 套餐管理 ──
+6
View File
@@ -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 获取租户钱包交易流水(管理后台)
+87 -24
View File
@@ -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(() => {
<ACard :bordered="false" title="钱包信息" class="mb-4">
<template #extra>
<ASpace>
<AButton type="primary" @click="openRecharge">充值 / 调整</AButton>
<AButton type="primary" @click="openRecharge">调整余额</AButton>
<AButton status="success" @click="openOfflineRecharge">线下入账</AButton>
<AButton @click="openThreshold">预警设置</AButton>
</ASpace>
</template>
<ADescriptions :column="3" bordered size="medium">
<ADescriptionsItem label="可用余额">
<span class="money">${{ (walletInfo.balance - walletInfo.frozen_balance).toFixed(2) }}</span>
<span class="money">${{ (walletInfo.balance - walletInfo.frozen_balance).toFixed(6) }}</span>
</ADescriptionsItem>
<ADescriptionsItem label="总余额">
${{ parseFloat(walletInfo.balance).toFixed(2) }}
${{ parseFloat(walletInfo.balance).toFixed(6) }}
</ADescriptionsItem>
<ADescriptionsItem label="冻结余额">
<span style="color: rgb(var(--orange-6))">${{ parseFloat(walletInfo.frozen_balance).toFixed(2) }}</span>
<span style="color: rgb(var(--orange-6))">${{ parseFloat(walletInfo.frozen_balance).toFixed(6) }}</span>
</ADescriptionsItem>
<ADescriptionsItem label="预警阈值">
{{ walletInfo.warning_threshold > 0 ? `$${parseFloat(walletInfo.warning_threshold).toFixed(2)}` : '关闭' }}
{{ walletInfo.warning_threshold > 0 ? `$${parseFloat(walletInfo.warning_threshold).toFixed(6)}` : '关闭' }}
</ADescriptionsItem>
</ADescriptions>
</ACard>
@@ -750,7 +786,7 @@ onMounted(() => {
<!-- Transaction History -->
<ACard :bordered="false" title="交易记录">
<template #extra>
<ARadioGroup v-model="txFilterType" type="button" size="small" @change="() => { txPagination.current = 1; fetchWalletDetail() }">
<ARadioGroup v-model="txFilterType" type="button" size="small" @change="() => { txPagination.current = 1; fetchWalletTransactions() }">
<ARadio value="">全部</ARadio>
<ARadio value="recharge">充值</ARadio>
<ARadio value="redemption">兑换码</ARadio>
@@ -773,8 +809,8 @@ onMounted(() => {
:page-size-options="[10, 20, 50]"
show-page-size
show-jumper
@change="fetchWalletDetail"
@page-size-change="() => { txPagination.current = 1; fetchWalletDetail() }"
@change="fetchWalletTransactions"
@page-size-change="() => { txPagination.current = 1; fetchWalletTransactions() }"
/>
</div>
</ACard>
@@ -964,23 +1000,50 @@ onMounted(() => {
</template>
</ADrawer>
<!-- Recharge / Adjust Balance Modal -->
<AModal v-model:visible="showRechargeModal" title="充值 / 调整余额" :width="420" :on-before-ok="handleRecharge" :ok-loading="rechargeLoading">
<div class="mb-3 text-sm" style="color: var(--ta-text-tertiary)">
当前余额:${{ walletInfo ? parseFloat(walletInfo.balance).toFixed(2) : '0.00' }}
<!-- Adjust Balance Modal -->
<AModal v-model:visible="showRechargeModal" title="调整余额" :width="440" :on-before-ok="handleRecharge" :ok-loading="rechargeLoading">
<div class="mb-3 text-sm leading-6" style="color: var(--ta-text-secondary)">
手动调整该租户的美元钱包余额(正数增加、负数扣减),用于运营补偿、余额纠错等场景。
<br />
总余额 <span class="money">${{ walletInfo ? parseFloat(walletInfo.balance).toFixed(6) : '0.000000' }}</span>
| 可用余额 <span class="money">${{ walletInfo ? (walletInfo.balance - walletInfo.frozen_balance).toFixed(6) : '0.000000' }}</span>
<span style="color: var(--ta-text-tertiary)">(扣减上限)</span>
</div>
<AForm :model="rechargeForm" layout="vertical">
<AFormItem label="金额" required>
<AInputNumber v-model="rechargeForm.amount" :precision="2" :step="1" placeholder="正数=充值,负数=扣减" class="w-full">
<AFormItem label="调整金额(USD)" required>
<AInputNumber v-model="rechargeForm.amount" :precision="2" :step="1" placeholder="正数=增加余额,负数=扣减余额" class="w-full">
<template #prefix>$</template>
</AInputNumber>
</AFormItem>
<AFormItem label="描述">
<ATextarea v-model="rechargeForm.description" placeholder="操作原因(选填,默认自动生成)" :auto-size="{ minRows: 2, maxRows: 4 }" />
<AFormItem label="调整说明">
<ATextarea v-model="rechargeForm.description" placeholder="如:运营补偿 / 余额纠错。建议填写,便于对账与审计" :auto-size="{ minRows: 2, maxRows: 4 }" />
</AFormItem>
</AForm>
<div class="mt-2 text-xs" style="color: var(--ta-text-tertiary)">
提示:输入正数充值余额,输入负数扣减余额
提示:扣减金额不能超过可用余额(不含冻结部分);如需人民币线下转账到账,请使用「线下入账」
</div>
</AModal>
<!-- Offline Recharge Modal -->
<AModal v-model:visible="showOfflineRechargeModal" title="线下充值入账" :width="440" :on-before-ok="handleOfflineRecharge" :ok-loading="offlineRechargeLoading">
<div class="mb-3 text-sm" style="color: var(--ta-text-tertiary)">
用于线下银行转账入账:输入人民币金额,到账按平台汇率换算为美元,并累计到累计充值(影响租户等级)。
</div>
<AForm :model="offlineRechargeForm" layout="vertical">
<AFormItem label="入账金额(人民币)" required>
<AInputNumber v-model="offlineRechargeForm.amount" :min="0.01" :precision="2" placeholder="如 100" class="w-full">
<template #prefix>¥</template>
</AInputNumber>
</AFormItem>
<AFormItem label="转账流水号">
<AInput v-model="offlineRechargeForm.transaction_no" placeholder="银行转账流水号(选填,用于对账与防重复入账)" />
</AFormItem>
<AFormItem label="入账说明" required>
<ATextarea v-model="offlineRechargeForm.description" placeholder="如:客户名称 / 转账原因" :auto-size="{ minRows: 2, maxRows: 4 }" />
</AFormItem>
</AForm>
<div class="mt-2 text-xs" style="color: var(--ta-text-tertiary)">
到账金额 = 人民币金额 × 平台汇率,精确到 6 位小数
</div>
</AModal>