v4.2 冲刺版+每日推送+支付修复+全量代码评审

## 新增功能
- 冲刺版 ¥49.9/月:完整支付→激活→权益扣减链路
- 每日一题定时推送(@nestjs/schedule,早8点微信订阅消息)
- miniprogram-ci 编译上传脚本(scripts/upload-mp.js)

## Bug修复
- 套餐值统一:vip→growth/sprint(interview轮次限制、analyze次数检查)
- member/pay 移除开发绕过:改为订单校验后激活
- progress→report 参数名不匹配:id→interviewId
- result.vue resume.create() 参数传错(对象→独立参数)
- resume.vue analyze请求缺少Authorization header
- bank.vue contribution请求缺少Authorization header
- member.vue startPay() 缺少try/catch导致网络错误崩溃
- login.vue 调试面板 v-if="true" 生产泄漏

## 配置
- 微信支付生产证书就位(商户号1113760598)
- .env 清理冗余文件(删除.example/.production)
- WX_NOTIFY_URL 更新为 zhiyinwx.yzrcloud.cn

## 文档
- PROJECT-STATUS.md v4.1→v4.2,状态全面更新
- DEPLOYMENT.md 新增小程序编译上传章节、清理检查清单
This commit is contained in:
yuzhiran
2026-06-09 20:03:05 +08:00
parent 37cfdfe93c
commit 9276ab9028
44 changed files with 15205 additions and 2062 deletions
+54 -57
View File
@@ -1,12 +1,14 @@
import { Controller, Post, Get, Body, HttpException, HttpStatus, UseGuards } from '@nestjs/common'
import { Controller, Post, Get, Body, HttpException, HttpStatus, UseGuards, Logger } 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 { Public } from '../../common/decorators/public.decorator'
const GROWTH_PRICE = 1990
const SPRINT_PRICE = 4990
const DURATION_DAYS = 30
const FREE_DAILY_LIMIT = 2
@@ -20,30 +22,23 @@ interface PlanConfig {
const PLANS: Record<string, PlanConfig> = {
free: {
id: 'free',
name: '免费版',
price: 0,
dailyLimit: FREE_DAILY_LIMIT,
id: 'free', name: '免费版', price: 0, dailyLimit: FREE_DAILY_LIMIT,
features: [
'每日 2 次 AI 模拟面试',
'基础面试报告',
'通用题库随机出题',
'简历诊断(限 3 次)',
'每日 2 次 AI 模拟面试', '基础面试报告', '通用题库随机出题', '简历诊断(限 3 次)',
],
},
growth: {
id: 'growth',
name: '成长版',
price: GROWTH_PRICE,
dailyLimit: 999,
id: 'growth', name: '成长版', price: GROWTH_PRICE, dailyLimit: 999,
features: [
'免费版全部权益',
'无限面试次数',
'详细面试报告(四维评分)',
'进步轨迹雷达图 + 打卡',
'每日一题推送',
'参考回答思路',
'公司真题库',
'免费版全部权益', '无限面试次数', '详细面试报告(四维评分)',
'进步轨迹雷达图 + 打卡', '每日一题推送', '参考回答思路', '公司真题库',
],
},
sprint: {
id: 'sprint', name: '冲刺版', price: SPRINT_PRICE, dailyLimit: 999,
features: [
'成长版全部权益', 'AI 语音分析(语气词/语速检测)', '技能缺口分析报告',
'学习路径推荐', '真人导师 1v1 点评(每月 1 次)', '简历精修(每月 1 次)', '内推优先',
],
},
}
@@ -51,30 +46,25 @@ const PLANS: Record<string, PlanConfig> = {
@Controller('member')
@UseGuards(JwtAuthGuard)
export class MemberController {
private readonly logger = new Logger(MemberController.name)
constructor(
@InjectModel(User.name) private userModel: Model<UserDocument>,
@InjectModel(PaymentOrder.name) private orderModel: Model<PaymentOrderDocument>,
) {}
// 公开的套餐配置(给前端会员页和限制拦截用)
@Public()
@Get('plans')
getPlans() {
return {
interview: {
dailyFreeLimit: FREE_DAILY_LIMIT,
maxRoundsFree: 5,
maxRoundsVip: 10,
},
interview: { dailyFreeLimit: FREE_DAILY_LIMIT, maxRoundsFree: 5, maxRoundsVip: 10 },
diagnosis: { dailyFreeLimit: 2 },
optimize: { dailyFreeLimit: 2 },
price: { monthly: GROWTH_PRICE },
price: { monthly: GROWTH_PRICE, sprint: SPRINT_PRICE },
plans: Object.values(PLANS).map(p => ({
id: p.id,
name: p.name,
price: p.price,
id: p.id, name: p.name, price: p.price,
priceDisplay: p.price === 0 ? '免费' : `¥${(p.price / 100).toFixed(1)}/月`,
dailyLimit: p.dailyLimit,
features: p.features,
dailyLimit: p.dailyLimit, features: p.features,
})),
}
}
@@ -90,41 +80,48 @@ export class MemberController {
remaining: user.remaining,
dailyLimit: planConfig.dailyLimit,
vipExpireAt: user.vipExpireAt,
sprintExpireAt: user.sprintExpireAt,
sprintRemaining: user.sprintRemaining || 0,
isVip: user.plan !== 'free',
}
}
@Post('create-order')
async createOrder(@CurrentUser('userId') userId: string) {
const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
const orderId = `ZHI${Date.now()}${userId.slice(-4)}`
return {
orderId,
planId: 'growth',
planName: '成长版',
amount: GROWTH_PRICE,
amountDisplay: `¥${(GROWTH_PRICE / 100).toFixed(1)}`,
duration: `${DURATION_DAYS}`,
}
}
/** 凭订单激活套餐(前端 JSAPI 支付成功后兜底调用) */
@Post('pay')
async pay(@CurrentUser('userId') userId: string, @Body('orderId') orderId: string) {
async pay(@CurrentUser('userId') userId: string, @Body('outTradeNo') outTradeNo: string) {
const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (user.plan !== 'free') return { success: true, plan: user.plan, message: '已是会员' }
const order = await this.orderModel.findOne({ outTradeNo, userId }).exec()
if (!order) throw new HttpException('订单不存在', HttpStatus.NOT_FOUND)
if (order.status !== 'success') throw new HttpException('支付未完成', HttpStatus.BAD_REQUEST)
const expireAt = new Date()
expireAt.setDate(expireAt.getDate() + DURATION_DAYS)
user.plan = 'growth'
user.vipExpireAt = expireAt
if (order.plan === 'sprint') {
user.plan = 'sprint'
user.sprintExpireAt = expireAt
user.sprintRemaining = 10
} else {
user.plan = 'growth'
user.vipExpireAt = expireAt
}
user.remaining = 999
await user.save()
return {
success: true,
plan: 'growth',
planName: '成长版',
expireAt,
message: '支付成功!欢迎开通成长版',
}
return { success: true, plan: user.plan, planName: PLANS[user.plan]?.name, expireAt }
}
/** 扣减冲刺版权益次数 */
@Post('sprint/deduct')
async deductSprint(@CurrentUser('userId') userId: string) {
const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
if (user.plan !== 'sprint') throw new HttpException('非冲刺版会员', HttpStatus.FORBIDDEN)
if (user.sprintExpireAt && user.sprintExpireAt < new Date()) throw new HttpException('会员已过期', HttpStatus.FORBIDDEN)
if ((user.sprintRemaining || 0) <= 0) throw new HttpException('剩余次数不足', HttpStatus.FORBIDDEN)
user.sprintRemaining = (user.sprintRemaining || 0) - 1
await user.save()
return { success: true, sprintRemaining: user.sprintRemaining }
}
}