SpringBoot+Dubbo+环信(即时通信)整合

SpringBoot+Dubbo+环信(即时通信)整合
1.注册环信账号
官网:https://www.easemob.com/ 稳定健壮,消息必达,亿级并发的即时通讯云
2. 了解平台架构
文档地址:http://docs-im.easemob.com/
平台架构:
在这里插入图片描述
3. 创建应用
这里选择授权注册,注册需要进行校验,防止随意添加用户在这里插入图片描述
创建成功
创建应用成功后,注意下面标红的几个参数,需要在配置文件中配置一下的参数
在这里插入图片描述
在这里插入图片描述
4. 后端集成用户体系
文档:http://docs-im.easemob.com/im/server/ready/user
集成环信需要参考上面这个官方开发文档
5. 功能整体流程图
在这里插入图片描述
说明:

  • 在APP端与后端系统,都需要完成与环信的集成。
  • 在APP端,使用Android的SDK与环信进行通信,通信时需要通过后台系统的接口查询当前用户的环信用户名和密码,进行登录环信。
  • 后台系统,在用户注册后,同步注册环信用户到环信平台,在后台系统中保存环信的用户名和密码。
  • APP拿到用户名和密码后,进行登录环信,登录成功后即可向环信发送消息给好友。
  • 后台系统也可以通过管理员的身份给用户发送系统信息。
    6. 创建dubbo工程
    dubbo工程创建采用开闭原则,开放接口,封装服务,只对外暴露接口,通过接口对外提供服务
    先创建一个父工程my-tanhua-dubbo(maven工程),然后在父工程下面创建两个子模块:my-tanhua-dubbo-interface(maven工程) my-tanhua-dubbo-huanxin(springboot工程),接口模块只提供对外服务的接口,服务模块是具体的服务提供方,共工程结构如下图所示:
    在这里插入图片描述
    在这里插入图片描述
    7. 编写dubbo服务

- 导入坐标
注意:这里导入的坐标是springboot的初始坐标和一些用到的坐标
**[**插入一个小知识点:这里my-tanhua-dubbo-huanxin模块引入了my-tanhua-dubbo-interface模块,如果interface使用了mongodb,而huanxin模块间接引入了mongodb服务,huanxin没有mongodb的配置,启动的时候会报错,如下图所示:
在这里插入图片描述
解决办法:
1.在pom中引入interface使用exclusions标签排除monggodb服务
2.在huanxin模块的引导类上排除mongodb

@SpringBootApplication(exclude = {MongoAutoConfiguration.class, MongoDataAutoConfiguration.class})

]

<dependencies>
        <!--引入interface依赖-->
        <dependency>
            <groupId>cn.itcast.tanhua</groupId>
            <artifactId>my-tanhua-dubbo-interface</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <!--dubbo的springboot支持-->
        <dependency>
            <groupId>com.alibaba.boot</groupId>
            <artifactId>dubbo-spring-boot-starter</artifactId>
        </dependency>
        <!--dubbo框架-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>dubbo</artifactId>
        </dependency>
        <!--zk依赖-->
        <dependency>
            <groupId>org.apache.zookeeper</groupId>
            <artifactId>zookeeper</artifactId>
        </dependency>
        <dependency>
            <groupId>com.github.sgroschupf</groupId>
            <artifactId>zkclient</artifactId>
        </dependency>
        <dependency>
            <groupId>io.netty</groupId>
            <artifactId>netty-all</artifactId>
        </dependency>
        <!--实用工具hutool-->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
        </dependency>
<!-- spring中的实现重试的一个框架-->
        <dependency>
            <groupId>org.springframework.retry</groupId>
            <artifactId>spring-retry</artifactId>
        </dependency>
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
        </dependency>
    </dependencies>

- 编写配置文件
application.properties:

# Spring boot application
spring.application.name = itcast-tanhua-dubbo-huanxin

# dubbo 扫描包配置
dubbo.scan.basePackages = com.tanhua.dubbo.server
dubbo.application.name = dubbo-provider-huanxin

#dubbo 对外暴露的端口信息
dubbo.protocol.name = dubbo
dubbo.protocol.port = 20881

