【OAuth2系列】集成微信小程序登录到 Spring Security OAuth 2.0

作者:后端小肥肠

创作不易,未经允许严禁转载。

姊妹篇:

【Spring Security系列】权限之旅:SpringSecurity小程序登录深度探索_spring security 微信小程序登录-CSDN博客

目录

1. 前言

2. 总体登录流程

3. 数据表设计

3.1. sys_user表

3.2. user_auth表

3.3. 表关系

4. OAuth2 扩展实现小程序登录

 4.1 OAuth2登录涉及到的重要组件讲解

4.2 微信小程序自定义登录

 5. 结语


1. 前言

随着微信小程序在国内的广泛应用,越来越多的企业希望将微信小程序登录功能集成到他们的系统中,以提供更便捷的用户体验。Spring Security OAuth 2.0 是一个强大的框架,可以帮助我们实现这一需求。本文将详细介绍如何在 Spring Security OAuth 2.0 中扩展支持微信小程序登录,通过自定义授权方式实现无缝登录。

2. 总体登录流程

微信登录绑定drawio-第 2 页.drawio.png
                                                                                                                后端小肥肠

上述流程图描述了小程序集成OAuth2获取Token登录步骤及日常携带token访问接口步骤:

登录步骤:

1. 获取用户基本信息

在小程序端,调用 wx.getUserProfile() 方法来获取用户的基本信息。这一步通常是在用户同意授权后进行的,用于获取用户的头像、昵称等基本资料。

2. 获取登录凭证

通过调用 wx.login() 方法,小程序端可以获取到一个 loginCode,这是一个临时登录凭证,用于后续的认证请求。

3. 获取手机号凭证

调用 getPhoneNumber 方法,小程序端可以获取到一个 phoneCode,这是一个用于获取用户手机号的临时凭证。

4. 发送登录请求

小程序端将 loginCodephoneCode 一起发送给开发者服务器。开发者服务器接收到这些凭证后,开始处理登录请求。

5. 调用微信接口获取 OpenID

