springboot集成websocket 实时输出日志到浏览器(一)

前言

 一个小功能,页面实时输出日志信息。

一、首先springboot集成websocket

maven配置

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.sakyoka.test</groupId>
  <artifactId>springboot-websocket-log-test</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <packaging>jar</packaging>

  <name>springboot-websocket-log-test</name>
  <url>http://maven.apache.org</url>

	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>1.5.4.RELEASE</version>
		<relativePath /> <!-- lookup parent from repository -->
	</parent>

	<properties>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
		<java.version>1.8</java.version>
	</properties>

	<dependencies>
		<!-- springboot start -->
		<!-- web -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
			<!-- <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> 
				<artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> -->
		</dependency>

		<!-- socket -->
		<dependency>
		  <groupId>org.springframework.boot</groupId>
		  <artifactId>spring-boot-starter-websocket</artifactId>
		</dependency>
		<!-- springboot end -->        
		
		<!-- utils start -->
		<!-- 日志 logging-->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-logging</artifactId>
		</dependency>

		<!-- 热部署 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<optional>true</optional>
		</dependency>
		<!-- utlis end -->
		
		<dependency> 
		    <groupId>org.projectlombok</groupId> 
		    <artifactId>lombok</artifactId> 
		</dependency>

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

		<dependency>
		    <groupId>org.apache.tomcat.embed</groupId>
		    <artifactId>tomcat-embed-jasper</artifactId>
		    <scope>provided</scope>
		</dependency>
	</dependencies>


   <build>
        <finalName>springboot-websocket-log-test</finalName>
        <resources>
            <resource>
                <directory>${basedir}/src/main/webapp</directory>
                <!--注意此次必须要放在此目录下才能被访问到-->
                <targetPath>META-INF/resources</targetPath>
                <includes>
                    <include>**/**</include>
                </includes>
            </resource>
            <resource>
                <directory>${basedir}/src/main/resources</directory>
                <includes>
                    <include>**/**</include>
                </includes>
            </resource>
        </resources>
    </build>
</project>

配置ServerEndpointExporter ,开启websoket

package com.sakyoka.test.webscoketlog;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

/**
 * 
 * 描述:开启WebSocket、注册ServerEndpointExporter实例
 * @author sakyoka
 * @date 2022年8月14日 2022
 */
@Configuration
@EnableWebSocket
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

二、利用socket事件读取日志信息

websocket读取逻辑实现

package com.sakyoka.test.webscoketlog;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;

import org.springframework.stereotype.Component;

import lombok.extern.log4j.Log4j;

/**
 * 
 * 描述:读取日志信息
 * @author sakyoka
 * @date 2022年8月14日 上午11:01:14
 */
@ServerEndpoint("/log")
@Log4j
@Component
public class WebSocketLog {

    private Process process;
    
    private InputStream inputStream;

    private static final ExecutorService EXECUTOR_SERVICE = Executors.newCachedThreadPool();

    @OnOpen
    public void onOpen(Session session) {

    	Map<String, List<String>> params = session.getRequestParameterMap(); 
    	String logPath = "D:\\system.log";
        if (params.containsKey("logPath")){
        	logPath = params.get("logPath").get(0);
        }
        //window系统 tail命令需要添加tail.exe小工具到system32
        String cmd = "tail -f " +logPath; 
        
        log.debug(String.format("show log cmd >> %s", cmd));
		
		Command command = Command. getBuilder().commandStr(cmd).autoReadStream(false);
		command.exec();
		process = command.getProcess();
		inputStream = process.getInputStream();
		
		EXECUTOR_SERVICE.execute(() -> {
	        String line;
	        BufferedReader reader = null;
	        try {
	        	reader = new BufferedReader(new InputStreamReader(inputStream));
	            while((line = reader.readLine()) != null) {
	                session.getBasicRemote().sendText(line + "<br>");
	            }
	        } catch (IOException e) {
	        	
	        }
		});

    }

	@OnMessage
    public void onMessage(String message, Session session){
		log.debug(String.format("socket onmessage ==> 接收到信息:%s", message));
    }

    @OnClose
    public void onClose(Session session) {
    	this.close();
    	log.debug(String.format("socket已关闭"));
    }

    @OnError
    public void onError(Throwable thr) {
    	this.close();
    	log.debug(String.format("socket异常,errorMessage:%s" , thr.getMessage()));
    }
    
