删除钱包充值功能并消除重复入账风险
钱包充值为开发态测试功能(生产环境本就禁用),且支付回调存在重复入账风险:入账与标记 paid 两步非原子、wallet_ledger 去重无唯一索引、幂等键使用了可变的 ProviderOrderID。直接删除该功能从根本上消除风险。 后端: - payment 移除 StartWalletRecharge/QueryWalletRecharge 及相关 handler/DTO/常量;confirmPaid 增加 OrderID 守卫;解除对 wallet 仓库的依赖 - wallet 移除 Recharge/ConfirmRechargeFromChannel 及相关定义 - 移除三条充值路由;adminfinance 财务统计口径只统计 order_pay - 清理充值相关测试用例 前端: - 移除充值 API、WalletView 充值面板/弹窗、admin 充值标签与筛选 - 保留钱包余额、流水、提现等核心能力 go build/vet 与 vue-tsc typecheck 均通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3ef7705776
commit
e8621728fd
@@ -162,7 +162,6 @@ function paymentBizTypeLabel(type: string) {
|
||||
admin_refund: '人工退款',
|
||||
cancel_refund: '取消退款',
|
||||
admin_close_refund: '客服关闭退款',
|
||||
wallet_recharge: '钱包充值',
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const filters = reactive({
|
||||
|
||||
const payAmount = computed(() =>
|
||||
payments.value
|
||||
.filter(item => item.biz_type === 'order_pay' || item.biz_type === 'wallet_recharge')
|
||||
.filter(item => item.biz_type === 'order_pay')
|
||||
.reduce((sum, item) => sum + Number(item.amount_cent || 0), 0)
|
||||
)
|
||||
const refundAmount = computed(() =>
|
||||
@@ -97,7 +97,6 @@ function paymentStatusLabel(status: string) {
|
||||
function bizTypeLabel(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
order_pay: '订单支付',
|
||||
wallet_recharge: '钱包充值',
|
||||
cancel_refund: '取消退款',
|
||||
admin_close_refund: '客服关闭退款',
|
||||
admin_refund: '人工退款',
|
||||
@@ -172,7 +171,6 @@ function jsonText(value: unknown) {
|
||||
<el-option label="仲裁退款" value="arbitration_refund" />
|
||||
<el-option label="人工退款" value="admin_refund" />
|
||||
<el-option label="取消退款" value="cancel_refund" />
|
||||
<el-option label="钱包充值" value="wallet_recharge" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
|
||||
@@ -2,8 +2,6 @@ import { apiClient } from '@/shared/api/client'
|
||||
|
||||
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
|
||||
import type { BalanceType, LedgerDirection, WalletStatus } from '@/shared/types/status'
|
||||
import type { PaymentOrder } from '@/features/orders/api/orders'
|
||||
import { yuanToCent } from '@/shared/utils/money'
|
||||
|
||||
export interface WalletAccount {
|
||||
user_id: number
|
||||
@@ -41,26 +39,3 @@ export async function fetchWalletLedger(page = 1, pageSize = 20) {
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function rechargeWallet(amountYuan: number) {
|
||||
const amount_cent = yuanToCent(amountYuan)
|
||||
const { data } = await apiClient.post<ApiResponse<WalletAccount>>('/wallet/recharge', {
|
||||
amount_cent,
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function startWalletRechargePayment(amountYuan: number) {
|
||||
const amount_cent = yuanToCent(amountYuan)
|
||||
const { data } = await apiClient.post<ApiResponse<PaymentOrder>>('/wallet/recharge/pay', {
|
||||
amount_cent,
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function queryWalletRechargePayment(id: number) {
|
||||
const { data } = await apiClient.post<ApiResponse<PaymentOrder>>(
|
||||
`/wallet/recharge/pay/${id}/query`
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -1,36 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { readError } from '@/shared/utils/error'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
CircleCheck,
|
||||
Coin,
|
||||
Loading,
|
||||
Lock,
|
||||
Money,
|
||||
Refresh,
|
||||
Tickets,
|
||||
Wallet as WalletIcon,
|
||||
} from '@element-plus/icons-vue'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
import {
|
||||
fetchWalletBalance,
|
||||
fetchWalletLedger,
|
||||
queryWalletRechargePayment,
|
||||
startWalletRechargePayment,
|
||||
type WalletAccount,
|
||||
type WalletLedger,
|
||||
} from '@/features/wallet'
|
||||
import type { PaymentOrder } from '@/features/orders'
|
||||
import {
|
||||
balanceTypeLabel,
|
||||
ledgerDirectionLabel,
|
||||
walletStatusLabel,
|
||||
} from '@/shared/utils/statusLabels'
|
||||
import { formatDateTime } from '@/shared/utils/time'
|
||||
import { formatCentWithSymbol, formatMoney } from '@/shared/utils/money'
|
||||
import { formatCentWithSymbol } from '@/shared/utils/money'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
@@ -39,16 +31,6 @@ const ledger = ref<WalletLedger[]>([])
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const isDev = import.meta.env.DEV
|
||||
const quickRechargeAmounts = [0.01, 1, 10, 100]
|
||||
const devRechargeAmount = ref(0.01)
|
||||
const devRecharging = ref(false)
|
||||
const rechargeDialogVisible = ref(false)
|
||||
const activeRechargePayment = ref<PaymentOrder | null>(null)
|
||||
const rechargeQRCodeURL = ref('')
|
||||
const rechargeQRGenerating = ref(false)
|
||||
const checkingRecharge = ref(false)
|
||||
let rechargePollingTimer: number | null = null
|
||||
|
||||
const walletMetrics = computed(() => {
|
||||
if (!account.value) {
|
||||
@@ -80,13 +62,6 @@ const walletMetrics = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(loadWallet)
|
||||
onBeforeUnmount(stopRechargePolling)
|
||||
|
||||
watch(rechargeDialogVisible, visible => {
|
||||
if (!visible) {
|
||||
stopRechargePolling()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadWallet() {
|
||||
loading.value = true
|
||||
@@ -116,114 +91,8 @@ function handleWithdraw() {
|
||||
router.push('/wallet/withdrawal')
|
||||
}
|
||||
|
||||
function selectDevRechargeAmount(amount: number) {
|
||||
devRechargeAmount.value = amount
|
||||
}
|
||||
|
||||
function rechargePayURL(payment = activeRechargePayment.value) {
|
||||
return payment?.jspay_url || payment?.td_code || payment?.jspay_info || ''
|
||||
}
|
||||
|
||||
async function renderRechargeQRCode(payment = activeRechargePayment.value) {
|
||||
const payURL = rechargePayURL(payment)
|
||||
rechargeQRCodeURL.value = ''
|
||||
if (!payURL) return
|
||||
rechargeQRGenerating.value = true
|
||||
try {
|
||||
rechargeQRCodeURL.value = await QRCode.toDataURL(payURL, {
|
||||
width: 220,
|
||||
margin: 1,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: {
|
||||
dark: '#111827',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
ElMessage.error('二维码生成失败')
|
||||
} finally {
|
||||
rechargeQRGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDevRecharge() {
|
||||
if (!isDev) return
|
||||
if (devRechargeAmount.value <= 0) {
|
||||
ElMessage.warning('请输入充值金额')
|
||||
return
|
||||
}
|
||||
devRecharging.value = true
|
||||
try {
|
||||
const payment = await startWalletRechargePayment(devRechargeAmount.value)
|
||||
activeRechargePayment.value = payment
|
||||
rechargeDialogVisible.value = true
|
||||
await renderRechargeQRCode(payment)
|
||||
if (payment.paid) {
|
||||
ElMessage.success('充值成功')
|
||||
rechargeDialogVisible.value = false
|
||||
await loadWallet()
|
||||
} else {
|
||||
startRechargePolling()
|
||||
ElMessage.success('测试支付单已创建')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '创建测试充值失败'))
|
||||
} finally {
|
||||
devRecharging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDevRechargeStatus(options: { silent?: boolean } = {}) {
|
||||
if (!activeRechargePayment.value || checkingRecharge.value) return
|
||||
const silent = options.silent === true
|
||||
checkingRecharge.value = true
|
||||
try {
|
||||
const payment = await queryWalletRechargePayment(activeRechargePayment.value.id)
|
||||
activeRechargePayment.value = payment
|
||||
await renderRechargeQRCode(payment)
|
||||
if (payment.paid) {
|
||||
stopRechargePolling()
|
||||
ElMessage.success('充值成功')
|
||||
rechargeDialogVisible.value = false
|
||||
await loadWallet()
|
||||
} else {
|
||||
if (!silent) {
|
||||
ElMessage.info('支付暂未完成')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
ElMessage.error(readError(error, '查询支付状态失败'))
|
||||
}
|
||||
} finally {
|
||||
checkingRecharge.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startRechargePolling() {
|
||||
stopRechargePolling()
|
||||
rechargePollingTimer = window.setInterval(() => {
|
||||
void checkDevRechargeStatus({ silent: true })
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function stopRechargePolling() {
|
||||
if (!rechargePollingTimer) return
|
||||
window.clearInterval(rechargePollingTimer)
|
||||
rechargePollingTimer = null
|
||||
}
|
||||
|
||||
function openRechargePayURL() {
|
||||
const payURL = rechargePayURL()
|
||||
if (!payURL) return
|
||||
window.open(payURL, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
|
||||
function walletBizTypeLabel(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
dev_recharge: '测试充值',
|
||||
channel_recharge: '渠道充值',
|
||||
order_pay: '订单支付',
|
||||
order_lock: '订单冻结',
|
||||
channel_order_lock: '支付冻结',
|
||||
@@ -302,40 +171,6 @@ function amountPrefix(direction: string) {
|
||||
</div>
|
||||
|
||||
<div class="wallet-workspace">
|
||||
<section v-if="isDev" class="recharge-panel">
|
||||
<div class="panel-title">
|
||||
<div class="panel-title-icon is-orange">
|
||||
<el-icon><Coin /></el-icon>
|
||||
</div>
|
||||
<div>
|
||||
<h2>测试充值</h2>
|
||||
<p>开发环境可见,用于快速验证当前支付渠道配置。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="quick-amounts">
|
||||
<button
|
||||
v-for="amount in quickRechargeAmounts"
|
||||
:key="amount"
|
||||
type="button"
|
||||
:class="{ active: devRechargeAmount === amount }"
|
||||
@click="selectDevRechargeAmount(amount)"
|
||||
>
|
||||
{{ formatMoney(amount) }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="recharge-action-row">
|
||||
<el-input-number v-model="devRechargeAmount" :min="0.01" :precision="2" :step="1" />
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Coin"
|
||||
:loading="devRecharging"
|
||||
@click="handleDevRecharge"
|
||||
>
|
||||
创建测试支付
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ledger-summary-card">
|
||||
<div class="panel-title">
|
||||
<div class="panel-title-icon is-blue">
|
||||
@@ -398,44 +233,6 @@ function amountPrefix(direction: string) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="rechargeDialogVisible" title="测试充值支付" width="520px">
|
||||
<div class="recharge-cashier">
|
||||
<div class="cashier-amount">
|
||||
<span>支付金额</span>
|
||||
<strong>{{
|
||||
activeRechargePayment
|
||||
? formatCentWithSymbol(activeRechargePayment.amount_cent)
|
||||
: `¥${formatMoney(devRechargeAmount)}`
|
||||
}}</strong>
|
||||
</div>
|
||||
<div v-if="rechargePayURL()" class="cashier-qr">
|
||||
<div class="qr-box">
|
||||
<el-icon v-if="rechargeQRGenerating" class="qr-loading"><Loading /></el-icon>
|
||||
<img v-else-if="rechargeQRCodeURL" :src="rechargeQRCodeURL" alt="测试充值支付二维码" />
|
||||
<span v-else>支付链接已生成</span>
|
||||
</div>
|
||||
<el-button text type="primary" @click="openRechargePayURL">打开支付链接</el-button>
|
||||
</div>
|
||||
<el-alert
|
||||
v-else
|
||||
title="支付单已创建,但渠道未返回可展示的支付链接"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<div class="cashier-meta" v-if="activeRechargePayment">
|
||||
<span>支付单号:{{ activeRechargePayment.payment_no }}</span>
|
||||
<span>渠道:{{ activeRechargePayment.provider }}</span>
|
||||
<span>状态:{{ activeRechargePayment.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="rechargeDialogVisible = false">关闭</el-button>
|
||||
<el-button type="primary" :loading="checkingRecharge" @click="checkDevRechargeStatus"
|
||||
>查询支付状态</el-button
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -569,7 +366,6 @@ function amountPrefix(direction: string) {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.recharge-panel,
|
||||
.ledger-summary-card {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
@@ -612,11 +408,6 @@ function amountPrefix(direction: string) {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.panel-title-icon.is-orange {
|
||||
background: #fff4ec;
|
||||
color: #ff6b00;
|
||||
}
|
||||
|
||||
.panel-title h2 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
@@ -629,101 +420,6 @@ function amountPrefix(direction: string) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.quick-amounts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.quick-amounts button {
|
||||
min-width: 92px;
|
||||
height: 36px;
|
||||
border: 1px solid #d8dee9;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-amounts button.active,
|
||||
.quick-amounts button:hover {
|
||||
border-color: #ff8a3d;
|
||||
background: #fff4ec;
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.recharge-action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.recharge-cashier {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.cashier-amount {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e6eaf2;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.cashier-amount span,
|
||||
.cashier-meta {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cashier-amount strong {
|
||||
color: #111a44;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.cashier-qr {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.qr-box {
|
||||
display: grid;
|
||||
width: 236px;
|
||||
height: 236px;
|
||||
place-items: center;
|
||||
border: 1px solid #e6eaf2;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.qr-box img {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.qr-loading {
|
||||
color: #64748b;
|
||||
font-size: 28px;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.cashier-meta {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.wallet-ledger-table {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e6eaf2;
|
||||
@@ -937,14 +633,6 @@ function amountPrefix(direction: string) {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.recharge-action-row :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recharge-action-row :deep(.el-button) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pay-qr-panel {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: center;
|
||||
|
||||
Reference in New Issue
Block a user