spring boot中常用的安全框架 Security框架 利用Security框架实现用户登录验证token和用户授权(接口权限控制)

spring boot中常用的安全框架
Security 和 Shiro 框架

Security 两大核心功能 认证 和 授权
重量级

Shiro 轻量级框架 不限于web 开发

在不使用安全框架的时候
一般我们利用过滤器和 aop自己实现 权限验证 用户登录

Security 实现逻辑

  1. 输入用户名和密码 提交
  2. 把提交用户名和密码封装对象
    3、4 调用方法实现验证
    5、调用方法、根据用户米查询用户信息
    6、查询用户信息返回对象
    7、密码比较
    8、填充回、返回
    9、返回对象放到上下文对象里面
    引入依赖
     <!-- Spring Security依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

刚开始测试的话 默认密码在控制台

把Security框架 使用到自己项目中
具体核心组件

- 第一步、登录接口 判断用户名和密码

自定义以下组件
1、 创建自己相对应的User 实体类 继承 org.springframework.security.core.userdetails.User
在里面定义自己的实体类字段和实现一个CustomUser()方法

package com.oa.security.custom;


import com.oa.model.system.SysUser;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

import java.util.Collection;

public class CustomUser extends User {

    /**
     * 我们自己的用户实体对象,要调取用户信息时直接获取这个实体对象。(这里我就不写get/set方法了)
     */
    private SysUser sysUser;

    public CustomUser(SysUser sysUser, Collection<? extends GrantedAuthority> authorities) {
        super(sysUser.getUsername(), sysUser.getPassword(), authorities);
        this.sysUser = sysUser;
    }

    public SysUser getSysUser() {
        return sysUser;
    }

    public void setSysUser(SysUser sysUser) {
        this.sysUser = sysUser;
    }
}

2、 重写 loadUserByUsername 方法
自定义一个 UserDetailsService 接口
继承org.springframework.security.core.userdetails.UserDetailsService 下的这个类
重写 UserDetailsService里的 loadUserByUsername方法
自定义一个 UserDetailsService 接口 的具体实现类 就是去数据库验证的实现类 比如 UserDetailsServiceImpl
在这个类 实现 loadUserByUsername 方法
实现后 最后返回 第一步创建的自定义的User 实体类
因为自己自定义的User类继承了UserDetails类
所以等于把数据交给了Security框架

UserDetailsService 接口

package com.oa.security.custom;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;

@Component
public interface UserDetailsService extends org.springframework.security.core.userdetails.UserDetailsService {

    /**
     * 根据用户名获取用户对象(获取不到直接抛异常)
     */
    @Override
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

UserDetailsServiceImpl实现接口

package com.erp.base.service;

import com.erp.api.entities.base.base.YsUser;
import com.erp.api.inteface.base.base.IYsUserService;
import com.erp.init.security.enities.CustomUser;
import com.erp.init.security.service.UserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;

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

/**
 * User: Json
 * <p>
 * Date: 2024/3/3
 * security 安全框架
 **/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private IYsUserService iYsUserService;

    @Override
    public UserDetails loadUserByUsername(String username) throws AuthenticationException {
        //根据用户名进行查询
        YsUser ysUser = iYsUserService.getUserByUserName(username);
        if(ObjectUtils.isEmpty(ysUser)) {
            //在用户登录的方法里 如果想让security 安全框架 正确抛出异常给前端 需要使用 BadCredentialsException
            // 只有使用了BadCredentialsException异常类
            // unsuccessfulAuthentication 这个方法里才能接收到异常 统一抛出
            // unsuccessfulAuthentication 这个方法是 认证失败调用的统一方法
            throw new BadCredentialsException("用户名不存在!");
        }

//        if(ysUser.getStatus().intValue() == 0) {
//            throw new ErpRuntimeException("账号已停用");
//        }

        //根据userid查询用户操作权限数据
        List<String> userPermsList =new ArrayList<>();
       // List<String> userPermsList = sysMenuService.findUserPermsByUserId(ysUser.getId());
        //创建list集合,封装最终权限数据
        List<SimpleGrantedAuthority> authList = new ArrayList<>();
        if(!CollectionUtils.isEmpty(userPermsList)){
            //查询list集合遍历
            for (String perm : userPermsList) {
                authList.add(new SimpleGrantedAuthority(perm.trim()));
            }
        }

        return new CustomUser(ysUser, authList);

    }
}

