SpringSecurity笔记(非自记)

SpringSecurity

它是一个身份认证及权限控制的框架。shiro是它的对标框架,springsecurity已经开发很多年了,由于shiro的优势,它发展的不是很好,直到springboot出现,security的配置变的非常简单,现在微服务的项目都会优先选择security框架。security实现原理是通过Filter实现的。

1、基础代码

Result

import lombok.Data;
​
import java.util.HashMap;
import java.util.Map;
​
import static com.wztsl.springboo.contants.ResultCode.ERROR;
import static com.wztsl.springboo.contants.ResultCode.SUCCESS;
​
/**
* @author szsw
* @date 2023/2/3 18:18:15
*/
@Data
public class Result {
​
   private Integer code;
​
   private String message;
​
   private Map<String, Object> map = new HashMap<>();
​
   private Result() {
  }
​
   public static Result ok() {
       Result r = new Result();
       r.setCode(SUCCESS.getCode());
       r.setMessage(SUCCESS.getMessage());
       return r;
  }
​
   public static Result error() {
       Result r = new Result();
       r.setCode(ERROR.getCode());
       r.setMessage(ERROR.getMessage());
       return r;
  }
​
   public Result put(String key, Object value) {
       map.put(key, value);
       return this;
  }
​
   public Object get(String key) {
       return map.get(key);
  }
​
}

ResultCode

/**
* @author szsw
* @date 2023/2/3 18:26:03
*/
public enum ResultCode {
​
   /**
    *
    */
   SUCCESS(0, "请求成功"),
   ERROR(1, "请求失败"),
  ;
​
   private int code;
   private String message;
​
   ResultCode(int code, String message) {
       this.code = code;
       this.message = message;
  }
​
   public int getCode() {
       return code;
  }
​
   public String getMessage() {
       return message;
  }
}

StringConstant

/**
* @author szsw
* @date 2023/2/3 19:42:55
*/
public class StringConstant {
​
   /**
    * session当前登录人信息
    */
   public static final String SESSION_USER = "SESSION_USER";
   public static final String AUTH_LIST = "AUTH_LIST";
   public static final String TOKEN = "token";
​
​
   private StringConstant() {
  }
}
​

AuthenticationEnum

/**
* @author szsw
* @date 2023/2/3 19:43:10
*/
public enum AuthenticationEnum {
}
​

2、工具代码

Md5

import lombok.extern.slf4j.Slf4j;
​
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
​
@Slf4j
@SuppressWarnings("java:S112")
public final class Md5 {
​
   private Md5() {
  }
​
   public static String encrypt(String strSrc) {
       try {
           char[] hexChars = {'0', '1', '2', '3', '4', '5', '6', '7', '8',
                   '9', 'a', 'b', 'c', 'd', 'e', 'f'};
           byte[] bytes = strSrc.getBytes();
           MessageDigest md = MessageDigest.getInstance("Md5");
           md.update(bytes);
           bytes = md.digest();
           int j = bytes.length;
           char[] chars = new char[j * 2];
           int k = 0;
           int i;
           for (i = 0; i < bytes.length; i++) {
               byte b = bytes[i];
               chars[k++] = hexChars[b >>> 4 & 0xf];
               chars[k++] = hexChars[b & 0xf];
          }
           return new String(chars);
      } catch (NoSuchAlgorithmException e) {
           log.info("MD5计算异常!", e);
           throw new RuntimeException("MD5加密出错!!+" + e);
      }
  }
​
}

ResponseUtil

import com.fasterxml.jackson.databind.ObjectMapper;
import com.wztsl.springboo.vo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
​
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
​
/**
* 响应工具
*
* @author by szsw
*/
@Slf4j
public class ResponseUtil {
​
   private ResponseUtil() {
  }
​
   public static void out(HttpServletResponse response, Result r) {
       ObjectMapper mapper = new ObjectMapper();
       response.setStatus(HttpStatus.OK.value());
       response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
       try {
           mapper.writeValue(response.getOutputStream(), r);
      } catch (IOException e) {
           log.info("响应IO异常!", e);
      }
  }
}
​

TokenManager

import io.jsonwebtoken.CompressionCodecs;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.stereotype.Component;
​
import java.util.Date;
​
/**
* token管理
*
* @author by szsw
*/
@Component
public class TokenManager {
​
   private static final long TOKEN_EXPIRATION = 24 * 60 * 60 * 1000L;
​
   private String tokenSignKey = "123456";
​
   /**
    * 生成token
    *
    * @param username 用户名
    * @return 通过用户名生成的token
    */
   public String createToken(String username) {
       return Jwts.builder().setSubject(username)
              .setExpiration(new Date(System.currentTimeMillis() + TOKEN_EXPIRATION))
              .signWith(SignatureAlgorithm.HS512, tokenSignKey).compressWith(CompressionCodecs.GZIP).compact();
  }
​
   /**
    * 通过token获取用户名
    *
    * @param token 令牌
    * @return 令牌对应的用户名
    */
   public String getUserFromToken(String token) {
       returnJwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token).getBody().getSubject();
  }
