纯netty实现websocket聊天,http协议页面展现,代码分析

上篇文章我介绍了netty聊天室的实现效果与实现需求,这篇文章我会给大家展示代码

 注:部分代码直接使用netty权威指南中的示例代码

netty权威指南附源代码下载地址:https://download.csdn.net/download/qq_37316272/10872031

netty聊天室在线演示地址:https://blog.csdn.net/qq_37316272/article/details/85130365 

主启动类代码

package com.ning.netty;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.stream.ChunkedWriteHandler;

public class WebSocketServer {
	public static ConcurrentMap<Object, Object> channels = new ConcurrentHashMap<>();
	public void run(int port) throws Exception{
		NioEventLoopGroup bossGroup = new NioEventLoopGroup();
		NioEventLoopGroup workerGroup = new NioEventLoopGroup();
		try {
			ServerBootstrap b = new ServerBootstrap();
			b.group(bossGroup, workerGroup)
				.channel(NioServerSocketChannel.class)
				.childHandler(new ChannelInitializer<SocketChannel>() {

					@Override
					protected void initChannel(SocketChannel ch) throws Exception {
						ChannelPipeline pipline = ch.pipeline();
						pipline.addLast("http-codec",new HttpServerCodec());
						pipline.addLast("aggregator",new HttpObjectAggregator(165536));
						ch.pipeline().addLast("http-chunked",new ChunkedWriteHandler());
						pipline.addLast("handler",new WebSocketServerHandler());
					}
				});
			Channel ch = b.bind(port).sync().channel();
			System.out.println("Web socket 启动完成,端口号:"+port);
			System.out.println("http://localhost:"+port+"/");
			ch.closeFuture().sync();
		} finally {
			bossGroup.shutdownGracefully();
			workerGroup.shutdownGracefully();
		}
	}
	public static void main(String[] args) throws Exception {
		new WebSocketServer().run(8099);
	}
}

注意这里我定义了一个 ConcurrentMap用来存放用户和对应的channel连接。

主要看handler代码实现

 首先定义静态代码存放位置,主要用于返回页面的html代码:

private String basePath;
	{
		String os = System.getProperty("os.name");
		if(os.toLowerCase().startsWith("win")){  
			basePath = "G:/myeclipsework/nettychat/src/main/resources/static/";
		}else{
			basePath = "/opt/nettychat/static/";
		}
	}

 如果接受到的请求是http请求,则去到定义的静态目录下寻找资源:

@Override
	protected void messageReceived(ChannelHandlerContext ctx, Object msg) throws Exception {
		if (msg instanceof FullHttpRequest) {
			handleHttpRequest(ctx, (FullHttpRequest) msg);
		} else if (msg instanceof WebSocketFrame) {
			handleWebSocketFrame(ctx, (WebSocketFrame) msg);
		}
	}

 如果请求是http请求,请求地址是网站根路径,直接返回index.html,否则就按照请求的路径寻找资源。如果是websocket请求则创建握手处理器类,注意因为消息中我需要包含base64编码的图片,是上万长度的字符串所以这里创建握手处理器类讲传输长度设置为65536*5,处理websocket连接。

