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:
@@ -1,212 +1,212 @@
|
||||
import * as bcrypt from 'bcrypt'
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { User, UserDocument } from './user.schema'
|
||||
import { EmailService } from '../email/email.service'
|
||||
|
||||
// In-memory stores
|
||||
const codeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||
const emailCodeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
private jwtService: JwtService,
|
||||
private emailService: EmailService,
|
||||
) {}
|
||||
|
||||
async sendCode(phone: string) {
|
||||
const code = process.env.NODE_ENV === 'production'
|
||||
? String(Math.floor(100000 + Math.random() * 900000))
|
||||
: '123456'
|
||||
|
||||
codeStore.set(phone, { code, expiresAt: Date.now() + 5 * 60 * 1000 })
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(`[DEV] Verification code for ${phone}: ${code}`)
|
||||
}
|
||||
return { message: '验证码已发送' }
|
||||
}
|
||||
|
||||
async loginByPhone(phone: string, code: string) {
|
||||
const record = codeStore.get(phone)
|
||||
if (!record || record.code !== code) {
|
||||
throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
if (Date.now() > record.expiresAt) {
|
||||
codeStore.delete(phone)
|
||||
throw new HttpException('验证码已过期', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
codeStore.delete(phone)
|
||||
|
||||
let user = await this.userModel.findOne({ phone }).exec()
|
||||
if (!user) {
|
||||
user = await this.userModel.create({ phone, nickname: `用户${phone.slice(-4)}` })
|
||||
}
|
||||
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
async loginByWx(code: string) {
|
||||
// WeChat silent login - exchange code for openid
|
||||
const appid = process.env.WX_APPID
|
||||
const secret = process.env.WX_SECRET
|
||||
if (!appid || !secret) {
|
||||
throw new HttpException('微信登录未配置', HttpStatus.SERVICE_UNAVAILABLE)
|
||||
}
|
||||
|
||||
const wxRes = await fetch(
|
||||
`https://api.weixin.qq.com/sns/jscode2session?appid=${appid}&secret=${secret}&js_code=${code}&grant_type=authorization_code`,
|
||||
)
|
||||
const wxData: any = await wxRes.json()
|
||||
|
||||
if (wxData.errcode) {
|
||||
throw new HttpException(`微信登录失败: ${wxData.errmsg}`, HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
|
||||
const openid = wxData.openid
|
||||
let user = await this.userModel.findOne({ wxOpenid: openid }).exec()
|
||||
if (!user) {
|
||||
user = await this.userModel.create({ wxOpenid: openid, nickname: '微信用户' })
|
||||
}
|
||||
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
// 📧 邮箱验证码
|
||||
async sendEmailCode(email: string) {
|
||||
if (!email || !email.includes('@')) {
|
||||
throw new HttpException('请输入正确的邮箱地址', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000))
|
||||
emailCodeStore.set(email, { code, expiresAt: Date.now() + 10 * 60 * 1000 })
|
||||
const sent = await this.emailService.sendVerificationCode(email, code)
|
||||
if (sent) {
|
||||
return { message: '验证码已发送到邮箱' }
|
||||
}
|
||||
// 邮件发送失败时返回 devCode 方便调试
|
||||
console.log(`[EMAIL] Dev code for ${email}: ${code}`)
|
||||
return { message: '验证码已发送(邮件服务暂不可用,开发模式)', devCode: code }
|
||||
}
|
||||
|
||||
async loginByEmail(email: string, code: string) {
|
||||
const record = emailCodeStore.get(email)
|
||||
if (!record || record.code !== code) {
|
||||
throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
if (Date.now() > record.expiresAt) {
|
||||
emailCodeStore.delete(email)
|
||||
throw new HttpException('验证码已过期', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
emailCodeStore.delete(email)
|
||||
|
||||
// 按邮箱查找或创建用户
|
||||
let user = await this.userModel.findOne({ email }).exec()
|
||||
let isNew = false
|
||||
if (!user) {
|
||||
isNew = true
|
||||
const nick = email.split('@')[0]
|
||||
user = await this.userModel.create({ email, nickname: nick, remaining: 3 })
|
||||
}
|
||||
return { ...this.generateAuthResponse(user), isNew, hasPassword: !!user.password }
|
||||
}
|
||||
|
||||
// 🔐 密码登录
|
||||
async loginByPassword(email: string, password: string) {
|
||||
const user = await this.userModel.findOne({ email }).exec()
|
||||
if (!user) throw new HttpException('账号不存在', HttpStatus.NOT_FOUND)
|
||||
if (!user.password) throw new HttpException('该账号未设置密码,请使用验证码登录', HttpStatus.UNAUTHORIZED)
|
||||
const match = await bcrypt.compare(password, user.password)
|
||||
if (!match) throw new HttpException('密码错误', HttpStatus.UNAUTHORIZED)
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
// 📝 邮箱+密码注册
|
||||
async registerWithPassword(email: string, password: string) {
|
||||
if (!email || !email.includes('@')) {
|
||||
throw new HttpException('请输入正确的邮箱地址', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
if (!password || password.length < 6) {
|
||||
throw new HttpException('密码至少6位', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
const existing = await this.userModel.findOne({ email }).exec()
|
||||
if (existing) {
|
||||
if (existing.password) {
|
||||
throw new HttpException('该邮箱已注册,请直接登录', HttpStatus.CONFLICT)
|
||||
}
|
||||
// 已有验证码注册的用户,补充设置密码
|
||||
existing.password = await bcrypt.hash(password, 10)
|
||||
await existing.save()
|
||||
return this.generateAuthResponse(existing)
|
||||
}
|
||||
const nick = email.split('@')[0]
|
||||
const hashed = await bcrypt.hash(password, 10)
|
||||
const user = await this.userModel.create({ email, nickname: nick, password: hashed, remaining: 3 })
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
// 🔑 已登录用户设置/修改密码
|
||||
async setPassword(userId: string, password: string) {
|
||||
if (!password || password.length < 6) {
|
||||
throw new HttpException('密码至少6位', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
const hashed = await bcrypt.hash(password, 10)
|
||||
await this.userModel.findByIdAndUpdate(userId, { password: hashed })
|
||||
return { message: '密码设置成功' }
|
||||
}
|
||||
|
||||
async getInfo(userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
return this.safeUser(user)
|
||||
}
|
||||
|
||||
async update(userId: string, data: { nickname?: string; avatar?: string }) {
|
||||
const user = await this.userModel.findByIdAndUpdate(userId, data, { new: true }).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
return this.safeUser(user)
|
||||
}
|
||||
|
||||
getModel() { return this.userModel }
|
||||
|
||||
async getUsage(userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
return { remaining: user.remaining, plan: user.plan, interviewCount: user.interviewCount }
|
||||
}
|
||||
|
||||
async deductRemaining(userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (user.remaining <= 0) throw new HttpException('使用次数已用完', HttpStatus.FORBIDDEN)
|
||||
user.remaining -= 1
|
||||
user.interviewCount += 1
|
||||
await user.save()
|
||||
}
|
||||
|
||||
private generateAuthResponse(user: UserDocument) {
|
||||
const payload = { userId: user._id.toString(), phone: user.phone || '' }
|
||||
return {
|
||||
token: this.jwtService.sign(payload),
|
||||
user: this.safeUser(user),
|
||||
}
|
||||
}
|
||||
|
||||
private safeUser(user: UserDocument) {
|
||||
return {
|
||||
id: user._id.toString(),
|
||||
phone: user.phone || '',
|
||||
email: user.email || '',
|
||||
nickname: user.nickname || '',
|
||||
avatar: user.avatar || '',
|
||||
plan: user.plan,
|
||||
role: user.role || 'user',
|
||||
isSystemAdmin: user.isSystemAdmin || false,
|
||||
remaining: user.remaining,
|
||||
interviewCount: user.interviewCount,
|
||||
}
|
||||
}
|
||||
}
|
||||
import * as bcrypt from 'bcrypt'
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { User, UserDocument } from './user.schema'
|
||||
import { EmailService } from '../email/email.service'
|
||||
|
||||
// In-memory stores
|
||||
const codeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||
const emailCodeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
@InjectModel(User.name) private userModel: Model<UserDocument>,
|
||||
private jwtService: JwtService,
|
||||
private emailService: EmailService,
|
||||
) {}
|
||||
|
||||
async sendCode(phone: string) {
|
||||
const code = process.env.NODE_ENV === 'production'
|
||||
? String(Math.floor(100000 + Math.random() * 900000))
|
||||
: '123456'
|
||||
|
||||
codeStore.set(phone, { code, expiresAt: Date.now() + 5 * 60 * 1000 })
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(`[DEV] Verification code for ${phone}: ${code}`)
|
||||
}
|
||||
return { message: '验证码已发送' }
|
||||
}
|
||||
|
||||
async loginByPhone(phone: string, code: string) {
|
||||
const record = codeStore.get(phone)
|
||||
if (!record || record.code !== code) {
|
||||
throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
if (Date.now() > record.expiresAt) {
|
||||
codeStore.delete(phone)
|
||||
throw new HttpException('验证码已过期', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
codeStore.delete(phone)
|
||||
|
||||
let user = await this.userModel.findOne({ phone }).exec()
|
||||
if (!user) {
|
||||
user = await this.userModel.create({ phone, nickname: `用户${phone.slice(-4)}` })
|
||||
}
|
||||
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
async loginByWx(code: string) {
|
||||
// WeChat silent login - exchange code for openid
|
||||
const appid = process.env.WX_APPID
|
||||
const secret = process.env.WX_SECRET
|
||||
if (!appid || !secret) {
|
||||
throw new HttpException('微信登录未配置', HttpStatus.SERVICE_UNAVAILABLE)
|
||||
}
|
||||
|
||||
const wxRes = await fetch(
|
||||
`https://api.weixin.qq.com/sns/jscode2session?appid=${appid}&secret=${secret}&js_code=${code}&grant_type=authorization_code`,
|
||||
)
|
||||
const wxData: any = await wxRes.json()
|
||||
|
||||
if (wxData.errcode) {
|
||||
throw new HttpException(`微信登录失败: ${wxData.errmsg}`, HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
|
||||
const openid = wxData.openid
|
||||
let user = await this.userModel.findOne({ wxOpenid: openid }).exec()
|
||||
if (!user) {
|
||||
user = await this.userModel.create({ wxOpenid: openid, nickname: '微信用户' })
|
||||
}
|
||||
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
// 📧 邮箱验证码
|
||||
async sendEmailCode(email: string) {
|
||||
if (!email || !email.includes('@')) {
|
||||
throw new HttpException('请输入正确的邮箱地址', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000))
|
||||
emailCodeStore.set(email, { code, expiresAt: Date.now() + 10 * 60 * 1000 })
|
||||
const sent = await this.emailService.sendVerificationCode(email, code)
|
||||
if (sent) {
|
||||
return { message: '验证码已发送到邮箱' }
|
||||
}
|
||||
// 邮件发送失败时返回 devCode 方便调试
|
||||
console.log(`[EMAIL] Dev code for ${email}: ${code}`)
|
||||
return { message: '验证码已发送(邮件服务暂不可用,开发模式)', devCode: code }
|
||||
}
|
||||
|
||||
async loginByEmail(email: string, code: string) {
|
||||
const record = emailCodeStore.get(email)
|
||||
if (!record || record.code !== code) {
|
||||
throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
if (Date.now() > record.expiresAt) {
|
||||
emailCodeStore.delete(email)
|
||||
throw new HttpException('验证码已过期', HttpStatus.UNAUTHORIZED)
|
||||
}
|
||||
emailCodeStore.delete(email)
|
||||
|
||||
// 按邮箱查找或创建用户
|
||||
let user = await this.userModel.findOne({ email }).exec()
|
||||
let isNew = false
|
||||
if (!user) {
|
||||
isNew = true
|
||||
const nick = email.split('@')[0]
|
||||
user = await this.userModel.create({ email, nickname: nick, remaining: 3 })
|
||||
}
|
||||
return { ...this.generateAuthResponse(user), isNew, hasPassword: !!user.password }
|
||||
}
|
||||
|
||||
// 🔐 密码登录
|
||||
async loginByPassword(email: string, password: string) {
|
||||
const user = await this.userModel.findOne({ email }).exec()
|
||||
if (!user) throw new HttpException('账号不存在', HttpStatus.NOT_FOUND)
|
||||
if (!user.password) throw new HttpException('该账号未设置密码,请使用验证码登录', HttpStatus.UNAUTHORIZED)
|
||||
const match = await bcrypt.compare(password, user.password)
|
||||
if (!match) throw new HttpException('密码错误', HttpStatus.UNAUTHORIZED)
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
// 📝 邮箱+密码注册
|
||||
async registerWithPassword(email: string, password: string) {
|
||||
if (!email || !email.includes('@')) {
|
||||
throw new HttpException('请输入正确的邮箱地址', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
if (!password || password.length < 6) {
|
||||
throw new HttpException('密码至少6位', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
const existing = await this.userModel.findOne({ email }).exec()
|
||||
if (existing) {
|
||||
if (existing.password) {
|
||||
throw new HttpException('该邮箱已注册,请直接登录', HttpStatus.CONFLICT)
|
||||
}
|
||||
// 已有验证码注册的用户,补充设置密码
|
||||
existing.password = await bcrypt.hash(password, 10)
|
||||
await existing.save()
|
||||
return this.generateAuthResponse(existing)
|
||||
}
|
||||
const nick = email.split('@')[0]
|
||||
const hashed = await bcrypt.hash(password, 10)
|
||||
const user = await this.userModel.create({ email, nickname: nick, password: hashed, remaining: 3 })
|
||||
return this.generateAuthResponse(user)
|
||||
}
|
||||
|
||||
// 🔑 已登录用户设置/修改密码
|
||||
async setPassword(userId: string, password: string) {
|
||||
if (!password || password.length < 6) {
|
||||
throw new HttpException('密码至少6位', HttpStatus.BAD_REQUEST)
|
||||
}
|
||||
const hashed = await bcrypt.hash(password, 10)
|
||||
await this.userModel.findByIdAndUpdate(userId, { password: hashed })
|
||||
return { message: '密码设置成功' }
|
||||
}
|
||||
|
||||
async getInfo(userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
return this.safeUser(user)
|
||||
}
|
||||
|
||||
async update(userId: string, data: { nickname?: string; avatar?: string }) {
|
||||
const user = await this.userModel.findByIdAndUpdate(userId, data, { new: true }).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
return this.safeUser(user)
|
||||
}
|
||||
|
||||
getModel() { return this.userModel }
|
||||
|
||||
async getUsage(userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
return { remaining: user.remaining, plan: user.plan, interviewCount: user.interviewCount }
|
||||
}
|
||||
|
||||
async deductRemaining(userId: string) {
|
||||
const user = await this.userModel.findById(userId).exec()
|
||||
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
|
||||
if (user.remaining <= 0) throw new HttpException('使用次数已用完', HttpStatus.FORBIDDEN)
|
||||
user.remaining -= 1
|
||||
user.interviewCount += 1
|
||||
await user.save()
|
||||
}
|
||||
|
||||
private generateAuthResponse(user: UserDocument) {
|
||||
const payload = { userId: user._id.toString(), phone: user.phone || '' }
|
||||
return {
|
||||
token: this.jwtService.sign(payload),
|
||||
user: this.safeUser(user),
|
||||
}
|
||||
}
|
||||
|
||||
private safeUser(user: UserDocument) {
|
||||
return {
|
||||
id: user._id.toString(),
|
||||
phone: user.phone || '',
|
||||
email: user.email || '',
|
||||
nickname: user.nickname || '',
|
||||
avatar: user.avatar || '',
|
||||
plan: user.plan,
|
||||
role: user.role || 'user',
|
||||
isSystemAdmin: user.isSystemAdmin || false,
|
||||
remaining: user.remaining,
|
||||
interviewCount: user.interviewCount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user