基于NIO非阻塞的java聊天demo(支持单聊和群聊)

1、聊天demo介绍


首先,你需要了解什么是缓存区(buffer)、通道(channel)、选择器(selector)、TCP协议、java组件Swing(这玩意我以为不会,需要用到什么百度查查就ok)。

其次对java网络编程socket有过简单的应用,起码有过认识,这样在看demo可能会理解更快!

最后,说到这里,先放最后的效果图吧,页面设计一般,请亲喷。





如上图所示,分别是服务端页面和客户端页面,其中服务端分为“服务器配置”、“在线用户列表”、“消息显示区”、“发送消息区”,客户端页面设计差不多,但是在去连接服务端时需要进行用户名和密码的校验,这算是一个基本的功能。


2、项目架构分析


页面绘制:这一块说是简单,但是java的图形控件我使的很少,现在基本上也不用,有机会就随便学学!如果非要谈设计,如下图所示:



项目架构:其实就是两个Main方法,也就是两个主线程之间的交互。一个是ChatServer服务端,一个是ChatClient客户端,代码我暂时没有做更详细的分层,结构见下图



3、功能分析


既然是聊天的demo,功能类似于扣扣吧,简单画图如下:

服务端功能:
(1)提供服务开启和服务关闭
(2)校验用户信息,完成登录检查
(3)接受用户数据包,解析做处理(这就需要有约定的协议)
(4)提供在线用户列表查询
客户端功能:
(1)连接服务端
(2)可以进行登录
(3)查询在线用户列表
(4)选中用户进行消息发送

其中,有很多的异常需要处理,列举以下
(1)服务端服务开启
(2)服务端服务正常关闭和异常关闭
(3)转发给用户聊天信息
(4)客户端正常关闭和异常关闭
(5)客户端登录失败
(6)客户端发送消息失败

4、详细设计与代码实现

1、用户类(User)
用户保存用户名和对应的socketChannel,主要是服务对用户聊天信息进行转发,将信息写到对应用户的通道中
package com.mychat;

import java.nio.channels.SocketChannel;

/**
 * 在线用户类
 * @author ccq
 *
 */
public class User {
	
	private String userName;
	private SocketChannel socketChannel;
	
	public User(String userName, SocketChannel socketChannel) {
		this.userName = userName;
		this.socketChannel = socketChannel;
	}
	public String getUserName() {
		return userName;
	}
	public void setUserName(String userName) {
		this.userName = userName;
	}
	public SocketChannel getSocketChannel() {
		return socketChannel;
	}
	public void setSocketChannel(SocketChannel socketChannel) {
		this.socketChannel = socketChannel;
	}

}

2、消息类(Message)
用户发送消息,需要将发送人,接收人,聊天信息,状态,命令打成一个数据包发送到服务端,服务端进行解析,按照命令做对应的逻辑操作


package com.mychat;

import net.sf.json.JSONObject;

/**
 * 消息类
 * 
 * @author ccq
 *
 */
public class Message {
	
	private String command;  // 命令
	private String status;	 // 状态
	private String content;	 // 内容
	private String fromUserName;
	private String toUserName;
	
	public Message() {}
	
	public Message(String command, String status, String content, String fromUserName, String toUserName) {
		super();
		this.command = command;
		this.status = status;
		this.content = content;
		this.fromUserName = fromUserName;
		this.toUserName = toUserName;
	}
	public String getCommand() {
		return command;
	}
	public void setCommand(String command) {
		this.command = command;
	}
	public String getStatus() {
		return status;
	}
	public void setStatus(String status) {
		this.status = status;
	}
	public String getContent() {
		return content;
	}
	public void setContent(String content) {
		this.content = content;
	}

	public String getFromUserName() {
		return fromUserName;
	}

	public void setFromUserName(String fromUserName) {
		this.fromUserName = fromUserName;
	}

	public String getToUserName() {
		return toUserName;
	}

	public void setToUserName(String toUserName) {
		this.toUserName = toUserName;
	}

	public static void main(String[] args) {
		Message msg = new Message("login","success","你好", "张三", "李四");
		JSONObject object = JSONObject.fromObject(msg);
		
		Message bean = (Message) JSONObject.toBean(object, Message.class);
		System.out.println(bean.getCommand());
	}