3、 自定义一个秘密校验器 CustomMd5PasswordEncoder 实现 org.springframework.security.crypto.password.PasswordEncoder 接口

package com.oa.security.custom;


import com.oa.common.utils.MD5;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

@Component
public class CustomMd5PasswordEncoder implements PasswordEncoder {
    public String encode(CharSequence rawPassword) {
        return MD5.encrypt(rawPassword.toString());
    }

    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        return encodedPassword.equals(MD5.encrypt(rawPassword.toString()));
    }
}

4、 创建一个过滤器 来验证token 比如 TokenLoginFilter
继承 org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
在方法里 定义四个方法 都是重写父类方法
定义一个构造方法
登录认证方法 获取输入的用户名和密码,调用方法认证 attemptAuthentication() 进行账号密码认证 认证实际就是执行了我们设置的第一步的内容
认证成功调用方法 successfulAuthentication() 如果认证成功 这个方法里 就处理比如生成token 存入权限 等
认证失败调用方法 unsuccessfulAuthentication() 如果认证失败 这个方法里 就处理失败的逻辑

package com.erp.init.security.filter;

import com.alibaba.fastjson.JSON;
import com.erp.api.out.R;
import com.erp.api.out.ResultCode;
import com.erp.api.request.LoginRequest;
import com.erp.api.response.ResponseUtil;
import com.erp.init.security.enities.CustomUser;
import com.erp.init.security.jwt.JwtHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

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.HashMap;
import java.util.Map;

/**
 * User: Json
 * <p>
 * Date: 2024/3/3
 * 用于登录认证
 **/
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {

    private RedisTemplate redisTemplate;
    //构造方法
    public TokenLoginFilter(AuthenticationManager authenticationManager,
                            RedisTemplate redisTemplate) {
        this.setAuthenticationManager(authenticationManager);
        this.setPostOnly(false);
        //指定登录接口及提交方式,可以指定任意路径 安全框架会从这个接口里取相应数据
        //这里 setRequiresAuthenticationRequestMatcher 定义了 /login
        // config文件中 .antMatchers("/admin/**").permitAll() 这里就不需要配置了
        // 同样 如果配置文件中 定义了 api前缀 这里是需要省略不写的
        this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/login","POST"));
        this.redisTemplate = redisTemplate;
    }

    //登录认证
    //获取输入的用户名和密码,调用方法认证
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response)
            throws AuthenticationException {
        try {
            //获取用户信息  实际就是获取的登录接口的 那个实体类里的数据
            LoginRequest loginRequest = new ObjectMapper().readValue(request.getInputStream(), LoginRequest.class);
            //封装对象 然后把用户名和密码 传入进去
            Authentication authenticationToken =
                    new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword());
            //调用方法
            return this.getAuthenticationManager().authenticate(authenticationToken);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    //认证成功调用方法
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication auth)
            throws IOException, ServletException {
        //获取当前用户
        CustomUser customUser = (CustomUser)auth.getPrincipal();
        //生成token
        String token = JwtHelper.createToken(customUser.getSysUser());

        //获取当前用户权限按钮数据,放到Redis里面 key:username   value:权限数据
        redisTemplate.opsForValue().set(customUser.getUsername(),
                JSON.toJSONString(customUser.getAuthorities()));

        //返回
        Map<String,Object> map = new HashMap<>();
        map.put("token",token);
        ResponseUtil.out(response, R.data(map));
    }

    //认证失败调用方法
    protected void unsuccessfulAuthentication(HttpServletRequest request,
                                              HttpServletResponse response,
                                              AuthenticationException failed)
            throws IOException, ServletException {
        //System.out.println(failed.getMessage());
        ResponseUtil.out(response,R.fail(ResultCode.FAILURE,failed.getMessage()));
    }

}


