【实战运用】SpringSecurity+Redis+Jwt实现用户认证授权

介绍

Spring Security是一个强大且灵活的身份验证和访问控制框架,用于Java应用程序。它是基于Spring框架的一个子项目,旨在为应用程序提供安全性。

Spring Security致力于为Java应用程序提供认证授权功能。开发者可以轻松地为应用程序添加强大的安全性,以满足各种复杂的安全需求。

SpringSecurity完整流程

JwtAuthenticationTokenFilter: 这里是我们自己定义的过滤器,主要负责放行不携带token的请求(如注册或登录请求),并对携带token的请求设置授权信息

UsernamePasswordAuthenticationFilter: 负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要由它负责

ExceptionTranslationFilter: 处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException

FilterSecurityInterceptor: 负责权限校验的过滤器。

一般认证工作流程

Authentication接口: 它的实现类表示当前访问系统的用户,封装了用户相关信息。

AuthenticationManager接口: 定义了认证Authentication的方法

UserDetailsService接口: 加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。

UserDetails接口: 提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。

数据库

数据库的采用**RBAC权限模型(基于角色的权限控制)**进行设计。

RBAC至少需要三张表:用户表–角色表–权限表(多对多的关系比较合理)

  • 用户表(user):存储用户名、密码等基础信息,进行登录校验
  • 角色表(role):对用户的角色进行分配
  • 权限表(menu):存储使用不同功能所需的权限

注册流程

配置匿名访问

在配置类中允许注册请求可以匿名访问

编写实现类

registerDTO中存在字符串roleId和实体类user,先取出user判断是否存在相同手机号。若该手机号没有注册过用户,对密码进行加密后即可将用户存入数据库。

创建register方法映射,保存用户的同时也要将roleId一并存入关系表中,使用户获得对应角色。如下图。

	@Override
    public Result register(RegisterDTO registerDTO) {
        // 获取Map中的数据
        User user = registerDTO.getUser();
        String roleId = registerDTO.getRoleId();
        // 判断是否存在相同手机号
        User dataUser = lambdaQuery()
                .eq(User::getUserPhone, user.getUserPhone()).one();
        if (!Objects.isNull(dataUser)) {
            return Result.fail("该手机号已注册过用户,请勿重复注册");
        }
        // 密码加密
        user.setUserPassword(passwordEncoder
                .encode(user.getUserPassword()));
        // 将用户及对应角色存入数据库
        save(user);
        userMapper.register(user.getUserPhone(), roleId);

        return Result.ok("注册成功");
    }

登录流程

配置匿名访问

在配置类中允许登录请求可以匿名访问

调用UserDetailsServiceImpl

登录流程一般对应认证工作流程

	@Resource
    private AuthenticationManager authenticationManager;
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private PasswordEncoder passwordEncoder;
    @Resource
    private UserMapper userMapper;
    @Override
    public Result login(User user) {
        //AuthenticationManager 进行用户认证,校验手机号和密码是否正确
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserPhone(), user.getUserPassword());
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);
        //认证失败给出提示
        if (Objects.isNull(authenticate)) {
            throw new RuntimeException("用户名或密码错误");
        }
        //认证通过,生成jwt并返回
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
        String userId = loginUser.getUser().getUserId();
        String jwtToken = JwtUtil.createToken(userId);
        Map<String, String> map = new HashMap<>();

        stringRedisTemplate.opsForValue()
                .set(LOGIN_CODE_KEY + userId, JSONUtil.toJsonStr(loginUser));
        map.put("token", jwtToken);

        return Result.ok(map);
    }

先看这段代码: UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserPhone(), user.getUserPassword());这里先用用户手机号和密码生成UsernamePasswordAuthenticationToken

再看这段代码:Authentication authenticate = authenticationManager.authenticate(authenticationToken);利用authenticate调用自定义实现类UserDetailsServiceImpl,根据用户名判断用户是否存在(对应认证流程的1、2、3、4)

实现UserDetailsServiceImpl

由于试下的是UserDetailsService接口,所以必须实现其方法loadUserByUsername(根据用户名查询数据库是否存在)这里我传入的是手机号。数据库中若存在用户,则返回UserDetails对象(这里的权限信息暂且不看,对应认证流程的5、5.1、5.2、6)

UserDetails对象返回后,authenticate方法会默认通过PasswordEncoder比对UserDetails与Authentication的密码是否相同。因为UserDetails是通过自定义实现类从数据库中查询出的user对象,而Authentication相当于是用户输入的用户名和密码,也就可以理解为通过前面自定义实现类利用用户名查询到用户后,再看这个用户的密码是否正确。如果用户名或密码不正确,authenticate将会为空,则抛出异常信息。(对应认证流程的7)