    private void close(){
    	
    	//这里应该先停止命令, 然后再关闭流
        if(process != null){
        	process.destroy();
        }

        try {
			if (Objects.nonNull(inputStream)){
				inputStream.close();
			}
		} catch (Exception e) {}   
    }
}

三、日志访问页面

application.properties配置视图、当前测试日志路径(根据实际配置就好)

#系统热部署
spring.devtools.restart.enabled=true
spring.devtools.restart.additional-paths=src/main/java

#端口号
server.port=9000

#视图配置
spring.mvc.view.prefix=/WEB-INF/views
spring.mvc.view.suffix=.jsp

#系统日志重新数据到这个路径文件
logging.file=D:\\system.log

控制层,添加访问页面及测试接口(包含数字打印信息测试)

package com.sakyoka.test.webscoketlog;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;

import lombok.extern.log4j.Log4j;

/**
 * 
 * 描述:  log控制层
 * @author sakyoka
 * @date 2022年8月14日 上午11:28:25
 */
@RequestMapping("/log")
@Controller
@Log4j
public class LogController {

	private int count = 0;
	
	@RequestMapping("/logconsole")
	public ModelAndView logPage() {
		return new ModelAndView("/logconsole/logconsole");
	}
	
	@RequestMapping("/testlog")
	@ResponseBody
	public String testlog() {
		String string = "测试数据:" + count;
		log.info(string);
		count++;
		return string;
	}
}

页面代码

<%@ page contentType="text/html; charset=UTF-8" %>
<!DOCTYPE html>
<html>
<head> 
    <title>jarLog</title>
	<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
	<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
	<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
	<meta name="renderer" content="webkit">
    <jsp:include page="/WEB-INF/views/common/commonstatic.jsp" flush="true" />
</head>
<body>
<div style="background-color:black;width:99%; height:500px; padding: 10px" id="console-parent">
    <div id="console" style="width:99%; height:95%; color:white;overflow-y: auto; overflow-x:hidden;"></div>
</div>
</body>
<script type="text/javascript" src="${root}/js/console.js"></script>
<script type="text/javascript" src="${root}/js/console-websocket.js"></script>
<script type="text/javascript">
//var port = window.location.port;//如果经过代理?这个经过网关是网关的端口
//var port = "${pageContext.request.serverPort}";//这个才是后端端口
var jarWebSocket;
var jarConsole;
//webscoket访问地址 对应@ServerEndpoint("/log")
var wsurl = 'ws://'+ ip +':'+ port + root +'/log';
$(function(){
	//添加console-parent内容变化,调整滚动条位置,自动滚动最下面
	$("#console").bind("DOMNodeInserted",function(e){
	 	var height = $(this).prop("scrollHeight");
	 	$(this).animate({scrollTop: height},10);		
	});
	
	jarConsole = new JarConsole();
	jarConsole.load('console');
    jarWebSocket= new JarWebSocket({
		url: wsurl,
		//获取后台返回信息
		onmessage: function(event){
			jarConsole.fill(event.data);
		}
	
	}).addEventListener();
});
</script>
</html>	

console-websocket.js 封装socket,socket自动连接

/**
 * WebSocket定义
 * add 2022-01-27 sakyoka
 * ws.readyState ==>
    CONNECTING:值为0,表示正在连接。
    OPEN:值为1,表示连接成功,可以通信了。
    CLOSING:值为2,表示连接正在关闭。
    CLOSED:值为3,表示连接已经关闭,或者打开连接失败。
 */