以上就是用户调用登录接口 利用Security框架 完成登录

第二步、认证解析token组件:解决调用其他接口时 看看用户有没有登录
判断请求头是否有token 如果有,认证完成 (通俗一点就是 判断当前是否登录)
自定义一个 TokenAuthenticationFilter 继承 org.springframework.web.filter.OncePerRequestFilter;
重写 里面的方法 doFilterInternal()

package com.erp.init.security.filter;

import com.alibaba.fastjson.JSON;
import com.erp.api.out.R;
import com.erp.api.out.ResultCode;
import com.erp.api.response.ResponseUtil;
import com.erp.init.security.LoginHelper.LoginUserInfoHelper;
import com.erp.init.security.jwt.JwtHelper;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
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.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * User: Json
 * <p>
 * Date: 2024/3/3
 * 用于处理基于令牌的身份验证请求
 **/
public class TokenAuthenticationFilter  extends OncePerRequestFilter {

    private RedisTemplate redisTemplate;

    public TokenAuthenticationFilter(RedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        //如果是登录接口,直接放行
         if("/api/login".equals(request.getRequestURI()) || request.getRequestURI().startsWith("/api/admin/")) {
            chain.doFilter(request, response);
            return;
        }

        UsernamePasswordAuthenticationToken authentication = getAuthentication(request);
        if(null != authentication) {
            SecurityContextHolder.getContext().setAuthentication(authentication);
            chain.doFilter(request, response);
        } else {
            ResponseUtil.out(response, R.fail(ResultCode.NO_USER));
        }
    }

    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        //请求头是否有token
        String token = request.getHeader("token");
        if(!StringUtils.isEmpty(token)) {
            String username = JwtHelper.getUsername(token);
            if(!StringUtils.isEmpty(username)) {
                //当前用户信息放到ThreadLocal里面  不放在 ThreadLocal  直接从请求头里取也是可以的
                // ThreadLocal 设置的变量对于每个线程都是独立的,线程之间的变量不会相互干扰
                LoginUserInfoHelper.setUserId(JwtHelper.getUserId(token));
                LoginUserInfoHelper.setUsername(username);
                LoginUserInfoHelper.setTenantId(JwtHelper.getTenantId(token));

                //通过username从redis获取权限数据
                String authString = (String)redisTemplate.opsForValue().get(username);
                //把redis获取字符串权限数据转换要求集合类型 List<SimpleGrantedAuthority>
                if(!StringUtils.isEmpty(authString)) {
                    List<Map> maplist = JSON.parseArray(authString, Map.class);
                  //  System.out.println(maplist);
                    List<SimpleGrantedAuthority> authList = new ArrayList<>();
                    for (Map map:maplist) {
                        String authority = (String)map.get("authority");
                        authList.add(new SimpleGrantedAuthority(authority));
                    }
                    return new UsernamePasswordAuthenticationToken(username,null, authList);
                } else {
                    return new UsernamePasswordAuthenticationToken(username,null, new ArrayList<>());
                }
            }
        }
        return null;
    }

}


第三步、在配置类配置相关认证类

package com.erp.init.security.config;

import com.erp.init.security.filter.TokenAuthenticationFilter;
import com.erp.init.security.filter.TokenLoginFilter;
import com.erp.init.security.service.UserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
 * User: Json
 * <p>
 * Date: 2024/3/3
 **/