	@Override
	public String toString() {
		return "Message [command=" + command + ", status=" + status + ", content=" + content + ", fromUserName="
				+ fromUserName + ", toUserName=" + toUserName + "]";
	}
	
}

3、日期格式化类(DateUtils)
package com.mychat;

import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * 日期工具类
 * @author ccq
 *
 */
public class DateUtils {
	
	private static final String PATTERN = "yyyy-MM-dd HH:mm:ss";
	
	public static String getCurrentDate(Date date) {
		SimpleDateFormat simpleDateFormat = new SimpleDateFormat(PATTERN);
		return simpleDateFormat.format(date);
	}

}

4、服务端类(ChatServer)

(1)初始化页面组件(绘制页面)
/**
	 * 初始化页面组件
	 */
	private void initComponents() {
		
		/******************用户信息和连接配置*********************/
		settingPanel = new JPanel();
		settingPanel.setBorder(new TitledBorder("服务器配置"));
		settingPanel.setLayout(new GridLayout(1, 6, 5, 10));
		
		
		/******************配置信息设置*********************/
		ipField = new JTextField("127.0.0.1");
		portField = new JTextField("9090");
		
		ipLabel = new JLabel("服务端ip:");
		portLabel = new JLabel("服务端端口:");
		
		startServerBtn = new JButton(START_SERVER);
		stopServerBtn = new JButton(STOP_SERVER);
		
		
		/******************将组件添加到配置中*********************/
		settingPanel.add(ipLabel);
		settingPanel.add(ipField);
		settingPanel.add(portLabel);
		settingPanel.add(portField);
		settingPanel.add(startServerBtn);
		settingPanel.add(stopServerBtn);
		
		
		/******************左边的在线用户*********************/
		listModel = new DefaultListModel<String>();
		friendList = new JList<String>(listModel);
		JScrollPane leftScroll = new JScrollPane(friendList);
		leftScroll.setBorder(new TitledBorder("在线用户"));
		
		
		/******************右边的历史消息显示和发送消息*********************/
		chatPanel = new JPanel(new BorderLayout());
		
		contentPanel = new JPanel(new BorderLayout());
		chatContentField = new JTextField();
		sendBtn = new JButton(SEND);
		
		clearContentBtn = new JButton(CLEAR_CONTENT);
		contentPanel.add(chatContentField, BorderLayout.CENTER);
		
		JPanel btnPanel = new JPanel(new GridLayout(1, 2, 5, 5));
		btnPanel.add(sendBtn);
		btnPanel.add(clearContentBtn);
		
		
		contentPanel.add(chatContentField, BorderLayout.CENTER);
		contentPanel.add(btnPanel, BorderLayout.EAST);
		contentPanel.setBorder(new TitledBorder("发送消息"));

		historyRecordArea = new JTextArea();
		historyRecordArea.setForeground(Color.blue);
		historyRecordArea.setEditable(false);
		
		chatPanel.add(historyRecordArea,BorderLayout.CENTER);
		chatPanel.add(contentPanel, BorderLayout.SOUTH);
		
		JScrollPane rightScroll = new JScrollPane(chatPanel);
		rightScroll.setBorder(new TitledBorder("消息显示区"));
		
		
		/******************设置左右显示定位*********************/
		JSplitPane centerSplit = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftScroll,rightScroll);
		centerSplit.setDividerLocation(100);
		
		
		/******************设置主体定位*********************/
		getContentPane().add(settingPanel,BorderLayout.NORTH);
		getContentPane().add(centerSplit,BorderLayout.CENTER);
		
		/******************初始化按钮和文本框状态*********************/
		initBtnAndTextConnect();
		
		/******************设置窗体大小和居中显示*********************/
		this.setTitle("服务器");
		this.setSize(800, 500);
		this.setLocationRelativeTo(this.getOwner());
		this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
		this.setVisible(true);
		
	}

(2)按钮的监听事件
/**
	 * 设置按钮的监听事件
	 */
	private void setupListener() {
		startServerBtn.addActionListener(this);
		stopServerBtn.addActionListener(this);
		sendBtn.addActionListener(this);
		clearContentBtn.addActionListener(this);
		// 发送消息的文本框回车事件
		chatContentField.addActionListener(this);
	}

