前后端分离项目总结——博客系统
文章目录
一、后端开发
1、新建Springboot项目
- 导入基础jar包
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
- devtools:项目的热加载重启插件
- lombok:简化代码的工具
2、整合mybatis plus
- 导入相关jar包
pom中导入mybatis plus的jar包,因为后面会涉及到代码生成,所以我们还需要导入页面模板引擎,这里我们用的是freemarker。
<!--mp-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.2.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!--mp代码生成器-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.2.0</version>
</dependency>
- 写配置文件
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/myblog?serverTimezone=UTC
username: root
password: mysql123456
mybatis-plus:
mapper-locations: classpath*:/mapper/**Mapper.xml
- 开启mapper接口扫描,添加分页插件
通过@mapperScan注解指定要变成实现类的接口所在的包,然后包下面的所有接口在编译之后都会生成相应的实现类。PaginationInterceptor是一个分页插件。
package com.jsu.config;
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;
/**
*
*/
@Configuration
@EnableTransactionManagement
@MapperScan("com.jsu.mapper")
public class MybatisPlusConfig {
@Bean
public PaginationInterceptor paginationInterceptor(){
PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
return paginationInterceptor;
}
}
- 代码生成
官方给我们提供了一个代码生成器,然后我写上自己的参数之后,就可以直接根据数据库表信息生成entity、service、mapper等接口和实现类。
package com.jsu;
import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException;
import com.baomidou.mybatisplus.core.toolkit.StringPool;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.generator.AutoGenerator;
import com.baomidou.mybatisplus.generator.InjectionConfig;
import com.baomidou.mybatisplus.generator.config.*;
import com.baomidou.mybatisplus.generator.config.po.TableInfo;
import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
// 演示例子,执行 main 方法控制台输入模块表名回车自动生成对应项目目录中
public class CodeGenerator {
/**
* <p>
* 读取控制台内容
* </p>
*/
public static String scanner(String tip) {
Scanner scanner = new Scanner(System.in);
StringBuilder help = new StringBuilder();
help.append("请输入" + tip + ":");
System.out.println(help.toString());
if (scanner.hasNext()) {
String ipt = scanner.next();
if (StringUtils.isNotEmpty(ipt)) {
return ipt;
}
}
throw new MybatisPlusException("请输入正确的" + tip + "!");
}
public static void main(String[] args) {
// 代码生成器
AutoGenerator mpg = new AutoGenerator();
// 全局配置
GlobalConfig gc = new GlobalConfig();
String projectPath = System.getProperty("user.dir");
gc.setOutputDir(projectPath + "/src/main/java");
// gc.setOutputDir("D:\\test");
gc.setAuthor("zjx");
gc.setOpen(false);
// gc.setSwagger2(true); 实体属性 Swagger2 注解
gc.setServiceName("%sService");
mpg.setGlobalConfig(gc);
// 数据源配置
DataSourceConfig dsc = new DataSourceConfig();
dsc.setUrl("jdbc:mysql://localhost:3306/myblog?serverTimezone=UTC");
// dsc.setSchemaName("public");
dsc.setDriverName("com.mysql.cj.jdbc.Driver");
dsc.setUsername("root");
dsc.setPassword("mysql123456");
mpg.setDataSource(dsc);
// 包配置
PackageConfig pc = new PackageConfig();
pc.setModuleName(null);
pc.setParent("com.jsu");
mpg.setPackageInfo(pc);
// 自定义配置
InjectionConfig cfg = new InjectionConfig() {
@Override
public void initMap() {
// to do nothing
}
};
// 如果模板引擎是 freemarker
String templatePath = "/templates/mapper.xml.ftl";
// 如果模板引擎是 velocity
// String templatePath = "/templates/mapper.xml.vm";
// 自定义输出配置
List<FileOutConfig> focList = new ArrayList<>();
// 自定义配置会被优先输出
focList.add(new FileOutConfig(templatePath) {
@Override
public String outputFile(TableInfo tableInfo) {
// 自定义输出文件名 , 如果你 Entity 设置了前后缀、此处注意 xml 的名称会跟着发生变化!!
return projectPath + "/src/main/resources/mapper/"
+ "/" + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML;
}
});
cfg.setFileOutConfigList(focList);
mpg.setCfg(cfg);
// 配置模板
TemplateConfig templateConfig = new TemplateConfig();
templateConfig.setXml(null);
mpg.setTemplate(templateConfig);
// 策略配置
StrategyConfig strategy = new StrategyConfig();
strategy.setNaming(NamingStrategy.underline_to_camel);
strategy.setColumnNaming(NamingStrategy.underline_to_camel);
strategy.setEntityLombokModel(true);
strategy.setRestControllerStyle(true);
strategy.setInclude(scanner("表名,多个英文逗号分割").split(","));
strategy.setControllerMappingHyphenStyle(true);
strategy.setTablePrefix("m_");
mpg.setStrategy(strategy);
mpg.setTemplateEngine(new FreemarkerTemplateEngine());
mpg.execute();
}
}
- 测试
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
UserService userService;
@GetMapping("/{id}")
public Object test(@PathVariable("id") Long id) {
return userService.getById(id);
}
}
3、统一结果封装
定义一个结果类,包含三个属性:状态码、结果描述、数据。
package com.jsu.common.lang;
import lombok.Data;
/**
* 统一结果封装
*/
@Data
public class Result {
private int code; // 状态码
private String msg; // 结果描述
private Object data; // 数据
public static Result success(Object data){
return success(200,"操作成功",data);
}
public static Result success(int code,String msg,Object data){
Result result = new Result();
result.setCode(code);
result.setMsg(msg);
result.setData(data);
return result;
}
public static Result fail(String msg){
return fail(400,msg,null);
}
public static Result fail(String msg,Object data){
return fail(400,msg,data);
}
public static Result fail(int code,String msg,Object data){
Result result = new Result();
result.setCode(code);
result.setMsg(msg);
result.setData(data);
return result;
}
}
4、整合shiro+jwt
一般会采用token或者jwt作为跨域身份验证解决方案。所以整合shiro的过程中,我们需要引入jwt的身份验证过程。
- 导入jar包
导入shiro-redis的starter包,还有jwt的工具包,为了简化开发,还引入hutool工具包。
<dependency>
<groupId>org.crazycake</groupId>
<artifactId>shiro-redis-spring-boot-starter</artifactId>
<version>3.2.1</version>
</dependency>
<!-- hutool工具类-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.3.3</version>
</dependency>
<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
- 编写配置
在com.jsu.config包下编写ShiroConfig配置类
该配置类主要完成以下事情:
- 引入RedisSessionDao和RedisCacheManager,为了解决shiro的权限数据和会话信息能保存到redis中,实现会话共享;
- 重写了SessionManager和DefaultWebSecurityManager,同时在DefaultWebSecurityManager中为了关闭shiro自带的session方式,我们需要配置为false,这样用户就不再能通过session方式登录shiro。后面将采用jwt凭证登录;
- 在ShiroFilterChainDefinition中,我们不再通过编码形式拦截Controller访问路径,而是所有的路由都需要经过JwtFilter这个过滤器,然后判断请求头中是否含有jwt的信息,有就登录,没有就跳过。跳过之后,有Controller中的shiro注解再次拦截,比如@RequiresAuthentication,这样控制权限访问。
package com.jsu.config;
import com.jsu.shiro.AccountRealm;
import com.jsu.shiro.JwtFilter;
import org.apache.shiro.mgt.SessionsSecurityManager;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.spring.web.config.DefaultShiroFilterChainDefinition;
import org.apache.shiro.spring.web.config.ShiroFilterChainDefinition;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.crazycake.shiro.RedisCacheManager;
import org.crazycake.shiro.RedisSessionDAO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Configuration
public class ShiroConfig {
@Autowired
JwtFilter jwtFilter;
@Bean
public SessionManager sessionManager(RedisSessionDAO redisSessionDAO) {
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
// inject redisSessionDAO
sessionManager.setSessionDAO(redisSessionDAO);
// other stuff...
return sessionManager;
}
@Bean
public SessionsSecurityManager securityManager(AccountRealm realm, SessionManager sessionManager, RedisCacheManager redisCacheManager) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(realm);
//inject sessionManager
securityManager.setSessionManager(sessionManager);
// inject redisCacheManager
securityManager.setCacheManager(redisCacheManager);
// other stuff...
return securityManager;
}
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition(){
DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
LinkedHashMap<String, String> filterMap = new LinkedHashMap<>();
filterMap.put("/**","jwt");
chainDefinition.addPathDefinitions(filterMap);
return chainDefinition;
}
@Bean("shiroFilterFactoryBean")
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager,ShiroFilterChainDefinition shiroFilterChainDefinition){
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);
Map<String, Filter> filters=new HashMap<>();
filters.put("jwt",jwtFilter);
shiroFilter.setFilters(filters);
Map<String, String> filterChainMap = shiroFilterChainDefinition.getFilterChainMap();
shiroFilter.setFilterChainDefinitionMap(filterChainMap);
return shiroFilter;
}
}
AccountRealm
AccountRealm是shiro进行登录或者权限校验的逻辑所在,需要重写以下3个方法:
- supports:为了让realm支持jwt的凭证校验
- doGetAuthorizationInfo:权限校验
- doGetAuthenticationInfo:登录认证校验
package com.jsu.shiro;
import cn.hutool.core.bean.BeanUtil;
import com.jsu.entity.User;
import com.jsu.service.UserService;
import com.jsu.util.JwtUtils;
import jdk.nashorn.internal.parser.Token;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.concurrent.locks.Lock;
@Component
public class AccountRealm extends AuthorizingRealm {
@Autowired
JwtUtils jwtUtils;
@Autowired
UserService userService;
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
return null;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
JwtToken jwtToken = (JwtToken) authenticationToken;
String userId = jwtUtils.getClaimByToken((String) jwtToken.getPrincipal()).getSubject();
User user = userService.getById(Long.parseLong(userId));
if (user==null) {
throw new UnknownAccountException("账户不存在");
}
if (user.getStatus() == -1) {
throw new LockedAccountException("账户已被锁定");
}
AccountProfile accountProfile = new AccountProfile();
BeanUtil.copyProperties(user,accountProfile);
return new SimpleAuthenticationInfo(accountProfile,jwtToken.getCredentials(),getName());
}
}
其实主要就是doGetAuthenticationInfo登录认证这个方法,可以看到我们通过jwt获取到用户信息,判断用户的状态,最后异常就抛出对应的异常信息,否者封装成SimpleAuthenticationInfo返回给shiro。
shiro默认supports的是UsernamePasswordToken,而我们现在采用了jwt的方式,所以这里我们自定义一个JwtToken,来完成shiro的supports方法。
在com.jsu.shiro包下编写JwtToken类:
package com.jsu.shiro;
import org.apache.shiro.authc.AuthenticationToken;
public class JwtToken implements AuthenticationToken {
private String token;
public JwtToken(String jwt){
this.token=jwt;
}
@Override
public Object getPrincipal() {
return this.token;
}
@Override
public Object getCredentials() {
return this.token;
}
}
JwtUtils是个生成和校验jwt的工具类,其中有些jwt相关的密钥信息是从项目配置文件中配置的:
package com.jsu.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.Date;
/**
* jwt工具类
*/
@Slf4j
@Data
@Component
@ConfigurationProperties(prefix = "jsu.jwt")
public class JwtUtils {
private String secret;
private long expire;
private String header;
/**
* 生成jwt token
*/
public String generateToken(long userId) {
Date nowDate = new Date();
//过期时间
Date expireDate = new Date(nowDate.getTime() + expire * 1000);
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setSubject(userId+"")
.setIssuedAt(nowDate)
.setExpiration(expireDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public Claims getClaimByToken(String token) {
try {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}catch (Exception e){
log.debug("validate is token error ", e);
return null;
}
}
/**
* token是否过期
* @return true:过期
*/
public boolean isTokenExpired(Date expiration) {
return expiration.before(new Date());
}
}
在AccountRealm我们还用到了AccountProfile,这是为了登录成功之后返回的一个用户信息的载体:
package com.jsu.shiro;
import lombok.Data;
import java.io.Serializable;
@Data
public class AccountProfile implements Serializable {
private Long id;
private String username;
private String avatar;
private String email;
}
- 配置参数
shiro-redis:
enable: true
redis-manager:
host: 127.0.0.1:6379
jsu:
jwt:
secret: f4e2e52034348f86b67cde581c0f9eb5
expire: 604800
header: Authorization
- 添加配置文件
如果项目有使用spring-boot-devtools,需要添加一个配置文件,在resources目录下新建文件夹META-INF,然后新建文件spring-devtools.properties,这样热重启时候才不会报错。
restart.include.shiro-redis=/shiro-[\\w-\\.]+jar
- 编写JwtFilter类
这个过滤器是我们的重点,这里我们继承的是Shiro内置的AuthenticatingFilter,一个可以内置了可以自动登录方法的的过滤器,有些同学继承BasicHttpAuthenticationFilter也是可以的。
需要重写以下几个方法:
- createToken:实现登录,我们需要生成我们自定义支持的JwtToken;
- onAccessDenied:拦截校验,当头部没有Authorization时候,我们直接通过,不需要自动登录;当带有的时候,首先我们校验jwt的有效性,没问题我们就直接执行executeLogin方法实现自动登录;
- onLoginFailure:登录异常时候进入的方法,我们直接把异常信息封装然后抛出;
- preHandle:拦截器的前置拦截,因为我们是前后端分析项目,项目中除了需要跨域全局配置之外,我们再拦截器中也需要提供跨域支持。这样,拦截器才不会在进入Controller之前就被限制了。
package com.jsu.shiro;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.jsu.common.lang.Result;
import com.jsu.util.JwtUtils;
import com.sun.xml.internal.ws.resources.HttpserverMessages;
import io.jsonwebtoken.Claims;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.ExpiredCredentialsException;
import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.RequestMethod;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtFilter extends AuthenticatingFilter {
@Autowired
JwtUtils jwtUtils;
@Override
protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
String jwt = request.getHeader("Authorization");
if (StringUtils.isEmpty(jwt)) {
return null;
}
return new JwtToken(jwt);
}
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
String jwt = request.getHeader("Authorization");
if (StringUtils.isEmpty(jwt)) {
return true;
}else{
//校验jwt
Claims claim = jwtUtils.getClaimByToken(jwt);
if (claim == null || jwtUtils.isTokenExpired(claim.getExpiration())) {
throw new ExpiredCredentialsException("token已失效,请重新登录");
}
return executeLogin(servletRequest,servletResponse);
}
}
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
Throwable throwable = e.getCause() == null ? e : e.getCause();
Result result = Result.fail(throwable.getMessage());
String json = JSONUtil.toJsonStr(result);
try {
httpServletResponse.getWriter().print(json);
} catch (IOException ex) {
}
return false;
}
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(org.springframework.http.HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
}
5、全局异常处理
定义GlobalExceptionHandler类:
package com.jsu.common.exception;
import com.jsu.common.lang.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.ShiroException;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 全局异常捕获类
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(value=ShiroException.class)
public Result handler(ShiroException e){
log.error("运行时异常:---------------{}",e);
return Result.fail(401,e.getMessage(),null);
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public Result handler(MethodArgumentNotValidException e){
log.error("实体校验异常:---------------{}",e);
BindingResult bindingResult = e.getBindingResult();
ObjectError objectError = bindingResult.getAllErrors().stream().findFirst().get();
return Result.fail(objectError.getDefaultMessage());
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(value=IllegalArgumentException.class)
public Result handler(IllegalArgumentException e){
log.error("Assert异常:---------------{}",e);
return Result.fail(e.getMessage());
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(value=RuntimeException.class)
public Result handler(RuntimeException e){
log.error("运行时异常:---------------{}",e);
return Result.fail(e.getMessage());
}
}
6、实体校验
用户类:
package com.jsu.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import java.time.LocalDateTime;
import java.io.Serializable;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
/**
* <p>
*
* </p>
*
* @author zjx
* @since 2021-07-15
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("m_user")
public class User implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@NotBlank(message = "用户名不能为空")
private String username;
private String avatar;
@Email(message = "邮箱格式不正确")
private String email;
@NotBlank(message = "密码不能为空")
private String password;
private Integer status;
private LocalDateTime created;
private LocalDateTime lastLogin;
}
7、跨域问题
后台进行全局跨域处理:
package com.jsu.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 解决跨域问题
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
.allowCredentials(true)
.maxAge(3600)
.allowedHeaders("*");
}
}
8、接口开发
- 登录接口开发
package com.jsu.controller;
import cn.hutool.core.map.MapUtil;
import cn.hutool.crypto.SecureUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.jsu.common.dto.LoginDto;
import com.jsu.common.lang.Result;
import com.jsu.entity.User;
import com.jsu.service.UserService;
import com.jsu.util.JwtUtils;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.Assert;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.security.Security;
/**
* 登录控制器
*/
@RestController
public class LoginController {
@Autowired
UserService userService;
@Autowired
JwtUtils jwtUtils;
@PostMapping("/login")
public Result login(@Validated @RequestBody LoginDto loginDto, HttpServletResponse response) {
User user = userService.getOne(new QueryWrapper<User>().eq("username", loginDto.getUsername()));
Assert.notNull(user, "用户不存在");
if (!user.getPassword().equals(SecureUtil.md5(loginDto.getPassword()))) {
return Result.fail("密码不正确");
}
String jwt = jwtUtils.generateToken(user.getId());
System.out.println("密钥:"+jwt);
response.setHeader("Authorization", jwt);
response.setHeader("Access-control-Expose-Headers", "Authorization");
return Result.success(MapUtil.builder()
.put("id", user.getId())
.put("username", user.getUsername())
.put("avatar", user.getAvatar())
.put("email", user.getEmail())
.map());
}
@RequiresAuthentication
@GetMapping("/logout")
public Result logout(){
SecurityUtils.getSubject().logout();
return Result.success(null);
}
}
- 博客接口开发
package com.jsu.controller;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.jsu.common.lang.Result;
import com.jsu.entity.Blog;
import com.jsu.service.BlogService;
import com.jsu.util.ShiroUtils;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.Assert;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.swing.text.StyledEditorKit;
import java.time.LocalDateTime;
/**
* <p>
* 前端控制器
* </p>
*
* @author zjx
* @since 2021-07-15
*/
@RestController
public class BlogController {
@Autowired
private BlogService blogService;
@GetMapping("/blogs")
public Result list(@RequestParam(defaultValue = "1") Integer currentPage){
Page page = new Page(currentPage, 5);
IPage<Blog> pageData = blogService.page(page, new QueryWrapper<Blog>().orderByDesc("created"));
return Result.success(pageData);
}
@GetMapping("/blog/{id}")
public Result detail(@PathVariable(name = "id") Long id){
// 查询博客
// 判断博客是否存在,不存在则提示用户
Blog blog = blogService.getById(id);
Assert.notNull(blog,"该博客不存在");
return Result.success(blog);
}
@RequiresAuthentication
@PostMapping("/blog/edit")
public Result edit(@Validated @RequestBody Blog blog){
// 判断博客的id是否为null
// 如果id不为null,则继续判断该博客是否是该用户的
// 如果是该用户的,则可以修改,否则不能修改他人的博客
Blog newBlog= null;
if (blog.getId()!=null) {
newBlog = blogService.getById(blog.getId());
Assert.isTrue(newBlog.getUserId().longValue() == ShiroUtils.getProfile().getId().longValue(),"没有编辑权限");
} else {
newBlog = new Blog();
newBlog.setUserId(ShiroUtils.getProfile().getId());
newBlog.setCreated(LocalDateTime.now());
newBlog.setStatus(0);
}
BeanUtil.copyProperties(blog,newBlog,"id","userId","created","status");
blogService.saveOrUpdate(newBlog);
return Result.success(newBlog);
}
}
二、前端开发
1、环境准备
- 在nodejs官网上下载对应的长期支持版;
- 安装;
- 安装完成利用 node -v 和 npm -v 命令查看版本信息。
配置可查看该博客:https://blog.youkuaiyun.com/dream_summer/article/details/108867317?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522162668320016780255216534%2522%252C%2522scm%2522%253A%252220140713.130102334…%2522%257D&request_id=162668320016780255216534&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2alltop_positive~default-3-108867317.pc_search_result_before_js&utm_term=vue%E5%AE%89%E8%A3%85%E5%8F%8A%E7%8E%AF%E5%A2%83%E9%85%8D%E7%BD%AE&spm=1018.2226.3001.4187
2、新建项目
# 打开vue的可视化管理工具界面
vue ui
打开可视化工具之后即可创建项目,然后选择手动配置,勾选项目需要用到的组件。
3、安装element-ui
# 切换到项目根目录
cd vueblog-vue
# 安装element-ui
cnpm install element-ui --save
打开项目src目录下的main.js,引入element-ui依赖
import Element from 'element-ui'
import "element-ui/lib/theme-chalk/index.css"
Vue.use(Element)
4、安装axios
# 安装axios的命令
cnpm install axios --save
打开项目src目录下的main.js,全局引入axios
import axios from 'axios'
Vue.prototype.$axios = axios
5、页面路由
在路由中心router\index.js中配置:
import Vue from 'vue'
import VueRouter from 'vue-router'
import Login from '../views/Login.vue'
import Blogs from '../views/Blogs.vue'
import BlogDetail from '../views/BlogDetail.vue'
import BlogEdit from '../views/BlogEdit.vue'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'Index',
redirect: {name: "Blogs"}
},
{
path: '/blogs',
name: 'Blogs',
component: Blogs
},
{
path: '/login',
name: 'Login',
component: Login
},
{
path: '/blog/add',
name: 'BlogAdd',
component: BlogEdit,
meta:{
requireAuth: true
}
},
{
path: '/blog/:blogId',
name: 'BlogDetail',
component: BlogDetail
}, {
path: '/blog/:blogId/edit',
name: 'BlogEdit',
component: BlogEdit,
meta:{
requireAuth: true
}
}
];
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
});
export default router
6、登录页面
在views包下创建Login.vue,编写代码:
<template>
<div>
<el-container>
<el-header>
<img class="logo" src="../assets/logo.png" alt="" />
</el-header>
<el-main>
<el-form :model="ruleForm" :rules="rules" ref="ruleForm" label-width="100px" class="login-ruleForm">
<el-form-item label="用户名" prop="username">
<el-input v-model="ruleForm.username"></el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input type="password" v-model="ruleForm.password"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm('ruleForm')">登录</el-button>
<el-button @click="resetForm('ruleForm')">重置</el-button>
</el-form-item>
</el-form>
</el-main>
</el-container>
</div>
</template>
<script>
import ElementUI from "element-ui";
export default {
data() {
return {
ruleForm: {
username: 'zjx123',
password: '111111'
},
rules: {
username: [
{required: true, message: '请输入用户名', trigger: 'blur'},
{min: 3, max: 10, message: '长度在 3 到 10 个字符', trigger: 'blur'}
],
password: [
{required: true, message: '请输入密码', trigger: 'change'},
{min: 6, max: 20, message: '长度在 6 到 20 个字符', trigger: 'blur'}
]
}
};
},
methods: {
submitForm(formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
const _this = this;
this.$axios.post("/login",this.ruleForm).then( res=>{
// 取出数据
// 存储到store中
// 获取数据
const jwt = res.headers['authorization'];
const userInfo = res.data.data;
_this.$store.commit('SET_TOKEN',jwt);
_this.$store.commit('SET_USERINFO',userInfo);
console.log(_this.$store.getters.getUser);
// 登录成功,则需跳转页面
this.$message({
message: '恭喜你,登录成功!',
type: 'success',
duration: 800
});
_this.$router.push('/blogs');
}
)
} else {
return false;
}
});
},
resetForm(formName) {
this.$refs[formName].resetFields();
}
}
}
</script>
<style scoped>
.el-header, .el-footer {
background-color: #B3C0D1;
color: #333;
text-align: center;
line-height: 60px;
}
.el-main {
color: #333;
text-align: center;
line-height: 250px;
}
body > .el-container {
margin-bottom: 40px;
}
.el-container:nth-child(5) .el-aside,
.el-container:nth-child(6) .el-aside {
line-height: 260px;
}
.el-container:nth-child(7) .el-aside {
line-height: 320px;
}
/*图标*/
.logo{
width: 120px;
height: 120px;
}
/*登录框*/
.login-ruleForm{
margin-top: 50px;
}
.login-ruleForm{
width: 500px;
height: 250px;
margin: 105px auto;
}
</style>
token状态同步
存储token,用的是localStorage,存储用户信息,用的是sessionStorage。在store包下编写index.js,代码如下:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
token: '',
userInfo: JSON.parse(sessionStorage.getItem('userInfo'))
},
mutations: {
SET_TOKEN:(state,token)=>{
state.token = token;
localStorage.setItem('token',token);
},
SET_USERINFO:(state, userInfo)=>{
state.userInfo = userInfo;
sessionStorage.setItem('userInfo',JSON.stringify(userInfo));
},
REMOVE_INFO:(state)=>{
state.token = '';
state.userInfo = {};
localStorage.setItem('token','');
sessionStorage.setItem('userInfo',JSON.stringify(''));
}
},
getters: {
getUser:(state)=>{
return state.userInfo;
}
},
actions: {
},
modules: {
}
})
7、定义全局axios拦截器
点击登录按钮发起登录请求,成功时候返回了数据,如果是密码错误,弹窗消息提示。为了让这个错误弹窗能运用到所有的地方,对axios做了个后置拦截器,就是返回数据时候,如果结果的code或者status不正常,那么我对应弹窗提示。
在src目录下创建一个文件axios.js(与main.js同级),定义axios的拦截:
import axios from 'axios'
import router from './router'
import ElementUI from 'element-ui';
import store from './store'
axios.defaults.baseURL='http://127.0.0.1:8082';
// 前置拦截
axios.interceptors.request.use(config =>{
return config;
});
axios.interceptors.response.use(response =>{
// 获取响应中的数据
let res = response.data;
console.log(res);
// 判断响应数据的状态码
if(res.code === 200){
return response;
} else if(res.code === 400){
ElementUI.Message.error('密码错误,请重新输入!',{duration : 2000});
return Promise.reject(res.msg);
} else {
ElementUI.Message.error('错了哦,这是一条错误消息',{duration : 2000});
return Promise.reject(res.msg);
}
}, error => {
// 判断状态码
if(error.response.status === 401){
store.commit("REMOVE_INFO");
router.push('/login');
}
ElementUI.Message.error(error.response.data.msg,{duration : 2000});
return Promise.reject(error);
});
8、博客首页
<template>
<div>
<Header></Header>
<div class="block">
<el-timeline>
<el-timeline-item v-for="blog in this.blogs" :timestamp="blog.created" placement="top">
<el-card>
<h4>
<router-link :to="{name:'BlogDetail',params:{blogId:blog.id}}">{{blog.title}}</router-link>
</h4>
<p>{{blog.description}}</p>
</el-card>
</el-timeline-item>
</el-timeline>
<!-- 分页-->
<el-pagination
background
layout="prev, pager, next"
:current-page="this.currentPage"
:page-size="this.pageSize"
:total="this.total"
@current-change=page
>
</el-pagination>
</div>
</div>
</template>
<script>
import Header from "../components/Header";
export default {
name: "Blogs",
components: {Header},
data() {
return {
blogs:{},
currentPage:1,
total:0,
pageSize:5
}
},
methods: {
page(currentPage) {
const _this = this;
_this.$axios.get("/blogs?currentPage="+currentPage).then(res => {
_this.blogs = res.data.data.records;
_this.currentPage = res.data.data.current;
_this.total = res.data.data.total;
_this.pageSize = res.data.data.size;
})
}
},
created() {
this.page(1);
}
}
</script>
<style scoped>
.el-header {
background-color: #B3C0D1;
color: #333;
text-align: center;
line-height: 60px;
}
.el-aside {
background-color: #D3DCE6;
color: #333;
text-align: center;
line-height: 500px;
}
.el-main {
background-color: #E9EEF3;
color: #333;
text-align: center;
line-height: 460px;
}
body > .el-container {
margin-bottom: 40px;
}
.el-container:nth-child(5) .el-aside,
.el-container:nth-child(6) .el-aside {
line-height: 260px;
}
.el-container:nth-child(7) .el-aside {
line-height: 320px;
}
</style>
9、富文本编辑器
# 安装mavon-editor
cnpm install mavon-editor --save
在main.js中全局注册:
// 全局注册
import Vue from 'vue'
import mavonEditor from 'mavon-editor'
import 'mavon-editor/dist/css/index.css'
// use
Vue.use(mavonEditor)
10、博客编辑(发表)页
<template>
<div>
<Header></Header>
<el-form :model="ruleForm" :rules="rules" ref="ruleForm" label-width="100px" class="demo-ruleForm">
<el-form-item label="标题" prop="title">
<el-input v-model="ruleForm.title"></el-input>
</el-form-item>
<el-form-item label="摘要" prop="description">
<el-input type="textarea" v-model="ruleForm.description"></el-input>
</el-form-item>
<el-form-item label="内容" prop="content">
<mavon-editor v-model="ruleForm.content"></mavon-editor>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm('ruleForm')">确定</el-button>
<el-button @click="resetForm('ruleForm')">重置</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
import Header from "../components/Header";
export default {
name: "BlogEdit",
components:{Header},
data() {
return {
ruleForm: {
id: '',
title: '',
description: '',
content: ''
},
rules: {
title: [
{ required: true, message: '请输入标题', trigger: 'blur' },
{ min: 3, max: 25, message: '长度在 3 到 25 个字符', trigger: 'blur' }
],
description: [
{ required: true, message: '请输入摘要', trigger: 'change' }
],
content: [
{ required: true, message: '请输入内容', trigger: 'change' }
]
}
};
},
methods: {
submitForm(formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
const _this = this;
_this.$axios.post("/blog/edit",this.ruleForm,{
headers:{
"Authorization": localStorage.getItem("token")
}
}).then(res => {
this.$alert('修改成功', '提示', {
confirmButtonText: '确定',
callback: action => {
_this.$router.push("/blogs");
}
});
});
} else {
console.log('error submit!!');
return false;
}
});
},
resetForm(formName) {
this.$refs[formName].resetFields();
}
},
created() {
const blogId = this.$route.params.blogId;
const _this = this;
if (blogId){
_this.$axios.get("/blog/"+blogId).then(res => {
console.log(res.data.data);
const blog = res.data.data;
_this.ruleForm.id = blog.id;
_this.ruleForm.title = blog.title;
_this.ruleForm.content = blog.content;
_this.ruleForm.description = blog.description;
})
}
}
}
</script>
<style scoped>
</style>
11、博客详情页
博客详情中需要回显博客信息,然后有个问题就是,后端传过来的是博客内容是markdown格式的内容,我们需要进行渲染然后显示出来,这里使用一个插件markdown-it,用于解析md文档,然后导入github-markdown-c,所谓md的样式。
# 用于解析md文档
cnpm install markdown-it --save
# md样式
cnpm install github-markdown-css
<template>
<div>
<Header></Header>
<div class="blog">
<h2>{{this.blog.title}}</h2>
<el-link icon="el-icon-edit" v-show="isMyBlog">
<router-link :to="{name: 'BlogEdit',params:{blogId:this.blog.id}}" >编辑</router-link>
</el-link>
<el-divider></el-divider>
<div class="markdown-body" v-html="this.blog.content" style="text-align: left">
</div>
</div>
</div>
</template>
<script>
import 'github-markdown-css';
import Header from "../components/Header";
export default {
name: "BlogDetail",
components:{Header},
data() {
return {
blog:{
id:"",
title:"",
content:""
},
isMyBlog:false
}
},
created() {
const _this = this;
const blogId = _this.$route.params.blogId;
_this.$axios.get("/blog/"+blogId).then( res => {
const blog = res.data.data;
_this.blog.id = blog.id;
_this.blog.title = blog.title;
var MarkdownIt = require("markdown-it");
var md = new MarkdownIt();
var result = md.render(blog.content);
_this.blog.content = result;
// 判断是否是自己的博客
_this.isMyBlog = blog.userId === _this.$store.getters.getUser.id;
})
}
}
</script>
<style scoped>
.blog {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
width: 1000px;
height: 700px;
margin: 0 auto;
padding: 20px 20px;
}
</style>
12、路由权限拦截
页面开发完毕之后,有些页面在未登录的状态下是不能直接访问的,如果访问则提示用户需要登录后操作,重定向到登录页面。
在src目录下新建一个permission.js文件,编写如下内容:
import router from './router/index';
// 路由守卫
router.beforeEach((to,from,next)=>{
// to要跳转到的路径
// from从哪个路径来
// next往下执行的回调
// 在localStorage中获取token
let token=localStorage.getItem('token');
// 判断该页面是否需要登录
if(to.meta.requireAuth){
// 如果token存在直接跳转
if(token){
next()
}else{
alert("请登录后操作!");
// 否则跳转到login登录页面
next({
path:'/login',
// 跳转时传递参数到登录页面,以便登录后可以跳转到对应页面
query:{
redirect:to.fullPath
}
})
}
}else{
// 如果不需要登录,则直接跳转到对应页面
next()
}
})