微服务项目->在线oj系统(Java-Spring)--C端用户(超详细)
C端登录注册功能
项目结构
发送验证码
由于我们前面已经讲了很多后端代码相关内容了,相信大家学到这里的时候应该对代码很熟悉了,就不做详细讲解,看代码直接懂就好了
Controller
import com.bite.common.core.controller.BaseController;
import com.bite.common.core.domain.R;
import com.bite.friend.model.DTO.UserDTO;
import com.bite.friend.service.IUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;@RestController
@RequestMapping("/user")
public class UserController extends BaseController {@Autowiredprivate IUserService userService;// /user/sendCode@PostMapping("sendCode")public R<Void> sendCode(@RequestBody UserDTO userDTO) {return toR(userService.sendCode(userDTO)) ;}
}
DTO
@Getter
@Setter
public class UserDTO {private String phone;private String code;
}
Service
我们这里首先对传过来的手机号进行验证验证,看是否符合一个手机号的格式,然后生成一个随机验证码,至于发送验证码,我们需要用到阿里云短信服务(但是,我们发现,阿里云的短信服务居然不允许个人使用了)
import cn.hutool.core.util.RandomUtil;
import com.bite.common.core.enums.ResultCode;
import com.bite.common.security.EXCEPTION.ServiceException;
import com.bite.friend.model.DTO.UserDTO;
import com.bite.friend.service.IUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;import java.util.regex.Matcher;
import java.util.regex.Pattern;@Service
@Slf4j
public class UserServiceImpl implements IUserService {@Overridepublic int sendCode(UserDTO userDTO) {if (!checkPhone(userDTO.getPhone())) {throw new ServiceException(ResultCode.FAILED_USER_PHONE);}String phoneCodeKey = getPhoneCodeKey(userDTO.getPhone());Long expire = redisService.getExpire(phoneCodeKey, TimeUnit.SECONDS);if (expire != null && (phoneCodeExpiration * 60 - expire) < 60 ){throw new ServiceException(ResultCode.FAILED_FREQUENT);}//每天的验证码获取次数有一个限制 50次 第二天 计数清0 重新开始计数 计数 怎么存 存在哪//操作这个次数数据频繁 、 不需要存储、 记录的次数 有有效时间的(当天有效) redis String key:c:t:手机号//获取已经请求的次数 和50 进行比较 如果大于限制抛出异常。如果不大于限制,正常执行后续逻辑,并且将获取计数 + 1String codeTimeKey = getCodeTimeKey(userDTO.getPhone());Long sendTimes = redisService.getCacheObject(codeTimeKey, Long.class);if (sendTimes != null && sendTimes >= sendLimit) {throw new ServiceException(ResultCode.FAILED_TIME_LIMIT);}String code=RandomUtil.randomNumbers(6);System.out.println("密码:"+code);redisService.setCacheObject(phoneCodeKey, code, phoneCodeExpiration, TimeUnit.MINUTES);redisService.increment(codeTimeKey);if (sendTimes == null) { //说明是当天第一次发起获取验证码的请求long seconds = ChronoUnit.SECONDS.between(LocalDateTime.now(),LocalDateTime.now().plusDays(1).withHour(0).withMinute(0).withSecond(0).withNano(0));redisService.expire(codeTimeKey, seconds, TimeUnit.SECONDS);}return 1;}
}
我们这里需要保证几点:
1.检查这个手机号是否为正确的
2.获得当前验证码还剩多少秒,如果一分钟内有5次以上,则抛出异常
3.查看一天内发送了多少次,如果超过一定限度则抛出异常,每次发送后++;
mapper
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.bite.friend.model.User;public interface UserMapper extends BaseMapper<User> {}
User
package com.bite.friend.model;import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.bite.common.core.domain.BaseEntity;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.Getter;
import lombok.Setter;@Getter
@Setter
@TableName("tb_user")
public class User extends BaseEntity {@JsonSerialize(using = ToStringSerializer.class)@TableId(value = "USER_ID", type = IdType.ASSIGN_ID)private Long userId;private String nickName;private String headImage;private Integer sex;private String phone;private String code;private String email;private String wechat;private String schoolName;private String majorName;private String introduce;private Integer status;
}
配置文件bootstrap.yml
server:port: 9202
# Spring
spring:application:# 应用名称name: oj-friendprofiles:# 环境配置active: localcloud:nacos:discovery:namespace: 78015f37-f9d0-4c31-8970-cc017a0923ffserver-addr: http://localhost:8848config:namespace: 78015f37-f9d0-4c31-8970-cc017a0923ffserver-addr: http://localhost:8848file-extension: yaml
nacos配置文件
除此之外我们还需要配置gateway的白名单以及路由
server:port: 19090
spring:data:redis:host: localhostpassword: 123456cloud:gateway:routes:# 管理模块- id: oj-systemuri: lb://oj-systempredicates:- Path=/system/**filters:- StripPrefix=1- id: oj-frienduri: lb://oj-friend # 假设friend服务的注册名是friend-servicepredicates:- Path=/friend/** # 匹配以/friend开头的请求filters:- StripPrefix=1
security:ignore:whites:- /system/sysUser/login- /friend/user/sendCode- /friend/user/code/login- /friend/user/test
jwt:secret: zxsksjdjoss
登录注册接口
登录流程设计
- 点击登录按钮后,系统会执行以下验证和操作流程
- 验证码校验阶段: 从Redis缓存中获取当前手机号对应的验证码,与用户输入的验证码进行比对。若验证失败则立即终止流程
- 用户验证阶段: 通过手机号查询数据库用户表。若用户存在则识别为老用户,不存在则进入新用户注册流程
- 老用户处理: 生成新的访问令牌(Token),将完整的用户信息存入Redis缓存,设置合理的过期时间
- 新用户处理: 自动完成新用户注册,为手机号创建基础用户记录并保存到数据库。随后执行与老用户相同的令牌生成和缓存操作
@Overridepublic String codeLogin(String phone, String code) {checkCode(phone, code);User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getPhone, phone));if (user == null) { //新用户//注册逻辑user = new User();user.setPhone(phone);user.setStatus(UserStatus.Normal.getValue());user.setCreateBy(Contants.SYSTEM_USER_ID);userMapper.insert(user);}return tokenService.createToken(user.getUserId(), secret, UserIdentity.ORDINARY.getCode(), user.getNickName(), user.getHeadImage());}
其余方法
方法1: 验证码校对
方法2: 获得手机号::密码key
方法3:获得密码::时间key
方法4:校验手机号格式是否正确
private void checkCode(String phone, String code) {String phoneCodeKey = getPhoneCodeKey(phone);String cacheCode = redisService.getCacheObject(phoneCodeKey, String.class);if (StrUtil.isEmpty(cacheCode)) {throw new ServiceException(ResultCode.FAILED_INVALID_CODE);}if (!cacheCode.equals(code)) {throw new ServiceException(ResultCode.FAILED_ERROR_CODE);}//验证码比对成功redisService.deleteObject(phoneCodeKey);}private String getPhoneCodeKey(String phone) {return CacheConstants.PHONE_CODE_KEY + phone;}private String getCodeTimeKey(String phone) {return CacheConstants.CODE_TIME_KEY + phone;}public static boolean checkPhone(String phone) {Pattern regex = Pattern.compile("^1[2|3|4|5|6|7|8|9][0-9]\\d{8}$");Matcher m = regex.matcher(phone);return m.matches();}
退出登录
点击退出按钮时,系统会先检查token是否为空。若token存在且包含预设前缀,则先去除前缀,再执行token删除操作。具体流程为:解析token获取对应的redis键值,然后在redis中删除该键值。
@DeleteMapping("/logout")public R<Void> logout(@RequestHeader(HttpConstants.AUTHENTICATION) String token) {return toR(userService.logout(token));}
@Overridepublic boolean logout(String token) {if (StrUtil.isNotEmpty(token) && token.startsWith(HttpConstants.PREFIX)) {token = token.replaceFirst(HttpConstants.PREFIX, StrUtil.EMPTY);}return tokenService.deleteLoginUser(token, secret);}
获得信息
从请求头中提取token并解析后,若未能从Redis获取到对应的LoginUser信息,则返回错误响应;若成功获取,则将LoginUser中的相关数据映射到返回给前端的VO对象中。
@GetMapping("/info")public R<LoginUserVO> info(@RequestHeader(HttpConstants.AUTHENTICATION) String token) {return userService.info(token);}
@Overridepublic R<LoginUserVO> info(String token) {if (StrUtil.isNotEmpty(token) && token.startsWith(HttpConstants.PREFIX)) {token = token.replaceFirst(HttpConstants.PREFIX, StrUtil.EMPTY);}LoginUser loginUser = tokenService.getLoginUser(token, secret);System.out.println(loginUser);if (loginUser == null) {return R.fail();}LoginUserVO loginUserVO = new LoginUserVO();loginUserVO.setNickName(loginUser.getNickName());loginUserVO.setHeadImage(loginUser.getHeadImage());System.out.println(loginUserVO);return R.ok(loginUserVO);}
前端代码
Login.vue
<template><div class="login-page"><div class="orange"> </div><div class="blue"></div><div class="blue small"></div><div class="login-box"><div class="logo-box"><img src="@/assets/logo.png"><div><div class="sys-name">比特OJ</div><div class="sys-sub-name">帮助100万大学生就业</div></div></div><div class="form-box-title"><span>验证码登录</span></div><div class="form-box"><div class="form-item"><img src="@/assets/images/shouji.png"><el-input v-model="mobileForm.phone" type="text" placeholder="请输入手机号" /></div><div class="form-item"><img src="@/assets/images/yanzhengma.png"><el-input style="width:134px" v-model="mobileForm.code" type="text" placeholder="请输入验证码" /><div class="code-btn-box" @click="getCode" :disabled="isCodeBtnDisabled"><span>{{ txt }}</span></div></div><div class="submit-box" @click="loginFun">登录/注册</div></div><div class="gray-bot"><p>注册或点击登录代表您同意 <span>服务条款</span> 和 <span>隐私协议</span></p></div></div></div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { setToken } from '@/utils/cookie'
import { sendCodeService, codeLoginService } from '@/api/user'
import router from '@/router'// 验证码登录表单
let mobileForm = reactive({phone: '',code: ''
})
let txt = ref('获取验证码')
let timer = null
// 新增:控制按钮是否可点击的状态
let isCodeBtnDisabled = ref(false)async function getCode() {// 点击后立即禁用按钮isCodeBtnDisabled.value = trueawait sendCodeService(mobileForm)txt.value = '59s'let num = 59timer = setInterval(() => {num--if (num < 1) {txt.value = '重新获取验证码'clearInterval(timer)// 倒计时结束,启用按钮isCodeBtnDisabled.value = false} else {txt.value = num + 's'}}, 1000)
}async function loginFun() {const loginRef = await codeLoginService(mobileForm)setToken(loginRef.data)router.push('/c-oj/home')
}
</script>
<style lang="scss" scoped>
.login-page {width: 100vw;height: 100vh;position: relative;margin-top: -60px;margin-left: -20px;overflow: hidden;.login-box {width: 600px;height: 604px;background: #FFFFFF;box-shadow: 0px 0px 6px 0px rgba(0, 0, 0, 0.1);border-radius: 10px;opacity: 0.9;position: absolute;top: 50%;left: 50%;transform: translate(-50%, -50%);z-index: 2;padding: 0 72px;padding-top: 50px;overflow: hidden;.logo-box {display: flex;align-items: center;&.refister-logo {margin-bottom: 56px;}img {width: 68px;height: 68px;margin-right: 16px;}.sys-name {height: 33px;font-family: PingFangSC, PingFang SC;font-weight: 600;font-size: 24px;color: #222222;line-height: 33px;margin-bottom: 13px;}.sys-sub-name {height: 22px;font-family: PingFangSC, PingFang SC;font-weight: 400;font-size: 16px;color: #222222;line-height: 22px;}}.form-box-title {height: 116px;display: flex;align-items: center;span {font-family: PingFangSC, PingFang SC;font-weight: 400;font-size: 24px;color: #000000;line-height: 33px;display: block;height: 33px;margin-right: 40px;position: relative;letter-spacing: 1px;cursor: pointer;&.active {font-weight: bold;&::before {position: absolute;content: '';bottom: -13px;left: 0;width: 100%;height: 5px;background: #32C5FF;border-radius: 10px;}}}}.gray-bot {position: absolute;left: 0;text-align: center;margin-top: 56px;width: 100%;height: 50px;background: #FAFAFA;font-family: PingFangSC, PingFang SC;font-weight: 400;font-size: 14px;color: #666666;line-height: 50px;p {margin: 0;}span {color: #32C5FF;cursor: pointer;}}:deep(.form-box) {.submit-box {margin-top: 90px;width: 456px;height: 48px;background: #96E1FE;border-radius: 8px;cursor: pointer;display: flex;justify-content: center;align-items: center;font-family: PingFangSC, PingFang SC;font-weight: 600;font-size: 16px;color: #FFFFFF;letter-spacing: 1px;&.refister-submit {margin-top: 72px;}&:hover {background: #32C5FF;}}.form-item {display: flex;align-items: center;width: 456px;height: 48px;background: #F8F8F8;border-radius: 8px;margin-bottom: 30px;position: relative;.code-btn-box {position: absolute;right: 0;width: 151px;height: 48px;background: #32C5FF;border-radius: 8px;top: 0;display: flex;align-items: center;justify-content: center;cursor: pointer;span {font-family: PingFangSC, PingFang SC;font-weight: 400;font-size: 16px;color: #FFFFFF;}}.error-tip {position: absolute;width: 140px;text-align: right;padding-right: 12px;height: 20px;font-family: PingFangSC, PingFang SC;font-weight: 400;font-size: 14px;color: #FD4C40;line-height: 20px;right: 0;&.bottom {right: 157px;}}.el-input {width: 380px;font-family: PingFangSC, PingFang SC;font-weight: 400;font-size: 16px;color: #222222;}.el-input__wrapper {border: none;box-shadow: none;background: transparent;width: 230px;padding-left: 0;}img {width: 24px;height: 24px;margin: 0 18px;}}}}&::after {position: absolute;top: 0;left: 0;height: 100vh;bottom: 0;right: 0;background: rgba(255, 255, 255, .8);z-index: 1;content: '';}.orange {background: #F0714A;width: 498px;height: 498px;border-radius: 50%;background: #F0714A;opacity: 0.67;filter: blur(50px);left: 14.2%;top: 41%;position: absolute;}.blue {width: 334px;height: 334px;background: #32C5FF;opacity: 0.67;filter: blur(50px);left: 14.2%;top: 42%;position: absolute;top: 16.3%;left: 80.7%;&.small {width: 186px;height: 186px;top: 8.2%;left: 58.2%;}}
}
</style>
Template和style部分的代码我们不做多余介绍,我们介绍一些js部分代码
Home.vue
<template><div class="oj-main-layout"><div class="oj-main-layout-header"><div class="oj-main-layout-nav"><Navbar></Navbar></div></div><div><img src="@/assets/images/log-banner.png" class="banner-img"></div></div>
</template><script setup>
import Navbar from '@/components/Navbar.vue'
</script><style lang="scss" scoped>
.el-main {padding: 0;
}.oj-main-layout {padding-top: 20px;.banner-img {max-width: 1520px;margin: 0 auto;border-radius: 16px;width: "100%"}.oj-main-layout-header {height: 60px;position: absolute;width: 100%;background: #fff;left: 0;top: 0;z-index: 3;overflow: hidden;}.oj-main-layout-nav {max-width: 1520px;min-width: 100%;margin: 0 auto;height: 60px;background: #fff;}.oj-ship-banner {max-width: 1520px;min-width: 1520;margin: 0 auto;width: 100%;height: 100%;height: 350px;color: #ffffff;background: url("@/assets/index_bg.png") left top no-repeat;background-size: cover;overflow: hidden;}
}
</style>
Navbar.vue
<template><div class="oj-navbar"><div class="oj-navbar-menus"><img class="oj-navbar-logo" src="@/assets/logo.png" /><el-menu class="oj-navbar-menu" mode="horizontal"><el-menu-item>题库</el-menu-item><el-menu-item>竞赛</el-menu-item></el-menu></div><div class="oj-navbar-users"><img v-if="isLogin" class="oj-message" @click="goMessage" src="@/assets/message/message.png" /><el-dropdown v-if="isLogin"><div class="oj-navbar-name"><img class="oj-head-image" v-if="isLogin" :src="userInfo.headImage" /><span>{{ userInfo.nickName }}</span></div><template #dropdown><el-dropdown-menu><el-dropdown-item @click="goUserInfo"><div class="oj-navabar-item"><span>个⼈中⼼</span></div></el-dropdown-item><el-dropdown-item @click="goMyExam"><div class="oj-navabar-item"><span>我的⽐赛</span></div></el-dropdown-item><el-dropdown-item><div class="oj-navabar-item"><span @click="handleLogout">退出登录</span></div></el-dropdown-item></el-dropdown-menu></template></el-dropdown><span class="oj-navbar-login-btn" v-if="!isLogin" @click="goLogin">登录</span></div></div>
</template><script setup>
import { ref, reactive, onMounted } from 'vue'
import router from '@/router';
import { getToken, removeToken } from '@/utils/cookie'
import { logoutService, getUserInfoService } from '@/api/user'
import { ElMessageBox } from 'element-plus'; // 补充可能遗漏的导入const userInfo = reactive({nickName: '',headImage: ''
})
// 当前登录状态
const isLogin = ref(false)// 检查登录状态
async function checkLogin() {if (getToken()) {// 实际项目中应调用接口验证token有效性const userInfoRes = await getUserInfoService()console.log(userInfoRes)userInfoRes.data.headImage = `/${userInfoRes.data.headImage}`Object.assign(userInfo, userInfoRes.data)console.log(userInfo)isLogin.value = true}
}
checkLogin()// 退出登录
async function handleLogout() {await ElMessageBox.confirm('确认退出','温馨提⽰',{confirmButtonText: '确认',cancelButtonText: '退出',type: 'warning',})await logoutService()removeToken()isLogin.value = false
}// 跳转到登录页
function goLogin() {router.push('/c-oj/login')
}// 以下方法需要补充实现
function goMessage() {// 消息页面跳转逻辑
}function goUserInfo() {// 个人中心跳转逻辑
}function goMyExam() {// 我的比赛跳转逻辑
}
</script><style lang="scss" scoped>
.oj-navbar {display: flex;justify-content: space-between;align-items: center;padding: 0 20px;box-sizing: border-box;max-width: 1520px;margin: 0 auto;.oj-navbar-menus {display: flex;align-items: center;height: 50px;.el-menu-item {font-family: PingFangSC, PingFang SC;font-weight: 400;font-size: 20px;color: #222222;line-height: 28px;text-align: center;width: 42px;text-align: left;margin-right: 25px;}}.oj-navbar-logo {width: 38px;height: 38px;background: #32C5FF;border-radius: 8px;cursor: pointer;object-fit: contain;margin-right: 59px;}.oj-navbar-menu {width: 600px;border: none;.el-menu-item {font-size: 16px;font-weight: 500;background-color: transparent !important;transition: none;border: none;line-height: 60px;}}.oj-navbar-users {display: flex;align-items: center;}.oj-navbar-login-btn {line-height: 60px;display: inline-block;font-family: PingFangSC, PingFang SC;font-weight: 400;font-size: 18px;color: #222222;text-align: center;cursor: pointer;.line {display: inline-block;width: 25px;}}.oj-message {cursor: pointer;margin-top: 15px;}.oj-head-image {width: 30px;height: 30px;border-radius: 30px;margin-right: 10px;}.oj-navbar-name {cursor: pointer;margin-top: 15px;font-weight: 400;color: #000;margin-left: 15px;font-size: 20px;width: 100px;display: flex;align-items: center;}.oj-navabar-item {display: flex;align-items: center;justify-content: center;padding: 0 32px;}
}
</style>
request.js
和之前学的一样,不做多余解释
import axios from 'axios'
import { getToken, removeToken } from './cookie'
import router from '@/router';//不同的功能,通过axios请求的是不同接口的地址
//127.0.0.1:19090
const service = axios.create({baseURL:"/dev-api",timeout:5000,
})
axios.defaults.headers["Content-Type"] = "application/json;charset=utf-8";//请求拦截器
service.interceptors.request.use((config) => {if (getToken()) {config.headers["Authorization"] = "Bearer " + getToken();}return config;},(error) => {console.log(error)Promise.reject(error);}
);//响应拦截器
service.interceptors.response.use((res) => { //res : 响应数据// 未设置状态码则默认成功状态const code = res.data.code;const msg = res.data.msg;if (code === 3001) {ElMessage.error(msg);removeToken()return Promise.reject(new Error(msg));} else if (code !== 1000) {ElMessage.error(msg);return Promise.reject(new Error(msg));} else {return Promise.resolve(res.data);}},(error) => {return Promise.reject(error);}
);export default service
cookie.js
和之前系统用户那里一样,不做多余解释
import Cookies from "js-cookie";
const TokenKey = "Oj-c-Token";export function getToken() {return Cookies.get(TokenKey);
}export function setToken(token) {return Cookies.set(TokenKey, token);
}export function removeToken() {return Cookies.remove(TokenKey);
}
index.js
路由功能,不做多余解释
import { createRouter, createWebHistory } from 'vue-router'const router = createRouter({history: createWebHistory(import.meta.env.BASE_URL),routes: [{path: "/",redirect: '/c-oj/home',},{path: "/c-oj/home",name: "home",component: () => import("@/views/Home.vue"),},{path: "/c-oj/login",name: "login",component: () => import("@/views/Login.vue"),},{path: "//c-oj/test",name: "test",component: () => import("@/views/Test.vue"),}],
})export default router
vite.config.js
配置功能,不多余解释
import { fileURLToPath, URL } from 'node:url'import { defineConfig } from 'vite'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import vue from '@vitejs/plugin-vue'// https://vitejs.dev/config/
export default defineConfig({plugins: [vue(),AutoImport({resolvers: [ElementPlusResolver()],}),Components({resolvers: [ElementPlusResolver()],}),],resolve: {alias: {'@': fileURLToPath(new URL('./src', import.meta.url))}},server: {proxy: {"/dev-api": {target: "http://127.0.0.1:19090/friend",rewrite: (p) => p.replace(/^\/dev-api/, ""),},},},
})