(3)对于监听事件的处理
// 用于监听按钮的点击事件
	@Override
	public void actionPerformed(ActionEvent e) {
		String actionCommand = e.getActionCommand();
		
		if (START_SERVER.equals(actionCommand)) {
			
			try {
				// 服务启动
				String serverIp = ipField.getText();
				String portStr = portField.getText();

				if (StringUtils.isEmpty(serverIp) || StringUtils.isEmpty(portStr)) {
					JOptionPane.showMessageDialog(this, "请输入服务器ip和端口号!");
					return;
				}
				// 初始化连接信息
				initConnection(InetAddress.getLocalHost(), Integer.parseInt(portStr));
				connect();
				
				setTitle("服务器 - " + hostAddress.getHostAddress());
			} catch (NumberFormatException e1) {
				JOptionPane.showMessageDialog(this, "端口输入异常,请输入数字(如:8080)", "错误", JOptionPane.ERROR_MESSAGE);
				//e1.printStackTrace();
			} catch (Exception e1) {
				JOptionPane.showMessageDialog(this, "服务启动失败!" + e1.getMessage(), "错误", JOptionPane.ERROR_MESSAGE);
				//e1.printStackTrace();
			}
			
		} else if (STOP_SERVER.equals(actionCommand)) {
			// 关闭服务器
			this.shutdown(serverThread);
			
		} else if (SEND.equals(actionCommand) || e.getSource() == chatContentField) {
			//发送按钮和文本框回车事件
			String message = chatContentField.getText();
			chatContentField.setText("");
			
			if (message == null || message.equals("")) {
				JOptionPane.showMessageDialog(this, "消息不能为空!", "错误", JOptionPane.ERROR_MESSAGE);
				return;
			}
			
			String toUserName = this.getSelectedUser();
			
			if(toUserName.equals(ALL_USER_COMMAND)) {
				historyRecordArea.append(formatMessage("对所有人说:" + message));
			}else {
				historyRecordArea.append(formatMessage("对 " + toUserName + " 说:" + message));
			}
			
			try {
				this.sendMsgToUser(toUserName,message);
			} catch (IOException e1) {
				JOptionPane.showMessageDialog(this, "消息发送失败" + e1.getMessage(), "错误", JOptionPane.ERROR_MESSAGE);
				//e1.printStackTrace();
			}
		}else if(CLEAR_CONTENT.equals(actionCommand)) {
			// 清空历史聊天记录
			historyRecordArea.setText("");
		}
	}
(4)服务端线程(处理客户端发来消息)

// 服务器线程,用与监听事件
	class ServerThread extends Thread{
		@Override
		public void run() {
			try {
				while(selector.select()>0) {
					Set<SelectionKey> selectedKeys = selector.selectedKeys();
					Iterator<SelectionKey> iterator = selectedKeys.iterator();
					
					while(iterator.hasNext()) {
						SelectionKey key = iterator.next();
						
						if(!key.isValid()) {
							continue;
						}
						
						if(key.isAcceptable()) {
							accept(key);
						} else if(key.isReadable()) {
							read(key);
						}
						iterator.remove();
					}
				}
				
			}
			catch (IOException e) {
				e.printStackTrace();
			}
		}
	}

// 接受事件
	private void accept(SelectionKey key) throws IOException {
		ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
		SocketChannel socketChannel = serverSocketChannel.accept();
		//System.out.println(socketChannel.getRemoteAddress());
		//Socket socket = socketChannel.socket();
		historyRecordArea.append(formatMessage(socketChannel.getRemoteAddress() + " 连接请求"));
		socketChannel.configureBlocking(false);
		socketChannel.register(this.selector, SelectionKey.OP_READ);
		key.interestOps(SelectionKey.OP_ACCEPT);
	}

