spring-boot + spring-security + jwt 实现前后端分离的权限管理

一、搭建环境

java中常用的权限管理框架有 shiro 和 spring security,之前一直在用 shiro 管理权限,但是后来发现 shiro 确实和前后端分离不太搭,就来研究了两天spring security,与 shiro 不同的是,spring security 是通过一系列的 过滤链管理权限的,而且这些过滤器都可以自定义,虽然比 shiro 体量更大,但是更加的灵活,可以高度自定义,而且 spring security 还会自动生成 login 接口。

1.1 导入依赖和基本配置

<dependencies>
<!--        java-jwt依赖-->
    <dependency>
        <groupId>com.auth0</groupId>
        <artifactId>java-jwt</artifactId>
        <version>3.11.0</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid</artifactId>
    </dependency>
    
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <!--mybatis-plus依赖-->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.4.1</version>
    </dependency>
    <!--模板引擎-->
    <dependency>
        <groupId>org.apache.velocity</groupId>
        <artifactId>velocity-engine-core</artifactId>
        <version>2.2</version>
    </dependency>
    <!--自动生成代码时会用到的依赖-->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-generator</artifactId>
        <version>3.4.1</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-web</artifactId>
    </dependency>
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.8.3</version>
        <scope>compile</scope>
    </dependency>
</dependencies>
server:
  port: 9999
spring:
  application:
    name: cloud-payment-service
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource            # 当前数据源操作类型
    driver-class-name: com.mysql.cj.jdbc.Driver            # mysql驱动包 com.mysql.jdbc.Driver
    url: {your url}
    username: {your username}
    password: {your password}

