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'
|
||||
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 次/月'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user