智能BI实战(4)--- RabbitMQ消磁队列

🤵‍♂️ 个人主页:@rain雨雨编程

😄微信公众号:rain雨雨编程

✍🏻作者简介:持续分享机器学习,爬虫,数据分析
🐋 希望大家多多支持,我们一起进步!
如果文章对你有帮助的话,
欢迎评论 💬点赞👍🏻 收藏 📂加关注+

目录

一、系统现状不足分析

二、RabbitMQ 项目实战

2.1 选择客户端

2.2 基础实战

引入依赖

引入配置

创建交换机和队列

2.3 BI 项目改造

改造的流程

创建交换机和队列

改造业务流程

三、扩展点

一、系统现状不足分析

首先,让我们来探讨一下目前系统存在的不足。我们的系统已经完成从同步到异步的转变。为什么需要改变呢?因为 AI 模型分析速度较慢,生成图表可能需要十几秒或更长的时间。虽然 OpenAI 最近已经优化了其人工智能服务的效率,但当系统用户量大时,仍可能出现系统资源不足或 AI 服务生成速度跟不上的情况,这会导致系统瓶颈。因此,我们采用异步处理,以提高用户体验、及时反馈。

目前,我们的异步实现依赖于 本地 线程池。这里的“本地”值得深思:本地线程池会存在哪些问题? 比如, 假设 AI 服务限制只能有两个用户同时使用,那么单个线程池最大核心线程数为 2 即可。但如果系统需求量增大,后端从单服务器部署扩展到多服务器,每个服务器都需要一个线程池。这样,在三个服务器的情况下,可能就有六个线程同时使用 AI 服务,超过了服务的限制。这就是我们目前的问题:无法集中限制,只能进行单机限制。

另一个问题:目前的任务数据是存在哪里的? 即使任务在数据库存储,但如果执行到一半,服务器宕机,任务可能就会丢失。虽然可以从数据库中重新读取,但这需要额外的编码工作,而且在系统复杂度增加时,实现任务重试或丢失恢复的编码工作成本较高。因此,由于任务是在内存中执行,就有可能会丢失,尽管可以从数据库手动恢复并重试,但这需要额外开发。

此外,随着系统功能的增多,例如 AI 对话和 AI 绘画,如果我们还要增加更多的长时间耗时任务,那么我们可能需要开辟更多的线程池,这将使系统变得越来越复杂。在这种情况下,我们可以考虑将一些耗时的任务或功能单独抽取出来,这就是微服务的思想。将这些耗时任务单独提取出来,既不影响主业务,又可以使我们的核心系统更加安全、稳定和清晰。因此,这里我们可以优化的一个方面就是服务拆分或应用解耦。

为了解决这些问题,我们可以思考如何运用其它技术手段。首先,针对无法集中限制的问题,我们是否可以统一管理各个服务器的线程数量? 我们可以在一个集中的地方生成线程,执行任务,或者下发任务。例如,Redis就可以作为分布式存储,来存储多个服务器中的共享状态,比如用户登录信息。同样的,Redis也可以帮助我们实现任务的集中管理。其次,针对内存中的任务可能丢失的问题,我们可以考虑将任务存放在可持久化的硬盘中。硬盘中的数据不会因为系统重启而丢失。

最后,我们来看应用解耦的问题。我们可以引入一个"中间人"来解决应用间的耦合问题。什么是"中间人"呢? 假设你现在想要购买一个国外的商品,但是你自己无法去国外,这时候,你就需要找一个中间人。这个中间人可以帮你与国外的商家对接,帮你购买你想要的商品,而无需你本人亲自去国外。在我们的场景中,这个"中间人"可以帮助我们实现核心系统和智能生成业务逻辑的解耦,从而使整个系统更加稳定和高效。

系统现状不足分析总结 让我们来讨论一下单机系统的问题。

现状:我们的异步处理是通过本地线程池实现的。

