我们有前台和后台两套系统。两套系统的前端工程都已经提供好了。所以我们只需要写两套系统的后端。
但是大家思考下,实际上两套后端系统的很多内容是可能重复的。这里如果我们只是单纯的创建两个后端工程。那么就会有大量的重复代码,并且需要修改的时候也需要修改两次。这就是代码复用性不高。
所以我们需要创建多模块项目,两套系统可能都会用到的代码可以写到一个公共模块中,让前台系统和后台系统分别取依赖公共模块。
目的就是要为了提高代码的复用性。多模态系统的开发。
首先创建一个maven项目,因为我们这里创建的是一个父工程,并不需要在父工程中写代码,所以可以把src文件给删除掉。
在dependencyManagement中进行子版本的控制,父模块主要是用于控制依赖的版本,并不实际导入了依赖,在没有字依赖的时候会报错。
4.0.0
com.sangeng
SGBlog
pom
1.0-SNAPSHOT
sangeng-framework
sangeng-admin
sangeng-blog
UTF-8
1.8
org.springframework.boot
spring-boot-dependencies
2.5.0
pom
import
com.alibaba
fastjson
1.2.33
io.jsonwebtoken
jjwt
0.9.0
com.baomidou
mybatis-plus-boot-starter
3.4.3
com.aliyun.oss
aliyun-sdk-oss
3.10.2
com.alibaba
easyexcel
3.0.5
io.springfox
springfox-swagger2
2.9.2
io.springfox
springfox-swagger-ui
2.9.2
org.apache.maven.plugins
maven-compiler-plugin
3.1
${java.version}
${project.build.sourceEncoding}
因为父模块是用来管理依赖的,并不用于存放依赖,所以这里还有公共子模块这一个层级
SGBlog
com.sangeng
1.0-SNAPSHOT
4.0.0
sangeng-framework
org.springframework.boot
spring-boot-starter-web
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-test
test
org.springframework.boot
spring-boot-starter-security
org.springframework.boot
spring-boot-starter-data-redis
com.alibaba
fastjson
io.jsonwebtoken
jjwt
com.baomidou
mybatis-plus-boot-starter
mysql
mysql-connector-java
com.aliyun.oss
aliyun-sdk-oss
org.springframework.boot
spring-boot-starter-aop
com.alibaba
easyexcel
io.springfox
springfox-swagger2
io.springfox
springfox-swagger-ui
SGBlog
com.sangeng
1.0-SNAPSHOT
4.0.0
sangeng-admin
com.sangeng
sangeng-framework
1.0-SNAPSHOT
SGBlog
com.sangeng
1.0-SNAPSHOT
4.0.0
sangeng-blog
com.sangeng
sangeng-framework
1.0-SNAPSHOT
@SpringBootApplication
@MapperScan("com.sangeng.mapper")
public class SanGengBlogApplication {
public static void main(String[] args) {
SpringApplication.run(SanGengBlogApplication.class,args);
}
}
server:
port: 7777
spring:
datasource:
url: jdbc:mysql://localhost:3306/sg_blog?characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
servlet:
multipart:
max-file-size: 2MB
max-request-size: 5MB
mybatis-plus:
configuration:
# 日志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: delFlag
logic-delete-value: 1
logic-not-delete-value: 0
id-type: auto
前端端口默认调用的是7777端口,id_type表示的是主键自增,不然就可能数据库自动用雪花算法生成id。
SQL脚本:SGBlog\资源\SQL\sg_article.sql
注意:实体类名,要和数据库表属性名一一对应,这里介绍一个插件,名为EasyCode,可以帮助创建实体类,提高开发效率。
这里的RemovePre表示的是可以将数据库的表名在这里进行更改,可以去掉前缀
可以使用mybatisx进行平替,生成实体类mapper和service等层。(37条消息) SpringBoot中MybatisX插件的简单使用教程(超详细!!)_啊陈晓的博客-CSDN博客
这里创建的Controller包应该放在qiantaiblog下面,因为前台和后台的controller层业务肯定不一样,所以要具体到前台系统。
此时运行程序会出现报错,因为刚修改了zyj-framework的service层,还没有打包,找的是老版本的,所以需要在maven里选择父工程root重新打包。这里出现过无法连接数据库起不来服务器的问题(37条消息) Failed to determine a suitable driver class 的处理方法_易意逸的博客-CSDN博客,评论里说子项目的packing需要注释掉!!!
通过需求去分析需要有哪些字段。
需要查询浏览量最高的前10篇文章的信息。要求展示文章标题和浏览量。把能让用户自己点击跳转到具体的文章详情进行浏览。
注意:不能把草稿展示出来,不能把删除了的文章查询出来。要按照浏览量进行降序排序。
见接口文档
①准备工作
统一响应类和响应枚举,因为你返回前端的数据,要自己统一格式,返回的code码等。可以使用封装的一个类,来继承serializable,序列化返回json数据到前端。为什么要继承serializable,(37条消息) Java后台框架中具体什么时候需要实现序列化接口Serializable_太阳晒屁股了的博客-CSDN博客_哪种情况下类需要实现序列化接口和(37条消息) 定义java实体类时为什么继承Serializable接口_nanaz11的博客-CSDN博客。当返回给前端是对象数据的时候需要序列化。
package com.sangeng.domain;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.sangeng.enums.AppHttpCodeEnum;
import java.io.Serializable;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ResponseResult implements Serializable {
private Integer code;
private String msg;
private T data;
public ResponseResult() {
this.code = AppHttpCodeEnum.SUCCESS.getCode();
this.msg = AppHttpCodeEnum.SUCCESS.getMsg();
}
public ResponseResult(Integer code, T data) {
this.code = code;
this.data = data;
}
public ResponseResult(Integer code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
}
public ResponseResult(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
public static ResponseResult errorResult(int code, String msg) {
ResponseResult result = new ResponseResult();
return result.error(code, msg);
}
public static ResponseResult okResult() {
ResponseResult result = new ResponseResult();
return result;
}
public static ResponseResult okResult(int code, String msg) {
ResponseResult result = new ResponseResult();
return result.ok(code, null, msg);
}
public static ResponseResult okResult(Object data) {
ResponseResult result = setAppHttpCodeEnum(AppHttpCodeEnum.SUCCESS, AppHttpCodeEnum.SUCCESS.getMsg());
if(data!=null) {
result.setData(data);
}
return result;
}
public static ResponseResult errorResult(AppHttpCodeEnum enums){
return setAppHttpCodeEnum(enums,enums.getMsg());
}
public static ResponseResult errorResult(AppHttpCodeEnum enums, String msg){
return setAppHttpCodeEnum(enums,msg);
}
public static ResponseResult setAppHttpCodeEnum(AppHttpCodeEnum enums){
return okResult(enums.getCode(),enums.getMsg());
}
private static ResponseResult setAppHttpCodeEnum(AppHttpCodeEnum enums, String msg){
return okResult(enums.getCode(),msg);
}
public ResponseResult> error(Integer code, String msg) {
this.code = code;
this.msg = msg;
return this;
}
public ResponseResult> ok(Integer code, T data) {
this.code = code;
this.data = data;
return this;
}
public ResponseResult> ok(Integer code, T data, String msg) {
this.code = code;
this.data = data;
this.msg = msg;
return this;
}
public ResponseResult> ok(T data) {
this.data = data;
return this;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}
package com.sangeng.enums;
public enum AppHttpCodeEnum {
// 成功
SUCCESS(200,"操作成功"),
// 登录
NEED_LOGIN(401,"需要登录后操作"),
NO_OPERATOR_AUTH(403,"无权限操作"),
SYSTEM_ERROR(500,"出现错误"),
USERNAME_EXIST(501,"用户名已存在"),
PHONENUMBER_EXIST(502,"手机号已存在"), EMAIL_EXIST(503, "邮箱已存在"),
REQUIRE_USERNAME(504, "必需填写用户名"),
LOGIN_ERROR(505,"用户名或密码错误");
int code;
String msg;
AppHttpCodeEnum(int code, String errorMessage){
this.code = code;
this.msg = errorMessage;
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
代码实现
@RestController
@RequestMapping("/article")
public class ArticleController {
@Autowired
private ArticleService articleService;
@GetMapping("/hotArticleList")
public ResponseResult hotArticleList(){
ResponseResult result = articleService.hotArticleList();
return result;
}
}
public interface ArticleService extends IService {
ResponseResult hotArticleList();
}
@Service
public class ArticleServiceImpl extends ServiceImpl implements ArticleService {
@Override
public ResponseResult hotArticleList() {
//查询热门文章 封装成ResponseResult返回
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
//必须是正式文章
queryWrapper.eq(Article::getStatus,0);
//按照浏览量进行排序
queryWrapper.orderByDesc(Article::getViewCount);
//最多只查询10条
Page page = new Page(1,10);
page(page,queryWrapper);
List articles = page.getRecords();
return ResponseResult.okResult(articles);
}
}
关于后端向前端返回数据为什么是json格式的原因(37条消息) @ResponseBody注解作用与原理_春风化作秋雨的博客-CSDN博客_@responsebody
(37条消息) SpringBoot与Vue交互解决跨域问题【亲测已解决】_灰小猿的博客-CSDN博客
目前我们的响应格式其实是不符合接口文档的标准的,多返回了很多字段。这是因为我们查询出来的结果是Article来封装的,Article中字段比较多。
我们在项目中一般最后还要把VO来接受查询出来的结果。一个接口对应一个VO,这样即使接口响应字段要修改也只要改VO即可。响应给前端的类称为VO。这里添加一个知识点关于Java的stream流(38条消息) Java Stream流(详解)_肥兄的博客-CSDN博客_java stream()
@Data
@NoArgsConstructor
@AllArgsConstructor
public class HotArticleVo {
private Long id;
//标题
private String title;
//访问量
private Long viewCount;
}
@Service
public class ArticleServiceImpl extends ServiceImpl implements ArticleService {
@Override
public ResponseResult hotArticleList() {
//查询热门文章 封装成ResponseResult返回
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
//必须是正式文章
queryWrapper.eq(Article::getStatus,0);
//按照浏览量进行排序
queryWrapper.orderByDesc(Article::getViewCount);
//最多只查询10条
Page page = new Page(1,10);
page(page,queryWrapper);
List articles = page.getRecords();
//bean拷贝
List articleVos = new ArrayList<>();
for (Article article : articles) {
HotArticleVo vo = new HotArticleVo();
BeanUtils.copyProperties(article,vo);
articleVos.add(vo);
}
return ResponseResult.okResult(articleVos);
}
}
这里一定要保持HotArticleVo的属性名和Article一致。
实际项目中都不允许直接在代码中使用字面值(即常量值)。都需要定义成常量来使用。这种方式有利于提高代码的可维护性。
public class SystemConstants
{
/**
* 文章是草稿
*/
public static final int ARTICLE_STATUS_DRAFT = 1;
/**
* 文章是正常分布状态
*/
public static final int ARTICLE_STATUS_NORMAL = 0;
}
之后更改的地方为
public class BeanCopyUtils {
private BeanCopyUtils() {
}
public static V copyBean(Object source,Class clazz) {
//创建目标对象
V result = null;
try {
result = clazz.newInstance();
//实现属性copy
BeanUtils.copyProperties(source, result);
} catch (Exception e) {
e.printStackTrace();
}
//返回结果
return result;
}
public static List copyBeanList(List list,Class clazz){
return list.stream()
.map(o -> copyBean(o, clazz))
.collect(Collectors.toList());
}
}
通过需求去分析需要有哪些字段。
建表SQL及初始化数据见:SGBlog\资源\SQL\sg_category.sql
页面上需要展示分类列表,用户可以点击具体的分类查看该分类下的文章列表。
注意: ①要求只展示有发布正式文章的分类 ②必须是正常状态的分类
3.2.2 接口设计
因为涉及到查询分类id,才能确定是否已经被正式发布,所以需要联表查询
@RestController
@RequestMapping("/category")
public class CategoryController {
@Autowired
private CategoryService categoryService;
@GetMapping("/getCategoryList")
public ResponseResult getCategoryList(){
return categoryService.getCategoryList();
}
}
public interface CategoryService extends IService {
ResponseResult getCategoryList();
}
@Service("categoryService")
public class CategoryServiceImpl extends ServiceImpl implements CategoryService {
@Autowired
private ArticleService articleService;
@Override
public ResponseResult getCategoryList() {
//查询文章表 状态为已发布的文章
LambdaQueryWrapper articleWrapper = new LambdaQueryWrapper<>();
articleWrapper.eq(Article::getStatus,SystemConstants.ARTICLE_STATUS_NORMAL);
List articleList = articleService.list(articleWrapper);
//获取文章的分类id,并且去重
Set categoryIds = articleList.stream()
.map(article -> article.getCategoryId())
.collect(Collectors.toSet());
//查询分类表
List categories = listByIds(categoryIds);
categories = categories.stream().
filter(category -> SystemConstants.STATUS_NORMAL.equals(category.getStatus()))
.collect(Collectors.toList());
//封装vo
List categoryVos = BeanCopyUtils.copyBeanList(categories, CategoryVo.class);
return ResponseResult.okResult(categoryVos);
}
}
在首页和分类页面都需要查询文章列表。
首页:查询所有的文章
分类页面:查询对应分类下的文章
要求:①只能查询正式发布的文章 ②置顶的文章要显示在最前面
这里前端会给后端传值。因为这里是get请求(get请求传过来的数据可直接放在路径上),当前端传过来的参数信息直接放在url路径上时,可直接在后端定义名字相同的形参进行接收。
当使用占位符传值时
(38条消息) @PathVariable注解的用法和作用(Demo详解)_辰兮要努力的博客-CSDN博客_pathvariable。
若前端传过来的是表单数据,接收方式(38条消息) 前端后端数据传递的几种方式__bliu的博客-CSDN博客_前端向后端传递数据的方式。(38条消息) 后端接收前端数据的三种注解方式_右脚丫99的博客-CSDN博客_当只接收一个参数如何备注给前端。(38条消息) 【java】后端接受前端数据的常用注解_吃鱼的小朋的博客-CSDN博客_java接收参数的注解
MP支持分页配置
@Configuration
public class MbatisPlusConfig {
/**
* 3.4.0之后版本
* @return
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(){
MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return mybatisPlusInterceptor;
}
}
在ArticleController中
@GetMapping("/articleList")
public ResponseResult articleList(Integer pageNum,Integer pageSize,Long categoryId){ rreturn articleService.articleList(pageNum,pageSize,categoryId); }
在ArticleService中
ResponseResult articleList(Integer pageNum, Integer pageSize, Long categoryId);
在ArticleServiceImpl中
@Override
public ResponseResult articleList(Integer pageNum, Integer pageSize, Long categoryId) {
//查询条件
LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>();
// 如果 有categoryId 就要 查询时要和传入的相同
lambdaQueryWrapper.eq(Objects.nonNull(categoryId)&&categoryId>0 ,Article::getCategoryId,categoryId);
// 状态是正式发布的
lambdaQueryWrapper.eq(Article::getStatus,SystemConstants.ARTICLE_STATUS_NORMAL);
// 对isTop进行降序
lambdaQueryWrapper.orderByDesc(Article::getIsTop);
//分页查询
Page page = new Page<>(pageNum,pageSize);
page(page,lambdaQueryWrapper);
List articles = page.getRecords();
//查询categoryName
articles.stream()
.map(article -> article.setCategoryName(categoryService.getById(article.getCategoryId()).getName()))
.collect(Collectors.toList());
//articleId去查询articleName进行设置
// for (Article article : articles) {
// Category category = categoryService.getById(article.getCategoryId());
// article.setCategoryName(category.getName());
// }
//封装查询结果
List articleListVos = BeanCopyUtils.copyBeanList(page.getRecords(), ArticleListVo.class);
PageVo pageVo = new PageVo(articleListVos,page.getTotal());
return ResponseResult.okResult(pageVo);
}
}
ArticleListVo
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ArticleListVo {
private Long id;
//标题
private String title;
//文章摘要
private String summary;
//所属分类名
private String categoryName;
//缩略图
private String thumbnail;
//访问量
private Long viewCount;
private Date createTime;
}
因为这里给前端返回的数据格式是这样的,所以在封装了ArticleListVo之后,还不能直接返回数据给前端,所以还需要再进行封装。所以需要封装一个PageVo类,所以需要封装成如下所示。
PageVo
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageVo {
private List rows;
private Long total;
}
补充知识点,关于http请求头(38条消息) 面试: http请求头和响应头都包含什么?_bug 的博客-CSDN博客_请求头里面有什么。
最后返回前端的数据如图所示
因为没有查询的是article表,并没有categoryname,所以需要补充加上categoryname
在Article中增加一个字段
@TableField(exist = false)
private String categoryName;
因为他本身在article表中是不存在的,所以需要加上tablefield注解。
引入了FastJson是用于创建时间时,生成相应格式的时间戳格式。放在webconfig下即可
@Bean//使用@Bean注入fastJsonHttpMessageConvert
public HttpMessageConverter fastJsonHttpMessageConverters() {
//1.需要定义一个Convert转换消息的对象
FastJsonHttpMessageConverter fastConverter = new FastJsonHttpMessageConverter();
FastJsonConfig fastJsonConfig = new FastJsonConfig();
fastJsonConfig.setSerializerFeatures(SerializerFeature.PrettyFormat);
fastJsonConfig.setDateFormat("yyyy-MM-dd HH:mm:ss");
SerializeConfig.globalInstance.put(Long.class, ToStringSerializer.instance);
fastJsonConfig.setSerializeConfig(SerializeConfig.globalInstance);
fastConverter.setFastJsonConfig(fastJsonConfig);
HttpMessageConverter> converter = fastConverter;
return converter;
}
@Override
public void configureMessageConverters(List> converters) {
converters.add(fastJsonHttpMessageConverters());
}
这里设置的是关于Date的数据类型。(38条消息) Alibaba Fastjson——超好用的JOSN解析库_小马 同学的博客-CSDN博客_alibaba fastjson
要求在文章列表点击阅读全文时能够跳转到文章详情页面,可以让用户阅读文章正文。
要求:①要在文章详情中展示其分类名
请求方式 |
请求路径 |
Get |
/article/{id} |
响应格式:
{
"code": 200,
"data": {
"categoryId": "1",
"categoryName": "java",
"content": "内容",
"createTime": "2022-01-23 23:20:11",
"id": "1",
"isComment": "0",
"title": "SpringSecurity从入门到精通",
"viewCount": "114"
},
"msg": "操作成功"
}
同时还需要自己创建ArticleDetailVo类。
ServiceImpl
@Override
public ResponseResult getArticleDetail(Long id) {
//根据id查询文章
Article article = getById(id);
//转换成VO
ArticleDetailVo articleDetailVo = BeanCopyUtils.copyBean(article, ArticleDetailVo.class);
//根据分类id查询分类名
Long categoryId = articleDetailVo.getCategoryId();
Category category = categoryService.getById(categoryId);
if(category!=null){
articleDetailVo.setCategoryName(category.getName());
}
//封装响应返回
return ResponseResult.okResult(articleDetailVo);
}
使用我们前台和后台的认证授权统一都使用SpringSecurity安全框架来实现。
需要实现登录功能
有些功能必须登录后才能使用,未登录状态是不能使用的。
请求方式 |
请求路径 |
POST |
/login |
请求体:
{
"userName":"sg",
"password":"1234"
}
响应格式:
{
"code": 200,
"data": {
"token": "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI0ODBmOThmYmJkNmI0NjM0OWUyZjY2NTM0NGNjZWY2NSIsInN1YiI6IjEiLCJpc3MiOiJzZyIsImlhdCI6MTY0Mzg3NDMxNiwiZXhwIjoxNjQzOTYwNzE2fQ.ldLBUvNIxQCGemkCoMgT_0YsjsWndTg5tqfJb77pabk",
"userInfo": {
"avatar": "https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fi0.hdslb.com%2Fbfs%2Farticle%2F3bf9c263bc0f2ac5c3a7feb9e218d07475573ec8.gi",
"email": "[email protected]",
"id": 1,
"nickName": "sg333",
"sex": "1"
}
},
"msg": "操作成功"
}
建表SQL及初始化数据见:SGBlog\资源\SQL\sys_user.sql
顺便生成下User和UserMapper后面会用到
登录
①自定义登录接口
调用ProviderManager的方法进行认证 如果认证通过生成jwt
把用户信息存入redis中
②自定义UserDetailsService
在这个实现类中去查询数据库
注意配置passwordEncoder为BCryptPasswordEncoder
校验:
①定义Jwt认证过滤器
获取token
解析token获取其中的userid
从redis中获取用户信息
存入SecurityContextHolder
①添加依赖
注意放开Security依赖的注释
org.springframework.boot
spring-boot-starter-data-redis
com.alibaba
fastjson
1.2.33
io.jsonwebtoken
jjwt
0.9.0
②工具类和相关配置类
见 :SGBlog\资源\登录功能所需资源
@RestController
public class BlogLoginController {
@Autowired
private BlogLoginService blogLoginService;
@PostMapping("/login")
public ResponseResult login(@RequestBody User user){
return blogLoginService.login(user);
}
}
public interface BlogLoginService {
ResponseResult login(User user);
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/login").anonymous()
// 除上面外的所有请求全部不需要认证即可访问
.anyRequest().permitAll();
http.logout().disable();
//允许跨域
http.cors();
}
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
@Service
public class BlogLoginServiceImpl implements BlogLoginService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisCache redisCache;
@Override
public ResponseResult login(User user) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
//判断是否认证通过
if(Objects.isNull(authenticate)){
throw new RuntimeException("用户名或密码错误");
}
//获取userid 生成token
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
String userId = loginUser.getUser().getId().toString();
String jwt = JwtUtil.createJWT(userId);
//把用户信息存入redis
redisCache.setCacheObject("bloglogin:"+userId,loginUser);
//把token和userinfo封装 返回
//把User转换成UserInfoVo
UserInfoVo userInfoVo = BeanCopyUtils.copyBean(loginUser.getUser(), UserInfoVo.class);
BlogUserLoginVo vo = new BlogUserLoginVo(jwt,userInfoVo);
return ResponseResult.okResult(vo);
}
}
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//根据用户名查询用户信息
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUserName,username);
User user = userMapper.selectOne(queryWrapper);
//判断是否查到用户 如果没查到抛出异常
if(Objects.isNull(user)){
throw new RuntimeException("用户不存在");
}
//返回用户信息
// TODO 查询权限信息封装
return new LoginUser(user);
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginUser implements UserDetails {
private User user;
@Override
public Collection extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUserName();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class BlogUserLoginVo {
private String token;
private UserInfoVo userInfo;
}
@Data
@Accessors(chain = true)
public class UserInfoVo {
/**
* 主键
*/
private Long id;
/**
* 昵称
*/
private String nickName;
/**
* 头像
*/
private String avatar;
private String sex;
private String email;
}
①定义Jwt认证过滤器
获取token
解析token获取其中的userid
从redis中获取用户信息
存入SecurityContextHolder
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private RedisCache redisCache;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//获取请求头中的token
String token = request.getHeader("token");
if(!StringUtils.hasText(token)){
//说明该接口不需要登录 直接放行
filterChain.doFilter(request, response);
return;
}
//解析获取userid
Claims claims = null;
try {
claims = JwtUtil.parseJWT(token);
} catch (Exception e) {
e.printStackTrace();
//token超时 token非法
//响应告诉前端需要重新登录
ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
WebUtils.renderString(response, JSON.toJSONString(result));
return;
}
String userId = claims.getSubject();
//从redis中获取用户信息
LoginUser loginUser = redisCache.getCacheObject("bloglogin:" + userId);
//如果获取不到
if(Objects.isNull(loginUser)){
//说明登录过期 提示重新登录
ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
WebUtils.renderString(response, JSON.toJSONString(result));
return;
}
//存入SecurityContextHolder
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser,null,null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/login").anonymous()
//jwt过滤器测试用,如果测试没有问题吧这里删除了
.antMatchers("/link/getAllLink").authenticated()
// 除上面外的所有请求全部不需要认证即可访问
.anyRequest().permitAll();
http.logout().disable();
//把jwtAuthenticationTokenFilter添加到SpringSecurity的过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//允许跨域
http.cors();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
目前我们的项目在认证出错或者权限不足的时候响应回来的Json是Security的异常处理结果。但是这个响应的格式肯定是不符合我们项目的接口规范的。所以需要自定义异常处理。
AuthenticationEntryPoint 认证失败处理器
AccessDeniedHandler 授权失败处理器
这是认证出错后返回给前端的json信息,但是我们希望返回的是统一的JSON格式信息,所以需要进行处理
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
authException.printStackTrace();
//InsufficientAuthenticationException
//BadCredentialsException
ResponseResult result = null;
if(authException instanceof BadCredentialsException){
result = ResponseResult.errorResult(AppHttpCodeEnum.LOGIN_ERROR.getCode(),authException.getMessage());
}else if(authException instanceof InsufficientAuthenticationException){
result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
}else{
result = ResponseResult.errorResult(AppHttpCodeEnum.SYSTEM_ERROR.getCode(),"认证或授权失败");
}
//响应给前端
WebUtils.renderString(response, JSON.toJSONString(result));
}
}
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
accessDeniedException.printStackTrace();
ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NO_OPERATOR_AUTH);
//响应给前端
WebUtils.renderString(response, JSON.toJSONString(result));
}
}
配置Security异常处理器
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Autowired
AuthenticationEntryPoint authenticationEntryPoint;
@Autowired
AccessDeniedHandler accessDeniedHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/login").anonymous()
//jwt过滤器测试用,如果测试没有问题吧这里删除了
.antMatchers("/link/getAllLink").authenticated()
// 除上面外的所有请求全部不需要认证即可访问
.anyRequest().permitAll();
//配置异常处理器
http.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
http.logout().disable();
//把jwtAuthenticationTokenFilter添加到SpringSecurity的过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//允许跨域
http.cors();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
Springboot注入有两种方式,一种是按名字注入,一种是按照类型注入。这里就是后者
实际我们在开发过程中可能需要做很多的判断校验,如果出现了非法情况我们是期望响应对应的提示的。但是如果我们每次都自己手动去处理就会非常麻烦。我们可以选择直接抛出异常的方式,然后对异常进行统一处理。把异常中的信息封装成ResponseResult响应给前端。
SystemException
/**
* @Author 三更 B站: https://space.bilibili.com/663528522
*/
public class SystemException extends RuntimeException{
private int code;
private String msg;
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
public SystemException(AppHttpCodeEnum httpCodeEnum) {
super(httpCodeEnum.getMsg());
this.code = httpCodeEnum.getCode();
this.msg = httpCodeEnum.getMsg();
}
}
GlobalExceptionHandler
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(SystemException.class)
public ResponseResult systemExceptionHandler(SystemException e){
//打印异常信息
log.error("出现了异常! {}",e);
//从异常对象中获取提示信息封装返回
return ResponseResult.errorResult(e.getCode(),e.getMsg());
}
@ExceptionHandler(Exception.class)
public ResponseResult exceptionHandler(Exception e){
//打印异常信息
log.error("出现了异常! {}",e);
//从异常对象中获取提示信息封装返回
return ResponseResult.errorResult(AppHttpCodeEnum.SYSTEM_ERROR.getCode(),e.getMessage());
}
}
请求方式 |
请求地址 |
请求头 |
POST |
/logout |
需要token请求头 |
响应格式:
{
"code": 200,
"msg": "操作成功"
}
要实现的操作:
删除redis中的用户信息
BlogLoginController
@PostMapping("/logout")
public ResponseResult logout(){
return blogLoginService.logout();
}
BlogLoginService
ResponseResult logout();
BlogLoginServiceImpl
@Override
public ResponseResult logout() {
//获取token 解析获取userid
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
//获取userid
Long userId = loginUser.getUser().getId();
//删除redis中的用户信息
redisCache.deleteObject("bloglogin:"+userId);
return ResponseResult.okResult();
}
SecurityConfig
要关闭默认的退出登录功能,不然会跳转到springsecurity自带的默认退出界面。并且要配置我们的退出登录接口需要认证才能访问。同时配置logout路径的权限,需要携带token才能进行访问
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/login").anonymous()
//注销接口需要认证才能访问
.antMatchers("/logout").authenticated()
//jwt过滤器测试用,如果测试没有问题吧这里删除了
.antMatchers("/link/getAllLink").authenticated()
// 除上面外的所有请求全部不需要认证即可访问
.anyRequest().permitAll();
//配置异常处理器
http.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
//关闭默认的注销功能
http.logout().disable();
//把jwtAuthenticationTokenFilter添加到SpringSecurity的过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//允许跨域
http.cors();
}
文章详情页面要展示这篇文章下的评论列表。
效果如下:
通过需求去分析需要有哪些字段。
建表SQL及初始化数据见:SGBlog\资源\SQL\sg_comment.sql
顺便生成下对应的代码
请求方式 |
请求地址 |
请求头 |
GET |
/comment/commentList |
不需要token请求头 |
Query格式请求参数:
articleId:文章id
pageNum: 页码
pageSize: 每页条数
不需要token是因为,在未登录的情况下,也可以看到评论信息。
响应格式:
{
"code": 200,
"data": {
"rows": [
{
"articleId": "1",
"children": [
{
"articleId": "1",
"content": "你说啥?",
"createBy": "1",
"createTime": "2022-01-30 10:06:21",
"id": "20",
"rootId": "1",
"toCommentId": "1",
"toCommentUserId": "1",
"toCommentUserName": "sg333",
"username": "sg333"
}
],
"content": "asS",
"createBy": "1",
"createTime": "2022-01-29 07:59:22",
"id": "1",
"rootId": "-1",
"toCommentId": "-1",
"toCommentUserId": "-1",
"username": "sg333"
}
],
"total": "15"
},
"msg": "操作成功"
}
先一步一步规划,不考虑查出子评论的情况,先把根评论给查出来。
CommentController
@RestController
@RequestMapping("/comment")
public class CommentController {
@Autowired
private CommentService commentService;
@GetMapping("/commentList")
public ResponseResult commentList(Long articleId,Integer pageNum,Integer pageSize){
return commentService.commentList(articleId,pageNum,pageSize);
}
}
CommentService
public interface CommentService extends IService {
ResponseResult commentList(Long articleId, Integer pageNum, Integer pageSize);
}
CommentServiceImpl
@Service("commentService")
public class CommentServiceImpl extends ServiceImpl implements CommentService {
@Autowired
private UserService userService;
@Override
public ResponseResult commentList(Long articleId, Integer pageNum, Integer pageSize) {
//查询对应文章的根评论
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
//对articleId进行判断
queryWrapper.eq(Comment::getArticleId,articleId);
//根评论 rootId为-1
queryWrapper.eq(Comment::getRootId,-1);
//分页查询
Page page = new Page(pageNum,pageSize);
page(page,queryWrapper);
List commentVoList = toCommentVoList(page.getRecords());
return ResponseResult.okResult(new PageVo(commentVoList,page.getTotal()));
}
private List toCommentVoList(List list){
List commentVos = BeanCopyUtils.copyBeanList(list, CommentVo.class);
//遍历vo集合
for (CommentVo commentVo : commentVos) {
//通过creatyBy查询用户的昵称并赋值
String nickName = userService.getById(commentVo.getCreateBy()).getNickName();
commentVo.setUsername(nickName);
//通过toCommentUserId查询用户的昵称并赋值
//如果toCommentUserId不为-1才进行查询
if(commentVo.getToCommentUserId()!=-1){
String toCommentUserName = userService.getById(commentVo.getToCommentUserId()).getNickName();
commentVo.setToCommentUserName(toCommentUserName);
}
}
return commentVos;
}
}
CommentVo
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CommentVo {
private Long id;
//文章id
private Long articleId;
//根评论id
private Long rootId;
//评论内容
private String content;
//所回复的目标评论的userid
private Long toCommentUserId;
private String toCommentUserName;
//回复目标评论id
private Long toCommentId;
private Long createBy;
private Date createTime;
private String username;
}
CommentVo在之前的基础上增加了 private List
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CommentVo {
private Long id;
//文章id
private Long articleId;
//根评论id
private Long rootId;
//评论内容
private String content;
//所回复的目标评论的userid
private Long toCommentUserId;
private String toCommentUserName;
//回复目标评论id
private Long toCommentId;
private Long createBy;
private Date createTime;
private String username;
private List children;
}
CommentServiceImpl
@Service("commentService")
public class CommentServiceImpl extends ServiceImpl implements CommentService {
@Autowired
private UserService userService;
@Override
public ResponseResult commentList(Long articleId, Integer pageNum, Integer pageSize) {
//查询对应文章的根评论
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
//对articleId进行判断
queryWrapper.eq(Comment::getArticleId,articleId);
//根评论 rootId为-1
queryWrapper.eq(Comment::getRootId,-1);
//分页查询
Page page = new Page(pageNum,pageSize);
page(page,queryWrapper);
List commentVoList = toCommentVoList(page.getRecords());
//查询所有根评论对应的子评论集合,并且赋值给对应的属性
for (CommentVo commentVo : commentVoList) {
//查询对应的子评论
List children = getChildren(commentVo.getId());
//赋值
commentVo.setChildren(children);
}
return ResponseResult.okResult(new PageVo(commentVoList,page.getTotal()));
}
/**
* 根据根评论的id查询所对应的子评论的集合
* @param id 根评论的id
* @return
*/
private List getChildren(Long id) {
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Comment::getRootId,id);
queryWrapper.orderByAsc(Comment::getCreateTime);
List comments = list(queryWrapper);
List commentVos = toCommentVoList(comments);
return commentVos;
}
private List toCommentVoList(List list){
List commentVos = BeanCopyUtils.copyBeanList(list, CommentVo.class);
//遍历vo集合
for (CommentVo commentVo : commentVos) {
//通过creatyBy查询用户的昵称并赋值
String nickName = userService.getById(commentVo.getCreateBy()).getNickName();
commentVo.setUsername(nickName);
//通过toCommentUserId查询用户的昵称并赋值
//如果toCommentUserId不为-1才进行查询
if(commentVo.getToCommentUserId()!=-1){
String toCommentUserName = userService.getById(commentVo.getToCommentUserId()).getNickName();
commentVo.setToCommentUserName(toCommentUserName);
}
}
return commentVos;
}
}
用户登录后可以对文章发表评论,也可以对评论进行回复。
用户登录后也可以在友链页面进行评论。
请求方式 |
请求地址 |
请求头 |
POST |
/comment |
需要token头 |
回复了文章:
{"articleId":1,"type":0,"rootId":-1,"toCommentId":-1,"toCommentUserId":-1,"content":"评论了文章"}
回复了某条评论:
{"articleId":1,"type":0,"rootId":"3","toCommentId":"3","toCommentUserId":"1","content":"回复了某条评论"}
区别在于他的toCommentId。
{
"code":200,
"msg":"操作成功"
}
CommentController
@PostMapping
public ResponseResult addComment(@RequestBody Comment comment){
return commentService.addComment(comment);
}
CommentService
ResponseResult addComment(Comment comment);
CommentServiceImpl
@Override
public ResponseResult addComment(Comment comment) {
//评论内容不能为空
if(!StringUtils.hasText(comment.getContent())){
throw new SystemException(AppHttpCodeEnum.CONTENT_NOT_NULL);
}
save(comment);
return ResponseResult.okResult();
}
SecurityUtils
public class SecurityUtils
{
/**
* 获取用户
**/
public static LoginUser getLoginUser()
{
return (LoginUser) getAuthentication().getPrincipal();
}
/**
* 获取Authentication
*/
public static Authentication getAuthentication() {
return SecurityContextHolder.getContext().getAuthentication();
}
public static Boolean isAdmin(){
Long id = getLoginUser().getUser().getId();
return id != null && 1L == id;
}
public static Long getUserId() {
return getLoginUser().getUser().getId();
}
}
配置MP字段自动填充
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
Long userId = null;
try {
userId = SecurityUtils.getUserId();
} catch (Exception e) {
e.printStackTrace();
userId = -1L;//表示是自己创建
}
this.setFieldValByName("createTime", new Date(), metaObject);
this.setFieldValByName("createBy",userId , metaObject);
this.setFieldValByName("updateTime", new Date(), metaObject);
this.setFieldValByName("updateBy", userId, metaObject);
}
@Override
public void updateFill(MetaObject metaObject) {
this.setFieldValByName("updateTime", new Date(), metaObject);
this.setFieldValByName(" ", SecurityUtils.getUserId(), metaObject);
}
}
用注解标识哪些字段在什么情况下需要自动填充
/**
* 创建人的用户id
*/
@TableField(fill = FieldFill.INSERT)
private Long createBy;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private Date createTime;
/**
* 更新人
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
ctrl+b查看具体的方法。
进入个人中心的时候需要能够查看当前用户信息
请求方式 |
请求地址 |
请求头 |
GET |
/user/userInfo |
需要token请求头 |
不需要参数
响应格式:
{
"code":200,
"data":{
"avatar":"https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fi0.hdslb.com%2Fbfs%2Farticle%2F3bf9c263bc0f2ac5c3a7feb9e218d07475573ec8.gi",
"email":"[email protected]",
"id":"1",
"nickName":"sg333",
"sex":"1"
},
"msg":"操作成功"
}
UserController
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/userInfo")
public ResponseResult userInfo(){
return userService.userInfo();
}
}
UserService增加方法定义
public interface UserService extends IService {
ResponseResult userInfo();
}
UserServiceImpl实现userInfo方法
@Override
public ResponseResult userInfo() {
//获取当前用户id
Long userId = SecurityUtils.getUserId();
//根据用户id查询用户信息
User user = getById(userId);
//封装成UserInfoVo
UserInfoVo vo = BeanCopyUtils.copyBean(user,UserInfoVo.class);
return ResponseResult.okResult(vo);
}
SecurityConfig配置该接口必须认证后才能访问
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/login").anonymous()
//注销接口需要认证才能访问
.antMatchers("/logout").authenticated()
//个人信息接口必须登录后才能访问
.antMatchers("/user/userInfo").authenticated()
// 除上面外的所有请求全部不需要认证即可访问
.anyRequest().permitAll();
//配置异常处理器
http.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
//关闭默认的注销功能
http.logout().disable();
//把jwtAuthenticationTokenFilter添加到SpringSecurity的过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//允许跨域
http.cors();
}
在个人中心点击编辑的时候可以上传头像图片。上传完头像后,可以用于更新个人信息接口。
因为如果把图片视频等文件上传到自己的应用的Web服务器,在读取图片的时候会占用比较多的资源。影响应用服务器的性能。
所以我们一般使用OSS(Object Storage Service对象存储服务)存储图片或视频。
秘钥
①添加依赖
com.qiniu
qiniu-java-sdk
[7.7.0, 7.7.99]
②复制修改案例代码
application.yml
oss:
accessKey: xxxx
secretKey: xxxx
bucket: sg-blog
OSSTest.java
这是参考官方的帮助文档写出来的Java的SDK
@SpringBootTest
@ConfigurationProperties(prefix = "oss")
public class OSSTest {
private String accessKey;
private String secretKey;
private String bucket;
public void setAccessKey(String accessKey) {
this.accessKey = accessKey;
}
public void setSecretKey(String secretKey) {
this.secretKey = secretKey;
}
public void setBucket(String bucket) {
this.bucket = bucket;
}
@Test
public void testOss(){
//构造一个带指定 Region 对象的配置类
Configuration cfg = new Configuration(Region.autoRegion());
//...其他参数参考类注释
UploadManager uploadManager = new UploadManager(cfg);
//...生成上传凭证,然后准备上传
// String accessKey = "your access key";
// String secretKey = "your secret key";
// String bucket = "sg-blog";
//默认不指定key的情况下,以文件内容的hash值作为文件名
String key = "2022/sg.png";
try {
// byte[] uploadBytes = "hello qiniu cloud".getBytes("utf-8");
// ByteArrayInputStream byteInputStream=new ByteArrayInputStream(uploadBytes);
InputStream inputStream = new FileInputStream("C:\\Users\\root\\Desktop\\Snipaste_2022-02-28_22-48-37.png");
Auth auth = Auth.create(accessKey, secretKey);
String upToken = auth.uploadToken(bucket);
try {
Response response = uploadManager.put(inputStream,key,upToken,null, null);
//解析上传成功的结果
DefaultPutRet putRet = new Gson().fromJson(response.bodyString(), DefaultPutRet.class);
System.out.println(putRet.key);
System.out.println(putRet.hash);
} catch (QiniuException ex) {
Response r = ex.response;
System.err.println(r.toString());
try {
System.err.println(r.bodyString());
} catch (QiniuException ex2) {
//ignore
}
}
} catch (Exception ex) {
//ignore
}
}
}
请求方式 |
请求地址 |
请求头 |
POST |
/upload |
需要token |
参数:
img,值为要上传的文件
请求头:
Content-Type :multipart/form-data;
响应格式:
{
"code": 200,
"data": "文件访问链接",
"msg": "操作成功"
}
补充知识点uuid,uuid其目的,是让分布式系统中的所有元素,都能有唯一的辨识信息,而不需要通过中央控制端来做辨识信息的指定。如此一来,每个人都可以创建不与其它人冲突的UUID。在这样的情况下,就不需考虑数据库创建时的名称重复问题。
@RestController
public class UploadController {
@Autowired
private UploadService uploadService;
@PostMapping("/upload")
public ResponseResult uploadImg(MultipartFile img){
return uploadService.uploadImg(img);
}
}
public interface UploadService {
ResponseResult uploadImg(MultipartFile img);
}
@Service
@Data
@ConfigurationProperties(prefix = "oss")
public class OssUploadService implements UploadService {
@Override
public ResponseResult uploadImg(MultipartFile img) {
//判断文件类型
//获取原始文件名
String originalFilename = img.getOriginalFilename();
//对原始文件名进行判断
if(!originalFilename.endsWith(".png")){
throw new SystemException(AppHttpCodeEnum.FILE_TYPE_ERROR);
}
//如果判断通过上传文件到OSS
String filePath = PathUtils.generateFilePath(originalFilename);
String url = uploadOss(img,filePath);// 2099/2/3/wqeqeqe.png
return ResponseResult.okResult(url);
}
private String accessKey;
private String secretKey;
private String bucket;
private String uploadOss(MultipartFile imgFile, String filePath){
//构造一个带指定 Region 对象的配置类
Configuration cfg = new Configuration(Region.autoRegion());
//...其他参数参考类注释
UploadManager uploadManager = new UploadManager(cfg);
//默认不指定key的情况下,以文件内容的hash值作为文件名
String key = filePath;
try {
InputStream inputStream = imgFile.getInputStream();
Auth auth = Auth.create(accessKey, secretKey);
String upToken = auth.uploadToken(bucket);
try {
Response response = uploadManager.put(inputStream,key,upToken,null, null);
//解析上传成功的结果
DefaultPutRet putRet = new Gson().fromJson(response.bodyString(), DefaultPutRet.class);
System.out.println(putRet.key);
System.out.println(putRet.hash);
return "http://r7yxkqloa.bkt.clouddn.com/"+key;
} catch (QiniuException ex) {
Response r = ex.response;
System.err.println(r.toString());
try {
System.err.println(r.bodyString());
} catch (QiniuException ex2) {
//ignore
}
}
} catch (Exception ex) {
//ignore
}
return "www";
}
}
PathUtils
public class PathUtils {
public static String generateFilePath(String fileName){
//根据日期生成路径 2022/1/15/
SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd/");
String datePath = sdf.format(new Date());
//uuid作为文件名
String uuid = UUID.randomUUID().toString().replaceAll("-", "");
//后缀和文件后缀一致
int index = fileName.lastIndexOf(".");
// test.jpg -> .jpg
String fileType = fileName.substring(index);
return new StringBuilder().append(datePath).append(uuid).append(fileType).toString();
}
}
在编辑完个人资料后点击保存会对个人资料进行更新。
请求方式 |
请求地址 |
请求头 |
PUT |
/user/userInfo |
需要token请求头 |
参数
请求体中json格式数据:
{
"avatar":"https://sg-blog-oss.oss-cn-beijing.aliyuncs.com/2022/01/31/948597e164614902ab1662ba8452e106.png",
"email":"[email protected]",
"id":"1",
"nickName":"sg333",
"sex":"1"
}
响应格式:
{
"code":200,
"msg":"操作成功"
}
UserController
@PutMapping("/userInfo")
public ResponseResult updateUserInfo(@RequestBody User user){
return userService.updateUserInfo(user);
}
UserService
ResponseResult updateUserInfo(User user);
UserServiceImpl
@Override
public ResponseResult updateUserInfo(User user) {
updateById(user);
return ResponseResult.okResult();
}
要求用户能够在注册界面完成用户的注册。要求用户名,昵称,邮箱不能和数据库中原有的数据重复。如果某项重复了注册失败并且要有对应的提示。并且要求用户名,密码,昵称,邮箱都不能为空。
注意:密码必须密文存储到数据库中。
请求方式 |
请求地址 |
请求头 |
POST |
/user/register |
不需要token请求头 |
参数
请求体中json格式数据:
{
"email": "string",
"nickName": "string",
"password": "string",
"userName": "string"
}
响应格式:
{
"code":200,
"msg":"操作成功"
}
UserController
@PostMapping("/register")
public ResponseResult register(@RequestBody User user){
return userService.register(user);
}
UserService
ResponseResult register(User user);
UserServiceImpl
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public ResponseResult register(User user) {
//对数据进行非空判断
if(!StringUtils.hasText(user.getUserName())){
throw new SystemException(AppHttpCodeEnum.USERNAME_NOT_NULL);
}
if(!StringUtils.hasText(user.getPassword())){
throw new SystemException(AppHttpCodeEnum.PASSWORD_NOT_NULL);
}
if(!StringUtils.hasText(user.getEmail())){
throw new SystemException(AppHttpCodeEnum.EMAIL_NOT_NULL);
}
if(!StringUtils.hasText(user.getNickName())){
throw new SystemException(AppHttpCodeEnum.NICKNAME_NOT_NULL);
}
//对数据进行是否存在的判断
if(userNameExist(user.getUserName())){
throw new SystemException(AppHttpCodeEnum.USERNAME_EXIST);
}
if(nickNameExist(user.getNickName())){
throw new SystemException(AppHttpCodeEnum.NICKNAME_EXIST);
}
//...
//对密码进行加密
String encodePassword = passwordEncoder.encode(user.getPassword());
user.setPassword(encodePassword);
//存入数据库
save(user);
return ResponseResult.okResult();
}
public enum AppHttpCodeEnum {
// 成功
SUCCESS(200,"操作成功"),
// 登录
NEED_LOGIN(401,"需要登录后操作"),
NO_OPERATOR_AUTH(403,"无权限操作"),
SYSTEM_ERROR(500,"出现错误"),
USERNAME_EXIST(501,"用户名已存在"),
PHONENUMBER_EXIST(502,"手机号已存在"), EMAIL_EXIST(503, "邮箱已存在"),
REQUIRE_USERNAME(504, "必需填写用户名"),
CONTENT_NOT_NULL(506, "评论内容不能为空"),
FILE_TYPE_ERROR(507, "文件类型错误,请上传png文件"),
USERNAME_NOT_NULL(508, "用户名不能为空"),
NICKNAME_NOT_NULL(509, "昵称不能为空"),
PASSWORD_NOT_NULL(510, "密码不能为空"),
EMAIL_NOT_NULL(511, "邮箱不能为空"),
NICKNAME_EXIST(512, "昵称已存在"),
LOGIN_ERROR(505,"用户名或密码错误");
int code;
String msg;
AppHttpCodeEnum(int code, String errorMessage){
this.code = code;
this.msg = errorMessage;
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
需要通过日志记录接口调用信息。便于后期调试排查。因为涉及到有很多接口都需要进行日志的记录。
接口被调用时日志打印格式如下:
相当于是对原有的功能进行增强。并且是批量的增强,这个时候就非常适合用AOP来进行实现。补充说明关于一些AOP的知识点
环绕增强
在实际开发中可能也不知道对应参数的信息,这个时候可以选择在方法内部打上断点,然后进行调试,获取参数的信息
可通过debug模式的此方式进行调试,查看对应函数的返回值
所以在使用环绕通知时,必须要执行pjp.proceed()方法;因为不执行的话,相当于只会执行环绕通知的方法,不会执行需要被增强的方法。。
注意:再使用了环绕通知之后,原被增强的方法的返回值是这里环绕通知方法中的返回值,所以这里需要返回ret,如果对ret进行更改就可以篡改返回值
在用户浏览博文时要实现对应博客浏览量的增加。
我们只需要在每次用户浏览博客时更新对应的浏览数即可。
但是如果直接操作博客表的浏览量的话,在并发量大的情况下会出现什么问题呢?
如何去优化呢?
①在应用启动时把博客的浏览量存储到redis中
②更新浏览量时去更新redis中的数据
③每隔10分钟把Redis中的浏览量更新到数据库中
④读取文章浏览量时从redis读取
如果希望在SpringBoot应用启动时进行一些初始化操作可以选择使用CommandLineRunner来进行处理。
我们只需要实现CommandLineRunner接口,并且把对应的bean注入容器。把相关初始化的代码重新到需要重新的方法中。
这样就会在应用启动的时候执行对应的代码。
@Component
public class TestRunner implements CommandLineRunner {
@Override
public void run(String... args) throws Exception {
System.out.println("程序初始化");
}
}
定时任务的实现方式有很多,比如XXL-Job等。但是其实核心功能和概念都是类似的,很多情况下只是调用的API不同而已。
这里就先用SpringBoot为我们提供的定时任务的API来实现一个简单的定时任务,让大家先对定时任务里面的一些核心概念有个大致的了解。
实现步骤
① 使用@EnableScheduling注解开启定时任务功能
我们可以在配置类上加上@EnableScheduling
@SpringBootApplication
@MapperScan("com.sangeng.mapper")
@EnableScheduling
public class SanGengBlogApplication {
public static void main(String[] args) {
SpringApplication.run(SanGengBlogApplication.class,args);
}
}
② 确定定时任务执行代码,并配置任务执行时间
使用@Scheduled注解标识需要定时执行的代码。注解的cron属性相当于是任务的执行时间。目前可以使用 0/5 * * * * ? 进行测试,代表从0秒开始,每隔5秒执行一次。
注意:对应的bean要注入容器,否则不会生效。
@Component
public class TestJob {
@Scheduled(cron = "0/5 * * * * ?")
public void testJob(){
//要执行的代码
System.out.println("定时任务执行了");
}
}
cron表达式是用来设置定时任务执行时间的表达式。
很多情况下我们可以用 : 在线Cron表达式生成器 来帮助我们理解cron表达式和书写cron表达式。
但是我们还是有需要学习对应的Cron语法的,这样可以更有利于我们书写Cron表达式。
如上我们用到的 0/5 * * * * ? *,cron表达式由七部分组成,中间由空格分隔,这七部分从左往右依次是:
秒(0~59),分钟(0~59),小时(0~23),日期(1-月最后一天),月份(1-12),星期几(1-7,1表示星期日),年份(一般该项不设置,直接忽略掉,即可为空值)
通用特殊字符:, - * / (可以在任意部分使用)
*
星号表示任意值,例如:
* * * * * ?
表示 “ 每年每月每天每时每分每秒 ” 。
,
可以用来定义列表,例如 :
1,2,3 * * * * ?
表示 “ 每年每月每天每时每分的每个第1秒,第2秒,第3秒 ” 。
-
定义范围,例如:
1-3 * * * * ?
表示 “ 每年每月每天每时每分的第1秒至第3秒 ”。
/
每隔多少,例如
5/10 * * * * ?
表示 “ 每年每月每天每时每分,从第5秒开始,每10秒一次 ” 。即 “ / ” 的左侧是开始值,右侧是间隔。如果是从 “ 0 ” 开始的话,也可以简写成 “ /10 ”
日期部分还可允许特殊字符: ? L W
星期部分还可允许的特殊字符: ? L #
?
只可用在日期和星期部分。表示没有具体的值,使用?要注意冲突。日期和星期两个部分如果其中一个部分设置了值,则另一个必须设置为 “ ? ”。
例如:
0\* * * 2 * ?
和
0\* * * ? * 2
同时使用?和同时不使用?都是不对的
例如下面写法就是错的
* * * 2 * 2
和
* * * ? * ?
W
只能用在日期中,表示当月中最接近某天的工作日
0 0 0 31W * ?
表示最接近31号的工作日,如果31号是星期六,则表示30号,即星期五,如果31号是星期天,则表示29号,即星期五。如果31号是星期三,则表示31号本身,即星期三。
L
表示最后(Last),只能用在日期和星期中
在日期中表示每月最后一天,在一月份中表示31号,在六月份中表示30号
也可以表示每月倒是第N天。例如: L-2表示每个月的倒数第2天
0 0 0 LW * ? LW可以连起来用,表示每月最后一个工作日,即每月最后一个星期五
在星期中表示7即星期六
0 0 0 ? * L
表示每个星期六
0 0 0 ? * 6L
若前面有其他值的话,则表示最后一个星期几,即每月的最后一个星期五
#
只能用在星期中,表示第几个星期几
0 0 0 ? * 6#3
表示每个月的第三个星期五。
请求方式 |
请求地址 |
请求头 |
PUT |
/article/updateViewCount/{id} |
不需要token请求头 |
参数
请求路径中携带文章id
响应格式:
{
"code":200,
"msg":"操作成功"
}
实现CommandLineRunner接口,在应用启动时初始化缓存。
@Component
public class ViewCountRunner implements CommandLineRunner {
@Autowired
private ArticleMapper articleMapper;
@Autowired
private RedisCache redisCache;
@Override
public void run(String... args) throws Exception {
//查询博客信息 id viewCount
List articles = articleMapper.selectList(null);
Map viewCountMap = articles.stream()
.collect(Collectors.toMap(article -> article.getId().toString(), article -> {
return article.getViewCount().intValue();//
}));
//存储到redis中
redisCache.setCacheMap("article:viewCount",viewCountMap);
}
}
RedisCache增加方法
public void incrementCacheMapValue(String key,String hKey,long v){
redisTemplate.boundHashOps(key).increment(hKey, v);
}
ArticleController中增加方法更新阅读数
@PutMapping("/updateViewCount/{id}")
public ResponseResult updateViewCount(@PathVariable("id") Long id){
return articleService.updateViewCount(id);
}
ArticleService中增加方法
ResponseResult updateViewCount(Long id);
ArticleServiceImpl中实现方法
@Override
public ResponseResult updateViewCount(Long id) {
//更新redis中对应 id的浏览量
redisCache.incrementCacheMapValue("article:viewCount",id.toString(),1);
return ResponseResult.okResult();
}
Article中增加构造方法
public Article(Long id, long viewCount) {
this.id = id;
this.viewCount = viewCount;
}
@Component
public class UpdateViewCountJob {
@Autowired
private RedisCache redisCache;
@Autowired
private ArticleService articleService;
@Scheduled(cron = "0/5 * * * * ?")
public void updateViewCount(){
//获取redis中的浏览量
Map viewCountMap = redisCache.getCacheMap("article:viewCount");
List articles = viewCountMap.entrySet()
.stream()
.map(entry -> new Article(Long.valueOf(entry.getKey()), entry.getValue().longValue()))
.collect(Collectors.toList());
//更新到数据库中
articleService.updateBatchById(articles);
}
}
@Override
public ResponseResult getArticleDetail(Long id) {
//根据id查询文章
Article article = getById(id);
//从redis中获取viewCount
Integer viewCount = redisCache.getCacheMapValue("article:viewCount", id.toString());
article.setViewCount(viewCount.longValue());
//转换成VO
ArticleDetailVo articleDetailVo = BeanCopyUtils.copyBean(article, ArticleDetailVo.class);
//根据分类id查询分类名
Long categoryId = articleDetailVo.getCategoryId();
Category category = categoryService.getById(categoryId);
if(category!=null){
articleDetailVo.setCategoryName(category.getName());
}
//封装响应返回
return ResponseResult.okResult(articleDetailVo);
}
Swagger 是一套基于 OpenAPI 规范构建的开源工具,可以帮助我们设计、构建、记录以及使用 Rest API。
当下很多公司都采取前后端分离的开发模式,前端和后端的工作由不同的工程师完成。在这种开发模式下,维持一份及时更新且完整的 Rest API 文档将会极大的提高我们的工作效率。传统意义上的文档都是后端开发人员手动编写的,相信大家也都知道这种方式很难保证文档的及时性,这种文档久而久之也就会失去其参考意义,反而还会加大我们的沟通成本。而 Swagger 给我们提供了一个全新的维护 API 文档的方式,下面我们就来了解一下它的优点:
1.代码变,文档变。只需要少量的注解,Swagger 就可以根据代码自动生成 API 文档,很好的保证了文档的时效性。2.跨语言性,支持 40 多种语言。3.Swagger UI 呈现出来的是一份可交互式的 API 文档,我们可以直接在文档页面尝试 API 的调用,省去了准备复杂的调用参数的过程。
io.springfox
springfox-swagger2
io.springfox
springfox-swagger-ui
在启动类上或者配置类加 @EnableSwagger2 注解
@SpringBootApplication
@MapperScan("com.sangeng.mapper")
@EnableScheduling
@EnableSwagger2
public class SanGengBlogApplication {
public static void main(String[] args) {
SpringApplication.run(SanGengBlogApplication.class,args);
}
}
访问:http://localhost:7777/swagger-ui.html 注意其中localhost和7777要调整成实际项目的域名和端口号。
属性介绍:
tags 设置标签
description 设置描述信息
@RestController
@RequestMapping("/comment")
@Api(tags = "评论",description = "评论相关接口")
public class CommentController {
}
@GetMapping("/linkCommentList")
@ApiOperation(value = "友链评论列表",notes = "获取一页友链评论")
public ResponseResult linkCommentList(Integer pageNum,Integer pageSize){
return commentService.commentList(SystemConstants.LINK_COMMENT,null,pageNum,pageSize);
}
@ApiImplicitParam 用于描述接口的参数,但是一个接口可能有多个参数,所以一般与 @ApiImplicitParams 组合使用。
@GetMapping("/linkCommentList")
@ApiOperation(value = "友链评论列表",notes = "获取一页友链评论")
@ApiImplicitParams({
@ApiImplicitParam(name = "pageNum",value = "页号"),
@ApiImplicitParam(name = "pageSize",value = "每页大小")
}
)
public ResponseResult linkCommentList(Integer pageNum,Integer pageSize){
return commentService.commentList(SystemConstants.LINK_COMMENT,null,pageNum,pageSize);
}
@ApiModel用于描述实体类。
@Data
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(description = "添加评论dto")
public class AddCommentDto{
//..
}
@ApiModelProperty用于描述实体的属性
@ApiModelProperty(notes = "评论类型(0代表文章评论,1代表友链评论)")
private String type;
@Configuration
public class SwaggerConfig {
@Bean
public Docket customDocket() {
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
.select()
.apis(RequestHandlerSelectors.basePackage("com.sangeng.controller"))
.build();
}
private ApiInfo apiInfo() {
Contact contact = new Contact("团队名", "http://www.my.com", "[email protected]");
return new ApiInfoBuilder()
.title("文档标题")
.description("文档描述")
.contact(contact) // 联系方式
.version("1.1.0") // 版本
.build();
}
}
(44条消息) knife4j:快速入门_林雨涵的博客-CSDN博客_knife4j也可以使用此进行配置
前端工程启动
npm install
npm run dev
①创建启动类
@SpringBootApplication
@MapperScan("com.sangeng.mapper")
public class BlogAdminApplication {
public static void main(String[] args) {
SpringApplication.run(BlogAdminApplication.class, args);
}
}
②创建application.yml配置文件
server:
port: 8989
spring:
datasource:
url: jdbc:mysql://localhost:3306/sg_blog?characterEncoding=utf-8&serverTimezone=UTC
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
servlet:
multipart:
max-file-size: 2MB
max-request-size: 5MB
mybatis-plus:
configuration:
# 日志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: delFlag
logic-delete-value: 1
logic-not-delete-value: 0
id-type: auto
③ SQL语句
SQL脚本:SGBlog\资源\SQL\sg_tag.sql
④ 创建实体类,Mapper,Service
注意思考这些文件应该写在哪个模块下?sangeng-framework
Tag
@SuppressWarnings("serial")
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("sg_tag")
public class Tag {
@TableId
private Long id;
private Long createBy;
private Date createTime;
private Long updateBy;
private Date updateTime;
//删除标志(0代表未删除,1代表已删除)
private Integer delFlag;
//备注
private String remark;
//标签名
private String name;
}
TagMapper
public interface TagMapper extends BaseMapper {
}
TagService
public interface TagService extends IService {
}
TagServiceImpl
@Service("tagService")
public class TagServiceImpl extends ServiceImpl implements TagService {
}
⑤ 创建Controller测试接口
注意思考这些文件应该写在哪个模块下?
TagController /content/tag
@RestController
@RequestMapping("/content/tag")
public class TagController {
@Autowired
private TagService tagService;
@GetMapping("/list")
public ResponseResult list(){
return ResponseResult.okResult(tagService.list());
}
}
⑥添加security相关类
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Autowired
AuthenticationEntryPoint authenticationEntryPoint;
@Autowired
AccessDeniedHandler accessDeniedHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
// .antMatchers("/login").anonymous()
// //注销接口需要认证才能访问
// .antMatchers("/logout").authenticated()
// .antMatchers("/user/userInfo").authenticated()
// .antMatchers("/upload").authenticated()
// 除上面外的所有请求全部不需要认证即可访问
.anyRequest().permitAll();
//配置异常处理器
http.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
//关闭默认的注销功能
http.logout().disable();
//把jwtAuthenticationTokenFilter添加到SpringSecurity的过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//允许跨域
http.cors();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private RedisCache redisCache;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//获取请求头中的token
String token = request.getHeader("token");
if(!StringUtils.hasText(token)){
//说明该接口不需要登录 直接放行
filterChain.doFilter(request, response);
return;
}
//解析获取userid
Claims claims = null;
try {
claims = JwtUtil.parseJWT(token);
} catch (Exception e) {
e.printStackTrace();
//token超时 token非法
//响应告诉前端需要重新登录
ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
WebUtils.renderString(response, JSON.toJSONString(result));
return;
}
String userId = claims.getSubject();
//从redis中获取用户信息
LoginUser loginUser = redisCache.getCacheObject("login:" + userId);
//如果获取不到
if(Objects.isNull(loginUser)){
//说明登录过期 提示重新登录
ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
WebUtils.renderString(response, JSON.toJSONString(result));
return;
}
//存入SecurityContextHolder
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser,null,null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
}
}
后台的认证授权也使用SpringSecurity安全框架来实现。
需要实现登录功能
后台所有功能都必须登录才能使用。
请求方式 |
请求路径 |
POST |
/user/login |
请求体:
{
"userName":"sg",
"password":"1234"
}
响应格式:
{
"code": 200,
"data": {
"token": "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI0ODBmOThmYmJkNmI0NjM0OWUyZjY2NTM0NGNjZWY2NSIsInN1YiI6IjEiLCJpc3MiOiJzZyIsImlhdCI6MTY0Mzg3NDMxNiwiZXhwIjoxNjQzOTYwNzE2fQ.ldLBUvNIxQCGemkCoMgT_0YsjsWndTg5tqfJb77pabk"
},
"msg": "操作成功"
}
登录
①自定义登录接口
调用ProviderManager的方法进行认证 如果认证通过生成jwt
把用户信息存入redis中
②自定义UserDetailsService
在这个实现类中去查询数据库
注意配置passwordEncoder为BCryptPasswordEncoder
校验:
①定义Jwt认证过滤器
获取token
解析token获取其中的userid
从redis中获取用户信息
存入SecurityContextHolder
①添加依赖
前面已经添加过相关依赖,不需要做什么处理
org.springframework.boot
spring-boot-starter-data-redis
com.alibaba
fastjson
1.2.33
io.jsonwebtoken
jjwt
0.9.0
复制一份BlogLoginController ,命名为LoginController,其中注入 LoginService
请求地址修改为/user/login即可
@RestController
public class LoginController {
@Autowired
private LoginService loginService;
@PostMapping("/user/login")
public ResponseResult login(@RequestBody User user){
if(!StringUtils.hasText(user.getUserName())){
//提示 必须要传用户名
throw new SystemException(AppHttpCodeEnum.REQUIRE_USERNAME);
}
return loginService.login(user);
}
}
复制一份BlogLoginService命名为LoginService即可
public interface LoginService {
ResponseResult login(User user);
}
之前已经复制过了
复制一份,LoginServiceImpl,命名为SystemLoginServiceImpl 实现 LoginService
login方法中存redis的key的前缀修改为login
返回的数据中只要返回token
@Service
public class SystemLoginServiceImpl implements LoginService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisCache redisCache;
@Override
public ResponseResult login(User user) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
//判断是否认证通过
if(Objects.isNull(authenticate)){
throw new RuntimeException("用户名或密码错误");
}
//获取userid 生成token
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
String userId = loginUser.getUser().getId().toString();
String jwt = JwtUtil.createJWT(userId);
//把用户信息存入redis
redisCache.setCacheObject("login:"+userId,loginUser);
//把token封装 返回
Map map = new HashMap<>();
map.put("token",jwt);
return ResponseResult.okResult(map);
}
}
复用原来的即可
复用原来的即可
后台系统需要能实现不同的用户权限可以看到不同的功能。
用户只能使用他的权限所允许使用的功能。
之前在我的SpringSecurity的课程中就介绍过RBAC权限模型。没有学习过的可以去看下 RBAC权限模型 。这里我们就是在RBAC权限模型的基础上去实现这个功能。
通过需求去分析需要有哪些字段。
建表SQL及初始化数据见:SGBlog\资源\SQL\sg_menu.sql
是
请求方式 |
请求地址 |
请求头 |
GET |
/getInfo |
需要token请求头 |
请求参数:
无
响应格式:
如果用户id为1代表管理员,roles 中只需要有admin,permissions中需要有所有菜单类型为C或者F的,状态为正常的,未被删除的权限
{
"code":200,
"data":{
"permissions":[
"system:user:list",
"system:role:list",
"system:menu:list",
"system:user:query",
"system:user:add"
//此次省略1000字
],
"roles":[
"admin"
],
"user":{
"avatar":"http://r7yxkqloa.bkt.clouddn.com/2022/03/05/75fd15587811443a9a9a771f24da458d.png",
"email":"[email protected]",
"id":1,
"nickName":"sg3334",
"sex":"1"
}
},
"msg":"操作成功"
}
请求方式 |
请求地址 |
请求头 |
GET |
/getRouters |
需要token请求头 |
请求参数:
无
响应格式:
前端为了实现动态路由的效果,需要后端有接口能返回用户所能访问的菜单数据。
注意:返回的菜单数据需要体现父子菜单的层级关系
如果用户id为1代表管理员,menus中需要有所有菜单类型为C或者M的,状态为正常的,未被删除的权限
数据格式如下:
{
"code":200,
"data":{
"menus":[
{
"children":[],
"component":"content/article/write/index",
"createTime":"2022-01-08 11:39:58",
"icon":"build",
"id":2023,
"menuName":"写博文",
"menuType":"C",
"orderNum":"0",
"parentId":0,
"path":"write",
"perms":"content:article:writer",
"status":"0",
"visible":"0"
},
{
"children":[
{
"children":[],
"component":"system/user/index",
"createTime":"2021-11-12 18:46:19",
"icon":"user",
"id":100,
"menuName":"用户管理",
"menuType":"C",
"orderNum":"1",
"parentId":1,
"path":"user",
"perms":"system:user:list",
"status":"0",
"visible":"0"
},
{
"children":[],
"component":"system/role/index",
"createTime":"2021-11-12 18:46:19",
"icon":"peoples",
"id":101,
"menuName":"角色管理",
"menuType":"C",
"orderNum":"2",
"parentId":1,
"path":"role",
"perms":"system:role:list",
"status":"0",
"visible":"0"
},
{
"children":[],
"component":"system/menu/index",
"createTime":"2021-11-12 18:46:19",
"icon":"tree-table",
"id":102,
"menuName":"菜单管理",
"menuType":"C",
"orderNum":"3",
"parentId":1,
"path":"menu",
"perms":"system:menu:list",
"status":"0",
"visible":"0"
}
],
"createTime":"2021-11-12 18:46:19",
"icon":"system",
"id":1,
"menuName":"系统管理",
"menuType":"M",
"orderNum":"1",
"parentId":0,
"path":"system",
"perms":"",
"status":"0",
"visible":"0"
}
]
},
"msg":"操作成功"
}
生成menu和role表对于的类
@Data
@Accessors(chain = true)
@AllArgsConstructor
@NoArgsConstructor
public class AdminUserInfoVo {
private List permissions;
private List roles;
private UserInfoVo user;
}
@RestController
public class LoginController {
@Autowired
private LoginService loginService;
@Autowired
private MenuService menuService;
@Autowired
private RoleService roleService;
@PostMapping("/user/login")
public ResponseResult login(@RequestBody User user){
if(!StringUtils.hasText(user.getUserName())){
//提示 必须要传用户名
throw new SystemException(AppHttpCodeEnum.REQUIRE_USERNAME);
}
return loginService.login(user);
}
@GetMapping("getInfo")
public ResponseResult getInfo(){
//获取当前登录的用户
LoginUser loginUser = SecurityUtils.getLoginUser();
//根据用户id查询权限信息
List perms = menuService.selectPermsByUserId(loginUser.getUser().getId());
//根据用户id查询角色信息
List roleKeyList = roleService.selectRoleKeyByUserId(loginUser.getUser().getId());
//获取用户信息
User user = loginUser.getUser();
UserInfoVo userInfoVo = BeanCopyUtils.copyBean(user, UserInfoVo.class);
//封装数据返回
AdminUserInfoVo adminUserInfoVo = new AdminUserInfoVo(perms,roleKeyList,userInfoVo);
return ResponseResult.okResult(adminUserInfoVo);
}
}
MenuServiceImpl selectPermsByUserId方法
@Service("menuService")
public class MenuServiceImpl extends ServiceImpl implements MenuService {
@Override
public List selectPermsByUserId(Long id) {
//如果是管理员,返回所有的权限
if(id == 1L){
LambdaQueryWrapper
MenuMapper
public interface MenuMapper extends BaseMapper {
List selectPermsByUserId(Long userId);
}
RoleServiceImpl selectRoleKeyByUserId方法
@Service("roleService")
public class RoleServiceImpl extends ServiceImpl implements RoleService {
@Override
public List selectRoleKeyByUserId(Long id) {
//判断是否是管理员 如果是返回集合中只需要有admin
if(id == 1L){
List roleKeys = new ArrayList<>();
roleKeys.add("admin");
return roleKeys;
}
//否则查询用户所具有的角色信息
return getBaseMapper().selectRoleKeyByUserId(id);
}
}
public interface RoleMapper extends BaseMapper {
List selectRoleKeyByUserId(Long userId);
}
@TableField(exit=false),表示该属性在数据库中并不存在,查找数据库时不用查找此属性,这就体现了建vo的好处,不然修改属性会导致查数据库时出错,或者加上此注解
查询的列:
SELECT DISTINCT m.id, m.parent_id, m.menu_name, m.path, m.component, m.visible, m.status, IFNULL(m.perms,'') AS perms, m.is_frame, m.menu_type, m.icon, m.order_num, m.create_time
注意需要按照parent_id和order_num排序
请求方式 |
请求地址 |
请求头 |
POST |
/user/logout |
需要token请求头 |
响应格式:
{
"code": 200,
"msg": "操作成功"
}
要实现的操作:
删除redis中的用户信息
LoginController
@PostMapping("/user/logout")
public ResponseResult logout(){
return loginServcie.logout();
}
LoginService
ResponseResult logout();
SystemLoginServiceImpl
@Override
public ResponseResult logout() {
//获取当前登录的用户id
Long userId = SecurityUtils.getUserId();
//删除redis中对应的值
redisCache.deleteObject("login:"+userId);
return ResponseResult.okResult();
}
SecurityConfig
要关闭默认的退出登录功能。并且要配置我们的退出登录接口需要认证才能访问
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
// //注销接口需要认证才能访问
// .antMatchers("/logout").authenticated()
// .antMatchers("/user/userInfo").authenticated()
// .antMatchers("/upload").authenticated()
// 除上面外的所有请求全部不需要认证即可访问
.anyRequest().authenticated();
//配置异常处理器
http.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
//关闭默认的注销功能
http.logout().disable();
//把jwtAuthenticationTokenFilter添加到SpringSecurity的过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
//允许跨域
http.cors();
}
为了方便后期对文章进行管理,需要提供标签的功能,一个文章可以有多个标签。
在后台需要分页查询标签功能,要求能根据标签名进行分页查询。 后期可能会增加备注查询等需求。
注意:不能把删除了的标签查询出来。
通过需求去分析需要有哪些字段。
请求方式 |
请求路径 |
Get |
content/tag/list |
Query格式请求参数:
pageNum: 页码
pageSize: 每页条数
name:标签名
remark:备注
响应格式:
{
"code":200,
"data":{
"rows":[
{
"id":4,
"name":"Java",
"remark":"sdad"
}
],
"total":1
},
"msg":"操作成功"
}