Spring Security Authentication认证的定制开发案例实现以及执行流程解析

本文演示如何实现 Spring Security 认证的定制开发。

一、新建 springboot工程。

 

二、添加pom依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>spring.security</groupId>
    <artifactId>spring-security</artifactId>
    <version>1.0-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.0.4.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <java.version>1.8</java.version>
        <mybatis.spring.version>1.3.2</mybatis.spring.version>
        <swagger.version>2.8.0</swagger.version>
        <jwt.version>0.9.1</jwt.version>
        <fastjson.version>1.2.48</fastjson.version>
        <pagehelper.version>4.1.6</pagehelper.version>
    </properties>

    <dependencies>

        <!-- spring boot -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!--swagger -->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>${swagger.version}</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>${swagger.version}</version>
        </dependency>

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

        <!--lombok-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>compile</scope>
        </dependency>

        <!--pagehelper-->
        <dependency>
            <groupId>com.github.pagehelper</groupId>
            <artifactId>pagehelper</artifactId>
            <version>${pagehelper.version}</version>
        </dependency>

        <!-- freemarker -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-freemarker</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

 

三、添加配置 application.properties
server.port=8081
server.tomcat.uri-encoding=UTF-8
#server.servlet.context-path=/
#spring.application.name=spring-security
#spring.aop.auto=true

#日志
logging.level.org.springframework.web=INFO

spring.freemarker.template-loader-path=classpath:/templates/
spring.freemarker.charset=utf-8
spring.freemarker.cache=false
spring.freemarker.expose-request-attributes=true
spring.freemarker.expose-session-attributes=true
spring.freemarker.expose-spring-macro-helpers=true
spring.freemarker.suffix=.ftl
 

四、启动器

package com.spring;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;

/**
 * 启动器
 */
@SpringBootApplication
@ComponentScan(basePackages = {"com.spring.security"})
public class SecurityApplication {
    public static void main(String[] args) {
        SpringApplication.run(SecurityApplication.class, args);
    }

}

 

五、实现真正 处理认证请求 的 AuthenticationProvider

package com.spring.security.auth;

import com.spring.security.model.User;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;

import java.util.*;
import java.util.stream.Collectors;

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {

    @Bean
    public List<User> preloadUsers() {
        return Arrays.asList(new User("user1", "password1", true, false, false),
                new User("user2", "password2", false, false, false),
                new User("user3", "password3", true, true, false),
                new User("user4", "password4", true, false, true));
    }
    
    private List<User> getUser(String username) {
        return preloadUsers().stream().filter(user -> user.getUsername().equals(username)).collect(Collectors.toList());
    }
    
    @Override
    public Authentication authenticate(Authentication authentication)
        throws AuthenticationException {
        // 获取用户登录时输入的用户名
        String username = authentication.getName();
        // 根据用户名查询系统中的用户信息
        List<User> users = getUser(username);
        // 如果用户列表为 null,说明查找用户功能出现异常,抛出 AuthenticationServiceException
        if (Objects.isNull(users)) {
            throw new AuthenticationServiceException(String.format("Searching user[%s] occurred error!", username));
        }
        // 如果用户列表为空,说明没有匹配的用户,抛出 UsernameNotFoundException
        if (users.size() == 0) {
            throw new UsernameNotFoundException(String.format("No qualified user[%s]!", username));
        }
        // 如果用户列表中不止一个匹配用户,说明系统中用户唯一性逻辑存在问题,抛出 ConflictAccountException
        if (users.size() > 1) {
            throw new ConflictAccountException(String.format("Conflict user[%s]", username));
        }
        // 获取用户列表中唯一的用户对象
        User user = users.get(0);
        // 如果用户没有设置启用或禁用状态,或者用户被设为禁用,则抛出 DisabledException
        Optional<Boolean> enabled = Optional.of(user.getEnabled());
        if (!enabled.orElse(false)) {
            throw new DisabledException(String.format("User[%s] is disabled!", username));
        }
        // 如果用户没有过期状态或过期状态为 true 则抛出 AccountExpiredException
        Optional<Boolean> expired = Optional.of(user.getExpired());
        if (expired.orElse(true)) {
            throw new AccountExpiredException(String.format("User[%s] is expired!", username));
        }
        // 如果用户没有锁定状态或锁定状态为 true 则抛出 LockedException
        Optional<Boolean> locked = Optional.of(user.getLocked());
        if (locked.orElse(true)) {
            throw new LockedException(String.format("User[%s] is locked!", username));
        }
        // 如果用户登录时输入的密码和系统中密码匹配,则返回一个完全填充的 Authentication 对象
        if (user.getPassword().equals(authentication.getCredentials().toString())) {
            return new UsernamePasswordAuthenticationToken(authentication, authentication.getCredentials(), new ArrayList<>());
        }
        // 如果密码不匹配则返回 null(此处可以抛异常,试具体应用场景而定)
        return null;
    }
    
    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}

 