由于这里的登录流程不涉及8,9,10,所以不再叙述。

在剩下的代码中我们利用用userId生成了jwt的令牌token,将其存入Redis中并返回token给前端。

登出流程

编写过滤器

除login、register请求外的所有请求都需要携带token才能访问,因此需要设计token拦截器代码,如下。

对于不携带token的请求(如登录/注册)直接放行;对于携带token的请求先判断该用户是否登录,即redis中是否存在相关信息,若存在,将用户授权信息存入SecurityContextHolder,方便用户授权,最后直接放行。

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 获取token
        String token = request.getHeader("token");
        if (!StringUtils.hasText(token)) {
            // 没有token,放行
            filterChain.doFilter(request, response);
            return;
        }
        // 解析token
        String userId = null;
        try {
            userId = JwtUtil.parseJwt(token);
        } catch (Exception e) {
            e.printStackTrace();
            System.out.println("token非法:" + e);
        }
        // 从redis中获取用户信息
        String userJson = stringRedisTemplate
                .opsForValue().get(LOGIN_CODE_KEY + userId);
        LoginUser loginUser = JSONUtil.toBean(userJson, LoginUser.class);
        if (Objects.isNull(loginUser)) {
            throw new RuntimeException("用户未登录");
        }
        // 存入SecurityContextHolder,设置用户授权信息
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        // 放行
        filterChain.doFilter(request, response);
    }
}

此外,还需将token拦截器设置在过滤器UsernamePasswordAuthenticationFilter的前面。

编写实现类

	@Override
    public Result logout() {
        // 获取SecurityContextHolder中的用户id
        UsernamePasswordAuthenticationToken authentication =
                (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        String userId = loginUser.getUser().getUserId();

        // 删除redis中的值
        stringRedisTemplate
                .delete(LOGIN_CODE_KEY + userId);
        return Result.ok("注销成功");
    }

获取SecurityContextHolder中的用户id后,删除redis中存储的值,即登出成功。

授权流程

确保实现类正确编写:

@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {
    private User user;

    private List<String> permissions;

    public LoginUser(User user, List<String> permissions) {
        this.user = user;
        this.permissions = permissions;
    }

    @JsonIgnore
    private List<SimpleGrantedAuthority> authorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        if (authorities != null) {
            return authorities;
        }
        // 把permissions中String类型的权限信息封装成SimpleGrantedAuthority对象
        authorities = permissions.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
        return authorities;
    }

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

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

在token拦截器中,我们添加了这段代码。

// 存入SecurityContextHolder,设置用户授权信息
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);

这样非登录/注册请求都会被设置授权信息。

为对应接口添加注解@PreAuthorize,就会检验该请求是否存在相关请求。

完整代码

config类

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
    @Resource
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
    @Resource
    private AccessDeniedHandlerImpl accessDeniedHandler;
    @Resource
    private AuthenticationEntryPointImpl authenticationEntryPoint;

    @Bean
    public PasswordEncoder passwordEncoder() {
        // 实例化PasswordEncoder
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/user/login", "/user/register").anonymous()
                .anyRequest().authenticated();
        // 添加过滤器
        http
                .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // 配置异常处理器
        http
                .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler);
        // 允许跨域
        http.cors();
        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        // 配置身份验证管理器
        return authenticationConfiguration.getAuthenticationManager();
    }

}

controller类

@RestController
@RequestMapping("/user")
public class UserController {
    @Resource
    private IUserService userService;
    @PostMapping("/login")
    public Result login(@RequestBody User user) {
        return userService.login(user);
    }

    @GetMapping("/logout")
    public Result logout() {
        return userService.logout();
    }

    @PostMapping("/register")
    public Result register(@RequestBody RegisterDTO registerDTO) {
        return userService.register(registerDTO);
    }

}

dto类

