SpringSecurity单体应用

SpringSecurity单体应用

注:本文讲述的是Security在单体架构的应用,不支持集群跨域。另外,本文基于前后端不分离,使用的前端模板引擎是Thymeleaf。

一、导入Security依赖

第一个依赖是SpringBoot为Security提供的starter依赖,导入后,Security立即生效,会默认生成一个用户名和密码(项目重启后控制台可见),使项目中所有的请求都需要认证。
第二个依赖是thymeleaf模板引擎为支持Security提供的依赖,这个依赖其实不是必须的,下文会简单提一下它的用法。

<dependency>
   <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>

二、创建UserDetails对象

一般我们的项目中已经有了User对象,直接让它实现UserDetails接口即可。UserDetails是Security默认的用户信息存储媒介,它只存储用户名(username)、密码(password)、权限(authorities)和其他一些用户状态,具体如下:

public interface UserDetails extends Serializable {
	// 获取授予用户的权限,包括权限级别authority和级别角色role
    Collection<? extends GrantedAuthority> getAuthorities();
	// 获取用户密码
    String getPassword();
    // 获取用户名
    String getUsername();
	// 标识用户的帐户是否已过期,true(未过期)、false(已过期),过期帐户无法验证。
    boolean isAccountNonExpired();
	// 标识用户是被锁定还是未锁定,true(未锁定),false(锁定),锁定的用户无法进行身份验证。
    boolean isAccountNonLocked();
	// 标识用户的凭据(密码)是否已过期,true(未过期)、false(已过期),过期的凭据会阻止身份验证。
    boolean isCredentialsNonExpired();
	// 标识用户是启用还是禁用,true(启用),false(禁用),禁用的用户无法进行身份验证。
    boolean isEnabled();
}

通常来说,我们会在自己的User对象中创建authority(权限级别)、role(级别角色)两个属性,用于实现getAuthorities()。当然,如果你的项目很简单,不需要级别角色的定义,只创建authority属性也是可以的。
username和password相信不用我多说了,我们自己的User对象就有这两个属性,它们的get方法就是UserDetails的接口方法。
至于四个boolean类型的接口方法,如果项目中需要这些功能,就相应添加boolean类型的accountNonExpired、accountNonLocked、credentialsNonExpired、enabled属性。如果项目中没有使用的必要,就直接实现这四个方法全部设定为true即可。

User对象实现UserDetails
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements UserDetails {
    private Integer id;
    // 此处,用户名为email,并不是一定要取名username、password、authority、role,实现UserDetails接口方法时区分好这些字段即可。
    // 如果你的User对象还有其他额外的字段,对UserDetails的实现是完全没有影响的,保持它们的原样即可。
    private String email;
    private String password;
    // 注意,此处的Authority和Role是我定义的枚举类,这也是权限字段常用的定义方式。
    private Authority authority;
    private Role role;
   	
    // 账号权限
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
    	// 此处注意,因为UserDetails把authority和role都放入了authoritie属性中,所以Security规定role前加上级别角色标识符“ROLE_”,以便区分authoritie列表中哪些元素是authority,哪些是role。
    	// 当然,你也可以把级别角色标识符“ROLE_”定义在枚举类属性中,此处就可以直接传参role.toString()了。
        return AuthorityUtils.createAuthorityList(authority.toString(), "ROLE_" + role.toString());
    }
    // 账号名
    @Override
    public String getUsername() {
    	// 因为我们的用户名为email,所以需要额外实现getUsername()方法
    	// 而getPassword()方法不用实现,因为@Data注解已经帮我们实现
        return email;
    }
    // 账号没有过期
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    // 账号没有锁定
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
    // 凭证未过期
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    // 账号可用
    @Override
    public boolean isEnabled() {
        return true;
    }
}
Authority枚举类
import com.alibaba.fastjson.annotation.JSONType;

@JSONType(serializeEnumAsJavaBean = true)
public enum Authority implements BaseEnum<Authority, Integer> {
    MEMBER(1, "普通成员"),
    ADMIN(2, "普通管理员"),
    SUPER(3, "超级管理员");

    private Integer code;
    private String name;
    Authority(Integer code, String name){
        this.code = code;
        this.name = name;
    }

    @Override
    public Integer getCode() {
        return code;
    }

    @Override
    public String getName() {
        return name;
    }

    public static Authority getEnum(Integer code){
        for (Authority value : Authority.values()) {
            if(value.getCode().equals(code)){
                return value;
            }
        }
        return null;
    }
}
Role枚举类
import com.alibaba.fastjson.annotation.JSONType;