var JarWebSocket = function(config){
	
	var config = config || {};//配置对象
	//是否连接
	var isConnect = false;
	//WebSocket对象
	var ws = config.ws;
	//请求地址
	var wsurl = config.url; 
	//onmessage 事件
	var onmessage = config.onmessage;
	//是否主动关闭
	var driving = false;
	//是否自动连接,不传默认是
	var autoConnect = config.autoConnect || true;
	//尝试重新连接次数
	var defaultFailTryConnTimes = config.failTryConnTimes || 5;
	var tryConnTimes = 0;
	//心跳失败次数
	var defaulFailtHeartCheckTimes = config.failHeartCheckTimes || 5;
	var tryHeartCheckTimes = 0;
	
	//心跳定时器
	var heartCheckInterval;
	
	//当前对象
	var jarWs = this;

	/**
	 * 创建
	 */
	this.create = function(url){
		
		if (isConnect === true &&ws != undefined){
			return this;
		}
		
		wsurl = url;
		ws = new WebSocket(wsurl);
		return this;
	}
	
	/**
	 * 事件处理
	 */
	this.addEventListener = function(){
		
		if (ws == undefined){
			this.create(wsurl);
		}
		
		if (ws == undefined){
			throw '获取WebSocket失败.';
		}
		
		ws.onopen = function(){
			isConnect = true;
			heartCheckInterval = heartCheck();
			console.log('连接websocket服务成功.');
		}
		
		ws.onerror = function(){
			//清除保持连接定时器
			if (heartCheckInterval){
				clearInterval(heartCheckInterval)
			}
			console.log('连接websocket服务失败.');
		}
		
		ws.onclose = function(){
			console.log('websocket连接服务关闭.');
			//标识连接失败
			isConnect = false;
			//清除保持连接定时器
			if (heartCheckInterval){
				clearInterval(heartCheckInterval)
			}
			//重新连接
			if (!driving && autoConnect === true){
				console.log('websocket尝试连接...');
				jarWs.reconnect();
			}
		}
		
		/**
		 * 接收信息
		 */
		ws.onmessage = function(event){
			if (onmessage){
				onmessage(event);
			}else{
				console.log(event.data);
			}
		}
		
		/**
		 * 浏览器刷新,关闭ws
		 */
		window.onbeforeunload = function(){
			jarWs.close();
		}		
		
		return this;
	}
	
	/**
	 * 主动关闭连接
	 */
	this.close = function(){
		
		//浏览器刷新也算是主动关闭,手动调用也是
		driving = true;
		
		//清除保持连接定时器
		if (heartCheckInterval){
			clearInterval(heartCheckInterval)
		}
		
		//关闭连接
		ws.close();
		
		isConnect = false;
		
		return this;
	}
	
	/**
	 * 重新连接
	 */
	this.reconnect = function(){
		tryConnTimes += 1;
		if (defaultFailTryConnTimes < tryConnTimes){
			console.log('已超过最大尝试连接次数失败,不再重连.times:' + tryConnTimes);
			return ;
		}
		
		if (isConnect === true){
			return ;
		}
		setTimeout(function(){
			if (heartCheckInterval){
				clearInterval(heartCheckInterval);
			}
			jarWs.create(wsurl).addEventListener();
	    }, 3000);
	}
	
	/**
	 * 清除
	 */
	this.reset = function(){
		//重置重连次数
		tryConnTimes = 0;
		//重置心跳次数
		tryHeartCheckTimes = 0;
		//清空定时
		if (heartCheckInterval){
			clearInterval(heartCheckInterval);
		}
		//设置没有主动关闭
		driving = false;
		return this;
	}
	
	/**
	 * 获取WebSocket
	 */
	this.getWebSocket = function(){
		return ws;
	}
	
	/**
	 * 获取连接状态true/false
	 */
	this.isConnect = function(){
		return isConnect;
	}
	
	/**
	 * 心跳连接,10秒发送一次
	 */
	var heartCheck = function(){
		
		var heartCheckInterval = setInterval(function(){
			
			if (defaulFailtHeartCheckTimes < tryHeartCheckTimes){
				console.log('已超过最大尝试心跳发送失败次数,不再发送.times:' + tryHeartCheckTimes);
				clearInterval(this);
				return ;
			}
			
			try{
				ws.send('HEART_CHECK');
			}catch(e){
				console.log("readyState:" + ws.readyState);
				tryHeartCheckTimes += 1;
			}
			
		}, 10 * 1000);
		
		return heartCheckInterval;
	}
}  

console.js,封装日志数据数据到控制台

