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