说明:
(1) preloadUsers 方法用于预置用户信息。实际开发中用户信息通常存储在关系型数据库,本文主要目的是演示 Spring Security Authentication (认证)的定制开发过程,省略了数据库相关功能,直接在内存中定义了四个预置的用户数据,包括一个正常用户、一个已被禁用的用户、一个过期用户和一个已锁定用户,以便后续演示不同用户的登录认证结果。

此方法中的 User 属于实际业务对象类型,可以根据实际业务场景定制,示例代码:

 

package com.spring.security.model;

import lombok.Data;

import java.util.Set;

/**
 * 用户模型
 */
@Data
public class User {

    private String username;

    private String password;

    private Boolean enabled;

    private Boolean expired;

    private Boolean locked;

    private Set<String> roles;

    public User() {
    }

    public User(String username, String password, Boolean enabled, Boolean expired, Boolean locked) {
        this.username = username;
        this.password = password;
        this.enabled = enabled;
        this.expired = expired;
        this.locked = locked;
    }

}

(2) getUser 方法根据用户登录时输入的用户名查找系统中匹配的用户信息,实际开发中通常是根据用户登录时输入的用户名在数据库中查找匹配的用户信息(DAO),或去其它第三方系统开放的认证接口中查找匹配的用户信息;
(3) authenticate 方法执行认证,注意在密码匹配部分直接使用明文,在实际开发中是不可能这样做的,后续如果有时间会专门写一篇有关 Spring Security 集成各种加密算法的方案。此方法中还使用了一个自定义异常 ConflictAccountException 标识账号冲突,实际上账号冲突通常是因为系统中账号唯一性逻辑出现了问题,是需要严格排查的,所以此处专门定义了一个异常:

package com.spring.security.auth;

import org.springframework.security.authentication.AccountStatusException;

public class ConflictAccountException extends AccountStatusException {
    
    public ConflictAccountException(String msg) {
        super(msg);
    }
}

(4) supports 方法判断是否支持此类型认证,因为本文示例代码中只有一个 AuthenticationProvider,所以设置为支持所有认证请求。

 

六、实现AuthenticationManager

package com.spring.security.auth;

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.ProviderNotFoundException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;

import java.util.Objects;

@Component
public class CustomAuthenticationManager implements AuthenticationManager {
    
    private final AuthenticationProvider authenticationProvider;
    
    public CustomAuthenticationManager(AuthenticationProvider authenticationProvider) {
        this.authenticationProvider = authenticationProvider;
    }
    
    @Override
    public Authentication authenticate(Authentication authentication)
        throws AuthenticationException {
        Authentication result = authenticationProvider.authenticate(authentication);
        if (Objects.nonNull(result)) {
            return result;
        }
        throw new ProviderNotFoundException("Authentication failed!");
    }
}

说明:
(1) 自定义的 AuthenticationManager 有一个 AuthenticationProvider 属性,通过构造器注入了上一步中自定义的 AuthenticationProvider 实例;
(2) AuthenticationManager 只有一个方法 authenticate,将接收的 Authentication 对象传递给 AuthenticationProvider 实例认证,认证返回结果为 null 则抛出 ProviderNotFoundException,否则直接返回 AuthenticationProvider 返回的结果。ProviderManager 是 Spring Security 提供的 AuthenticationManager 默认实现,所以自定义的 AuthenticationManager 也可以直接继承 ProviderManager

 

七、实现 AbstractAuthenticationProcessingFilter

package com.spring.config;

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Objects;

