spring boot整合spring security(二)--手机号验证码登陆

概述

spring security默认使用的是用户名密码的登陆,如果想要增加一种登陆方式,

就要仿照源码中用户名密码登陆的方式, 手写一套手机号验证码登陆校验, 话不多说, 开干!

搭建基础架构

搭建项目基础架构工作, 在(一)中已经完成, 这里直接在其基础上继续开发

认证流程

  • 1.进入 UsernamePasswordAuthenticationFilter 然后构建一个没有认证的 UsernamePasswordAuthenticationToken
  • 2.随后交给 AuthenticationManager 进行验证,
  • 3.AuthenticationManager 找到对应的 AuthenticationProvider进行认证
  • 4.AuthenticationProvider找到上下文中的UserDetailsService 中寻找用户然后对比
  • 5.验证成功返回 Authentication 放入 SecurityContextHolder中

这里需要自定义一个token,filter和provider

定义token

参考UsernamePasswordAuthenticationToken, 其中principal是用户名, credentials是密码,
由于这里只有手机号, 不需要密码, 所以, 将其中有关credentials的去除, 复制下来即可
在这里插入图片描述

自定义SmsCodeAuthenticationToken如下

package com.etouch.security.security.smslogin;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;

import java.util.Collection;

/*
 *这一步的作用是为了替换原有系统的 UsernamePasswordAuthenticationToken 用来做验证
 *
 * 代码都是从UsernamePasswordAuthenticationToken 里粘贴出来的
 *
 */
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    /**
     * 在 UsernamePasswordAuthenticationToken 中该字段代表登录的用户名,
     * 在这里就代表登录的手机号码
     */
    private final Object principal;

    /**
     * 构建一个没有鉴权的 SmsCodeAuthenticationToken
     */
    public SmsCodeAuthenticationToken(Object principal) {
        super(null);
        this.principal = principal;
        setAuthenticated(false);
    }

    /**
     * 构建拥有鉴权的 SmsCodeAuthenticationToken
     */
    public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        super.setAuthenticated(true); // must use super, as we override
    }



    // ~ Methods
    // 剩下的方法不用动就行了 就是从 UsernamePasswordAuthenticationToken 里粘贴出来的
    // ========================================================================================================

    public Object getCredentials() {
        return null;
    }

    public Object getPrincipal() {
        return this.principal;
    }

    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        if (isAuthenticated) {
            throw new IllegalArgumentException(
                    "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        }

        super.setAuthenticated(false);
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
    }
}

定义过滤器filter

同样参照UsernamePasswordAuthenticationFilter,将和password有关的去除, 略微修改以下, 即可完成

同样参照UsernamePasswordAuthenticationFilter图片位

自定义的SmsCodeAuthenticationFilter代码如下

package com.etouch.security.security.smslogin;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

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

/**
 * 短信登录的鉴权过滤器,模仿 UsernamePasswordAuthenticationFilter 实现
*/
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    /**
     * form表单中手机号码的字段name
     */
    public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "phone";

    private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY;
    /**
     * 是否仅 POST 方式
     */
    private boolean postOnly = true;

    public SmsCodeAuthenticationFilter() {
        // 短信登录的请求 post 方式的 /sms/login
        super(new AntPathRequestMatcher("/sys/login/phone", "POST"));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        if (postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }

        // 电话号码
        String mobile = obtainUsername(request);
        if (StringUtils.isEmpty(mobile)) {
            throw new AuthenticationServiceException("电话号码不能为空");
        }
        return this.getAuthenticationManager().authenticate(new SmsCodeAuthenticationToken(mobile));
    }


    protected String obtainUsername(HttpServletRequest request) {
        return request.getParameter(mobileParameter);
    }

    protected String obtainPassword(HttpServletRequest request) {
        return request.getParameter(mobileParameter);
    }
}

接下来是自定义手机号验证码登陆鉴证器provider

代码如下

package com.etouch.security.security.smslogin;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.util.Map;
import java.util.Objects;

/**
 * 短信登陆鉴权 Provider,要求实现 AuthenticationProvider 接口
 */
