diff --git a/backend/src/modules/user/user.controller.ts b/backend/src/modules/user/user.controller.ts index 80d885e..286b75d 100644 --- a/backend/src/modules/user/user.controller.ts +++ b/backend/src/modules/user/user.controller.ts @@ -18,8 +18,8 @@ export class UserController { @Public() @Post('login') @HttpCode(HttpStatus.OK) - async login(@Body('phone') phone: string, @Body('code') code: string) { - return this.userService.loginByPhone(phone, code) + async login(@Body('phone') phone: string, @Body('code') code: string, @Req() req) { + return this.userService.loginByPhone(phone, code, req.ip) } // 📧 邮箱验证码登录(H5 用) @@ -41,32 +41,32 @@ export class UserController { @Public() @Post('email-login') @HttpCode(HttpStatus.OK) - async emailLogin(@Body('email') email: string, @Body('code') code: string) { - return this.userService.loginByEmail(email, code) + async emailLogin(@Body('email') email: string, @Body('code') code: string, @Req() req) { + return this.userService.loginByEmail(email, code, req.ip) } // 密码登录 @Public() @Post('password-login') @HttpCode(HttpStatus.OK) - async passwordLogin(@Body('email') email: string, @Body('password') password: string) { - return this.userService.loginByPassword(email, password) + async passwordLogin(@Body('email') email: string, @Body('password') password: string, @Req() req) { + return this.userService.loginByPassword(email, password, req.ip) } // 邮箱+密码注册 @Public() @Post('register') @HttpCode(HttpStatus.OK) - async register(@Body('email') email: string, @Body('password') password: string) { - return this.userService.registerWithPassword(email, password) + async register(@Body('email') email: string, @Body('password') password: string, @Req() req) { + return this.userService.registerWithPassword(email, password, req.ip) } // 微信静默登录 @Public() @Post('wx-login') @HttpCode(HttpStatus.OK) - async wxLogin(@Body('code') code: string) { - return this.userService.loginByWx(code) + async wxLogin(@Body('code') code: string, @Req() req) { + return this.userService.loginByWx(code, undefined, req.ip) } @Get('info') diff --git a/backend/src/modules/user/user.schema.ts b/backend/src/modules/user/user.schema.ts index 09338c3..8382763 100644 --- a/backend/src/modules/user/user.schema.ts +++ b/backend/src/modules/user/user.schema.ts @@ -65,6 +65,15 @@ export class User { @Prop({ default: '', select: false }) password?: string + + @Prop() + lastLoginAt?: Date + + @Prop({ default: '' }) + lastLoginIp?: string + + @Prop({ default: '' }) + lastLoginLocation?: string } export const UserSchema = SchemaFactory.createForClass(User) diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index 2e9dff3..bf694fd 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -6,6 +6,18 @@ import { JwtService } from '@nestjs/jwt' import { User, UserDocument } from './user.schema' import { EmailService } from '../email/email.service' +/** 通过 IP 查询粗略地理位置(ip-api.com 免费接口) */ +async function lookupIpLocation(ip: string): Promise { + if (!ip || ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.')) return '' + try { + const res = await fetch(`http://ip-api.com/json/${ip}?fields=country,regionName,city&lang=zh-CN`, { signal: AbortSignal.timeout(3000) }) + if (!res.ok) return '' + const data: any = await res.json() + if (data.status !== 'success') return '' + return [data.country, data.regionName, data.city].filter(Boolean).join(' ') + } catch { return '' } +} + // In-memory stores const codeStore = new Map() const emailCodeStore = new Map() @@ -33,7 +45,7 @@ export class UserService { return { message: '验证码已发送' } } - async loginByPhone(phone: string, code: string) { + async loginByPhone(phone: string, code: string, ip?: string) { const record = codeStore.get(phone) if (!record || record.code !== code) { throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED) @@ -49,10 +61,11 @@ export class UserService { user = await this.userModel.create({ phone, nickname: `用户${phone.slice(-4)}`, gravity: 5 }) } + await this.recordLogin(user._id.toString(), ip) return this.generateAuthResponse(user) } - async loginByWx(code: string, userId?: string) { + async loginByWx(code: string, userId?: string, ip?: string) { const appid = process.env.WX_APPID const secret = process.env.WX_SECRET if (!appid || !secret) { @@ -84,6 +97,7 @@ export class UserService { user = await this.userModel.create({ wxOpenid: openid, nickname: '微信用户', gravity: 5 }) } + await this.recordLogin(user._id.toString(), ip) return this.generateAuthResponse(user) } @@ -139,7 +153,7 @@ export class UserService { return { message: '验证码已发送,请查收邮件' } } - async loginByEmail(email: string, code: string) { + async loginByEmail(email: string, code: string, ip?: string) { const record = emailCodeStore.get(email) if (!record || record.code !== code) { throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED) @@ -158,21 +172,23 @@ export class UserService { const nick = email.split('@')[0] user = await this.userModel.create({ email, nickname: nick, remaining: 0, gravity: 5 }) } + await this.recordLogin(user._id.toString(), ip) return { ...this.generateAuthResponse(user), isNew, hasPassword: !!user.password } } // 🔐 密码登录 - async loginByPassword(email: string, password: string) { + async loginByPassword(email: string, password: string, ip?: 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) + await this.recordLogin(user._id.toString(), ip) return this.generateAuthResponse(user) } // 📝 邮箱+密码注册 - async registerWithPassword(email: string, password: string) { + async registerWithPassword(email: string, password: string, ip?: string) { if (!email || !email.includes('@')) { throw new HttpException('请输入正确的邮箱地址', HttpStatus.BAD_REQUEST) } @@ -187,11 +203,13 @@ export class UserService { // 已有验证码注册的用户,补充设置密码 existing.password = await bcrypt.hash(password, 10) await existing.save() + await this.recordLogin(existing._id.toString(), ip) 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: 0, gravity: 5 }) + await this.recordLogin(user._id.toString(), ip) return this.generateAuthResponse(user) } @@ -219,6 +237,17 @@ export class UserService { getModel() { return this.userModel } + /** 记录登录时间/IP/归属地 */ + async recordLogin(userId: string, ip?: string) { + const update: any = { lastLoginAt: new Date() } + if (ip) { + update.lastLoginIp = ip + const location = await lookupIpLocation(ip) + if (location) update.lastLoginLocation = location + } + await this.userModel.findByIdAndUpdate(userId, { $set: update }).exec() + } + async getUsage(userId: string) { const user = await this.userModel.findById(userId).exec() if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) @@ -260,6 +289,9 @@ export class UserService { freeOptimizeUsed: user.freeOptimizeUsed ?? 0, shareCredits: user.shareCredits ?? 0, gravity: user.gravity ?? 0, + lastLoginAt: user.lastLoginAt, + lastLoginIp: user.lastLoginIp, + lastLoginLocation: user.lastLoginLocation, } } }