【Spring Security系列】权限之旅:SpringSecurity小程序登录深度探索

作者:后端小肥肠

创作不易,未经允许严禁转载。

姊妹篇:

【Spring Security系列】Spring Security+JWT+Redis实现用户认证登录及登出_spring security jwt 退出登录-CSDN博客

1. 前言

欢迎来到【Spring Security系列】!在当今数字化时代,安全是任何应用程序都必须优先考虑的核心问题之一。而在众多安全框架中,Spring Security 作为一个功能强大且广泛应用的安全框架,为 Java 应用程序提供了全面的身份验证、授权、攻击防护等功能。而随着移动应用的普及,小程序作为一种轻量级、跨平台的应用形式,其安全性也成为了开发者们关注的焦点。本文将带领您深入探索如何使用 Spring Security 来保护小程序的登录认证,旨在为您提供全方位的学习体验和实践指导。

2. 小程序登录涉及SpringSecurity核心组件介绍

如果要在SpringSecurity默认的用户名密码模式登录模式上扩展小程序登录,涉及到的核心组件如下:

  1. AuthenticationProvider

    创建自定义的AuthenticationProvider,负责处理从微信开放平台获取的用户信息,并进行身份验证。
  2. UserDetailsService

    调整UserDetailsService来获取并管理基于微信OpenID的用户信息。
  3. AuthenticationManager

    确保您的自定义AuthenticationProvider被正确注册到AuthenticationManager中,以便处理小程序登录请求。
  4. SecurityConfigurer

    创建一个SecurityConfigurer来配置Spring Security以支持小程序登录,并将其添加到Spring Security的配置类中。
  5. Filter

    创建一个自定义的过滤器来拦截和处理小程序登录请求,提取微信登录凭证,并将其传递给您的自定义AuthenticationProvider进行处理。

要扩展Spring Security以支持小程序登录,您需要创建自定义的AuthenticationProvider并调整UserDetailsService以处理微信OpenID的用户信息。

3. SpringSecurity集成小程序登录原理

3.1. 小程序登录流程

以下是微信官方文档中小程序登录的流程:

由上图可看出,小程序登录使用微信提供的登录凭证 code,通过微信开放平台的接口获取用户的唯一标识 OpenID 和会话密钥 SessionKey。在集成小程序登录时,我们需要将这些凭证传递给后端服务器,由后端服务器进行校验和处理,最终完成用户的登录认证。

3.2. SpringSecurity集成小程序登录流程梳理

结合SpringSecurity原理,在SpringSecurity中集成小程序登录的流程如下:

  • 小程序端:通过微信登录接口获取登录凭证 code,这里取名为loginCode。
  • 小程序端,通过手机号快速验证组件获取phoneCode。
  • 小程序端,获取用户昵称(nickName)和用户头像(imageUrl)地址。
  • 小程序端:将登录凭证 loginCodephoneCodenickNameimageUrl发送给后端服务器。
  • 后端服务器:接收到登录凭证 loginCode后,调用微信开放平台的接口,换取用户的唯一标识 OpenID 和会话密钥 SessionKey
  • 后端服务器:根据 OpenID 查询用户信息,如果用户不存在,则创建新用户;如果用户已存在,则返回用户信息。
  • 后端服务器:生成用户的身份认证信息JWT Token,返回给小程序端。
  • 小程序端:存储用户的身份认证信息,后续请求携带该信息进行访问控制。

大体流程只是在3.1小程序登录流程上做了细化,图我就不画了(因为懒)。

3.3. 小程序登录接口设计

小程序登录接口如下图所示:

由上图所示,我们需要传入loginCode,phoneCode(获取手机号),nickName(昵称用于登录后展示),imageUrl(头像用于登录后展示)这几个必传参数。

4. 核心代码讲解

4.1. 小程序端获取必要参数

1. 小程序端调用微信登录接口,获取用户登录凭证 loginCode。

wx.login({
  success (res) {
    if (res.code) {
      //发起网络请求
      wx.request({
        url: 'https://example.com/onLogin',
        data: {
          code: res.code
        }
      })
    } else {
      console.log('登录失败!' + res.errMsg)
    }
  }
})

2. 获取PhoneCode

3. 获取头像昵称

4.2. 编写WeChatAuthenticationFilter

public class WeChatAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    private final String loginCode = "loginCode";
    private final String phoneCode="phoneCode";
    private final String nickName="nickName";
    private final String imageUrl="imageUrl";

    public WeChatAuthenticationFilter(String appId, String secret) {
        super(new AntPathRequestMatcher("/wx/login", "POST"));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        String loginCode = obtainLoginCode(request)==null?"":obtainLoginCode(request).trim();
        String phoneCode=obtainPhoneCode(request)==null?"":obtainPhoneCode(request).trim();
        String nickName=obtainNickName(request)==null?"":obtainNickName(request).trim();
        String imageUrl=obtainImageUrl(request)==null?"":obtainImageUrl(request).trim();


        WechatAuthenticationToken authRequest = new WechatAuthenticationToken(loginCode,phoneCode,nickName,imageUrl);

        return this.getAuthenticationManager().authenticate(authRequest);
    }

    protected String obtainLoginCode(HttpServletRequest request) {
        return request.getParameter(loginCode);
    }

    protected String obtainPhoneCode(HttpServletRequest request){return request.getParameter(phoneCode);}

    protected String obtainNickName(HttpServletRequest request){return request.getParameter(nickName);}

    protected String obtainImageUrl(HttpServletRequest request){return request.getParameter(imageUrl);}
}

以上代码定义了一个名为WeChatAuthenticationFilter的类,它继承自AbstractAuthenticationProcessingFilter类,用于处理微信登录认证。在构造函数中,指定了请求匹配路径为"/wx/login",请求方法为POST。类中定义了四个常量:loginCode、phoneCode、nickName和imageUrl,分别表示登录码、手机号码、昵称和头像URL。

attemptAuthentication方法中,首先通过obtainLoginCode、obtainPhoneCode、obtainNickName和obtainImageUrl方法获取请求中的登录码、手机号码、昵称和头像URL,并进行了空值处理。然后将这些信息封装到WechatAuthenticationToken对象中,并通过getAuthenticationManager().authenticate方法进行认证。

4.3. 编写WeChatAuthenticationProvider

@Slf4j
public class WeChatAuthenticationProvider implements AuthenticationProvider {
    private final WechatConfig wechatConfig;
    private  RestTemplate restTemplate;
    private final WeChatService weChatService;
    private final ISysUserAuthService sysUserAuthService;

    public WeChatAuthenticationProvider(WechatConfig wechatConfig, WeChatService weChatService,ISysUserAuthService sysUserAuthService) {
        this.wechatConfig = wechatConfig;
        this.weChatService = weChatService;
        this.sysUserAuthService=sysUserAuthService;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        WechatAuthenticationToken wechatAuthenticationToken = (WechatAuthenticationToken) authentication;
        String loginCode = wechatAuthenticationToken.getPrincipal().toString();
        log.info("loginCode is {}",loginCode);
        String phoneCode=wechatAuthenticationToken.getPhoneCode().toString();
        log.info("phoneCode is {}",phoneCode);
        String nickName=wechatAuthenticationToken.getNickName().toString();
        log.info("nickName is {}",nickName);
        String imageUrl=wechatAuthenticationToken.getImageUrl().toString();
        log.info("imageUrl is {}",imageUrl);
        restTemplate=new RestTemplate();
        //获取openId
        JwtUser jwtUser=null;
        String url = "https://api.weixin.qq.com/sns/jscode2session?appid={appid}&secret={secret}&js_code={code}&grant_type=authorization_code";
        Map<String, String> requestMap = new HashMap<>();
        requestMap.put("appid", wechatConfig.getAppid());
        requestMap.put("secret", wechatConfig.getSecret());
        requestMap.put("code", loginCode);
        ResponseEntity<String> responseEntity = restTemplate.getForEntity(url, String.class,requestMap);
        JSONObject jsonObject= JSONObject.parseObject(responseEntity.getBody());
        log.info(JSONObject.toJSONString(jsonObject));
        String openId=jsonObject.getString("openid");
        if(StringUtils.isBlank(openId)) {
            throw new BadCredentialsException("weChat get openId error");
        }
        if(sysUserAuthService.getUserAuthCountByIdentifier(openId)>0){
            jwtUser = (JwtUser) weChatService.getUserByOpenId(openId);
            if(!jwtUser.isEnabled()){
                throw new BadCredentialsException("用户已失效");
            }
            return getauthenticationToken(jwtUser,jwtUser.getAuthorities());
        }
        //获取手机号第一步,获取accessToken
        String accessTokenUrl="https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appid}&secret={secret}";
        Map<String, String> accessTokenRequestMap = new HashMap<>();
        accessTokenRequestMap.put("appid", wechatConfig.getAppid());
        accessTokenRequestMap.put("secret", wechatConfig.getSecret());
        ResponseEntity<String>  accessTokenResponseEntity = restTemplate.getForEntity(accessTokenUrl, String.class,accessTokenRequestMap);
        JSONObject  accessTokenJsonObject= JSONObject.parseObject(accessTokenResponseEntity.getBody());
        log.info(JSONObject.toJSONString(accessTokenJsonObject));
        String  accessToken=accessTokenJsonObject.getString("access_token");
        if(StringUtils.isBlank(accessToken)) {
            throw new BadCredentialsException("weChat get accessToken error");
        }
        //获取手机号第二部,远程请求获取手机号
        String pohoneUrl="https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token="+accessToken+"";
        JSONObject phoneJson=new JSONObject();
        phoneJson.put("code",phoneCode);
        String resPhoneStr= RestTemplateUtil.postForJson(pohoneUrl,phoneJson,restTemplate);
        log.info(resPhoneStr);
        JSONObject resPhonJson= JSON.parseObject(resPhoneStr);
        JSONObject phoneInfo=resPhonJson.getJSONObject("phone_info");
        String mobile=phoneInfo.getString("phoneNumber");
        if(StringUtils.isBlank(mobile)){
            throw new BadCredentialsException("Wechat get mobile error");
        }
        jwtUser= (JwtUser) weChatService.getUserByMobile(mobile,nickName,imageUrl);
        sysUserAuthService.saveUserAuth(new AddUserAuthReq(jwtUser.getUid(),"wechat",openId));
        return getauthenticationToken(jwtUser,jwtUser.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return WechatAuthenticationToken.class.isAssignableFrom(authentication);
    }
    public WechatAuthenticationToken getauthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities){
        WechatAuthenticationToken authenticationToken=new WechatAuthenticationToken(principal,authorities);
        LinkedHashMap<Object, Object> linkedHashMap = new LinkedHashMap<>();
        linkedHashMap.put("principal", authenticationToken.getPrincipal());
        authenticationToken.setDetails(linkedHashMap);
        return authenticationToken;
    }
}

上述代码是一个自定义的认证提供者类,名为WeChatAuthenticationProvider。其主要功能是处理微信登录认证。在authenticate方法中,首先从传入的Authentication对象中提取出微信登录所需的参数,包括登录码、手机号码、昵称和头像URL。然后通过RestTemplate发送HTTP请求到微信API获取用户的openId,以验证用户身份。若成功获取openId,则检查系统中是否存在该用户的认证信息,若存在则直接返回认证token;若不存在,则继续获取用户的手机号,并根据手机号获取用户信息,并保存用户认证信息。最后,返回经过认证的token。

4.4. 编写WechatAuthenticationToken

public class WechatAuthenticationToken extends AbstractAuthenticationToken {

    private final Object principal;

    private  Object phoneCode;

    private Object nickName;

    private Object imageUrl;

    public WechatAuthenticationToken(String loginCode,String phoneCode,String nickName,String imageUrl) {
        super(null);
        this.principal = loginCode;
        this.phoneCode=phoneCode;
        this.nickName=nickName;
        this.imageUrl=imageUrl;
        setAuthenticated(false);
    }

    public WechatAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        super.setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return null;
    }

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

    public Object getPhoneCode() {
        return phoneCode;
    }

    public Object getNickName() {
        return nickName;
    }

    public Object getImageUrl() {
        return imageUrl;
    }
}

4.5. WechatConfig

@Data
@Component
@ConfigurationProperties(prefix="wechat")
public class WechatConfig {
    private String appid;
    private String secret;
}

4.6. 更新WebSecurityConfigurer

@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {

    @Autowired
    @Qualifier("authUserDetailsServiceImpl")
    private UserDetailsService userDetailsService;

    @Autowired
    private SecurOncePerRequestFilter securOncePerRequestFilter;
    @Autowired
    private SecurAuthenticationEntryPoint securAuthenticationEntryPoint;
    @Autowired
    private SecurAccessDeniedHandler securAccessDeniedHandler;

    //登录成功处理器
    @Autowired
    private SecurAuthenticationSuccessHandler securAuthenticationSuccessHandler;
    @Autowired
    private SecurAuthenticationFailureHandler securAuthenticationFailureHandler;

    //退出处理器
    @Autowired
    private SecurLogoutHandler securLogoutHandler;
    @Autowired
    private SecurLogoutSuccessHandler securLogoutSuccessHandler;

    @Autowired
    BCryptPasswordEncoderUtil bCryptPasswordEncoderUtil;


    @Value("${wechat.appid}")
    private String appId;
    @Value("${wechat.secret}")
    private String secret;

    @Autowired
    WechatConfig wechatConfig;

    @Autowired
    private  WeChatService weChatService;
    @Autowired
    private ISysUserAuthService sysUserAuthService;

//    @Autowired
//    DynamicPermission dynamicPermission;

    /**
     * 从容器中取出 AuthenticationManagerBuilder,执行方法里面的逻辑之后,放回容器
     *
     * @param authenticationManagerBuilder
     * @throws Exception
     */
    @Autowired
    public void configureAuthentication(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
        authenticationManagerBuilder.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoderUtil);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        //第1步:解决跨域问题。cors 预检请求放行,让Spring security 放行所有preflight request(cors 预检请求)
        http.authorizeRequests().requestMatchers(CorsUtils::isPreFlightRequest).permitAll();

        //第2步:让Security永远不会创建HttpSession,它不会使用HttpSession来获取SecurityContext
        http.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and().headers().cacheControl();

        //第3步:请求权限配置
        //放行注册API请求,其它任何请求都必须经过身份验证.
        http.authorizeRequests()
//                .antMatchers("/**").permitAll()
                .antMatchers(HttpMethod.POST,"/sys-user/register").permitAll()
                .antMatchers(HttpMethod.GET,"/temp/create","/department/enable-department","/instance/**","/file/download/**").permitAll()
                .antMatchers("/css/**", "/js/**", "/images/**", "/fonts/**","/editor-app/**","/model/**","/editor/**").permitAll()
                .antMatchers("/modeler.html/**").permitAll()
                .antMatchers("/feign/**").permitAll()
                //ROLE_ADMIN可以操作任何事情
                .antMatchers("/v2/api-docs", "/v2/feign-docs",
                        "/swagger-resources/configuration/ui",
                        "/swagger-resources","/swagger-resources/configuration/security",
                        "/swagger-ui.html", "/webjars/**").permitAll()
                .antMatchers(HttpMethod.POST, "/user/wx/login").permitAll()
                .anyRequest().authenticated();

//                .antMatchers("/**").hasAnyAuthority("USER","SUPER_ADMIN","ADMIN");
                /*
                 由于使用动态资源配置,以上代码在数据库中配置如下:
                 在sys_backend_api_table中添加一条记录
                 backend_api_id=1,
                 backend_api_name = 所有API,
                 backend_api_url=/**,
                 backend_api_method=GET,POST,PUT,DELETE
                 */
                //动态加载资源
//                .anyRequest().access("@dynamicPermission.checkPermisstion(request,authentication)");


        //第4步:拦截账号、密码。覆盖 UsernamePasswordAuthenticationFilter过滤器
        http.addFilterAt(securUsernamePasswordAuthenticationFilter() , UsernamePasswordAuthenticationFilter.class);

        //第5步:拦截token,并检测。在 UsernamePasswordAuthenticationFilter 之前添加 JwtAuthenticationTokenFilter
        http.addFilterBefore(securOncePerRequestFilter, UsernamePasswordAuthenticationFilter.class);

        http.addFilterBefore(weChatAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);


        //第6步:处理异常情况:认证失败和权限不足
        http.exceptionHandling().authenticationEntryPoint(securAuthenticationEntryPoint).accessDeniedHandler(securAccessDeniedHandler);

        //第7步:登录,因为使用前端发送JSON方式进行登录,所以登录模式不设置也是可以的。
        http.formLogin();

        //第8步:退出
        http.logout().addLogoutHandler(securLogoutHandler).logoutSuccessHandler(securLogoutSuccessHandler);

    }

    @Bean
    public WeChatAuthenticationFilter weChatAuthenticationFilter() throws Exception {
        WeChatAuthenticationFilter filter = new WeChatAuthenticationFilter(appId, secret);
        filter.setAuthenticationManager(authenticationManagerBean());
        filter.setAuthenticationSuccessHandler(securAuthenticationSuccessHandler);
        filter.setAuthenticationFailureHandler(securAuthenticationFailureHandler);
        return filter;
    }
    /**
     * 手动注册账号、密码拦截器
     * @return
     * @throws Exception
     */
    @Bean
    SecurUsernamePasswordAuthenticationFilter securUsernamePasswordAuthenticationFilter() throws Exception {
        SecurUsernamePasswordAuthenticationFilter filter = new SecurUsernamePasswordAuthenticationFilter();
        //成功后处理
        filter.setAuthenticationSuccessHandler(securAuthenticationSuccessHandler);
        //失败后处理
        filter.setAuthenticationFailureHandler(securAuthenticationFailureHandler);

        filter.setAuthenticationManager(authenticationManagerBean());
        return filter;
    }
    @Bean
    public WeChatAuthenticationProvider weChatAuthenticationProvider() {
        return new WeChatAuthenticationProvider(wechatConfig,weChatService,sysUserAuthService);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 添加微信登录认证提供者
        auth.authenticationProvider(weChatAuthenticationProvider());
        // 添加用户名密码登录认证提供者
        auth.authenticationProvider(daoAuthenticationProvider());
    }

    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(new BCryptPasswordEncoder());
        return provider;
    }
}

说一个容易踩坑的地方:在Spring Security中,当你配置了自定义的认证提供者(如weChatAuthenticationProvider())来处理特定类型的认证(如微信登录),如果没有同时配置默认的认证提供者(如daoAuthenticationProvider()),则原有的基于用户名和密码的认证机制不会自动生效。这是因为Spring Security的认证机制是基于一个可配置的AuthenticationManager,它管理一个AuthenticationProvider列表,这些提供者会依次尝试认证用户提交的Authentication请求。

5. 结语

在本文中以流程讲解和代码实操讲解了如何在已有用户名和密码登录的基础上,实现微信小程序登录集成。下期将介绍基于OAuth2框架如何实现小程序登录,感兴趣的同学动动你们发财的小手点点关注吧~

 6. 参考链接 

开放能力 / 用户信息 / 手机号快速验证组件 (qq.com)

  • 24
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 19
    评论
### 回答1: 要实现微信小程序登录,可以使用Spring Security提供的OAuth 2.0协议实现。以下是基本的步骤: 1. 在微信开放平台中创建小程序,获取AppID和AppSecret。 2. 在Spring Security中配置OAuth 2.0客户端,设置微信小程序的AppID、AppSecret以及授权范围。 3. 创建一个Controller,处理微信小程序登录请求。在该Controller中,使用RestTemplate向微信平台发送请求,获取access_token和openid等信息。 4. 根据openid创建用户信息,并将用户信息存储在数据库中。 5. 在Spring Security中配置自定义的UserDetailsService,根据openid从数据库中查询用户信息并返回。 6. 在Spring Security中配置自定义的AuthenticationProvider,对用户进行认证。 具体实现细节可以参考Spring Security官方文档和示例代码。 ### 回答2: Spring Security可以用于实现微信小程序登录功能。下面是实现该功能的大概步骤: 1. 配置微信小程序开放平台的AppID和AppSecret,并获取sessionKey和openid。 2. 创建一个用于处理登录请求的接口,并在该接口中获取小程序传递的code参数。 3. 使用HTTP请求,向微信服务器发送code和之前配置的AppID、AppSecret,以获取openid和sessionKey。 4. 将获取到的openid和sessionKey存储在数据库中,作为用户的登录凭证。 5. 创建一个用户实体类,并添加相应的字段,比如openid、sessionKey等。 6. 实现一个自定义的UserDetailsService接口,用于根据openid查询用户信息。 7. 创建一个TokenGranter类,用于创建自定义的Token,包含openid和sessionKey等信息。 8. 实现一个自定义的AuthenticationProvider类,用于根据Token进行认证,并授权用户的访问权限。 9. 创建一个自定义的AuthenticationFilter类,用于处理登录请求,并验证用户的Token是否有效。 10. 将上述配置添加到Spring Security的配置类中,并配置相关的路径和权限。 通过上述步骤,我们可以实现微信小程序登录功能。用户通过小程序登录后,系统会根据openid查询用户信息,并通过Token进行认证和授权,确保用户可以访问相应的资源。同时,可以根据业务需求,在上述步骤中添加其他的逻辑处理。 ### 回答3: Spring Security是基于Java的安全框架,用于处理应用程序的认证和授权功能。要实现微信小程序登录,可以按照以下步骤进行: 1. 配置微信小程序登录:首先,需要在微信开发者平台注册小程序,并获取到小程序的AppID和AppSecret。然后,在Spring Security配置中,配置微信登录的认证提供商和回调URL。例如,在`SecurityConfig`类中可以使用`WeChatAuthenticationFilter`来处理微信登录流程和认证。 2. 创建WeChatAuthenticationFilter:继承`AbstractAuthenticationProcessingFilter`类,重写`attemptAuthentication`方法,实现微信登录的认证逻辑。在该方法中,将获取到的小程序code发送到微信服务器,通过code获取到微信用户的唯一标识OpenID和会话标识SessionKey。 3. 自定义AuthenticationProvider:创建一个自定义的`AuthenticationProvider`实现类,用于处理微信登录的认证逻辑。在该类中,可以根据微信的OpenID进行用户的查询和创建,生成用户的凭证信息,并返回一个实现了`Authentication`接口的认证对象。 4. 处理认证成功和失败的逻辑:在`SuccessfulAuthenticationHandler`中处理认证成功的逻辑,例如生成并返回JWT Token给前端;在`FailureAuthenticationHandler`中处理认证失败的逻辑,例如返回登录失败的提示信息给前端。 5. 配置微信登录接口和拦截器:配置微信登录的接口路径和访问权限,使用`WeChatAuthenticationFilter`拦截微信登录请求,进行认证处理。 通过以上步骤,就可以实现Spring Security微信小程序登录功能。当用户通过微信小程序登录时,将会调用相应的微信登录接口,并经过认证流程完成登录。根据需求可以进行进一步的用户信息补全、鉴权和授权等功能的实现。
评论 19
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

后端小肥肠

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

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

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

打赏作者

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

抵扣说明:

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

余额充值