@Configuration
@EnableWebSecurity //@EnableWebSecurity是开启SpringSecurity的默认行为
@EnableGlobalMethodSecurity(prePostEnabled = true)  //它允许在方法级别进行安全性控制,用于按钮权限控制
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private RedisTemplate redisTemplate;

    //导入这个接口的时候 有可能会报错找不到这个类
    //我们有自己创建了一个 UserDetailsService 接口
    // 如果导入我们的 也会报错
    // 解决方案就是 把我们自定义的 UserDetailsService 接口 继承 框架下的UserDetailsService
    // 就可以了
    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private CustomMd5PasswordEncoder customMd5PasswordEncoder;

    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    //http  不需要通过认证即可访问,比如登录页面通常是不需要认证的
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 这是配置的关键,决定哪些接口开启防护,哪些接口绕过防护
        http
                //关闭csrf跨站请求伪造
                .csrf().disable()
                // 开启跨域以便前端调用接口
                .cors().and()
                .authorizeRequests()
                // 指定某些接口不需要通过验证即可访问。登陆接口肯定是不需要认证的
                // 如果你在配置文件中
                //server:
                // port: 18181
                // servlet:
                //  context-path: /api
                //  配置了 api 前缀 context-path: /api
                //  在使用.antMatchers 这个方法的时候 不需要加 /api 直接写控制器里的路径就好
                .antMatchers("/admin/**").permitAll()
                // .permitAll() 这种权限有很多中 方法 也有根据角色的方法 使用到 百度即可
                // 注意: 如果使用了自定义的过滤器  比如 TokenAuthenticationFilter 这种  因为过滤器的优先级高
                //如果需要哪些接口 不需要token验证 直接放行 那就需要在自定义的过滤器 进行接口判断 放行
                // 过滤器 判断后 会再走这里的配置  .antMatchers("/admin/**").permitAll()  因为自定义过滤器优先级高
                // 所以 如果自定义了 过滤器 security框架放行接口 需要配置两个地方
                // 这里意思是其它所有接口需要认证才能访问
                .anyRequest().authenticated()
                .and()
                //TokenAuthenticationFilter放到UsernamePasswordAuthenticationFilter的前面,这样做就是为了除了登录的时候去查询数据库外,其他时候都用token进行认证。
                .addFilterBefore(new TokenAuthenticationFilter(redisTemplate),
                        UsernamePasswordAuthenticationFilter.class)
                .addFilter(new TokenLoginFilter(authenticationManager(),redisTemplate));


        //禁用session
//        禁用 Session 主要是为了实现基于 Token 的认证机制,提高应用程序的安全性和性能。
//
//        当禁用 Session 后,意味着每个请求都将被视为无状态的,即不再依赖于服务器端的会话状态。
//        相反,每个请求都需要携带有效的认证 Token 来进行身份验证。这种方式被称为“无状态身份验证”,
//        它将认证信息完全交给客户端处理,服务器不再存储用户的认证状态,从而减轻了服务器的负担,并提高了系统的可伸缩性和性能。
//
//        禁用 Session 适用于前后端分离的架构和无状态的 RESTful API,
//        其中客户端通常会在每个请求中携带 Token 来进行身份验证。
//        通过禁用 Session,可以更好地支持这种架构,并使应用程序更加安全和高效。
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 指定UserDetailService和加密器
        auth.userDetailsService(userDetailsService).passwordEncoder(customMd5PasswordEncoder);
    }

    /**
     * 配置哪些请求不拦截
     * 排除swagger相关请求
     * web  主要是为了过滤通常用于配置一些静态资源,如图片、样式表、脚本文件等
     * @param web
     * @throws Exception
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/swagger-resources/**",  "/swagger-ui.html/**", "/doc.html");
    }

}


用户授权

代码在上面的类里已经存在,这里只截图提现
比如按钮权限 哪些按钮课余访问
这些按钮权限 在 数据库存着 一般是给前端使用
但是这种操作 如果懂技术的人 可以绕过前端 直接访问后端api接口
为了解决这个问题 所以后端也需要做用户授权
第一步 在查询用户名密码验证的时候 把用户的按钮权限查询出来
一个按钮一般对应的是一个接口
然后交给 Security框架
在这里插入图片描述

第二步验证成功后,给前端返回token 那里从Security框架里拿出 按钮权限
然后存到redis里
在这里插入图片描述

第三步用户在请求接口的时候 这个时候把从redis里把当前用户的按钮权限拿出来
然后验证
在这里插入图片描述

第四步 在Security配置文件中添加上redis配置
在这里插入图片描述

第五步 在控制器里 controller 里添加权限注解
@PreAuthorize(“hasAuthority(‘bnt.sysRole.list’)”)
//bnt.sysRole.list 这个值 就是存在数据库里的按钮的字段 前端和后端可以共同使用
在这里插入图片描述

最后在定义以下异常处理类

  /**
     * spring security异常
     * @param e
     * @return
     */
    @ExceptionHandler(AccessDeniedException.class)
    @ResponseBody
    public Result error(AccessDeniedException e) throws AccessDeniedException {
        return Result.fail().code(205).message("没有操作权限");
    }