但是存在以下问题:

  1. 无法集中限制,仅能单机限制: 如果AI服务的使用限制为同时只能有两个用户,我们可以通过限制单个线程池的最大核心线程数为 2 来实现这个限制。但是,当系统需要扩展到分布式,即多台服务器时,每台服务器都需要有 2 个线程,这样就可能会产生 2N 个线程,超过 AI 服务的限制。因此,我们需要一个集中的管理方式来分发任务,例如统一存储当前正在执行的任务数。

  2. 由于任务存储在内存中执行,可能会丢失: 我们可以通过从数据库中人工提取并重试,但这需要额外的开发(如定时任务)。然而,重试的场景是非常典型的,我们不需要过于关注或自行实现。一个可能的解决方案是将任务存储在可以持久化的硬盘中。

  3. 优化: 随着系统功能的增加,长耗时任务也越来越多,系统会变得越来越复杂(比如需要开启多个线程池,可能出现资源抢占的问题)。一种可能的解决方案是服务拆分,即应用解耦。我们可以将长耗时、资源消耗大的任务单独抽成一个程序,以避免影响主业务。此外,我们也可以引入一个"中间人",让它帮助我们连接两个系统,比如核心系统和智能生成业务。

二、RabbitMQ 项目实战

2.1 选择客户端

先考虑怎么在项目中使用 RabbitMQ

  1. 使用官方的客户端(推荐)

  • 优点:兼容性好,换语言成本低,比较灵活

  • 缺点:太灵活,要自己去处理一些事情。比如要自己维护管理链接,很麻烦。

  1. 使用封装好的客户端,比如 Spring Boot RabbitMQ Starter(本次使用)

  • 优点:简单易用,直接配置直接用,更方便地去管理连接

  • 缺点:封装的太好了,你没学过的话反而不知道怎么用。不够灵活,被框架限制。 根据场景来选择,没有绝对的优劣:类似jdbcMyBatis

本次使用 Spring Boot RabbitMQ Starter(因为我们是 Spring Boot 项目🐶)

建议看官方文档,不要看过期博客!👉 官方文档

2.2 基础实战

引入依赖

保选择的版本与使用的 Spring Boot 版本保持一致。为了避免出现不兼容的情况,查看项目使用的 Spring Boot 版本是什么,这里 Spring Boot 的版本是 2.7.2。

<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-amqp -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
    <version>2.7.2</version>
</dependency>
引入配置

创建交换机和队列

bizmq包下新建MyMessageProducer.java。 编写消息生产者代码,用于发送消息到 RabbitMQ

// 使用@Component注解标记该类为一个组件,让Spring框架能够扫描并将其纳入管理
@Component
public class MyMessageProducer {
 // 使用@Resource注解对rabbitTemplate进行依赖注入
    @Resource
    private RabbitTemplate rabbitTemplate;
 /**
     * 发送消息的方法
     *
     * @param exchange   交换机名称,指定消息要发送到哪个交换机
     * @param routingKey 路由键,指定消息要根据什么规则路由到相应的队列
     * @param message    消息内容,要发送的具体消息
     */
    public void sendMessage(String exchange, String routingKey, String message) {
        // 使用rabbitTemplate的convertAndSend方法将消息发送到指定的交换机和路由键
        rabbitTemplate.convertAndSend(exchange, routingKey, message);
    }

}

bizmq包下新建MyMessageConsumer.java。 编写消息消费者代码,用于接收 RabbitMQ 中的消息。

// 使用@Component注解标记该类为一个组件,让Spring框架能够扫描并将其纳入管理
@Component
// 使用@Slf4j注解生成日志记录器
@Slf4j
public class MyMessageConsumer {

