feat: payment refund support + admin payment management
- Add refund()/queryRefund()/downloadPlatformCerts() to WechatPayService - Add refundId field to PaymentOrder schema - Fix WeChat Pay callback to auto-download platform certs on verification failure - Fix syncOrder to handle sprint plan properly - Add admin refund, refund-query, order-detail endpoints - Add refund UI (button, modal, query) to admin.vue orders tab - Fix member.vue MP payment: pass outTradeNo instead of prepayId to pollPayResult
This commit is contained in:
@@ -279,12 +279,19 @@ export class AdminController {
|
||||
const user = await this.userModel.findById(order.userId).exec()
|
||||
if (user && user.plan === 'free') {
|
||||
const pricing = await this.pricingService.getConfig()
|
||||
const credits = pricing.plans?.growth?.credits || { interview: 999, resumeOptimize: 20, resumeDownload: 10 }
|
||||
const planId = order.plan === 'sprint' ? 'sprint' : 'growth'
|
||||
const planCfg = pricing.plans?.[planId]
|
||||
const credits = planCfg?.credits || { interview: 999, resumeOptimize: 20, resumeDownload: 10 }
|
||||
const expireAt = new Date()
|
||||
expireAt.setDate(expireAt.getDate() + (pricing.plans?.growth?.durationDays || VIP_DURATION_DAYS))
|
||||
user.plan = 'growth'
|
||||
expireAt.setDate(expireAt.getDate() + (planCfg?.durationDays || VIP_DURATION_DAYS))
|
||||
user.plan = planId
|
||||
if (planId === 'sprint') {
|
||||
user.sprintExpireAt = expireAt
|
||||
user.sprintRemaining = 10
|
||||
} else {
|
||||
user.vipExpireAt = expireAt
|
||||
await this.quotaService.setPlanQuota(order.userId, 'growth', credits)
|
||||
}
|
||||
await this.quotaService.setPlanQuota(order.userId, planId, credits)
|
||||
}
|
||||
} else {
|
||||
const pricing = await this.pricingService.getConfig()
|
||||
@@ -302,6 +309,46 @@ export class AdminController {
|
||||
return { order, wxResult }
|
||||
}
|
||||
|
||||
/** 订单详情(含用户信息) */
|
||||
@Get('order/:outTradeNo')
|
||||
async getOrderDetail(@Param('outTradeNo') outTradeNo: string) {
|
||||
const order = await this.orderModel.findOne({ outTradeNo }).lean().exec()
|
||||
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
|
||||
const user = await this.userModel.findById(order.userId).select('phone nickname plan').lean().exec()
|
||||
return { order, user }
|
||||
}
|
||||
|
||||
/** 发起退款 */
|
||||
@Post('order/refund')
|
||||
async refundOrder(@Body('outTradeNo') outTradeNo: string, @Body('amount') amount?: number, @Body('reason') reason?: string) {
|
||||
const order = await this.orderModel.findOne({ outTradeNo }).exec()
|
||||
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
|
||||
if (order.status !== 'success') throw new HttpException('仅支付成功的订单可退款', HttpStatus.BAD_REQUEST)
|
||||
if (order.refundAmount && order.refundAmount > 0) throw new HttpException('该订单已退款', HttpStatus.BAD_REQUEST)
|
||||
|
||||
const result = await this.wechatPay.refund(outTradeNo, order.amount, amount || order.amount, reason)
|
||||
const refundId = result?.refund_id || ''
|
||||
|
||||
order.status = 'refunded'
|
||||
order.refundAmount = amount || order.amount
|
||||
order.refundedAt = new Date()
|
||||
order.refundReason = reason || ''
|
||||
order.refundId = refundId
|
||||
await order.save()
|
||||
|
||||
return { success: true, refundId }
|
||||
}
|
||||
|
||||
/** 查询微信侧退款状态 */
|
||||
@Get('order/refund/:outTradeNo')
|
||||
async queryRefund(@Param('outTradeNo') outTradeNo: string) {
|
||||
const order = await this.orderModel.findOne({ outTradeNo }).lean().exec()
|
||||
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
|
||||
if (!order.refundId) return { localStatus: order.status, message: '无微信退款单号' }
|
||||
const wxResult = await this.wechatPay.queryRefund(order.refundId)
|
||||
return { localStatus: order.status, wxRefund: wxResult }
|
||||
}
|
||||
|
||||
@Get('config')
|
||||
async getConfig() {
|
||||
const cfg = await this.configModel.findOne({ key: 'site_config' }).exec()
|
||||
@@ -362,8 +409,8 @@ const DEFAULT_CONFIG = {
|
||||
optimize: { dailyFreeLimit: 2 },
|
||||
price: { monthly: 1990 },
|
||||
plans: {
|
||||
free: { name: '免费版', price: 0, features: ['AI 模拟面试 1 次(体验)', '每场最多 5 轮 AI 对话', '基础面试报告', '简历优化(限 3 次)'] },
|
||||
growth: { name: '成长版', price: 1990, features: ['免费版全部权益', 'AI 数字人面试无限次', '每场最多 10 轮 AI 对话', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'] },
|
||||
free: { name: '免费版', price: 0, features: ['AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费)'] },
|
||||
growth: { name: '成长版', price: 1990, features: ['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'] },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -375,12 +422,12 @@ const DEFAULT_PRICING = {
|
||||
growth: {
|
||||
price: 1990, durationDays: 30,
|
||||
credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 },
|
||||
features: ['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'],
|
||||
features: ['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'],
|
||||
},
|
||||
sprint: {
|
||||
price: 4990, durationDays: 30,
|
||||
credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 },
|
||||
features: ['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先', '简历优化 50 次/月', '简历下载 30 次/月'],
|
||||
features: ['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '公司真题库精选', '简历优化 50 次/月', '简历下载 30 次/月'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ export class PaymentOrder {
|
||||
|
||||
@Prop()
|
||||
refundReason?: string
|
||||
|
||||
@Prop()
|
||||
refundId?: string // 微信退款单号
|
||||
}
|
||||
|
||||
export const PaymentOrderSchema = SchemaFactory.createForClass(PaymentOrder)
|
||||
|
||||
@@ -122,18 +122,23 @@ export class WechatPayService {
|
||||
|
||||
/** 验证并解密回调通知 */
|
||||
verifyAndDecrypt(body: any, wechatSignature: string, wechatTimestamp: string, wechatNonce: string) {
|
||||
// 1. 验签
|
||||
const message = `${wechatTimestamp}\n${wechatNonce}\n${JSON.stringify(body)}\n`
|
||||
const certDir = path.resolve(__dirname, '../../certs')
|
||||
if (!fs.existsSync(certDir)) {
|
||||
this.logger.error(`证书目录不存在: ${certDir}`)
|
||||
return null
|
||||
}
|
||||
const platformCert = fs.readFileSync(path.join(certDir, 'pub_key.pem'), 'utf8')
|
||||
const pemPath = path.join(certDir, 'pub_key.pem')
|
||||
if (!fs.existsSync(pemPath)) {
|
||||
this.logger.error('平台证书 pub_key.pem 不存在,请运行 downloadPlatformCerts')
|
||||
return null
|
||||
}
|
||||
const platformCert = fs.readFileSync(pemPath, 'utf8')
|
||||
const verify = crypto.createVerify('RSA-SHA256').update(message)
|
||||
const isValid = verify.verify(platformCert, wechatSignature, 'base64')
|
||||
if (!isValid) {
|
||||
this.logger.warn('微信支付回调验签失败')
|
||||
this.logger.warn('微信支付回调验签失败,尝试重新下载平台证书...')
|
||||
this.downloadPlatformCerts().catch(e => this.logger.error(`自动更新证书失败: ${e.message}`))
|
||||
return null
|
||||
}
|
||||
// 2. 解密 resource
|
||||
@@ -143,7 +148,6 @@ export class WechatPayService {
|
||||
const nonce = resource.nonce
|
||||
const key = API_V3_KEY
|
||||
if (!key) throw new Error('WX_API_V3_KEY 未配置')
|
||||
// AES-256-GCM 解密
|
||||
const authTag = ciphertext.subarray(ciphertext.length - 16)
|
||||
const data = ciphertext.subarray(0, ciphertext.length - 16)
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(key), nonce)
|
||||
@@ -157,4 +161,49 @@ export class WechatPayService {
|
||||
async queryOrder(outTradeNo: string) {
|
||||
return this.request('GET', `/v3/pay/transactions/out-trade-no/${outTradeNo}?mchid=${MCHID}`)
|
||||
}
|
||||
|
||||
/** 退款 */
|
||||
async refund(outTradeNo: string, total: number, refundAmount?: number, reason?: string) {
|
||||
const body: any = {
|
||||
out_trade_no: outTradeNo,
|
||||
out_refund_no: `RF${Date.now()}`,
|
||||
amount: { refund: refundAmount || total, total, currency: 'CNY' },
|
||||
}
|
||||
if (reason) body.reason = reason
|
||||
return this.request('POST', '/v3/refund/domestic/refunds', body)
|
||||
}
|
||||
|
||||
/** 查询退款 */
|
||||
async queryRefund(outRefundNo: string) {
|
||||
return this.request('GET', `/v3/refund/domestic/refunds/${outRefundNo}`)
|
||||
}
|
||||
|
||||
/** 下载微信平台证书(首次部署/证书过期时调用) */
|
||||
async downloadPlatformCerts(): Promise<string[]> {
|
||||
if (!API_V3_KEY) throw new Error('WX_API_V3_KEY 未配置')
|
||||
const certs = await this.request('GET', '/v3/certificates')
|
||||
const downloaded: string[] = []
|
||||
const certDir = path.resolve(__dirname, '../../certs')
|
||||
if (!fs.existsSync(certDir)) fs.mkdirSync(certDir, { recursive: true })
|
||||
|
||||
for (const item of certs.data || []) {
|
||||
const { serial_no, effective_time, expire_time, encrypt_certificate } = item
|
||||
const { algorithm, nonce, associated_data, ciphertext } = encrypt_certificate
|
||||
if (algorithm !== 'AEAD_AES_256_GCM' || !nonce) continue
|
||||
|
||||
const cipherBuf = Buffer.from(ciphertext, 'base64')
|
||||
const authTag = cipherBuf.subarray(cipherBuf.length - 16)
|
||||
const data = cipherBuf.subarray(0, cipherBuf.length - 16)
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(API_V3_KEY), nonce)
|
||||
decipher.setAAD(Buffer.from(associated_data))
|
||||
decipher.setAuthTag(authTag)
|
||||
const decrypted = decipher.update(data) + decipher.final('utf8')
|
||||
|
||||
const pemPath = path.join(certDir, 'pub_key.pem')
|
||||
fs.writeFileSync(pemPath, decrypted)
|
||||
downloaded.push(serial_no)
|
||||
this.logger.log(`微信平台证书已更新: ${serial_no}, 有效期至 ${expire_time}`)
|
||||
}
|
||||
return downloaded
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,8 +142,10 @@
|
||||
{{ o.status === 'success' ? '已支付' : o.status === 'refunded' ? '已退款' : '待支付' }}
|
||||
</view>
|
||||
<text class="order-time">{{ o.createdAt ? o.createdAt.slice(0,16).replace('T',' ') : '--' }}</text>
|
||||
<view class="order-actions" v-if="o.status === 'pending'">
|
||||
<text class="sync-btn" @click="syncOrder(o.outTradeNo)">同步</text>
|
||||
<view class="order-actions">
|
||||
<text class="sync-btn" v-if="o.status === 'pending'" @click="syncOrder(o.outTradeNo)">同步</text>
|
||||
<text class="refund-btn" v-if="o.status === 'success'" @click="openRefundModal(o)">退款</text>
|
||||
<text class="sync-btn" v-if="o.status === 'refunded'" @click="queryRefund(o.outTradeNo)">查询</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -338,6 +340,29 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 退款弹窗 -->
|
||||
<view class="modal-mask" v-if="refundModal.show" @click="closeRefundModal">
|
||||
<view class="modal-content" @click.stop>
|
||||
<text class="modal-title">退款 - {{ refundModal.order?.outTradeNo }}</text>
|
||||
<view class="cfg-row">
|
||||
<text>订单金额</text>
|
||||
<text class="cfg-val">¥{{ ((refundModal.order?.amount || 0) / 100).toFixed(1) }}</text>
|
||||
</view>
|
||||
<view class="cfg-row">
|
||||
<text>退款金额(元)</text>
|
||||
<input class="cfg-input" type="digit" v-model.number="refundAmount" :placeholder="((refundModal.order?.amount || 0) / 100).toFixed(1)" />
|
||||
</view>
|
||||
<view class="cfg-row">
|
||||
<text>退款原因</text>
|
||||
<input class="cfg-input" style="width:300rpx" v-model="refundReason" placeholder="选填" />
|
||||
</view>
|
||||
<view class="modal-actions">
|
||||
<button class="modal-btn cancel" @click="closeRefundModal">取消</button>
|
||||
<button class="modal-btn confirm" style="background:#EF4444" @click="doRefund">确认退款</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 管理员 -->
|
||||
<view v-if="tab === 'admins'" class="section">
|
||||
<view class="search-bar">
|
||||
@@ -448,6 +473,67 @@ const creditTypes = ref([
|
||||
{ key: 'shareCredits', label: '分享积分', value: 0 },
|
||||
])
|
||||
|
||||
// Refund modal
|
||||
const refundModal = ref({ show: false, order: null })
|
||||
const refundAmount = ref(0)
|
||||
const refundReason = ref('')
|
||||
|
||||
const openRefundModal = (order) => {
|
||||
refundModal.value = { show: true, order }
|
||||
refundAmount.value = order.amount / 100
|
||||
refundReason.value = ''
|
||||
}
|
||||
|
||||
const closeRefundModal = () => {
|
||||
refundModal.value = { show: false, order: null }
|
||||
}
|
||||
|
||||
const doRefund = async () => {
|
||||
const order = refundModal.value.order
|
||||
if (!order) return
|
||||
uni.showModal({
|
||||
title: '确认退款', content: `确定对订单 ${order.outTradeNo} 退款 ¥${refundAmount.value.toFixed(1)}?`,
|
||||
success: async (r) => {
|
||||
if (!r.confirm) return
|
||||
try {
|
||||
const res = await apiAdmin('/order/refund', {
|
||||
method: 'POST',
|
||||
body: { outTradeNo: order.outTradeNo, amount: Math.round(refundAmount.value * 100), reason: refundReason.value },
|
||||
})
|
||||
if (res.statusCode === 200) {
|
||||
uni.showToast({ title: '退款成功', icon: 'success' })
|
||||
closeRefundModal()
|
||||
loadOrders()
|
||||
} else {
|
||||
uni.showToast({ title: res.data?.message || '退款失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '退款失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const queryRefund = async (outTradeNo) => {
|
||||
uni.showToast({ title: '查询中...', icon: 'none' })
|
||||
try {
|
||||
const res = await apiAdmin('/order/refund/' + outTradeNo)
|
||||
if (res.statusCode === 200) {
|
||||
const wx = res.data.wxRefund
|
||||
if (wx) {
|
||||
uni.showModal({
|
||||
title: '退款状态',
|
||||
content: `微信状态: ${wx.status || '--'}\n退款金额: ¥${(wx.amount?.refund || 0) / 100}\n建议以微信侧为准`,
|
||||
})
|
||||
} else {
|
||||
uni.showToast({ title: '本地状态: ' + (res.data.localStatus || '--'), icon: 'none' })
|
||||
}
|
||||
} else {
|
||||
uni.showToast({ title: '查询失败', icon: 'none' })
|
||||
}
|
||||
} catch { uni.showToast({ title: '查询失败', icon: 'none' }) }
|
||||
}
|
||||
|
||||
const token = () => uni.getStorageSync('token') || ''
|
||||
|
||||
const apiAdmin = (path, opts = {}) => {
|
||||
@@ -860,6 +946,7 @@ onMounted(() => { doVerify() })
|
||||
.order-time { font-size: 20rpx; color: var(--color-text-tertiary); flex: 1; text-align: right; }
|
||||
.order-actions { }
|
||||
.sync-btn { font-size: 20rpx; color: var(--color-primary); padding: 4rpx 12rpx; border: 2rpx solid var(--color-primary); border-radius: var(--radius-round); }
|
||||
.refund-btn { font-size: 20rpx; color: #EF4444; padding: 4rpx 12rpx; border: 2rpx solid #EF4444; border-radius: var(--radius-round); }
|
||||
.config-card { background: #FFF; border-radius: var(--radius-sm); padding: 20rpx; margin-bottom: 12rpx; }
|
||||
.cfg-title { font-size: 24rpx; font-weight: 600; color: var(--color-text); margin-bottom: 12rpx; }
|
||||
.cfg-row { display: flex; justify-content: space-between; padding: 8rpx 0; font-size: 22rpx; color: var(--color-text-secondary); align-items: center; }
|
||||
|
||||
@@ -107,8 +107,8 @@ const growthPriceText = ref('¥19.9')
|
||||
const sprintPriceText = ref('¥49.9')
|
||||
const currentOutTradeNo = ref('')
|
||||
const freeFeatures = ref(['AI 模拟面试 1 次(体验)', '基础面试报告', '通用题库随机出题', '简历优化(限 3 次免费)'])
|
||||
const growthFeatures = ref(['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库', '每场最多 10 轮 AI 对话'])
|
||||
const sprintFeatures = ref(['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先', 'AI 实时提示功能'])
|
||||
const growthFeatures = ref(['免费版全部权益', 'AI 数字人面试无限次', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题', '参考回答思路', '公司真题库'])
|
||||
const sprintFeatures = ref(['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '公司真题库精选'])
|
||||
|
||||
const token = () => uni.getStorageSync('token') || ''
|
||||
|
||||
@@ -197,7 +197,10 @@ const startPay = async (selectedPlan) => {
|
||||
package: pp.package,
|
||||
signType: pp.signType || 'RSA',
|
||||
paySign: pp.paySign,
|
||||
success: () => pollPayResult(res.data.prepayId, planLabel),
|
||||
success: () => {
|
||||
const no = currentOutTradeNo.value || res.data.outTradeNo
|
||||
pollPayResult(no, planLabel)
|
||||
},
|
||||
fail: (err) => { payError.value = '支付取消或失败'; uni.showToast({ title: '支付取消', icon: 'none' }) },
|
||||
})
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user