@Component
public class CustomAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    
    public CustomAuthenticationFilter(
        AuthenticationManager authenticationManager, AuthenticationFailureHandler authenticationFailureHandler,
        AuthenticationSuccessHandler authenticationSuccessHandler) {
        super(new AntPathRequestMatcher("/login", "POST"));
        this.setAuthenticationManager(authenticationManager);
        this.setAuthenticationFailureHandler(authenticationFailureHandler);
        this.setAuthenticationSuccessHandler(authenticationSuccessHandler);
    }
    
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
        throws AuthenticationException {
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                "Authentication method not supported: " + request.getMethod());
        }
        /*
        // 添加验证码校验功能
        String captcha = request.getParameter("captcha");
        if (!checkCaptcha(captcha)) {
            throw new AuthenticationException("Invalid captcha!");
        }
        */


        String username = request.getParameter("username");
        String password = request.getParameter("password");
    
        username = Objects.isNull(username) ? "" : username.trim();
        password = Objects.isNull(password) ? "" : password;
    
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
            username, password);
        authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

说明:
(1) 自定义的 AbstractAuthenticationProcessingFilter 通过构造器注入了上一步自定义的 AuthenticationManager,除此之外还注入了一个 AuthenticationSuccessHandler 对象和一个 AuthenticationFailureHandler 对象;
(2) AuthenticationSuccessHandler 在登录认证成功后会被调用,自定义的 AuthenticationSuccessHandler 代码如下:

package com.spring.config;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

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

@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    
    public CustomAuthenticationSuccessHandler() {
    }
    
    @Override
    public void onAuthenticationSuccess(
        HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication)
        throws IOException, ServletException {
        httpServletResponse.sendRedirect("/index");
        // 可以自定义登录成功后的其它动作,如记录用户登录日志、发送上线消息等
    }
}

(3) AuthenticationFailureHandler 在登录认证失败后会被调用,自定义的 AuthenticationFailureHandler 代码如下:

package com.spring.config;

import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

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

@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
    
    public CustomAuthenticationFailureHandler() {
    }
    
    /**
     * 通过检查异常类型实现页面跳转控制
     */
    @Override
    public void onAuthenticationFailure(
        HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e)
        throws IOException, ServletException {
        if (e instanceof UsernameNotFoundException) {
            httpServletResponse.sendRedirect("/login/page?inexistent");
        } else if (e instanceof DisabledException) {
            httpServletResponse.sendRedirect("/login/page?disabled");
        } else if (e instanceof AccountExpiredException) {
            httpServletResponse.sendRedirect("/login/page?expired");
        } else if (e instanceof LockedException) {
            httpServletResponse.sendRedirect("/login/page?locked");
        } else {
            httpServletResponse.sendRedirect("/login/page?error");
        }
    }
}

(4) attemptAuthentication 方法同 org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter 十分类似,唯一多出的注释掉的代码用于实现验证码校验功能,当然此处可以根据实际业务需求定制任意验证功能,有时间可以参考一下 UsernamePasswordAuthenticationFilter 的源码。自定义 AbstractAuthenticationProcessingFilter 也可以直接继承 UsernamePasswordAuthenticationFilter

 

八、实现 AuthenticationEntryPoint

说明:此处为了方便直接使用 Spring Security 中的 org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint 类定义了一个对象,最主要的目的是设置登录页的 URL,如果想了解更多 AuthenticationEntryPoint 接口细节可以参考 LoginUrlAuthenticationEntryPoint 源码。

package com.spring.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;

@Configuration
public class CustomAuthenticationEntryPoint {
    
    @Bean
    public LoginUrlAuthenticationEntryPoint loginUrlAuthenticationEntryPoint() {
        return new LoginUrlAuthenticationEntryPoint("/login/page");
    }
}

 

九、覆盖默认的安全配置  WebSecurityConfigurerAdapter 

