Compare commits
2 Commits
13b2a764ef
...
b6323f02eb
| Author | SHA1 | Date | |
|---|---|---|---|
| b6323f02eb | |||
| 04b30d0024 |
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<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
|
||||
const codeStore = 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: '验证码已发送' }
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,11 @@
|
||||
<text class="time-label" v-if="u.vipExpireAt">到期:{{ u.vipExpireAt?.slice(0,10) }}</text>
|
||||
<text class="time-label" v-if="u.sprintExpireAt">冲刺到期:{{ u.sprintExpireAt?.slice(0,10) }}</text>
|
||||
</view>
|
||||
<view class="user-meta-row time-row" v-if="u.lastLoginAt">
|
||||
<text class="time-label">最后登录:{{ u.lastLoginAt?.slice(0,16).replace('T',' ') }}</text>
|
||||
<text class="meta-tag" v-if="u.lastLoginIp">IP:{{ u.lastLoginIp }}</text>
|
||||
<text class="meta-tag" v-if="u.lastLoginLocation">{{ u.lastLoginLocation }}</text>
|
||||
</view>
|
||||
<view class="user-actions">
|
||||
<text class="user-action-btn" v-if="u.plan === 'free'" @click="setVip(u._id)">设为会员</text>
|
||||
<text class="user-action-btn credit" @click="openCreditModal(u)">调整额度</text>
|
||||
@@ -300,6 +305,8 @@
|
||||
<text>每月引力值</text>
|
||||
<input class="cfg-input" type="digit" v-model.number="pricing.plans.sprint.gravityPerMonth" />
|
||||
</view>
|
||||
<view class="cfg-row">
|
||||
<text>面试额度/月</text>
|
||||
<input class="cfg-input" type="digit" v-model.number="pricing.plans.sprint.credits.interview" />
|
||||
</view>
|
||||
<view class="cfg-row">
|
||||
@@ -539,6 +546,7 @@
|
||||
<text class="admin-set-btn done" v-else>已是管理员</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -736,6 +744,7 @@ const switchTab = (t) => {
|
||||
if (t === 'pricing') loadPricing()
|
||||
if (t === 'orders') loadOrders()
|
||||
if (t === 'analysis') loadAnalysis()
|
||||
if (t === 'share') loadShareRecords()
|
||||
}
|
||||
|
||||
const loadUsers = async () => {
|
||||
|
||||
Reference in New Issue
Block a user