​
   @SuppressWarnings("all")
   public void removeToken(String token) {
       //jwttoken无需删除,客户端扔掉即可。
  }
​
}

3、实体代码

User

import lombok.Data;
​
/**
* @author szsw
* @date 2023/2/3 20:11:37
*/
@Data
public class User {
​
   private String username;
   private String password;
​
}
​

SecurityUser

import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;

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

/**
 * @author szsw
 * @date 2023/2/3 20:11:45
 */
@Data
public class SecurityUser implements UserDetails {

    private User user;
    private List<String> authList;

    public SecurityUser(User user) {
        this.user = user;
    }

    /**
     * 返回用户的权限
     *
     * @return the authorities, sorted by natural key (never <code>null</code>)
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        if (CollectionUtils.isEmpty(authList)) {
            return authorities;
        }
        for (String auths : authList) {
            if (StringUtils.isEmpty(auths)) {
                continue;
            }
            authorities.add(new SimpleGrantedAuthority(auths));
        }
        return authorities;
    }

    /**
     * Returns the password used to authenticate the user.
     *
     * @return the password
     */
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    /**
     * Returns the username used to authenticate the user. Cannot return <code>null</code>.
     *
     * @return the username (never <code>null</code>)
     */
    @Override
    public String getUsername() {
        return user.getUsername();
    }

    /**
     * Indicates whether the user's account has expired. An expired account cannot be
     * authenticated.
     *
     * @return <code>true</code> if the user's account is valid (ie non-expired),
     * <code>false</code> if no longer valid (ie expired)
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * Indicates whether the user is locked or unlocked. A locked user cannot be
     * authenticated.
     *
     * @return <code>true</code> if the user is not locked, <code>false</code> otherwise
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * Indicates whether the user's credentials (password) has expired. Expired
     * credentials prevent authentication.
     *
     * @return <code>true</code> if the user's credentials are valid (ie non-expired),
     * <code>false</code> if no longer valid (ie expired)
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * Indicates whether the user is enabled or disabled. A disabled user cannot be
     * authenticated.
     *
     * @return <code>true</code> if the user is enabled, <code>false</code> otherwise
     */
    @Override
    public boolean isEnabled() {
        return true;
    }
}

4、配置代码

TokenWebSecurityConfig

import com.wztsl.springboo.security.filter.TokenAuthenticationFilter;
import com.wztsl.springboo.security.filter.TokenLoginFilter;
import com.wztsl.springboo.security.filter.TokenLogoutHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
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.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;

/**
 * Security配置类
 *
 * @author by szsw
 * @date 2020/10/27 16:39
 */
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class TokenWebSecurityConfig extends WebSecurityConfigurerAdapter {

    private UserDetailsService userDetailsService;
    private TokenManager tokenManager;
    private DefaultPasswordEncoder defaultPasswordEncoder;

    @Autowired
    public TokenWebSecurityConfig(UserDetailsService userDetailsService, DefaultPasswordEncoder defaultPasswordEncoder, TokenManager tokenManager) {
        this.userDetailsService = userDetailsService;
        this.defaultPasswordEncoder = defaultPasswordEncoder;
        this.tokenManager = tokenManager;
    }


    /**
     * 配置设置
     *
     * @param http httpSecurity配置对象
     * @throws Exception 异常
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        /*
         * always – 如果session不存在总是需要创建;
         * ifRequired – 仅当需要时,创建session(默认配置);
         * never – 框架从不创建session,但如果已经存在,会使用该session ;
         * stateless – Spring Security不会创建session,或使用session;
         * 解决Feign调用时session丢失问题
         */
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER);
        /*
         * "migrateSession",即认证时,创建一个新http session,原session失效,属性从原session中拷贝过来
         * “none”,原session保持有效;
         * “newSession”,新创建session,且不从原session中拷贝任何属性。
         * 解决Feign调用时session丢失问题
         */
        http.sessionManagement().sessionFixation().none();
        http.exceptionHandling()
                .authenticationEntryPoint(new UnauthorizedEntryPoint())
                .and()
                .cors()
                .and()
                .csrf()
                .disable()
                .authorizeRequests()
                .anyRequest()
                .authenticated()
                .and()
                .logout()
                .logoutUrl("/center/logout")
                .addLogoutHandler(new TokenLogoutHandler(tokenManager))
                .and()
                .addFilter(new TokenLoginFilter(authenticationManager(), tokenManager))
                .addFilter(new TokenAuthenticationFilter(authenticationManager(), tokenManager))
                .httpBasic();

    }

    /**
     * 密码处理
     *
     * @param auth 权限管理创建者
     * @throws Exception 异常
     */
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(defaultPasswordEncoder);
    }

    /**
     * 配置哪些请求不拦截
     *
     * @param web webSecurity配置对象
     * @throws Exception 异常
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        String[] matchers = {"/**/allowAuth/**", "/assets/**"};
        web.ignoring().antMatchers(matchers);
    }

}

DefaultPasswordEncoder

import com.wztsl.springboo.utils.Md5;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

/**
 * @author szsw
 * @date 2023/2/3 19:46:52
 */