private void handleHttpRequest(ChannelHandlerContext ctx, FullHttpRequest req) throws Exception {
		if (!req.getDecoderResult().isSuccess() || (!"websocket".equals(req.headers().get("Upgrade")))) {
			String uri = req.getUri();
			File file;
			if(uri.equals("/")){
				file = new File(basePath+"/index.html");
			}else{
				file = new File(basePath+uri);
			}
			RandomAccessFile randomAccessFile = null;
			try {
				randomAccessFile = new RandomAccessFile(file, "r");// 以只读的方式打开文件
			} catch (FileNotFoundException fnfe) {
				return;
			}
			long fileLength = randomAccessFile.length();
			HttpResponse response = new DefaultHttpResponse(HTTP_1_1, OK);
			setContentLength(response, fileLength);
			setContentTypeHeader(response, file);
			if (isKeepAlive(req)) {
				response.headers().set(CONNECTION, HttpHeaders.Values.KEEP_ALIVE);
			}
			ctx.write(response);
			ChannelFuture sendFileFuture;
			sendFileFuture = ctx.write(new ChunkedFile(randomAccessFile, 0, fileLength, 8192),
					ctx.newProgressivePromise());
			sendFileFuture.addListener(new ChannelProgressiveFutureListener() {
				@Override
				public void operationProgressed(ChannelProgressiveFuture future, long progress, long total) {
					if (total < 0) { // total unknown
						System.err.println("Transfer progress: " + progress);
					} else {
						System.err.println("Transfer progress: " + progress + " / " + total);
					}
				}
				
				@Override
				public void operationComplete(ChannelProgressiveFuture future) throws Exception {
					System.out.println("Transfer complete.");
				}
			});
			ChannelFuture lastContentFuture = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
			if (!isKeepAlive(req)) {
				lastContentFuture.addListener(ChannelFutureListener.CLOSE);
			}
			return;
		}
		WebSocketServerHandshakerFactory wsFactory = new WebSocketServerHandshakerFactory(
				"ws://localhost:8099/websocket", null, false,65536*5);
		handshaker = wsFactory.newHandshaker(req);
		if (handshaker == null) {
			WebSocketServerHandshakerFactory.sendUnsupportedWebSocketVersionResponse(ctx.channel());
		} else {
			handshaker.handshake(ctx.channel(), req);
		}
	}

先看一下网页代码实现吧:

