Files
zhiyin/backend/src/modules/virtual-payment/virtual-payment.controller.ts
T
yuzhiran 81f86d995d v1.0.18: 小程序虚拟支付上线 + 定价调整为整数
- 新增虚拟支付 (short_series_coin 代币模式,1:1 兑换)
- 后端  修复为正确 VP 格式,返回 mode 参数
- 前端 VP 调用补齐 、 格式调整
- 套餐价格调整:成长版 ¥19.9 → ¥19,冲刺版 ¥49.9 → ¥49
- 数据库定价同步更新为 1900/4900(分)
- 会员页未登录时也拉取 ,套餐对比数据由服务端返回
- 文档统一更新定价和 VP 说明
- 修正 AGENTS.md 引力值数据(250/600 → 80/200)
2026-06-22 20:29:51 +08:00

260 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Controller, Post, Get, Param, Body, Query, UseGuards, HttpException, HttpStatus, Logger, Req, HttpCode } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { User, UserDocument } from '../user/user.schema'
import { PaymentOrder, PaymentOrderDocument } from '../payment/payment-order.schema'
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
import { CurrentUser } from '../../common/decorators/current-user.decorator'
import { VirtualPaymentService } from './virtual-payment.service'
import { PricingService } from '../schemas/pricing.service'
import { QuotaService } from '../user/quota.service'
import { Public } from '../../common/decorators/public.decorator'
@Controller('virtual-payment')
export class VirtualPaymentController {
private readonly logger = new Logger(VirtualPaymentController.name)
constructor(
@InjectModel(User.name) private userModel: Model<UserDocument>,
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
private vpService: VirtualPaymentService,
private pricingService: PricingService,
private quotaService: QuotaService,
) {}
/**
* 创建虚拟支付订单(小程序代币充值)
* 返回前端调起 wx.requestVirtualPayment 所需的全部参数
*/
@UseGuards(JwtAuthGuard)
@Post('create')
@HttpCode(200)
async create(
@CurrentUser('userId') userId: string,
@Body('type') type: string,
@Body('quantity') quantity: number = 1,
@Body('wxCode') wxCode: string,
@Req() req: any,
) {
if (!['interview', 'optimize', 'download', 'growth', 'sprint'].includes(type)) {
throw new HttpException('无效产品类型', HttpStatus.BAD_REQUEST)
}
const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (!user.wxOpenid) {
throw new HttpException({ message: '未绑定微信', needBindWx: true }, HttpStatus.BAD_REQUEST)
}
if (!wxCode) {
throw new HttpException('缺少 wxCode,请先调用 wx.login()', HttpStatus.BAD_REQUEST)
}
const isPlan = type === 'growth' || type === 'sprint'
if (isPlan && user.plan !== 'free') {
throw new HttpException('已是会员', HttpStatus.BAD_REQUEST)
}
const pricing = await this.pricingService.getConfig()
let totalFee: number
let qty = 1
let productQty = Math.max(1, Math.min(99, quantity || 1))
if (isPlan) {
const planCfg = pricing.plans[type]
if (!planCfg) throw new HttpException('套餐未配置', HttpStatus.INTERNAL_SERVER_ERROR)
totalFee = planCfg.price
} else {
const priceMap: Record<string, number> = {
interview: pricing.interview.pricePerSession,
optimize: pricing.resumeOptimize.pricePerOptimize,
download: pricing.resumeDownload.pricePerDownload,
}
qty = productQty
totalFee = priceMap[type] * qty
if (!totalFee) throw new HttpException('价格未配置', HttpStatus.INTERNAL_SERVER_ERROR)
}
const mode = 'short_series_coin'
const buyQuantity = totalFee / 100 // 控制台配 1 币 = 1 元,totalFee 单位分
const outTradeNo = `VP${type.slice(0, 2).toUpperCase()}${Date.now()}${userId.slice(-6)}`
const env = process.env.VP_SANDBOX === 'true' ? 1 : (process.env.NODE_ENV === 'production' ? 0 : 1)
const userIp = req.ip || '127.0.0.1'
// 1. 用 wx.login code 换取 session_key + openid,计算用户态签名
let openid: string
let signature: string
try {
const signData = this.vpService.buildSignData(outTradeNo, user.wxOpenid, totalFee, userIp, env, mode, buyQuantity)
const result = await this.vpService.exchangeCodeAndSign(wxCode, signData)
openid = result.openid
signature = result.signature
} catch (e: any) {
this.logger.error(`[VP] code2session 失败: userId=${userId}, wxCode=${wxCode?.slice(0, 20)}, error=${e.message}, stack=${e.stack?.slice(0, 300)}`)
throw new HttpException(`微信身份验证失败: ${e.message}`, HttpStatus.BAD_REQUEST)
}
// 校验 openid 一致
this.logger.log(`[VP] code2session 成功: userId=${userId}, wxOpenid=${user.wxOpenid}, code2session_openid=${openid}`)
if (openid !== user.wxOpenid) {
this.logger.warn(`[VP] openid 不匹配: userId=${userId}, stored=${user.wxOpenid}, got=${openid}`)
throw new HttpException('微信身份不匹配', HttpStatus.FORBIDDEN)
}
// 2. 计算支付签名 pay_sig
const signData = this.vpService.buildSignData(outTradeNo, openid, totalFee, userIp, env, mode, buyQuantity)
const paySig = this.vpService.computePaySig('requestVirtualPayment', signData, env)
// 3. 创建本地订单
let title: string
const titles: Record<string, string> = {
interview: 'AI 模拟面试',
optimize: '简历优化',
download: '简历下载',
growth: '成长版月度会员',
sprint: '冲刺版月度会员',
}
if (isPlan) {
title = titles[type]
} else {
title = qty > 1 ? `${titles[type]} ×${qty}` : titles[type]
}
await this.orderModel.create({
outTradeNo,
userId,
userPhone: user.phone || '',
amount: totalFee,
title,
status: 'pending',
channel: 'virtual',
type,
plan: isPlan ? type : 'growth',
metadata: { quantity: qty },
})
return {
outTradeNo,
env,
mode,
offerId: this.vpService.getOfferId(),
signData,
paySig,
signature,
openid,
}
}
/**
* 微信消息推送回调——虚拟支付通知
* 在小程序管理后台 → 开发 → 开发管理 → 消息推送 中配置服务器地址指向此接口
*/
@Public()
@Post('callback')
async callback(@Body() body: any, @Req() req: any) {
try {
// 微信消息体可能是 XML 或 JSON
const msg = body.xml || body
const event = msg.Event || msg.event
this.logger.log(`[vp-callback] event=${event}, body=${JSON.stringify(body).slice(0, 500)}`)
if (event === 'xpay_coin_pay_notify') {
await this.handleCoinPayNotify(msg)
} else if (event === 'xpay_goods_deliver_notify') {
await this.handleGoodsDeliverNotify(msg)
} else if (event === 'xpay_refund_notify') {
await this.handleRefundNotify(msg)
} else {
this.logger.warn(`[vp-callback] 未知事件: ${event}`)
}
return { ErrCode: 0, ErrMsg: 'success' }
} catch (e: any) {
this.logger.error(`[vp-callback] 处理失败: ${e.message}`)
return { ErrCode: -1, ErrMsg: e.message }
}
}
private async handleCoinPayNotify(msg: any) {
const outTradeNo = msg.OutTradeNo || msg.out_trade_no
if (!outTradeNo) {
this.logger.warn('[vp-callback] 代币支付通知缺少 outTradeNo')
return
}
const order = await this.orderModel.findOne({ outTradeNo }).exec()
if (!order) {
this.logger.warn(`[vp-callback] 订单不存在: ${outTradeNo}`)
return
}
if (order.status !== 'pending') {
this.logger.log(`[vp-callback] 订单已处理: ${outTradeNo} status=${order.status}`)
return
}
order.status = 'success'
order.paidAt = new Date()
order.description = `虚拟支付代币充值成功 env=${msg.Env ?? ''}`
await order.save()
const pricing = await this.pricingService.getConfig()
if (order.type === 'growth' || order.type === 'sprint') {
// 套餐激活
const planCfg = pricing.plans[order.type]
if (!planCfg) return
const expireAt = new Date()
expireAt.setDate(expireAt.getDate() + planCfg.durationDays)
await this.userModel.findByIdAndUpdate(order.userId, {
$set: {
plan: order.type,
[order.type === 'sprint' ? 'sprintExpireAt' : 'vipExpireAt']: expireAt,
...(order.type === 'sprint' ? { sprintRemaining: 10 } : {}),
},
}).exec()
await this.quotaService.setPlanQuota(order.userId, planCfg.gravityPerMonth)
this.logger.log(`[vp-callback] 套餐已激活: userId=${order.userId}, plan=${order.type}, gravityPerMonth=${planCfg.gravityPerMonth}`)
} else {
// 发放引力值(按次购买)
const gravityMap: Record<string, number> = {
interview: pricing.gravityRates.interviewPerUse,
optimize: pricing.gravityRates.optimizePerUse,
download: pricing.gravityRates.downloadPerUse,
}
const g = gravityMap[order.type]
const quantity = order.metadata?.quantity || 1
if (g) {
await this.quotaService.grantGravity(order.userId, g * quantity)
this.logger.log(`[vp-callback] 引力值已发放: userId=${order.userId}, gravity=${g * quantity}`)
}
}
}
private async handleGoodsDeliverNotify(msg: any) {
// 道具发货通知——代币模式下通常不需要额外处理
this.logger.log(`[vp-callback] 道具发货通知: outTradeNo=${msg.OutTradeNo}`)
}
private async handleRefundNotify(msg: any) {
const outTradeNo = msg.MchOrderId || msg.MchOrderNo
if (!outTradeNo) return
const order = await this.orderModel.findOne({ outTradeNo }).exec()
if (!order) return
order.status = 'refunded'
order.refundAmount = msg.RefundFee || order.amount
order.refundedAt = new Date()
await order.save()
this.logger.log(`[vp-callback] 订单已退款: ${outTradeNo}`)
}
/** 查询本地订单状态(前端轮询) */
@UseGuards(JwtAuthGuard)
@Get('check/:outTradeNo')
async checkOrder(@Param('outTradeNo') outTradeNo: string, @CurrentUser('userId') userId: string) {
const order = await this.orderModel.findOne({ outTradeNo, userId }).exec()
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
return { status: order.status, type: order.type, paidAt: order.paidAt }
}
}