Spring Security如何使用JWT作为访问数据身份认证Token,并将身份数据存入Redis

前言

本文将着重介绍在Spring Security中如何使用JWT作为数据访问的身份认证令牌,使用JWT这个将代替原来的cookie身份认证。

一、流程分析

在正式开始前,我们先对Spring Security 的登录流程进行分析
在这里插入图片描述
在我们登录成功后,就需要生成JWT令牌,并将其返回给前端。前端需要访问某个资源时,必须携带token,后端就需要在访问资源前对判断是否携带JWT令牌,令牌是否有效。

那么接下来的问题就是认证流程的问题了,也就是一个用户是否在我们设定的数据源中,假如存在且用户为启动状态,则登录成功,执行上述流程,反之登录失败,返回失败信息。

我们来看看,security中的整个认证流程:
认证流程(图片源于b站up三更草堂)
在整个认证流程中,我们在提交用户表单数据的时候,会先被UsernamePasswordAuthenticationFilter捕获到如何被封装为UsernamePasswordAuthenticationToken 的token 对象,然后将整个对象交给AuthenticationManager认证管理器去实现认证,而这个时候就会去数据源UserDetailService类中去进行对应的认证方式,认证成功返回UserDetails对象,整个对象还会被进一步封装为Authentication对象返回给AuthenticationManager。

到这里我们的目的就比较明确了, 我们只需要重新修改两个流程步骤即可实现JWT的生成和身份认证,第一,是修改UsernamePasswordAuthenticationFilter过滤链,使这条过滤链拥有对JWT的判断功能,二是实现认证成功后生成JWT并返回给前端同时将这个对象信息存储进redis作为缓存

二、准备工作

1.配置Redis

(1)引入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>2.0.25</version>
</dependency>

(2)Redis配置文件和工具类

@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);
    }
}
@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;
    }
}
/**
 * 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);
    }
}

(3)application.yaml配置Redis

spring:
  redis:
    host: 服务器地址
    port: 6379 #Redis默认端口
    password: 密码
    lettuce:
      pool:
        max-active: 8
        # 最大阻塞等待时间(使用负数表示没有限制)
        max-wait: 1ms
        # 最大空闲链接
        max-idle: 8
        # 最小空闲链接
        min-idle: 0

2.配置JWT

(1)引入依赖

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.0</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
    <version>2.7.5</version>
</dependency>

(2) JWT工具类

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

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

    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();
    }

    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("hzj")     // 签发者
                .setIssuedAt(now)      // 签发时间
                .signWith(signatureAlgorithm, secretKey) //使用HS256对称加密算法签名, 第二个参数为秘钥
                .setExpiration(expDate);
    }

    /**
     * 创建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();
    }


    /**
     * 生成加密后的秘钥 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;
    }
    
    /**
     * 解析
     *
     * @param jwt
     * @return
     * @throws Exception
     */
    public static Claims parseJWT(String jwt) throws Exception {
        SecretKey secretKey = generalKey();
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(jwt)
                .getBody();
    }
}

3.数据源配置

(1)定义User类继承自UserDetails

@Data
public class User implements UserDetails {
    private Long id;

    private String username;

    private String password;

    private String nickname;

    private boolean enabled;

    @TableField(exist = false)
    private List<Role> roles;

    private String email;

    private String userface;

    @TableField(value = "reg_time")
    private Timestamp regTime;

    @Override
    @JsonIgnore
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 创建角色集合
        Set<GrantedAuthority> grantedRoles = new HashSet<>();
        // 遍历实例对象所拥有的角色链表
        for (Role role : roles) {
            // 将每个角色封装为 授权角色对象,
            grantedRoles.add(new SimpleGrantedAuthority(UserRoles.isWho(role.getRid())));
        }
        return grantedRoles;
    }

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

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

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

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

public final class UserRoles {
    public static final Long ADMIN = Long.valueOf(1);

    public static final Long USER = Long.valueOf(2);