@JSONType(serializeEnumAsJavaBean = true)
public enum Role implements BaseEnum<Role, Integer> {
	// 如果在枚举类中直接适应Security,直接定义为ROLE_AD、ROLE_HR、ROLE_MD、ROLE_TD即可。
    AD(1, "行政部成员"),
    HR(2, "人力资源部成员"),
    MD(3, "市场部成员"),
    TD(3, "技术部成员");

    private Integer code;
    private String name;
    Role(Integer code, String name){
        this.code = code;
        this.name = name;
    }

    @Override
    public Integer getCode() {
        return code;
    }

    @Override
    public String getName() {
        return name;
    }

    public static Role getEnum(Integer code){
        for (Role value : Role.values()) {
            if(value.getCode().equals(code)){
                return value;
            }
        }
        return null;
    }
}

关于枚举类的使用可查看我的上一篇文章,枚举类通用接口BaseEnum有讲到它的来源与使用。

三、创建UserDetailsService对象

同样的,我们的项目中已经有了UserServiceImpl,直接让它再实现UserDetailsService接口即可。UserDetailsService是Security默认的用户信息查询接口,里面只有一个接口方法,如下:

public interface UserDetailsService {
	// 参数var1代表用户名,即根据用户名查询UserDetails对象,而我们使用User对象继承了UserDetails,所以相当于查询User对象。
    UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
}
UserServiceImpl实现UserDetailsService
@Service
public class UserServiceImpl implements UserService, UserDetailsService {
    @Resource
    private UserDao userDao;
	
	// 同样的,UserServiceImpl中还有大量实现我们自定义的UserService的方法,并不影响对UserDetailsService的实现
	@Override
	public User selectByEmail(String emails){
		return userDao.selectByEmail(email);
	}
    
	@Override
	public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
		// 因为我们的用户名是email,所以就是用email去查询User对象啦
        return this.selectByEmail(email);
    }
}

关于UserService和UserDao的实现,这里就不用细说了吧!
UserServiceImpl之所以要把selectByEmail()方法单独列出来,是因为loadUserByUsername()方法返回的是UserDetails对象,这个对象只包含了authorities、username、password等属性,这是Security想要的,但不是我们想要的,我们想要的是完整的User对象,selectByEmail返回的正是User对象,下文会讲到它的使用。

四、配置DataSource

这一步就很常规了,既然都涉及到了查询数据库的用户信息,那么,对于数据源的配置当然是不可少的。

spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/test?characterEncoding=utf-8&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=123456

五、创建SecurityConfig配置类

