You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于React与MongoDB的手机号OTP验证及账号流转方案咨询

React + MongoDB 账号流程实现方案

后端实现(Express + Mongoose)

前端无法直接操作MongoDB,必须通过后端接口完成数据交互,以下是基于Node.js+Express+Mongoose的核心实现:

1. 数据模型定义

创建用户模型,核心字段包含手机号(唯一索引)、OTP信息、邮箱及验证状态:

const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  phone: {
    type: String,
    required: true,
    unique: true,
    trim: true
  },
  otp: {
    type: String,
    default: null
  },
  otpExpiresAt: {
    type: Date,
    default: null
  },
  email: {
    type: String,
    default: null,
    trim: true
  },
  isEmailVerified: {
    type: Boolean,
    default: false
  },
  isPhoneVerified: {
    type: Boolean,
    default: false
  }
});

// 手机号唯一索引,防止重复创建
userSchema.index({ phone: 1 }, { unique: true });

module.exports = mongoose.model('User', userSchema);

2. 核心接口实现

(1)手机号校验接口

const User = require('../models/User');

app.post('/api/check-phone', async (req, res) => {
  try {
    const { phone } = req.body;
    const existingUser = await User.findOne({ phone });
    
    if (existingUser) {
      return res.status(200).json({ exists: true });
    }

    // 手机号不存在,创建初始用户记录(未验证状态)
    const newUser = await User.create({ phone });
    res.status(201).json({ exists: false, userId: newUser._id.toString() });
  } catch (err) {
    // 捕获唯一索引冲突(并发请求创建同一手机号)
    if (err.code === 11000) {
      return res.status(200).json({ exists: true });
    }
    res.status(500).json({ error: '服务器错误' });
  }
});

(2)OTP生成与验证接口

const crypto = require('crypto');

// 发送OTP(5分钟过期)
app.post('/api/send-otp', async (req, res) => {
  try {
    const { userId } = req.body;
    const otp = crypto.randomInt(100000, 999999).toString();
    const otpExpiresAt = new Date(Date.now() + 5 * 60 * 1000);

    await User.findByIdAndUpdate(userId, { otp, otpExpiresAt });

    // 调用第三方短信服务发送OTP(如阿里云/腾讯云短信API)
    // sendSms(phone, otp);

    res.status(200).json({ message: 'OTP已发送' });
  } catch (err) {
    res.status(500).json({ error: '发送OTP失败' });
  }
});

// 验证OTP
app.post('/api/verify-otp', async (req, res) => {
  try {
    const { userId, otp } = req.body;
    const user = await User.findById(userId);

    if (!user || user.otp !== otp || user.otpExpiresAt < new Date()) {
      return res.status(400).json({ error: 'OTP无效或已过期' });
    }

    // 标记手机号已验证,清空OTP
    await User.findByIdAndUpdate(userId, {
      isPhoneVerified: true,
      otp: null,
      otpExpiresAt: null
    });

    res.status(200).json({ success: true });
  } catch (err) {
    res.status(500).json({ error: '验证OTP失败' });
  }
});

(3)邮箱验证接口

const jwt = require('jsonwebtoken');

// 发送邮箱验证链接
app.post('/api/send-email-verification', async (req, res) => {
  try {
    const { userId, email } = req.body;
    // 生成1小时有效期的验证token
    const token = jwt.sign({ userId, email }, process.env.JWT_SECRET, { expiresIn: '1h' });
    const verifyLink = `http://your-domain.com/api/verify-email?token=${token}`;

    // 调用第三方邮件服务发送链接(如Nodemailer)
    // sendEmail(email, verifyLink);

    // 先更新邮箱到用户记录
    await User.findByIdAndUpdate(userId, { email });

    res.status(200).json({ message: '验证邮件已发送' });
  } catch (err) {
    res.status(500).json({ error: '发送验证邮件失败' });
  }
});

// 验证邮箱链接
app.get('/api/verify-email', async (req, res) => {
  try {
    const { token } = req.query;
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    const { userId } = decoded;

    await User.findByIdAndUpdate(userId, { isEmailVerified: true });

    // 跳转至前端注册页
    res.redirect('/register');
  } catch (err) {
    res.status(400).json({ error: '验证链接无效或已过期' });
  }
});

前端React实现(React Router v6)

1. 路由配置

import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import PhoneCheckPage from './pages/PhoneCheckPage';
import LoginPage from './pages/LoginPage';
import OTPVerifyPage from './pages/OTPVerifyPage';
import EmailVerifyPage from './pages/EmailVerifyPage';
import RegisterPage from './pages/RegisterPage';

function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<PhoneCheckPage />} />
        <Route path="/login" element={<LoginPage />} />
        <Route path="/verify-otp" element={<OTPVerifyPage />} />
        <Route path="/verify-email" element={<EmailVerifyPage />} />
        <Route path="/register" element={<RegisterPage />} />
        <Route path="*" element={<Navigate to="/" />} />
      </Routes>
    </Router>
  );
}

export default App;

2. 手机号校验页面(核心入口)

