前言

  Fast Scaffold是一套极简的前后端分离项目脚手架,包含一个portal前端、一个admin后端,可用于快速的搭建前后端分离项目进行二次开发

  技术栈

  portal前端:vue + element-ui + avue,使用typescript语法编码

  admin后端:springboot + mybatis-plus + mysql,采用jwt进行身份认证

  项目结构

Vue项目入门实例-LMLPHP

Vue项目入门实例-LMLPHP  Vue项目入门实例-LMLPHP

  portal前端

  前端项目,使用的是我们:Vue项目入门实例,在此基础上做了一下跳转

  引入avue

  avue,基于element-ui开发的一个很多骚操作的前端框架,我们也在test测试模块中的Admin页面中进行了简单测试

  官网:https://avuejs.com/

  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: {
  }
})

  工具类封装

Vue项目入门实例-LMLPHP

  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

Vue项目入门实例-LMLPHP

Vue项目入门实例-LMLPHP

  首页

  极简的项目首页,路径/,一般作为项目主页,现在页面就是一个简单的欢迎页面,包括了几个router-link路由以及登出按钮

Vue项目入门实例-LMLPHP  

  test测试

  集成了vue数据绑定等简单测试

Vue项目入门实例-LMLPHP

  info测试

  获取当前活跃配置环境分支,读取配置文件信息等简单测试

Vue项目入门实例-LMLPHP

  admin测试

  element-ui配合上avue,可以快速搭建admin后台管理页面以及功能

Vue项目入门实例-LMLPHP

  打包部署

  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文件夹,生成的文件就在里面

Vue项目入门实例-LMLPHP

  把生成的文件放到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包就在里面

Vue项目入门实例-LMLPHP

  使用java命令:java -jar admin.jar,运行jar包,后端admin项目完成部署

  后记

  一套极简的前后端分离项目脚手架就暂时记录到这,后续再进行补充 

  代码开源

   注:admin后端数据库文件在admin后端项目的resources/sql目录下面

  代码已经开源、托管到我的GitHub、码云:

  GitHub:https://github.com/huanzi-qch/fast-scaffold

  码云:https://gitee.com/huanzi-qch/fast-scaffold

11-07 06:14