开发者服务器使用 appidappsecret 以及 loginCode 调用微信的登录凭证校验接口(https://api.weixin.qq.com/sns/jscode2session),微信返回 session_keyopenid 等信息。

6. 检查 OpenID

开发者服务器查询本地的 user_auth 表,检查是否存在对应的 openid。如果 openid 已经存在,说明用户已经注册过,直接进行登录操作。

7. 获取 AccessToken

如果 openid 不存在,开发者服务器需要调用微信的接口获取 access_tokenhttps://api.weixin.qq.com/cgi-bin/token)。

8. 获取用户手机号

使用获取到的 access_tokenphoneCode,开发者服务器调用微信接口(https://api.weixin.qq.com/wxa/business/getuserphonenumber)来获取用户的手机号。

9. 用户注册或绑定

开发者服务器将获取到的手机号与 user 表中的数据进行比对。如果存在匹配的手机号,则在 user_auth 表中新增一条记录,将 openid 绑定到用户账户上。如果不存在匹配的手机号,则创建一个新的用户账户,并将 openid 与该新账户绑定。

10. 自定义登录状态

开发者服务器根据用户的注册或绑定结果,创建并返回一个自定义的 OAuth2 登录状态(OAuth2Authentication 对象),并生成相应的 Token。

11. 返回 Token 和用户基本信息

开发者服务器将生成的 Token 和用户的基本信息返回给小程序端。

携带token访问接口步骤:

1. 业务请求

小程序端在后续的业务请求中,将 Token 放在请求头中发送给开发者服务器。

2. 验证登录状态

开发者服务器在接收到业务请求后,验证请求头中的 Token 以确认用户的登录状态,并处理相应的业务逻辑。

3. 返回业务数据

验证通过后,开发者服务器返回相应的业务数据给小程序端,完成整个流程。

通过以上步骤,实现了微信小程序与 OAuth2 的无缝集成,确保了用户的便捷登录和系统的安全性。


3. 数据表设计

3.1. sys_user表

CREATE TABLE "public"."sys_user" (
  "id" "pg_catalog"."varchar" COLLATE "pg_catalog"."default" NOT NULL,
  "username" "pg_catalog"."varchar" COLLATE "pg_catalog"."default" NOT NULL,
  "password" "pg_catalog"."varchar" COLLATE "pg_catalog"."default",
  "is_enabled" "pg_catalog"."int4",
  "mobile" "pg_catalog"."varchar" COLLATE "pg_catalog"."default",
  "create_time" "pg_catalog"."timestamp",
  "update_time" "pg_catalog"."timestamp",
  "version" "pg_catalog"."int4" DEFAULT 1,
  "department_id" "pg_catalog"."varchar" COLLATE "pg_catalog"."default",
  "name" "pg_catalog"."varchar" COLLATE "pg_catalog"."default" NOT NULL,
  "image_url" "pg_catalog"."varchar" COLLATE "pg_catalog"."default",
  CONSTRAINT "sys_user_intranet_pkey" PRIMARY KEY ("id")
)
;


COMMENT ON COLUMN "public"."sys_user"."id" IS '用户 ID';

COMMENT ON COLUMN "public"."sys_user"."username" IS '用户名';

COMMENT ON COLUMN "public"."sys_user"."password" IS '密码,加密存储, admin/1234';

COMMENT ON COLUMN "public"."sys_user"."is_enabled" IS '帐户是否可用(1 可用,0 删除用户)';

COMMENT ON COLUMN "public"."sys_user"."mobile" IS '注册手机号';

COMMENT ON COLUMN "public"."sys_user"."create_time" IS '创建时间';

COMMENT ON COLUMN "public"."sys_user"."update_time" IS '更新时间';

COMMENT ON COLUMN "public"."sys_user"."version" IS '乐观锁';

COMMENT ON COLUMN "public"."sys_user"."name" IS '真实姓名';

COMMENT ON TABLE "public"."sys_user" IS '用户信息表';

3.2. user_auth表

CREATE TABLE "public"."sys_user_auth" (
  "id" "pg_catalog"."varchar" COLLATE "pg_catalog"."default" NOT NULL,
  "user_id" "pg_catalog"."varchar" COLLATE "pg_catalog"."default" NOT NULL,
  "identity_type" "pg_catalog"."varchar" COLLATE "pg_catalog"."default" NOT NULL,
  "identifier" "pg_catalog"."varchar" COLLATE "pg_catalog"."default",
  "credential" "pg_catalog"."varchar" COLLATE "pg_catalog"."default",
  "log_time" "pg_catalog"."timestamp",
  "is_phone_verified" "pg_catalog"."int2",
  "is_email_verified" "pg_catalog"."int2",
  CONSTRAINT "sys_user_auth_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "user_auth_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."sys_user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION
)
;

 上表中的identifier字段为小程序登录时的openId:

3.3. 表关系

在上图中可看出sys_user表的id与sys_user_auth中的user_id为逻辑外键的关系。 


4. OAuth2 扩展实现小程序登录

 
4.1 OAuth2登录涉及到的重要组件讲解

1. TokenEndpoint

Token请求的入口是 TokenEndpoint。客户端通过 /oauth/token 发送请求来获取访问令牌。

2. ClientDetailsService

ClientDetailsService 负责加载客户端的详细信息。InMemoryClientDetailsService 是一种典型的实现方式,它从内存中加载客户端详细信息,但也可以使用其他实现,例如从数据库加载数据的 JdbcClientDetailsService

3. TokenRequest

加载客户端详细信息后,会创建一个 TokenRequest 对象,该对象包含关于客户端请求令牌的信息,例如客户端ID、授权类型、范围等。

4. TokenGranter

TokenGranter 接口定义了授予令牌的机制。CompositeTokenGranter 是一种典型的实现,根据授权类型(例如授权码、密码、客户端凭证等)委派给其他 TokenGranter 实现。

5. OAuth2Request

OAuth2Request 代表客户端发起的OAuth2请求。该对象封装了客户端请求的所有参数。

6. Authentication

认证过程验证客户端凭据以及其他必要的认证步骤(例如,如果是密码授权类型,还需要验证用户的凭据)。

7. OAuth2Authentication

OAuth2Authentication 对象将 OAuth2Request 与认证的主体(用户详细信息或客户端详细信息)结合起来。这个对象用于创建访问令牌。

8. AuthorizationServerTokenServices

AuthorizationServerTokenServices 接口定义了发放令牌的操作。DefaultTokenServices 是一种典型的实现,处理令牌的创建和持久化。

9. TokenStore 和 TokenEnhancer

TokenStore 接口定义了如何存储和检索令牌(例如,内存、数据库、JWT等)。TokenEnhancer 允许在令牌发放之前添加额外的信息。

10. OAuth2AccessToken

这个令牌包含访问令牌本身,以及其他信息如过期时间、刷新令牌、范围等。

4.2 微信小程序自定义登录

OAuth2默认授权方式为5种(授权码模式、简化模式、密码模式、客户端凭据模式和刷新令牌模式),不包含小程序登录,需要编写自定义授权代码,拓展AbstractTokenGranter,步骤如下:

1.在原有的五种授权模式上新增WechatTokenGranter,集成自AbstractTokenGranter:

package com.geoscene.ynbackoauth2server.oauth2.granter;

/**
 * @version 1.0
 * @description: TODO
 * @author: xfc
 * @date 2022-10-10 15:12
 */

import com.geoscene.ynbackoauth2server.oauth2.authentication.WechatAuthenticationToken;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.common.exceptions.InvalidGrantException;
import org.springframework.security.oauth2.provider.*;
import org.springframework.security.oauth2.provider.token.AbstractTokenGranter;
import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices;
import java.util.LinkedHashMap;
import java.util.Map;


public class WechatTokenGranter extends AbstractTokenGranter {

    // 自定义授权方式为 wechat
    private static final String GRANT_TYPE = "wechat";

    private final AuthenticationManager authenticationManager;

    public WechatTokenGranter(AuthenticationManager authenticationManager,
                              AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) {
        this(authenticationManager, tokenServices, clientDetailsService, requestFactory, GRANT_TYPE);
    }

    protected WechatTokenGranter(AuthenticationManager authenticationManager, AuthorizationServerTokenServices tokenServices,
                                 ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory, String grantType) {
        super(tokenServices, clientDetailsService, requestFactory, grantType);
        this.authenticationManager = authenticationManager;
    }

    @Override
    protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {

        Map<String, String> parameters = new LinkedHashMap(tokenRequest.getRequestParameters());
        String loginCode = parameters.get("loginCode");
        String phoneCode=parameters.get("phoneCode");
//        String encryptedData = parameters.get("encryptedData");
//        String iv = parameters.get("iv");

        // 移除后续无用参数
        parameters.remove("loginCode");
        parameters.remove("phoneCode");
//        parameters.remove("encryptedData");
//        parameters.remove("iv");

        Authentication userAuth = new WechatAuthenticationToken(loginCode,phoneCode); // 未认证状态
        ((AbstractAuthenticationToken) userAuth).setDetails(parameters);

        try {
            userAuth = this.authenticationManager.authenticate(userAuth); // 认证中
        } catch (Exception e) {
            throw new InvalidGrantException(e.getMessage());
        }

        if (userAuth != null && userAuth.isAuthenticated()) { // 认证成功
            OAuth2Request storedOAuth2Request = this.getRequestFactory().createOAuth2Request(client, tokenRequest);
            return new OAuth2Authentication(storedOAuth2Request, userAuth);
        } else { // 认证失败
            throw new InvalidGrantException("Could not authenticate code: " + loginCode);
        }
    }
}

WechatTokenGranter 类通过自定义的授权类型 "wechat" 实现了微信小程序登录的认证流程。它扩展了 AbstractTokenGranter,并通过重写 getOAuth2Authentication 方法来处理微信特有的认证逻辑。该类确保了在微信小程序登录过程中,能够正确处理 loginCodephoneCode,并通过 authenticationManager 进行认证,最终返回 OAuth2 的认证结果。 

2.新增WechatAuthenticationProvider用于验证WechatAuthenticationToken:

package com.geoscene.ynbackoauth2server.oauth2.authentication;

/**
 * @version 1.0
 * @description: TODO
 * @author: xfc
 * @date 2022-10-10 15:30
 */

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.geoscene.ynbackapi.entities.SysUser;
import com.geoscene.ynbackapi.feign.IFeignSystemController;
import com.geoscene.ynbackapi.req.AddUserAuthReq;
import com.geoscene.ynbackoauth2server.oauth2.config.WechatConfig;
import com.geoscene.ynbackoauth2server.oauth2.service.JwtUser;
import com.geoscene.ynbackoauth2server.oauth2.service.WeChatService;
import com.geoscene.ynbackoauth2server.web.utils.RedisUtils;
import com.geoscene.ynbackoauth2server.web.utils.RestTemplateUtil;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import java.util.*;

@Slf4j
@Component
public class WechatAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private RedisUtils redisUtils;

    @Autowired
    private WechatConfig wechatConfig;

    @Autowired
    private WeChatService weChatService;

    @Autowired
    RestTemplate restTemplate;

    @Autowired
    IFeignSystemController feignSystemController;


    @Override
    @SneakyThrows
    public Authentication authenticate(Authentication authentication) {

        WechatAuthenticationToken wechatAuthenticationToken = (WechatAuthenticationToken) authentication;
        String loginCode = wechatAuthenticationToken.getPrincipal().toString();
        log.info("loginCode为:{}",loginCode);
        String phoneCode=wechatAuthenticationToken.getPhoneCode().toString();
        log.info("phoneCode为:{}",phoneCode);
        //获取openId
        JwtUser jwtUser=null;
        String url = "https://api.weixin.qq.com/sns/jscode2session?appid={appid}&secret={secret}&js_code={code}&grant_type=authorization_code";
        Map<String, String> requestMap = new HashMap<>();
        requestMap.put("appid", wechatConfig.getAppid());
        requestMap.put("secret", wechatConfig.getSecret());
        requestMap.put("code", loginCode);
        ResponseEntity<String> responseEntity = restTemplate.getForEntity(url, String.class,requestMap);
        JSONObject jsonObject= JSONObject.parseObject(responseEntity.getBody());
        log.info(JSONObject.toJSONString(jsonObject));
        String openId=jsonObject.getString("openid");
        if(StringUtils.isBlank(openId)) {
            throw new BadCredentialsException("weChat get openId error");
        }
        if(feignSystemController.getuserAuthCountByIdentifier(openId)>0){
             jwtUser = (JwtUser) weChatService.getUserByOpenId(openId);
             return getauthenticationToken(jwtUser,jwtUser.getAuthorities());
        }
        //获取手机号第一步,获取accessToken
        String accessTokenUrl="https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appid}&secret={secret}";
        Map<String, String> accessTokenRequestMap = new HashMap<>();
        accessTokenRequestMap.put("appid", wechatConfig.getAppid());
        accessTokenRequestMap.put("secret", wechatConfig.getSecret());
        ResponseEntity<String>  accessTokenResponseEntity = restTemplate.getForEntity(accessTokenUrl, String.class,accessTokenRequestMap);
        JSONObject  accessTokenJsonObject= JSONObject.parseObject(accessTokenResponseEntity.getBody());
        log.info(JSONObject.toJSONString(accessTokenJsonObject));
        String  accessToken=accessTokenJsonObject.getString("access_token");
        if(StringUtils.isBlank(accessToken)) {
            throw new BadCredentialsException("weChat get accessToken error");
        }
        //获取手机号第二部,远程请求获取手机号
        String pohoneUrl="https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token="+accessToken+"";
        JSONObject phoneJson=new JSONObject();
        phoneJson.put("code",phoneCode);
        String resPhoneStr= RestTemplateUtil.postForJson(pohoneUrl,phoneJson,restTemplate);
        log.info(resPhoneStr);
        JSONObject resPhonJson=JSON.parseObject(resPhoneStr);
        JSONObject phoneInfo=resPhonJson.getJSONObject("phone_info");
        String mobile=phoneInfo.getString("phoneNumber");
        if(StringUtils.isBlank(mobile)){
            throw new BadCredentialsException("Wechat get mobile error");
        }
        jwtUser= (JwtUser) weChatService.getUserByMobile(mobile);
        feignSystemController.saveUserAuth(new AddUserAuthReq(jwtUser.getUid(),"wechat",openId));
        return getauthenticationToken(jwtUser,jwtUser.getAuthorities());

    }

    @Override
    public boolean supports(Class<?> authentication) {
        return WechatAuthenticationToken.class.isAssignableFrom(authentication);
    }
    public WechatAuthenticationToken getauthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities){
        WechatAuthenticationToken authenticationToken=new WechatAuthenticationToken(principal,authorities);
        LinkedHashMap<Object, Object> linkedHashMap = new LinkedHashMap<>();
        linkedHashMap.put("principal", authenticationToken.getPrincipal());
        authenticationToken.setDetails(linkedHashMap);
        return authenticationToken;
    }
}