    public static final Long TEST_USER_1 = Long.valueOf(3);

    public static final Long TEST_USER_2 = Long.valueOf(4);

    public static final Long TEST_USER_3 = Long.valueOf(5);

    public static final String ROLE_ADMIN = "ROLE_ADMIN";

    public static final String ROLE_USER = "ROLE_ADMIN";

    public static final String ROLE_TEST_USER_01 = "ROLE_TEST_USER_01";

    public static final String ROLE_TEST_USER_02 = "ROLE_TEST_USER_02";

    public static final String ROLE_TEST_USER_03 = "ROLE_TEST_USER_03";

    public static String isWho(Long rid) {
        if (Objects.equals(rid, ADMIN)) {
            return ROLE_ADMIN;
        }else
        if (Objects.equals(rid, USER)) {
            return ROLE_USER;
        }else
        if (Objects.equals(rid, TEST_USER_1)) {
            return ROLE_TEST_USER_01;
        }else
        if (Objects.equals(rid, TEST_USER_2)) {
            return ROLE_TEST_USER_02;
        }
        return ROLE_TEST_USER_03;
    }
    private UserRoles(){

    }
}

(2) 基于数据库自定义数据源(使用MyBatis-plus)

@Component
public class LoginUserDetailService implements UserDetailsService {

    private final UserMapper userMapper;

    private final RoleMapper roleMapper;

    @Autowired
    public LoginUserDetailService(UserMapper userMapper, RoleMapper roleMapper) {
        this.userMapper = userMapper;
        this.roleMapper = roleMapper;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
   		System.out.println("当前在自定义数据源中");
        // 查询用户
        LambdaQueryWrapper<User> queryWrapper_User = new LambdaQueryWrapper<>();
        queryWrapper_User.eq(User::getUsername,username);
        User user = userMapper.selectOne(queryWrapper_User);
        // 工具类判断是否查询user对象是否有效
        if (ObjectUtils.isEmpty(user)) {
            throw new UsernameNotFoundException("用户不存在");
        }
        LambdaQueryWrapper<Role> queryWrapper_Role = new LambdaQueryWrapper<>();
        queryWrapper_Role.eq(Role::getUid,user.getId());
        List<Role> roles = roleMapper.selectList(queryWrapper_Role);
        user.setRoles(roles);
        return user;
    }
}

4.Spring Security 配置

(1)JWT判断过滤类

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private RedisCache redisCache;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //获取token
        String token = request.getHeader("token");
        if (!StringUtils.hasText(token)) {
            //放行
            System.out.println("无token直接进到过滤链");
            filterChain.doFilter(request, response);
            return;
        }
        System.out.println("有token获取认证信息");
        //解析token
        String userid;
        try {
            Claims claims = JwtUtil.parseJWT(token);
            // 获取jwt中的用户id
            userid = claims.getSubject();
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("token非法");
        }
        //从redis中获取用户信息
        String redisKey = "login:" + userid;
        JSONObject loginUser = redisCache.getCacheObject(redisKey);
        User user = loginUser.toJavaObject(User.class);
        if(Objects.isNull(user)){
            throw new RuntimeException("用户未登录");
        }
        System.out.println(token);
        //存入SecurityContextHolder
        //TODO 获取权限信息封装到Authentication中
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(user,null,null);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        //放行
        filterChain.doFilter(request, response);
    }
}

(4) Security配置类

@EnableWebSecurity
//@ApiModel(value = "博客Security自定义配置器")
public class BlogWebSecurityConfiger     {

    @Autowired
    private LoginUserDetailService loginUserDetailService;

