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)
This commit is contained in:
yuzhiran
2026-06-22 20:29:51 +08:00
parent 1a45822a58
commit 81f86d995d
10 changed files with 821 additions and 165 deletions
@@ -21,13 +21,13 @@ interface PricingConfig {
}
const DEFAULT_PRICING: PricingConfig = {
interview: { pricePerSession: 500, creditsPerPurchase: 1 },
resumeOptimize: { freeLimit: 3, pricePerOptimize: 300, creditsPerPurchase: 1 },
interview: { pricePerSession: 600, creditsPerPurchase: 1 },
resumeOptimize: { freeLimit: 3, pricePerOptimize: 400, creditsPerPurchase: 1 },
resumeDownload: { pricePerDownload: 200, creditsPerPurchase: 1 },
gravityRates: { interviewPerUse: 5, optimizePerUse: 3, downloadPerUse: 2 },
plans: {
growth: { price: 1990, durationDays: 30, gravityPerMonth: 250, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: ['免费版全部权益', 'AI 数字人面试每次 3 引力值(折扣价', '详细面试报告(四维评分)', '进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库', '简历优化 20 次/月', '简历下载 10 次/月'] },
sprint: { price: 4990, durationDays: 30, gravityPerMonth: 600, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: ['成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告', '学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先', '简历优化 50 次/月', '简历下载 30 次/月'] },
growth: { price: 1900, durationDays: 30, gravityPerMonth: 80, credits: { interview: 999, resumeOptimize: 20, resumeDownload: 10 }, features: ['免费版全部权益', 'AI 模拟面试每次消耗 5 引力值,无限次', '详细面试报告(四维评分 + 语音复盘', '进步轨迹雷达图 + 打卡日历', '每日一题推送 + 参考思路', '公司真题库', '每月赠送 80 引力值,可用于面试/优化/下载'] },
sprint: { price: 4900, durationDays: 30, gravityPerMonth: 200, credits: { interview: 999, resumeOptimize: 50, resumeDownload: 30 }, features: ['成长版全部权益', 'AI 语音深度分析(语气词/语速/停顿检测)', '技能缺口分析报告', '公司真题库精选', '每月赠送 200 引力值,可用于面试/优化/下载'] },
},
}
@@ -0,0 +1,259 @@
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 }
}
}
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { VirtualPaymentController } from './virtual-payment.controller'
import { VirtualPaymentService } from './virtual-payment.service'
import { User, UserSchema } from '../user/user.schema'
import { PaymentOrder, PaymentOrderSchema } from '../payment/payment-order.schema'
import { PricingModule } from '../schemas/pricing.module'
import { UserModule } from '../user/user.module'
@Module({
imports: [
MongooseModule.forFeature([
{ name: User.name, schema: UserSchema },
{ name: PaymentOrder.name, schema: PaymentOrderSchema },
]),
PricingModule,
UserModule,
],
controllers: [VirtualPaymentController],
providers: [VirtualPaymentService],
})
export class VirtualPaymentModule {}
@@ -0,0 +1,79 @@
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common'
import * as crypto from 'crypto'
import axios from 'axios'
const WX_APPID = requireEnv('WX_APPID')
const WX_SECRET = requireEnv('WX_SECRET')
const VP_APPKEY = requireEnv('VP_APPKEY')
const VP_APPKEY_SANDBOX = requireEnv('VP_APPKEY_SANDBOX')
const VP_OFFER_ID = requireEnv('VP_OFFER_ID')
function requireEnv(name: string): string {
const val = process.env[name]
if (!val) throw new InternalServerErrorException(`环境变量 ${name} 未配置`)
return val
}
@Injectable()
export class VirtualPaymentService {
private readonly logger = new Logger(VirtualPaymentService.name)
/** 计算支付签名 pay_sig */
computePaySig(uri: string, postBody: string, env: number): string {
const appKey = env === 1 ? VP_APPKEY_SANDBOX : VP_APPKEY
const data = `${uri}&${postBody}`
return crypto.createHmac('sha256', appKey).update(data).digest('hex')
}
/** 通过 wx.login code 换取 session_key 并计算用户态签名 */
async exchangeCodeAndSign(code: string, signData: string): Promise<{ openid: string; signature: string }> {
const res = await axios.get('https://api.weixin.qq.com/sns/jscode2session', {
params: { appid: WX_APPID, secret: WX_SECRET, js_code: code, grant_type: 'authorization_code' },
timeout: 10000,
})
if (res.data.errcode) {
throw new Error(`code2session 失败: ${res.data.errmsg}`)
}
const { openid, session_key } = res.data
if (!session_key) {
throw new Error('未获取到 session_key')
}
const signature = crypto.createHmac('sha256', session_key).update(signData).digest('hex')
return { openid, signature }
}
/** 构建 signData JSON 字符串(与 wx.requestVirtualPayment 要求的格式一致) */
buildSignData(outTradeNo: string, openid: string, totalFee: number, userIp: string, env: number, mode: string, buyQuantity?: number): string {
const base = {
offerId: VP_OFFER_ID,
env,
outTradeNo,
attach: outTradeNo,
currencyType: 'CNY' as const,
platform: 'android' as const,
zoneId: '',
}
if (mode === 'short_series_coin') {
// 代币充值
return JSON.stringify({
...base,
buyQuantity: buyQuantity || 1,
})
}
// short_series_goods — 道具直购
return JSON.stringify({
...base,
productId: openid,
goodsPrice: totalFee,
})
}
getOfferId(): string { return VP_OFFER_ID }
/** 验证消息推送签名(可选,依赖配置的 Token) */
verifyPushSignature(signature: string, timestamp: string, nonce: string, token: string): boolean {
const arr = [token, timestamp, nonce].sort()
const sha1 = crypto.createHash('sha1').update(arr.join('')).digest('hex')
return sha1 === signature
}
}