<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<title>Netty聊天室-信息时代-技术博客</title>
		<style>
			*{padding: 0;margin: 0;list-style: none;}
			html,body{width: 100%;height: 100%;}
			#useUl li{
				list-style: none;
				cursor: pointer;
				height: 50px;
				width: 250px;
				border-bottom: 1px solid #ccc;
			}/* #ff784d */
			#useUl li img{
				width: 50px;
				height: 50px;
				border-radius: 50%;
				vertical-align: middle;
				margin-right: 20px;
			}
			#loginDiv{
				padding: 25px;
				width: 260px;
				height: 400px;
				border: 1px solid #ccc;
				margin: 100px auto;
			}
			#headImg{
				width: 100px;
				height: 100px;
				border-radius: 50%;
				margin: auto;
				line-height: 100px;
				text-align: center;
				cursor: pointer;
			}
			#username{
				width: 95%;
				display: block;
				height: 30px;
				border: 1px solid #ccc;
				padding-left: 5%;
				margin-top: 30px;
			}
			#login{
				height: 30px;
				margin-top: 30px;
				width: 100%;
				background: #ff784d;
				border: none;
				cursor: pointer;
				outline: none;
				color: #fff;
			}
			#login:hover{
				background: #ff521b;
			}
			#chatDiv{
				width: 100%;
				height: 100%;
				display: none;
			}
			#userDiv{
				width: 250px;
				min-height: 400px;
				border: 1px solid #ccc;
				float: right;
				position: absolute;
				top: 0;
				right: 0;
				padding: 20px;
			}
			#mesDiv{
				width: 600px;
				height: 500px;
				border: 1px solid #ccc;
				position: absolute;
				left: 30%;
				top: 50%;
				margin-top: -250px;
			}
			#sendBtn{
				width: 60px;
				height: 25px;
				background: #ff784d;
				border: none;
				cursor: pointer;
				outline: none;
				color: #fff;
			}
			#sendBtn:hover{
				background: #ff521b;
			}
			.msgDiv{
				padding: 0 10px;
				margin: 5px 0;
			}
			.msgDiv .headImg{
				width: 40px;
				height: 40px;
				border-radius: 50%;
				border:1px solid #ccc;
				margin-right:10px;
				vertical-align: top;
			}
			.msgDiv .msgWrap{
				display: inline-block;
				max-width: 70%;
			}
			.msgDiv .userN{
				color: #999;
			}
			.langMsg{
				background: #e2e2e2;
				padding: 10px;
				border-radius: 5px;
				color: #333;
			}
			.rightMes .headImg{
				margin-left: 10px;
				margin-right: 0;
			}
			.rightMes{
				text-align: right;
			}
			.rightMes .langMsg{
				background: #5FB878;
				color:#fff
			}
			#mes{
				overflow-y: scroll;
			}
			#mes::-webkit-scrollbar {
				width: 4px;     
				height: 1px;
			}
			#mes::-webkit-scrollbar-thumb {
				background: #fff;
			}
			#mes::-webkit-scrollbar-track {
				background: #fff;
			}
			#mes:hover::-webkit-scrollbar-thumb {
				background: #ff3e00;
			}
			#mes:hover::-webkit-scrollbar-track {
				background: rgba(128, 128, 128, 0.5);
			}
		</style>
	</head>
	<body>
		<canvas id="canvas" style="display: none">
			
		</canvas>
		<div id="loginDiv">
			<div id="headImg" style="padding: 10px;border:1px solid #ccc">点击上传头像</div><input type="file" id="fileInp" style="display: none"><input type="text" id="username" placeholder="请输入用户名"><input type="button" id="login" value="登陆">
			<input type="hidden" id="headInp">
		</div>
		<div id="chatDiv">
			<div id="mesDiv" style="">
				<div id="topUser" style="border-bottom: 1px solid #ccc;height: 45px;line-height: 30px;padding: 10px;">
					<img src="/favicon.ico" style="vertical-align: middle;border: 1px solid #ccc;margin-right: 20px;border-radius: 50%;">Netty总群
				</div>
				<div id="mes" style="height: 280px;border-bottom: 1px solid #ccc;vertical-align: top;">
				</div>
				<div id="mesText" style="height: 130px;padding: 10px;">
					<p><img src="/image.png" alt="" style="width: 20px;cursor: pointer;" id="chatImg"></p>
					<textarea id="sendInp" style="font-size: 16px;border: none;overflow: hidden;resize: none;outline: none;width: 100%;height: 80px;"></textarea>
					<p style="text-align: right;"><input type="button" id="sendBtn" value="发送"></p>
				</div>
			</div>
			<input type="hidden" id="chatInp">
			<input type="file" id="chatFileInp" style="display: none">
			<div id="userDiv">
				<div style="margin: auto;width: 100px;height: 100px;border-radius: 50%;overflow: hidden;">
					<img src="" id="headSrc" style="width:100px;height: 100px;">
				</div>
				<p style="padding: 20px 0;text-align: center;border-bottom: 1px solid #ccc;"><span style="color: #ff521b;" id="us">admin</span></p>
				<ul id="useUl" style=";vertical-align: top;">
				</ul>
			</div>
		</div>
	</body>
	<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
	<script>
		var socket;
		if(!window.WebSocket){
			window.WebSokcet = window.MozWebSocket;
		}
		if(window.WebSocket){
			socket = new WebSocket("ws://192.168.1.9:8099/websocket");
			socket.onmessage = function(event){
				var res = JSON.parse(event.data);
				console.log(res);
				if(res.type == "user"){
					$("#mes").append('<div class="leftMes msgDiv"><img src="/favicon.ico" class="headImg"><div class="msgWrap"><p class="userN">管理员</p><p class="langMsg">'+res.username+'已登录</p></div></div>');
					$("#useUl").append("<li flag='0'><img src='/headImg/"+res.headImg+"'>"+res.username+"</li>")
				}
				if(res.type == "msg"){
					$("#mes").append('<div class="leftMes msgDiv"><img src="/headImg/'+res.headImg+'" class="headImg"><div class="msgWrap"><p class="userN">'+res.username+'</p><p class="langMsg">'+res.msg+'</p></div></div>');
				}
				if(res.type == "logout"){
					$("#mes").append('<div class="leftMes msgDiv"><img src="/headImg/'+res.headImg+'" class="headImg"><div class="msgWrap"><p class="userN">管理员</p><p class="langMsg">'+res.username+'已退出</p></div></div>');
					$("#useUl li").each(function(){
						if($(this).text() == res.username){
							$(this).remove();
						}
					});
				}
				if(res.type == "img"){
					$("#mes").append('<div class="leftMes msgDiv"><img src="/headImg/'+res.headImg+'" class="headImg"><div class="msgWrap"><p class="userN">'+res.username+'</p><p class="langMsg"><img src="/chatimg/'+res.chatImg+'" onload="scrollBottom()" style="width:100px"></p></div></div>');
				}
				$("#mes").scrollTop($("#mes").prop("scrollHeight"),200);
			}
			socket.onopen = function(event){
				console.log("打开WebSocket服务正常,浏览器支持WebSoket");
			}
			socket.onclose = function(evnet){
				console.log("WebSocket关闭!");
			}
		}
		$("#headImg").click(function(){
			$("#fileInp").click();
		});
		$("#chatImg").click(function(){
			$("#chatFileInp").click();
		});
		$("#fileInp").change(function(){
			var f = $(this)[0].files[0];
			var reader = new FileReader();
			if(f){
				reader.readAsDataURL(f);
				reader.onload = function(e){
					var res = reader.result;
					$("#headImg").html("<img id='HEADIMG' style='width:100px;border-radius:50%;height:100px' src='"+res+"'>");
					/* base64转canvas */
					var _img = new Image();
					_img.src = res;
					_img.onload = function(){
						var can = document.getElementById("canvas");
						var w = $("#HEADIMG").width();
						var h = $("#HEADIMG").height();
						var ctx = can.getContext("2d");
						$(can).attr("width",w);
						$(can).attr("height",h);
						$(can).width(w);
						$(can).height(h)
						ctx.clearRect(0,0,$(can).width(),$(can).height())
						ctx.drawImage(_img, 0, 0,100,100);
						var base64 = can.toDataURL("image/jpeg");
						$("#headInp").val(base64);
					}
					/*  */
				}
			}
		});
		function scrollBottom(){
			$("#mes").scrollTop($("#mes").prop("scrollHeight"),200);
		}
		$("#chatFileInp").change(function(){
			var f = $(this)[0].files[0];
			var reader = new FileReader();
			if(f){
				reader.readAsDataURL(f);
				reader.onload = function(e){
					var res = reader.result;
					$("#mes").append('<div class="rightMes msgDiv"><div class="msgWrap"><p class="userN">我</p><p class="langMsg"><img src="'+res+'" style="width:100px"></p></div><img src="'+$("#headSrc").attr("src")+'" onload="scrollBottom()" class="headImg"></div>');
					var _img = new Image();
					_img.src = res;
					_img.onload = function(){
						var can = document.getElementById("canvas");
						var w = _img.width;
						var h = _img.height;
						if(w > 700){
							w = 400;
							h = 300;
						}
						$(can).attr("width",w);
						$(can).attr("height",h);
						$(can).width(w);
						$(can).height(h)
						var ctx = can.getContext("2d");
						ctx.drawImage(_img, 0, 0,w,h);
						var base64 = can.toDataURL("image/jpeg");
						$("#chatInp").val(base64);
						var list = $("#useUl li[flag=1]");
						var len = list.length;
						var str = "";
						if(len > 0){
							for(var i = 0;i < len;i++){
								if(i != len -1){
									str += list.eq(i).text()+",";
								}else{
									str += list.eq(i).text()
								}
							}
						}
						var isSelect = str == "" ? 0 : 1;
						var param = {
							"isSelect":isSelect,
							"type":"img",
							"user":str,
							"username":$("#username").val().trim(),
							"chatImg":$("#chatInp").val()
						}
						socket.send(JSON.stringify(param));
					}
				}
			}
		});
		$("#sendBtn").click(function(){
			var list = $("#useUl li[flag=1]");
			var len = list.length;
			var str = "";
			if(len > 0){
				for(var i = 0;i < len;i++){
					if(i != len -1){
						str += list.eq(i).text()+",";
					}else{
						str += list.eq(i).text()
					}
				}
			}
			var isSelect = str == "" ? 0 : 1;
			var param = {
				"isSelect":isSelect,
				"type":"msg",
				"user":str,
				"username":$("#username").val().trim(),
				"msg":$("#sendInp").val().trim()
			}
			$("#mes").append('<div class="rightMes msgDiv"><div class="msgWrap"><p class="userN">我</p><p class="langMsg">'+$("#sendInp").val().trim()+'</p></div><img src="'+$("#headSrc").attr("src")+'" class="headImg"></div>');
			$("#mes").scrollTop($("#mes").prop("scrollHeight"),200);
			socket.send(JSON.stringify(param));
		});
		$(document).on("click","#useUl li",function(){
			if($(this).attr("flag") == "0"){
				$(this).attr("flag",1)
				$(this).css("color","red");
			}else{
				$(this).css("color","black")
				$(this).attr("flag",0)
			}
		});
		$("#login").click(function(){
			var user = $("#username").val().trim();
			if(user == "" || $("#headInp").val() == ""){
				alert("请输入用户名,并上传头像");
				return false;
			}
			socket.send(JSON.stringify({"type":"user","username":user,"headImg":$("#headInp").val()}));
			$("#loginDiv").hide();
			$("#chatDiv").show();
			$("#mes").append('<div class="leftMes msgDiv"><img src="/favicon.ico" class="headImg"><div class="msgWrap"><p class="userN">管理员</p><p class="langMsg">欢迎登陆:'+user+'</p></div></div>');
			$("#us").text(user);
			$("#headSrc").attr("src",$("#headInp").val());
			$("#mes").scrollTop($("#mes").prop("scrollHeight"),200);
		});
	</script>