@Data
@NoArgsConstructor
@AllArgsConstructor
public class RegisterDTO {
    private User user;
    private String roleId;
}
/**
 * @author modox
 * @date 2023年6月1日
 * @description 封装结果后返回
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {

    public static final Integer SUCCESS_CODE = 200;     // 访问成功状态码
    public static final Integer TOKEN_ERROR = 400;      // Token错误状态码
    public static final Integer ERROR_CODE = 500;       // 访问失败状态码
    private Integer status;                               // 状态码
    private String msg;                                 // 提示消息
    private Object data = null;

    public Result(Integer status, String msg) {
        this.status = status;
        this.msg = msg;
    }

    public static Result ok(Integer status,String msg,Object data){
        return new Result(status,msg,data);
    }

    public static Result ok(String msg,Object data){
        return new Result(SUCCESS_CODE,msg,data);
    }

    public static Result ok(Object data){
        return new Result(SUCCESS_CODE,"操作成功",data);
    }

    public static Result ok(){
        return new Result(SUCCESS_CODE,"操作成功",null);
    }

    public static Result fail(Integer status,String msg){
        return new Result(status,msg);
    }

    public static Result fail(String msg){
        return new Result(ERROR_CODE,msg);
    }

    public static Result fail(){
        return new Result(ERROR_CODE,"操作失败");
    }

    public static Map<String,Object> ok(Map<String,Object> map){
        map.put("status",SUCCESS_CODE);
        map.put("msg","查询成功");
        return map;
    }

    public static Map<String,Object> ok(PageInfo pageInfo){
        Map<String,Object> map = new HashMap<>();
        map.put("status",SUCCESS_CODE);
        map.put("msg","查询成功");
        map.put("count",pageInfo.getTotal());
        map.put("data",pageInfo.getList());
        return map;
    }
}

entity类

UserDetails的实现类

@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {
    private User user;

    private List<String> permissions;

    public LoginUser(User user, List<String> permissions) {
        this.user = user;
        this.permissions = permissions;
    }

    @JsonIgnore
    private List<SimpleGrantedAuthority> authorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        if (authorities != null) {
            return authorities;
        }
        // 把permissions中String类型的权限信息封装成SimpleGrantedAuthority对象
        authorities = permissions.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
        return authorities;
    }

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

    @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;
    }
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName(value = "grd_menu")
public class Menu {
    @TableId
    private String menuId;
    private String menuName;
    private String menuPerms;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName(value = "grd_user")
public class User {
    @TableId(type = IdType.ASSIGN_ID)
    private String userId;
    private String userName;
    private Integer userSex;
    private String userPhone;
    private String userPassword;
    private String userSchool;
    private Byte[] userImage;
}

filter类

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 获取token
        String token = request.getHeader("token");
        if (!StringUtils.hasText(token)) {
            // 没有token,放行
            filterChain.doFilter(request, response);
            return;
        }
        // 解析token
        String userId = null;
        try {
            userId = JwtUtil.parseJwt(token);
        } catch (Exception e) {
            e.printStackTrace();
            System.out.println("token非法:" + e);
        }
        // 从redis中获取用户信息
        String userJson = stringRedisTemplate
                .opsForValue().get(LOGIN_CODE_KEY + userId);
        LoginUser loginUser = JSONUtil.toBean(userJson, LoginUser.class);
        if (Objects.isNull(loginUser)) {
            throw new RuntimeException("用户未登录");
        }
        // 存入SecurityContextHolder,设置用户授权信息
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        // 放行
        filterChain.doFilter(request, response);
    }
}

handler类

@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        Result result = new Result(HttpStatus.FORBIDDEN.value(), "您的权限不足");
        String json = JSONUtil.toJsonStr(result);
        // 处理异常
        WebUtils.renderString(response, json);
    }
}
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        Result result = new Result(HttpStatus.UNAUTHORIZED.value(), "用户认证失败");
        String json = JSONUtil.toJsonStr(result);
        // 处理异常
        WebUtils.renderString(response, json);
    }
}

service实现类

@Service
public class UserDetailsServiceImpl extends ServiceImpl<UserMapper, User>  implements UserDetailsService {
    @Resource
    private MenuMapper menuMapper;
    @Override
    public UserDetails loadUserByUsername(String userPhone) throws UsernameNotFoundException {
        //根据用户名查询用户信息
        QueryWrapper<User> wrapper = new QueryWrapper<>();
        wrapper.eq("user_phone", userPhone);
        User user = getOne(wrapper);
        //若数据库中不存在用户
        if (Objects.isNull(user)) {
            throw new RuntimeException("该手机号未注册");
        }
        // 根据用户查询权限信息 添加到LoginUser中
        List<String> list = menuMapper.selectPermsByUserPhone(user.getUserPhone());
        // 封装成UserDetails对象返回
        return new LoginUser(user, list);
    }
}
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
    @Resource
    private AuthenticationManager authenticationManager;
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private PasswordEncoder passwordEncoder;
    @Resource
    private UserMapper userMapper;
    @Override
    public Result login(User user) {
        //AuthenticationManager 进行用户认证,校验手机号和密码是否正确
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserPhone(), user.getUserPassword());
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);
        //认证失败给出提示
        if (Objects.isNull(authenticate)) {
            throw new RuntimeException("用户名或密码错误");
        }
        //认证通过,生成jwt并返回
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
        String userId = loginUser.getUser().getUserId();
        String jwtToken = JwtUtil.createToken(userId);
        Map<String, String> map = new HashMap<>();

        stringRedisTemplate.opsForValue()
                .set(LOGIN_CODE_KEY + userId, JSONUtil.toJsonStr(loginUser));
        map.put("token", jwtToken);

        return Result.ok(map);
    }

    @Override
    public Result logout() {
        // 获取SecurityContextHolder中的用户id
        UsernamePasswordAuthenticationToken authentication =
                (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        String userId = loginUser.getUser().getUserId();

        // 删除redis中的值
        stringRedisTemplate
                .delete(LOGIN_CODE_KEY + userId);
        return Result.ok("注销成功");
    }

    @Override
    public Result register(RegisterDTO registerDTO) {
        // 获取Map中的数据
        User user = registerDTO.getUser();
        String roleId = registerDTO.getRoleId();
        // 判断是否存在相同手机号
        User dataUser = lambdaQuery()
                .eq(User::getUserPhone, user.getUserPhone()).one();
        if (!Objects.isNull(dataUser)) {
            return Result.fail("该手机号已注册过用户,请勿重复注册");
        }
        // 密码加密
        user.setUserPassword(passwordEncoder
                .encode(user.getUserPassword()));
        // 将用户及对应角色存入数据库
        save(user);
        userMapper.register(user.getUserPhone(), roleId);

        return Result.ok("注册成功");
    }
}

utils类

public class JwtUtil {
    // token失效:24小时
    public static final String token = "token";
    public static final long EXPIPE = 1000 * 60 * 60 * 10;
    public static final String APP_SECRET = "modox@ukc8BDbRigUDaY6pZFfWus2jZWLPHO";

    /**
     * 根据传入的用户Id生成token
     * @param userId
     * @return JWT规则生成的token
     */
    public static String createToken(String userId) {
        String JwtToken = Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setHeaderParam("alg", "HS256")
                .setSubject("grd_user")
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + EXPIPE))
                .claim("userId", userId)
                .signWith(SignatureAlgorithm.HS256, APP_SECRET)
                .compact();

        return JwtToken;
    }

    /**
     * 验证token是否有效
     * @param jwtToken token字符串
     * @return 如果token有效返回true,否则false
     */
    public static boolean checkToken(String jwtToken) {
        try {
            if (!StringUtils.hasText(jwtToken))
                return false;
            Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

    /**
     * 根据token获取User信息
     * @param jwtToken token字符串
     * @return 解析token获得的user对象
     */
    public static String parseJwt(String jwtToken) {
        //验证token
        if (checkToken(jwtToken)) {
            Claims claims = Jwts.parser().setSigningKey(APP_SECRET).parseClaimsJws(jwtToken).getBody();
            return claims.get("userId").toString();
        }else {
            throw new RuntimeException("超时或不合法token");
        }
    }
}

public class RedisConstants {
    public static final String LOGIN_CODE_KEY = "login:code:";
    public static final Long LOGIN_CODE_TTL = 2L;
    public static final String LOGIN_USER_KEY = "login:token:";
    public static final Long LOGIN_USER_TTL = 36000L;
}
public class WebUtils {
    /**
     * 将字符串渲染到客户端
     * @param response
     * @param string
     * @return
     */
    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;
    }
}
  • 25
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Spring Boot 是一个用于构建微服务的开源框架,它能够快速搭建项目并且提供了许多便捷的功能和特性。Spring Security 是一个用于处理认证授权的框架,可以保护我们的应用程序免受恶意攻击。JWT(JSON Web Token)是一种用于身份验证的开放标准,可以被用于安全地传输信息。Spring MVC 是一个用于构建 Web 应用程序的框架,它能够处理 HTTP 请求和响应。MyBatis 是一个用于操作数据库的框架,可以简化数据库操作和提高效率。Redis 是一种高性能的键值存储系统,可以用于缓存与数据存储。 基于这些技术,可以搭建一个商城项目。Spring Boot 可以用于构建商城项目的后端服务,Spring Security 可以确保用户信息的安全性,JWT 可以用于用户的身份验证,Spring MVC 可以处理前端请求,MyBatis 可以操作数据库Redis 可以用于缓存用户信息和商品信息。 商城项目的后端可以使用 Spring Boot 和 Spring Security 来搭建,通过 JWT 来处理用户的身份验证和授权数据库操作可以使用 MyBatis 来简化与提高效率,同时可以利用 Redis 来缓存一些常用的数据和信息,提升系统的性能。前端请求则可以通过 Spring MVC 来处理,实现商城项目的整体功能。 综上所述,借助于 Spring Boot、Spring SecurityJWTSpring MVC、MyBatis 和 Redis 这些技术,可以构建出一个高性能、安全可靠的商城项目,为用户提供良好的购物体验。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值