这样 验证和 授权 就全部完成了

用到的工具类:
JwtHelper

package com.erp.init.security.jwt;

import io.jsonwebtoken.*;
import org.springframework.util.StringUtils;

import java.util.Date;

/**
 * User: Json
 * <p>
 * Date: 2024/3/3
 **/
public class JwtHelper {
    private static long tokenExpiration = 365 * 24 * 60 * 60 * 1000;
    private static String tokenSignKey = "123456";

    //根据用户id和用户名称生成token字符串
    public static String createToken(Integer userId, String username) {
        String token = Jwts.builder()
                //分类
                .setSubject("AUTH-USER")

                //设置token有效时长
                .setExpiration(new Date(System.currentTimeMillis() + tokenExpiration))

                //设置主体部分
                .claim("userId", userId)
                .claim("username", username)

                //签名部分
                .signWith(SignatureAlgorithm.HS512, tokenSignKey)
                .compressWith(CompressionCodecs.GZIP)
                .compact();
        return token;
    }

    //从生成token字符串获取用户id
    public static Long getUserId(String token) {
        try {
            if (StringUtils.isEmpty(token)) return null;

            Jws<Claims> claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
            Claims claims = claimsJws.getBody();
            Integer userId = (Integer) claims.get("userId");
            return userId.longValue();
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    //从生成token字符串获取用户名称
    public static String getUsername(String token) {
        try {
            if (StringUtils.isEmpty(token)) return "";

            Jws<Claims> claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
            Claims claims = claimsJws.getBody();
            return (String) claims.get("username");
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    public static void main(String[] args) {
        String token = JwtHelper.createToken(6, "li4");
        System.out.println(token);
        Long userId = JwtHelper.getUserId(token);
        String username = JwtHelper.getUsername(token);
        System.out.println(userId);
        System.out.println(username);
    }
}

ResponseUtil

package com.erp.api.response;

import com.erp.api.out.R;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * User: Json
 * <p>
 * Date: 2024/3/3
 **/
public class ResponseUtil {
   // R 是返回的数据格式
    public static void out(HttpServletResponse response, R r) {
        ObjectMapper mapper = new ObjectMapper();
        response.setStatus(HttpStatus.OK.value());
        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        try {
            mapper.writeValue(response.getWriter(), r);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

LoginUserInfoHelper 类

package com.erp.init.security.LoginHelper;

/**
 * User: Json
 * <p>
 * Date: 2024/3/3
 **/
public class LoginUserInfoHelper {
    private static ThreadLocal<Integer> userId = new ThreadLocal<Integer>();
    private static ThreadLocal<String> username = new ThreadLocal<String>();

    public static void setUserId(Integer _userId) {
        userId.set(_userId);
    }
    public static Integer getUserId() {
        return userId.get();
    }
    public static void removeUserId() {
        userId.remove();
    }
    public static void setUsername(String _username) {
        username.set(_username);
    }
    public static String getUsername() {
        return username.get();
    }
    public static void removeUsername() {
        username.remove();
    }
}

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Json____

您的鼓励是我创作的动力~

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值