#dubbo注册中心的配置
dubbo.registry.address = zookeeper://192.168.200.129:2181
dubbo.registry.client = zkclient
dubbo.registry.timeout = 60000 

# Redis相关配置
spring.redis.jedis.pool.max-wait = 5000ms
spring.redis.jedis.pool.max-Idle = 100
spring.redis.jedis.pool.min-Idle = 10
spring.redis.timeout = 10s
#下面两行注释采用的是redis集群的方式配置
#spring.redis.cluster.nodes = 192.168.200.129:6379,192.168.200.129:6380,192.168.200.129:6381
#spring.redis.cluster.max-redirects=5
#这里我们使用的是单节点
spring.redis.port=6379
spring.redis.host=192.168.200.129

#数据库连接信息
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/tanhua?useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true&useSSL=false

spring.datasource.username=root
spring.datasource.password=root

# 表名前缀
mybatis-plus.global-config.db-config.table-prefix=tb_
# id策略为自增长
mybatis-plus.global-config.db-config.id-type=auto

huanxin.properties:切记改成自己创建的应用的信息

#环信参数要使用自己创建的应用的信息
tanhua.huanxin.url=http://a1.easemob.com/
tanhua.huanxin.orgName=1105190515097562
tanhua.huanxin.appName=tanhua
tanhua.huanxin.clientId=YXA67ZofwHblEems-_Fh-17T2g
tanhua.huanxin.clientSecret=YXA60r45rNy2Ux5wQ7YYoEPwynHmUZk

- 编写配置类:加载环信的配置文件

package com.tanhua.dubbo.server.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
//配置类
@Configuration
//加载配置文件
@PropertySource("classpath:huanxin.properties")
@ConfigurationProperties(prefix = "tanhua.huanxin")
@Data
public class HuanXinConfig {

    private String url;
    private String orgName;
    private String appName;
    private String clientId;
    private String clientSecret;

}

- 编写配置类:

package com.tanhua.dubbo.server.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
//这是一个配置类
@Configuration
//加载配置文件
@PropertySource("classpath:huanxin.properties")
//配置文件配置的前缀
@ConfigurationProperties(prefix = "tanhua.huanxin")
//lomback生成set和get等方法
@Data
public class HuanXinConfig {

    private String url;
    private String orgName;
    private String appName;
    private String clientId;
    private String clientSecret;

}
  • 管理员获取token
    官方文档
    环信提供的 REST API 需要权限才能访问,权限通过发送 HTTP 请求时携带 token 来体现,具体的获取token的业务逻辑在TokenService中完成。实现要点:
    在这里插入图片描述

     - 分析官方文档中的请求url、参数、响应数据等内容
     - 请求到token需要缓存到redis中,不能频繁的获取token操作,可能会被封号
    
package com.tanhua.dubbo.server.service;

import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.tanhua.dubbo.server.config.HuanXinConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
//管理员(就是创建应用生成的那个id和密码的那个)获取token
@Service
@Slf4j
public class TokenService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    private static final String REDIS_KEY = "HX_TOKEN";

    @Autowired
    private HuanXinConfig huanXinConfig;

    /**
     * 获取token,先从redis中获取,如果没有,再去环信接口获取
     *
     * @return
     */
    public String getToken() {
        String token = this.redisTemplate.opsForValue().get(REDIS_KEY);
        if (StrUtil.isNotEmpty(token)) {
            return token;
        }

        //访问环信接口获取token
        return this.refreshToken();
    }

    /**
     * 刷新token,请求环信接口,将token存储到redis中
     *
     * @return
     */
    public String refreshToken() {
        String targetUrl = this.huanXinConfig.getUrl() +
                this.huanXinConfig.getOrgName() + "/" +
                this.huanXinConfig.getAppName() + "/token";

        Map<String, Object> param = new HashMap<>();
        param.put("grant_type", "client_credentials");
        param.put("client_id", this.huanXinConfig.getClientId());
        param.put("client_secret", this.huanXinConfig.getClientSecret());

        HttpResponse response = HttpRequest.post(targetUrl)
                .body(JSONUtil.toJsonStr(param))
                .timeout(20000) //请求超时时间
                .execute();

        if (!response.isOk()) {
            log.error("刷新token失败~~~ ");
            return null;
        }
        //拿到环信响应的响应体,获取到里面的token和过期时间expires_in.有效期为7天,但不能完全保证
        String jsonBody = response.body();
        JSONObject jsonObject = JSONUtil.parseObj(jsonBody);
        String token = jsonObject.getStr("access_token");
        if (StrUtil.isNotEmpty(token)) {
            //将token数据缓存到redis中,缓存时间由expires_in决定
            //提前1小时失效
            long timeout = jsonObject.getLong("expires_in") - 3600;
            this.redisTemplate.opsForValue().set(REDIS_KEY, token, timeout, TimeUnit.SECONDS);

            return token;
        }

        return null;
    }
}