    /**
     * 接收消息的方法
     *
     * @param message      接收到的消息内容,是一个字符串类型
     * @param channel      消息所在的通道,可以通过该通道与 RabbitMQ 进行交互,例如手动确认消息、拒绝消息等
     * @param deliveryTag  消息的投递标签,用于唯一标识一条消息
     */
    // 使用@SneakyThrows注解简化异常处理
    @SneakyThrows
    // 使用@RabbitListener注解指定要监听的队列名称为"code_queue",并设置消息的确认机制为手动确认
    @RabbitListener(queues = {"code_queue"}, ackMode = "MANUAL")
    // @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag是一个方法参数注解,用于从消息头中获取投递标签(deliveryTag),
    // 在RabbitMQ中,每条消息都会被分配一个唯一的投递标签,用于标识该消息在通道中的投递状态和顺序。通过使用@Header(AmqpHeaders.DELIVERY_TAG)注解,可以从消息头中提取出该投递标签,并将其赋值给long deliveryTag参数。
    public void receiveMessage(String message, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) {
        // 使用日志记录器打印接收到的消息内容
        log.info("receiveMessage message = {}", message);
        // 投递标签是一个数字标识,它在消息消费者接收到消息后用于向RabbitMQ确认消息的处理状态。通过将投递标签传递给channel.basicAck(deliveryTag, false)方法,可以告知RabbitMQ该消息已经成功处理,可以进行确认和从队列中删除。
        // 手动确认消息的接收,向RabbitMQ发送确认消息
        channel.basicAck(deliveryTag, false);
    }

}

新建一个 main 函数,用于创建测试程序用到的交换机和队列;

因为在程序首次启动,肯定不能依赖程序的启动来创建队列,需要提前把队列创建好。

bizmq包下新建MqInitMain.java

/**
 * 用于创建测试程序用到的交换机和队列(只用在程序启动前执行一次)
 */
public class MqInitMain {

    public static void main(String[] args) {
        try {
            // 创建连接工厂
            ConnectionFactory factory = new ConnectionFactory();
            factory.setHost("localhost");
            // 创建连接
            Connection connection = factory.newConnection();
            // 创建通道
            Channel channel = connection.createChannel();
            // 定义交换机的名称为"code_exchange"
            String EXCHANGE_NAME = "code_exchange";
            // 声明交换机,指定交换机类型为 direct
            channel.exchangeDeclare(EXCHANGE_NAME, "direct");

            // 创建队列,随机分配一个队列名称
            String queueName = "code_queue";
            // 声明队列,设置队列持久化、非独占、非自动删除,并传入额外的参数为 null
            channel.queueDeclare(queueName, true, false, false, null);
            // 将队列绑定到交换机,指定路由键为 "my_routingKey"
            channel.queueBind(queueName, EXCHANGE_NAME, "my_routingKey");
        } catch (Exception e) {
         // 异常处理
        }
    }
}

启动MqInitMain.java。 查看管理页面,创建了新的交换机和队列。

编写测试代码,用于测试消息生产者的发送消息功能。

// 使用 @SpringBootTest 注解标记该类为一个 Spring Boot 的测试类
@SpringBootTest
class MyMessageProducerTest {
    
 // 使用 @Resource 注解注入一个消息生产者的实例
    @Resource
    private MyMessageProducer myMessageProducer;

    // 使用 @Test 注解标记一个测试方法,命名为 sendMessage
    @Test
    /**
     * 在测试方法中,调用消息生产者的 sendMessage 方法,发送一条消息
     *
     * 参数1:交换机名称为 "code_exchange",表示将消息发送到该交换机
     * 参数2:路由键为 "my_routingKey",表示消息将通过该路由键进行路由
     * 参数3:消息内容为 "你好呀",即要发送的具体消息内容
     */
    void sendMessage() {
        // 调用消息生产者的 sendMessage 方法发送消息
        myMessageProducer.sendMessage("code_exchange", "my_routingKey", "你好呀");
    }
}

2.3 BI 项目改造

改造的流程

开始进行项目回归,之前的做法是将任务提交到线程池中,然后在线程池中编写处理程序的代码,任务在线程池中排队等待执行。然而,这种方式存在一个问题,如果程序中断了,那么任务就会丢失,无法得到处理。

现在我们需要对流程进行改进,具体步骤如下:

  1. 将任务的提交方式改为向消息队列发送消息。

  2. 编写一个专门用于接收消息并处理任务的程序。

  3. 如果程序中断,消息未被确认,消息队列将会重新发送这些消息,确保任务不会丢失。

  4. 现在,所有的消息都集中发送到消息队列中,你可以部署多个后端程序,它们都从同一个消息队列中获取任务,从而实现了分布式负载均衡的效果。

