feat: 邮箱验证码注册+登录协议勾选
- 后端: VerificationCode模型+send-code API+register增加verifyCode校验 - 前端: 注册表单增加验证码输入框+60s倒计时获取按钮 - 前端: 登录表单增加用户协议/隐私政策勾选 - 前端: 登录按钮增加disabled状态(未勾选时不可点击) - 后端: nodemailer邮件发送工具(可配置SMTP,未配置时返回devCode)
This commit is contained in:
Generated
+14
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wdkj-server",
|
||||
"version": "1.0.0",
|
||||
"name": "ai-dimension-server",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wdkj-server",
|
||||
"version": "1.0.0",
|
||||
"name": "ai-dimension-server",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
@@ -24,6 +24,7 @@
|
||||
"mongoose": "^8.0.3",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.1.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -4250,6 +4251,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/nodemailer/-/nodemailer-9.0.3.tgz",
|
||||
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "3.1.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
||||
|
||||
+8
-2
@@ -8,7 +8,12 @@
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"keywords": ["wechat", "miniprogram", "ai", "education"],
|
||||
"keywords": [
|
||||
"wechat",
|
||||
"miniprogram",
|
||||
"ai",
|
||||
"education"
|
||||
],
|
||||
"author": "宇之然团队",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -27,6 +32,7 @@
|
||||
"mongoose": "^8.0.3",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.1.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37,4 +43,4 @@
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,13 +53,19 @@ const UserSchema = new Schema({
|
||||
},
|
||||
|
||||
// H5环境用户名密码登录
|
||||
username: {
|
||||
type: String,
|
||||
unique: true,
|
||||
sparse: true,
|
||||
index: true
|
||||
},
|
||||
password: {
|
||||
username: {
|
||||
type: String,
|
||||
unique: true,
|
||||
sparse: true,
|
||||
index: true
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
unique: true,
|
||||
sparse: true,
|
||||
index: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
select: false
|
||||
},
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
const VerificationCodeSchema = new Schema({
|
||||
email: { type: String, required: true, index: true },
|
||||
code: { type: String, required: true },
|
||||
expiresAt: { type: Date, required: true, index: true },
|
||||
used: { type: Boolean, default: false },
|
||||
createdAt: { type: Date, default: Date.now }
|
||||
})
|
||||
|
||||
// 自动过期 TTL 索引(MongoDB 自动删除过期文档)
|
||||
VerificationCodeSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 })
|
||||
|
||||
module.exports = mongoose.model('VerificationCode', VerificationCodeSchema)
|
||||
@@ -9,6 +9,7 @@ const AIModel = require('./AIModel')
|
||||
const LoginLog = require('./LoginLog')
|
||||
const Feedback = require('./Feedback')
|
||||
const Trend = require('./Trend')
|
||||
const VerificationCode = require('./VerificationCode')
|
||||
|
||||
module.exports = {
|
||||
User,
|
||||
@@ -21,5 +22,6 @@ module.exports = {
|
||||
AIModel,
|
||||
LoginLog,
|
||||
Feedback,
|
||||
Trend
|
||||
Trend,
|
||||
VerificationCode
|
||||
}
|
||||
|
||||
+119
-16
@@ -1,10 +1,11 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const jwt = require('jsonwebtoken')
|
||||
const { User, Admin, AdminLog, LoginLog } = require('../models')
|
||||
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')
|
||||
|
||||
/**
|
||||
@@ -312,10 +313,17 @@ router.post('/admin/login', async (req, res) => {
|
||||
*/
|
||||
router.post('/user/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body
|
||||
const { username, email, password } = req.body
|
||||
// 支持邮箱或用户名登录
|
||||
const identifier = email || username
|
||||
|
||||
// 查找用户(显式包含密码字段)
|
||||
const user = await User.findOne({ username }).select('+password')
|
||||
const user = await User.findOne({
|
||||
$or: [
|
||||
{ email: identifier },
|
||||
{ username: identifier }
|
||||
]
|
||||
}).select('+password')
|
||||
if (!user) {
|
||||
return ApiResponse.error(res, '用户名或密码错误', 401)
|
||||
}
|
||||
@@ -349,28 +357,123 @@ router.post('/user/login', async (req, res) => {
|
||||
}, '登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('用户登录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
logger.error('用户登录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* H5环境用户注册
|
||||
* POST /api/auth/user/register
|
||||
*/
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
* 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, password, nickName, avatarUrl } = req.body
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const existingUser = await User.findOne({ username })
|
||||
if (existingUser) {
|
||||
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,
|
||||
username: finalUsername,
|
||||
email: email || '',
|
||||
password,
|
||||
nickName: nickName || '星辰旅行者',
|
||||
avatarUrl: avatarUrl || '',
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
const nodemailer = require('nodemailer')
|
||||
const logger = require('./logger')
|
||||
|
||||
// SMTP 配置(优先从环境变量读取,方便部署时配置)
|
||||
const SMTP_HOST = process.env.SMTP_HOST || ''
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '465')
|
||||
const SMTP_USER = process.env.SMTP_USER || ''
|
||||
const SMTP_PASS = process.env.SMTP_PASS || ''
|
||||
const SMTP_FROM = process.env.SMTP_FROM || SMTP_USER
|
||||
|
||||
let transporter = null
|
||||
|
||||
function getTransporter() {
|
||||
if (transporter) return transporter
|
||||
if (!SMTP_HOST || !SMTP_USER || !SMTP_PASS) {
|
||||
logger.warn('[mail] SMTP 未配置,邮件发送功能不可用')
|
||||
return null
|
||||
}
|
||||
transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_PORT === 465,
|
||||
auth: { user: SMTP_USER, pass: SMTP_PASS }
|
||||
})
|
||||
return transporter
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码邮件
|
||||
* @param {string} to - 收件人邮箱
|
||||
* @param {string} code - 6位验证码
|
||||
*/
|
||||
async function sendVerificationCode(to, code) {
|
||||
const transport = getTransporter()
|
||||
if (!transport) {
|
||||
// 开发环境:直接打印验证码
|
||||
logger.info(`[mail-dev] 验证码 ${code} -> ${to} (SMTP 未配置,请设置 SMTP_HOST/SMTP_USER/SMTP_PASS)`)
|
||||
return { success: true, devCode: code }
|
||||
}
|
||||
try {
|
||||
await transport.sendMail({
|
||||
from: `"AI维度" <${SMTP_FROM}>`,
|
||||
to,
|
||||
subject: '【AI维度】邮箱验证码',
|
||||
html: `
|
||||
<div style="max-width:480px;margin:0 auto;padding:24px;font-family:sans-serif;">
|
||||
<h2 style="color:#6366f1;">AI 维度 - 邮箱验证</h2>
|
||||
<p>您好!</p>
|
||||
<p>您的验证码为:</p>
|
||||
<div style="font-size:32px;font-weight:700;color:#6366f1;letter-spacing:8px;text-align:center;padding:20px;background:#f5f5f7;border-radius:12px;margin:16px 0;">
|
||||
${code}
|
||||
</div>
|
||||
<p style="color:#666;">验证码有效期为 10 分钟,请勿泄露给他人。</p>
|
||||
<p style="color:#999;font-size:12px;">如果这不是您的操作,请忽略此邮件。</p>
|
||||
<hr style="border:none;border-top:1px solid #eee;margin:20px 0;">
|
||||
<p style="color:#999;font-size:12px;text-align:center;">宇之然 AI 维度 · 北京宇之然科技中心</p>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
logger.info(`[mail] 验证码邮件发送成功 -> ${to}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`[mail] 发送验证码失败 -> ${to}:`, error.message)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sendVerificationCode }
|
||||
Reference in New Issue
Block a user