//jarId对应的JarConsole对象
var JarConsoleRecordObject = {};
var JarConsole = function(){
	
	var consoleStr = "";
	
	var id = "";
	
	var consoleEleObj;
	
	var autoClearRef = false;
	
	var consoleParams = {};
	
    /** 
     * 生成对应控制台div字符串 
    */
    this.createConsoleDivStr = function(id){
	    var historyObject = JarConsoleRecordObject[id];
        if (historyObject != undefined){
	        return historyObject.getConsoleStr();
        }
	    JarConsoleRecordObject[id] = this;
        id = id;
        consoleStr = '<div style="width:99%;height:99%;background-color:black;" id="console-'+ id +'"></div>';
        consoleEleObj = $(consoleStr);
        return consoleStr;
    }
    
    /**
     * 加载元素
     */
    this.load = function(idOrEle){
    	var ele = (typeof(idOrEle) == 'string' ? $('#' + idOrEle): idOrEle);
    	var id = $(ele).attr('id');
	    var historyObject = JarConsoleRecordObject[id];
        if (historyObject != undefined){
	        return historyObject;
        }   	
    	consoleStr = $(ele).html();
    	consoleEleObj = $(ele);
    	JarConsoleRecordObject[id] = this;
    	return this;
    }
    
    /**
     * 填充字符
     */
    this.fill = function(str, extraParams){
    	
    	if (str == undefined || str == '' || consoleEleObj == undefined){
    		return ;
    	}
    	extraParams = extraParams || {};
    	for (var k in extraParams){
	        consoleParams[k] = extraParams[k];
        }
    	var splitStr = consoleParams.splitStr || '\n';
    	var contents = str.split(splitStr);
    	if (contents.length == 0){
    		return ;
    	}
    	
    	var index = 0;
    	var timeoutObj ;
    	var time = consoleParams.time || 100;
    	var allowShowMaxRows = consoleParams.allowShowMaxRows || 200;
        var setTimeoutFillContent = function(){
        	
        	//控制控制台最大展示行数,以免文本内容过大
        	var length = consoleEleObj.children().length;
        	if (length > allowShowMaxRows){
	            consoleEleObj.children().eq(0).remove();
            }
        	
        	consoleEleObj.append('<p>'+ contents[index] +'</p>');
        	index += 1;
        	timeoutObj = setTimeout(function(){setTimeoutFillContent();}, time);
        	
        	if (contents.length <= index){
        		clearTimeout(timeoutObj);
        		return ;
        	}
        }
    	
        setTimeoutFillContent();
        
    	if (autoClearRef === true){
    		this.autoClearRef();
    	}
    	
    	return this;
    }
    
    /**
     * 是否自动清除
     */
    this.autoClearRef = function(clear){
    	autoClearRef = clear;
    	return this;
    }
    
    /**
     * 清空内容
     */
    this.clear = function(){
    	consoleEleObj.empty();
    }
    
    /**
     * 获取对应控制台div字符串
    */
    this.getConsoleStr = function(){
	    return consoleStr;
    }
    
    /**
     * 清除关联
     */
    this.clearRef = function(){
	    //移除其它 
      
        //清除关联
	    delete JarConsoleRecordObject[id];
    }
}

四、打印效果

好了,上面工作做好,就可以测试日志实时输出到页面的效果了

首先,启动项目访问http://127.0.0.1:9000/log/logconsole

 可以看到,刚启动的日志信息,然后在访问几次接口http://127.0.0.1:9000/log/testlog试试

 到此测试日志实时输出完毕,有兴趣可以了解下。

问题发现

    1、发现一个有趣事情,webscoket(@ServerEndpoint)和controller放在同一个目录,被一个aop拦截时候,异常as it is not annotated with @ServerEndpoint

补充命令执行类

public class Command {
	
	public static Command getBuilder() {
		return new Command();
	}
	
	private String contents;
	
	private List<String> listContents;
	
	private boolean isPrint = false;
	
	private String commandStr;
	
	private Process process;
	
	private boolean autoReadStream = true;
	
	private String[] commandArr;
	
	private boolean useStrCommand = true;
	
	public Command commandArr(String ...commandArr) {
		this.commandArr = commandArr;
		this.useStrCommand = false;
		return this;
	}
	
	public Command commandStr(String commandStr) {
		this.commandStr = commandStr;
		this.useStrCommand = true;
		return this;
	}
	
	public Command isPrint(boolean isPrint) {
		this.isPrint = isPrint;
		return this;
	}
	
	public Command autoReadStream(boolean autoReadStream) {
		this.autoReadStream = autoReadStream;
		return this;
	}
	
	/**
	 * 
	 * 描述:执行返回信息:字符串
	 * @author sakyoka
	 * @date 2020 上午12:32:54
	 * @return
	 */
	public String toStringContents() {
		StringBuilder stringBuilder = new StringBuilder(); 
		this.process = new RuntimeExecutor() {
			
			@Override
			protected void doWath(String line) {
				print(line);
				stringBuilder.append(line).append("\n");
			}
			
		}.exec();
		
		this.contents = stringBuilder.toString();
		
		return this.contents;
	}
	
