import * as bcrypt from 'bcrypt' import { Injectable, HttpException, HttpStatus, Logger } 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() const emailCodeStore = new Map() @Injectable() export class UserService { private readonly logger = new Logger(UserService.name) constructor( @InjectModel(User.name) private userModel: Model, 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') { this.logger.log(`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: '验证码已发送到邮箱' } } if (process.env.NODE_ENV !== 'production') { this.logger.log(`Email code for ${email}: ${code}`) return { message: '验证码已发送(邮件服务暂不可用,开发模式)', devCode: code } } return { message: '验证码已发送,请查收邮件' } } 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 }).select('+password').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 }).select('+password').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 }).select('+password').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 || '', role: user.role || 'user' } 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, interviewCredits: user.interviewCredits ?? 1, resumeOptimizeCredits: user.resumeOptimizeCredits ?? 0, resumeDownloadCredits: user.resumeDownloadCredits ?? 0, freeOptimizeUsed: user.freeOptimizeUsed ?? 0, shareCredits: user.shareCredits ?? 0, } } }