603 lines
16 KiB
JavaScript
Executable File
603 lines
16 KiB
JavaScript
Executable File
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
|