mybatis-plus:
  mapper-locations: classpath:com/gewj/mapper/xml/*

1.2、 数据库和简单实体类创建

create table user
(
    id        bigint auto_increment comment 'userId'
        primary key,
    username  varchar(50)                not null comment '用户名',
    password  varchar(1024)              not null comment '密码',
    nickname  varchar(50)                null comment '昵称',
    telepnone varchar(20)                null,
    email     varchar(30)                null comment '邮箱',
    role      varchar(10) default 'ROLE_USER' null
);

创建实体类可以用 mybatis-plus 的代码生成器,也可以手动写,此处就不赘述啦

需要注意的是,userService 里面最好有一个 getUserByUsername() 的方法

public User getByUsername(String username) {
    QueryWrapper<User> wrapper = new QueryWrapper<>();
    wrapper.eq("username",username);
    User one = getOne(wrapper);
    return one;
}

1.3、 创建 UserDetails 类

@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserDetials extends User implements UserDetails {


    private Long id;
    private String username;
    private String password;
    private Collection<? extends GrantedAuthority> authorities;
    
    //下面的变量不是必须的
    private boolean isAccountNonExpired;
    private boolean isAccountNonLocked;
    private boolean isCredentialsNonExpired;
    private boolean isEnabled;



    public UserDetials(User user, Collection<? extends GrantedAuthority> authorities) {
        this.setUsername(user.getUsername());
        this.setId(user.getId());
        this.setPassword(user.getPassword());
        this.setAuthorities(authorities);
        this.setAccountNonExpired(true);
        this.setAccountNonLocked(true);
        this.setCredentialsNonExpired(true);
        this.setEnabled(true);
    }

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

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

    @Override
    public String getUsername() {
        return this.username;
    }

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

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


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

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

}

这个类的作用是提供给 spring security的。

Result 类

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Result {
    private Integer code;
    private String  message;
    private Boolean success;
    private Map<String, Object> data = new HashMap<>();

    public static Result ok(String message) {
        Result result = new Result();
        result.setCode(200);
        result.setMessage(message);
        result.setSuccess(true);

        return result;
    }

    public static Result error(Integer code, String message) {
        Result result = new Result();
        result.setSuccess(false);
        result.setCode(code);
        result.setMessage(message);
        return result;
    }

    public static Result ok() {
        Result result = new Result();
        result.setCode(200);
        result.setSuccess(true);

        return result;
    }

    public static Result error() {
        Result result = new Result();
        result.setCode(500);
        return result;
    }

    public static Result error(Integer code) {
        Result result = new Result();
        result.setCode(code);
        return result;
    }

    public Result message(String message) {
        this.setMessage(message);
        return this;
    }

    public Result data(Map<String, Object> map) {
        this.setData(map);
        return this;
    }

    public Result data(String key, Object value) {
        this.data.put(key, value);
        return this;
    }

    public Result(Integer code) {
        this.code = code;
    }

    public Result code(Integer code) {
        this.setCode(code);
        return this;
    }

}

1.4、 jwt 工具类

@Component
public class JwtUtil {

//    过期时长(分钟);
    private static final int EXPIRE_TIME_MINUTE = 300;
    private static final String secret = "zlgewj";

    public static String sign(String username, String authorities) {

        Date date = DateUtil.offsetMinute(new Date(),EXPIRE_TIME_MINUTE);

        String jwt = JWT.create()
                .withClaim("username",username)
                .withClaim("currentTimeMillis", String.valueOf(date))
                .withClaim("authorities",authorities)
                .withExpiresAt(date)
                .sign(Algorithm.HMAC256(secret));
        return jwt;
    }

    public static String getUsername(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("username").asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    public static boolean verify(String token, String username) {
        try {
            DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC256(secret))
                    .withClaim("username", username)
                    .build()
                    .verify(token);
            return true;
        }catch (Exception e) {
            return false;
        }
    }

    public static String getClaim(String token, String claimName) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim(claimName).asString();
        }catch (JWTDecodeException e){
            e.printStackTrace();
            return null;
        }
    }

}

二、 filter 和 handler

下面正头戏才开始:

2.1、 登录过滤器

@Slf4j
public class JwtLoginFilter extends AbstractAuthenticationProcessingFilter {

    
    public JwtLoginFilter(String defaultFilterProcessesUrl, AuthenticationManager authenticationManager) {

        super(new AntPathRequestMatcher(defaultFilterProcessesUrl));
        setAuthenticationManager(authenticationManager);
    }

	//发送登录请求会执行的方法
    @Override
    public Authentication attemptAuthentication(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws AuthenticationException, IOException, ServletException {

        log.info("执行了 attemptAuthentication 方法");
        UserDetials userDetials = new ObjectMapper().readValue(httpServletRequest.getInputStream(), UserDetials.class);

        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
                userDetials.getUsername(),
                userDetials.getPassword(),
                userDetials.getAuthorities()
        );
        //调用 AuthenticationManager 的 authenticate 方法验证,我们传了一个 UsernamePasswordAuthenticationToken
        return getAuthenticationManager().authenticate(token);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {

        log.info("执行了登陆成功回调");

        Collection<? extends GrantedAuthority> authorities = authResult.getAuthorities();

        StringBuffer stringBuffer = new StringBuffer();

        authorities.forEach(authority -> {
            stringBuffer.append(authority.getAuthority()).append(",");
        });

        String sign = JwtUtil.sign(authResult.getName(),stringBuffer.toString());
        response.setContentType("application/json; charset=UTF-8");
        ServletOutputStream outputStream = response.getOutputStream();

        UserVo userVo = new UserVo();
        userVo.setToken(sign);

        Result result = Result.ok().data("user", userVo).message("登陆成功!");
        outputStream.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));

        outputStream.flush();
        outputStream.close();

    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        response.setContentType("application/json; charset=UTF-8");

        log.info("执行了登录失败回调");
        Result res = Result.error(403, "账号或密码错误~");

        ServletOutputStream outputStream = response.getOutputStream();
        outputStream.write(JSONUtil.toJsonStr(res).getBytes(StandardCharsets.UTF_8));
        outputStream.flush();
        outputStream.close();
    }
}

发送的登录请求会来到 AbstractAuthenticationProcessingFilter 这个过滤器,我们实现了这个过滤器,调用getAuthenticationManager().authenticate(token); 这个时候,token 里面还没有 authorities, spring security 会用 UserDetailsService 查询用户的详细信息

2.2、 授权过滤器

@Slf4j
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String token = request.getHeader("USER_TOKEN");

        Authentication authentication = null;

            if (token != null) {
                List<SimpleGrantedAuthority> authorities = Arrays.stream(JwtUtil.getClaim(token, "authorities").toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList()
                        );
                String username = JwtUtil.getUsername(token);
                log.info("认证过滤器执行了,当前用户权限:{}",authorities.toString());

                if (username != null) {
                    UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, null, authorities);
                    usernamePasswordAuthenticationToken.setDetails(token);
                    
                    if (!JwtUtil.verify(token, username)) {
                        log.info("token验证失败~");
                        response.setContentType("application/json; charset=UTF-8");
                        ServletOutputStream outputStream = response.getOutputStream();
                        Result error = Result.error(403, "登录过时,请重新登录~");
                        outputStream.write(JSONUtil.toJsonStr(error).getBytes(StandardCharsets.UTF_8));
                        outputStream.flush();
                        outputStream.close();
                    }
                    
                    authentication = usernamePasswordAuthenticationToken;
                }
            }
            SecurityContextHolder.getContext().setAuthentication(authentication);

            filterChain.doFilter(request, response);


    }
}

这个方法会根据请求头里 token 的 权限列表拿出来,放到全局,用我们 JwtUtil.verify 方法认证

2.3、权限不足的处理

@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {

        response.setContentType("application/json; charset=UTF-8");

        ServletOutputStream outputStream = response.getOutputStream();
        Result error = Result.error(403, "权限不足~");
        outputStream.write(JSONUtil.toJsonStr(error).getBytes(StandardCharsets.UTF_8));
        outputStream.flush();
        outputStream.close();

    }
}

当请求的权限不足的时候就会来到这里

2.4、 获取 UserDetails 的 service

@Slf4j
@Component
public class UserDetailsServiceImpl implements UserDetailsService {


    private static UserService userService;

    @Autowired
    public void setUserService(UserService userService1) {
        userService = userService1;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        User byUsername = userService.getByUsername(username);
        if ( null == byUsername) {
            throw new UsernameNotFoundException("用户名或密码不正确");
        }
        UserDetials userDetials = new UserDetials(byUsername,getUserAuthority(byUsername.getId()));

        System.out.println(userDetials.toString());
        return userDetials;
    }


    // 根据 userId 获取用户的角色列表, 含有多个角色的用 ,分隔
    public List<GrantedAuthority> getUserAuthority(Long userId){

        // 角色(ROLE_ADMIN)
        User user = userService.getById(userId);  // ROLE_ADMIN,ROLE_NORMAL
        String role = user.getRole();
        return AuthorityUtils.commaSeparatedStringToAuthorityList(role);
    }

}

执行 getAuthenticationManager().authenticate(token); 方法的时候 loadUserByUsername() 这个方法就会被调用,所以我们需要重写这个方法

三、配置类

刚刚我们写的 filter 和 handler,spring security 只知道它们的存在,不知道该不该调用,什么时候调用,所以

@Configuration
@EnableWebSecurity
//开启精确到方法的注解支持
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    public static String ADMIN = "ROLE_ADMIN";
    public static String USER = "ROLE_USER";
    @Autowired
    private MyLogoutHandler myLogoutHandler;
    @Autowired
    private MyAccessDeniedHandler myAccessDeniedHandler;

    /**
     * 开放访问的请求
     */
    private final static String[] PERMIT_ALL_MAPPING = {
            "/api/hello",
            "/api/login",
            "/api/home",
            "/api/verifyImage",
            "/api/image/verify",
            "/images/**"
    };


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

    /**
     * 跨域配置
     */
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        // 允许跨域访问的 URL
        List<String> allowedOriginsUrl = new ArrayList<>();
        allowedOriginsUrl.add("*");

        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        // 设置允许跨域访问的 URL
        config.setAllowedOrigins(allowedOriginsUrl);
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers(PERMIT_ALL_MAPPING)
                .permitAll()
                .antMatchers("/api/user/**", "/api/data", "/api/logout")
                // USER 和 ADMIN 都可以访问
                .hasAnyAuthority(USER, ADMIN)
                .antMatchers("/api/admin/**")
                // 只有 ADMIN 才可以访问
                .hasAnyAuthority(ADMIN)
                .anyRequest()
                .authenticated()
                .and()
                // 添加过滤器链,前一个参数过滤器, 后一个参数过滤器添加的地方
                // 登陆过滤器
                .addFilterBefore(new JwtLoginFilter("/api/login", authenticationManager()), UsernamePasswordAuthenticationFilter.class)
                // 请求过滤器
                .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                // 开启跨域
                .cors()
                .and()
                .csrf()
                .disable()
                .logout()
                .addLogoutHandler(myLogoutHandler)
                .and()
                .exceptionHandling()
                .accessDeniedHandler(myAccessDeniedHandler);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        super.configure(web);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 在内存中写入用户数据
        auth.
                authenticationProvider(daoAuthenticationProvider());
    }

    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() {

        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setHideUserNotFoundExceptions(false);
        provider.setPasswordEncoder(passwordEncoder());
        provider.setUserDetailsService(new UserDetailsServiceImpl());
        return provider;
    }
}