    @Autowired
    private AuthenticationConfiguration authenticationConfiguration;

    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    /**
     * 处理静态资源,使其图片,css样式等不需要认证就能获取
     */
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
                // Spring Security should completely ignore URLs starting with /resources/
                .antMatchers("/resources/static/**");
    }

    // 自定义过滤链
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests((auth) -> {
                    auth
                            .mvcMatchers("/user/login")
                            .permitAll()
                            .anyRequest()
                            .authenticated();
                })
                //不通过Session获取SecurityContext,关闭cookie
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .formLogin()
                .and()
                .logout()
                .permitAll()
                .and()
                .csrf()
                .disable();
        // at: ⽤来某个 filter 替换过滤器链中哪个 filter
        // before: 放在过滤器链中哪个 filter 之前
        // after: 放在过滤器链中那个 filter 之后
        http.addFilterAt(jwtAuthenticationTokenFilter,
                        UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    // 加密
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // 获取AuthenticationManager(认证管理器),登录时认证使用。
    @Bean
    public AuthenticationManager authenticationManager() throws Exception {
        return this.authenticationConfiguration.getAuthenticationManager();
    }
    
    // 解决跨域问题
    private CorsConfigurationSource configurationSource() {
        CorsConfiguration corsConfiguration = new
                CorsConfiguration();
        // 设置接收cookie
        corsConfiguration.setAllowCredentials(true);
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");
        corsConfiguration.addAllowedOriginPattern("*");
        corsConfiguration.setMaxAge(3600L);
        UrlBasedCorsConfigurationSource source = new
                UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**",
                corsConfiguration);
        return source;
    }
}

5.登录控制层及服务层类

(1) 服务层

public interface LoginServcie {
    ResponseResult login(String username, String password);

    ResponseResult logout();
}

```java
@Service
public class LoginServiceImpl implements LoginServcie {

    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private RedisCache redisCache;

    @Override
    public ResponseResult login(String username,String password) {
        System.out.println("当前在service的login方法里面");
        // 将username 和 password 封装成 用户 token 信息
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
        // 将封装token传非认证管理器进行认证,内部会访问定义的数据源来进行认证,认证成功则返回封装的Authentication对象,失败返回null
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);
        if (Objects.isNull(authenticate)) {
            throw new RuntimeException("用户名或密码错误");
        }
        // 使用userid生成token
        // 获取封装在Authentication对象内部的User对象
        User loginUser = (User) authenticate.getPrincipal();
        // 获取id
        String userId = loginUser.getId().toString();
        // 构造一个JWT 字符串
        String jwt = JwtUtil.createJWT(userId);
        //authenticate存入redis
        redisCache.setCacheObject("login:" + userId, loginUser);
        System.out.println(jwt);
        //把token响应给前端
        HashMap<String, String> map = new HashMap<>();
        map.put("token", jwt);
        return new ResponseResult(200, "登陆成功", map);
    }

    @Override
    public ResponseResult logout() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        User loginUser = (User) authentication.getPrincipal();
        Long userid = loginUser.getId();
        redisCache.deleteObject("login:" + userid);
        return new ResponseResult(200, "退出成功");
    }
}

(2)控制层

@RestController
@RequestMapping("/user")
public class LoginController {

    @Autowired
    private LoginServcie loginServcie;

    @PostMapping("/login")
    public ResponseResult login(String username, String password){
        System.out.println(username+"========="+password);
        return loginServcie.login(username, password);
    }
}
@RestController
public class HelloController {
    @RequestMapping("/hello")
    public String hello(){
        return "hello";
    }
}

6.响应体

@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;
    }
}

8.总览

在这里插入图片描述

三、测试

到这里基本工作就做完了,我们使用postman测试一下,发送表单登录数据
在这里插入图片描述
postman登录成功信息,后端给前端发送了token
在这里插入图片描述
后端反馈

查看redis内存储数据
在这里插入图片描述

可以看到存储了用户数据

接着我们继续测试带着Token去获取其他数据
在这里插入图片描述
这里需要主要的是设置请求头携带token,token的值为刚刚登录成功的JWT字符串
在这里插入图片描述
结果返回hello获取成功

后端反馈
在这里插入图片描述

THE END
感谢阅读,希望有帮助
  • 3
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 5
    评论
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

摸鱼儿hzj

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

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

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

打赏作者

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

抵扣说明:

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

余额充值