@Component
public class DefaultPasswordEncoder implements PasswordEncoder {
    /**
     * Encode the raw password. Generally, a good encoding algorithm applies a SHA-1 or
     * greater hash combined with an 8-byte or greater randomly generated salt.
     *
     * @param rawPassword 密码
     */
    @Override
    public String encode(CharSequence rawPassword) {
        return Md5.encrypt(rawPassword.toString());
    }

    /**
     * Verify the encoded password obtained from storage matches the submitted raw
     * password after it too is encoded. Returns true if the passwords match, false if
     * they do not. The stored password itself is never decoded.
     *
     * @param rawPassword     the raw password to encode and match
     * @param encodedPassword the encoded password from storage to compare with
     * @return true if the raw password, after encoding, matches the encoded password from
     * storage
     */
    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        if(rawPassword == null || encodedPassword == null){
            return false;
        }
        return encodedPassword.equals(Md5.encrypt(rawPassword.toString()));
    }
}

UnauthorizedEntryPoint

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

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

/**
 * @author szsw
 * @date 2023/2/3 19:47:06
 */
public class UnauthorizedEntryPoint implements AuthenticationEntryPoint {
    /**
     * Commences an authentication scheme.
     * <p>
     * <code>ExceptionTranslationFilter</code> will populate the <code>HttpSession</code>
     * attribute named
     * <code>AbstractAuthenticationProcessingFilter.SPRING_SECURITY_SAVED_REQUEST_KEY</code>
     * with the requested target URL before calling this method.
     * <p>
     * Implementations should modify the headers on the <code>ServletResponse</code> as
     * necessary to commence the authentication process.
     *
     * @param request       that resulted in an <code>AuthenticationException</code>
     * @param response      so that the user agent can begin authentication
     * @param authException that caused the invocation
     */
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {

    }
}

5、Filter代码

TokenAuthenticationFilter

import com.wztsl.springboo.utils.ResponseUtil;
import com.wztsl.springboo.utils.StringUtils;
import com.wztsl.springboo.utils.TokenManager;
import com.wztsl.springboo.vo.Result;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Collection;
import java.util.List;

import static com.wztsl.springboo.contants.StringConstant.*;

/**
 * @author szsw
 * @date 2023/2/3 19:47:36
 */
public class TokenAuthenticationFilter extends BasicAuthenticationFilter {

    private AuthenticationManager authenticationManager;
    private TokenManager tokenManager;

    public TokenAuthenticationFilter(AuthenticationManager authenticationManager, TokenManager tokenManager) {
        super(authenticationManager);
        this.authenticationManager = authenticationManager;
        this.tokenManager = tokenManager;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        String requestUri = request.getRequestURI();
        handing(request, response, chain, requestUri);
    }

    @SuppressWarnings("unchecked")
    private void handing(HttpServletRequest request, HttpServletResponse response, FilterChain chain, String requestUri) throws IOException, ServletException {
        HttpSession session = request.getSession();
        Object userObj = session.getAttribute(SESSION_USER);
        if (userObj != null) {
            UsernamePasswordAuthenticationToken auth = createUpat(request);
            if(auth == null){
                ResponseUtil.out(response, Result.error("没有权限!"));
            }
            SecurityContextHolder.getContext().setAuthentication(auth);
            List<SimpleGrantedAuthority> authorityList = (List<SimpleGrantedAuthority>) session.getAttribute(AUTH_LIST);
            boolean authFlag = authorityList.stream().anyMatch(t -> requestUri.contains(t.getAuthority()));
            if (authFlag) {
                chain.doFilter(request, response);
            }else{
                ResponseUtil.out(response, Result.error("没有权限!"));
            }
        } else {
            ResponseUtil.out(response, Result.error("请登录!"));
        }
    }