再来个测试接口测试一下

@RestController()
@RequestMapping("/api")
public class TestController {

    @GetMapping("/hello")
    //只有 ROLE_ADMIN 角色可以访问
    @Secured({"ROLE_ADMIN"})
    public String hello() {
        return "hello";
    }

}
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
Spring BootSpring Security是非常常用的Java框架和库,用于构建安全和可扩展的Web应用程序。JWT(JSON Web Token)是一种用于在网络应用间安全传递身份验证和声明信息的标准。 要在Spring Boot中使用Spring SecurityJWT,你需要进行以下步骤: 1. 添加依赖:在你的项目的pom.xml文件中添加Spring SecurityJWT的依赖。 ```xml <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> ``` 2. 创建JWT工具类:编写一个JWT工具类,用于生成和解析JWT。 ```java import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.security.core.userdetails.UserDetails; import java.util.Date; import java.util.HashMap; import java.util.Map; public class JwtUtil { private static final String SECRET_KEY = "your-secret-key"; private static final long EXPIRATION_TIME = 864_000_000; // 10 days public static String generateToken(UserDetails userDetails) { Map<String, Object> claims = new HashMap<>(); return Jwts.builder() .setClaims(claims) .setSubject(userDetails.getUsername()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) .signWith(SignatureAlgorithm.HS512, SECRET_KEY) .compact(); } public static String extractUsername(String token) { return extractClaims(token).getSubject(); } public static Date extractExpiration(String token) { return extractClaims(token).getExpiration(); } private static Claims extractClaims(String token) { return Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody(); } } ``` 3. 配置Spring Security:创建一个继承自WebSecurityConfigurerAdapter的配置类,用于配置Spring Security。 ```java import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; 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.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; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder; @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserDetailsService userDetailsService; @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); } @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeRequests() .antMatchers("/api/auth/**").permitAll() .anyRequest().authenticated() .and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); } @Bean public PasswordEncoder passwordEncoder() { return new Pbkdf2PasswordEncoder(); } @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } } ``` 4. 创建UserDetails实现类:实现Spring Security的UserDetails接口,用于获取用户信息。 ```java import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.Collection; import java.util.List; public class CustomUserDetails implements UserDetails { private String username; private String password; public CustomUserDetails(String username, String password) { this.username = username; this.password = password; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return List.of(() -> "ROLE_USER"); } @Override public String getPassword() { return password; } @Override public String getUsername() { return username; } // Other UserDetails methods... @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } } ``` 5. 创建认证和授权的Controller:创建一个RestController,用于处理用户认证和授权的请求。 ```java import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private AuthenticationManager authenticationManager; @Autowired private UserDetailsService userDetailsService; @PostMapping("/login") public ResponseEntity<String> login(@RequestBody AuthRequest authRequest) { authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(authRequest.getUsername(), authRequest.getPassword()) ); UserDetails userDetails = userDetailsService.loadUserByUsername(authRequest.getUsername()); String token = JwtUtil.generateToken(userDetails); return ResponseEntity.ok(token); } } class AuthRequest { private String username; private String password; // getters and setters... } ``` 这样,你就可以在Spring Boot应用程序中使用Spring SecurityJWT实现认证和授权了。当用户登录时,会生成一个JWT,并在以后的请求中使用该JWT进行身份验证。 请注意,以上代码只是一个简单的示例,你可能需要根据你的实际需求进行适当的修改和扩展。另外,确保保护敏感信息(如密钥)的安全。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

斜晖丶

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

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

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

打赏作者

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

抵扣说明:

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

余额充值