springboot教程之整合SpringSecurity+JWT 2万字超详细鉴权系统讲解

图片

    Spring Security是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架。它提供了一组可以在Spring应用上下文中配置的Bean,充分利用了Spring IoC,DI(控制反转Inversion of Control ,DI:Dependency Injection 依赖注入)和AOP(面向切面编程)功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作。

    它是一个轻量级的安全框架,它确保基于Spring的应用程序提供身份验证和授权支持。它与Spring MVC, Springboot有很好地集成,并配备了流行的安全算法实现捆绑在一起。安全主要包括两个操作“认证”与“验证”(有时候也会叫做权限控制)。“认证”是为用户建立一个其声明的角色的过程,这个角色可以一个用户、一个设备或者一个系统。“验证”指的是一个用户在你的应用中能够执行某个操作。

SpringSecurity 核心功能

  • 认证 (你是谁)

  • 授权 (你能干什么)

  • 攻击防护 (防止伪造身份)

图片

图片

    Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。

验证流程

图片

图片

创建  sys_user, sys_role , sys_menu , sys_user_role , sys_role_menu,

sys_user_detail 表

SET NAMES utf8mb4;SET FOREIGN_KEY_CHECKS = 0;
-- ------------------------------ Table structure for sys_menu-- ----------------------------DROP TABLE IF EXISTS `sys_menu`;CREATE TABLE `sys_menu`  (  `menu_id` int(0) NOT NULL AUTO_INCREMENT COMMENT '权限主键',  `menu_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL COMMENT '权限名称',  `menu_permission` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '权限表达式',  `path` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '前端跳转路径',  `parent_id` int(0) NOT NULL DEFAULT 0 COMMENT '父级id 为0为根目录',  `menu_icon` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '目录icon',  `menu_type` int(0) NOT NULL DEFAULT 0 COMMENT '菜单类型 (类型   0:目录   1:菜单   2:按钮)',  `del_flag` tinyint(1) NOT NULL DEFAULT 0 COMMENT '删除标记 0为 未删除 1为 删除',  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',  `create_user` int(0) NULL DEFAULT NULL COMMENT '创建用户',  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '修改时间',  `update_user` int(0) NULL DEFAULT NULL COMMENT '修改用户',  `tenant_id` int(0) NULL DEFAULT NULL COMMENT '预留租户id',  PRIMARY KEY (`menu_id`) USING BTREE) ENGINE = InnoDB AUTO_INCREMENT = 11 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ------------------------------ Table structure for sys_role-- ----------------------------DROP TABLE IF EXISTS `sys_role`;CREATE TABLE `sys_role`  (  `role_id` int(0) NOT NULL AUTO_INCREMENT COMMENT '角色主键',  `role_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '角色名称',  `role_description` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '角色描述',  `role_code` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '角色编码',  `del_flag` tinyint(1) NOT NULL DEFAULT 0 COMMENT '删除标记',  `default_role` tinyint(1) NOT NULL DEFAULT 0 COMMENT '默认角色 1为默认 0为普通',  `tenant_id` int(0) NULL DEFAULT NULL COMMENT '预留租户id',  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',  `create_user` int(0) NULL DEFAULT NULL COMMENT '创建用户',  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '修改时间',  `update_user` int(0) NULL DEFAULT NULL COMMENT '修改用户',  PRIMARY KEY (`role_id`) USING BTREE) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ------------------------------ Table structure for sys_role_menu-- ----------------------------DROP TABLE IF EXISTS `sys_role_menu`;CREATE TABLE `sys_role_menu`  (  `id` int(0) NOT NULL AUTO_INCREMENT COMMENT '主键标识',  `role_id` int(0) NOT NULL COMMENT '角色id',  `menu_id` int(0) NOT NULL COMMENT '权限id',  PRIMARY KEY (`id`) USING BTREE) ENGINE = InnoDB AUTO_INCREMENT = 8 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ------------------------------ Table structure for sys_user-- ----------------------------DROP TABLE IF EXISTS `sys_user`;CREATE TABLE `sys_user`  (  `user_id` int(0) NOT NULL AUTO_INCREMENT COMMENT '用户主键',  `login_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL COMMENT '用户登录名称',  `password` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL COMMENT '用户登录密码',  `user_status` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL DEFAULT 'NORMAL' COMMENT '用户账号状态',  `tenant_id` int(0) NULL DEFAULT NULL COMMENT '预留租户id',  `registered_time` datetime(0) NULL DEFAULT CURRENT_TIMESTAMP(0) COMMENT '注册时间',  PRIMARY KEY (`user_id`) USING BTREE) ENGINE = InnoDB AUTO_INCREMENT = 14 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ------------------------------ Table structure for sys_user_detail-- ----------------------------DROP TABLE IF EXISTS `sys_user_detail`;CREATE TABLE `sys_user_detail`  (  `user_detail_id` int(0) NOT NULL AUTO_INCREMENT COMMENT '用户详情主键',  `user_id` int(0) NOT NULL COMMENT '对应用户的id',  `user_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL COMMENT '用户名称',  `nick_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL COMMENT '用户昵称 如果没有昵称则名称为昵称',  `user_detail_img` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL COMMENT '用户头像',  `user_detail_tel` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '用户手机号',  `user_detail_sex` tinyint(1) NOT NULL DEFAULT 1 COMMENT '用户性别 true为男 false为女',  `user_detail_address` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NULL DEFAULT NULL COMMENT '用户通信地址',  `user_detail_mail` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '用户邮箱',  `shop_id` int(0) NULL DEFAULT NULL COMMENT '用户所在门店id',  PRIMARY KEY (`user_detail_id`) USING BTREE) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-- ------------------------------ Table structure for sys_user_role-- ----------------------------DROP TABLE IF EXISTS `sys_user_role`;CREATE TABLE `sys_user_role`  (  `id` int(0) NOT NULL AUTO_INCREMENT COMMENT '主键标示',  `user_id` int(0) NOT NULL COMMENT '用户id',  `role_id` int(0) NOT NULL COMMENT '角色id',  PRIMARY KEY (`id`) USING BTREE) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;

根据上篇文档整合MP的自动生成代码

springboot教程之整合Mybatis-Plus

UserController

@RestController@RequestMapping("/user")public class UserController {    private final RedisOperator redisOperator;
    public UserController(RedisOperator redisOperator) {        this.redisOperator = redisOperator;    }
    @ApiOperation("获取验证码")    @GetMapping(value = "/code")    public ResponseEntity<Object> getCode(){        ArithmeticCaptcha captcha = new ArithmeticCaptcha(111, 36);        // 几位数运算,默认是两位        captcha.setLen(2);        // 获取运算的结果        String result = captcha.text();        String uuid = "loginCode:"+ UUID.randomUUID().toString();
        System.out.println("result:"+result);        System.out.println("uuid:"+uuid);
        // 保存        redisOperator.set(uuid, result,120);        Map<String,Object> imgResult = new HashMap<String,Object>(2){{            put("status",200);            put("img", captcha.toBase64());            put("uuid", uuid);        }};        return ResponseEntity.ok(imgResult);    }
    @GetMapping("/info/show")    public JSONResult test(){        SecurityUser securityUser = SecurityUntil.getUserInfo();        System.out.println(securityUser);        return JSONResult.ok();    }
    @GetMapping("/info/admin")    @PreAuthorize("hasAnyRole('SUPER_ADMIN')")    public JSONResult admin(){        return JSONResult.ok(SecurityUntil.getUserInfo());    }
    @GetMapping("/info/user")    @PreAuthorize("hasAnyRole('user','SUPER_ADMIN')")    public JSONResult user(){        return JSONResult.ok(new SecurityUser());    }
    /**     * 必须同时拥有SUPER_ADMIN和admin的话     * @return     */    @GetMapping("/helloUser")    @PreAuthorize("hasRole('SUPER_ADMIN') AND hasRole('user')")    public String helloUser() {        return "hello,user";    }}

IUserService

public interface IUserService extends IService<User> {
    /**     * 根据手机号查询用户     * @param s 手机号     * @return 用户信息     */    User selectUserByPhoneNumber(String s);
    /**     * 获取用户角色信息     * @param userId 用户id     * @return 角色集合     */    List<Role> selectRoleByUserId(Integer userId);
    /**     * 获取用户权限列表     * @param userId 用户id     * @return 权限集合     */    List<Menu> selectMenuByUserId(Integer userId);
    /**     * 根据登录名获取用户基本信息     * @param loginName 登录用户名     * @return 用户基本信息     */    UserInfoBO getUserInfo(String loginName);}

UserServiceImpl

@Servicepublic class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
    @Override    public User selectUserByPhoneNumber(String s) {        return this.baseMapper.selectUserByPhoneNumber(s);    }
    @Override    public List<Role> selectRoleByUserId(Integer userId) {        return this.baseMapper.selectRoleByUserId(userId);    }
    @Override    public List<Menu> selectMenuByUserId(Integer userId) {        return this.baseMapper.getMenus(userId);    }
    @Override    public UserInfoBO getUserInfo(String loginName) {        return this.baseMapper.getUserInfo(loginName);    }}

UserMapper

import com.cloud.sys.entity.Menu;import com.cloud.sys.entity.Role;import com.cloud.sys.entity.User;import com.baomidou.mybatisplus.core.mapper.BaseMapper;import org.apache.ibatis.annotations.Param;import org.apache.ibatis.annotations.Select;
import java.util.List;
/** * @author 康东伟 * @since 2021-05-11 */public interface UserMapper extends BaseMapper<User> {
    /**     * 根据手机号查询用户     * @param s 手机号     * @return 用户信息     */    @Select("SELECT\n" +            "\tsu.*\n" +            "FROM\n" +            "\tsys_user su\n" +            "\tJOIN sys_user_detail sud ON su.user_id = sud.user_id \n" +            "WHERE\n" +            "\tsud.user_detail_tel = #{tel}")    User selectUserByPhoneNumber(@Param("tel") String s);
    /**     * 获取用户角色信息     * @param userId 用户id     * @return 角色集合     */    @Select("SELECT sr.* FROM sys_role sr\n" +            "\t\tLEFT JOIN sys_user_role se ON se.role_id = sr.role_id\n" +            "\t\tWHERE se.user_id = #{userId}")    List<Role> selectRoleByUserId(@Param("userId") Integer userId);
    /**     * 根据用户id获取权限列表     * @param userId 用户id     * @return 权限集合     */    List<Menu> getMenus(Integer userId);        /**     * 根据登录名获取用户基本信息     * @param loginName 登录名     * @return 用户基本信息     */    @Select("SELECT\n" +            "\tsu.user_id,\n" +            "\tsu.login_name,\n" +            "\tsu.PASSWORD,\n" +            "\tsu.user_status AS STATUS,\n" +            "\tsu.tenant_id,\n" +            "\tsu.registered_time,\n" +            "\tsud.user_name\n" +            "FROM\n" +            "\tsys_user su\n" +            "\tJOIN sys_user_detail sud ON su.user_id = sud.user_id \n" +            "WHERE\n" +            "\tlogin_name = #{name}")    UserInfoBO getUserInfo(@Param("name") String loginName);}

UserDetailMapper

import com.cloud.sys.entity.UserDetail;import com.baomidou.mybatisplus.core.mapper.BaseMapper;import com.cloud.vo.LoginSuccessVO;import org.apache.ibatis.annotations.Param;import org.apache.ibatis.annotations.Select;
/** * <p> *  Mapper 接口 * </p> * * @author 康东伟 * @since 2021-05-13 */public interface UserDetailMapper extends BaseMapper<UserDetail> {
    /**     * 根据用户id获取用户基本信息     * @param userId 用户id     * @return 用户基本信息     */    @Select("SELECT\n" +            "\tsu.user_id,\n" +            "\tsu.tenant_id,\n" +            "\tsud.user_name,\n" +            "\tsud.nick_name,\n" +            "\tsud.user_detail_img,\n" +            "\tsud.user_detail_tel,\n" +            "\tsud.user_detail_sex,\n" +            "\tsud.user_detail_mail,\n" +            "\tsud.user_detail_address,\n" +            "\tms.shop_id,\n" +            "\tms.shop_name \n" +            "FROM\n" +            "\tsys_user su\n" +            "\tJOIN sys_user_detail sud ON su.user_id = sud.user_id\n" +            "\tJOIN merchant_shop ms ON sud.shop_id = ms.shop_id \n" +            "WHERE\n" +            "\tsu.user_id = #{userId}")    LoginSuccessVO getUserDetailById(@Param("userId") Integer userId);}

UserMapper.xml

<select id="getMenus" resultType="com.cloud.sys.entity.Menu">        SELECT DISTINCT m.* FROM sys_user_role ur      LEFT JOIN sys_role_menu rm ON ur.role_id = rm.role_id      LEFT JOIN sys_menu m ON rm.menu_id = m.menu_id    WHERE ur.user_id = #{userId}</select>

新建 LoginSuccessVO

@Datapublic class LoginSuccessVO {
    /**     * 用户ID     */    private Integer userId;    /**     * 用户名     */    private String username;    /**     * 用户昵称     */    private String nickName;
    /**     * 多租户标示     */    private Long tenantId;
    private String token;
    @ApiModelProperty(value = "用户头像")    private String userDetailImg;
    @ApiModelProperty(value = "用户手机号")    private String userDetailTel;
    @ApiModelProperty(value = "用户性别 true为男 false为女")    private Boolean userDetailSex;
    @ApiModelProperty(value = "用户通信地址")    private String userDetailAddress;
    @ApiModelProperty(value = "用户邮箱")    private String userDetailMail;
    @ApiModelProperty(value = "用户所在门店id")    private Integer shopId;
    @ApiModelProperty(value = "所属商铺名称")    private String shopName;}

新建 UserInfoBO

/** * @author 康东伟 * @date 2021/5/17 */@Datapublic class UserInfoBO {
    /**     * 用户ID     */    private Integer userId;    /**     * 用户名     */    private String username;    /**     * 登录名     */    private String loginName;    /**     * 密码     */    private String password;    /**     * 状态:NORMAL正常  PROHIBIT禁用     */    private String status;
    /**     * 多租户标示     */    private Long tenantId;}

图片

修改POM 文件

<!--Security start-->        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-security</artifactId>        </dependency>        <!--Security end-->
        <!--jwt start-->        <dependency>            <groupId>io.jsonwebtoken</groupId>            <artifactId>jjwt</artifactId>            <version>0.9.0</version>        </dependency>        <!--jwt end-->
        <!--redis-->        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-data-redis</artifactId>        </dependency>
        <!--验证码 -->        <dependency>            <groupId>com.github.whvcse</groupId>            <artifactId>easy-captcha</artifactId>            <version>1.6.2</version>        </dependency>

添加JWT配置

spring:   redis:    database: 5    host: 139.186.148.75    jedis:      pool:        max-active: 8        max-idle: 8        max-wait: 30000ms        min-idle: 1    password: kdw$0517.    port: 6399    timeout: 6000msjwt:  # 密匙KEY  secret: JWTSecret  # HeaderKEY  tokenHeader: Authorization  # Token前缀字符  tokenPrefix: Kang-  # 过期时间 单位秒 1天后过期=86400 7天后过期=604800  expiration: 8640  # 配置不需要认证的接口  antMatchers: /user/test,/menu/basisTree,/login/**,/user/code,/swagger-ui.html,/webjars/**,/swagger-resources/**,/v2/*,/sms/send,/user/verify/**,/inventory/export/**

新建JWT过滤器及配置类

新建 JWTconfig,JWTAuthenticationTokenFilter,JWTTokenUtil

import lombok.Getter;import org.springframework.boot.context.properties.ConfigurationProperties;import org.springframework.stereotype.Component;
/** * JWT配置类 * @author  kang * @date  2020/2/13 */@Getter@Component@ConfigurationProperties(prefix = "jwt")public class JWTConfig {    /**     * 密钥KEY     */    public static String secret;    /**     * TokenKey     */    public static String tokenHeader;    /**     * Token前缀字符     */    public static String tokenPrefix;    /**     * 过期时间     */    public static Integer expiration;    /**     * 不需要认证的接口     */    public static String antMatchers;

    public void setSecret(String secret) {        JWTConfig.secret = secret;    }
    public void setTokenHeader(String tokenHeader) {        JWTConfig.tokenHeader = tokenHeader;    }
    public void setTokenPrefix(String tokenPrefix) {        JWTConfig.tokenPrefix = tokenPrefix;    }
    public void setExpiration(Integer expiration) {        JWTConfig.expiration = expiration * 1000;    }
    public void setAntMatchers(String antMatchers) {        JWTConfig.antMatchers = antMatchers;    }}

import com.alibaba.fastjson.JSONObject;import com.cloud.config.security.entity.SecurityUser;import io.jsonwebtoken.Claims;import io.jsonwebtoken.Jwts;import lombok.extern.slf4j.Slf4j;import org.springframework.security.authentication.AuthenticationManager;import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;import org.springframework.security.core.GrantedAuthority;import org.springframework.security.core.authority.SimpleGrantedAuthority;import org.springframework.security.core.context.SecurityContextHolder;import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;import org.springframework.util.StringUtils;
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.ArrayList;import java.util.List;import java.util.Map;
/** * JWT接口请求校验拦截器 * 请求接口时会进入这里验证Token是否合法和过期 * @author 康东伟 * @date 2021/5/14 */@Slf4jpublic class JWTAuthenticationTokenFilter extends BasicAuthenticationFilter {
    public JWTAuthenticationTokenFilter(AuthenticationManager authenticationManager) {        super(authenticationManager);    }
    @Override    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {        // 获取请求头中JWT的Token        String tokenHeader = request.getHeader(JWTConfig.tokenHeader);        if (null!=tokenHeader && tokenHeader.startsWith(JWTConfig.tokenPrefix)) {            String token = tokenHeader.replace(JWTConfig.tokenPrefix, "");            Claims claims = Jwts.parser()                    .setSigningKey(JWTConfig.secret)                    .parseClaimsJws(token)                    .getBody();            // 获取用户名            String username = claims.getSubject();            String userId=claims.getId();            if(!StringUtils.isEmpty(username)&&!StringUtils.isEmpty(userId)) {                // 获取角色                List<GrantedAuthority> authorities = new ArrayList<>();                String authority = claims.get("authorities").toString();                if(!StringUtils.isEmpty(authority)){                    List<Map<String,String>> authorityMap = JSONObject.parseObject(authority, List.class);                    for(Map<String,String> role : authorityMap){                        if(!StringUtils.isEmpty(role)) {                            authorities.add(new SimpleGrantedAuthority(role.get("authority")));                        }                    }                }                //组装参数                SecurityUser securityUser = new SecurityUser();                securityUser.setUsername(claims.getSubject());                securityUser.setUserId(Integer.decode(claims.getId()));                securityUser.setLoginName(claims.get("login_name").toString());                securityUser.setAuthorities(authorities);                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(securityUser, userId, authorities);                SecurityContextHolder.getContext().setAuthentication(authentication);            }
        }        filterChain.doFilter(request, response);    }}