注意:到现在为止真正的dubbo从下面开始编写,以上只是获取token并存储到redis中的逻辑实现

- 在interface中定义pojo对象

package com.tanhua.dubbo.server.pojo;

import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Date;

/**
 * 环信用户对象
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("tb_huanxin_user")
public class HuanXinUser implements java.io.Serializable{

    private static final long serialVersionUID = -6400630011196593976L;

    private Long id; //主键Id

    /**
     * 环信 ID ;也就是 IM 用户名的唯一登录账号,长度不可超过64个字符长度
     */
    private String username;
    /**
     * 登录密码,长度不可超过64个字符长度
     */
    private String password;
    /**
     * 昵称(可选),在 iOS Apns 推送时会使用的昵称(仅在推送通知栏内显示的昵称),
     * 并不是用户个人信息的昵称,环信是不保存用户昵称,头像等个人信息的,
     * 需要自己服务器保存并与给自己用户注册的IM用户名绑定,长度不可超过100个字符
     */
    private String nickname;

    private Long userId; //用户id

    private Date created; //创建时间

    private Date updated; //更新时间

}

- 编写HuanXinUserMapper
注册成功后,在环信上的用户名和密码需要和其他信息需要存储到mysql数据库中

package com.tanhua.dubbo.server.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.tanhua.dubbo.server.pojo.HuanXinUser;
import org.apache.ibatis.annotations.Mapper;
//这个用于保存环信注册成功用户的用户名和密码
@Mapper
public interface HuanXinUserMapper extends BaseMapper<HuanXinUser> {
}

- 定义interface中的接口

package com.tanhua.dubbo.server.api;

import com.tanhua.dubbo.server.pojo.HuanXinUser;

/**
 * 与环信平台集成的相关操作
 */
public interface HuanXinApi {

    /**
     * 获取环信token(获取管理员权限)
     * 参见:http://docs-im.easemob.com/im/server/ready/user#%E8%8E%B7%E5%8F%96%E7%AE%A1%E7%90%86%E5%91%98%E6%9D%83%E9%99%90
     *
     * @return
     */
    String getToken();

    /**
     * 注册环信用户
     * 参见:http://docs-im.easemob.com/im/server/ready/user#%E6%B3%A8%E5%86%8C%E5%8D%95%E4%B8%AA%E7%94%A8%E6%88%B7_%E5%BC%80%E6%94%BE
     *
     * @param userId 用户id
     * @return
     */
    Boolean register(Long userId);

    /**
     * 根据用户Id询环信账户信息
     *
     * @param userId
     * @return
     */
    HuanXinUser queryHuanXinUser(Long userId);
    /**
     * 根据环信用户名查询用户信息
     *
     * @param userName
     * @return
     */
    HuanXinUser queryUserByUserName(String userName);

 
}

- 实现接口
在my-tanhua-dubbo-huanxin中完成。

package com.tanhua.dubbo.server.api;

import cn.hutool.core.util.IdUtil;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.Method;
import cn.hutool.json.JSONUtil;
import com.alibaba.dubbo.config.annotation.Service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.tanhua.dubbo.server.config.HuanXinConfig;
import com.tanhua.dubbo.server.mapper.HuanXinUserMapper;
import com.tanhua.dubbo.server.pojo.HuanXinUser;
import com.tanhua.dubbo.server.service.RequestService;
import com.tanhua.dubbo.server.service.TokenService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.Arrays;
import java.util.Date;