@Configuration
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;

        String mobile = (String) authenticationToken.getPrincipal();

        checkSmsCode(mobile);

        UserDetails userDetails = userDetailsService.loadUserByUsername(mobile);
        if (userDetails == null){
            throw new InternalAuthenticationServiceException("无法获取用户信息");
        }
        // 此时鉴权成功后,应当重新 new 一个拥有鉴权的 authenticationResult 返回
        SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(userDetails, userDetails.getAuthorities());

        authenticationResult.setDetails(authenticationToken.getDetails());

        return authenticationResult;
    }

    private void checkSmsCode(String mobile) {
        HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
      
        String inputCode = request.getParameter("smsCode");
        
        //这里的验证码我们放session里,这里拿出来跟用户输入的做对比
        Map<String, Object> smsLogin = (Map<String, Object>) request.getSession().getAttribute("smsLogin");
        if (smsLogin == null) {
            throw new BadCredentialsException("未检测到申请验证码");
        }

        String applyMobile = (String) smsLogin.get("phone");

        int code = (int) smsLogin.get("smsCode");

        if (!applyMobile.equals(mobile)) {
            throw new BadCredentialsException("申请的手机号码与登录手机号码不一致");
        }
        if (code != Integer.parseInt(inputCode)) {
            throw new BadCredentialsException("验证码错误");
        }
    }

    @Override
    public boolean supports(Class<?> authentication) {
        // 判断 authentication 是不是 SmsCodeAuthenticationToken 的子类或子接口
        return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
    }

    public UserDetailsService getUserDetailsService() {
        return userDetailsService;
    }

    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }
}


接下来是配置securityConfig

package com.etouch.security.security;

import com.etouch.security.security.handler.*;
import com.etouch.security.pojo.entity.SysPermission;
import com.etouch.security.security.smslogin.SmsCodeAuthenticationFilter;
import com.etouch.security.security.smslogin.SmsCodeAuthenticationProvider;
import com.etouch.security.service.SysPermissionService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;

import java.util.List;

/**
 * UsernamePasswordAuthenticationFilter拦截登录请求
 * UsernamePasswordAuthenticationFilter获取到用户名和密码构造一个UsernamePasswordAuthenticationToken传入AuthenticationManager
 * AuthenticationManager找到对应的Provider进行具体校验逻辑处理
 * 最后登录信息保存进SecurityContext
 *
 * @author chenyunchang
 */