    @SuppressWarnings("unchecked")
    private UsernamePasswordAuthenticationToken createUpat(HttpServletRequest request) {
        String token = request.getHeader(TOKEN);
        if (StringUtils.isEmpty(token)) {
            return null;
        }
        String username = tokenManager.getUserFromToken(token);
        if (StringUtils.isEmpty(username)) {
            return null;
        }
        Object authList = request.getSession().getAttribute(AUTH_LIST);
        return new UsernamePasswordAuthenticationToken(username, token, (Collection<? extends GrantedAuthority>) authList);
    }


}

TokenLoginFilter

import com.fasterxml.jackson.databind.ObjectMapper;
import com.wztsl.springboo.utils.ResponseUtil;
import com.wztsl.springboo.utils.TokenManager;
import com.wztsl.springboo.vo.Result;
import com.wztsl.springboo.vo.SecurityUser;
import com.wztsl.springboo.vo.User;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

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

import static com.wztsl.springboo.contants.StringConstant.AUTH_LIST;
import static com.wztsl.springboo.contants.StringConstant.SESSION_USER;

/**
 * @author szsw
 * @date 2023/2/3 19:47:46
 */
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {

    private AuthenticationManager authenticationManager;
    private TokenManager tokenManager;

    public TokenLoginFilter(AuthenticationManager authenticationManager, TokenManager tokenManager) {
        this.authenticationManager = authenticationManager;
        this.tokenManager = tokenManager;
        this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/login"));
    }

    /**
     * 获取数据和返回响应的
     *
     * @param request  请求
     * @param response 响应
     * @return 认证
     * @throws AuthenticationException 认证过程中可能会由Spring抛出认证异常
     */
    @Override
    @SuppressWarnings("java:S112")
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        try {
            User user = new ObjectMapper().readValue(request.getInputStream(), User.class);
            UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), new ArrayList<>());
            return authenticationManager.authenticate(authToken);
        } catch (IOException e) {
            throw new RuntimeException("认证失败!");
        }
    }

    /**
     * 认证成功
     *
     * @param request    请求
     * @param response   响应
     * @param chain      chain对象
     * @param authResult the object returned from the <tt>attemptAuthentication</tt>
     *                   method.
     * @throws IOException      认证过程中可能会由Spring抛出认证异常
     * @throws ServletException 认证过程中可能会由Spring抛出认证异常
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        SecurityUser securityUser = (SecurityUser) authResult.getPrincipal();
        String token = tokenManager.createToken(securityUser.getUsername());
        request.getSession().setAttribute(SESSION_USER, securityUser.getUser());
        request.getSession().setAttribute(AUTH_LIST, securityUser.getAuthorities());
        ResponseUtil.out(response, Result.ok().put("token", token));
    }

    /**
     * 认证失败
     *
     * @param request  请求
     * @param response 响应
     * @param e        认证异常
     */
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        ResponseUtil.out(response, Result.error("用户名或密码错误!"));
    }
}

TokenLogoutHandler

import com.wztsl.springboo.utils.TokenManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutHandler;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * @author szsw
 * @date 2023/2/3 19:47:54
 */
public class TokenLogoutHandler implements LogoutHandler {

    private TokenManager tokenManager;

    public TokenLogoutHandler(TokenManager tokenManager) {
        this.tokenManager = tokenManager;
    }

    /**
     * Causes a logout to be completed. The method must complete successfully.
     *
     * @param request        the HTTP request
     * @param response       the HTTP response
     * @param authentication the current principal details
     */
    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {

    }
}

6、service代码

UserDetailsServiceImpl

import com.wztsl.springboo.utils.Md5;
import com.wztsl.springboo.vo.SecurityUser;
import com.wztsl.springboo.vo.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 java.util.ArrayList;
import java.util.List;

/**
 * @author szsw
 * @date 2023/2/3 19:48:10
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    /**
     * Locates the user based on the username. In the actual implementation, the search
     * may possibly be case sensitive, or case insensitive depending on how the
     * implementation instance is configured. In this case, the <code>UserDetails</code>
     * object that comes back may have a username that is of a different case than what
     * was actually requested..
     *
     * @param username the username identifying the user whose data is required.
     * @return a fully populated user record (never <code>null</code>)
     * @throws UsernameNotFoundException if the user could not be found or the user has no
     *                                   GrantedAuthority
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = new User();
        SecurityUser securityUser = new SecurityUser(user);
        if ("admin".equals(username)) {
            user.setPassword(Md5.encrypt("123456"));
            user.setUsername(username);
            List<String> authList = new ArrayList<>();
            authList.add("start/add");
            securityUser.setAuthList(authList);
        }
        return securityUser;
    }
}

7、maven依赖

<jwt.version>0.7.0</jwt.version>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>${jwt.version}</version>
</dependency>

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

XiangXi响希

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

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

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

打赏作者

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

抵扣说明:

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

余额充值