@Service(version = "1.0.0")
@Slf4j
public class HuanXinApiImpl implements HuanXinApi {

    @Autowired
    private TokenService tokenService;
    @Autowired
    private HuanXinConfig huanXinConfig;
    @Autowired
    private RequestService requestService;
    @Autowired
    private HuanXinUserMapper huanXinUserMapper;

    /**
     * 管理员获取token
     * @return
     */
    @Override
    public String getToken() {
        return this.tokenService.getToken();
    }

    /**
     * 普通用户注册环信账号
     * @param userId 用户id
     * @return
     */
    @Override
    public Boolean register(Long userId) {
        String targetUrl = this.huanXinConfig.getUrl()
                + this.huanXinConfig.getOrgName() + "/" +
                this.huanXinConfig.getAppName() + "/users";

        HuanXinUser huanXinUser = new HuanXinUser();
        huanXinUser.setUsername("HX_" + userId);  // 用户名
        huanXinUser.setPassword(IdUtil.simpleUUID()); //随机生成的密码,这里的密码是服务器端自动生成的,不是环信给你生成的
        //调用通用的方法向环信的rest接口发送请求,批量添加数据
        HttpResponse response = this.requestService.execute(targetUrl, JSONUtil.toJsonStr(Arrays.asList(huanXinUser)), Method.POST);
        if (response.isOk()) {
            //将环信的账号信息保存到数据库
            huanXinUser.setUserId(userId);
            huanXinUser.setCreated(new Date());
            huanXinUser.setUpdated(huanXinUser.getCreated());

            this.huanXinUserMapper.insert(huanXinUser);

            return true;
        }

        return false;
    }

    /**
     * 根据用户的id查询环信信息
     * @param userId
     * @return
     */
    @Override
    public HuanXinUser queryHuanXinUser(Long userId) {
        QueryWrapper<HuanXinUser> wrapper = new QueryWrapper<>();
        wrapper.eq("user_id", userId);
        return this.huanXinUserMapper.selectOne(wrapper);
    }

    /**
     * 根据环信用户名查询用户名,注意:这里生成的环信用户名是HX_103,对应唯一的用户id
     * @param userName
     * @return
     */
    @Override
    public HuanXinUser queryUserByUserName(String userName) {
        QueryWrapper<HuanXinUser> wrapper = new QueryWrapper<>();
        wrapper.eq("username", userName);
        return this.huanXinUserMapper.selectOne(wrapper);
    }
}

- 编写统一的请求环信rest接口的请求类
这里使用了hutool工具来发送http请求的一个方法

1.先自定义一个异常

package com.tanhua.dubbo.server.exception;

import cn.hutool.http.Method;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
//自定义异常注解
@AllArgsConstructor
@NoArgsConstructor
@Data
public class UnauthorizedException extends RuntimeException {

    private String url;
    private String body;
    private Method method;

}

2.导入重试框架(Spring-Retry)的坐标
Spring提供了重试的功能,使用非常的简单、优雅。

<!--Spring重试模块-->
<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>

3.编写统一的请求逻辑
请求逻辑中使用了重试框架
@Retryable用在重试方法上
@Recover用在重试全部失败后执行的方法上

package com.tanhua.dubbo.server.service;

import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.Method;
import com.tanhua.dubbo.server.exception.UnauthorizedException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;

/**
 * 环信接口通用请求服务
 */
@Service
@Slf4j
public class RequestService {

    @Autowired
    private TokenService tokenService;