@Configuration
@EnableWebSecurity
//开启Security注解(使用接口上的注解来控制访问权限)
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
@Slf4j
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    //登录成功处理
    @Autowired
    private SuccessAuthenticationHandler successAuthenticationHandler;

    //登录失败处理
    @Autowired
    private FailureAuthenticationHandler failureAuthenticationHandler;

    //未登录处理
    @Autowired
    private CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

    //没有权限处理
    @Autowired
    private AuthAccessDeniedHandler authAccessDeniedHandler;

    @Autowired
    private UserDetailsServiceImpl userDetailsService;

    @Autowired
    private SysPermissionService sysPermissionService;

    @Autowired
    private SmsCodeAuthenticationProvider smsCodeAuthenticationProvider;

    @Autowired
    private MyLogoutSuccessHandler myLogoutSuccessHandler;


    /**
     * 注入身份管理器bean
     *
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * 密码加密器
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        /**
         * BCryptPasswordEncoder:相同的密码明文每次生成的密文都不同,安全性更高
         */
        return new BCryptPasswordEncoder();
    }

    /**
     * 表达式	说明
     * hasRole([role])	用户拥有制定的角色时返回true (Spring security默认会带有ROLE_前缀)
     * hasAnyRole([role1,role2])	用户拥有任意一个制定的角色时返回true
     * hasAuthority([authority])	等同于hasRole,但不会带有ROLE_前缀
     * asAnyAuthority([auth1,auth2])	等同于hasAnyRole
     * permitAll	永远返回true
     * denyAll	永远返回false
     * authentication	当前登录用户的authentication对象
     * fullAuthenticated	当前用户既不是anonymous也不是rememberMe用户时返回true
     * hasIpAddress('192.168.1.0/24'))	请求发送的IP匹配时返回true
     */
    /**
     * Http安全配置
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable();
        //配置自定义登陆路径
        http.formLogin()
                //登陆接口
                .loginProcessingUrl("/sys/login")
                //自定义登陆失败处理
                .failureHandler(failureAuthenticationHandler)
                //自定义登陆成功处理
                .successHandler(successAuthenticationHandler)
                //以下是异常处理器
                .and().exceptionHandling()
                //未登录自定义返回
                .authenticationEntryPoint(customAuthenticationEntryPoint)
                //没有权限访问处理
                .accessDeniedHandler(authAccessDeniedHandler)
                .and().logout().logoutUrl("/sys/logout")
        ;
        //短信验证码登陆
//        http.apply(smsCodeAuthenticationSecurityConfig)
//                .and().formLogin().loginProcessingUrl("/sys/login/phone")
//        ;
        //短信验证码登陆验证
        //添加手机号登陆过滤器
        SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
        smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
        smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);
        http.authenticationProvider(smsCodeAuthenticationProvider)
                .addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        //查询所有权限,动态权限认证
        List<SysPermission> permissions = sysPermissionService.list();
        ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry authorizeRequests = http
                .authorizeRequests();
        permissions.forEach(permission ->
        {
            log.info("获取权限为" + permission.getPermCode());
            //将连接地址对应的权限存入
            authorizeRequests.antMatchers(permission.getUrl()).hasAnyAuthority(permission.getPermCode());
        });
        //配置无需认证的访问路径
        http.authorizeRequests()
                // 跨域预检请求
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                // 登录URL
                .antMatchers("/login/**").permitAll()
                .antMatchers("/sms/**").permitAll()
                // swagger
                .antMatchers("/swagger**/**").permitAll()
                .antMatchers("/webjars/**").permitAll()
                .antMatchers("/v2/**").permitAll()
                // 其他所有请求需要身份认证
                .anyRequest().authenticated();

        // 退出登录处理器
        http.logout()
                .logoutUrl("/logout")
                .logoutSuccessHandler(myLogoutSuccessHandler);
    }

    /**
     * 配置无需登陆就可以访问的路径
     *
     * @param web
     * @throws Exception
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        //allow Swagger URL to be accessed without authentication
        web.ignoring().antMatchers(
                //swagger api json
                "/v2/api-docs",
                //用来获取支持的动作
                "/swagger-resources/configuration/ui",
                //用来获取api-docs的URI
                "/swagger-resources",
                //安全选项
                "/swagger-resources/configuration/security",
                "/swagger-ui.html",
                "/doc.html",
                "/css/**", "/js/**"
        );
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 这里要设置自定义认证
        //手机号验证
        auth.authenticationProvider(smsCodeAuthenticationProvider);
    }
}


修改UserDetailService的实现类为

package com.etouch.security.security;

import com.etouch.security.pojo.dto.SysRoleDTO;
import com.etouch.security.pojo.dto.SysUserDTO;
import com.etouch.security.service.SysUserService;
import com.etouch.security.util.MobileUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
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 org.springframework.util.StringUtils;

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

/**
 * 自定义的认证用户获取服务类
 */