	/**
	 * 
	 * 描述:执行返回信息:集合存储
	 * @author sakyoka
	 * @date 2020 上午12:33:18
	 * @return
	 */
	public List<String> toListContets(){
		List<String> contents = new ArrayList<String>();
		this.process = new RuntimeExecutor() {
		
			@Override
			protected void doWath(String line) {
				print(line);
				contents.add(line);
			}
			
		}.exec();
		
		this.listContents = contents;
		
		return this.listContents;
	}
	
	/**
	 * 描述:普通执行
	 * @author sakyoka
	 * @date 2022年1月12日 上午10:07:00
	 */
	public void exec(){
		
		this.process = new RuntimeExecutor() {
			
			private final String preffixName = Command.class.getPackage().getName() + ".Command.exec";
			
			@Override
			protected void doWath(String line) {
				print(preffixName + ":"+ line);
			}
			
		}.exec();		
	}
	
	abstract class RuntimeExecutor{
		
		public Process exec() {
			
			try {
				Process process = (useStrCommand ? Runtime.getRuntime().exec(commandStr) 
						: Runtime.getRuntime().exec(commandArr));
				if (autoReadStream){
			        streamPrint(process.getInputStream());
					streamPrint(process.getErrorStream());				
				}
		        return process;
			} catch (IOException e) {
				throw new RuntimeException("执行命令出错 ====>>>> " , e);
			}		
		}
		
		private void streamPrint(InputStream inputStream) {
			if (inputStream == null){
				return ;
			}
			BufferedReader reader = null;
	        String line;
	        try {
	        	reader = new BufferedReader(new InputStreamReader(inputStream, "gb2312"));
		        while ((line = reader.readLine()) != null) {
		        	this.doWath(line);
		        }	
			} catch (IOException e) {
				
			}finally {
				if (reader != null){
					try {
						reader.close();
					} catch (IOException e) {
						e.printStackTrace();
					}
				}
				if (inputStream != null){
					try {
						inputStream.close();
					} catch (IOException e) {
					}
				}
			}
		
		}

		protected abstract void doWath(String line);
	} 

	public String getContents() {
		return contents;
	}

	public List<String> getListContents() {
		return listContents;
	}
	
	public String getCommandStr() {
		return commandStr;
	}

	public Process getProcess() {
		return process;
	}

	public boolean isPrint() {
		return isPrint;
	}

	public boolean isAutoReadStream() {
		return autoReadStream;
	}

	public String[] getCommandArr() {
		return commandArr;
	}

	public boolean isUseStrCommand() {
		return useStrCommand;
	}

	/**
	 * 
	 * 描述:控制台打印
	 * @author sakyoka
	 * @date 2020 下午5:26:02
	 * @param content
	 */
	private void print(String content) {
		if (this.isPrint)
			System.out.println(content);
	}
}

其它

    websocket日志读取日志输出-Java文档类资源-CSDN下载

   windowtail命令支持-WindowsServer文档类资源-CSDN下载

springboot集成websocket 清空日志后消息广播通知前端重新连接(二)_sakyoka的博客-CSDN博客

  • 3
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
SpringBoot集成WebSocket是指在SpringBoot项目中使用WebSocket技术来实现后台向前端推送信息的功能。通过集成WebSocket,可以实现实时的双向通信,使后端能够主动向前端推送消息。 要实现SpringBoot集成WebSocket,首先需要创建一个SpringBoot项目,并引入WebSocket依赖。可以在pom.xml文件中添加以下依赖: ``` <!-- WebSocket dependency --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> <version>2.7.12</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> <version>2.7.12</version> </dependency> ``` 然后,需要在SpringBoot的配置类上添加@EnableWebSocket注解,启用WebSocket功能。同时,可以创建一个WebSocket处理器类,用于处理WebSocket连接和消息的处理逻辑。 在启动项目后,可以通过访问http://localhost:8081/demo/toWebSocketDemo/{cid}来跳转到页面,然后就可以和WebSocket进行交互了。通过WebSocket连接,后台可以向前端主动推送消息,实现实时的双向通信。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* *3* [SpringBoot 集成WebSocket详解](https://blog.csdn.net/qq_42402854/article/details/130948270)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值