import { useState } from 'react';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';

function PhoneCheckPage() {
  const [phone, setPhone] = useState('');
  const [loading, setLoading] = useState(false);
  const navigate = useNavigate();

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!phone.trim()) return;

    setLoading(true);
    try {
      const res = await axios.post('/api/check-phone', { phone });
      if (res.data.exists) {
        navigate('/login');
      } else {
        // 存储userId到localStorage,供后续页面使用
        localStorage.setItem('userId', res.data.userId);
        navigate('/verify-otp');
      }
    } catch (err) {
      alert('网络错误,请重试');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="phone-check">
      <h2>输入手机号</h2>
      <form onSubmit={handleSubmit}>
        <input
          type="tel"
          value={phone}
          onChange={(e) => setPhone(e.target.value)}
          placeholder="请输入手机号"
          disabled={loading}
        />
        <button type="submit" disabled={loading}>
          {loading ? '校验中...' : '下一步'}
        </button>
      </form>
    </div>
  );
}

export default PhoneCheckPage;

3. OTP验证页面

import { useState, useEffect } from 'react';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';

function OTPVerifyPage() {
  const [otp, setOtp] = useState('');
  const [loading, setLoading] = useState(false);
  const navigate = useNavigate();
  const userId = localStorage.getItem('userId');

  // 页面加载自动发送OTP
  useEffect(() => {
    if (!userId) {
      navigate('/');
      return;
    }
    const sendOTP = async () => {
      try {
        await axios.post('/api/send-otp', { userId });
      } catch (err) {
        alert('发送OTP失败,请重试');
      }
    };
    sendOTP();
  }, [userId, navigate]);

  const handleVerify = async (e) => {
    e.preventDefault();
    if (otp.length !== 6) return;

    setLoading(true);
    try {
      const res = await axios.post('/api/verify-otp', { userId, otp });
      if (res.data.success) {
        navigate('/verify-email');
      }
    } catch (err) {
      alert('OTP无效或已过期');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="otp-verify">
      <h2>输入验证码</h2>
      <form onSubmit={handleVerify}>
        <input
          type="text"
          value={otp}
          onChange={(e) => setOtp(e.target.value.replace(/\D/g, ''))}
          maxLength={6}
          placeholder="6位验证码"
          disabled={loading}
        />
        <button type="submit" disabled={loading}>
          {loading ? '验证中...' : '验证'}
        </button>
      </form>
    </div>
  );
}

export default OTPVerifyPage;

4. 邮箱验证页面

import { useState, useEffect } from 'react';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';

function EmailVerifyPage() {
  const [email, setEmail] = useState('');
  const [loading, setLoading] = useState(false);
  const [sent, setSent] = useState(false);
  const navigate = useNavigate();
  const userId = localStorage.getItem('userId');

  useEffect(() => {
    if (!userId) navigate('/');
  }, [userId, navigate]);

  const handleSendVerification = async (e) => {
    e.preventDefault();
    if (!email.trim()) return;

    setLoading(true);
    try {
      await axios.post('/api/send-email-verification', { userId, email });
      setSent(true);
      alert('验证邮件已发送,请查收');
    } catch (err) {
      alert('发送邮件失败,请重试');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="email-verify">
      {!sent ? (
        <>
          <h2>输入邮箱</h2>
          <form onSubmit={handleSendVerification}>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              placeholder="请输入邮箱"
              disabled={loading}
            />
            <button type="submit" disabled={loading}>
              {loading ? '发送中...' : '发送验证邮件'}
            </button>
          </form>
        </>
      ) : (
        <div>
          <h3>验证邮件已发送</h3>
          <p>请点击邮件中的链接完成验证,验证后将自动跳转至注册页</p>
        </div>
      )}
    </div>
  );
}

export default EmailVerifyPage;

5. 注册页(补全用户信息)

import { useState, useEffect } from 'react';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
import bcrypt from 'bcryptjs';

function RegisterPage() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);
  const navigate = useNavigate();
  const userId = localStorage.getItem('userId');

  useEffect(() => {
    if (!userId) navigate('/');
  }, [userId, navigate]);

  const handleRegister = async (e) => {
    e.preventDefault();
    if (!username.trim() || password.length < 6) return;

    setLoading(true);
    try {
      const hashedPassword = await bcrypt.hash(password, 10);
      await axios.put('/api/complete-profile', {
        userId,
        username,
        password: hashedPassword
      });
      localStorage.removeItem('userId');
      navigate('/login');
      alert('注册成功');
    } catch (err) {
      alert('注册失败,请重试');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="register">
      <h2>完成注册</h2>
      <form onSubmit={handleRegister}>
        <input
          type="text"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
          placeholder="用户名"
          disabled={loading}
        />
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          placeholder="密码(至少6位)"
          disabled={loading}
        />
        <button type="submit" disabled={loading}>
          {loading ? '注册中...' : '完成注册'}
        </button>
      </form>
    </div>
  );
}

export default RegisterPage;

内容的提问来源于stack exchange,提问作者Shrutik Dawane

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.30 08:15:02