// 读取事件
	private void read(SelectionKey key) throws IOException {
		SocketChannel socketChannel = (SocketChannel) key.channel();
		this.readBuffer.clear();
		
		int len = 0;
		try {
			len = socketChannel.read(this.readBuffer);
		} catch (IOException e) {
			// 远程强制关闭通道,取消选择键并关闭通道
			closeClient(key,socketChannel);
			return;
		}
		
		if(len == -1) {
			// 客户端通道调用close进行关闭,取消选择键并关闭通道
			closeClient(key,socketChannel);
			return;
		}
		
		String msg = new String(this.readBuffer.array(),0,len);
		
		Message message = (Message) JSONObject.toBean(JSONObject.fromObject(msg), Message.class);
		
		String command = message.getCommand();
		String fromUserName = message.getFromUserName();
		String content = message.getContent();
		String toUserName = message.getToUserName();
		
		Message returnMsg = new Message();
		
		Message toAllMsg = new Message();
		
		// 业务逻辑处理
		switch(command) {
			case LOGIN_COMMAND:
				System.out.println(formatMessage("用户 :" + fromUserName + "请求登录..."));
				String password = PropertyFactory.getProperty(fromUserName);
				
				if(password == null) {
					System.out.println(formatMessage("用户:" + fromUserName + "不存在"));
					returnMsg.setContent("用户不存在");
					returnMsg.setStatus("MSG_PWD_ERROR");
					historyRecordArea.append(formatMessage("用户 :" + fromUserName+ "不存在!"));
				}else if(password.equals(content)) {
					if(!userMap.containsKey(fromUserName)) {
						System.out.println(formatMessage("用户:"+ fromUserName +"登录成功!"));
						User user = new User(fromUserName, socketChannel);
						userMap.put(fromUserName, user);
						returnMsg.setContent("用户:"+ fromUserName +"登录成功!");
						returnMsg.setStatus("MSG_SUCCESS");
						returnMsg.setFromUserName(fromUserName);
						
						listModel.addElement(fromUserName);
						historyRecordArea.append(formatMessage(fromUserName+ " 成功上线!"));
						
					}else {
						System.out.println(formatMessage("该帐号已经登录"));
						returnMsg.setContent("用户:"+ fromUserName +"已经登录!");
						returnMsg.setStatus("MSG_REPEAT");
						historyRecordArea.append(formatMessage(fromUserName+ " 重复登陆,失败!"));
					}
				}else {
					returnMsg.setContent("密码错误");
					returnMsg.setStatus("MSG_PWD_ERROR");
					historyRecordArea.append(formatMessage("用户 :" + fromUserName+ "密码错误!"));
				}
				returnMsg.setCommand(LOGIN_COMMAND);
				//发送登录结果	
                sendMessage(socketChannel, returnMsg);
                break;
			case CHAT_COMMAND:
				historyRecordArea.append(formatMessage("用户:"+ fromUserName + "发消息给用户:" + toUserName + ", 内容是:" + content));
				returnMsg.setCommand(CHAT_COMMAND);
				// 群聊
				if(StringUtils.isNotEmpty(toUserName) && ALL_USER_COMMAND.equals(toUserName)) {
					returnMsg.setFromUserName(fromUserName);
					returnMsg.setToUserName(toUserName);
					returnMsg.setStatus("MSG_SUCCESS");
					returnMsg.setContent(content);
					sendAllUserMessage(returnMsg);
					break;
				}
				// 私聊
				if(userMap.containsKey(fromUserName) && userMap.containsKey(toUserName) 
						&& StringUtils.isNotEmpty(content)) {
					SocketChannel sc = userMap.get(toUserName).getSocketChannel();
					returnMsg.setFromUserName(fromUserName);
					returnMsg.setToUserName(toUserName);
					returnMsg.setStatus("MSG_SUCCESS");
					returnMsg.setContent(content);
	                sendMessage(sc, returnMsg);
				}else {
					returnMsg.setFromUserName(fromUserName);
					returnMsg.setToUserName(toUserName);
					returnMsg.setStatus("MSG_ERROR");
					returnMsg.setContent("消息发送失败!");
					sendMessage(socketChannel, returnMsg);
				}
				break;
			case ONLINE_USERLIST_COMMAND:
				// 通知所有人上线消息
				toAllMsg.setCommand(ONLINE_USER_COMMAND);
				toAllMsg.setFromUserName(fromUserName);
				sendAllMessage(toAllMsg);
		}
	}