通过这样的改进,我们实现了一种更可靠的任务处理方式。任务不再依赖于线程池,而是通过消息队列来进行分发和处理,即使程序中断或出现故障,任务也能得到保证并得到正确处理。同时,我们还可以通过部署多个后端程序来实现负载均衡,提高系统的处理能力和可靠性。

实现步骤:

  1. 创建交换机和队列

  2. 将线程池中的执行代码移到消费者类中

  3. 根据消费者的需求来确认消息的格式(chartId

  4. 将提交线程池改造为发送消息到队列

验证:

验证发现,如果程序中断了,没有 ack、也没有 nack(服务中断,没有任何响应),那么这条消息会被重新放到消息队列中,从而实现了每个任务都会执行。

创建交换机和队列

复制MqInitMain.java粘贴到bizmq包下,并重命名为BiInitMain

复制MyMessageConsumer.java粘贴到bizmq包下,并重命名为BiMessageConsumer

复制MyMessageProducer.java粘贴到bizmq包下,并重命名为BiMessageProducer


💡 其实,对于这个 BI 生产者来说,并没有必要存在,因为我们的生产者代码中并没有任何与特定业务逻辑相关的代码,全部都是现成的代码。可以做一些优化,将交换机的名称写死,即固定为一个特定的值。这样做的好处是,我们不需要在每次使用时都传递交换机名称的参数,而是直接在代码中指定一个固定的交换机名称。

bizmq包下创建BiMqConstant.java(存放常量):

将常量存放在一个包的接口中(创建一个专门用于存放常量的包,并在该包中定义接口或类来存放相关常量)。

这里如果为了复用多个消费者,你可以做多个路由键。

public interface BiMqConstant {

    String BI_EXCHANGE_NAME = "bi_exchange";

    String BI_QUEUE_NAME = "bi_queue";

    String BI_ROUTING_KEY = "bi_routingKey";
}

🪔 线程池是不是更适用于需要多个线程处理任务,而 MQ 更适用于服务间通信与应用解耦?

  • 对,线程池和消息队列(MQ)在不同的场景下有不同的适用性。线程池更适合处理需要多个线程并发执行的任务,而 MQ 更适合用于分布式场景下的信息传输、应用解耦、负载均衡以及消息可靠性保证。

🪔 也就是说,分布式中使用线程池就不适合了呗,保证不了任务的先后?

  • 确实,在分布式环境中使用线程池可能无法保证任务的先后顺序。如果你需要考虑消息的顺序性,就需要设计额外的机制来实现。单独使用一个消息队列可以确保消息的顺序传递,但是如果引入了其他复杂的机制,就无法保证顺序了。例如,如果你按顺序接收消息 1、2、3、4、5,但是将它们作为任务提交给线程池执行,就无法保证它们按照顺序执行。

然而,只要你保证按顺序将任务提交给线程池,它们实际上也会按顺序执行。这意味着,如果你以顺序方式将消息 1、2、3、4、5 进入线程池作为任务,线程池会按照任务的顺序依次执行。

因此,需要根据具体情况来权衡使用线程池和消息队列,并设计适当的机制来确保任务的顺序性。如果消息的顺序对业务很重要,可以考虑使用有序消息队列或其他保证顺序性的解决方案。

改造业务流程

现在要改造异步消息队列,这块地方,以前是我们直接提交一个任务到线程池。

把中间这段代码逻辑全部剪切,这一步不提线程池。

即下面代码:

// 先修改图表任务状态为 “执行中”。等执行成功后,修改为 “已完成”、保存执行结果;执行失败后,状态修改为 “失败”,记录任务失败信息。(为了防止同一个任务被多次执行)
Chart updateChart = new Chart();
updateChart.setId(chart.getId());
// 把任务状态改为执行中
updateChart.setStatus("running");
boolean b = chartService.updateById(updateChart);
// 如果提交失败(一般情况下,更新失败可能意味着你的数据库出问题了)
if (!b) {
    handleChartUpdateError(chart.getId(), "更新图表执行中状态失败");
    return;
}

// 调用 AI
String result = aiManager.doChat(biModelId, userInput.toString());
String[] splits = result.split("【【【【【");
if (splits.length < 3) {
    handleChartUpdateError(chart.getId(), "AI 生成错误");
    return;
}
String genChart = splits[1].trim();
String genResult = splits[2].trim();
// 调用AI得到结果之后,再更新一次
Chart updateChartResult = new Chart();
updateChartResult.setId(chart.getId());
updateChartResult.setGenChart(genChart);
updateChartResult.setGenResult(genResult);
// todo 建议定义状态为枚举值
updateChartResult.setStatus("succeed");
boolean updateResult = chartService.updateById(updateChartResult);
if (!updateResult) {
    handleChartUpdateError(chart.getId(), "更新图表成功状态失败");
}

改成往消息队列里发消息。

那这个消息发送什么呢?是不是要把这个参数传递给消费者。换句话说,将消费者需要的参数作为消息的内容传递过去,将其发送到消息队列中。这样,消费者就可以从消息队列中获取消息,并恢复当时的场景,以便执行相应的任务。这种方式使得消费者能够独立处理任务,而不是在线程池中处理。通过在消息队列中发送消息并携带所需参数,消费者可以获取到这些消息并进行相应的处理。

所以在这里,首先需要查看消费者的代码,以了解消费者需要什么样的参数或数据。根据消费者需要的内容来确定生产者发送的消息内容。因此,生产者的代码应该与消费者的代码保持一致,根据消费者的要求来发送相应的消息。

把刚刚的代码逻辑粘贴进去。

看看需要什么。

  1. 首先这里有一个处理图表失败的任务,把ChartController.java中的handleChartUpdateError复制。

private void handleChartUpdateError(long chartId, String execMessage) {
        Chart updateChartResult = new Chart();
        updateChartResult.setId(chartId);
        updateChartResult.setStatus("failed");
        updateChartResult.setExecMessage("execMessage");
        boolean updateResult = chartService.updateById(updateChartResult);
        if (!updateResult) {
            log.error("更新图表失败状态失败" + chartId + "," + execMessage);
        }
    }

其实可以放到service里,这里省点事,放到消费者代码中。

  1. 然后看看哪些参数需要传递,这里有个userInput

回到ChartController.java,可以看到它是构造用户的输入。

在当前代码中,并没有将userInput保存到数据库中,因此我们需要将它传递到消息队列中;

还有一种方法,userInput中的goal、csvData是保存到数据库中,而userGoal是已经封装好的,chart也是图表的信息,因此我们可以直接从数据库中获取这些信息,这样,在消息传递时,我们只需要传递一个标识或者引用就可以了;

既然所有消费者需要的信息都已经存储在数据库中,就可以从数据库中获取这些信息,从而节约了消息的大小和体积。

我们先去插入数据库,插入成功之后,就得到消息的 id,传递消息的 id

拿字符串封装好了之后,我们发消息、取消息只要传一个 userId

复制用户的输入。

// 构造用户输入
StringBuilder userInput = new StringBuilder();
userInput.append("分析需求:").append("\n");

// 拼接分析目标
String userGoal = goal;
if (StringUtils.isNotBlank(chartType)) {
    userGoal += ",请使用" + chartType;
}
userInput.append(userGoal).append("\n");
userInput.append("原始数据:").append("\n");
// 压缩后的数据
String csvData = ExcelUtils.excelToCsv(multipartFile);
userInput.append(csvData).append("\n");

粘贴到消费者,写个一个方法重新构造。

/**
 * 构建用户输入
 * @param chart 图表对象
 * @return 用户输入字符串
 */
private String buildUserInput(Chart chart) {
 // 获取图表的目标、类型和数据
    String goal = chart.getGoal();
    String chartType = chart.getChartType();
    String csvData = chart.getChartData();

    // 构造用户输入
    StringBuilder userInput = new StringBuilder();
    userInput.append("分析需求:").append("\n");

    // 拼接分析目标
    String userGoal = goal;
    if (StringUtils.isNotBlank(chartType)) {
        userGoal += ",请使用" + chartType;
    }
    userInput.append(userGoal).append("\n");
    userInput.append("原始数据:").append("\n");
    userInput.append(csvData).append("\n");
 // 将StringBuilder转换为String并返回
    return userInput.toString();
}

这里改成buildUserInput(chart),把chart对象传过来。

继续改造BiMessageConsumer.javareceiveMessage方法。

@SneakyThrows
@RabbitListener(queues = {BiMqConstant.BI_QUEUE_NAME}, ackMode = "MANUAL")
public void receiveMessage(String message, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) {
    log.info("receiveMessage message = {}", message);
    if (StringUtils.isBlank(message)) {
        // 如果更新失败,拒绝当前消息,让消息重新进入队列
        channel.basicNack(deliveryTag, false, false);
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "消息为空");
    }
    long chartId = Long.parseLong(message);
    Chart chart = chartService.getById(chartId);
    if (chart == null) {
        // 如果图表为空,拒绝消息并抛出业务异常
        channel.basicNack(deliveryTag, false, false);
        throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "图表为空");
    }
    
    Chart updateChart = new Chart();
    updateChart.setId(chart.getId());
    updateChart.setStatus("running");
    boolean b = chartService.updateById(updateChart);
    if (!b) {
        // 如果更新图表执行中状态失败,拒绝消息并处理图表更新错误
        channel.basicNack(deliveryTag, false, false);
        handleChartUpdateError(chart.getId(), "更新图表执行中状态失败");
        return;
    }
    
    String result = aiManager.doChat(CommonConstant.BI_MODEL_ID, buildUserInput(chart));
    String[] splits = result.split("【【【【【");
    if (splits.length < 3) {
        channel.basicNack(deliveryTag, false, false);
        handleChartUpdateError(chart.getId(), "AI 生成错误");
        return;
    }
    String genChart = splits[1].trim();
    String genResult = splits[2].trim();
    Chart updateChartResult = new Chart();
    updateChartResult.setId(chart.getId());
    updateChartResult.setGenChart(genChart);
    updateChartResult.setGenResult(genResult);
    // todo 建议定义状态为枚举值
    updateChartResult.setStatus("succeed");
    boolean updateResult = chartService.updateById(updateChartResult);
    if (!updateResult) {
        // 如果更新图表成功状态失败,拒绝消息并处理图表更新错误
        channel.basicNack(deliveryTag, false, false);
        handleChartUpdateError(chart.getId(), "更新图表成功状态失败");
    }
    // 消息确认
    channel.basicAck(deliveryTag, false);
}

