From c161ffbc3c7e57a5927f13f2392db74fde7359b7 Mon Sep 17 00:00:00 2001 From: yuzhiran Date: Thu, 18 Jun 2026 19:33:10 +0800 Subject: [PATCH] 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 --- backend/src/modules/admin/admin.controller.ts | 65 +++++++++++-- .../modules/payment/payment-order.schema.ts | 3 + .../src/modules/payment/wechat-pay.service.ts | 57 +++++++++++- zhiyin-app/src/pages/admin/admin.vue | 91 ++++++++++++++++++- zhiyin-app/src/pages/member/member.vue | 9 +- 5 files changed, 207 insertions(+), 18 deletions(-) diff --git a/backend/src/modules/admin/admin.controller.ts b/backend/src/modules/admin/admin.controller.ts index 242dde3..6737350 100644 --- a/backend/src/modules/admin/admin.controller.ts +++ b/backend/src/modules/admin/admin.controller.ts @@ -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' - user.vipExpireAt = expireAt - await this.quotaService.setPlanQuota(order.userId, 'growth', credits) + 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, 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 次/月'], }, }, } diff --git a/backend/src/modules/payment/payment-order.schema.ts b/backend/src/modules/payment/payment-order.schema.ts index e872bec..5a72ec0 100644 --- a/backend/src/modules/payment/payment-order.schema.ts +++ b/backend/src/modules/payment/payment-order.schema.ts @@ -54,6 +54,9 @@ export class PaymentOrder { @Prop() refundReason?: string + + @Prop() + refundId?: string // 微信退款单号 } export const PaymentOrderSchema = SchemaFactory.createForClass(PaymentOrder) diff --git a/backend/src/modules/payment/wechat-pay.service.ts b/backend/src/modules/payment/wechat-pay.service.ts index 8d50473..f8d40d4 100644 --- a/backend/src/modules/payment/wechat-pay.service.ts +++ b/backend/src/modules/payment/wechat-pay.service.ts @@ -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 { + 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 + } } diff --git a/zhiyin-app/src/pages/admin/admin.vue b/zhiyin-app/src/pages/admin/admin.vue index 0b3932a..b526909 100644 --- a/zhiyin-app/src/pages/admin/admin.vue +++ b/zhiyin-app/src/pages/admin/admin.vue @@ -142,8 +142,10 @@ {{ o.status === 'success' ? '已支付' : o.status === 'refunded' ? '已退款' : '待支付' }} {{ o.createdAt ? o.createdAt.slice(0,16).replace('T',' ') : '--' }} - - 同步 + + 同步 + 退款 + 查询 @@ -338,6 +340,29 @@ + + + + 退款 - {{ refundModal.order?.outTradeNo }} + + 订单金额 + ¥{{ ((refundModal.order?.amount || 0) / 100).toFixed(1) }} + + + 退款金额(元) + + + + 退款原因 + + + + + + + + + @@ -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; } diff --git a/zhiyin-app/src/pages/member/member.vue b/zhiyin-app/src/pages/member/member.vue index cde6be3..27e5424 100644 --- a/zhiyin-app/src/pages/member/member.vue +++ b/zhiyin-app/src/pages/member/member.vue @@ -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 {