Files
ai-dimension/server/src/routes/auth.js
T

603 lines
16 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express')
const router = express.Router()
const jwt = require('jsonwebtoken')
const { User, Admin, AdminLog, LoginLog, VerificationCode } = require('../models')
const ApiResponse = require('../utils/response')
const weChatService = require('../utils/weixin')
const logger = require('../utils/logger')
const { sendVerificationCode } = require('../utils/mail')
const axios = require('axios')
/**
* 获取客户端IP
*/
const getClientIp = (req) => {
return req.headers['x-forwarded-for'] ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.socket.remoteAddress ||
'0.0.0.0'
}
/**
* 解析User-Agent
*/
const parseUserAgent = (userAgent) => {
if (!userAgent) return { device: '', os: '', browser: '' }
const device = /Mobile|Android|iPhone|iPad|iPod/i.test(userAgent) ? 'mobile' : 'desktop'
let os = ''
if (/Windows/i.test(userAgent)) os = 'Windows'
else if (/Mac OS X/i.test(userAgent)) os = 'MacOS'
else if (/Android/i.test(userAgent)) os = 'Android'
else if (/iOS|iPhone|iPad|iPod/i.test(userAgent)) os = 'iOS'
else if (/Linux/i.test(userAgent)) os = 'Linux'
else os = 'Unknown'
let browser = ''
if (/MicroMessenger/i.test(userAgent)) browser = 'WeChat'
else if (/Chrome/i.test(userAgent)) browser = 'Chrome'
else if (/Safari/i.test(userAgent)) browser = 'Safari'
else if (/Firefox/i.test(userAgent)) browser = 'Firefox'
else if (/Edge/i.test(userAgent)) browser = 'Edge'
else browser = 'Unknown'
return { device, os, browser }
}
/**
* 记录登录日志
*/
const recordLoginLog = async (userId, openid, type, method, source, req, success = true, failReason = '') => {
try {
const ip = getClientIp(req)
const userAgent = req.headers['user-agent'] || ''
const { device, os, browser } = parseUserAgent(userAgent)
await LoginLog.create({
userId,
openid,
type,
method,
source,
ip,
userAgent,
device,
os,
browser,
success,
failReason
})
} catch (error) {
logger.error('记录登录日志失败:', error)
}
}
/**
* 小程序登录
* POST /api/auth/login
*/
router.post('/login', async (req, res) => {
try {
const { code, userInfo, source = 'miniapp' } = req.body
// 方式1: 通过 code 登录(推荐)
let openid = null
if (code) {
const sessionData = await weChatService.code2Session(code)
openid = sessionData.openid
req.body._sessionKey = sessionData.sessionKey || ''
}
// 方式2: 开发环境直接使用 openid (已废弃,仅用于本地调试)
if (!openid && process.env.NODE_ENV === 'development' && req.body.openid) {
openid = req.body.openid
}
if (!openid) {
return ApiResponse.error(res, '无法获取用户标识', 400)
}
// 查找或创建用户
let user = await User.findOne({ openid })
let isNewUser = false
if (!user) {
// 新用户
user = new User({
openid,
nickName: userInfo?.nickName || '星辰旅行者',
avatarUrl: userInfo?.avatarUrl || '',
unlockedDims: [1]
})
isNewUser = true
logger.info(`新用户注册: ${openid}`)
} else {
// 老用户,更新登录时间
user.lastLoginAt = new Date()
if (userInfo?.nickName) user.nickName = userInfo.nickName
if (userInfo?.avatarUrl) user.avatarUrl = userInfo.avatarUrl
}
if (req.body._sessionKey) user.sessionKey = req.body._sessionKey
await user.save()
// 记录登录日志
await recordLoginLog(
user._id,
openid,
isNewUser ? 'register' : 'login',
'wechat_miniapp',
source,
req,
true
)
// 生成 JWT token(可选,小程序通常不需要)
const token = jwt.sign(
{ openid, userId: user._id },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRE }
)
return ApiResponse.success(res, {
token,
openid,
isNewUser,
userInfo: {
nickName: user.nickName,
avatarUrl: user.avatarUrl
},
firstPlayDate: user.createdAt
}, isNewUser ? '注册成功' : '登录成功')
} catch (error) {
// 空message的错误也要留下可诊断线索(微信侧errmsg/错误名)
const detail = (error && (error.message || error.errmsg)) || (error && error.name) || '未知错误'
logger.error('登录失败:', error, '| detail:', detail)
return ApiResponse.error(res, detail ? `登录失败:${detail}` : '登录服务异常,请稍后再试', 500)
}
})
/**
* WebView 静默登录
* POST /api/auth/webview-login
* 用于微信小程序 webview 内嵌 H5 的自动登录
*/
router.post('/webview-login', async (req, res) => {
try {
const { code, userInfo } = req.body
if (!code) {
return ApiResponse.error(res, '缺少登录凭证', 400)
}
// 通过 code 获取 openid
let openid = null
try {
const sessionData = await weChatService.code2Session(code)
openid = sessionData.openid
} catch (error) {
logger.error('WebView登录获取session失败:', error)
return ApiResponse.error(res, '登录凭证无效', 401)
}
if (!openid) {
return ApiResponse.error(res, '无法获取用户标识', 400)
}
// 查找或创建用户
let user = await User.findOne({ openid })
let isNewUser = false
if (!user) {
// 新用户 - 使用微信提供的信息创建账户
user = new User({
openid,
nickName: userInfo?.nickName || '星辰旅行者',
avatarUrl: userInfo?.avatarUrl || '',
unlockedDims: [1]
})
isNewUser = true
logger.info(`WebView新用户注册: ${openid}`)
} else {
// 老用户,更新登录时间和信息
user.lastLoginAt = new Date()
if (userInfo?.nickName) user.nickName = userInfo.nickName
if (userInfo?.avatarUrl) user.avatarUrl = userInfo.avatarUrl
logger.info(`WebView老用户登录: ${openid}`)
}
await user.save()
// 记录登录日志
await recordLoginLog(
user._id,
openid,
isNewUser ? 'register' : 'auto_login',
'wechat_webview',
'webview',
req,
true
)
// 生成 JWT token
const token = jwt.sign(
{ openid, userId: user._id },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRE }
)
return ApiResponse.success(res, {
token,
openid,
isNewUser,
userInfo: {
nickName: user.nickName,
avatarUrl: user.avatarUrl
},
firstPlayDate: user.createdAt
}, isNewUser ? '注册成功' : '自动登录成功')
} catch (error) {
logger.error('WebView登录失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
/**
* 管理员登录
* POST /api/auth/admin/login
*/
router.post('/admin/login', async (req, res) => {
try {
const { username, password } = req.body
// 查找管理员
const admin = await Admin.findOne({ username })
if (!admin) {
return ApiResponse.error(res, '用户名或密码错误', 401)
}
// 验证密码
const isMatch = await admin.validatePassword(password)
if (!isMatch) {
return ApiResponse.error(res, '用户名或密码错误', 401)
}
// 检查状态
if (admin.status !== 'active') {
return ApiResponse.error(res, '账户已禁用', 403)
}
// 更新登录信息
admin.lastLogin = new Date()
admin.lastLoginIp = req.ip
admin.loginCount += 1
await admin.save()
// 记录日志
await AdminLog.create({
adminId: admin._id,
adminName: admin.username,
action: 'login',
resource: 'system',
ip: req.ip,
userAgent: req.get('User-Agent')
})
// 生成 JWT token
const token = jwt.sign(
{ adminId: admin._id, role: admin.role },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRE }
)
// 获取权限
const permissions = Admin.getPermissionsByRole(admin.role)
return ApiResponse.success(res, {
token,
adminId: admin._id,
username: admin.username,
role: admin.role,
permissions
}, '登录成功')
} catch (error) {
logger.error('管理员登录失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
/**
* H5环境用户名密码登录
* POST /api/auth/user/login
*/
router.post('/user/login', async (req, res) => {
try {
const { username, email, password } = req.body
// 支持邮箱或用户名登录
const identifier = email || username
// 查找用户(显式包含密码字段)
const user = await User.findOne({
$or: [
{ email: identifier },
{ username: identifier }
]
}).select('+password')
if (!user) {
return ApiResponse.error(res, '用户名或密码错误', 401)
}
// 验证密码
const isMatch = await user.validatePassword(password)
if (!isMatch) {
return ApiResponse.error(res, '用户名或密码错误', 401)
}
// 更新登录时间
user.lastLoginAt = new Date()
await user.save()
// 生成 JWT token
const token = jwt.sign(
{ userId: user._id, openid: user.openid },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRE }
)
return ApiResponse.success(res, {
token,
userId: user._id,
openid: user.openid,
userInfo: {
nickName: user.nickName,
avatarUrl: user.avatarUrl
},
firstPlayDate: user.createdAt
}, '登录成功')
} catch (error) {
logger.error('用户登录失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
/**
* 发送邮箱验证码
* POST /api/auth/user/send-code
*/
router.post('/user/send-code', async (req, res) => {
try {
const { email } = req.body
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return ApiResponse.error(res, '邮箱格式不正确', 400)
}
// 检查邮箱是否已被注册
const existing = await User.findOne({ email })
if (existing) {
return ApiResponse.error(res, '该邮箱已被注册', 400)
}
// 检查发送频率(1分钟内只能发一次)
const recent = await VerificationCode.findOne({
email,
createdAt: { $gte: new Date(Date.now() - 60000) }
})
if (recent) {
return ApiResponse.error(res, '发送过于频繁,请 1 分钟后再试', 429)
}
// 生成6位随机验证码
const code = String(Math.floor(100000 + Math.random() * 900000))
// 10分钟过期
const expiresAt = new Date(Date.now() + 10 * 60 * 1000)
// 使之前的验证码失效
await VerificationCode.updateMany({ email, used: false }, { $set: { used: true } })
// 保存新验证码
await VerificationCode.create({ email, code, expiresAt })
// 发送邮件
const result = await sendVerificationCode(email, code)
if (result.success) {
return ApiResponse.success(res, {
devCode: result.devCode || undefined
}, '验证码已发送')
} else {
return ApiResponse.error(res, '验证码发送失败,请稍后重试', 500)
}
} catch (error) {
logger.error('发送验证码失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
/**
* H5环境用户注册(需邮箱验证码)
* POST /api/auth/user/register
*/
router.post('/user/register', async (req, res) => {
try {
const { username, email, password, nickName, avatarUrl, verifyCode } = req.body
const identifier = email || username
if (!identifier) {
return ApiResponse.error(res, '请输入邮箱或用户名', 400)
}
if (!email) {
return ApiResponse.error(res, '邮箱为必填项', 400)
}
// 验证邮箱验证码
if (!verifyCode) {
return ApiResponse.error(res, '请输入邮箱验证码', 400)
}
const validCode = await VerificationCode.findOne({
email,
code: verifyCode,
used: false,
expiresAt: { $gt: new Date() }
})
if (!validCode) {
return ApiResponse.error(res, '验证码无效或已过期', 400)
}
// 检查邮箱是否已存在
if (email) {
const existingEmail = await User.findOne({ email })
if (existingEmail) {
return ApiResponse.error(res, '该邮箱已被注册', 400)
}
}
// 检查用户名是否已存在
if (username) {
const existingUser = await User.findOne({ username })
if (existingUser) {
return ApiResponse.error(res, '用户名已存在', 400)
}
}
// 标记验证码为已使用
validCode.used = true
await validCode.save()
// 自动生成用户名(若未提供)
const finalUsername = username || email.split('@')[0]
// 创建新用户
const user = new User({
username: finalUsername,
email: email || '',
password,
nickName: nickName || '星辰旅行者',
avatarUrl: avatarUrl || '',
unlockedDims: [1]
})
await user.save()
logger.info(`新用户注册: ${username}`)
// 生成 JWT token
const token = jwt.sign(
{ userId: user._id },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRE }
)
return ApiResponse.success(res, {
token,
userId: user._id,
userInfo: {
nickName: user.nickName,
avatarUrl: user.avatarUrl
}
}, '注册成功')
} catch (error) {
logger.error('用户注册失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
/**
* 获取当前用户信息
* GET /api/auth/me
*/
router.get('/me', async (req, res) => {
try {
const token = req.header('Authorization')?.replace('Bearer ', '')
if (!token) {
return ApiResponse.unauthorized(res)
}
const decoded = jwt.verify(token, process.env.JWT_SECRET)
const user = await User.findById(decoded.userId)
if (!user) {
return ApiResponse.notFound(res, '用户不存在')
}
return ApiResponse.success(res, user)
} catch (error) {
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
return ApiResponse.unauthorized(res, '认证令牌无效或已过期')
}
logger.error('获取用户信息失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
/**
* WebView Token 交换接口
* POST /api/auth/webview-token
* 用于小程序 WebView 内嵌 H5 时,通过 openid 获取登录 token
* 小程序在 URL 中传递 openid,H5 用这个接口换取 token
*/
router.post('/webview-token', async (req, res) => {
try {
const { openid } = req.body
if (!openid) {
return ApiResponse.error(res, '缺少用户标识', 400)
}
// 查找用户
const user = await User.findOne({ openid })
if (!user) {
return ApiResponse.error(res, '用户不存在', 404)
}
// 生成新的 JWT token
const token = jwt.sign(
{ openid, userId: user._id },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRE }
)
// 更新最后登录时间
user.lastLoginAt = new Date()
await user.save()
// 记录登录日志
await recordLoginLog(
user._id,
openid,
'auto_login',
'wechat_webview',
'webview_h5',
req,
true
)
logger.info(`WebView Token交换成功: ${openid}`)
return ApiResponse.success(res, {
token,
openid,
userInfo: {
nickName: user.nickName,
avatarUrl: user.avatarUrl
}
}, '登录成功')
} catch (error) {
logger.error('WebView Token交换失败:', error)
return ApiResponse.serverError(res, error.message)
}
})
module.exports = router