前言
Fast Scaffold是一套极简的前后端分离项目脚手架,包含一个portal前端、一个admin后端,可用于快速的搭建前后端分离项目进行二次开发
技术栈
portal前端:vue + element-ui + avue,使用typescript语法编码
admin后端:springboot + mybatis-plus + mysql,采用jwt进行身份认证
项目结构
portal前端
前端项目,使用的是我们:Vue项目入门实例,在此基础上做了一下跳转
引入avue
avue,基于element-ui开发的一个很多骚操作的前端框架,我们也在test测试模块中的Admin页面中进行了简单测试
router配置
router路由配置,新增test模块菜单路由,beforeEach中判断无令牌,跳转登录页面
router.beforeEach(async(to, from, next) => { console.log("跳转开始,目标:"+to.path); document.title = `${to.meta.title}`; //无令牌,跳转登录页面 if (to.name !== 'Login' && !TokenUtil.getToken()){ console.log("无令牌,跳转登录页面"); next({ name: 'Login' }); } //跳转页面 next(); });
store配置
store配置,新增user属性,getters提供getUser方法,以及mutations、actions的setUser方法
import Vue from 'vue' import Vuex from 'vuex' import User from "@/vo/user"; import CommonUtil from "@/utils/commonUtil"; import {Object} from "@/utils/commonUtil" import AxiosUtil from "@/utils/axiosUtil"; import TokenUtil from "@/utils/tokenUtil"; import SessionStorageUtil from "@/utils/sessionStorageUtil"; Vue.use(Vuex); /* 约定,组件不允许直接变更属于 store 实例的 state,而应执行 action 来分发 (dispatch) 事件通知 store 去改变 */ export default new Vuex.Store({ state: { user:User, }, getters:{ getUser: state => { return state.user; } }, mutations: { SET_USER: (state, user) => { state.user = user; } }, actions: { async setUser({commit}){ let thid = this; console.log("调用getUserByToken接口获取登录用户!"); AxiosUtil.post(CommonUtil.getAdminUrl()+"/getUserByToken",{token:TokenUtil.getToken()},function (result) { let data = result.data as Object; commit('SET_USER', new User(data.id,data.username)); //设置到sessionStorage SessionStorageUtil.setItem("loginUser",thid.getters.getUser); }); } }, modules: { } })
工具类封装
axiosUtil.ts
设置全局withCredentials,timeout
设置request拦截,在请求头中设置token令牌
设置response拦截,设置了统一响应异常消息提示以及令牌无效时跳转登录页面
封装了post、get等静态方法,方便调用
commonUtil.ts
封装了一下常用、通用方法,比如获取后端服务地址、获取登录用户等
sessionStorageUtil.ts
封装sessionStorage会话级缓存,方便设置缓存
tokenUtil.ts
封装token令牌工具类,方便设置token令牌到cookie
admin后端
后端项目,使用的是我们的:SpringBoot系列——MyBatis-Plus整合封装,在此基础上进行了调整
只保留tb_user表模块,其他表以及代码模块都不需要,密码改成MD5加密存储
配置文件
server: port: 10086 spring: application: name: admin datasource: #数据库相关 url: jdbc:mysql://localhost:3306/test?serverTimezone=GMT%2B8&characterEncoding=utf-8 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mvc: format: date: yyyy-MM-dd HH:mm:ss jackson: date-format: yyyy-MM-dd HH:mm:ss #jackson对响应回去的日期参数进行格式化 time-zone: GMT+8 portal: url: http://172.16.35.52:10010 #前端地址(用于跨域配置) token: secret: huanzi-qch #token加密私钥(很重要,注意保密) expire: time: 86400000 #token有效时长,单位毫秒 24*60*60*1000
cors安全跨域
创建MyConfiguration,开启cors安全跨域,详情可看回我们之前的博客:SpringBoot系列——CORS(跨源资源共享)
@Configuration public class MyConfiguration { @Value("${portal.url}") private String portalUrl; @Bean public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins(portalUrl) .allowedMethods("*") .allowedHeaders("*") .allowCredentials(true).maxAge(3600); } }; } }
jwt身份认证
maven引入jwt依赖
<!-- JWT --> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.5.0</version> </dependency>
JwtUtil工具类,封装生成token,校验token,以及根据token获取登录用户
/** * JWT工具类 */ @Component public class JwtUtil { /** * 过期时间,毫秒 */ private static long TOKEN_EXPIRE_TIME; @Value("${token.expire.time}") public void setExpireTime(long expireTime) { JwtUtil.TOKEN_EXPIRE_TIME = expireTime; } /** * token私钥 */ private static String TOKEN_SECRET; @Value("${token.secret}") public void setSecret(String secret) { JwtUtil.TOKEN_SECRET = secret; } /** * 生成签名 */ public static String sign(String userId){ //过期时间 Date date = new Date(System.currentTimeMillis() + TOKEN_EXPIRE_TIME); //私钥及加密算法 Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET); //设置头信息 HashMap<String, Object> header = new HashMap<>(2); header.put("typ", "JWT"); header.put("alg", "HS256"); //附带userID生成签名 return JWT.create().withHeader(header).withClaim("userId",userId).withExpiresAt(date).sign(algorithm); } /** * 验证签名 */ public static boolean verity(String token){ //令牌为空 if(StringUtils.isEmpty(token)){ return false; } try { Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET); JWTVerifier verifier = JWT.require(algorithm).build(); //是否能解密 DecodedJWT jwt = verifier.verify(token); //校验过期时间 if(new Date().after(jwt.getExpiresAt())){ return false; } return true; } catch (IllegalArgumentException | JWTVerificationException e) { ErrorUtil.errorInfoToString(e); } return false; } /** * 根据token获取用户id */ public static String getUserIdByToken(String token){ try { Algorithm algorithm = Algorithm.HMAC256(TOKEN_SECRET); JWTVerifier verifier = JWT.require(algorithm).build(); DecodedJWT jwt = verifier.verify(token); return jwt.getClaim("userId").asString(); } catch (IllegalArgumentException | JWTVerificationException e) { ErrorUtil.errorInfoToString(e); } return null; } }
登录拦截器
LoginFilter登录拦截器,不拦截登录请求、跨域预检请求,其他请求全部拦截校验是否有令牌
PS:我们已经配置了全局安全跨域,但在拦截器中,PrintWriter.print回去的response,要手动添加一下响应头标记允许对方跨域
//标记当前请求对方允许跨域访问 response.setHeader("Access-Control-Allow-Credentials","true"); response.setHeader("Access-Control-Allow-Headers","content-type, token"); response.setHeader("Access-Control-Allow-Methods","*"); response.setHeader("Access-Control-Allow-Origin",portalUrl);
/** * 登录拦截器 */ @Component public class LoginFilter implements Filter { @Value("${server.servlet.context-path:}") private String contextPath; @Value("${portal.url}") private String portalUrl; @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) servletRequest; HttpServletResponse response = (HttpServletResponse) servletResponse; String method = request.getMethod(); //不拦截登录请求、跨域预检请求,其他请求全部拦截校验是否有令牌 if (!"/login".equals(request.getRequestURI().replaceFirst(contextPath,"")) && !"options".equals(method.toLowerCase())) { String token = request.getHeader("token"); //验证签名 if(!JwtUtil.verity(token)){ String dataString = "{"status":401,"message":"无效token令牌,访问失败,请重新登录系统!"}"; //清除cookie Cookie cookie = new Cookie("PORTAL_TOKEN", null); cookie.setPath("/"); cookie.setMaxAge(0); response.addCookie(cookie); //转json字符串并转成Object对象,设置到Result中并赋值给返回值,记得表明当前页面可以跨域访问 response.setHeader("Access-Control-Allow-Credentials","true"); response.setHeader("Access-Control-Allow-Headers","content-type, token"); response.setHeader("Access-Control-Allow-Methods","*"); response.setHeader("Access-Control-Allow-Origin",portalUrl); response.setCharacterEncoding("UTF-8"); response.setContentType("application/json; charset=utf-8"); PrintWriter out = response.getWriter(); out.print(dataString); out.flush(); out.close(); return; } } filterChain.doFilter(servletRequest, servletResponse); } }
简单控制器
IndexController控制器,提供三个post方法:login登录,logout登出,getUserByToken通过token令牌获取登录用户
@RestController @RequestMapping("/") @Slf4j public class IndexController { @Autowired private TbUserService tbUserService; /** * 登录 */ @PostMapping("login") public Result<String> login(@RequestBody TbUserVo entityVo){ //只关注用户名、密码 if(StringUtils.isEmpty(entityVo.getUsername()) || StringUtils.isEmpty(entityVo.getPassword())){ return Result.build(400,"账号或密码不能为空......",""); } TbUserVo tbUserVo = new TbUserVo(); tbUserVo.setUsername(entityVo.getUsername()); //密码MD5加密后密文存储,匹配时先MD5加密后匹配 tbUserVo.setPassword(MD5Util.getMD5(entityVo.getPassword())); Result<List<TbUserVo>> listResult = tbUserService.list(tbUserVo); if(Result.OK.equals(listResult.getStatus()) && listResult.getData().size() > 0){ TbUserVo userVo = listResult.getData().get(0); //token String token = JwtUtil.sign(userVo.getId()+""); return Result.build(Result.OK,"登录成功!",token); } return Result.build(400,"账号或密码错误...",""); } /** * 登出 */ @PostMapping("logout") public Result<String> logout(HttpServletResponse response){ //清除cookie Cookie cookie = new Cookie("PORTAL_TOKEN", null); cookie.setPath("/"); cookie.setMaxAge(0); response.addCookie(cookie); return Result.build(Result.OK,"此路是我开,此树是我栽,要从此路过,留下token令牌!",""); } /** * 通过token令牌获取登录用户 */ @PostMapping("getUserByToken") public Result<TbUserVo> getUserByToken(@RequestBody TbUserVo entityVo){ String userId = JwtUtil.getUserIdByToken(entityVo.getToken()); Result<TbUserVo> result = tbUserService.get(userId); result.getData().setPassword(null); return userId == null ? Result.build(500,"操作失败!",new TbUserVo()) : result; } }
效果演示
登录
这是一个极简登录页面、登录功能,没用令牌,路由会拦截跳到登录页面
登录成功后保存token令牌到cookie中,并获取登录用户信息,保存到Store中
为了解决刷新页面Store数据丢失,同时要保存一份数据到sessionStorage缓存,在读取Store无数据时,先读取缓存,如果存在,再设置回Store中
登出成功后置空Store、sessionStorage
首页
极简的项目首页,路径/,一般作为项目主页,现在页面就是一个简单的欢迎页面,包括了几个router-link路由以及登出按钮
test测试
集成了vue数据绑定等简单测试
info测试
获取当前活跃配置环境分支,读取配置文件信息等简单测试
admin测试
element-ui配合上avue,可以快速搭建admin后台管理页面以及功能
打包部署
portal前端
已经配置好了package.json文件
"scripts": { "dev": "vue-cli-service serve --mode dev", "test": "vue-cli-service test --mode test", "build": "vue-cli-service build --mode prod" },
同时,vue.config.js中配置了生成路径
publicPath: './', outputDir: 'dist', assetsDir: 'static',
执行build命令,就会在package.json的同级目录下面,创建dist文件夹,生成的文件就在里面
把生成的文件放到Tomcat容器或者其他容器中,运行容器,前端portal项目完成部署
admin后端
pom文件已经设置了打包配置
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <finalName>${project.artifactId}</finalName> <outputDirectory>package</outputDirectory> </configuration> </plugin>
maven直接执行package命令,就会在与pom文件同级目录下面创建package文件夹,生成的jar包就在里面
使用java命令:java -jar admin.jar,运行jar包,后端admin项目完成部署
后记
一套极简的前后端分离项目脚手架就暂时记录到这,后续再进行补充
代码开源
注:admin后端数据库文件在admin后端项目的resources/sql目录下面
代码已经开源、托管到我的GitHub、码云: