SpringSecurity-三更草堂学习笔记--01

1.快速开始

(1)新建SpringBoot项目,并引入依赖:

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-parent</artifactId>
	<version>2.4.5</version>
</dependency>
<dependency>
	<groupId>org.projectlombok</groupId>
	<artifactId>lombok</artifactId>
	<optional>true</optional>
</dependency>

(2)确保项目可以正常运行,然后引入SpringSecurity依赖:

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

引入依赖后再访问之前的接口会先进入login页面,需要登录才能继续访问接口,默认账号是:user,密码默认需要去启动后的控制台寻找。
在这里插入图片描述
登录后就会跳转到接口访问结果。

2.了解登录校验流程

在这里插入图片描述

3.SpringSecurity原理初探

SpringSecurity的原理其实就是一个过滤器链,其内部包含了提供各种功能的过滤器,这里我们先看看入门案例中的过滤器:
在这里插入图片描述

  • UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要有它负责;
  • ExceptionTranslationFilter:处理过滤器链中抛出的任何Access DeniedException和AuthenticationException;
  • FilterSecurityInterceptor:负责权限校验的过滤器。

4.认证工作详细解读

上面提到认证工作主要由UsernamePasswordAuthenticationFilter过滤器负责,但是实际是UsernamePasswordAuthenticationFilter和其他的一些类合作共同实现的:
认证工作流程

整个流程的核心要点就是过滤器将我们提交的用户名与密码封装成Authentication对象,然后通过一层层的传递加工去内存中存储的账号密码权限信息进行比对,如果比对上了就将用户的权限信息也封装到Authentication对象中,然后再传回到过滤器。

  • Authentication接口:它的实现类,表示当前访问系统的用户,封装了用户相关信息。
  • AuthenticationManager接口:定义了认证Authentication的方法
  • UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。
  • UserDetails接口:提供核心用户信总信息通过UserDetailsService根起用户名获取处理的用户信息要封装成JserDetails对象返回。然后将这些信息封装到Authentication对象中。

我们需要改造上述流程,改造成我们自己的认证流程。

5.修改登录认证的思路

需要改造的点:

  • 在第5.1步中InMemoryUserDetailsManager实现类中从内存中查找数据需要改成在数据库中查找数据,所以我们自己定义一个UserDetailsService的实现类来替换InMemoryUserDetailsManager。
  • 第10步需要响应回一个token,我们可以自己定义一个Controller完成响应token的操作。

修改后的流程:
修改后的流程
当验证登录通过之后,在之后的请求中怎么验证是否登录?这就需要用到Jwt认证过滤器了:

在这里插入图片描述
当用户登录成功后,前端需要访问请求时,会携带token发起请求,请求由Jwt过滤器拦截,获取到token中存储存储的userId,然后将用户信息封装到SecurityContextHolder中,其他过滤器可以从SecurityContextHolder获得当前登录的用户的信息,这就是进行校验。问题来了:token中只存放的是用户的userId,那用户的其他信息我们怎么获取呢?如果从数据库中查找会不会影响性能呢?
所以我们可以引入redis,通过userId去redis中查找用户信息(其实这里我觉得不太合理,直接将用户的其他信息(不包括重要的私密信息,比如密码)和userId一样,也存放到JWT中应该也可以,直接从JWT中读取到用户的信息即可),当我们生成token的时候也往redis中存储一份就可以:
[外链图片转存失败,源站可能有防盗在这里插入!链机制,建描述]议将图片上)]

  • SecurityContextHolder介绍:
    SecurityContextHolder是SpringSecurity最基本的组件了,是用来存放SecurityContext的对象,默认是使用ThreadLocal实现的,这样就保证了本线程内所有的方法都可以获得SecurityContext对象。SecurityContextHolder中保存的是当前访问者的信息。Spring Security使用一个Authentication对象来表示这个信息。一般情况下,我们都不需要创建这个对象,在登录过程中,Spring Security已经创建了该对象并帮我们放到了SecurityContextHolder中。

思路总结:

  • 登录:
    (1)自定义登录接口
    调用ProviderManager的方法进行认证,如果认证通过就生成JWT
    将用户信息存入redis中
    (2)自定义UserDetailsService
    在这个实现类中去查询数据库
  • 校验:
    (1)定义JWT认证过滤器
    获取token
    解析token,获取其中的userId
    从redis中获取用户信息
    将用户信息存入SecurityContextHolder

6.修改登录验证实操

  1. 首先新建一个SpringBoot项目,并创建如下文件结构:
  2. config包下:
    config包下有RedisConfig类,是Redis的配置类,定义了一些序列化的方式:
	package com.example.springsecurity02.config;


import com.example.springsecurity02.util.FastJsonRedisSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * RedisConfig配置类
 */
@Configuration
public class RedisConfig {

    @Bean
    @SuppressWarnings(value = {"unchecked", "rawtypes"})
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        FastJsonRedisSerializer serializer = new FastJsonRedisSerializer(Object.class);

        // 使用StringRedisSerializer来序列化和反序列化redis的key值
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);

        // Hash的key也采用StringRedisSerializer的序列化方式
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);

        template.afterPropertiesSet();
        return template;
    }
}

  1. controller包:
    controller包下只有一个HelloController,用于我们进行调用的api:
package com.example.springsecurity02.controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author StormArcita
 * @date 2023/8/9 15:23
 */
@RestController
public class HelloController {

    @RequestMapping("/hello")
    public String hello(){
        return "hello~";
    }
}

  1. domain包:
    domain包下有实体类User、LoginUser和ResponseResult

LoginUser就是UserDetails的实现类,需要对用户信息进行封装。

LoginUser:

package com.example.springsecurity02.domain;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

@Data
@NoArgsConstructor
@AllArgsConstructor
/**
    UserDetails是接口,这里是定义UserDetails的实现类
 */
public class LoginUser implements UserDetails {

    private User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUserName();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

User实体类:

package com.example.springsecurity02.domain;

import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Date;

@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("sys_user") //因为表名和实体类的名称不一致,所以需要指定一下
public class User {
    //主键
    @TableId
    private Long id;
    //用户名
    private String userName;
    //昵称
    private String nickName;
    //密码
    private String password;
    //用户类型:0代表普通用户,1代表管理员
    private String userType;
    //账号状态(0正常 1停用)
    private String status;
    //邮箱
    private String email;
    //手机号
    private String phonenumber;
    //用户性别(0男,1女,2未知)
    private String sex;
    //头像
    private String avatar;
    //创建人的用户id
    private Long createBy;
    //创建时间
    private Date createTime;
    //更新人
    private Long updateBy;
    //更新时间
    private Date updateTime;
    //删除标志(0代表未删除,1代表已删除)
    private Integer delFlag;
}

ResponseResult类:

package com.example.springsecurity02.domain;

import com.fasterxml.jackson.annotation.JsonInclude;

/**
 * 响应结果类
 *
 * @param <T>
 */
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ResponseResult<T> {
    /**
     * 状态码
     */
    private Integer code;
    /**
     * 提示信息,如果有错误时,前端可以获取该字段进行提示
     */
    private String msg;
    /**
     * 查询到的结果数据,
     */
    private T data;

    public ResponseResult(Integer code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    public ResponseResult(Integer code, T data) {
        this.code = code;
        this.data = data;
    }

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }

    public ResponseResult(Integer code, String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }
}

  1. Mapper包
    Mapper包下只有userMapper接口,用于对数据库进行操作:
package com.example.springsecurity02.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.springsecurity02.domain.User;

/**
 * @author StormArcita
 * @date 2023/8/9 17:30
 */
public interface UserMapper extends BaseMapper<User> {
}

6.service包下就是我们要编写的对UserDetailsService进行实现的类,之后详细介绍;
7. util包
util包下有四个工具类:
FastJsonRedisSerializer用于对FastJson进行redis序列化,JwtUtil用于创建token等,RdisCache就是redis的工具类,WebUtils中有将字符串渲染到屏幕的方法:

package com.example.springsecurity02.util;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.ParserConfig;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.type.TypeFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;

import java.nio.charset.Charset;

/**
 * Redis使用FastJson序列化
 */
public class FastJsonRedisSerializer<T> implements RedisSerializer<T> {

   public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");

   private Class<T> clazz;

   static {
      ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
   }

   public FastJsonRedisSerializer(Class<T> clazz) {
      super();
      this.clazz = clazz;
   }

   @Override
   public byte[] serialize(T t) throws SerializationException {
      if (t == null) {
         return new byte[0];
      }
      return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
   }

   @Override
   public T deserialize(byte[] bytes) throws SerializationException {
      if (bytes == null || bytes.length <= 0) {
         return null;
      }
      String str = new String(bytes, DEFAULT_CHARSET);

      return JSON.parseObject(str, clazz);
   }


   protected JavaType getJavaType(Class<?> clazz) {
      return TypeFactory.defaultInstance().constructType(clazz);
   }
}

package com.example.springsecurity02.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;

/**
 * JWT工具类
 */
public class JwtUtil {

    //有效期为
    public static final Long JWT_TTL = 60 * 60 * 1000L;// 60 * 60 *1000  一个小时
    //设置秘钥明文
    public static final String JWT_KEY = "zwp";

    public static String getUUID() {
        String token = UUID.randomUUID().toString().replaceAll("-", "");
        return token;
    }

    /**
     * 生成jtw
     *
     * @param subject token中要存放的数据(json格式)
     * @return
     */
    public static String createJWT(String subject) {
        JwtBuilder builder = getJwtBuilder(subject, null, getUUID());// 设置过期时间
        return builder.compact();
    }

    /**
     * 生成jtw
     *
     * @param subject   token中要存放的数据(json格式)
     * @param ttlMillis token超时时间
     * @return
     */
    public static String createJWT(String subject, Long ttlMillis) {
        JwtBuilder builder = getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间
        return builder.compact();
    }

    /**
     * 创建token
     *
     * @param id
     * @param subject
     * @param ttlMillis
     * @return
     */
    public static String createJWT(String id, String subject, Long ttlMillis) {
        JwtBuilder builder = getJwtBuilder(subject, ttlMillis, id);// 设置过期时间
        return builder.compact();
    }

    private static JwtBuilder getJwtBuilder(String subject, Long ttlMillis, String uuid) {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        SecretKey secretKey = generalKey();
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);
        if (ttlMillis == null) {
            ttlMillis = JwtUtil.JWT_TTL;
        }
        long expMillis = nowMillis + ttlMillis;
        Date expDate = new Date(expMillis);
        return Jwts.builder()
                .setId(uuid)              //唯一的ID
                .setSubject(subject)      // 主题 可以是JSON数据
                .setIssuer("zwp")         // 签发者
                .setIssuedAt(now)         // 签发时间
                .signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥
                .setExpiration(expDate);
    }

    /**
     * 解析
     *
     * @param jwt
     * @return
     * @throws Exception
     */
    public static Claims parseJWT(String jwt) throws Exception {
        SecretKey secretKey = generalKey();
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(jwt)
                .getBody();
    }

    /**
     * 生成加密后的秘钥 secretKey
     *
     * @return
     */
    public static SecretKey generalKey() {
        byte[] encodedKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
        SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES");
        return key;
    }

    public static void main(String[] args) throws Exception {
//        String jwt = createJWT("123456");
//        System.out.println(jwt);
        Claims claims = parseJWT("eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxOTU4MWJkMWQ3NTA0YWY5YmMzNGVkNDFmZjQ2NzBiNyIsInN1YiI6IjEyMzQ1NiIsImlzcyI6Inp3cCIsImlhdCI6MTY2MjYyMTI0MiwiZXhwIjoxNjYyNjI0ODQyfQ.qG-dFsFKVvh9qoN2B4FAt-YCrxtDNEO3MWYGHtNcSb0");
        System.out.println(claims);
        System.out.println(claims.getSubject());
    }
}

package com.example.springsecurity02.util;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;

import java.util.*;
import java.util.concurrent.TimeUnit;

@SuppressWarnings(value = {"unchecked", "rawtypes"})
@Component
public class RedisCache {
    @Autowired
    public RedisTemplate redisTemplate;

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key   缓存的键值
     * @param value 缓存的值
     */
    public <T> void setCacheObject(final String key, final T value) {
        redisTemplate.opsForValue().set(key, value);
    }

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key      缓存的键值
     * @param value    缓存的值
     * @param timeout  时间
     * @param timeUnit 时间颗粒度
     */
    public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit) {
        redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
    }

    /**
     * 设置有效时间
     *
     * @param key     Redis键
     * @param timeout 超时时间
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout) {
        return expire(key, timeout, TimeUnit.SECONDS);
    }

    /**
     * 设置有效时间
     *
     * @param key     Redis键
     * @param timeout 超时时间
     * @param unit    时间单位
     * @return true=设置成功;false=设置失败
     */
    public boolean expire(final String key, final long timeout, final TimeUnit unit) {
        return redisTemplate.expire(key, timeout, unit);
    }

    /**
     * 获得缓存的基本对象。
     *
     * @param key 缓存键值
     * @return 缓存键值对应的数据
     */
    public <T> T getCacheObject(final String key) {
        ValueOperations<String, T> operation = redisTemplate.opsForValue();
        return operation.get(key);
    }

    /**
     * 删除单个对象
     *
     * @param key
     */
    public boolean deleteObject(final String key) {
        return redisTemplate.delete(key);
    }

    /**
     * 删除集合对象
     *
     * @param collection 多个对象
     * @return
     */
    public long deleteObject(final Collection collection) {
        return redisTemplate.delete(collection);
    }

    /**
     * 缓存List数据
     *
     * @param key      缓存的键值
     * @param dataList 待缓存的List数据
     * @return 缓存的对象
     */
    public <T> long setCacheList(final String key, final List<T> dataList) {
        Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
        return count == null ? 0 : count;
    }

    /**
     * 获得缓存的list对象
     *
     * @param key 缓存的键值
     * @return 缓存键值对应的数据
     */
    public <T> List<T> getCacheList(final String key) {
        return redisTemplate.opsForList().range(key, 0, -1);
    }

    /**
     * 缓存Set
     *
     * @param key     缓存键值
     * @param dataSet 缓存的数据
     * @return 缓存数据的对象
     */
    public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet) {
        BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);
        Iterator<T> it = dataSet.iterator();
        while (it.hasNext()) {
            setOperation.add(it.next());
        }
        return setOperation;
    }

    /**
     * 获得缓存的set
     *
     * @param key
     * @return
     */
    public <T> Set<T> getCacheSet(final String key) {
        return redisTemplate.opsForSet().members(key);
    }

    /**
     * 缓存Map
     *
     * @param key
     * @param dataMap
     */
    public <T> void setCacheMap(final String key, final Map<String, T> dataMap) {
        if (dataMap != null) {
            redisTemplate.opsForHash().putAll(key, dataMap);
        }
    }

    /**
     * 获得缓存的Map
     *
     * @param key
     * @return
     */
    public <T> Map<String, T> getCacheMap(final String key) {
        return redisTemplate.opsForHash().entries(key);
    }

    /**
     * 往Hash中存入数据
     *
     * @param key   Redis键
     * @param hKey  Hash键
     * @param value 值
     */
    public <T> void setCacheMapValue(final String key, final String hKey, final T value) {
        redisTemplate.opsForHash().put(key, hKey, value);
    }

    /**
     * 获取Hash中的数据
     *
     * @param key  Redis键
     * @param hKey Hash键
     * @return Hash中的对象
     */
    public <T> T getCacheMapValue(final String key, final String hKey) {
        HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();
        return opsForHash.get(key, hKey);
    }

    /**
     * 删除Hash中的数据
     *
     * @param key
     * @param hkey
     */
    public void delCacheMapValue(final String key, final String hkey) {
        HashOperations hashOperations = redisTemplate.opsForHash();
        hashOperations.delete(key, hkey);
    }

    /**
     * 获取多个Hash中的数据
     *
     * @param key   Redis键
     * @param hKeys Hash键集合
     * @return Hash对象集合
     */
    public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys) {
        return redisTemplate.opsForHash().multiGet(key, hKeys);
    }

    /**
     * 获得缓存的基本对象列表
     *
     * @param pattern 字符串前缀
     * @return 对象列表
     */
    public Collection<String> keys(final String pattern) {
        return redisTemplate.keys(pattern);
    }
}

package com.example.springsecurity02.util;

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

public class WebUtils
{
    /**
     * 将字符串渲染到客户端
     *
     * @param response 渲染对象
     * @param string 待渲染的字符串
     * @return null
     */
    public static String renderString(HttpServletResponse response, String string) {
        try
        {
            response.setStatus(200);
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().print(string);
        }
        catch (IOException e)
        {
            e.printStackTrace();
        }
        return null;
    }
}

8.配置类
application.propertie中没有配置东西,application.yml中进行了数据库和端口配置:

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/bank?userUnicode=true&characterEncoding=utf-8&serverTimezone=UTC
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver
server:
  port: 8099

9.接下来编写UserDetailsService的实现类:UserDetailsServiceImpl

package com.example.springsecurity02.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.springsecurity02.domain.LoginUser;
import com.example.springsecurity02.domain.User;
import com.example.springsecurity02.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
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 java.util.Objects;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //查询用户信息
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(User::getUserName,username);
        User user = userMapper.selectOne(queryWrapper);
        //如果没有查询到用户就抛出异常
        if(Objects.isNull(user)){
            //在认证鉴权的过程中如果抛出异常都会被ExceptionTranslationFilter捕获到
            throw new RuntimeException("用户名或者密码错误");

        }
        //把数据封装成UserDetails返回
        LoginUser loginUser = new LoginUser();
        loginUser.setUser(user);

        //TODO 查询对应的权限信息


        return loginUser;

    }
}

完成上述部分还不够,还需要去数据库中将密码部分前面加上{noop}才可以:
在这里插入图片描述
如果你想你的密码要以明文的形式存储到数据库中,就需要这么做。
然后启动,登录:
在这里插入图片描述

输入账号密码,成功登陆。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值