import com.alibaba.fastjson.JSON;import com.cloud.config.jwt.JWTConfig;import com.cloud.config.security.entity.SecurityUser;import io.jsonwebtoken.Jwts;import io.jsonwebtoken.SignatureAlgorithm;import lombok.extern.slf4j.Slf4j;
import java.util.Date;
/** * @author 康东伟 * @date 2021/5/14 */@Slf4jpublic class JWTTokenUtil {
    /**     * 私有化构造器     */    private JWTTokenUtil(){}
    /**     * 生成Token     * @param   securityUser 用户安全实体     * @return Token 用户令牌     */    public static String createAccessToken(SecurityUser securityUser){        log.info("生成令牌 security:"+securityUser);        // 登陆成功生成JWT        return Jwts.builder()                // 放入用户名和用户ID                .setId(securityUser.getUserId()+"")                // 主题                .setSubject(securityUser.getUsername())                // 签发时间                .setIssuedAt(new Date())                // 签发者                .setIssuer("kang")//                .addClaims(claims)                // 自定义属性 放入用户拥有权限                .claim("authorities", JSON.toJSONString(securityUser.getAuthorities()))                .claim("tenant_id",securityUser.getTenantId())                .claim("login_name",securityUser.getUsername())                // 失效时间                .setExpiration(new Date(System.currentTimeMillis() + JWTConfig.expiration))                // 签名算法和密钥                .signWith(SignatureAlgorithm.HS512, JWTConfig.secret)                .compact();    }}

添加配置Security 

新建security处理类 :  

1. 权限不足处理类

@Componentpublic class UserAuthAccessDeniedHandler implements AccessDeniedHandler {    /**     * 暂无权限返回结果     */    @Override    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException exception){        JSONResult.responseJson(response,JSONResult.resultCode(403,"未授权"));    }}

2.  未登录处理类

@Componentpublic class UserAuthenticationEntryPointHandler implements AuthenticationEntryPoint {    /**     * 用户未登录返回结果     */    @Override    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception){        JSONResult.responseJson(response,JSONResult.resultCode(401,"未登录"));    }}