注意,这一步信息量可就大了,代码的每一行注释都值得仔细阅读。

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter implements ProjectConstant {
    @Autowired
    private UserService userService;
    @Autowired
    private DataSource dataSource;
    // 自定义登录失败处理器,下文会给出
    @Autowired
    private MyFailureHandler failureHandler;
    // 自定义登录验证码过滤器,下文会给出
    @Autowired
    private CaptchaFilter captchaFilter;
	
	// 封装PersistentTokenRepository对象,用于辅助实现基于Cookie和数据库的remember-me记住我功能,下文会讲到。
    @Bean
    PersistentTokenRepository tokenRepository(){
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        return tokenRepository;
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
    	// 设置Security忽略静态资源的访问拦截
        web.ignoring().antMatchers("/static/**");
    }
	
	// 自定义登录验证逻辑
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(new AuthenticationProvider() {
            @Override
            public Authentication authenticate(Authentication authentication) throws AuthenticationException {
            	// 获取前端登录表单传来的username值和password值,Security规定前端的请求参数一定要为”username“和”password“。
                String username = authentication.getName();
                String password = (String) authentication.getCredentials();
                // 使用selectByEmail()方法查询完整的User对象,而不用loadUserByUsername()方法
                // 这也是自定义登录验证逻辑的好处,如果采用Security默认的登录逻辑,使用的就是loadUserByUsername()方法
                User user = userService.selectByEmail(username);
                if(ObjectUtils.isEmpty(user)){
                    throw new UsernameNotFoundException("用户名不存在!");
                }
                // 注意,用户注册时我使用了BCryptPasswordEncoder.encode()方法对密码进行了加密,于是,登录验证时也需要使用它验证密码是否正确。
                BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
                // 注意,再使用BCryptPasswordEncoder.encode()方法对password进行加密,然后使用equals方法进行比较是错误的。
                // BCryptPasswordEncoder每次的加密结果都不一样,需使用matches()方法才能验证密码是否正确。
                boolean matches = passwordEncoder.matches(password, user.getPassword());
                if(!matches){
                    throw new BadCredentialsException("密码不正确!");
                }
                // 登录成功后,将完整的User对象,password、authorities交给Security缓存。
                // 在业务代码中,我们想要获取当前登录User对象,直接读取Security的缓存数据即可,下文会给出具体的读取方法。
                return new UsernamePasswordAuthenticationToken(user, password, user.getAuthorities());
            }
			
			// 固定写法,标识项目使用传统的用户名与字符串密码的方式登录,而不是使用第三方扫码登录、人脸识别登录等高级登录方式。
            @Override
            public boolean supports(Class<?> aClass) {
                return UsernamePasswordAuthenticationToken.class.equals(aClass);
            }
        });
    }

	// 自定义认证与授权逻辑
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //加入自定义的验证码过滤器,在用户名密码过滤器之前生效
        http.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class);
        // 指定登录路径,指定登录失败逻辑,loginPage为get访问,loginProcessingUrl为post提交,两者一个指登录页面访问路径,一个指登录表单提交路径,是不一样的。
        // defaultSuccessUrl是登录成功后的默认跳转路径,如果用户第一次访问的是登录页面,他登录后将跳转到main页面,如果用户是访问其他页面被拦截到登录页,他登录成功后将回到之前的被拦截页。
        // 这一点是Security做得比较好的,这也是不用自定义登录成功逻辑(successHandler)的原因。如果自定义登录成功后的跳转逻辑,还真做不到页面拦截记忆的效果。
        http.formLogin().loginPage("/login").loginProcessingUrl("/login").defaultSuccessUrl("/main")
                .failureHandler(failureHandler);
        // 基于数据库保存记录的记住我功能,30天免登录,共计2592000秒。Security规定前端传递的记住我参数为”remember-me“,boolean型。
        http.rememberMe().tokenRepository(tokenRepository()).tokenValiditySeconds(86400 * 30).userDetailsService((UserDetailsService) userService);
        // 指定退出登录路径,自定义登出逻辑,注意,最新的SpringSecurity版本已默认登出url为post提交方式,get提交方式不能被Security识别。
        http.logout().logoutUrl("/logout")
                .logoutSuccessHandler(new LogoutSuccessHandler() {
                    @Override
                    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                        response.sendRedirect(request.getContextPath() + "/login");
                    }
                });
        // 绑定访问路径与用户权限之间的关系
        http.authorizeRequests()
        		// login、forget(找回密码)、register(注册)、captcha(获取验证码图片)、mail/activate(获取邮箱验证码)等请求不用登录认证
                .antMatchers("/login", "/forget", "/register", "/captcha", "/mail/activate",
                        "/404", "/500").permitAll()
                // 普通管理员能访问的功能
                .antMatchers("/admin", "/admin/*").hasAuthority(Authority.ADMIN.toString())
                // 超级管理员才能访问的功能
                .antMatchers("/super", "/super/*").hasAuthority(Authority.SUPER.toString())
                // 普通管理员和超级管理员都可以访问的功能
                .antMatchers("/manager", "/manager/*").hasAnyAuthority(Authority.SECRETARY.toString(), Authority.ADMIN.toString())
                // 普通技术员工就可以访问的功能
                // 如果你的Role枚举类在设计时已经携带了级别角色前缀”ROLE_“,直接传参Role.ROLE_TD.toString()即可。
                .antMatchers("/member/technolog").hasRole("ROLE_"+Role.TD.toString())
                // 只有普通管理员和超级管理员中的技术部成员才能访问的功能
                // 注意,Security关于这方面的功能我并没有实证过,Security是否会先检查admin/*请求需要admin权限,然后再检查admin/technolog请求需要ROLE_TD角色,这需要读者自行鉴定。
                .antMatchers("/admin/technolog", "/super/technolog", "/manager/technolog").hasRole("ROLE_"+Role.TD.toString())
                .anyRequest().authenticated();
        // 自定义用户没有登录或者没有权限时的处理方式
        http.exceptionHandling()
                // 用户未登录
                .authenticationEntryPoint(new AuthenticationEntryPoint() {
                    @Override
                    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
                        String xRequestedWith = request.getHeader("X-Requested-With");
                        // 异步请求
                        if("XMLHttpRequest".equals(xRequestedWith)){
                            response.setContentType("application/json;charset=utf-8");
                            PrintWriter writer = response.getWriter();
                            writer.write(JSON.toJSONString(R.error(403, e.getMessage())));
                        }else{
                        	// 同步请求
                            response.sendRedirect(request.getContextPath() + "/login");
                        }
                    }
                })
                // 用户没有相应页面的操作权限
                .accessDeniedHandler(new AccessDeniedHandler() {
                    @Override
                    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
                        String xRequestedWith = request.getHeader("X-Requested-With");
                        // 异步请求
                        if("XMLHttpRequest".equals(xRequestedWith)){
                            response.setContentType("application/json;charset=utf-8");
                            PrintWriter writer = response.getWriter();
                            writer.write(JSON.toJSONString(R.error(403, e.getMessage())));
                        }else{
                        	// 同步请求
                            request.setAttribute(ATTRIBUTE_MESSAGE, R.error(e.getMessage()));
                            request.getRequestDispatcher("/404").forward(request, response);
                        }
                    }
                });
        // 禁用SpringSecurity默认使用X-Frame-Options防止网页被Frame,如果项目中使用到iframe层弹窗需要禁用它。
        http.headers().frameOptions().disable();
        // 不禁用SpringSecurity CSRF安全认证,开启拦截CSRF网络攻击的功能
        //http.csrf().disable();
    }
}
自定义登录失败处理器MyFailureHandler
@Component
public class MyFailureHandler extends SimpleUrlAuthenticationFailureHandler implements ProjectConstant {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        /*
         * SpringSecurity认证失败后默认返回302状态码
         * 302状态码会引起前端发起 /login重定向,从而覆盖转发内容
         * 经测试,直接通过response.setStatus(403)是无法修改成功的
         * SpringSecurity依然会修改状态码为302,细节原因暂时不清
         * 经测试,在Controller层添加 /login post方法后,response.setStatus()能设置成功
         * 而且此时不通过response.setStatus()修改状态码也能正常转发
         * 暂且把这种解决方法称作一个善意的欺骗,避免SpringSecurity产生302状态码
         *
         * 在解决此问题之前,我采用的是重定向传参的方法,但要解决中文参数乱码的问题
         * String msg = URLEncoder.encode(e.getMessage(), "UTF-8");
         * response.sendRedirect(request.getContextPath() + "/login?msg="+ msg);
         */
        request.setAttribute(ATTRIBUTE_MESSAGE, R.error(exception.getMessage()));
        request.getRequestDispatcher("/login").forward(request, response);
    }
}
自定义验证码过滤器CaptchaFilter
@Component
public class CaptchaFilter extends OncePerRequestFilter implements ProjectConstant {

