基于SpringBoot的哈士奇博客项目(四)

4、后台管理

可以说是若依框架吧

4.1、准备工作

同理前台博客

创建启动类

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.hashiqi.mapper")
@EnableSwagger2
public class AdminApplication {
    public static void main(String[] args) {
        SpringApplication.run(AdminApplication.class, args);
    }
}

创建yml配置文件

server:
  port: 8989
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/hashiqi_blog?characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
  servlet:
    multipart:
      max-file-size: 5MB
      max-request-size: 10MB
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      logic-delete-field: isDeleted
      logic-delete-value: 1
      logic-not-delete-value: 0
      id-type: auto

SQL表

创建标签表

DROP TABLE IF EXISTS `tb_tag`;
CREATE TABLE `tb_tag`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '标签名',
  `create_by` bigint(20) NULL DEFAULT NULL,
  `create_time` datetime(0) NULL DEFAULT NULL,
  `update_by` bigint(20) NULL DEFAULT NULL,
  `update_time` datetime(0) NULL DEFAULT NULL,
  `is_deleted` int(1) NULL DEFAULT 0 COMMENT '删除标志(0代表未删除,1代表已删除)',
  `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 14 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '标签' ROW_FORMAT = Dynamic;

Tag

import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import java.io.Serializable;
import java.util.Date;

import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;

@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_tag")
public class Tag implements Serializable {

    private static final long serialVersionUID=1L;

    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /**
     * 标签名
     */
    private String name;

    private Long createBy;

    private Date createTime;

    private Long updateBy;

    private Date updateTime;

    /**
     * 删除标志(0代表未删除,1代表已删除)
     */
    private Integer isDeleted;

    /**
     * 备注
     */
    private String remark;
}

TagService

import com.hashiqi.domain.entity.Tag;
import com.baomidou.mybatisplus.extension.service.IService;

public interface TagService extends IService<Tag> {

}

TagServiceImpl

import com.hashiqi.domain.entity.Tag;
import com.hashiqi.mapper.TagMapper;
import com.hashiqi.service.TagService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;

@Service
public class TagServiceImpl extends ServiceImpl<TagMapper, Tag> implements TagService {

}

后台管理模块中的TagController

import com.hashiqi.domain.ResponseResult;
import com.hashiqi.service.TagService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/content/tag")
@Api(tags = "标签接口管理", value = "标签相关接口")
public class TagController {

    @Autowired
    private TagService tagService;

    @GetMapping("/list")
    @ApiOperation(value = "获取所有的标签")
    public ResponseResult list() {
        return ResponseResult.okResult(tagService.list());
    }
}

Security相关配置

config包

import com.hashiqi.filter.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Autowired
    private AuthenticationEntryPoint authenticationEntryPoint;

    @Autowired
    private AccessDeniedHandler accessDeniedHandler;

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf,因为如果是分布式系统,则不适合session
                .csrf().disable()
                // 不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口,允许匿名访问【即未登录状态下可以访问 .permitAll()代表都可以访问】
//                .antMatchers("/login").anonymous()
//                // 注销接口需要认证才能访问
//                .antMatchers("/logout").authenticated()
//                // 个人信息接口必须登录后才可访问
//                .antMatchers("/user/userInfo").authenticated()
                // 出上面外的所有请求全部不需要认证即可访问
                .anyRequest().permitAll();

        // 关闭默认的注销功能
        http.logout().disable();
        // 允许跨域
        http.cors();
        // 将token检验过滤器放在用户名和密码校验之前
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // 配置认证和授权失败处理器
        http.exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler);
    }
}

filter包

import com.alibaba.fastjson.JSON;
import com.hashiqi.constants.RedisKeyConstants;
import com.hashiqi.constants.SystemConstants;
import com.hashiqi.domain.ResponseResult;
import com.hashiqi.domain.entity.LoginUser;
import com.hashiqi.enums.AppHttpCodeEnum;
import com.hashiqi.utils.JwtUtil;
import com.hashiqi.utils.RedisCache;
import com.hashiqi.utils.WebUtils;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;

@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(SystemConstants.TOKEN);
        if (!StringUtils.hasText(token)) {
            // 说明该接口无需token,直接放行即可
            filterChain.doFilter(request, response);
            return;
        }
        // 解析获取userId
        Claims claims = null;
        try {
            claims = JwtUtil.parseJWT(token);
        } catch (Exception e) {
            // token超时
            // token非法请求
            e.printStackTrace();
            // 响应前端,需重新进行登录
            ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
            WebUtils.renderString(response, JSON.toJSONString(result));
            return;
        }
        String userId = claims.getSubject();
        // 从redis中获取用户信息
        LoginUser loginUser = redisCache.getCacheObject(RedisKeyConstants.ADMIN_LOGIN_ID + userId);
        // redis过期
        if (Objects.isNull(loginUser)) {
            // 说明redis过期,提示重新登录
            ResponseResult result = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
            WebUtils.renderString(response, JSON.toJSONString(result));
            return;
        }
        // 存入SecurityContextHolder
        // TODO 现阶段未有权限信息
        UsernamePasswordAuthenticationToken authenticationToken
                = new UsernamePasswordAuthenticationToken(loginUser, null, null);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request, response);
    }
}

RedisKeyConstants

/**
 * 后台管理用户登录key前缀
 */
public static final String ADMIN_LOGIN_ID = "admin:login:";

4.2、后台登录

后台的认证授权也使用SpringSecurity安全框架来实现。

4.2.1、需求分析

需要实现登录功能;

后台所有功能都必须登录才能使用。

4.2.2、接口设计

请求方式请求路径
POST/user/login

请求体:

{
    "userName":"hashiqi",
    "password":"123456"
}

响应格式:

{
    "code": 200,
    "data": {
        "token": "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI0ODBmOThmYmJkNmI0NjM0OWUyZjY2NTM0NGNjZWY2NSIsInN1YiI6IjEiLCJpc3MiOiJzZyIsImlhdCI6MTY0Mzg3NDMxNiwiZXhwIjoxNjQzOTYwNzE2fQ.XASDASDSADWAD545S4AD"
    },
    "msg": "操作成功"
}

4.2.3、思路分析

登录

  • 自定义登录接口

    • 调用ProviderManager的方法进行认证 如果认证通过生成jwt

    • 把用户信息存入redis中

  • 自定义UserDetailsService

    • 在这个实现类中去查询数据库,注意配置passwordEncoder为BCryptPasswordEncoder。

检验

  • 定义jwt认证过滤器

    • 获取token

    • 解析token获取其中的userId

    • 从redis中获取用户信息

    • 存入SecurityContextHolder

4.2.4、准备工作

添加相应依赖

在公共子模块中已经存在,并且已经将该模块引入后台管理模块中。

<!-- redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- fastjson -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
</dependency>
<!-- jwt -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
</dependency>

4.2.5、登录接口代码实现

将前台博客模块中的BlogLoginController复制一份,命名为AdminLoginController,将LoginService进行注入。修改其请求路径地址。

AdminLoginController

import com.hashiqi.domain.ResponseResult;
import com.hashiqi.domain.entity.User;
import com.hashiqi.enums.AppHttpCodeEnum;
import com.hashiqi.exception.SystemException;
import com.hashiqi.service.AdminLoginService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * 后台登录接口
 */
@RestController
@Api(tags = "后台登录接口管理")
@RequestMapping("/user")
public class AdminLoginController {

    @Autowired
    private AdminLoginService adminLoginService;

    // 登录
    @PostMapping("/login")
    @ApiOperation(value = "登录")
    public ResponseResult login(@RequestBody User user) {
        if (!StringUtils.hasText(user.getUserName())) {
            throw new SystemException(AppHttpCodeEnum.REQUIRE_USERNAME);
        }
        return adminLoginService.login(user);
    }
}

复制BlogLoginService

import com.hashiqi.domain.ResponseResult;
import com.hashiqi.domain.entity.User;

public interface AdminLoginService {

    // 登录
    ResponseResult login(User user);
}

复制一份BlogServiceImpl,实现AdminService

import com.hashiqi.constants.RedisKeyConstants;
import com.hashiqi.constants.SystemConstants;
import com.hashiqi.domain.ResponseResult;
import com.hashiqi.domain.entity.LoginUser;
import com.hashiqi.domain.entity.User;
import com.hashiqi.service.AdminLoginService;
import com.hashiqi.utils.JwtUtil;
import com.hashiqi.utils.RedisCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Objects;

@Service
public class AdminLoginServiceImpl implements AdminLoginService {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisCache redisCache;

    /**
     * 登录
     * @param user 用户
     * @return 返回结果集
     */
    @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(RedisKeyConstants.ADMIN_LOGIN_ID + userId, loginUser);
        // 把token封装并返回
        Map<String, String> map =  new HashMap<>();
        map.put(SystemConstants.TOKEN, jwt);
        return ResponseResult.okResult(map);
    }
}

UserDetailServiceImpl

复制前台博客模块中的即可,但后面会有权限加入。

LoginUser

同上理

测试

要将SecurityConfig类中修改代码

.anyRequest().authenticated();

4.3、后台权限控制以及动态路由

4.3.1、需求分析

后台系统需要能实现不同的用户权限可以看到不同的功能。

用户只能使用他的权限所允许使用的功能。

4.3.2、功能设计

基于RBAC权限模型去实现这个功能。

4.3.3、数据库表分析

通过需求去分析需要有哪些字段。

创建菜单表

DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '菜单ID',
  `menu_name` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '菜单名称',
  `parent_id` bigint(20) NULL DEFAULT 0 COMMENT '父菜单ID',
  `order_num` int(4) NULL DEFAULT 0 COMMENT '显示顺序',
  `path` varchar(200) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '路由地址',
  `component` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '组件路径',
  `is_frame` int(1) NULL DEFAULT 1 COMMENT '是否为外链(0是 1否)',
  `menu_type` char(1) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '菜单类型(M目录 C菜单 F按钮)',
  `visible` char(1) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '0' COMMENT '菜单状态(0显示 1隐藏)',
  `status` char(1) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '0' COMMENT '菜单状态(0正常 1停用)',
  `perms` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '权限标识',
  `icon` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '#' COMMENT '菜单图标',
  `create_by` bigint(20) NULL DEFAULT NULL COMMENT '创建者',
  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
  `update_by` bigint(20) NULL DEFAULT NULL COMMENT '更新者',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
  `remark` varchar(500) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '' COMMENT '备注',
  `is_deleted` char(1) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT '0',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2031 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '菜单权限表' ROW_FORMAT = Dynamic;

创建角色表

CREATE TABLE `sys_role` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
  `role_name` varchar(30) NOT NULL COMMENT '角色名称',
  `role_key` varchar(100) NOT NULL COMMENT '角色权限字符串',
  `role_sort` int(4) NOT NULL COMMENT '显示顺序',
  `status` char(1) NOT NULL COMMENT '角色状态(0正常 1停用)',
  `is_deleted` char(1) DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)',
  `create_by` bigint(20) DEFAULT NULL COMMENT '创建者',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_by` bigint(20) DEFAULT NULL COMMENT '更新者',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8 COMMENT='角色信息表';