3. 自定义登录类

@Component@Slf4jpublic class UserAuthenticationProvider implements AuthenticationProvider {
    @Autowired    private LoginUntil loginUntil;
    @Override    public Authentication authenticate(Authentication authentication) throws AuthenticationException {        log.info("执行登录");        HttpServletRequest request = ((ServletRequestAttributes)                RequestContextHolder.getRequestAttributes())                .getRequest();        String type = request.getParameter("type");        if("1".equals(type)) {            return loginUntil.loginByUserName(request,authentication);        }else {            return loginUntil.loginBySms(request);        }    }    @Override    public boolean supports(Class<?> authentication) {        return true;    }}

4. 登录失败处理类

@Slf4j@Componentpublic class UserLoginFailureHandler implements AuthenticationFailureHandler {    /**     * 登录失败返回结果     */    @Override    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception){        // 这些对于操作的处理类可以根据不同异常进行不同处理        if (exception instanceof UsernameNotFoundException){            log.info("【登录失败】"+exception.getMessage());            JSONResult.responseJson(response, JSONResult.resultCode(500,"用户名不存在"));        }        if (exception instanceof LockedException){            log.info("【登录失败】"+exception.getMessage());            JSONResult.responseJson(response,JSONResult.resultCode(500,"用户被冻结"));        }        if (exception instanceof BadCredentialsException){            log.info("【登录失败】"+exception.getMessage());            JSONResult.responseJson(response,JSONResult.resultCode(500,"用户名密码不正确"));        }        if (exception instanceof LoginCodeException){            log.info("【登录失败】"+exception.getMessage());            JSONResult.responseJson(response,JSONResult.resultCode(500,"验证码超时,请刷新验证码"));        }        if (exception instanceof LoginResultException){            log.info("【登录失败】"+exception.getMessage());            JSONResult.responseJson(response,JSONResult.resultCode(500,"验证码错误"));        }        JSONResult.responseJson(response,JSONResult.resultCode(500,"登录失败,请重新登录"));    }}

5. 登录成功处理类

@Slf4j@Componentpublic class UserLoginSuccessHandler implements AuthenticationSuccessHandler {
    @Autowired    private IUserDetailService detailService;
    /**     * 登录成功返回结果     * SysLoginLog     */    @Override    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication){        /*         组装JWT         */        SecurityUser userEntity =  (SecurityUser) authentication.getPrincipal();        log.info("userEntity:"+userEntity);        String token = JWTTokenUtil.createAccessToken(userEntity);        token = JWTConfig.tokenPrefix + token;        //为前端提供返回vo        LoginSuccessVO successVo;        successVo = detailService.getUserDetailById(userEntity.getUserId());        successVo.setToken(token);
        // 封装返回参数        Map<String,Object> resultData = new HashMap<>(3);        resultData.put("meta", JSONResult.login());        resultData.put("data",successVo);
        JSONResult.responseJson(response,resultData);    }}