package com.spring.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class CustomSecurityConfig extends WebSecurityConfigurerAdapter {
    
    private final AuthenticationEntryPoint authenticationEntryPoint;
    
    private final AbstractAuthenticationProcessingFilter authenticationProcessingFilter;
    
    public CustomSecurityConfig(AuthenticationEntryPoint authenticationEntryPoint, AbstractAuthenticationProcessingFilter authenticationProcessingFilter) {
        this.authenticationEntryPoint = authenticationEntryPoint;
        this.authenticationProcessingFilter = authenticationProcessingFilter;
    }
    
    @Override
    protected void configure(AuthenticationManagerBuilder auth)
        throws Exception {
        auth.eraseCredentials(false);
    }
    
    @Override
    public void configure(WebSecurity web)
        throws Exception {
        web.ignoring().antMatchers("/css/**", "/js/**", "/lib/**");
    }
    
    @Override
    protected void configure(HttpSecurity http)
        throws Exception {
        http
            .exceptionHandling().authenticationEntryPoint(authenticationEntryPoint)
            .and()
            // 允许所有人访问 /login/page
            .authorizeRequests().antMatchers("/login/page").permitAll()
                // 跨域预检请求
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                // 登录URL
                .antMatchers("/login").permitAll()
                // swagger
                .antMatchers("/swagger-ui.html").permitAll()
                .antMatchers("/swagger-resources").permitAll()
                .antMatchers("/v2/api-docs").permitAll()
                .antMatchers("/webjars/springfox-swagger-ui/**").permitAll()
            // 任意访问请求都必须先通过认证
            .anyRequest().authenticated()
            .and()
            // 启用 iframe 功能
            .headers().frameOptions().disable()
            .and()
            // 将自定义的 AbstractAuthenticationProcessingFilter 加在 Spring 过滤器链中
            .addFilterBefore(authenticationProcessingFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

说明:
(1) @EnableWebSecurity 注解禁用 Spring Boot 默认的 Security 配置,自定义扩展 WebSecurityConfigurerAdapter 的类并使用 @Configuration 注解可以实现定制的 Security 配置;
(2) 覆盖 public void configure(WebSecurity web) 方法,此方法主要实现 Web 层配置,一般用于实现不需要安全检查的目录,譬如存放静态文件(前端 JS / CSS 等)的目录;
(3) 覆盖 protected void configure(HttpSecurity http) 方法实现 Request 层的配置,对应 XML Configuration 中的 <http> 元素。这个方法很重要,可以实现很多配置。

(4) swagger 配置

package com.spring.config;
import java.util.ArrayList;
import java.util.List;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.ParameterBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.schema.ModelRef;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Parameter;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

/**
 * Swagger配置
 */
@Configuration
@EnableSwagger2
public class SwaggerConfig {

    @Bean
    public Docket createRestApi(){
        // 添加请求参数,我们这里把token作为请求头部参数传入后端
        ParameterBuilder parameterBuilder = new ParameterBuilder();
        List<Parameter> parameters = new ArrayList<Parameter>();
        parameterBuilder.name("Authorization").description("令牌").modelRef(new ModelRef("string")).parameterType("header")
                .required(false).build();
        parameters.add(parameterBuilder.build());
        return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo()).select().apis(RequestHandlerSelectors.any())
                .paths(PathSelectors.any()).build().globalOperationParameters(parameters);
    }

    private ApiInfo apiInfo(){
        return new ApiInfoBuilder().build();
    }

}

 

十、使用springMVC集成 FreeMarker 演示 登录页面 及成功跳转

(1) Spring Boot 中 FreeMarker 基础配置,已在第二步添加。

(2) 定义 Controller 处理 请求 PageController

package com.spring.security.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@Controller
public class PageController{


    @RequestMapping(value = "/index", method = RequestMethod.GET)
    public String index() {
        return "index";
    }

    @RequestMapping(value = "/login", method = RequestMethod.GET)
    public String login() {
        return "login";
    }

}


(3) 在 resources/templates 目录下新建页面模板 /login/page,登录页 login.ftl。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Login</title>
</head>
<body>
<p>This is login page</p>
<form action="/login" method="post">
    <input name="${_csrf.parameterName}" type="hidden" value="${_csrf.token}">
    <table>
        <tr>
            <th>用户名:</th>
            <td><input type="text" id="username" name="username"></td>
        </tr>
        <tr>
            <th>密码:</th>
            <td><input type="password" id="password" name="password"></td>
        </tr>
        <tr>
            <th>验证码:</th>
            <td><input type="text" id="captcha" name="captcha"></td>
        </tr>
    </table>
    <input type="submit" value="登录">
</form>
</body>
</html>

(4) 登陆成功后的首页 index.ftl。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Index</title>
</head>
<body>
<p>登录成功,欢迎光临 !</p>
</body>
</html>

 

亲测可用,如图所示:

 

 

 

 

 

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

码码再也不用担心我的学习

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

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

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

打赏作者

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

抵扣说明:

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

余额充值