    @Autowired
    private MyFailureHandler failureHandler;
    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 判断是否登录请求,登录请求才检查验证码
        if((request.getContextPath()+"/login").equals(request.getRequestURI()) && "POST".equals(request.getMethod())){
            /*下列方法是检验验证码是否正确,通过failureHandler将验证码错误信息返回给前端
            需要加return,不然filterChain.doFilter又放行,将导致response has been committed错误,
            由于全局异常类只能处理Controller层,所以需要手动捕获异常*/
            try {
                validateCaptcha(request);
            }catch (CaptchaException e){
                failureHandler.onAuthenticationFailure(request, response, e);
                return;
            }
            filterChain.doFilter(request, response);
        }
        filterChain.doFilter(request, response);
    }

    private void validateCaptcha(HttpServletRequest request) {
    	// 此处自己规定前端验证码参数必须为”captcha“
        String captcha = request.getParameter(”captcha“);
        if(StringUtils.isBlank(captcha)){
            throw new CaptchaException("验证码不能为空!");
        }
        String captchaKey = RedisKeyUtils.getCaptchaKey(request.getRemoteAddr());
        Object captchaRedis = redisTemplate.opsForValue().get(captchaKey);
        if(ObjectUtils.isEmpty(captchaRedis)){
            throw new CaptchaException("验证码已失效,请刷新!");
        }
        String captchaLogin = (String) captchaRedis;
        if(!captcha.toUpperCase().equals(captchaLogin)){
            throw new CaptchaException("验证码错误!");
        }
    }
}

此处,验证码文本我是存储在Redis中的,用request.getRemoteAddr()获取客户端IP地址作为验证码的拥有者。初学者可以存储在Session中,Session对每个用户都是独立空间,不用额外指定验证码的所有者。

Controller层的配合

Security默认会生成一个简陋的登录页,只能输入用户名和密码,没有remember-me复选框,更没有图形验证码,所以自定义我们自己的登录页肯定是必须的。这就需要Controller层的SpringMVC方法配合。

