【SpringBoot+自定义注解+Aop+长轮询】实现简单的实时消息推送

如何在Spring Boot中实现实时通知 通过本文,我们详细介绍了在Spring Boot应用中实现实时通知的两种主要方式:WebSocket和Server-Sent Events (SSE)。我们通过具体的代码示例演示了如何在Spring Boot中配置和使用这些技术,以及如何在前端页面接收和展示实时通知。今天我们将讨论如何在Spring Boot应用中实现实时通知功能,这在现代Web应用程序和服务中是非常重要的一部分。与传统的轮询或定时拉取相比,实时通知能够实现更低的延迟和更高的实时性,为用户提供更好的交互体验。 阅读详情


前言

小白摸索,大佬勿喷

我们平常在网站上都可以看到右上角的消息上未读消息数量,以及有消息时右下角实时弹出的例子

我公司项目刚好也有这个需求,但是由于甲方要求不能使用第三方服务,于是便有了本文的解决思路


一、应用场景

该思路适用于一些无法使用第三方服务的场景,如果可以使用第三方服务仍旧是使用第三方的服务比较好

二、使用步骤

1.引入依赖

这里我用到的依赖有:aspectj、mybatis-plus、jjwt、hutool等,大家可以自行选择

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.3.2</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-generator</artifactId>
            <version>3.3.2</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-freemarker</artifactId>
            <version>2.7.3</version>
        </dependency>
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjrt</artifactId>
            <version>1.9.6</version>
        </dependency>
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
            <version>1.9.6</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>

        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.7.20</version>
        </dependency>

2.数据库建立

消息表:

CREATE TABLE `t_message` (
  `id` int NOT NULL AUTO_INCREMENT,
  `content` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '内容',
  `from_uid` int DEFAULT NULL COMMENT '发起人id',
  `to_uid` int DEFAULT NULL COMMENT '接收人id',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

部门表:

CREATE TABLE `t_dept` (
  `id` int NOT NULL AUTO_INCREMENT,
  `dept_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '部门名称',
  `dept_code` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '部门编码',
  `leader_uid` int DEFAULT NULL COMMENT '部门领导id',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

用户表:

CREATE TABLE `t_user` (
  `id` int NOT NULL AUTO_INCREMENT,
  `username` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '用户名',
  `password` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '密码',
  `real_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '真实姓名',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

3.编写业务代码

为了方便演示我这里使用的是MybatisPlus生成后自带的方法去添加,@RealTimeMessage为自定义注解,下面会提到

/**
     * 添加部门
     *
     * @param dept 部门实体类
     * @return String
     */
    @RealTimeMessage("添加部门")
    @PostMapping("/add")
    public String addDept(@RequestBody Dept dept) {
        boolean save = deptService.save(dept);
        if (save) {
            return "SUCCESS";
        } else {
            return "FAILED";
        }
    }

4.Token相关

JwtUtils:

@Component
public class JwtUtils {

    private final String KEY = "abcdefghijklmnopqrstuvwxyz";

    /**
     * 创建token
     *
     * @param user 用户实体
     * @return String
     */
    public String createToken(User user) {
        Map<String, Object> claims = BeanUtil.beanToMap(user);
        JwtBuilder jwtBuilder = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date())
                .signWith(SignatureAlgorithm.HS512, KEY);
        return jwtBuilder.compact();
    }

    /**
     * 解析token
     *
     * @param token token
     * @return User
     */
    public User parseToken(String token) {
        Claims claims;
        try {
            claims = Jwts.parser()
                    .setSigningKey(KEY)
                    .parseClaimsJws(token)
                    .getBody();
        } catch (JwtException e) {
            claims = null;
            e.printStackTrace();
        }
        return BeanUtil.toBean(claims, User.class);
    }

}

TokenService及实现类:

// TokenService
public interface TokenService {
	/**
	* 获取当前登录用户的信息
	* @return User 
	*/
    User getUserByToken();
}


// TokenService的实现类
@Service
public class TokenServiceImpl implements TokenService {

    @Resource
    private JwtUtils jwtUtils;

    @Override
    public User getUserByToken() {
        HttpServletRequest request = getRequest();
        String token = request.getHeader("token");
        return jwtUtils.parseToken(token);
    }

    public HttpServletRequest getRequest(){
        return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
    }

5.编写队列相关方法

@Component
public class MessageQueue {

    /**
     * 存放消息的map集合
     */
    private static final Map<User, Queue<Message>> MSG_MAP = new ConcurrentHashMap<>();
    private static final Map<User, Integer> RETURN_TIME = new ConcurrentHashMap<>();

    /**
     * 注册当前请求用户到map中
     *
     * @param user 当前用户
     */
    public void register(User user) {
        MSG_MAP.put(user, new ConcurrentLinkedDeque<>());
        RETURN_TIME.put(user, 0);
    }

    /**
     * 销毁已注册的用户
     *
     * @param user 当前用户
     */
    public void remove(User user) {
        MSG_MAP.remove(user);
        RETURN_TIME.remove(user);
    }

    /**
     * 将消息存入对应的接收方队列中
     *
     * @param message 要接收的消息
     */
    public void push(Message message) {
        MSG_MAP.keySet().forEach(a -> {
            synchronized (a) {
                if (a.getId().equals(message.getToUid())) {
                    MSG_MAP.get(a).add(message);
                    a.notifyAll();
                }
            }
        });
    }

    /**
     * 推送消息
     *
     * @param user 当前用户实体
     * @return List<Message>
     */
    public List<Message> pollAll(User user) {
        Map<User,Queue<Message>> map1 = MSG_MAP;
        Map<User,Integer> map2 = RETURN_TIME;
        List<Message> messageList = new ArrayList<>();
        Queue<Message> messageQueue = MSG_MAP.get(user);
        //如果队列不为空,则将消息添加到对应的队列中  否则将该用户注册到map中
        if (messageQueue != null) {
            messageQueue.forEach(a -> {
                Message poll = messageQueue.poll();
                if (poll != null) {
                    messageList.add(poll);
                }
            });
        } else {
            register(user);
        }

        //如果消息为空则将服务阻塞 不响应前端请求 直到map中有新消息则返回消息 
        //此时需要与前端配合设置阻塞时间 可根据实际需求更改
        if (messageList.isEmpty() && RETURN_TIME.get(user) < 3) {
            try {
                Integer time = RETURN_TIME.get(user);
                RETURN_TIME.put(user, time + 1);
                synchronized (user) {
                    user.wait(10000);
                }
                return pollAll(user);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        return messageList;

    }

}

6.自定义注解+AOP

自定义注解,value用来存放操作名称

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RealTimeMessage {

    String value();
}

编写切面去拦截添加了自定义注解的方法

@Aspect
@Component
public class RealTimeMessageAop {

    @Resource
    private MessageService messageService;

    @Resource
    private UserService userService;

    @Resource
    private DeptService deptService;

    @Resource
    private TokenService tokenService;

	//
    @Resource
    private MessageQueue messageQueue;

    @Pointcut("@annotation(com.example.validator.anno.RealTimeMessage)")
    public void realTimeMessage() {
    }

    @Around("realTimeMessage()")
    public Object check(ProceedingJoinPoint point) throws Throwable {
        Object obj;
        //获取当前请求用户对象
        User user = tokenService.getUserByToken();
        //获取自定义注解
        Method method=((MethodSignature)point.getSignature()).getMethod();
        Signature signature = point.getSignature();
        Method realMethod = point.getTarget().getClass().getDeclaredMethod(signature.getName(), method.getParameterTypes());
        RealTimeMessage anno = realMethod.getAnnotation(RealTimeMessage.class);
        //获取前端传参,并转为map集合
        Object[] args = point.getArgs();
        Dept dept = BeanUtil.toBean(args[0], Dept.class);
        //继续执行原有方法
        obj = point.proceed();
        //获取返回消息
        String result = String.valueOf(obj);
        //如果请求成功则弹出消息 否则不做操作
        if ("SUCCESS".equals(result)) {
            //添加消息到数据库中
            Message build = Message.builder()
                    .content(anno.value())
                    .fromUid(user.getId())
                    .toUid(dept.getLeaderUid())
                    .build();
            messageService.save(build);
            //发送实时消息
            messageQueue.push(build);
        }
        return obj;
    }

}

此处我想实现的效果为当管理员添加部门,并指定该部门的领导时,将通知实时发送给管理员设置的领导

7.长轮询

该接口登陆后由前端调用,到达约定时间或返回结果后,前端再次调用该接口实现长轮询

@RestController
@RequestMapping("/message")
public class MessageController {

    @Resource
    private MessageQueue messageQueue;

    @Resource
    private TokenService tokenService;

	/**
	* 拉取实时消息
	* @return List<Message> 
	*/
    @GetMapping("/realTimeMsg")
    public List<Message> getRealTimeMsg() {
        //获取当前请求用户信息
        User user = tokenService.getUserByToken();
        try {
            //注册该用户
            messageQueue.register(user);
            //推送消息
            return messageQueue.pollAll(user);
        } catch (Exception e) {
            throw new IllegalStateException();
        } finally {
            //销毁注册
            messageQueue.remove(user);
        }
    }

功能测试

自此我们的代码已经全部编写完成,接下来就启动项目看一下效果

然后打开我们的接口调试工具把接口与数据填进去
添加部门接口调试

拉取实时消息接口调试

我们还需要Token的参与,为了方便演示我们就直接在生成所需要的Token

Token生成单元测试
接下来我们把管理员Token填入添加单位的接口的Header里
添加管理员Token到添加部门接口

然后我们把任命领导的Token填入拉取消息的Header里

添加领导Token到拉取消息接口

我们首先调用拉取实时消息的接口,接口在没有消息的时候会一直阻塞,直到到达约定时间,如果到了约定时间仍无新消息则直接返回空列表,效果如下:
请求阻塞调用拉取实时消息接口

接着我们重新调用拉取实时消息的接口,并发送添加部门的请求,这时我们可以看到消息已经推送过来了,效果如下:
调用添加部门接口
拉取实时消息接口

自此我们的全部功能就已经完成啦


总结

本文提供了一种简单实时消息推送的一种思路,在使用时可根据实际需求进行修改,该思路主要工作在后端实现,前端只需要轮番调用拉取消息的接口即可


推荐

关注博客和公众号获取最新文章

Bummon’s Blog | Bummon’s Home | 公众号

Springboot整合Websocket(推送消息通知) 阅读详情

相关推荐

SpringBoot集成WebSocket实现消息实时推送(提供Gitee源码)

在最近的工作当中,客户反应需要实时接收消息提醒,这个功能虽然不大,但不过也用到了一些新的技术,于是我这边写一个关于我如何实现这个功能、编写、测试到部署服务器,归纳到这篇博客中进行总结。

黄团团的个人博客 5660

【springboot】闲话 springboot 的几种异步机制 及 长轮询的概念和简单实现

聊聊 springboot的几种异步方式和长轮询

大胡子老哥的博客 2659

浙政钉(专有钉钉)门户免登(超详细)

钉钉家族 介绍浙政钉门户免登之前,首先搞清楚几个软件的关系,软件分别是:钉钉,专有钉钉,浙政钉 钉钉 是阿里巴巴集团专为中小企业打造的沟通和协同的多端平台。 专有钉钉 原名 政务钉钉,有更开放的设计能力、集成能力、更结构化协同产品、更定制化开发平台。支持专有化部署,数据落本地,更安全、客户可对该产品持牌运营、结构化编排,协助构建本地应用中心、提供移动应用全面安全保障体系。 浙政钉 为规范浙政钉整体架构体系,按照统分结合原则,由省政府办公厅统一设计整体工作界面和系统框架,统筹指导全省统建应用建设,各单

一缕南风的博客 2万+

SpringBoot DeferredResult 长轮询实现实现方式?

大概了解到这个程度差不多了,如果想要深入了解还是需要掌握挺多内容的。用简单的话来总结下 Spring DeferredResult :如果返回值类型是 DeferredResult 则表明其是异步请求,tomcat 线程不会等到应用程序处理完或者超时,...

LoveSummer 3万+

WRF替换静态地理数据中的土地利用数据(WRF替换下垫面数据)

   在进行自己的WRF项目模拟的时候,因为需要用中国土地利用数据替换WRF内嵌老旧的土地利用数据,故需要将土地利用进行替换。我使用的中国的土地利用数据是来自中国土地利用数据(1980-2015)。其中我以下载的lucc1980为例进行演示。 1、坐标转换    (1)下载的lucc1980数据如下图所示:    (2)在ArcMap中打开如下图所示: 打开属性表可以看到,lucc1980的坐标系为投影坐标系Krasovsky_1940_Albers,数据格式为GRID。这里需要将投影坐标系转换成地理坐

GIS 1万+

保姆级避坑指南:Mid-360雷达到手后,用livox_ros_driver2从接线到出点云的完整流程

本文提供Mid-360雷达到手后的完整使用指南,涵盖从硬件接线、网络配置到livox_ros_driver2驱动的安装与调试,帮助用户避开常见陷阱,实现点云数据的稳定输出与可视化。重点解析了配置文件的参数设置和点云格式选择,确保用户能够高效部署和使用Mid-360雷达。

weixin_30733003的博客 486

重生——第十一次面试之挖财一面2026.8.19已OC

查询:从最高层索引向右遍历,数值大于目标就向下一层,时间复杂度 O (logn)。没有 ICP:存储引擎拿到索引记录,回表读取完整行数据,在 MySQL Server 层过滤 where 条件。开启 ICP:存储引擎层,直接利用索引内的字段过滤条件,不满足条件直接丢弃,线程不安全,允许 null,元素有序。适用场景:联合索引,where 中部分条件属于索引列,但不能用于定位,只能用来过滤。Kafka 长轮询优化:没有消息的时候,Broker hold 住请求,有消息到达或者超时才返回,兼顾实时性与性能。

2502_92141759的博客 274

TI IWR 6843 ISK 毫米波雷达开箱测试

TI IWR6843 ISK毫米波雷达上手体验,教程十分详细,对于新手很友好。

qq_57061492的博客 4034

【源码解析】SpringBoot使用DeferredResult实现长轮询的原理分析

【源码解析】SpringBoot使用DeferredResult实现长轮询的原理分析

秋装什么的博客 1699

RK3568平台(电源篇)电源域IO配置

在kernel之前需要修改电源域,主控电源域的IO电平要与对接外设芯片的IO电平匹配,还要注意软件的电压配置要跟硬件的电压一致,否则,最坏的情况可能会导致IO的损坏。其实就是管理 IO 输出的电平;假如硬件上 IO 电源域配置为 3.3V,则 IO 输出最大电平为 3.3V;同理 IO 电源域硬件配置为 1.8V,则 IO 输出最大电平是 1.8V;这样有利于 IO 电平的灵活配置;

weixin_49303682的博客 3478

材料仿真软件:Materials Studio_(14).电学性质计算

设置计算参数,包括 k 点网格、交换相关泛函等。

kkchenjj的博客 1863

Spring Boot使用Servlet居然也可以实现长轮询,敲了5年代码,我居然不知道 - 第413篇

Spring Boot使用Servlet居然也可以实现长轮询,敲了5年代码,我居然不知道 - 第413篇

qq_44600824的博客 498

基于STM32的汽车主门电动窗开关系统设计方案

以下是基于 STM32CubeMX 和 HAL 库的示例代码,可实现基本的功能。

weixin_42300449的博客 995

springboot 长轮询实现

springboot 长轮询实现 基于 @EnableAsync , @Sync @SpringBootApplication @EnableAsync public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.cla...

maple_son的专栏 9516

AutoSAR系列讲解 - 前言

博主xyfx是一名汽车行业的嵌入式软件工程师,本职工作是同步电机的驱动控制(主要做FOC的,这个大家要是有兴趣,以后可以单独开一个专栏来讲解一下),由于做控制器的时候需要用到AutoSAR,所以特意的去学习了相关知识,同时博主自己也在同步提高中,有写的不合理的地方也请大家指正。我准备对AutoSAR开设两个大的方面来讲解,一是AutoSAR的入门篇,二是AutoSAR的实践篇,分别讲述AutoSAR的理论基础和工具的使用。

雪云飞星的博客 4万+

OV9734 DATASHEET,完整规格书

OV9734完整规格书,包括电气参数,尺寸规格,寄存器配置表等

springBoot集成websocket实现消息实时推送提醒

在浏览某些网页的时候,例如WebQQ、京东在线客服服务、优快云私信消息等类似的情况下,我们可以在网页上进行在线聊天,或者即时消息的收取与回复,可见,这种功能的需求由来已久,并且应用广泛,和pc端web系统待办提醒 等。Web端 常见的消息推送实际上大多数都是模拟推送,之所以是模拟推送,是因为这种实现并不是服务器主动推送,本质依旧是客户端发起请求,服务端返回数据,起主动作用的是客户端。

weixin_35815479的博客 7883

锂电池充电器电路设计 里面有电路

锂电池充电器电路设计 里面有电路 3.7V升压到5V电路设计。

Spring Boot实现长轮询和短轮询(Short Polling & Long Polling)长连接和短连接

个人感觉,长连接和短连接是错误的描述,应该是长轮询和短轮询。 长轮询是一个在过去被广泛使用的概念,正是这种技术让网络感觉像是实时的,我认为了解一点历史会帮助你更好地理解。 目录 互联网简史 Javascript 之后的时代 长轮询的诞生 短轮询技术 长轮询 长轮询技术的实现 长轮询控制器 保持轮询方法 长轮询控制器测试 结论 互联网简史 如果您足够大,那么您就会知道早期的网络非常无聊。我所说的无聊是指静态的,没有活动部件。 它只是在 HTTP 请求/响应模型下以单向方式

jake_Aaron的博客 1万+

RU DOS版、EFI版32bit/64bit BIOS常用工具

DOS版、EFI版的RU工具,可用来查看IO、MEM、PCI、SMBIOS等资源。亲测在EFI SHELL下可用。

迈瑞BS系列全自动生化分析仪LIS协议接口手册(内含HL7和ASTM)

迈瑞BS系列全自动生化分析仪LIS协议接口手册,内含HL7和ASTM)

Spring Boot实时推送技术详解:三个经典案例

本文介绍了三种使用Spring Boot实现实时消息推送的方法:长轮询、WebSocket和GraphQL订阅。每种方法都有其独特的优势和适用场景。长轮询适用于对实时性要求不是特别高,但需要减少服务器资源浪费的场景;WebSocket适用于需要频繁双向通信的场景;GraphQL订阅则适用于需要精确请求数据并实时更新的场景。根据具体需求选择合适的方法,可以实现高效、实时的消息推送功能。

2301_79727522的博客 1452

springboot+netty实现站内消息通知(完整代码)

见名知意,就是channel 初始化器,当每个客户端创建连接时这里面的代码都会执行一遍。问题:哪些实例可以声明成单例,或者交给spring管理?因为如果每个连接都创建这么一大堆对象,可以想像1万个连接,这里会多占用多少内存出来?这个问题也不难回答,没有中间态,线程安全的类是可以声明成单例的,所以我们顺着这个方向大概就可以知道哪些是可以作为单例进行声明得。授人以鱼不如授人以渔。

a807719447的专栏 4328

在 Spring Boot 中优化长轮询(Long Polling)连接频繁建立销毁问题

本文介绍了如何在Spring Boot中优化长轮询(Long Polling)机制,提升实时通信效率。长轮询虽然能够在不支持WebSocket或SSE的环境中实现"伪实时"通信,但存在频繁建立/销毁连接导致资源浪费的问题。文章提供了多种优化策略:使用DeferredResult实现异步非阻塞处理;合理设置超时时间与轮询间隔;启用HTTP/2提升连接复用效率;采用客户端智能重试与退避算法;以及利用缓存机制减少重复请求。这些方法可以有效降低服务器负载,同时维持良好的实时性。文章还对比了各项优

2503_92145588的博客 1927

SpringBoot 实现微信模板消息通知推送提醒

SpringBoot 实现微信模板消息通知推送提醒

qq_34709784的博客 3519

springboot+vue实现长轮询

长轮询和短轮询应用场景 通常需要实时刷新数据时,需要用到轮询,字面意思就是不断重复请求。 短轮询实现 实时刷新最简单的实现就是短轮询,实现方式就是隔一段时间发送一次请求,进行数据刷新,实现很简单,代码如下 以上就是断轮询代码,后台无需处理 长轮询代码 长轮询需要后台配合,后台代码如下: ...

weixin_46415189的博客 2835

vue+springboot+websocket实现消息通知,含应用场景

vue+springboot+websocket实践!

Think_and_work的博客 4776

基于 SptringBoot 实现实时消息推送,这里有7种解决方案!

.markdown-body { line-height: 1.75; font-weight: 400; font-size: 16px; overflow-x: hidden; color: rgba(37, 41, 51, 1) } .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .ma...

程序员小明1024 1187

Spring Boot 使用DeferredResult实现长轮询

文章目录前言1. 阻塞的REST服务2. 使用DeferredResult的非阻塞REST3. DeferredResult回调 前言 异步支持是在Servlet 3.0中引入的,简单来说,它允许在请求接收器线程之外的另一个线程中处理HTTP请求。 从Spring 3.2开始可用的DeferredResult有助于将长时间运行的计算从http-worker线程卸载到单独的线程。 尽管另一个线程将占用一些资源来进行计算,但同时不会阻止工作线程,并且可以处理传入的客户端请求。 异步请求处理模型非常有用,因为它有

laker的博客 3414
上一篇: 【设计模式】第三章:建造者模式详解及应用案例
下一篇: 【SpringBoot+WebSocket】实现简单的即时通讯
Bummon.
博客等级 码龄12年 3572粉丝 48原创
评论 2
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

打赏作者

Bummon.

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

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

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

打赏作者

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

抵扣说明:

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

余额充值