</html>

网页一加载完成就会用js进行websocket连接,这时,服务器与浏览器已经建立了连接,但我要实现的是用户带头像登陆功能,所以暂时不能存储用户的信息和channel。当用户选择图片之后,首先将图片进行base64绘制到canvas上,目的是将图片转化成统一的jpg格式,方便服务器统一解析。发送的数据格式为json数据字符串。请注意是将json对象字符串化。JSON.stringify()方法。下面是服务器的接受消息的方法。

接受的消息首先是字符串,需要把字符串转化为对象,所以用fastjson转化成Map对象,每个消息上我都定义了消息类型,如果是user类型,则是用户登录,如果是logout类型则是用户退出,其他都是消息,消息分类普通消息和图片消息

private void handleWebSocketFrame(ChannelHandlerContext ctx, WebSocketFrame frame) throws Exception{
		if (frame instanceof CloseWebSocketFrame) {
			handshaker.close(ctx.channel(), (CloseWebSocketFrame) frame.retain());
			return;
		}
		if (frame instanceof PingWebSocketFrame) {
			ctx.channel().write(new PongWebSocketFrame(frame.content().retain()));
			return;
		}
		if (!(frame instanceof TextWebSocketFrame)) {
			throw new UnsupportedOperationException(
					String.format("%s frame types not supported", frame.getClass().getName()));
		}
		String request = ((TextWebSocketFrame) frame).text();
		System.out.println(request);
		Map<String, Object> map = (Map<String, Object>) JSON.parse(request);
		if (map.get("type").equals("user")) {
			String uuid = saveImg(map,"headimg","headImg");
			map.put("headImg", uuid+".jpg");
			for (Map.Entry<Object, Object> c : WebSocketServer.channels.entrySet()) {
				Map<String,Object> key = (Map<String, Object>) c.getKey();
				Map<String, Object> maps = new HashMap<String, Object>();
				maps.put("type", "user");
				maps.put("username", key.get("username"));
				maps.put("headImg", key.get("headImg"));
				ctx.channel().writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(maps)));
			}
			request = JSON.toJSONString(map);
			sendAllUser(request);
			WebSocketServer.channels.put(map, ctx.channel());
		} else {
			if(map.get("type").equals("img")){
				String uuid = saveImg(map,"chatimg","chatImg");
				map.put("chatImg", uuid+".jpg");
			}
			Map<String, Object> myInfo = getMyInfo(ctx);
			map.put("headImg", myInfo.get("headImg"));
			request = JSON.toJSONString(map);
			if (map.get("isSelect").equals(0)) {
				sendAllNotMe(ctx.channel(), request);
			} else {
				sendSelectUser(map.get("user").toString(), request);
			}
		}
	}

 当用户登录的时候发送得是用户名,头像键值对字符串,所以把用户头像的base64字符串转化成图片存在本地,并把用户头像属性换成本地存放的文件名称,并且把当前登陆的用户信息发送给其他已登录的用户,提示新用户登陆,并且将所有已登录的用户发送给这个新登录的用户,提示当前所有已登录的用户。给当前所有登陆用户发送完消息后,在将自己的信息放入到map中,这样再有新用户登陆,就会遍历这个map发送消息了。