// return "login"就是返回我们自定义的登录视图页面了,这个前端页面就不用我给出来了吧!
// 这个MCV方法之所以同时绑定了POST请求,是因为我在MyFailureHandler代码注释中讲到的善意的欺骗,算是Security使用中的一个坑吧,以这种巧妙的方式解决了。
@RequestMapping(value = "/login", method = {RequestMethod.POST, RequestMethod.GET})
public String login(){
    return "login";
}
引申

提到Controller层,其实Security也支持在Controller层以注解的方式绑定请求路径和用户权限的关系。常用的注解有@Secured和@PreAuthorize。

// 表示只有技术部成员才能访问的功能
@Secured("ROLE_TD")
@ResponseBody
@GetMapping("/member/technolog")
public String teacher(){
	// 业务代码略
    return JSON.toJSONString(R.ok());
}

// 表示只有普通管理员中的技术部成员才能访问的功能
// 之前在SecurityConfig中写的hasAuthority与hasRole配合使用的例子我不知道对不对,但用注解@PreAuthorize的这个写法我确定是对的
@PreAuthorize("hasAuthority('ADMIN') && hasRole('ROLE_TD')")
@ResponseBody
@GetMapping("/admin/technolog")
public String manager(){
    // 业务代码略
    return JSON.toJSONString(R.ok());
}
前端Thymeleaf模板配合

SpringSecurity开启拦截csrf网络攻击的功能后,会默认在前端所有的form表单增加一个隐藏框,用于识别当前访问环境的安全性。如下:

<input type="hidden" name="_csrf" value="XXXXXXXXXXXXXXXXXXXXXXXX" />

那么,表单请求的确是自动携带了_csrf值,异步请求怎么办呢,异步请求不会自动携带_csrf值,默认会被Security拦截。这时候就需要我们手动携带_csrf值了,以AJAX请求为例:
1、在HTML页面指定meta标签传值_csrf.headerName与_csrf.token。

<meta name="_csrf_header" th:content="${_csrf.headerName}">
<meta name="_csrf" th:content="${_csrf.token}">

2、在JavaScript脚本中指定JAX请求携带CSRF令牌

$(document).ajaxSend(function (e, xhr, options) {
    var key = $("meta[name='_csrf_header']").attr("content");
    var value = $("meta[name='_csrf']").attr("content");
    xhr.setRequestHeader(key, value);
});

由此,AJAX异步请求就可以正常访问了。

引申

之前,我们遗留了一个问题,就是Thymeleaf为支持Security提供的依赖有什么作用?这里既然讲到了Thymeleaf,就简单提一下那个依赖的使用。
1、声明此依赖在前端模板中的对象名,就像声明th="http://www.thymeleaf.org"一样。

<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">

2、使用sec对象

<div sec:authorize ="hasAuthority('ADMIN')" >
	<!--只有拥有ADMIN权限的用户才能看到的页面元素-->
</div >
<div sec:authorize ="hasRole('ROLE_TD')" >
	<!--只有拥有ROLE_TD角色的用户才能看到的页面元素-->
</div >

可以看出,Thymeleaf提供的这个依赖是为了契合Security的配置习惯,用与后端配置类相同的hasAuthority与hasRole语句来绑定前端页面显示与用户权限的关系。
然而,在实际开发中,登录用户的User对象我们肯定会传给前端的,即我们之前缓存在Security的User对象。在所有设计转发视图的SpringMVC方法中,我们都会把User对象绑定到视图中,根据这个User对象依然可以实现判断当前用户权限的目的,进而使用th对象就可以绑定前端页面显示与用户权限的关系,不用引入sec。
1、我们可以在后端创建一个工具类,获取Security缓存的User对象,供业务层代码调用,如下:

@Component
public class SecurityHolder {
    @SneakyThrows
    public User getUser() {
        Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        if(principal instanceof User){
            return (User)principal;
        }
        throw new PrincipalException("SpringSecurity保存的Authentication对象中主要信息Principal无法转换为User对象或者为空");
    }
    
    public String getUsername(){
        return SecurityContextHolder.getContext().getAuthentication().getName();
    }
}

2、SpringMVC方法绑定视图与数据

@GetMapping("/main")
public String main(Model model){
    User user = securityHolder.getUser();
    model.addAttribute("user", user);
    return "main";
}

3、Thymeleaf识别User对象做到前端权限隔离

<div th:if ="${user.authority.code == 2}" >
	<!--只有拥有ADMIN权限(code值为2)的用户才能看到的页面元素-->
</div >
<div th:if ="${user.role.code == 4}" >
	<!--只有拥有ROLE_TD角色(code值为4)的用户才能看到的页面元素-->
</div >
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值