@Service
@Slf4j
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private SysUserService sysUserService;

    /**
     * 根据用户名获取认证用户信息
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if (StringUtils.isEmpty(username)) {
            log.info("UserDetailsService没有接收到用户账号");
            throw new UsernameNotFoundException("UserDetailsService没有接收到用户账号");
        } else {
            //根据用户名查找用户信息
            SysUserDTO sysUserDTO = null;
            if (MobileUtil.isMobileNO(username)) {
                //手机号验证码登陆
                sysUserDTO = sysUserService.getUserByPhone(username);
            } else {
                //用户名, 密码登陆
                sysUserDTO = sysUserService.getUserByUserName(username);
            }
            if (sysUserDTO == null) {
                throw new UsernameNotFoundException(String.format("用户不存在", username));
            }
            //新建权限集合
            List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
            //模拟从数据库获取角色权限
            List<SysRoleDTO> sysRoleDTOList = sysUserDTO.getSysRoleDTOList();
            for (SysRoleDTO sysRoleDTO : sysRoleDTOList) {
                //封装用户信息和角色信息到SecurityContextHolder全局缓存中
                grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + sysRoleDTO.getRoleName()));
            }

            //创建一个用于认证的用户对象并返回,包括:用户名,密码,角色
            return new User(sysUserDTO.getUsername(), sysUserDTO.getPassword(), grantedAuthorities);
        }
    }
}

生成验证码接口

package com.etouch.security.controller;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/sms")
@Slf4j
@Api(tags = "短信接口")
public class SmsController {

    @ApiOperation("发送短信验证码")
    @RequestMapping(value = "/code",method = RequestMethod.POST)
    public String sms(String phone, HttpServletRequest request) {
        HttpSession session = request.getSession();
        int smsCode = (int) Math.ceil(Math.random() * 9000 + 1000);
        Map<String, Object> map = new HashMap<>(16);
        map.put("phone", phone);
        map.put("smsCode", smsCode);
        session.setAttribute("smsLogin", map);
        log.info("{}:为 {} 设置短信验证码:{}", session.getId(), phone, smsCode);
        return "你的手机号"+phone+"验证码是"+smsCode;
    }


}

登陆接口

package com.etouch.security.controller;

import com.etouch.security.exception.ExceptionEnum;
import com.etouch.security.exception.ProjectException;
import com.etouch.security.pojo.dto.SysUserDTO;
import com.etouch.security.security.smslogin.SmsCodeAuthenticationToken;
import com.etouch.security.service.SysUserService;
import com.etouch.security.util.ResultUtils;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.catalina.security.SecurityUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.security.Security;

import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY;

/**
 * @author chenyunchang
 * @title
 * @date 2020/10/28 11:10
 * @Description:
 */
@RestController
@RequestMapping
@Api(tags = "登陆相关接口")
public class LoginController {

    @Autowired
    private SysUserService sysUserService;

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private UserDetailsService userDetailsService;


    @ApiOperation("用户名,密码登陆")
    @PostMapping("/login")
    public ResultUtils<SysUserDTO> login(@RequestParam String username, @RequestParam String password, HttpServletRequest request) {
        SysUserDTO sysUserDTO = sysUserService.getUserByUserName(username);
        if (sysUserDTO == null) {
            throw new ProjectException(ExceptionEnum.USER_NOT_FOUND);
        }
        BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
        boolean matches = bCryptPasswordEncoder.matches(password, sysUserDTO.getPassword());
        if (!matches) {
            throw new ProjectException(ExceptionEnum.USERNAME_OR_PASSWORD_ERRO);
        }
        sysUserDTO.setPassword(null);
        // 系统登录认证
        return ResultUtils.success("登陆成功", sysUserDTO);
    }

    @ApiOperation("手机号, 验证码登陆")
    @PostMapping("/login/phone")
    public ResultUtils<SysUserDTO> loginByPhoneAndCode(String phone, String smsCode, HttpServletRequest request) {
        //security已经错过校验, 这里不再校验验证码
        SysUserDTO sysUserDTO = sysUserService.getUserByPhone(phone);
        if (sysUserDTO == null) {
            throw new ProjectException(ExceptionEnum.USER_NOT_FOUND);
        }
        sysUserDTO.setPassword(null);
        //进行手动security登陆
        UserDetails userDetails = userDetailsService.loadUserByUsername(sysUserDTO.getUsername());
        SmsCodeAuthenticationToken smsCodeAuthenticationToken = new SmsCodeAuthenticationToken(phone, userDetails.getAuthorities());
        Authentication authenticate = authenticationManager.authenticate(smsCodeAuthenticationToken);
        SecurityContext context = SecurityContextHolder.getContext();
        context.setAuthentication(authenticate);
        HttpSession session = request.getSession(true);
        //在session中存放security context,方便同一个session中控制用户的其他操作
        session.setAttribute(SPRING_SECURITY_CONTEXT_KEY, context);
        return ResultUtils.success("登陆成功", sysUserDTO);
    }
}


测试

未登录状态

在这里插入图片描述

用户名 密码登陆

在这里插入图片描述

手机号 验证码登陆

测试前, 先退出
在这里插入图片描述
再次访问任意接口

在这里插入图片描述
提示, 未登录, 证明退出登陆成功

接下来, 进行手机号, 验证码登陆
在这里插入图片描述
未完待续…

  • 2
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

意田天

你的鼓励将是我创作的最大动力

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

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

打赏作者

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

抵扣说明:

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

余额充值