创建角色菜单关联表

CREATE TABLE `sys_role_menu` (
  `role_id` bigint(20) NOT NULL COMMENT '角色ID',
  `menu_id` bigint(20) NOT NULL COMMENT '菜单ID',
  PRIMARY KEY (`role_id`,`menu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='角色和菜单关联表';

创建用户角色关联表

CREATE TABLE `sys_user_role` (
  `user_id` bigint(20) NOT NULL COMMENT '用户ID',
  `role_id` bigint(20) NOT NULL COMMENT '角色ID',
  PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户和角色关联表';

4.3.4、接口设计

4.3.4.1、getInfo接口设计
请求方式请求地址请求头
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"
            //...
		],
		"roles":[
			"admin"
		],
		"user":{
			"avatar":"http:/xxx.clouddn.com/2022/03/05/75fd15587811443a9a9a771f24da458d.png",
			"email":"545487956@qq.com",
			"id":1,
			"nickName":"xxx",
			"sex":"1"
		}
	},
	"msg":"操作成功"
}

4.3.4.2、getRouters接口
请求方式请求地址请求头
GET/getRouters需要token请求头

请求参数:无

响应格式:

前端为了实现动态路由的效果,需要后端有接口能返回用户所能访问的菜单数据。

注意:返回的菜单数据需要体现父子菜单的层级关系

如果用户id为1代表管理员,menus中需要有所有菜单类型为C或者M的,状态为正常的,未被删除的权限。

扩展

“C” 可能表示 “Category”(分类)的缩写。在这种情况下,菜单类型为 “C” 可能指示该菜单项是一个分类或者是一个父菜单,包含其他子菜单项。

“M” 可能表示 “Menu”(菜单)的缩写。在这种情况下,菜单类型为 “M” 可能指示该菜单项是一个实际的菜单项,通常与其他父菜单或分类关联。

4.3.5、代码实现

4.3.5.1、准备工作

生成menu和role表对应的类

4.3.5.2、getInfo接口实现

AdminUserInfoVO

import com.hashiqi.domain.vo.UserInfoVO;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;

import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
public class AdminUserInfoVO {

    private List<String> permissions;

    private List<String> roles;

    private UserInfoVO user;
}

AdminLoginController

import com.hashiqi.domain.ResponseResult;
import com.hashiqi.domain.entity.LoginUser;
import com.hashiqi.domain.entity.User;
import com.hashiqi.domain.vo.UserInfoVO;
import com.hashiqi.entity.vo.AdminUserInfoVO;
import com.hashiqi.enums.AppHttpCodeEnum;
import com.hashiqi.exception.SystemException;
import com.hashiqi.service.AdminLoginService;
import com.hashiqi.service.MenuService;
import com.hashiqi.service.RoleService;
import com.hashiqi.utils.BeanCopyUtils;
import com.hashiqi.utils.SecurityUtils;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * 后台登录接口
 */
@RestController
@Api(tags = "后台登录接口管理")
@RequestMapping("/user")
public class AdminLoginController {

    @Autowired
    private AdminLoginService adminLoginService;

    @Autowired
    private MenuService menuService;

    @Autowired
    private RoleService roleService;

    // 登录
    @PostMapping("/login")
    @ApiOperation(value = "登录")
    public ResponseResult login(@RequestBody User user) {
        if (!StringUtils.hasText(user.getUserName())) {
            throw new SystemException(AppHttpCodeEnum.REQUIRE_USERNAME);
        }
        return adminLoginService.login(user);
    }

    @GetMapping("/getInfo")
    @ApiOperation(value = "获取用户信息")
    public ResponseResult<AdminUserInfoVO> getInfo() {
        // 获取当前登录的用户
        LoginUser loginUser = SecurityUtils.getLoginUser();
        // 获取用户信息
        User user = loginUser.getUser();
        Long userId = user.getId();
        // 根据用户id查询权限信息
        List<String> perms =  menuService.selectPermsByUserId(userId);
        // 根据用户id查询角色信息
        List<String> roleKeyList = roleService.selectRoleKeyByUserId(userId);
        // 将User转换成UserInfoVO
        UserInfoVO userInfoVO = BeanCopyUtils.copyBean(user, UserInfoVO.class);
        // 封装数据并返回
        AdminUserInfoVO adminUserInfoVO = new AdminUserInfoVO(perms, roleKeyList, userInfoVO);
        return ResponseResult.okResult(adminUserInfoVO);
    }
}

SystemConstants

/**
 * 用户类型为超级管理员,指定用户id为1
 */
public static final Long BLOG_SUPER_ADMIN_ID = 1L;
/**
 * 用户类型为管理员
 */
public static final Long BLOG_ADMIN = 1L;
/**
 * 用户类型为普通用户
 */
public static final Long BLOG_USER = 0L;
/**
 * 菜单标识
 */
public static final String MENU = "C";
/**
 * 按钮标识
 */
public static final String BUTTON = "F";
/**
 * 角色类型为管理员
 */
public static final String BLOG_ADMIN_REMARK = "admin";

RoleService

import com.hashiqi.entity.Role;
import com.baomidou.mybatisplus.extension.service.IService;

import java.util.List;

public interface RoleService extends IService<Role> {

    // 根据用户id查询角色信息
    List<String> selectRoleKeyByUserId(Long userId);
}

RoleServiceImpl

import com.hashiqi.constants.SystemConstants;
import com.hashiqi.entity.Role;
import com.hashiqi.mapper.RoleMapper;
import com.hashiqi.service.RoleService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements RoleService {

    /**
     * 根据用户id查询角色信息
     * @param userId 用户id
     * @return 角色集合
     */
    @Override
    public List<String> selectRoleKeyByUserId(Long userId) {
        List<String> roleKeys = new ArrayList<>();
        // 判断是否是管理员,如果是返回集合中需要有admin
        if (SystemConstants.BLOG_SUPER_ADMIN_ID.equals(userId)) {
            roleKeys.add(SystemConstants.BLOG_ADMIN_REMARK);
            return roleKeys;
        }
        // 否,则查询用户所对应的角色信息
        roleKeys = getBaseMapper().selectRoleKeyByUserId(userId);
        return roleKeys;
    }
}

RoleMapper

import com.hashiqi.entity.Role;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;

import java.util.List;

public interface RoleMapper extends BaseMapper<Role> {

    // 根据用户id查询角色信息
    List<String> selectRoleKeyByUserId(Long userId);
}

RoleMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.hashiqi.mapper.RoleMapper">
    <!-- 根据用户id查询角色信息 -->
    <select id="selectRoleKeyByUserId" resultType="java.lang.String">
        SELECT
            r.role_key
        FROM
            hashiqi_blog.sys_user_role ur
        LEFT JOIN
            hashiqi_blog.sys_role r ON ur.role_id = r.id
        WHERE
            ur.user_id = #{userId}
        AND
            r.status = 0
        AND
            r.is_deleted = 0
    </select>
</mapper>

MenuService

import com.hashiqi.entity.Menu;
import com.baomidou.mybatisplus.extension.service.IService;

import java.util.List;

public interface MenuService extends IService<Menu> {

    // 根据用户id查询权限信息
    List<String> selectPermsByUserId(Long userId);
}

MenuServiceImpl

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.hashiqi.constants.SystemConstants;
import com.hashiqi.entity.Menu;
import com.hashiqi.mapper.MenuMapper;
import com.hashiqi.service.MenuService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements MenuService {

    /**
     * 根据用户id查询权限信息
     * @param userId 用户id
     * @return 菜单集合
     */
    @Override
    public List<String> selectPermsByUserId(Long userId) {
        // 如果是超级管理员,返回所有权限
        if (SystemConstants.BLOG_SUPER_ADMIN_ID.equals(userId)) {
            List<Menu> menus = this.list(new LambdaQueryWrapper<Menu>()
                    .in(Menu::getMenuType, SystemConstants.MENU, SystemConstants.BUTTON)
                    .eq(Menu::getStatus, SystemConstants.STATUS_NORMAL));
            return menus.stream().map(Menu::getPerms).collect(Collectors.toList());
        }
        // 如果不是,返回对应的权限
        return getBaseMapper().selectPermsByUserId(userId);
    }
}

MenuMapper

import com.hashiqi.entity.Menu;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;

import java.util.List;

public interface MenuMapper extends BaseMapper<Menu> {

    // 根据用户id查询权限信息
    List<String> selectPermsByUserId(Long userId);
}

MenuMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.hashiqi.mapper.MenuMapper">
    <!-- 根据用户id查询权限信息 -->
    <select id="selectPermsByUserId" resultType="java.lang.String">
        SELECT
            DISTINCT m.perms
        FROM
            hashiqi_blog.sys_user_role ur
        LEFT JOIN
            hashiqi_blog.sys_role_menu rm ON ur.role_id = rm.role_id
        LEFT JOIN
            hashiqi_blog.sys_menu m ON m.id = rm.menu_id
        WHERE
            ur.user_id = #{userId}
        AND
            m.menu_type IN ('C', 'F')
        AND
            m.status = 0
        AND
            m.is_deleted = 0
    </select>
</mapper>

4.3.5.3、getRouters接口实现

AdminLoginController

@GetMapping("/getRouters")
@ApiOperation(value = "获取菜单路由树结构")
public ResponseResult<RouterVO> getRouters() {
    Long userId = SecurityUtils.getUserId();
    // 查询menu,封装成tree结构,并返回
    List<Menu> menus = menuService.selectRouterMenuTreeByUserId(userId);
    return ResponseResult.okResult(new RouterVO(menus));
}

MenuService

// 获取菜单路由树结构
List<Menu> selectRouterMenuTreeByUserId(Long userId);

MenuServiceImpl

/**
 * 获取菜单路由树结构
 * @param userId 用户id
 * @return 菜单路由树结构
 */
@Override
public List<Menu> selectRouterMenuTreeByUserId(Long userId) {
    List<Menu> menus;
    // 判断是否是超级管理员
    if (SecurityUtils.isAdmin()) {
        // 如果是,获取所有符合要求的Menu
        menus = getBaseMapper().selectAllRouterMenu();
    } else {
        // 如果不是,则查询当前用户所具有的的菜单权限
        menus = getBaseMapper().selectRouterMenuTreeByUserId(userId);
    }
    // 构建菜单权限路由树结构
    return builderMenuTree(menus);
}

/**
 * 构建菜单权限路由树结构
 *
 * @param menus 菜单权限
 * @return 菜单权限路由树结构
 */
private List<Menu> builderMenuTree(List<Menu> menus) {
    // 首先,根节点下的子菜单并赋值给children,然后通过其作为父菜单继续往下找其子菜单
    return menus.stream().filter(menu -> SystemConstants.ROUTER_ROOT.equals(menu.getParentId()))
            .map(menu -> menu.setChildren(getChildren(menu, menus)))
            .collect(Collectors.toList());
}

/**
 * 获取当前菜单的子菜单
 * @param parentMenu 当前菜单
 * @return 当前菜单的子菜单
 */
private List<Menu> getChildren(Menu parentMenu, List<Menu> menus) {
    return menus.stream().filter(
            menu -> menu.getParentId().equals(parentMenu.getId()))
            .map(menu -> menu.setChildren(getChildren(menu, menus)))
            .collect(Collectors.toList());
}

MenuMapper

// 返回所有符合要求的Menu
List<Menu> selectAllRouterMenu();

// 查询当前用户所具有的的菜单权限
List<Menu> selectRouterMenuTreeByUserId(Long userId);

MenuMapper.xml

<!-- 返回所有符合要求的Menu -->
<select id="selectAllRouterMenu" resultType="com.hashiqi.entity.Menu">
    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
    FROM
        hashiqi_blog.sys_menu m
    WHERE
        m.menu_type IN ('C', 'M')
      AND
        m.status = 0
      AND
        m.is_deleted = 0
</select>

<!-- 查询当前用户所具有的的菜单权限 -->
<select id="selectRouterMenuTreeByUserId" resultType="com.hashiqi.entity.Menu">
    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
    FROM
        hashiqi_blog.sys_user_role ur
            LEFT JOIN
        hashiqi_blog.sys_role_menu rm ON ur.role_id = rm.role_id
            LEFT JOIN
        hashiqi_blog.sys_menu m ON m.id = rm.menu_id
    WHERE
        ur.user_id = #{userId}
      AND
        m.menu_type IN ('C', 'M')
      AND
        m.status = 0
      AND
        m.is_deleted = 0
</select>

当然,这也可以根据所设字段进行相应的排序。

RouterVO

import com.hashiqi.entity.Menu;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;

import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
public class RouterVO {

    private List<Menu> menus;

}

SystemConstants

/**
 * 菜单根节点标识
 */
public static final Long ROUTER_ROOT = 0L;

4.4、退出登录接口

4.4.1、接口设计

请求方式请求地址请求头
POST/user/logout需要token请求头

响应格式:

{
    "code": 200,
    "msg": "操作成功"
}

4.4.2、代码实现

实现的操作:删除redis中的用户信息

AdminLoginController

@PostMapping("/user/logout")
@ApiOperation(value = "退出登录")
public ResponseResult logout() {
    return adminLoginService.logout();
}

AdminLoginService

// 退出登录
ResponseResult logout();

AdminLoginServiceImpl

/**
 * 退出登录
 * @return 返回结果集
 */
@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(RedisKeyConstants.ADMIN_LOGIN_ID + userId);
    return ResponseResult.okResult();
}

SecurityConfig

import com.hashiqi.filter.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Autowired
    private AuthenticationEntryPoint authenticationEntryPoint;

    @Autowired
    private AccessDeniedHandler accessDeniedHandler;

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf,因为如果是分布式系统,则不适合session
                .csrf().disable()
                // 不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口,允许匿名访问【即未登录状态下可以访问 .permitAll()代表都可以访问】
                .antMatchers("/user/login").anonymous()
//                // 注销接口需要认证才能访问
//                .antMatchers("/logout").authenticated()
                // 出上面外的所有请求全部不需要认证即可访问
                .anyRequest().authenticated();

        // 关闭默认的注销功能
        http.logout().disable();
        // 允许跨域
        http.cors();
        // 将token检验过滤器放在用户名和密码校验之前
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // 配置认证和授权失败处理器
        http.exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler);
    }
}

4.5、查询标签列表

4.5.1、需求分析

为了方便后期对文章进行管理,需要提供标签的功能,一个文章可以有多个标签。

在后台需要分页查询标签功能,要求能根据标签名进行分页查询。 后期可能会增加备注查询等需求

注意:不能把删除了的标签查询出来。

4.5.2、标签表设计

之前已有。

4.5.3、接口设计

请求方式请求路径
Getcontent/tag/list

query格式请求参数:

pageNum: 页码
pageSize: 每页条数
name:标签名
remark:备注

响应格式:

{
    "code":200,
    "data":{
        "rows":[
            {
                "id":4,
                "name":"Java",
                "remark":"xxx"
            }
        ],
        "total":1
    },
    "msg":"操作成功"
}

4.5.4、代码实现

TagController

import com.hashiqi.domain.ResponseResult;
import com.hashiqi.domain.dto.TagListDTO;
import com.hashiqi.domain.vo.PageVO;
import com.hashiqi.service.TagService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/content/tag")
@Api(tags = "标签接口管理", value = "标签相关接口")
public class TagController {

    @Autowired
    private TagService tagService;

    @GetMapping("/list")
    @ApiOperation(value = "获取所有的标签【分页】")
    public ResponseResult<PageVO> list(
            Integer pageNum,
            Integer pageSize,
            TagListDTO tagListDto) {
        return tagService.pageTagList(pageNum, pageSize, tagListDto);
    }
}

TagService

import com.hashiqi.domain.ResponseResult;
import com.hashiqi.domain.dto.TagListDTO;
import com.hashiqi.domain.entity.Tag;
import com.baomidou.mybatisplus.extension.service.IService;
import com.hashiqi.domain.vo.PageVO;

public interface TagService extends IService<Tag> {

    // 获取所有的标签【分页】
    ResponseResult<PageVO> pageTagList(Integer pageNum, Integer pageSize, TagListDTO tagListDto);
}

TagServiceImpl

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hashiqi.domain.ResponseResult;
import com.hashiqi.domain.dto.TagListDTO;
import com.hashiqi.domain.entity.Tag;
import com.hashiqi.domain.vo.PageVO;
import com.hashiqi.mapper.TagMapper;
import com.hashiqi.service.TagService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

/**
 * <p>
 * 标签 服务实现类
 * </p>
 *
 * @author hashiqi
 * @since 2023-09-16
 */
@Service
public class TagServiceImpl extends ServiceImpl<TagMapper, Tag> implements TagService {

    /**
     * 获取所有的标签【分页】
     * @param pageNum 当前页
     * @param pageSize 页大小
     * @param tagListDto 标签集合
     * @return 所有标签分页列表
     */
    @Override
    public ResponseResult<PageVO> pageTagList(Integer pageNum, Integer pageSize, TagListDTO tagListDto) {
        // 分页查询
        Page<Tag> page = this.page(
                new Page<>(pageNum, pageSize),
                new LambdaQueryWrapper<Tag>()
                        .like(StringUtils.hasText(tagListDto.getName()), Tag::getName, tagListDto.getName())
                        .like(StringUtils.hasText(tagListDto.getRemark()), Tag::getRemark, tagListDto.getRemark()));
        // 封装数据并返回
        return ResponseResult.okResult(new PageVO(page.getRecords(), page.getTotal()));
    }
}

4.6、新增标签

4.6.1、需求分析

点击标签管理的新增按钮可以实现新增标签的功能。

4.6.2、接口设计

请求方式请求地址请求头
POST/content/tag需要token请求头

请求体格式:

{
    "name":"c#",
    "remark":"c++++"
}

响应格式:

{
    "code":200,
    "msg":"操作成功"
}

4.6.3、代码实现

TagController

@PostMapping
@ApiOperation(value = "添加标签")
public ResponseResult addTag(@RequestBody TagVO tagVO) {
    return tagService.addTag(tagVO);
}

TagService

// 添加标签
ResponseResult addTag(TagVO tagVO);

TagServiceImpl

/**
 * 添加标签
 * @param tagVO 标签VO对象
 * @return 结果集
 */
@Override
public ResponseResult addTag(TagVO tagVO) {
    // 判断tagVO中标签名、备注【可以不写】是否为空
    String name = tagVO.getName();
    String remark = tagVO.getRemark();
    if (!StringUtils.hasText(name)) {
        throw new SystemException(AppHttpCodeEnum.TAG_NOT_NULL);
    }
    if (!StringUtils.hasText(remark)) {
        throw new SystemException(AppHttpCodeEnum.REMARK_NOT_NULL);
    }
    // 判断标签名是否已存在
    if (isTagExist(name)) {
        throw new SystemException(AppHttpCodeEnum.TAG_EXIST);
    }
    // 将TagVO转成Tag
    this.save(BeanCopyUtils.copyBean(tagVO, Tag.class));
    return ResponseResult.okResult();
}

/**
 * 判断标签名是否已存在
 * @param name 标签名
 * @return true:已存在;false:未存在
 */
private boolean isTagExist(String name) {
    return this.count(new LambdaQueryWrapper<Tag>().eq(Tag::getName, name)) > 0;
}

TagVO

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class TagVO {

    private String name;
    private String remark;
}

SystemConstants

TAG_NOT_NULL(513, "标签不能为空"),
TAG_EXIST(514, "标签已存在"),
REMARK_NOT_NULL(515, "备注不能为空");

4.7、修改标签

4.7.1、接口设计

4.7.1.1、获取标签信息
请求方式请求地址请求头
GET/content/tag/{id}需要token请求头

请求参数放入请求路径中,使用ResutFul表达式

响应格式:

{
    "code":200,
    "data":{
        "id":4,
        "name":"Java",
        "remark":"xxx"
    },
    "msg":"操作成功"
}

4.7.1.2、修改标签接口
请求方式请求地址请求头
PUT/content/tag需要token请求头

请求格式:

{
    "id":7,
    "name":"c#",
    "remark":"c++"
}

响应格式:

{
    "code":200,
    "msg":"操作成功"
}

4.7.2、代码实现
4.7.2.1、获取标签信息代码实现

TagController

@GetMapping("/{id}")
@ApiOperation(value = "获取对应id的标签信息")
public ResponseResult getTag(@PathVariable("id") Long id) {
    return ResponseResult.okResult(BeanCopyUtils.copyBean(tagService.getById(id), TagVO.class));
}

TagVO

import com.hashiqi.domain.dto.TagDTO;

public class TagVO extends TagDTO {
}

4.7.2.2、修改标签信息代码实现

TagController

@PutMapping
@ApiOperation(value = "根据标签id更新标签信息")
public ResponseResult updateTag(@RequestBody TagDTO tagDTO) {
    return tagService.addOrUpdateTag(tagDTO);
}

经过思考,将修改和增加方法合并,虽然耦合性增强了,但同时减少了冗余代码,当然你也可以选择解耦,使用反射的方法对两个方法就分离。修改如下:

TagService

// 添加/更新标签
ResponseResult addOrUpdateTag(TagDTO tagDTO);

TagServiceImpl

/**
 * 添加标签
 * @param tagDTO 标签VO对象
 * @return 结果集
 */
@Override
public ResponseResult addOrUpdateTag(TagDTO tagDTO) {
    // 判断tagDTO中标签名、备注【可以不写】是否为空
    String name = tagDTO.getName();
    String remark = tagDTO.getRemark();
    if (!StringUtils.hasText(name)) {
        throw new SystemException(AppHttpCodeEnum.TAG_NOT_NULL);
    }
    if (!StringUtils.hasText(remark)) {
        throw new SystemException(AppHttpCodeEnum.REMARK_NOT_NULL);
    }
    // 判断标签名是否已存在
    if (isTagExist(name)) {
        throw new SystemException(AppHttpCodeEnum.TAG_EXIST);
    }
    // 将TagDTO转成Tag,没有id为新增,相反更新
    if (null != tagDTO.getId()) {
        this.updateById(BeanCopyUtils.copyBean(tagDTO, Tag.class));
    } else {
        this.save(BeanCopyUtils.copyBean(tagDTO, Tag.class));
    }
    return ResponseResult.okResult();
}

TagDTO

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class TagDTO {

    private Long id;
    private String name;
    private String remark;
}

4.8、删除标签

4.8.1、需求分析

因为这里不仅仅是为了逻辑删除标签,同时应当考虑与标签表对应的文章标签关联表,现在有两种选择。

其一要么直接直接同时逻辑删除文章标签关联表与标签表中的数据;

其二,当标签与文章有关联存在时,当点击逻辑删除标签时,提醒该标签不能删除,与文章有着对应关系,请取消文章和该标签的关联关系,然后再删除标签。

这里,我选择前者进行处理。这样处理原因是因为,不用去判断是否有文章贴上该标签~~

4.8.2、接口设计

请求方式请求地址请求头
DELETE/content/tag/{id}需要token请求头

响应格式:

{
    "code":200,
    "msg":"操作成功"
}

4.8.3、代码实现

TagController

@DeleteMapping("/{id}")
@ApiOperation(value = "根据id删除标签")
public ResponseResult removeTag(@PathVariable("id") Long id) {
    return tagService.removeTagById(id);
}

TagService

// 根据id删除标签
ResponseResult removeTagById(Long id);

TagServiceImpl

/**
 * 根据id删除标签
 * @param id 标签id
 * @return 结果集
 */
@Override
@Transactional
public ResponseResult removeTagById(Long id) {
    // 首先删除文章标签关联表中与该标签关联的文章记录
    getBaseMapper().removeArticleAndTagById(id);
    // 如果删除成功,则删除该标签
    if (!(baseMapper.deleteById(id) > 0)) {
        throw new SystemException(AppHttpCodeEnum.TAG_NOT_EXIST);
    }
    return ResponseResult.okResult();
}

TagMapper

import com.hashiqi.domain.entity.Tag;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;

public interface TagMapper extends BaseMapper<Tag> {

    // 删除文章标签关联表中与该标签关联的文章记录
    void removeArticleAndTagById(Long id);
}

TagMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.hashiqi.mapper.TagMapper">
    <delete id="removeArticleAndTagById">
        DELETE FROM
            hashiqi_blog.tb_article_tag
        WHERE
            tag_id = #{id}
    </delete>
</mapper>

AppHttpCodeEnum

TAG_NOT_EXIST(516, "标签不存在")

4.9、博客文章

4.9.1、需求分析

需要提供写博文的功能,写博文时需要关联分类和标签。

可以上传缩略图,也可以在正文中添加图片。

文章可以直接发布,也可以保存到草稿箱。

4.9.2、文章标签关联表

DROP TABLE IF EXISTS `tb_article_tag`;
CREATE TABLE `tb_article_tag`  (
  `article_id` bigint(20) NOT NULL COMMENT '文章id',
  `tag_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '标签id',
  PRIMARY KEY (`article_id`, `tag_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '文章标签关联表' ROW_FORMAT = Dynamic;

4.9.3、接口设计

4.9.3.1、查询所有分类接口
请求方式请求地址请求头
GET/content/category/listAllCategory需要token请求头

请求参数:无

响应格式:

{
    "code":200,
    "data":[
        {
            "description":"5454",
            "id":1,
            "name":"xxx"
        },
        {
            "description":"sdasd",
            "id":2,
            "name":"yyy"
        }
    ],
    "msg":"操作成功"
}

4.9.3.2、查询所有标签接口
请求方式请求地址请求头
GET/content/tag/listAllTag需要token请求头

请求参数:无

响应格式:

{
	"code":200,
	"data":[
		{
			"id":1,
			"name":"Mybatis"
		},
		{
			"id":4,
			"name":"Java"
		}
	],
	"msg":"操作成功"
}

4.9.3.3、上传图片
请求方式请求地址请求头
POST/upload需要token请求头

参数:

key: img;value: 要上传的文件

响应格式:

{
    "code": 200,
    "data": "文件访问链接",
    "msg": "操作成功"
}

4.9.3.4、新增博客文章
请求方式请求地址请求头
POST/content/article需要token请求头

请求体格式:

{
    "title":"测试新增博文",
    "thumbnail":"https://xxx-blog-oss.oss-cn-xxx.aliyuncs.com/2022/08/21/4ceebc07e7484beba732f12b0d2c43a9.png",
    "isTop":"0",
    "isComment":"0",
    "content":"# 一",
    "tags":[
        1,
        4
    ],
    "categoryId":1,
    "summary":"哈哈",
    "status":"1"
}

响应格式:

{
	"code":200,
	"msg":"操作成功"
}

4.9.4、代码实现

4.9.4.1、查询所有分类接口实现

CategoryVO

修改之前的CategoryVO,新增description属性

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class CategoryVO {

    private Long id;
    private String name;
    private String description;
}

CategoryController

在后台管理模块中创建该CategoryController。本来因为考虑到前台博客和后台管理调用的是同一个方法,心想直接使用远程调用即可,后来又想了下,远程调用还需要nacos,算了有点小麻烦。

import com.hashiqi.domain.ResponseResult;
import com.hashiqi.domain.vo.CategoryVO;
import com.hashiqi.service.CategoryService;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/content/category")
public class CategoryController {

    @Autowired
    private CategoryService categoryService;

    @GetMapping("/listAllCategory")
    @ApiOperation(value = "后台博客获取所有分类列表")
    public ResponseResult listAllCategory() {
        List<CategoryVO> list = categoryService.listAllCategory();
        return ResponseResult.okResult(list);
    }
}

CategoryService

// 后台管理获取所有分类列表
List<CategoryVO> listAllCategory();

CategoryServiceImpl

/**
 * 后台管理获取所有分类列表
 * @return 返回所有分类
 */
@Override
public List<CategoryVO> listAllCategory() {
    // 获取正常状态的所有分类
    List<Category> list = this.list(new LambdaQueryWrapper<Category>()
            .eq(Category::getStatus, SystemConstants.STATUS_NORMAL));
    // 将Category集合转成CategoryVO集合
    return BeanCopyUtils.copyBeanList(list, CategoryVO.class);
}

4.9.4.2、查询所有标签接口

TagController

@GetMapping("/listAllTag")
@ApiOperation("获取所有标签【不分页】")
public ResponseResult listAllTag() {
    return ResponseResult.okResult(tagService.listAllTag());
}

TagService

// 获取所有标签【不分页】
List<TagVO> listAllTag();

TagServiceImpl

/**
 * 获取所有标签【不分页】
 * @return 所有标签
 */
@Override
public List<TagVO> listAllTag() {
    // 获取未删除的所有标签
    List<Tag> list = this.list(new LambdaQueryWrapper<Tag>()
            .eq(Tag::getIsDeleted, SystemConstants.STATUS_NOT_DELETED));
    return BeanCopyUtils.copyBeanList(list, TagVO.class);
}

SystemsConstants

/**
 * 未删除标识
 */
public static final Integer STATUS_NOT_DELETED = 0;
/**
 * 已删除标识
 */
public static final Integer STATUS_IS_DELETED = 1;

4.9.4.3、上传图片接口实现

UploadController

在后台管理模块中创建该UploadController。

import com.hashiqi.constants.SystemConstants;
import com.hashiqi.domain.ResponseResult;
import com.hashiqi.service.UploadService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
public class UploadController {

    @Autowired
    private UploadService uploadService;

    @PostMapping("/upload")
    public ResponseResult uploadImg(@RequestParam("img") MultipartFile multipartFile) {
        try {
            return uploadService.uploadImg(multipartFile);
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException(SystemConstants.FILE_UPLOAD_ERROR);
        }
    }
}

4.9.4.4、新增博客文章

AddArticleDTO

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
@ApiModel("新增博客文章实体")
public class AddArticleDTO {

    private Long id;
    @ApiModelProperty(value = "标题")
    private String title;
    @ApiModelProperty(value = "文章内容")
    private String content;
    @ApiModelProperty(value = "文章摘要")
    private String summary;
    @ApiModelProperty(value = "所属分类id")
    private Long categoryId;
    @ApiModelProperty(value = "缩略图")
    private String thumbnail;
    @ApiModelProperty(value = "是否置顶(0:否;1:是)")
    private String isTop;
    @ApiModelProperty(value = "状态(0:已发布;1:草稿)")
    private String status;
    @ApiModelProperty(value = "访问量")
    private Long viewCount;
    @ApiModelProperty(value = "是否允许评论(0:否;1:是)")
    private String isComment;
    @ApiModelProperty(value = "标签集合tags")
    private List<Long> tags;
}

Article

对创建者、时间,最后更新者、时间进行填充

/**
 * 创建者
 */
@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;

ArticleController

import com.hashiqi.domain.ResponseResult;
import com.hashiqi.domain.dto.AddArticleDTO;
import com.hashiqi.service.ArticleService;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/content/article")
public class ArticleController {

    @Autowired
    private ArticleService articleService;

    @PostMapping
    @ApiOperation(value = "新增博客文章")
    public ResponseResult add(@RequestBody AddArticleDTO article) {
        return articleService.add(article);
    }
}

ArticleService

// 新增博客文章
ResponseResult add(AddArticleDTO article);

ArticleTag

import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import java.io.Serializable;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;

@Data
@EqualsAndHashCode(callSuper = false)
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
@TableName("tb_article_tag")
public class ArticleTag implements Serializable {

    private static final long serialVersionUID=1L;

    /**
     * 文章id
     */
    private Long articleId;

    /**
     * 标签id
     */
    private Long tagId;
}

ArticleServiceImpl

/**
 * 新增博客文章
 * @param articleDTO 博客文章
 * @return 结果集
 */
@Override
@Transactional
public ResponseResult add(AddArticleDTO articleDTO) {
    // 添加博客文章
    Article article = BeanCopyUtils.copyBean(articleDTO, Article.class);
    this.save(article);
    // 添加博客文章和标签的关联
    List<ArticleTag> articleTags = articleDTO.getTags().stream().map(
            tagId -> new ArticleTag(article.getId(), tagId)
    ).collect(Collectors.toList());
    articleTagService.saveBatch(articleTags);
    return ResponseResult.okResult();
}

4.10、导出所有分类到Excel

EasyExcel自行查阅,请谅解。

4.10.1、需求分析

在分类管理中点击导出按钮可以把所有的分类导出到Excel文件中。

4.10.2、技术需求

使用EasyExcel实现Excel的导出操作。

4.10.3、接口设计

请求方式请求地址请求头
GET/content/category/export需要token请求头

请求参数:无

响应格式:

成功,导出Excel,即创建Excel。

失败:

{
    "code":500,
    "msg":"出现错误"
}

4.10.4、代码实现

在这之前,先加入查询分类分页列表接口。

CategoryController

@GetMapping("/list")
@ApiOperation(value = "后台管理获取所有分类列表【分页】")
public ResponseResult getCategoryByPage(Integer pageNum,
                                        Integer pageSize,
                                        CategoryDTO categoryDTO) {
    return categoryService.pageCategoryList(pageNum, pageSize, categoryDTO);
}

CategoryService

// 后台管理获取所有分类列表【分页】
ResponseResult pageCategoryList(Integer pageNum, Integer pageSize, CategoryDTO categoryDTO);

CategoryServiceImpl

/**
 * 后台管理获取所有分类列表【分页】
 * @param pageNum 当前页
 * @param pageSize 页大小
 * @param categoryDTO 分类查询条件信息
 * @return 符合条件的分类列表
 */
@Override
public ResponseResult pageCategoryList(Integer pageNum, Integer pageSize, CategoryDTO categoryDTO) {
    // 获取当前符合条件的分类分页列表
    Page<Category> page = this.page(
            new Page<>(pageNum, pageSize),
            new LambdaQueryWrapper<Category>()
                    .like(Category::getName, categoryDTO.getName())
                    .eq(!ObjectUtils.isEmpty(categoryDTO.getStatus()), Category::getStatus, categoryDTO.getStatus()));
    // 将Category转成CategoryVO
    List<CategoryVO> categoryVOs = BeanCopyUtils.copyBeanList(page.getRecords(), CategoryVO.class);
    return ResponseResult.okResult(new PageVO(categoryVOs, page.getTotal()));
}

WebUtils

// 用于excel文件
public static void setDownLoadHeader(String filename, HttpServletResponse response) throws UnsupportedEncodingException {
    response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
    response.setCharacterEncoding("utf-8");
    String fName = URLEncoder.encode(filename, "UTF-8").replaceAll("\\+", "%20");
    response.setHeader("Content-disposition", "attachment;filename=" + fName);
}

ExcelCategoryVO

import com.alibaba.excel.annotation.ExcelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ExcelCategoryVO {

    @ExcelProperty("分类名")
    private String name;
    @ExcelProperty("描述")
    private String description;
    @ExcelProperty("状态")
    private String status;
}

SystemConstants

/**
 * 设置分类文件名
 */
public static final String EXCEL_FILE_NAME = "分类";
/**
 * 设置分类模块标识
 */
public static final String EXCEL_MODULE_NAME = "分类导出版";
/**
 * excel文件后缀格式标识
 */
public static final String EXCEL_POINT_SUFFIX = ".xlsx";

CategoryController

@GetMapping("/export")
@ApiOperation(value = "导出文件")
public void export(HttpServletResponse response) {
    try {
        // 设置下载文件的请求头
        WebUtils.setDownLoadHeader(SystemConstants.EXCEL_FILE_NAME + SystemConstants.EXCEL_POINT_SUFFIX, response);
        // 获取将要导出的数据体
        List<Category> categoryList = categoryService.list();
        // Category转换成ExcelCategoryVO
        List<ExcelCategoryVO> excelCategoryVOs = BeanCopyUtils.copyBeanList(categoryList, ExcelCategoryVO.class);
        // 将数据体写入excel
        EasyExcel.write(response.getOutputStream(), ExcelCategoryVO.class)
                .autoCloseStream(Boolean.FALSE).sheet(SystemConstants.EXCEL_MODULE_NAME)
                .doWrite(excelCategoryVOs);
    } catch (Exception e) {
        // 出现异常则响应json
        WebUtils.renderString(response, JSON.toJSONString(ResponseResult.errorResult(AppHttpCodeEnum.FILE_EXPORT_ERROR)));
    }
}

AppHttpCodeEnum

FILE_EXPORT_ERROR(518, "文件导出错误");

4.11、权限控制

4.11.1、需求分析

需要对导出分类的接口做权限控制。

4.11.2、代码实现

SecurityConfig

该配置类上加入

@EnableGlobalMethodSecurity(prePostEnabled = true)

UserDetailServiceImpl

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.hashiqi.constants.SystemConstants;
import com.hashiqi.domain.entity.LoginUser;
import com.hashiqi.domain.entity.User;
import com.hashiqi.mapper.MenuMapper;
import com.hashiqi.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Objects;

@Service
public class UserDetailServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;
    
    @Autowired
    private MenuMapper menuMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 根据用户名查询用户信息
        User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUserName, username));
        // 判断是否存在该用户信息,如果否,则抛出异常
        if (Objects.isNull(user)) {
            throw new RuntimeException("用户不存在");
        }
        // 后台用户才需要查询权限封装
        if (SystemConstants.BLOG_ADMIN.equals(user.getType())) {
            List<String> list = menuMapper.selectPermsByUserId(user.getId());
            return new LoginUser(user, list);
        }
        // 如果是,返回该用户信息
        return new LoginUser(user, null);
    }
}

LoginUser

private List<String> permissions;

PermissionService

import com.hashiqi.utils.SecurityUtils;
import org.springframework.stereotype.Service;

import java.util.List;

@Service("ps")
public class PermissionService {

    /**
     * 判断当前用户是否具有响应权限
     * @param permission 权限
     * @return true:是;false:否
     */
    public boolean hasPermission(String permission) {
        // 如果是超级管理员,直接返回true
        if (SecurityUtils.isAdmin()) {
            return true;
        }
        // 相反,获取当前登录用户所具有的权限,判断是否存在
        List<String> permissions = SecurityUtils.getLoginUser().getPermissions();
        return permissions.contains(permission);
    }
}

CategoryController

@GetMapping("/export")
@PreAuthorize("@ps.hasPermission('content:category:export')")
@ApiOperation(value = "导出文件")
public void export(HttpServletResponse response) {
}

4.12、文章列表

4.12.1、需求分析

为了对文章进行管理,需要提供文章列表,

在后台需要分页查询文章功能,要求能根据标题和摘要模糊查询

4.12.2、接口设计

请求方式请求路径是否需求token头
Get/content/article/list

query格式请求参数:

pageNum: 页码
pageSize: 每页条数
title:文章标题
summary:文章摘要

响应格式:

{
    "code":200,
    "data":{
        "rows":[
            {
                "categoryId":"1",
                "content":"哈哈哈哈哈",
                "createTime":"2023-10-01 07:20:11",
                "id":"1",
                "isComment":"0",
                "isTop":"1",
                "status":"0",
                "summary":"6",
                "thumbnail":"https://xxx.oss-cn-xxx.aliyuncs.com/2022/01/31/948597e164614902ab1662ba8452e106.png",
                "title":"xxx",
                "viewCount":"15454"
            }
        ],
        "total":"1"
    },
    "msg":"操作成功"
}

4.12.3、代码实现

AdminArticleDTO

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class AdminArticleDTO {

    private Long id;
    private String title;
    private String summary;
}

AdminArticleVO

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Date;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class AdminArticleVO {

    private Long id;
    private String title;
    private String summary;
    private Date createTime;
}

ArticleController

@GetMapping("/list")
@ApiOperation(value = "分页获取文章列表")
public ResponseResult pageArticleList(
        Integer pageNum,
        Integer pageSize,
        AdminArticleDTO articleDTO) {
    return articleService.pageArticleList(pageNum, pageSize, articleDTO);
}

ArticleService

// 分页获取文章列表
ResponseResult pageArticleList(Integer pageNum, Integer pageSize, AdminArticleDTO articleDTO);

ArticleServiceImpl

/**
 * 分页获取文章列表
 * @param pageNum 当前页
 * @param pageSize 页大小
 * @param articleDTO 查询文章列表条件
 * @return 符合条件的文章列表
 */
@Override
public ResponseResult pageArticleList(Integer pageNum, Integer pageSize, AdminArticleDTO articleDTO) {
    // 查询符合条件的文章分页列表
    Page<Article> page = this.page(new Page<>(pageNum, pageSize), new LambdaQueryWrapper<Article>()
            .like(Article::getTitle, articleDTO.getTitle())
            .like(Article::getSummary, articleDTO.getSummary()));
    // 将Article转成AdminArticleVO并封装
    return ResponseResult.okResult(new PageVO(BeanCopyUtils.copyBeanList(page.getRecords(), AdminArticleVO.class), page.getTotal()));
}

4.13、修改文章

4.13.1、需求分析

点击文章列表中的修改按钮可以跳转到写博文页面,回显示该文章的具体信息。

用户可以在该页面修改文章信息,点击更新按钮后修改文章。

4.13.2、需求分析

这个功能的实现首先需要能够根据文章id查询文章的详细信息这样才能实现文章的回显。

4.13.3、接口设计

4.13.3.1、查询文章详情接口
请求方式请求路径是否需求token头
Getcontent/article/{id}

请求路径参数:文章id

响应格式:

{
    "code":200,
    "data":{
        "categoryId":"1",
        "content":"xxxxxxx",
        "createBy":"1",
        "createTime":"2023-10-01 15:15:46",
        "isDelted":0,
        "id":"10",
        "isComment":"0",
        "isTop":"1",
        "status":"0",
        "summary":"xx",
        "tags":[
            "1",
            "4",
            "5"
        ],
        "thumbnail":"https://xxx-blog-oss.oss-cn-xxx.aliyuncs.com/2022/08/28/7659aac2b74247fe8ebd9e054b916dbf.png",
        "title":"s",
        "updateBy":"1",
        "updateTime":"2023-10-01 15:15:46",
        "viewCount":"0"
    },
    "msg":"操作成功"
}

4.13.3.2、更新文章接口
请求方式请求路径是否需求token头
PUTcontent/article

请求体格式:

{
    "categoryId":"1",
    "content":"6",
    "createBy":"1",
    "createTime":"2023-10-01 15:15:46",
    "isDelted":0,
    "id":"10",
    "isComment":"0",
    "isTop":"1",
    "status":"0",
    "summary":"22",
    "tags":[
        "1",
        "4",
        "5"
    ],
    "thumbnail":"https:/xxx.oss-cn-xxx.aliyuncs.com/2023/08/28/7659aac2b74247fe8ebd9e054b916dbf.png",
    "title":"6",
    "updateBy":"1",
    "updateTime":"2023-10-01 15:15:46",
    "viewCount":"0"
}

响应格式:

{
    "code":200,
    "msg":"操作成功"
}

4.13.4、代码实现

4.13.4.1、查询文章详情接口实现

ArticleController

@GetMapping("/{id}")
@ApiOperation(value = "通过文章id获取单篇文章")
public ResponseResult getArticleDetail(@PathVariable("id") Long id) {
    return ResponseResult.okResult(articleService.getById(id));
}

4.14、删除文章

4.14.1、需求分析

点击文章后面的删除按钮可以删除该文章,该删除为逻辑删除。

4.14.2、接口设计

请求方式请求路径是否需求token头
DELETEcontent/article/{id}

请求参数:文章id

响应格式:

{
    "code":200,
    "msg":"操作成功"
}

4.14.3、代码实现

ArticleController

@DeleteMapping("/{id}")
@ApiOperation(value = "通过文章id逻辑删除文章")
public ResponseResult deleteArticleById(@PathVariable("id") Long id) {
    articleService.removeById(id);
    return ResponseResult.okResult();
}

到这里,告一段落,后面会继续补充完整~~敬请期待

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值