(5)、显示消息的模板方法
// 消息记录显示模板
	public String formatMessage(String connect) {
		return String.format(DateUtils.getCurrentDate(new Date())+ SEPARATOR + "%s\n", connect);
	}

(6)、发送消息方法
// 发送消息
	private void sendMessage(SocketChannel socketChannel, Message returnMsg) throws IOException {

		JSONObject msg = JSONObject.fromObject(returnMsg);
		if(socketChannel != null && msg != null) {
			byte[] val = msg.toString().getBytes();
			socketChannel.write(ByteBuffer.wrap(val));
		}
	}
	
	// 用户获取在线用户列表,同时将他上线的消息通知到所有的客户端
	public void sendAllMessage(Message message) throws IOException {
		
		Message toFromUserMsg = new Message();
		StringBuffer onlineUserName = new StringBuffer();
		
		// 通知所有人 他上线了
		Set<Entry<String, User>> entrySet = userMap.entrySet();
		for(Entry<String, User> e : entrySet) {
			if(!e.getKey().equals(message.getFromUserName())) {
				JSONObject msg = JSONObject.fromObject(message);
				byte[] val = msg.toString().getBytes();
				
				e.getValue().getSocketChannel().write(ByteBuffer.wrap(val));
				
				onlineUserName.append(e.getKey()).append("#");
			}
		}
		
		// 返回在线用户列表
		if(onlineUserName.length() > 1) {
			String userNames = onlineUserName.substring(0, onlineUserName.length()-1);
			System.out.println(userNames);
			toFromUserMsg.setContent(userNames);
			toFromUserMsg.setCommand(ONLINE_USERLIST_COMMAND);
			JSONObject msg = JSONObject.fromObject(toFromUserMsg);
			byte[] val = msg.toString().getBytes();
			userMap.get(message.getFromUserName()).getSocketChannel().write(ByteBuffer.wrap(val));
		}
		
		
	}
	
	// 发送给所有在线用户消息
	public void sendAllUserMessage(Message message) throws IOException {
		Set<Entry<String, User>> entrySet = userMap.entrySet();
		for(Entry<String, User> e : entrySet) {
			// 群聊不发给自己
			if(StringUtils.isNotEmpty(message.getFromUserName()) 
					&& e.getKey().equals(message.getFromUserName())) {
				continue;
			}
			JSONObject msg = JSONObject.fromObject(message);
			byte[] val = msg.toString().getBytes();
			e.getValue().getSocketChannel().write(ByteBuffer.wrap(val));
		}
	}
	
	// 服务器 聊天发送
	private void sendMsgToUser(String toUserName, String content) throws IOException {
		Message message = new Message();
		message.setCommand(CHAT_COMMAND);
		message.setContent(content);
		message.setStatus("MSG_SUCCESS");
		message.setFromUserName("CCQ服务器");
		message.setToUserName(toUserName);
		
		if(toUserName.equals(ALL_USER_COMMAND)) {
			// 群发
			sendAllUserMessage(message);
		}else {
			// 单发
			sendMessage(userMap.get(toUserName).getSocketChannel(), message);
		}
	}

5、客户端类(CharClient)

和服务端差不多,贴一下主要的逻辑代码

(1)连接服务器
// 连接服务器
	public void connect() {
		try {
			this.selector = Selector.open();
			socketChannel = SocketChannel.open();
			
			boolean connect = socketChannel.connect(new InetSocketAddress(this.hostAddress, this.port));
			socketChannel.configureBlocking(false);
			
			System.out.println("connect = "+connect);
			socketChannel.register(selector, SelectionKey.OP_READ);
			
			historyRecordArea.append(formatMessage("本地连接参数:" + socketChannel.getLocalAddress()));
			
			historyRecordArea.append(formatMessage("您已经成功连接服务器 ip:" + hostAddress + " 端口:"+port));
			
		} catch (ClosedChannelException e) {
			historyRecordArea.append(formatMessage("====服务器连接失败!===" + e.getMessage()));
			e.printStackTrace();
		} catch (IOException e) {
			historyRecordArea.append(formatMessage("服务器连接失败!" + e.getMessage()));
			e.printStackTrace();
		}
		
		ClientThread clientThread = new ClientThread();
		// 设置客户端线程为守护线程
		clientThread.setDaemon(true);
	    clientThread.start();
	    
	}