6. 登出成功处理类

@Componentpublic class UserLogoutSuccessHandler implements LogoutSuccessHandler {    /**     * 用户登出返回结果     * 这里应该让前端清除掉Token     */    @Override    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication){        Map<String,Object> resultData = new HashMap<>(2);        resultData.put("code","200");        resultData.put("msg", "登出成功");        /*         * 清空权限信息         */        SecurityContextHolder.clearContext();        JSONResult.responseJson(response,JSONResult.resultSuccess(resultData));    }}

7. 登录工具类

@Slf4j@Componentpublic class LoginUntil {
    @Autowired    private SecurityUserDetailsService userDetailsService;    @Autowired    private SmsSecurityUserDetailsService smsSecurityUserDetailsService;    @Autowired    private IUserService userService;    @Autowired    private RedisOperator redisOperator;
    public UsernamePasswordAuthenticationToken loginByUserName(HttpServletRequest request, Authentication authentication ){        log.info("执行用户名密码登录");        String code = request.getParameter("code");        String result = request.getParameter("result");        // 获取表单输入中返回的用户名        String userName = (String) authentication.getPrincipal();        // 获取表单中输入的密码        String password = (String) authentication.getCredentials();        // 查询用户是否存在        SecurityUser securityUser = userDetailsService.loadUserByUsername(userName);        if (securityUser == null) {            throw new UsernameNotFoundException("用户不存在");        }        // 我们还要判断密码是否正确,这里我们的密码使用BCryptPasswordEncoder进行加密的        if (!new BCryptPasswordEncoder().matches(password, securityUser.getPassword())) {            throw new BadCredentialsException("密码不正确");        }
        Set<GrantedAuthority> authorities = getGrantedAuthorities(code, result, securityUser);        // 进行登录        return new UsernamePasswordAuthenticationToken(securityUser, password, authorities);    }
    public UsernamePasswordAuthenticationToken loginBySms(HttpServletRequest request){        log.info("执行手机验证码登录");        String code = request.getParameter("code");        String result = request.getParameter("result");        String number =request.getParameter("number");
        SecurityUser securityUser = smsSecurityUserDetailsService.loadUserByUsername(number);        if (securityUser == null) {            throw new UsernameNotFoundException("用户不存在");        }        Set<GrantedAuthority> authorities = getGrantedAuthorities(code, result, securityUser);        return new UsernamePasswordAuthenticationToken(securityUser, "", authorities);    }
    private Set<GrantedAuthority> getGrantedAuthorities(String code, String result, SecurityUser securityUser) {        if (!redisOperator.exists(code)) {            throw new LoginCodeException("验证码不存在,请刷新验证码");        }        if (!redisOperator.getString(code).equals(result)) {            throw new LoginResultException("验证码错误");        }        // 还可以加一些其他信息的判断,比如用户账号已停用等判断        String userStatus = "PROHIBIT";        if (userStatus.equals(securityUser.getStatus())) {            throw new LockedException("该用户已被冻结");        }        // 还可以加一些其他信息的判断,比如用户账号已停用等判断        String del = "DEL";        if (del.equals(securityUser.getStatus())) {            throw new LockedException("该用户已被删除");        }        // 角色集合        Set<GrantedAuthority> authorities = new HashSet<>();        // 查询用户角色        List<Role> roleList = userService.selectRoleByUserId(securityUser.getUserId());        for (Role role : roleList) {            authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getRoleCode()));        }        securityUser.setAuthorities(authorities);        return authorities;    }}

8. 新建自定义权限注解验证类

@Componentpublic class UserPermissionEvaluator implements PermissionEvaluator {    @Autowired    private IUserService userService;    /**     * hasPermission鉴权方法     * 这里仅仅判断PreAuthorize注解中的权限表达式     * 实际中可以根据业务需求设计数据库通过targetUrl和permission做更复杂鉴权     * 当然targetUrl不一定是URL可以是数据Id还可以是管理员标识等,这里根据需求自行设计     * @Param  authentication  用户身份(在使用hasPermission表达式时Authentication参数默认会自动带上)     * @Param  targetUrl  请求路径     * @Param  permission 请求路径权限     * @Return boolean 是否通过     */    @Override    public boolean hasPermission(Authentication authentication, Object targetUrl, Object permission) {        // 获取用户信息        SecurityUser securityUser =(SecurityUser) authentication.getPrincipal();        // 查询用户权限(这里可以将权限放入缓存中提升效率)        Set<String> permissions = new HashSet<>();        List<Menu> menuList = userService.selectMenuByUserId(securityUser.getUserId());        for (Menu menu:menuList) {            permissions.add(menu.getMenuPermission());        }        // 权限对比        if (permissions.contains(permission.toString())){            return true;        }        return false;    }    @Override    public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {        return false;    }}

9.  自定义安全用户类

@Datapublic class SecurityUser implements Serializable, UserDetails {
    private static final long serialVersionUID = 1L;
    /**     * 用户ID     */    private Integer userId;    /**     * 用户名     */    private String username;    /**     * 登录名     */    private String loginName;    /**     * 密码     */    private String password;    /**     * 状态:NORMAL正常  PROHIBIT禁用     */    private String status;
    /**     * 多租户标示     */    private Long tenantId;
    /**     * 用户角色     */    private Collection<GrantedAuthority> authorities;    /**     * 账户是否过期     */    private boolean isAccountNonExpired = false;    /**     * 账户是否被锁定     */    private boolean isAccountNonLocked = false;    /**     * 证书是否过期     */    private boolean isCredentialsNonExpired = false;    /**     * 账户是否有效     */    private boolean isEnabled = true;

    @Override    public Collection<GrantedAuthority> getAuthorities() {        return authorities;    }    @Override    public boolean isAccountNonExpired() {        return isAccountNonExpired;    }    @Override    public boolean isAccountNonLocked() {        return isAccountNonLocked;    }    @Override    public boolean isCredentialsNonExpired() {        return isCredentialsNonExpired;    }    @Override    public boolean isEnabled() {        return isEnabled;    }}

10.  Security工具类

public class SecurityUntil {
    /**     * 私有化构造器     */    private SecurityUntil(){}
    /**     * 获取当前用户信息     */    public static SecurityUser getUserInfo(){        SecurityUser userDetails = (SecurityUser) SecurityContextHolder.getContext().getAuthentication() .getPrincipal();        return userDetails;    }    /**     * 获取当前用户ID     */    public static Integer getUserId(){        return getUserInfo().getUserId();    }    /**     * 获取当前用户账号     */    public static String getUserName(){        return getUserInfo().getUsername();    }
    public static String getLoginName(){        return getUserInfo().getLoginName();    }
    /**     * 获取当前用户角色     */    public static List<GrantedAuthority> getRole(){        List<GrantedAuthority> authorities = (List<GrantedAuthority>) SecurityContextHolder.getContext().getAuthentication().getAuthorities();        return authorities;    }
    /**     * 获取当前租户信息     * @return     */    public static Long getTenantId(){        return getUserInfo().getTenantId();    }

11.  自定义用户详情业务处理类

@Componentpublic class SecurityUserDetailsService implements UserDetailsService {
    @Autowired    private IUserService userService;
    /**     * 查询用户信息     * @param   username  用户名     * @return  UserDetails SpringSecurity用户信息     */    @Override    public SecurityUser loadUserByUsername(String username) throws UsernameNotFoundException {        // 查询用户信息        UserInfoBO user =userService.getUserInfo(username);        if (user!=null){            // 组装参数            SecurityUser securityUser = new SecurityUser();            BeanUtils.copyProperties(user,securityUser);            return securityUser;        }        return null;    }}

12.  自定义用户详情业务处理类(根据手机号)

@Componentpublic class SmsSecurityUserDetailsService implements UserDetailsService {
    @Autowired    private IUserService userService;
    @Override    public SecurityUser loadUserByUsername(String s) throws UsernameNotFoundException {        // 查询用户信息        User user =userService.selectUserByPhoneNumber(s);        if (user!=null){            // 组装参数            SecurityUser securityUser = new SecurityUser();            BeanUtils.copyProperties(user,securityUser);            return securityUser;        }        return null;    }}

13. 新建security配置类

/** * SpringSecurity配置类 * EnableGlobalMethodSecurity 开启权限注解,默认是关闭的 * @author kang * @date 2020-01-02 */@Configuration@EnableWebSecurity@EnableGlobalMethodSecurity(prePostEnabled = true)public class SecurityConfig extends WebSecurityConfigurerAdapter {    /**     * 自定义登录成功处理器     */    @Autowired    private UserLoginSuccessHandler userLoginSuccessHandler;    /**     * 自定义登录失败处理器     */    @Autowired    private UserLoginFailureHandler userLoginFailureHandler;    /**     * 自定义注销成功处理器     */    @Autowired    private UserLogoutSuccessHandler userLogoutSuccessHandler;    /**     * 自定义暂无权限处理器     */    @Autowired    private UserAuthAccessDeniedHandler userAuthAccessDeniedHandler;    /**     * 自定义未登录的处理器     */    @Autowired    private UserAuthenticationEntryPointHandler userAuthenticationEntryPointHandler;    /**     * 自定义登录逻辑验证器     */    @Autowired    private UserAuthenticationProvider userAuthenticationProvider;
    /**     * 加密方式     */    @Bean    public BCryptPasswordEncoder bCryptPasswordEncoder(){        return new BCryptPasswordEncoder();    }
    /**     * 注入自定义PermissionEvaluator     */    @Bean    public DefaultWebSecurityExpressionHandler userSecurityExpressionHandler(){        DefaultWebSecurityExpressionHandler handler = new DefaultWebSecurityExpressionHandler();        handler.setPermissionEvaluator(new UserPermissionEvaluator());        return handler;    }
    /**     * 配置登录验证逻辑     */    @Override    protected void configure(AuthenticationManagerBuilder auth){        //这里可启用我们自己的登陆验证逻辑        auth.authenticationProvider(userAuthenticationProvider);    }
    /**     *  允许跨域     */    @Bean    public WebMvcConfigurer corsConfigurer() {        return new WebMvcConfigurerAdapter() {            @Override            public void addCorsMappings(CorsRegistry registry) {                registry.addMapping("/**").allowedOrigins("*")                        .allowedMethods("GET", "HEAD", "POST","PUT", "DELETE", "OPTIONS")                        .allowCredentials(false).maxAge(3600);            }        };    }
    /**     * 配置security的控制逻辑     * @param http 请求     */    @Override    protected void configure(HttpSecurity http) throws Exception {        //开启跨域        http.cors().and()                // 取消跨站请求伪造防护                .csrf().disable()                .authorizeRequests()                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()                // 放行Security相关请求                .antMatchers("/user/code","/user/test"                        ,"/swagger-ui.html","/webjars/**","/swagger-resources/**"                        ,"/v2/*","/druid/**","/sms/send","/user/verify/**","/excel/**")                .anonymous()                // 不进行权限验证的请求或资源(从配置文件中读取)                .antMatchers(JWTConfig.antMatchers.split(","))                .permitAll()                // 其他的需要登陆后才能访问                .anyRequest().authenticated()                .and()                // 配置未登录自定义处理类                .httpBasic().authenticationEntryPoint(userAuthenticationEntryPointHandler)                .and()                // 配置登录地址                .formLogin()                .loginProcessingUrl("/login/userLogin")                // 配置登录成功自定义处理类                .successHandler(userLoginSuccessHandler)                // 配置登录失败自定义处理类                .failureHandler(userLoginFailureHandler)                .and()                // 配置登出地址                .logout()                .logoutUrl("/login/userLogout")                // 配置用户登出自定义处理类                .logoutSuccessHandler(userLogoutSuccessHandler)                .and()                // 配置没有权限自定义处理类                .exceptionHandling().accessDeniedHandler(userAuthAccessDeniedHandler);        // 基于Token不需要session        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);        // 禁用缓存并开启iframe调用        http.headers().cacheControl().and().frameOptions().disable();        // 添加JWT过滤器        http.addFilter(new JWTAuthenticationTokenFilter(authenticationManager()));    }}

14 . 新建登录验证码错误异常类和验证码失效异常类

public class LoginCodeException extends AuthenticationException {
    public LoginCodeException(String msg) {        super(msg);    }}
public class LoginResultException extends AuthenticationException {
    public LoginResultException(String msg) {        super(msg);    }}

15. 统一返回类

package com.cloud.utils.json;
import com.alibaba.fastjson.JSON;import com.fasterxml.jackson.databind.JsonNode;import com.fasterxml.jackson.databind.ObjectMapper;
import javax.servlet.ServletResponse;import java.io.PrintWriter;import java.io.Serializable;import java.util.HashMap;import java.util.List;import java.util.Map;
/** * * @author Administrator * @Description: 自定义响应数据结构 *         这个类是提供给门户,ios,安卓,微信商城用的 *         门户接受此类数据后需要使用本类的方法转换成对于的数据类型格式(类,或者list) *         其他自行处理 *         200:表示成功 *         500:表示错误,错误信息在msg字段中 *         501:bean验证错误,不管多少个错误都以map形式返回 *         502:拦截器拦截到用户token出错 *         555:异常抛出信息 * @author 康东伟 * @date 2021/5/14 */public class JSONResult implements Serializable {    private static final long serialVersionUID = 1L;    /**     *  定义jackson对象     */    private static final ObjectMapper MAPPER = new ObjectMapper();    /**     *  响应业务状态     */    private Integer status;    /**     *  响应消息     */    private String msg;    /**     * 响应中的数据     */    private Object data;    /**     *  不使用     */    private String ok;
    public static JSONResult build(Integer status, String msg, Object data) {        return new JSONResult(status, msg, data);    }
    public static JSONResult ok(Object data) {        return new JSONResult(data);    }
    public static JSONResult ok() {        return new JSONResult(null);    }
    public static JSONResult errorMsg(String msg) {        return new JSONResult(500, msg, null);    }
    public static JSONResult login() {        return new JSONResult(200, "登录成功", null);    }
    public static JSONResult sendSmsOk(Object object,String msg) {        return new JSONResult(object,msg);    }
    public static JSONResult errorNofind(String msg) {        return new JSONResult(404, msg, null);    }
    public static JSONResult errorMap(Object data) {        return new JSONResult(501, "error", data);    }
    public static JSONResult errorTokenMsg(String msg) {        return new JSONResult(502, msg, null);    }
    public static JSONResult errorException(String msg) {        return new JSONResult(555, msg, null);    }
    public JSONResult() {
    }
//    public static LeeJSONResult build(Integer status, String msg) {//        return new LeeJSONResult(status, msg, null);//    }
    public JSONResult(Integer status, String msg, Object data) {        this.status = status;        this.msg = msg;        this.data = data;    }
    public JSONResult(Object data) {        this.status = 200;        this.msg = "OK";        this.data = data;    }
    public JSONResult(Object data,String msg) {        this.status = 200;        this.msg = msg;        this.data = data;    }
    public Boolean isOK() {        return this.status == 200;    }
    public Integer getStatus() {        return status;    }
    public void setStatus(Integer status) {        this.status = status;    }
    public String getMsg() {        return msg;    }
    public void setMsg(String msg) {        this.msg = msg;    }
    public Object getData() {        return data;    }
    public void setData(Object data) {        this.data = data;    }
    /**     *     * @Description: 将json结果集转化为LeeJSONResult对象     *         需要转换的对象是一个类     * @param jsonData     * @param clazz     * @return     */    public static JSONResult formatToPojo(String jsonData, Class<?> clazz) {        try {            if (clazz == null) {                return MAPPER.readValue(jsonData, JSONResult.class);            }            JsonNode jsonNode = MAPPER.readTree(jsonData);            JsonNode data = jsonNode.get("data");            Object obj = null;            if (clazz != null) {                if (data.isObject()) {                    obj = MAPPER.readValue(data.traverse(), clazz);                } else if (data.isTextual()) {                    obj = MAPPER.readValue(data.asText(), clazz);                }            }            return build(jsonNode.get("status").intValue(), jsonNode.get("msg").asText(), obj);        } catch (Exception e) {            return null;        }    }
    /**     *     * @Description: 没有object对象的转化     * @param json     * @return     */    public static JSONResult format(String json) {        try {            return MAPPER.readValue(json, JSONResult.class);        } catch (Exception e) {            e.printStackTrace();        }        return null;    }
    /**     *     * @Description: Object是集合转化     *         需要转换的对象是一个list     * @param jsonData     * @param clazz     * @return     *     */    public static JSONResult formatToList(String jsonData, Class<?> clazz) {        try {            JsonNode jsonNode = MAPPER.readTree(jsonData);            JsonNode data = jsonNode.get("data");            Object obj = null;            if (data.isArray() && data.size() > 0) {                obj = MAPPER.readValue(data.traverse(),                        MAPPER.getTypeFactory().constructCollectionType(List.class, clazz));            }            return build(jsonNode.get("status").intValue(), jsonNode.get("msg").asText(), obj);        } catch (Exception e) {            return null;        }    }
    public String getOk() {        return ok;    }
    public void setOk(String ok) {        this.ok = ok;    }

    /**     * 使用response输出JSON 用于security     * @Author Sans     * @CreateTime 2019/9/28 11:23     * @Param  resultMap 数据     * @Return void     */    public static void responseJson(ServletResponse response, Map<String, Object> resultMap){        PrintWriter out = null;        try {            response.setCharacterEncoding("UTF-8");            response.setContentType("application/json");            out = response.getWriter();            out.println(JSON.toJSONString(resultMap));        } catch (Exception e) {            System.out.println(e.getMessage());        }finally{            if(out!=null){                out.flush();                out.close();            }        }    }    /**     * 返回成功示例 用于security     * @Author Sans     * @CreateTime 2019/9/28 11:29     * @Param  resultMap  返回数据MAP     * @Return Map<String,Object> 返回数据MAP     */    public static Map<String, Object> resultSuccess(Map<String, Object> resultMap){        resultMap.put("message","操作成功");        resultMap.put("code", 200);        return resultMap;    }
    /**     * 返回成功map集合 用于手机验证码     * @Author Sans     * @CreateTime 2019/9/28 11:29     * @Param  resultMap  返回数据MAP     * @Return Map<String,Object> 返回数据MAP     */    public static Map<String, Object> map(Integer s){        Map<String, Object> resultMap = new HashMap<>();        resultMap.put("code", s);        return resultMap;    }
    /**     * 返回成功示例 用于security     * @Author Sans     * @CreateTime 2019/9/28 11:29     * @Param  resultMap  返回数据MAP     * @Return Map<String,Object> 返回数据MAP     */    public static Map<String, Object> error(){        Map<String, Object> resultMap = new HashMap<>();        resultMap.put("message","token error");        resultMap.put("code", 500);        return resultMap;    }
    /**     * 返回成功示例 用于security     * @Author Sans     * @CreateTime 2019/9/28 11:29     * @Param  resultMap  返回数据MAP     * @Return Map<String,Object> 返回数据MAP     */    public static Map<String, Object> filterError(String msg){        Map<String, Object> resultMap = new HashMap<>();        resultMap.put("message",msg);        resultMap.put("code", 500);        return resultMap;    }
    /**     * 返回成功示例 用于security     * @Author Sans     * @CreateTime 2019/9/28 11:29     * @Param  resultMap  返回数据MAP     * @Return Map<String,Object> 返回数据MAP     */    public static Map<String, Object> NotFound(String msg){        Map<String, Object> resultMap = new HashMap<>();        resultMap.put("message",msg);        resultMap.put("code", 404);        return resultMap;    }
    /**     * 返回失败示例 用于security     * @Author Sans     * @CreateTime 2019/9/28 11:31     * @Param  resultMap  返回数据MAP     * @Return Map<String,Object> 返回数据MAP     */    public static JSONResult resultError(String message){        return errorMsg(message);    }
    /**     * 通用示例 用于security     * @Author Sans     * @CreateTime 2019/9/28 11:35     * @Param  code 信息码     * @Param  msg  信息     * @Return Map<String,Object> 返回数据MAP     */    public static Map<String, Object> resultCode(Integer code,String msg){        Map<String, Object> resultMap = new HashMap<>();        resultMap.put("message",msg);        resultMap.put("code",code);        return resultMap;    }

    /**     * redis 验证码失效     * @return     */    public static JSONResult redisExistsError() {        return new JSONResult(404, "验证码失效 请刷新", null);    }
    /**     * redis 验证码错误     * @return     */    public static JSONResult redisResultError() {        return new JSONResult(500, "验证码错误 请重试", null);    }
}

到现在SpringSecurity已经整合完成

redis这里就不进行讲解了 不会的小伙伴请看

springboot教程之整合 Redis 详解

图片

这里需要post请求携带header,所以建议安装postman

图片

获取验证码

图片

请求登录

图片

图片

测试验证码过期,时间为1分钟

图片

测试验证码输入错误

图片

测试用户名错误

图片

测试密码错误

图片

测试权限管理

图片

图片

测试全部通过

图片

 其他例子就不一一测试了,如果有需要小伙伴自己测试吧

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值