代码讲解: 

1. 根据前端传入的code,结合appid和secret,远程调用https://api.weixin.qq.com/sns/jscode2session?appid={appid}&secret={secret}&js_code={code}&grant_type=authorization_code获取openId;

2.根据获取的openId查看user_auth表中identifier是否有对应openId,有直接登录返回token,没有则调用获取手机号接口;

3.根据appid和secret调用https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appid}&secret={secret}获取accessToken;

4.根据前端传来的code(与获取openId的code不同)结合accessToken调用https://api.weixin.qq.com/wxa/business/getuserphonenumber获取手机号;

5.将获取的手机号与user表中手机号进行对比,存在则在user_auth表中新增一条数据,并返回token;

6.不存在手机号则在user表中新增一条数据,用户名为手机号,权限为普通用户,同时在user_auth,user_role中新增一条记录,并登录返回token;

 5. 结语

通过本文的介绍,我们成功地在 Spring Security OAuth 2.0 中实现了微信小程序的登录扩展。通过自定义授权器和验证器,我们能够处理微信小程序特有的登录流程,确保用户能够安全、便捷地通过小程序登录到我们的系统中。这不仅提升了用户体验,也增强了系统的安全性和灵活性。如果你在实际操作中遇到任何问题或有更好的建议,欢迎交流与探讨。

  • 15
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
### 回答1: 要实现微信小程序登录,可以使用Spring Security提供的OAuth 2.0协议实现。以下是基本的步骤: 1. 在微信开放平台中创建小程序,获取AppID和AppSecret。 2. 在Spring Security中配置OAuth 2.0客户端,设置微信小程序的AppID、AppSecret以及授权范围。 3. 创建一个Controller,处理微信小程序登录请求。在该Controller中,使用RestTemplate向微信平台发送请求,获取access_token和openid等信息。 4. 根据openid创建用户信息,并将用户信息存储在数据库中。 5. 在Spring Security中配置自定义的UserDetailsService,根据openid从数据库中查询用户信息并返回。 6. 在Spring Security中配置自定义的AuthenticationProvider,对用户进行认证。 具体实现细节可以参考Spring Security官方文档和示例代码。 ### 回答2: Spring Security可以用于实现微信小程序登录功能。下面是实现该功能的大概步骤: 1. 配置微信小程序开放平台的AppID和AppSecret,并获取sessionKey和openid。 2. 创建一个用于处理登录请求的接口,并在该接口中获取小程序传递的code参数。 3. 使用HTTP请求,向微信服务器发送code和之前配置的AppID、AppSecret,以获取openid和sessionKey。 4. 将获取到的openid和sessionKey存储在数据库中,作为用户的登录凭证。 5. 创建一个用户实体类,并添加相应的字段,比如openid、sessionKey等。 6. 实现一个自定义的UserDetailsService接口,用于根据openid查询用户信息。 7. 创建一个TokenGranter类,用于创建自定义的Token,包含openid和sessionKey等信息。 8. 实现一个自定义的AuthenticationProvider类,用于根据Token进行认证,并授权用户的访问权限。 9. 创建一个自定义的AuthenticationFilter类,用于处理登录请求,并验证用户的Token是否有效。 10. 将上述配置添加到Spring Security的配置类中,并配置相关的路径和权限。 通过上述步骤,我们可以实现微信小程序登录功能。用户通过小程序登录后,系统会根据openid查询用户信息,并通过Token进行认证和授权,确保用户可以访问相应的资源。同时,可以根据业务需求,在上述步骤中添加其他的逻辑处理。 ### 回答3: Spring Security是基于Java的安全框架,用于处理应用程序的认证和授权功能。要实现微信小程序登录,可以按照以下步骤进行: 1. 配置微信小程序登录:首先,需要在微信开发者平台注册小程序,并获取到小程序的AppID和AppSecret。然后,在Spring Security配置中,配置微信登录的认证提供商和回调URL。例如,在`SecurityConfig`类中可以使用`WeChatAuthenticationFilter`来处理微信登录流程和认证。 2. 创建WeChatAuthenticationFilter:继承`AbstractAuthenticationProcessingFilter`类,重写`attemptAuthentication`方法,实现微信登录的认证逻辑。在该方法中,将获取到的小程序code发送到微信服务器,通过code获取到微信用户的唯一标识OpenID和会话标识SessionKey。 3. 自定义AuthenticationProvider:创建一个自定义的`AuthenticationProvider`实现类,用于处理微信登录的认证逻辑。在该类中,可以根据微信的OpenID进行用户的查询和创建,生成用户的凭证信息,并返回一个实现了`Authentication`接口的认证对象。 4. 处理认证成功和失败的逻辑:在`SuccessfulAuthenticationHandler`中处理认证成功的逻辑,例如生成并返回JWT Token给前端;在`FailureAuthenticationHandler`中处理认证失败的逻辑,例如返回登录失败的提示信息给前端。 5. 配置微信登录接口和拦截器:配置微信登录的接口路径和访问权限,使用`WeChatAuthenticationFilter`拦截微信登录请求,进行认证处理。 通过以上步骤,就可以实现Spring Security微信小程序登录功能。当用户通过微信小程序登录时,将会调用相应的微信登录接口,并经过认证流程完成登录。根据需求可以进行进一步的用户信息补全、鉴权和授权等功能的实现。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

后端小肥肠

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

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

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

打赏作者

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

抵扣说明:

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

余额充值