(2)客户端线程(接受服务端发来的数据包进行处理逻辑)
// 客户端线程,用于监听事件
	class ClientThread extends Thread{
		
		@Override
		public void run() {
			try {
				while(selector.select()>0) {
					Set<SelectionKey> selectedKeys = selector.selectedKeys();
					Iterator<SelectionKey> iterator = selectedKeys.iterator();
					
					while(iterator.hasNext()) {
						SelectionKey key = iterator.next();
						if(key.isReadable()) {
							read(key);
						}
						iterator.remove();
					}
				}
				
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}
	
	// 读事件
	private void read(SelectionKey key) throws IOException {
		
		SocketChannel socketChannel = (SocketChannel) key.channel();
		this.readBuffer.clear();
		
		int len;
		try {
			len = socketChannel.read(this.readBuffer);
		} catch (IOException e) {
			key.cancel();
			socketChannel.close();
			return;
		}
		System.out.println("收到字符串长度 len = " + len);
		if(len == -1) {
			key.channel().close();
			key.cancel();
			return;
		}
		
		String msg = new String(this.readBuffer.array(),0,len);
		
		Message message = (Message) JSONObject.toBean(JSONObject.fromObject(msg), Message.class);
		
		String command = message.getCommand();
		String fromUserName = message.getFromUserName();
		String content = message.getContent();
		String toUserName = message.getToUserName();
		String status = message.getStatus();
		
		// 逻辑处理
		switch(command) {
			case LOGIN_COMMAND:
				
				if("MSG_SUCCESS".equals(status)) {
					this.userName = fromUserName;
					showBtnAndTextConnectSuccess();
					historyRecordArea.append(formatMessage("您已成功上线!"));
					// 获取在线用户列表
					this.findOnlineList();
					
				}else if("MSG_PWD_ERROR".equals(status)){
					
					JOptionPane.showMessageDialog(this, content, "错误", JOptionPane.ERROR_MESSAGE);
					this.selector.close();
					this.socketChannel.close();
					historyRecordArea.append(formatMessage("登录失败," + content));
					
				} else if("MSG_REPEAT".equals(status)){
					
					JOptionPane.showMessageDialog(this, content, "错误", JOptionPane.ERROR_MESSAGE);
					this.selector.close();
					this.socketChannel.close();
					historyRecordArea.append(formatMessage("登录失败," + content));
				} 
				break;
			case CHAT_COMMAND:
				if("MSG_SUCCESS".equals(status)) {
					if(StringUtils.isNotEmpty(toUserName) && ALL_USER_COMMAND.equals(toUserName)) {
						
						historyRecordArea.append(formatMessage(fromUserName + "对所有人说:" + content));
					}else {
						
						historyRecordArea.append(formatMessage(fromUserName + "说:" + content));
					}
				}else {

					historyRecordArea.setDisabledTextColor(Color.BLACK);
					historyRecordArea.append(formatMessage("失败消息###发送给"+ toUserName+ " :" + content));
				}
				break;
			case ONLINE_USER_COMMAND:

				historyRecordArea.append(formatMessage(fromUserName + "上线了!"));
				listModel.addElement(fromUserName);
				break;
			case OFFLINE_USE_COMMAND:

				historyRecordArea.append(formatMessage(fromUserName + "下线了!"));
				listModel.removeElement(fromUserName);
			case ONLINE_USERLIST_COMMAND:
				
				String[] userNames = content.split("#");
				System.out.println(userNames.length + "==============在线人数================");
				for(int i=0; i<userNames.length; i++) {
					if(!userNames[i].equals(this.userName)) {
						listModel.addElement(userNames[i]);
					}
				}
				break;
			case SERVER_STOP:
				shutdownConnect();
		}
		key.interestOps(SelectionKey.OP_READ);
	}

5、总结

对于还不熟悉的NIO朋友,我建议先去看看基础吧,在最开始的地方我有说重要的点!
从上周开始,经历初始NIO——>熟悉NIO——>简单编写demo,终于完成这个小程序,其实还是挺好玩的,最近对TCP协议挺有兴趣的,多看多学多做吧!
最后再放几张demo聊天截图:














评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值