本文参考自
Springboot3+微服务实战12306高性能售票系统 - 慕课网 (imooc.***)
本文是仿12306项目实战第(二)章——项目实现 的第二篇,详细讲解使用vue3 + Vue CLI 实现前端模块搭建的过程,同时其中也会涉及一些前后端交互的实现,因此也会开发一些后端接口;搭建好前端页面后,还会实现JWT单点登录功能
一、环境准备
-
安装nodejs 18 +
设置镜像
- IDEA 配置nodejs
-
安装vue cli
npm install -g @vue/cli@5.0.8
这里报了错误,原因是淘宝镜像过期了,解决办法是:修改镜像地址为https://registry.npmmirror.***
npm config set registry https://registry.npmmirror.***
参考:npm报错:request to https://registry.npm.taobao.org failed, reason certificate has expired-CSDN博客
二、使用Vue CLI 创建web模块
解决IDEA命令行(powershell)提示vue脚本报错:
IDEA中报错:因为在此系统上禁止运行脚本有关详细信息,请参阅…(图文解释 亲测已解决)_因为在此系统上禁止运行脚本。有关详细信息-CSDN博客
-
创建web模块
vue create web
-
启动
$ cd web $ npm run serve
-
修改package.json文件,改变启动的默认端口
三、集成Ant Design Vue
-
安装
npm i ant-design-vue
这里使用npm i ant-design-vue@3.2.15 安装和教程一样的版本
-
全局引入组件
main.js
- 测试
-
引入css样式
main.js
import 'ant-design-vue/dist/antd.css';
-
引入Icon
npm install --save @ant-design/icons-vue
版本课程里是6.1.0
全局使用图标
main.js
import * as Icons from '@ant-design/icons-vue';
const app = createApp(App); app.use(Antd).use(store).use(router).mount('#app'); //全局使用图标 const icons = Icons; for (const i in icons) { app.***ponent(i,icons[i]) }
-
测试
四、注册登录二合一界面开发
由于本课程项目主要针对后端技术学习,所以前端就不做详细的讲解
-
加路由
web/src/router/index.js
{ path: '/login', ***ponent: () => import('../views/login.vue') }
-
增加login.vue页面
web/src/views/login.vue
<template> <a-row class="login"> <a-col :span="8" :offset="8" class="login-main"> <h1 style="text-align: center"><rocket-two-tone /> neilxu 12306售票系统</h1> <a-form :model="loginForm" name="basic" auto***plete="off" @finish="onFinish" @finishFailed="onFinishFailed" > <a-form-item label="" name="mobile" :rules="[{ required: true, message: '请输入手机号!' }]" > <a-input v-model:value="loginForm.mobile" placeholder="手机号"/> </a-form-item> <a-form-item label="" name="code" :rules="[{ required: true, message: '请输入验证码!' }]" > <a-input v-model:value="loginForm.code"> <template #addonAfter> <a @click="sendCode">获取验证码</a> </template> </a-input> <!--<a-input v-model:value="loginForm.code" placeholder="验证码"/>--> </a-form-item> <a-form-item> <a-button type="primary" block html-type="submit">登录</a-button> </a-form-item> </a-form> </a-col> </a-row> </template> <script> import { define***ponent, reactive } from 'vue'; export default define***ponent({ name: "login-view", setup() { const loginForm = reactive({ mobile: '13000000000', code: '', }); const onFinish = values => { console.log('Su***ess:', values); }; const onFinishFailed = errorInfo => { console.log('Failed:', errorInfo); }; return { loginForm, onFinish, onFinishFailed, }; }, }); </script> <style> .login-main h1 { font-size: 25px; font-weight: bold; } .login-main { margin-top: 100px; padding: 30px 30px 20px; border: 2px solid grey; border-radius: 10px; background-color: #fcfcfc; } </style>
这里注意name用两个单词以上,不然之前安装的ESLint会报错语法不规范
export default define***ponent({ name: "login-view",
或者直接去package.json,“eslintConfig"下的"rules”
修改成"rules": { "vue/multi-word-***ponent-names": 0 }
则可以关闭eslint multi-word的校验
- 效果
五、发送短信验证码接口开发
-
请求实体类
***.neilxu.train.member.req.MemberSendCodeReq
@Data public class MemberSendCodeReq { @NotBlank(message = "【手机号】不能为空") @Pattern(regexp = "^\\d{10}$",message = "手机号码格式错误") private String mobile; }
-
service方法
public void sendCode(MemberSendCodeReq req) { String mobile = req.getMobile(); MemberExample memberExample = new MemberExample(); memberExample.createCriteria().andMobileEqualTo(mobile); List<Member> list = memberMapper.selectByExample(memberExample); // 如果手机号不存在,则插入一条记录 if (CollUtil.isEmpty(list)) { LOG.info("手机号不存在,插入一条记录"); Member member = new Member(); member.setId(SnowUtil.getSnowflakeNextId()); member.setMobile(mobile); memberMapper.insert(member); } else { LOG.info("手机号存在,不插入记录"); } // 生成验证码 // String code = RandomUtil.randomString(4); String code = "8888"; LOG.info("生成短信验证码:{}", code); // 保存短信记录表:手机号,短信验证码,有效期,是否已使用,业务类型,发送时间,使用时间 LOG.info("保存短信记录表"); // 对接短信通道,发送短信 LOG.info("对接短信通道"); }
-
controller层
@PostMapping("/send-code") public ***monResp<Long> sendCode(@Valid MemberSendCodeReq req) { memberService.sendCode(req); return new ***monResp<>(); }
-
测试
POST http://localhost:8000/member/member/send-code Content-Type: application/x-www-form-urlencoded mobile=13000000000 ###
六、短信验证码登录接口开发
-
更新下hutool依赖
这里课程讲解到使用BeanUtil类的时候,发现缺少了BeanUtil.copyToList()方法,因此修改下依赖版本
<dependency> <groupId>***.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.10</version> </dependency>
BeanUtil.copyToList
方法属于浅拷贝,它只会复制对象的引用而不会复制对象本身。换句话说,当使用BeanUtil.copyToList
方法将源对象列表中的属性复制到目标对象列表中时,如果属性是引用类型(如自定义类对象),则复制的是对象引用而不是新的独立对象。这意味着如果源对象列表中的对象发生了变化,目标对象列表中对应元素的属性也会随之变化。如果需要进行深拷贝,即复制对象本身而不是仅复制引用,可以考虑使用Hutool工具类库中的其他深拷贝方法,例如
CopyUtil.copyList
。深拷贝会创建全新的对象实例,并将原对象的所有属性值都复制到新创建的对象中,这样即使原对象发生变化也不会影响到新的拷贝对象。 ----------来自ChatGPT的回答
-
登录请求实体类
***.neilxu.train.member.req.MemberLoginReq
@Data public class MemberLoginReq { @NotBlank(message = "【手机号】不能为空") @Pattern(regexp = "^1\\d{10}$",message = "手机号码格式错误") private String mobile; @NotBlank(message = "【短信验证码】不能为空") private String code; }
-
登录返回结果类
***.neilxu.train.member.resp.MemberLoginResppackage ***.neilxu.train.member.resp; public class MemberLoginResp { private Long id; private String mobile; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getMobile() { return mobile; } public void setMobile(String mobile) { this.mobile = mobile; } @Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append(getClass().getSimpleName()); sb.append(" ["); sb.append("Hash = ").append(hashCode()); sb.append(", id=").append(id); sb.append(", mobile=").append(mobile); sb.append("]"); return sb.toString(); } }
-
新增异常枚举
MEMBER_MOBILE_NOT_EXIST("请先获取短信验证码"), MEMBER_MOBILE_CODE_ERROR("短信验证码错误");
-
service方法
public MemberLoginResp login(MemberLoginReq req) { String mobile = req.getMobile(); String code = req.getCode(); Member memberDB = selectByMobile(mobile); // 如果手机号不存在,则插入一条记录 if (ObjectUtil.isNull(memberDB)) { throw new BusinessException(BusinessExceptionEnum.MEMBER_MOBILE_NOT_EXIST); } // 校验短信验证码 if (!"8888".equals(code)) { throw new BusinessException(BusinessExceptionEnum.MEMBER_MOBILE_CODE_ERROR); } return BeanUtil.copyProperties(memberDB, MemberLoginResp.class); }
这里将前面的代码块封装了一个方法——通过手机号查找用户
private Member selectByMobile(String mobile) { MemberExample memberExample = new MemberExample(); memberExample.createCriteria().andMobileEqualTo(mobile); List<Member> list = memberMapper.selectByExample(memberExample); if (CollUtil.isEmpty(list)) { return null; } else { return list.get(0); } }
正常项目的登录接口还需要校验验证码的有效性(redis),以及对接口做访问频率检查等(防止黑客恶意访问),但本项目重点不在此,所以不做过多的处理
-
controller层
***.neilxu.train.member.controller.MemberController
@PostMapping("/login") public ***monResp<MemberLoginResp> login(@Valid MemberLoginReq req) { MemberLoginResp resp = memberService.login(req); return new ***monResp<>(resp); }
-
测试
七、集成Axios完成登录功能
-
安装Axios组件
npm install axios
-
引入Axios
web/src/views/login.vue
import axios from 'axios';
const sendCode = () => { axios.post("http://localhost:8000/member/member/send-code", { mobile: loginForm.mobile }).then(response => { console.log(response); }); }; return { loginForm, onFinish, onFinishFailed, sendCode };
此时,会有跨域问题(ip相同但是端口不同)
-
解决跨域问题
跨域问题(Cross-Origin Resource Sharing)是指在浏览器环境中,由于浏览器遵循同源策略的原则,导致在跨域访问资源时被阻止或限制的问题。
同源策略是浏览器的一项安全策略,它要求网页只能从同一源(协议、域名、端口号)的文档加载其他资源或与同一源的服务器进行交互。当浏览器发现当前网页请求的资源不符合同源策略的要求时,会阻止或限制该请求。
跨域问题通常在前端开发中遇到,例如当浏览器中运行的 JavaScript 代码尝试获取另一个域名下的数据时,就可能触发跨域问题。为了解决这些问题,通常需要在后端进行一些配置或在前端使用一些技术手段来绕过浏览器的限制。
----------来自ChatGPT的回答
解决:
修改网关模块配置文件
gateway/src/main/resources/application.properties
# 允许请求来源(老版本叫allowedOrigin) spring.cloud.gateway.globalcors.cors-configurations.[/**].allowedOriginPatterns=* # 允许携带的头信息 spring.cloud.gateway.globalcors.cors-configurations.[/**].allowedHeaders=* # 允许的请求方式 spring.cloud.gateway.globalcors.cors-configurations.[/**].allowedMethods=* # 是否允许携带cookie spring.cloud.gateway.globalcors.cors-configurations.[/**].allowCredentials=true # 跨域检测的有效期,会发起一个OPTION请求 spring.cloud.gateway.globalcors.cors-configurations.[/**].maxAge=3600
-
解决前后端传参问题
修改controller参数接收类型
***.neilxu.train.member.controller.MemberController
@PostMapping("/send-code")
public ***monResp<Long> sendCode(@Valid @RequestBody MemberSendCodeReq req) {
memberService.sendCode(req);
return new ***monResp<>();
}
修改http请求测试
POST http://localhost:8000/member/member/send-code
Content-Type: application/json
{
"mobile": "13000000001"
}
###
测试
-
完成登录功能
***.neilxu.train.member.controller.MemberController
@PostMapping("/login") public ***monResp<MemberLoginResp> login(@Valid @RequestBody MemberLoginReq req) { MemberLoginResp resp = memberService.login(req); return new ***monResp<>(resp); }
web/src/views/login.vue
<a-form-item> <a-button type="primary" block @click="login">登录</a-button> </a-form-item>
import { notification } from 'ant-design-vue';
const sendCode = () => { axios.post("http://localhost:8000/member/member/send-code", { mobile: loginForm.mobile }).then(response => { console.log(response); let data = response.data; if (data.su***ess) { notification.su***ess({ description: '发送验证码成功!' }); loginForm.code = "8888"; } else { notification.error({ description: data.message }); } }); }; const login = () => { axios.post("http://localhost:8000/member/member/login", loginForm).then(response => { let data = response.data; if (data.su***ess) { notification.su***ess({ description: '登录成功!' }); console.log("登录成功:", data.content); } else { notification.error({ description: data.message }); } }) }; return { loginForm, sendCode, login };
-
测试
八、增加axios拦截器,打印请求参数和返回结果
web/src/main.js
import axios from 'axios';
/**
* axios拦截器
*/
axios.interceptors.request.use(function (config) {
console.log('请求参数:', config);
return config;
}, error => {
return Promise.reject(error);
});
axios.interceptors.response.use(function (response) {
console.log('返回结果:', response);
return response;
}, error => {
console.log('返回错误:', error);
return Promise.reject(error);
});
测试
九、Vue CLI多环境配置;为axios配置后端域名
-
新增配置文件
web/.env.dev
NODE_ENV=development VUE_APP_SERVER=http://localhost:8000
web/.prod.dev
NODE_ENV=production VUE_APP_SERVER=http://train.imooc.***
-
修改main.js
axios.defaults.baseURL = process.env.VUE_APP_SERVER; console.log('环境:', process.env.NODE_ENV); console.log('服务端:', process.env.VUE_APP_SERVER);
-
修改package.json
"serve-dev": "vue-cli-service serve --mode dev --port 9000", "serve-prod": "vue-cli-service serve --mode prod --port 9000",
-
修改login.vue
去掉baseURL
const sendCode = () => { axios.post("/member/member/send-code", { mobile: loginForm.mobile }).then(response => { let data = response.data; if (data.su***ess) { notification.su***ess({ description: '发送验证码成功!' }); loginForm.code = "8888"; } else { notification.error({ description: data.message }); } }); }; const login = () => { axios.post("/member/member/login", loginForm).then(response => { let data = response.data; if (data.su***ess) { notification.su***ess({ description: '登录成功!' }); } else { notification.error({ description: data.message }); } }) };
-
重启测试
十、增加web控台主页,登录成功后跳转主页
-
修改路由文件
web/src/router/index.js
import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/login', ***ponent: () => import('../views/login.vue') }, { path: '/', ***ponent: () => import('../views/main.vue') } ] const router = createRouter({ history: createWebHistory(process.env.BASE_URL), routes }) export default router
-
增加控台主页面
web/src/views/main.vue
从ant design vue官网扒代码
<template>
<a-layout id="***ponents-layout-demo-top-side-2">
<a-layout-header class="header">
<div class="logo" />
<a-menu
v-model:selectedKeys="selectedKeys1"
theme="dark"
mode="horizontal"
:style="{ lineHeight: '64px' }"
>
<a-menu-item key="1">nav 1</a-menu-item>
<a-menu-item key="2">nav 2</a-menu-item>
<a-menu-item key="3">nav 3</a-menu-item>
</a-menu>
</a-layout-header>
<a-layout>
<a-layout-sider width="200" style="background: #fff">
<a-menu
v-model:selectedKeys="selectedKeys2"
v-model:openKeys="openKeys"
mode="inline"
:style="{ height: '100%', borderRight: 0 }"
>
<a-sub-menu key="sub1">
<template #title>
<span>
<user-outlined />
subnav 1
</span>
</template>
<a-menu-item key="1">option1</a-menu-item>
<a-menu-item key="2">option2</a-menu-item>
<a-menu-item key="3">option3</a-menu-item>
<a-menu-item key="4">option4</a-menu-item>
</a-sub-menu>
<a-sub-menu key="sub2">
<template #title>
<span>
<laptop-outlined />
subnav 2
</span>
</template>
<a-menu-item key="5">option5</a-menu-item>
<a-menu-item key="6">option6</a-menu-item>
<a-menu-item key="7">option7</a-menu-item>
<a-menu-item key="8">option8</a-menu-item>
</a-sub-menu>
<a-sub-menu key="sub3">
<template #title>
<span>
<notification-outlined />
subnav 3
</span>
</template>
<a-menu-item key="9">option9</a-menu-item>
<a-menu-item key="10">option10</a-menu-item>
<a-menu-item key="11">option11</a-menu-item>
<a-menu-item key="12">option12</a-menu-item>
</a-sub-menu>
</a-menu>
</a-layout-sider>
<a-layout style="padding: 0 24px 24px">
<a-breadcrumb style="margin: 16px 0">
<a-breadcrumb-item>Home</a-breadcrumb-item>
<a-breadcrumb-item>List</a-breadcrumb-item>
<a-breadcrumb-item>App</a-breadcrumb-item>
</a-breadcrumb>
<a-layout-content
:style="{ background: '#fff', padding: '24px', margin: 0, minHeight: '280px' }"
>
Content
</a-layout-content>
</a-layout>
</a-layout>
</a-layout>
</template>
<script>
import { UserOutlined, LaptopOutlined, NotificationOutlined } from '@ant-design/icons-vue';
import { define***ponent, ref } from 'vue';
export default define***ponent({
name: "main-view",
***ponents: {
UserOutlined,
LaptopOutlined,
NotificationOutlined,
},
setup() {
return {
selectedKeys1: ref(['2']),
selectedKeys2: ref(['1']),
collapsed: ref(false),
openKeys: ref(['sub1']),
};
},
});
</script>
<style>
#***ponents-layout-demo-top-side-2 .logo {
float: left;
width: 120px;
height: 31px;
margin: 16px 24px 16px 0;
background: rgba(255, 255, 255, 0.3);
}
.ant-row-rtl #***ponents-layout-demo-top-side-2 .logo {
float: right;
margin: 16px 0 16px 24px;
}
.site-layout-background {
background: #fff;
}
</style>
注意
复制过来后可能出现兼容问题,例如这里需要增加id属性,不然logo就看不到了
还有就是注意这里加个名字,不然还会有ESLint语法报错
export default define***ponent({
name: "main-view",
-
修改login.vue
import { useRouter } from 'vue-router' export default define***ponent({ name: "login-view", setup() { const router = useRouter(); const loginForm = reactive({ mobile: '13000000000', code: '', });
const login = () => { axios.post("/member/member/login", loginForm).then(response => { let data = response.data; if (data.su***ess) { notification.su***ess({ description: '登录成功!' }); // 登录成功,跳到控台主页 router.push("/"); } else { notification.error({ description: data.message }); } }) };
-
测试效果
十一、制作Vue3公共组件
这里我们将头部header和侧边栏sider提取出来作为组件,使用课程的vue3语法
-
提取the-header组件
web/src/views/main.vue
更新为左边,后面同理
<template>
<a-layout id="***ponents-layout-demo-top-side-2">
<the-header-view></the-header-view>
<a-layout>
<a-layout-sider width="200" style="background: #fff">
<a-menu
v-model:selectedKeys="selectedKeys2"
v-model:openKeys="openKeys"
mode="inline"
:style="{ height: '100%', borderRight: 0 }"
>
<a-sub-menu key="sub1">
<template #title>
<span>
<user-outlined />
subnav 1
</span>
</template>
<a-menu-item key="1">option1</a-menu-item>
<a-menu-item key="2">option2</a-menu-item>
<a-menu-item key="3">option3</a-menu-item>
<a-menu-item key="4">option4</a-menu-item>
</a-sub-menu>
<a-sub-menu key="sub2">
<template #title>
<span>
<laptop-outlined />
subnav 2
</span>
</template>
<a-menu-item key="5">option5</a-menu-item>
<a-menu-item key="6">option6</a-menu-item>
<a-menu-item key="7">option7</a-menu-item>
<a-menu-item key="8">option8</a-menu-item>
</a-sub-menu>
<a-sub-menu key="sub3">
<template #title>
<span>
<notification-outlined />
subnav 3
</span>
</template>
<a-menu-item key="9">option9</a-menu-item>
<a-menu-item key="10">option10</a-menu-item>
<a-menu-item key="11">option11</a-menu-item>
<a-menu-item key="12">option12</a-menu-item>
</a-sub-menu>
</a-menu>
</a-layout-sider>
<a-layout style="padding: 0 24px 24px">
<a-breadcrumb style="margin: 16px 0">
<a-breadcrumb-item>Home</a-breadcrumb-item>
<a-breadcrumb-item>List</a-breadcrumb-item>
<a-breadcrumb-item>App</a-breadcrumb-item>
</a-breadcrumb>
<a-layout-content
:style="{ background: '#fff', padding: '24px', margin: 0, minHeight: '280px' }"
>
Content
</a-layout-content>
</a-layout>
</a-layout>
</a-layout>
</template>
<script>
import { UserOutlined, LaptopOutlined, NotificationOutlined } from '@ant-design/icons-vue';
import { define***ponent, ref } from 'vue';
import TheHeaderView from "@/***ponents/the-header";
export default define***ponent({
name: "main-view",
***ponents: {
TheHeaderView,
UserOutlined,
LaptopOutlined,
NotificationOutlined,
},
setup() {
return {
selectedKeys2: ref(['1']),
collapsed: ref(false),
openKeys: ref(['sub1']),
};
},
});
</script>
<style>
#***ponents-layout-demo-top-side-2 .logo {
float: left;
width: 120px;
height: 31px;
margin: 16px 24px 16px 0;
background: rgba(255, 255, 255, 0.3);
}
.ant-row-rtl #***ponents-layout-demo-top-side-2 .logo {
float: right;
margin: 16px 0 16px 24px;
}
.site-layout-background {
background: #fff;
}
</style>
web/src/***ponents/the-header.vue
<template>
<a-layout-header class="header">
<div class="logo" />
<a-menu
v-model:selectedKeys="selectedKeys1"
theme="dark"
mode="horizontal"
:style="{ lineHeight: '64px' }"
>
<a-menu-item key="1">nav 11</a-menu-item>
<a-menu-item key="2">nav 2</a-menu-item>
<a-menu-item key="3">nav 3</a-menu-item>
</a-menu>
</a-layout-header>
</template>
<script>
import {define***ponent, ref} from 'vue';
export default define***ponent({
name: "the-header-view",
setup() {
return {
selectedKeys1: ref(['2']),
};
},
});
</script>
<!-- Add "scoped" attribute to limit CSS to this ***ponent only -->
<style scoped>
</style>
-
提取the-sider组件
web/src/views/main.vue
这里注意icons删了且the-sider组件里也没加上是因为前面main.js已经全局引用了icon
<template>
<a-layout id="***ponents-layout-demo-top-side-2">
<the-header-view></the-header-view>
<a-layout>
<the-sider-view></the-sider-view>
<a-layout style="padding: 0 24px 24px">
<a-breadcrumb style="margin: 16px 0">
<a-breadcrumb-item>Home</a-breadcrumb-item>
<a-breadcrumb-item>List</a-breadcrumb-item>
<a-breadcrumb-item>App</a-breadcrumb-item>
</a-breadcrumb>
<a-layout-content
:style="{ background: '#fff', padding: '24px', margin: 0, minHeight: '280px' }"
>
Content
</a-layout-content>
</a-layout>
</a-layout>
</a-layout>
</template>
<script>
import { define***ponent } from 'vue';
import TheHeaderView from "@/***ponents/the-header";
import TheSiderView from "@/***ponents/the-sider";
export default define***ponent({
name: "main-view",
***ponents: {
TheSiderView,
TheHeaderView,
},
setup() {
return {
};
},
});
</script>
<style>
#***ponents-layout-demo-top-side-2 .logo {
float: left;
width: 120px;
height: 31px;
margin: 16px 24px 16px 0;
background: rgba(255, 255, 255, 0.3);
}
.ant-row-rtl #***ponents-layout-demo-top-side-2 .logo {
float: right;
margin: 16px 0 16px 24px;
}
.site-layout-background {
background: #fff;
}
</style>
web/src/***ponents/the-sider.vue
<template>
<a-layout-sider width="200" style="background: #fff">
<a-menu
v-model:selectedKeys="selectedKeys2"
v-model:openKeys="openKeys"
mode="inline"
:style="{ height: '100%', borderRight: 0 }"
>
<a-sub-menu key="sub1">
<template #title>
<span>
<user-outlined />
subnav 11
</span>
</template>
<a-menu-item key="1">option1</a-menu-item>
<a-menu-item key="2">option2</a-menu-item>
<a-menu-item key="3">option3</a-menu-item>
<a-menu-item key="4">option4</a-menu-item>
</a-sub-menu>
<a-sub-menu key="sub2">
<template #title>
<span>
<laptop-outlined />
subnav 2
</span>
</template>
<a-menu-item key="5">option5</a-menu-item>
<a-menu-item key="6">option6</a-menu-item>
<a-menu-item key="7">option7</a-menu-item>
<a-menu-item key="8">option8</a-menu-item>
</a-sub-menu>
<a-sub-menu key="sub3">
<template #title>
<span>
<notification-outlined />
subnav 3
</span>
</template>
<a-menu-item key="9">option9</a-menu-item>
<a-menu-item key="10">option10</a-menu-item>
<a-menu-item key="11">option11</a-menu-item>
<a-menu-item key="12">option12</a-menu-item>
</a-sub-menu>
</a-menu>
</a-layout-sider>
</template>
<script>
import {define***ponent, ref} from 'vue';
export default define***ponent({
name: "the-sider-view",
setup() {
return {
selectedKeys2: ref(['1']),
openKeys: ref(['sub1']),
};
},
});
</script>
<!-- Add "scoped" attribute to limit CSS to this ***ponent only -->
<style scoped>
</style>
- 测试效果
十二、实现JWT单点登录功能
1.单点登录2种方式的介绍
-
redis+token
生成的token是无意义字符串,每个用户每次登录都随机生成,token作为key,用户信息作为value存储在redis中
【每次登录后端都随机生成字符串token,返给前端保存,之后请求时候header带上token,后端查redis去校验】
-
jwt
生成的token是含有用户信息的一段字符串
【每次登陆后端都由jwt工具包生成token,返给前端保存,之后请求时候header带上token,后端用工具包解密校验token】
本项目采用方式二实现单点登录
2.JWT单点登录原理与存在的问题及解决方案
JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在各方之间安全地传输信息作为 JSON 对象。JWT 可以使用 HMAC 算法或 RSA 的公钥/私钥对来签名,以验证发送者的身份以及确保消息的完整性。
JWT 通常由三部分组成:头部(Header)、载荷(Payload)和签名(Signature)。其结构如下:
eyJhbGciOiJIUzI1NiIsInR5***I6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
header.payload.signature
- 头部(Header):包含了两部分信息:令牌类型(即JWT)和所使用的签名算法。
- 载荷(Payload):包含了所要传递的信息,如用户ID、用户名等。
- 签名(Signature):由头部、载荷以及一个密钥(只有服务器知道的)共同组成,用于验证消息的完整性。
JWT 的优点之一是它的信息是经过签名的,因此接收者可以验证它是否被篡改。此外,由于信息被编码为 JSON 格式,因此它们可以轻松地在不同平台之间传递。
在实际应用中,JWT 经常用于身份验证和授权机制,特别是在 Web 应用程序中。用户登录后,服务器可以颁发一个 JWT,之后用户每次请求时都将该 JWT 发送给服务器,服务器通过验证 JWT 的签名来确认用户的身份和权限。
----------来自ChatGPT的回答
-
存在的问题
-
token被解密
解决:加盐值(密钥),每个项目的盐值不能一样
-
token被拿到第三方使用
例如 ChatGPT 国内很多人就把这个包装了一层变成自己的产品来收费别人,实际用户交费后登录进去用的都是作者自己的ChatGPT账号的token
解决:目前只能是限流来制止
-
3.使用Hutool生成JWT单点登录token
-
修改登录返回结果类,增加token字段
***.neilxu.train.member.resp.MemberLoginResp
package ***.neilxu.train.member.resp; public class MemberLoginResp { private Long id; private String mobile; private String token; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getMobile() { return mobile; } public void setMobile(String mobile) { this.mobile = mobile; } public String getToken() { return token; } public void setToken(String token) { this.token = token; } @Override public String toString() { final StringBuffer sb = new StringBuffer("MemberLoginResp{"); sb.append("id=").append(id); sb.append(", mobile='").append(mobile).append('\''); sb.append(", token='").append(token).append('\''); sb.append('}'); return sb.toString(); } }
-
修改登录service方法
***.neilxu.train.member.service.MemberService
public MemberLoginResp login(MemberLoginReq req) { String mobile = req.getMobile(); String code = req.getCode(); Member memberDB = selectByMobile(mobile); // 如果手机号不存在,则插入一条记录 if (ObjectUtil.isNull(memberDB)) { throw new BusinessException(BusinessExceptionEnum.MEMBER_MOBILE_NOT_EXIST); } // 校验短信验证码 if (!"8888".equals(code)) { throw new BusinessException(BusinessExceptionEnum.MEMBER_MOBILE_CODE_ERROR); } MemberLoginResp memberLoginResp = BeanUtil.copyProperties(memberDB, MemberLoginResp.class); Map<String, Object> map = BeanUtil.beanToMap(memberLoginResp); String key = "neilxu12306"; String token = JWTUtil.createToken(map, key.getBytes()); memberLoginResp.setToken(token); return memberLoginResp; }
-
测试
-
优化:封装JWT工具类
放在***mon模块下
***.neilxu.train.***mon.util.JwtUtil
package ***.neilxu.train.***mon.util; import ***.hutool.core.date.DateField; import ***.hutool.core.date.DateTime; import ***.hutool.json.JSONObject; import ***.hutool.jwt.JWT; import ***.hutool.jwt.JWTPayload; import ***.hutool.jwt.JWTUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.HashMap; import java.util.Map; public class JwtUtil { private static final Logger LOG = LoggerFactory.getLogger(JwtUtil.class); /** * 盐值很重要,不能泄漏,且每个项目都应该不一样,可以放到配置文件中 */ private static final String key = "neilxu12306"; public static String createToken(Long id, String mobile) { DateTime now = DateTime.now(); DateTime expTime = now.offsetNew(DateField.SECOND, 10); Map<String, Object> payload = new HashMap<>(); // 签发时间 payload.put(JWTPayload.ISSUED_AT, now); // 过期时间 payload.put(JWTPayload.EXPIRES_AT, expTime); // 生效时间 payload.put(JWTPayload.NOT_BEFORE, now); // 内容 payload.put("id", id); payload.put("mobile", mobile); String token = JWTUtil.createToken(payload, key.getBytes()); LOG.info("生成JWT token:{}", token); return token; } public static boolean validate(String token) { JWT jwt = JWTUtil.parseToken(token).setKey(key.getBytes()); // validate包含了verify boolean validate = jwt.validate(0); LOG.info("JWT token校验结果:{}", validate); return validate; } public static JSONObject getJSONObject(String token) { JWT jwt = JWTUtil.parseToken(token).setKey(key.getBytes()); JSONObject payloads = jwt.getPayloads(); payloads.remove(JWTPayload.ISSUED_AT); payloads.remove(JWTPayload.EXPIRES_AT); payloads.remove(JWTPayload.NOT_BEFORE); LOG.info("根据token获取原始内容:{}", payloads); return payloads; } public static void main(String[] args) { createToken(1L, "123"); String token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYmYiOjE2NzY4OTk4MjcsIm1vYmlsZSI6IjEyMyIsImlkIjoxLCJleHAiOjE2NzY4OTk4MzcsImlhdCI6MTY3Njg5OTgyN30.JbFfdeNHhxKhAeag63kifw9pgYhnNXISJM5bL6hM8eU"; validate(token); getJSONObject(token); } }
注意:payload里放了过期时间相关,若是过期了,token校验会不通过(但是仍然可以解密得到用户信息)
-
优化后修改service方法
***.neilxu.train.member.service.MemberService
public MemberLoginResp login(MemberLoginReq req) { String mobile = req.getMobile(); String code = req.getCode(); Member memberDB = selectByMobile(mobile); // 如果手机号不存在,则插入一条记录 if (ObjectUtil.isNull(memberDB)) { throw new BusinessException(BusinessExceptionEnum.MEMBER_MOBILE_NOT_EXIST); } // 校验短信验证码 if (!"8888".equals(code)) { throw new BusinessException(BusinessExceptionEnum.MEMBER_MOBILE_CODE_ERROR); } MemberLoginResp memberLoginResp = BeanUtil.copyProperties(memberDB, MemberLoginResp.class); String token = JwtUtil.createToken(memberLoginResp.getId(), memberLoginResp.getMobile()); memberLoginResp.setToken(token); return memberLoginResp; }
-
测试
4.使用vuex保存登录信息
-
修改/store/index.js
这里是全局变量
import { createStore } from 'vuex' export default createStore({ state: { member: {} }, getters: { }, mutations: { setMember (state, _member) { state.member = _member; } }, actions: { }, modules: { } })
-
保存登录信息
web/src/views/login.vue
import store from "@/store";
const login = () => { axios.post("/member/member/login", loginForm).then(response => { let data = response.data; if (data.su***ess) { notification.su***ess({ description: '登录成功!' }); // 登录成功,跳到控台主页 router.push("/"); // store保存登录信息 store.***mit("setMember", data.content); } else { notification.error({ description: data.message }); } }) };
-
读取信息并展示
<template> <a-layout-header class="header"> <div class="logo" /> <div style="float: right; color: white;"> 您好:{{member.mobile}} <router-link to="/login"> 退出登录 </router-link> </div> <a-menu v-model:selectedKeys="selectedKeys1" theme="dark" mode="horizontal" :style="{ lineHeight: '64px' }" > <a-menu-item key="1">nav 11</a-menu-item> <a-menu-item key="2">nav 2</a-menu-item> <a-menu-item key="3">nav 3</a-menu-item> </a-menu> </a-layout-header> </template> <script> import {define***ponent, ref} from 'vue'; import store from "@/store"; export default define***ponent({ name: "the-header-view", setup() { let member = store.state.member; return { selectedKeys1: ref(['2']), member }; }, }); </script> <!-- Add "scoped" attribute to limit CSS to this ***ponent only --> <style scoped> </style>
-
测试效果
5.vuex配合h5 session缓存,解决浏览器刷新丢失数据的问题
上面第4步有个问题:刷新浏览器,登录信息就没了
-
新增自定义js
web/public/js/session-storage.js
SessionStorage = { get: function (key) { var v = sessionStorage.getItem(key); if (v && typeof(v) !== "undefined" && v !== "undefined") { return JSON.parse(v); } }, set: function (key, data) { //JSON.stringify() 是 JavaScript 中一个用于将 JavaScript 对象或值转换为 JSON 字符串的方法。 sessionStorage.setItem(key, JSON.stringify(data)); }, remove: function (key) { sessionStorage.removeItem(key); }, clearAll: function () { sessionStorage.clear(); } };
sessionStorage和localStorage区别:
sessionStorage
和localStorage
是 HTML5 中引入的 Web 存储 API,它们都用于在客户端存储数据,但有一些重要的区别:-
作用域:
-
sessionStorage
:存储在sessionStorage
中的数据只在当前会话期间有效。当用户关闭浏览器标签或窗口时,会话结束,sessionStorage
中的数据也会被清除。 -
localStorage
:存储在localStorage
中的数据是永久性的,除非通过 JavaScript 显式删除,否则会一直保存在浏览器中,即使用户关闭了浏览器窗口或重新启动计算机。
-
-
数据共享:
-
sessionStorage
:每个页面的sessionStorage
是独立的,即使是同一个页面打开了多个标签,它们之间的sessionStorage
也是互相隔离的,无法共享数据。 -
localStorage
:所有同源(相同协议、主机和端口)页面共享相同的localStorage
,这意味着一个页面设置的localStorage
数据可以被同一域下的其他页面访问和修改。
-
-
容量限制:
-
sessionStorage
和localStorage
都有存储容量限制,但具体限制因浏览器而异。一般来说,localStorage
的容量限制要大于sessionStorage
。
-
-
存储期限:
-
sessionStorage
:存储在sessionStorage
中的数据在当前会话结束时被清除,即用户关闭浏览器标签或窗口时。 -
localStorage
:存储在localStorage
中的数据没有过期时间,除非通过 JavaScript 显式删除。
-
-
API 使用:
- 两者的 API 使用方法类似,都是通过
setItem()
,getItem()
,removeItem()
等方法来操作存储的数据。
- 两者的 API 使用方法类似,都是通过
总的来说,
sessionStorage
适合临时存储会话相关的数据,而localStorage
适合长期存储的数据,如用户首选项、本地缓存等。 -------------来自ChatGPT的回答
-
作用域:
-
引入js
web/public/index.html
<script src="<%= BASE_URL %>js/session-storage.js"></script>
-
修改store全局变量
web/src/store/index.js
import { createStore } from 'vuex' const MEMBER = "MEMBER"; export default createStore({ state: { member: window.SessionStorage.get(MEMBER) || {} }, getters: { }, mutations: { setMember (state, _member) { state.member = _member; window.SessionStorage.set(MEMBER, _member); } }, actions: { }, modules: { } })
-
测试
刷新后正常
6.演示gateway拦截器的使用
-
Test1Filter
***.neilxu.train.gateway.config.Test1Filter
package ***.neilxu.train.gateway.config; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.stereotype.***ponent; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; @***ponent public class Test1Filter implements GlobalFilter, Ordered { private static final Logger LOG = LoggerFactory.getLogger(Test1Filter.class); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { LOG.info("Test1Filter"); return chain.filter(exchange); } @Override public int getOrder() { return 0; } }
-
Test2Filter
***.neilxu.train.gateway.config.Test2Filter
package ***.neilxu.train.gateway.config; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.stereotype.***ponent; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; @***ponent public class Test2Filter implements GlobalFilter, Ordered { private static final Logger LOG = LoggerFactory.getLogger(Test2Filter.class); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { LOG.info("Test2Filter"); return chain.filter(exchange); } @Override public int getOrder() { return 1; } }
-
重启测试
7.为gateway增加登录校验拦截器
自动刷新maven依赖:
-
增加依赖
gateway的 pom文件
<dependency> <groupId>***.hutool</groupId> <artifactId>hutool-all</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency>
-
增加拦截器
package ***.neilxu.train.gateway.config; import ***.neilxu.train.gateway.util.JwtUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.http.HttpStatus; import org.springframework.stereotype.***ponent; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; @***ponent public class LoginMemberFilter implements Ordered, GlobalFilter { private static final Logger LOG = LoggerFactory.getLogger(LoginMemberFilter.class); @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path = exchange.getRequest().getURI().getPath(); // 排除不需要拦截的请求 if (path.contains("/admin") || path.contains("/hello") || path.contains("/member/member/login") || path.contains("/member/member/send-code")) { LOG.info("不需要登录验证:{}", path); return chain.filter(exchange); } else { LOG.info("需要登录验证:{}", path); } // 获取header的token参数 String token = exchange.getRequest().getHeaders().getFirst("token"); LOG.info("会员登录验证开始,token:{}", token); if (token == null || token.isEmpty()) { LOG.info( "token为空,请求被拦截" ); exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().set***plete(); } // 校验token是否有效,包括token是否被改过,是否过期 boolean validate = JwtUtil.validate(token); if (validate) { LOG.info("token有效,放行该请求"); return chain.filter(exchange); } else { LOG.warn( "token无效,请求被拦截" ); exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().set***plete(); } } /** * 优先级设置 值越小 优先级越高 * * @return */ @Override public int getOrder() { return 0; } }
这里将JwtUtil从***mon复制了一个到gateway,不再引入***mon依赖
小tips:
ctrl + alt + t :可以给选中的代码块加try catch
-
测试
GET http://localhost:8000/member/member/count A***ept: application/json token: 123 ###
8.为axios增加登录相关拦截:请求时带上token,返回时校验返回码是不是401
-
修改main.js
web/src/main.js
/** * axios拦截器 */ axios.interceptors.request.use(function (config) { console.log('请求参数:', config); const _token = store.state.member.token; if (_token) { config.headers.token = _token; console.log("请求headers增加token:", _token); } return config; }, error => { return Promise.reject(error); }); axios.interceptors.response.use(function (response) { console.log('返回结果:', response); return response; }, error => { console.log('返回错误:', error); const response = error.response; const status = response.status; if (status === 401) { // 判断状态码是401 跳转到登录页 +console.log("未登录或登录超时,跳到登录页"); store.***mit("setMember", {}); notification.error({description: "未登录或登录超时"}); router.push('/login'); } return Promise.reject(error); });
-
修改控台页面
<template> <a-layout id="***ponents-layout-demo-top-side-2"> <the-header-view></the-header-view> <a-layout> <the-sider-view></the-sider-view> <a-layout style="padding: 0 24px 24px"> <a-breadcrumb style="margin: 16px 0"> <a-breadcrumb-item>Home</a-breadcrumb-item> <a-breadcrumb-item>List</a-breadcrumb-item> <a-breadcrumb-item>App</a-breadcrumb-item> </a-breadcrumb> <a-layout-content :style="{ background: '#fff', padding: '24px', margin: 0, minHeight: '280px' }" > 所有会员总数:{{ count }} </a-layout-content> </a-layout> </a-layout> </a-layout> </template> <script> import {define***ponent, ref} from 'vue'; import TheHeaderView from "@/***ponents/the-header"; import TheSiderView from "@/***ponents/the-sider"; import axios from "axios"; import {notification} from "ant-design-vue"; import store from "@/store"; export default define***ponent({ name: "main-view", ***ponents: { TheSiderView, TheHeaderView, }, setup() { const count = ref(0); axios.get("/member/member/count").then((response) => { let data = response.data; if (data.su***ess) { count.value = data.content; } else { notification.error({description: data.message}); } }); return { count }; }, }); </script> <style> #***ponents-layout-demo-top-side-2 .logo { float: left; width: 120px; height: 31px; margin: 16px 24px 16px 0; background: rgba(255, 255, 255, 0.3); } .ant-row-rtl #***ponents-layout-demo-top-side-2 .logo { float: right; margin: 16px 0 16px 24px; } .site-layout-background { background: #fff; } </style>
-
测试
注意:
可能由于axios版本问题,这里会出现页面直接把401报错展示到顶层,如图所示
解决办法:
查看web前端目录下vue.config.js配置文件,如配置文件入下:
const { defineConfig } = require('@vue/cli-service') module.exports = defineConfig({ transpileDependencies: true })
则增加如下配置即可关闭问题中所示的错误提示界面:
const { defineConfig } = require('@vue/cli-service') module.exports = defineConfig({ transpileDependencies: true, // 其他配置项 devServer:{ client:{ overlay: false } } })
9.为路由页面增加登录拦截,访问所有的控台页面都需要登录
-
修改路由js
import { createRouter, createWebHistory } from 'vue-router' import store from "@/store"; import {notification} from "ant-design-vue"; const routes = [ { path: '/login', ***ponent: () => import('../views/login.vue') }, { path: '/', ***ponent: () => import('../views/main.vue'), meta: { loginRequire: true }, } ] const router = createRouter({ history: createWebHistory(process.env.BASE_URL), routes }) // 路由登录拦截 router.beforeEach((to, from, next) => { // 要不要对meta.loginRequire属性做监控拦截 if (to.matched.some(function (item) { console.log(item, "是否需要登录校验:", item.meta.loginRequire || false); return item.meta.loginRequire })) { const _member = store.state.member; console.log("页面登录校验开始:", _member); if (!_member.token) { console.log("用户未登录或登录超时!"); notification.error({ description: "未登录或登录超时" }); next('/login'); } else { next(); } } else { next(); } }); export default router
-
测试
直接访问控台页面——“/"