    /**
     * 通用的发送请求方法(这里使用了一个spring里面的retry重试框架)
     * 1.向环信rest接口发送的http请求,这里使用的是hutool的一个工具
     * 2.这里我们在环信中创建的应用使用的是授权模式,每一次请求环信的rest接口都需要校验令牌(放在请求头中携带),切记Bearer ${token}中间有一个空格,
     * 3.@Retryable 这个是重试注解,放在需要重试的方法上面
     * @param url    请求地址
     * @param body   请求参数
     * @param method 请求方法
     * @return
     */
    //注解参数说明:UnauthorizedException:自定义的异常,触发重试的条件
    //maxAttempts:最多重试次数
    //backoff:重试的时间间隔策略,delay表示第一次重试后间隔两秒,multiplier表示以后每次间隔时间进行倍增
    @Retryable(value = UnauthorizedException.class, maxAttempts = 5, backoff = @Backoff(delay = 2000L, multiplier = 2))
    public HttpResponse execute(String url, String body, Method method) {
        String token = this.tokenService.getToken();

        HttpRequest httpRequest;

        switch (method) {
            case POST: {
                httpRequest = HttpRequest.post(url);
                break;
            }
            case DELETE: {
                httpRequest = HttpRequest.delete(url);
                break;
            }
            case PUT: {
                httpRequest = HttpRequest.put(url);
                break;
            }
            case GET: {
                httpRequest = HttpRequest.get(url);
                break;
            }
            default: {
                return null;
            }
        }
        //使用hutool的后台发送http请求
        HttpResponse response = httpRequest
                .header("Content-Type", "application/json") //设置请求内容类型
                .header("Authorization", "Bearer " + token)  //设置token,注意bearer后面有一个空格(不可缺),Bearer ${token}
                .body(body) // 设置请求数据
                .timeout(20000) // 超时时间
                .execute(); // 执行请求
        //返回值401,未授权[无token、token错误、token过期]
        if (response.getStatus() == 401) {
            //token失效,重新刷新token
            this.tokenService.refreshToken();

            //抛出异常,需要进行重试,重试到最大次数后就会执行下面的recover方法逻辑
            throw new UnauthorizedException(url, body, method);
        }

        return response;
    }

    /**
     * 全部重试失败后执行
     * @param e 参数异常类型必须和触发重试的异常类型一致,因为这个方法要查看异常中的内容
     * @return  方法返回值类型也必须和重试方法的返回值类型一致
     */
    @Recover
    public HttpResponse recover(UnauthorizedException e) {
        log.error("获取token失败!url = " + e.getUrl() + ", body = " + e.getBody() + ", method = " + e.getMethod().toString());
        //如果重试5次后,依然不能获取到token,说明网络或账号出现了问题,只能返回null了,后续的请求将无法再执行
        return null;
    }
}

@Retryable参数说明:

  • value:抛出指定异常才会重试
  • maxAttempts:最大重试次数,默认3次
  • backoff:重试等待策略,默认使用@Backoff
    • @Backoff 的value默认为1000L,我们设置为2000L;
    • multiplier(指定延迟倍数)默认为0,表示固定暂停1秒后进行重试,如果把multiplier设置为2,则第一次重试为2秒,第二次为4秒,第三次为6秒。

@Recover标注的方法,是在所有的重试都失败的情况下,最后执行该方法,该方法有2个要求:

  • 方法的第一个参数必须是 Throwable 类型,最好与 @Retryable 中的 value一致。
  • 方法的返回值必须与@Retryable的方法返回值一致,否则该方法不能被执行。
  • huanxin模块的引导类如下
package com.tanhua.dubbo.server;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration;
import org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration;
import org.springframework.retry.annotation.EnableRetry;

@SpringBootApplication(exclude = {MongoAutoConfiguration.class, MongoDataAutoConfiguration.class}) //排除mongo的自动配置
//开启重试功能
@EnableRetry
public class HuanXinDubboApplication {

    public static void main(String[] args) {
        SpringApplication.run(HuanXinDubboApplication.class, args);
    }
}

- 测试

package com.tanhua.dubbo.server.api;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

@SpringBootTest
@RunWith(SpringRunner.class)
public class TestHuanXinApi {

    @Autowired
    private HuanXinApi huanXinApi;

    @Test
    public void testGetToken(){
        String token = this.huanXinApi.getToken();
        System.out.println(token);
    }
    @Test
    public void testRegister(){
        //注册用户id为1的用户到环信
        System.out.println(this.huanXinApi.register(1L));
    }

    @Test
    public void testQueryHuanXinUser(){
        //根据用户id查询环信用户信息
        System.out.println(this.huanXinApi.queryHuanXinUser(1L));
    }
}
  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值