如果是普通的消息,则看用户是否选中其他用户,如果选中了其他用户,则为私聊,否则群发所有登陆用户。

如果服务器收到图片消息,则首先将base64图片解码成图片,存放于服务器目录下,然后将文件名和发送给其他用户,其他用户展示消息会访问服务器的图片进行展示。

下面是保存图片代码

private String saveImg(Map<String, Object> map,String path,String param) throws IOException, FileNotFoundException {
		String headImg = map.get(param).toString();
		headImg = headImg.split(",")[1];
		
		Decoder decoder = Base64.getDecoder();
		byte[] b = decoder.decode(headImg);
		for (int i = 0; i < b.length; ++i) {
			if (b[i] < 0) {// 调整异常数据
				b[i] += 256;
			}
		}
		String uuid = UUID.randomUUID().toString();
		File dir = new File(basePath+path);
		if(!dir.exists()){
			dir.mkdirs();
		}
		File file = new File(dir+File.separator+uuid+".jpg");
		if(!file.exists()){
			file.createNewFile();
		}
		FileChannel chan = new FileOutputStream(file).getChannel();
		ByteBuffer buf = ByteBuffer.allocate(b.length);
		buf.put(b);
		buf.flip();
		chan.write(buf);
		chan.close();
		return uuid;
	}

