feat(backend): record lastLoginAt/IP/location on every login

Add lastLoginAt, lastLoginIp, lastLoginLocation to User schema. recordLogin() method called from all 5 login flows (phone, email, wx, password, register). Exposed in safeUser so info endpoint returns login metadata.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
yuzhiran
2026-06-22 12:38:07 +08:00
parent 13b2a764ef
commit 04b30d0024
3 changed files with 56 additions and 15 deletions
+10 -10
View File
@@ -18,8 +18,8 @@ export class UserController {
@Public() @Public()
@Post('login') @Post('login')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async login(@Body('phone') phone: string, @Body('code') code: string) { async login(@Body('phone') phone: string, @Body('code') code: string, @Req() req) {
return this.userService.loginByPhone(phone, code) return this.userService.loginByPhone(phone, code, req.ip)
} }
// 📧 邮箱验证码登录(H5 用) // 📧 邮箱验证码登录(H5 用)
@@ -41,32 +41,32 @@ export class UserController {
@Public() @Public()
@Post('email-login') @Post('email-login')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async emailLogin(@Body('email') email: string, @Body('code') code: string) { async emailLogin(@Body('email') email: string, @Body('code') code: string, @Req() req) {
return this.userService.loginByEmail(email, code) return this.userService.loginByEmail(email, code, req.ip)
} }
// 密码登录 // 密码登录
@Public() @Public()
@Post('password-login') @Post('password-login')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async passwordLogin(@Body('email') email: string, @Body('password') password: string) { async passwordLogin(@Body('email') email: string, @Body('password') password: string, @Req() req) {
return this.userService.loginByPassword(email, password) return this.userService.loginByPassword(email, password, req.ip)
} }
// 邮箱+密码注册 // 邮箱+密码注册
@Public() @Public()
@Post('register') @Post('register')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async register(@Body('email') email: string, @Body('password') password: string) { async register(@Body('email') email: string, @Body('password') password: string, @Req() req) {
return this.userService.registerWithPassword(email, password) return this.userService.registerWithPassword(email, password, req.ip)
} }
// 微信静默登录 // 微信静默登录
@Public() @Public()
@Post('wx-login') @Post('wx-login')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
async wxLogin(@Body('code') code: string) { async wxLogin(@Body('code') code: string, @Req() req) {
return this.userService.loginByWx(code) return this.userService.loginByWx(code, undefined, req.ip)
} }
@Get('info') @Get('info')
+9
View File
@@ -65,6 +65,15 @@ export class User {
@Prop({ default: '', select: false }) @Prop({ default: '', select: false })
password?: string password?: string
@Prop()
lastLoginAt?: Date
@Prop({ default: '' })
lastLoginIp?: string
@Prop({ default: '' })
lastLoginLocation?: string
} }
export const UserSchema = SchemaFactory.createForClass(User) export const UserSchema = SchemaFactory.createForClass(User)
+37 -5
View File
@@ -6,6 +6,18 @@ import { JwtService } from '@nestjs/jwt'
import { User, UserDocument } from './user.schema' import { User, UserDocument } from './user.schema'
import { EmailService } from '../email/email.service' import { EmailService } from '../email/email.service'
/** 通过 IP 查询粗略地理位置(ip-api.com 免费接口) */
async function lookupIpLocation(ip: string): Promise<string> {
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 // In-memory stores
const codeStore = new Map<string, { code: string; expiresAt: number }>() const codeStore = new Map<string, { code: string; expiresAt: number }>()
const emailCodeStore = new Map<string, { code: string; expiresAt: number }>() const emailCodeStore = new Map<string, { code: string; expiresAt: number }>()
@@ -33,7 +45,7 @@ export class UserService {
return { message: '验证码已发送' } return { message: '验证码已发送' }
} }
async loginByPhone(phone: string, code: string) { async loginByPhone(phone: string, code: string, ip?: string) {
const record = codeStore.get(phone) const record = codeStore.get(phone)
if (!record || record.code !== code) { if (!record || record.code !== code) {
throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED) throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED)
@@ -49,10 +61,11 @@ export class UserService {
user = await this.userModel.create({ phone, nickname: `用户${phone.slice(-4)}`, gravity: 5 }) user = await this.userModel.create({ phone, nickname: `用户${phone.slice(-4)}`, gravity: 5 })
} }
await this.recordLogin(user._id.toString(), ip)
return this.generateAuthResponse(user) 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 appid = process.env.WX_APPID
const secret = process.env.WX_SECRET const secret = process.env.WX_SECRET
if (!appid || !secret) { if (!appid || !secret) {
@@ -84,6 +97,7 @@ export class UserService {
user = await this.userModel.create({ wxOpenid: openid, nickname: '微信用户', gravity: 5 }) user = await this.userModel.create({ wxOpenid: openid, nickname: '微信用户', gravity: 5 })
} }
await this.recordLogin(user._id.toString(), ip)
return this.generateAuthResponse(user) return this.generateAuthResponse(user)
} }
@@ -139,7 +153,7 @@ export class UserService {
return { message: '验证码已发送,请查收邮件' } return { message: '验证码已发送,请查收邮件' }
} }
async loginByEmail(email: string, code: string) { async loginByEmail(email: string, code: string, ip?: string) {
const record = emailCodeStore.get(email) const record = emailCodeStore.get(email)
if (!record || record.code !== code) { if (!record || record.code !== code) {
throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED) throw new HttpException('验证码错误', HttpStatus.UNAUTHORIZED)
@@ -158,21 +172,23 @@ export class UserService {
const nick = email.split('@')[0] const nick = email.split('@')[0]
user = await this.userModel.create({ email, nickname: nick, remaining: 0, gravity: 5 }) 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 } 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() const user = await this.userModel.findOne({ email }).select('+password').exec()
if (!user) throw new HttpException('账号不存在', HttpStatus.NOT_FOUND) if (!user) throw new HttpException('账号不存在', HttpStatus.NOT_FOUND)
if (!user.password) throw new HttpException('该账号未设置密码,请使用验证码登录', HttpStatus.UNAUTHORIZED) if (!user.password) throw new HttpException('该账号未设置密码,请使用验证码登录', HttpStatus.UNAUTHORIZED)
const match = await bcrypt.compare(password, user.password) const match = await bcrypt.compare(password, user.password)
if (!match) throw new HttpException('密码错误', HttpStatus.UNAUTHORIZED) if (!match) throw new HttpException('密码错误', HttpStatus.UNAUTHORIZED)
await this.recordLogin(user._id.toString(), ip)
return this.generateAuthResponse(user) return this.generateAuthResponse(user)
} }
// 📝 邮箱+密码注册 // 📝 邮箱+密码注册
async registerWithPassword(email: string, password: string) { async registerWithPassword(email: string, password: string, ip?: string) {
if (!email || !email.includes('@')) { if (!email || !email.includes('@')) {
throw new HttpException('请输入正确的邮箱地址', HttpStatus.BAD_REQUEST) throw new HttpException('请输入正确的邮箱地址', HttpStatus.BAD_REQUEST)
} }
@@ -187,11 +203,13 @@ export class UserService {
// 已有验证码注册的用户,补充设置密码 // 已有验证码注册的用户,补充设置密码
existing.password = await bcrypt.hash(password, 10) existing.password = await bcrypt.hash(password, 10)
await existing.save() await existing.save()
await this.recordLogin(existing._id.toString(), ip)
return this.generateAuthResponse(existing) return this.generateAuthResponse(existing)
} }
const nick = email.split('@')[0] const nick = email.split('@')[0]
const hashed = await bcrypt.hash(password, 10) const hashed = await bcrypt.hash(password, 10)
const user = await this.userModel.create({ email, nickname: nick, password: hashed, remaining: 0, gravity: 5 }) 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) return this.generateAuthResponse(user)
} }
@@ -219,6 +237,17 @@ export class UserService {
getModel() { return this.userModel } 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) { async getUsage(userId: string) {
const user = await this.userModel.findById(userId).exec() const user = await this.userModel.findById(userId).exec()
if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND) if (!user) throw new HttpException('用户不存在', HttpStatus.NOT_FOUND)
@@ -260,6 +289,9 @@ export class UserService {
freeOptimizeUsed: user.freeOptimizeUsed ?? 0, freeOptimizeUsed: user.freeOptimizeUsed ?? 0,
shareCredits: user.shareCredits ?? 0, shareCredits: user.shareCredits ?? 0,
gravity: user.gravity ?? 0, gravity: user.gravity ?? 0,
lastLoginAt: user.lastLoginAt,
lastLoginIp: user.lastLoginIp,
lastLoginLocation: user.lastLoginLocation,
} }
} }
} }