🪔 集群情况是不是得保证消息不重复?

是的,集群环境下确实需要考虑消息的去重。 RabbitMQ 本身提供了消息确认机制,可以确保消息只被消费一次。当同一条消息被确认后,它就会被标记为已确认,这样就不会再被消费者接收到。如果你的集群是备份集群,也就是多个机器都可能接收和存储同一条消息,你需要确保消费者不会重复读取多个机器的消息。一般情况下,RabbitMQ 已经为你提供了相应的机制来解决这个问题。可以参考网上的教程来深入了解。

三、扩展点

项目中还有很多的扩展点可以考虑。比如,当消息被拒绝后,我们可以将其放入死信队列中进行处理,被拒绝的消息放入死信队列后,我们可以对其进行进一步处理。

对于被拒绝的消息或生成失败的图表,我们可以在数据库中将其状态更改为失败。这样,前端展示的图表状态就不再是一直处于响应中或生成中的状态。这些图表的生成中或待生成状态都是由于之前的程序中断或特殊的失败导致的。因此,我们必须有一个容错机制,通过死信队列就可以实现这一点。

另外,我们还要考虑程序是否会无限执行的问题,并且还有许多优化点可以进一步完善。例如,支持通过分组进行图表搜索,为图表打标签以增强系统的功能。此外,我们还可以利用缓存来提升已生成图表的加载速度,从而提升系统的性能和用户体验。

这个项目非常适合使用消息队列来实现。你也可以考虑将其改造为分布式架构,并使用多个消费者和多台服务器来监听消息队列。

文章持续跟新,可以微信搜一搜公众号  rain雨雨编程 ],第一时间阅读,涉及数据分析,机器学习,Java编程,爬虫,实战项目等。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值