这是发送给选中用户的代码

private static void sendSelectUser(String userStr, String msg) {
		String[] split = userStr.split(",");
		List<String> list = Arrays.asList(split);
		for(Map.Entry<Object, Object> en :WebSocketServer.channels.entrySet()){
			Map<String,Object> key = (Map<String, Object>) en.getKey();
			if(list.contains(key.get("username"))){
				((Channel)en.getValue()).writeAndFlush(new TextWebSocketFrame(msg));
			}
		}
	}

发送给所有用户,但不包括自己,因为自己的不用接收自己的消息,当点击发送按钮的时候直接操作dom进行展示即可

private static void sendAllNotMe(Channel ch, String msg) {
		for (Map.Entry<Object, Object> c : WebSocketServer.channels.entrySet()) {
			if (!c.getValue().equals(ch)) {
				((Channel) c.getValue()).writeAndFlush(new TextWebSocketFrame(msg));
			}
		}
	}

当用户刷新页面,或者用户异常,则channel关闭,则视为用户退出,执行下面的代码,页面会提示用户已退出:

@Override
	public void close(ChannelHandlerContext ctx, ChannelPromise promise) throws Exception {
		for (Map.Entry<Object, Object> ent : WebSocketServer.channels.entrySet()) {
			if (ent.getValue().equals(ctx.channel())) {
				Map<String,Object> user = (Map<String,Object>) ent.getKey();
				String username = user.get("username").toString();
				WebSocketServer.channels.remove(user);
				for (Map.Entry<Object, Object> s : WebSocketServer.channels.entrySet()) {
					Map<String, Object> maps = new HashMap<String, Object>();
					maps.put("type", "logout");
					maps.put("username", username);
					Channel channel = (Channel) s.getValue();
					channel.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(maps)));
				}
				break;
			}
		}
		ctx.close();
	}

源码下载地址:https://download.csdn.net/download/qq_37316272/10872047

netty权威指南下载地址:https://download.csdn.net/download/qq_37316272/10872031

欢迎大家下载,多多学习交流,我这个程序还有许多需要改进的地方,欢迎大家不吝赐教哦!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值