利用纯真ip库搭建ip查询服务

18 篇文章 1 订阅
5 篇文章 0 订阅

前言: 前段时间听过了纯真ip数据库,只知道是一个qqwry.dat文件,里面有一些网友收集的数据,可以用来查询ip的大致位置,即ip定位。正好,我最近在一个项目里面看到了这个数据库,它就是将qqwry.dat集成到springboot项目里面,做成一个查询服务来使用的。所以,我也想来试试,但是我不是直接使用qqwry.dat这个文件。我换一种方式,我才用将qqwry.dat中的ip数据导入mysql数据库中,通过数据库的查询来提供ip位置查询功能。

gitee代码地址: 纯真ip库服务搭建

基本介绍

纯真ip数据库

纯真数据库收集了包括中国电信、中国移动、中国联通、长城宽带、聚友宽带等 ISP 的 IP 地址数据,包括网吧数据。希望能够通过大家的共同努力打造一个没有未知数据,没有错误数据的QQ IP。IP数据库每5天更新快速,请大家定期更新IP数据库!

因为IP地址数据是民间收集的,各运营商也会不时的更改IP段,所以有点遗漏、错误是难免的。随数据库附送IP解压,查询软件。假如发现IP地址有不对的,或想提供新的IP地址,请到纯真IP小秘书提供IP地址数据,或者登陆纯真时空论坛“电脑板块”中的“代理及IP板”块告诉我们。以便及时更新IP数据库。谢谢!

这个数据库目前2020.12月版本的含有52万+条ip数据。包括了0.0.0.0-255.255.255.255的所有ip,但是我们都知道ipv4地址总数是:2^32,大概42亿多条。所以,这里的52多万条只是一部分,它是按照ip地址段来区分的,可以这样来理解:把0.0.0.0-255.255.255.255想象成坐标轴上一个长度为42亿的区间,然后分成52万份(ip段),每一份属于一个地区。 它的作用就算是这个,通过ip知道此ip属于哪个地区(当然了,精度不是太高,不适合于精确定位。)

注意:
1.一个ip段是连续的,包括一个ip段起始地址和一个ip段结束地址。
2.纯真数据库对于国外的ip地址,只能定位到国家,对于国内的ip地址是可以有省市县的,毕竟主要收集的还是国内的地址。

在这里插入图片描述

在这里插入图片描述

关于ip定位的精度:

因为ip地址(发达国家就占据了大部分了)不够用,所以它实际上是动态分配的,所以一个人的ip一直都会变化的,但是在一段时间内是不变的(政府、学校、公司等机构会具有固定的ip,个人一般没有这个权力),所以定位精度不是很好,但是如果你在国内发现某个人的ip地址在国外,这并不是数据出了问题,而是因为他的ip地址基本就是在国外。但是,ip地址的分配记录是保存下来的,如果有法律权限是可以查看的,因此上网需要规范言行,互联网不是法外之地,举头三尺有神明!
在这里插入图片描述

如何使用
我们的ip地址必在0.0.0.0-255.255.255.255之间(这里不考虑ipv6的地址),所以只要判断某个ip是否在ipstart和ipend之间就行了(即一个ip段内),就知道它属于哪里了。

sql查询方式即为:

注:INET_ATON函数 会将一个有效的ip地址转成整数,所以不用自己手动转换ip地址了,直接传递点分式ip地址就行了。
在这里插入图片描述
但是不推荐这样使用,前面那种方式是直接将ip转为整数存入数据库,下面这种方式是直接存入ip的点分式,然后查询时转换。这样的话,速度非常慢,因为它相当于要对每一个ip地址做转换才能查询,这是错误的使用方式。
在这里插入图片描述

数据准备

首先,需要准备qqwry.dat这个文件,因为数据在它里面。但是,我发现了一个更好用的软件,这里就不直接使用这个文件了。
在这里插入图片描述

注意:鉴于网络上面资源下载容易踩坑,这里推荐到这个网站去下载,点击页面中的下载即可。如果有能力的话,也可以选择捐助作者。
在这里插入图片描述

下载完成之后,解压安装这个软件,就可以使用ip查询了。但是它是一个桌面软件,我们这里的目的是做成web服务,即像这个网站一样的查询服务。这样的ip查询服务有很多,但是如果使用量大的话,自己搭建一个还是非常好的,成本低、可靠!并且,这个数据库是一直在更新的,而且更新频率也还高,说明它的效果还是很好的。

在这里插入图片描述

测试一下,结果同上面的web页面
在这里插入图片描述
这里我们不直接使用这个软件,而是使用它的一个功能,在主界面上有一个解压按钮,点击将ip数据导出成文本(我这里命名成qqwry.txt)。

在这里插入图片描述
qqwry.txt的内容格式如下
在这里插入图片描述
因为这个qqwry.txt的格式,直接在数据库中查询不方便,所以需要处理一下,它目前的格式是:4列(ip段起始地址 ip段结束地址 area地区 location位置)。
如果直接将它们存入数据库中,那么查询就会变得麻烦,而且基本上也无法使用索引了,对于性能影响很大。这里我们采用网上别人的方式,将ip起始地址和ip结束地址转成数字。

所以需要先使用程序对上面的文本数据进行处理一下,我看别人的博客处理数据都是基于php的,但是我并不会世界上最好的语言,所以我就采用java来处理了。

注意:由于java语言没有无符号数的概念,所以ip地址并不能转成java的int型。ipv4地址是32位,int型也是32位,但是java的int中第一位是符号位,所以实际可以表示的大小是其它语言中int的一半。这也导致了,如果将ip地址转换为int的话,会有许多数据溢出,变成负数,所以这里需要将ip地址转成long型。

数据处理

数据处理代码

package re;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.stream.Collectors;

public class IPRecord {
	private static final String regex = "\\s+";
	private static final int limit = 4;   // split方法的这个参数的作用很值得深入学习一下
	private static final String ipRegex = "\\.";
	
	private static class Record {
		String ipStartStr;
		String ipEndStr;
		long ipStart;
		long ipEnd;
		String area;
		String location;
		
		public Record(String[] record) {
			this.ipStartStr = record[0];
			this.ipEndStr = record[1];
			this.ipStart = ip2Long(record[0]);   // 将ip地址字符串转换成long
			this.ipEnd = ip2Long(record[1]);     // 将ip地址字符串转换成long
			this.area = record[2];
			this.location = record[3];
		}
		
		@Override
		public String toString() {
			return ipStart + "," + ipEnd + "," + area + "," + location + "," + ipStartStr + "," + ipEndStr;  // 转成csv格式
		}
		
		public String toRedis() {
			return "ZADD\tqqwry\t" + ipEnd + "\t" + "{\"ipstart\":" + ipStart 
					+ ",\"ipend\":" + ipEnd
					+ ",\"area\":" + "\"" + area + "\""
					+ ",\"location\":" + "\"" + location + "\""
					+ ",\"ipstartstr\":" + "\"" + ipStartStr + "\""
					+ ",\"ipstartend\":" + "\"" + ipEndStr
					+ "\"}";
		}
	}
	
	public static void main(String[] args) throws IOException {
		// qqwry.tx 的文件路径
		Path path = Paths.get("C:/Users/Alfred/Desktop/data/test/qqwry.txt");
		// 使用指定字符集(UTF-8)读取文件
		List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
    //  输出测试,注意不要省略 limit,因为总共有 52万+行数据,直接输出的话,压力比较大。
//		lines.stream().map(IPRecord::split).limit(10).map(Record::toJson).forEach(System.out::println);
		// 将文件转为csv文件存放
		
		long start = System.currentTimeMillis();
		lines = lines.stream()
				     .map(IPRecord::split)
				     .map(Record::toString)
				     .limit(528129)     // 有效数据行数,剩下的是一些空行和最后的统计信息
				     .collect(Collectors.toList());
		
//	    String csvHeader = "ipstart,ipend, area, location, ipstartstr, ipendstr\n";
//		Files.write(Paths.get("C:/Users/Alfred/Desktop/data/test/qqwry.csv"), csvHeader.getBytes(), StandardOpenOption.CREATE);            //写入csv文件的头部
		Files.write(Paths.get("C:/Users/Alfred/Desktop/data/test/qqwry_redis.csv"), lines, StandardCharsets.UTF_8, StandardOpenOption.CREATE);  //写入csv文件的主体部分
		System.out.println("Execute Success! Time: " + (System.currentTimeMillis()-start) + " ms");
		
	}
	
	public static Record split(String record) {
		return new Record(record.split(regex, limit));
	}
	
	/**
	 * int类型是32位的,但是无法直接将ip和int类型进行转换,因为可能会溢出。
	 * int类型是有符号的,除非这里有无符号整型,但是Java是没有这个的,所以
	   *    这里需要转换成long型。
	 * */
	public static long ip2Long(String ip) {
		String[] strs = ip.split(ipRegex);
		int[] bs = new int[4];
		for (int i = 0; i < 4; i++) {
			bs[i] = Integer.parseInt(strs[i]);  // Byte.parseByte(strs[i]); 这个方法转成的字节范围是 -128-127,真是太坑了!
		}
		return ((bs[3] & 0xFFL)      ) +        // 这个转换方法是从jdk源码里面偷来的,挺好用的!
	               ((bs[2] & 0xFFL) <<  8) +
	               ((bs[1] & 0xFFL) << 16) +
	               ((bs[0] & 0xFFL) << 24);
	}
}


// 默认的split(regex)方法会以split(regex,0)调用
// 当limit N 大于0时,会匹配N-1次,数组长度最大为N,返回数组的最后一个元素会把数组最后的所有字符包括进来
// 当limit N 小于0时,会匹配尽可能多次,返回最大长度的数组
// 当limit N 等于0时,会匹配尽可能多次,返回最大长度的数组,并且末尾的空串不会包含进来


//推荐使用正则表达式,速度飞起
//String record = "0.0.0.0         0.255.255.255   IANA 保留地址";
//String regex = "\\s+";
//long start = System.currentTimeMillis();
//for (String s : record.split(regex)) {
//	System.out.println(s);
//}
//System.out.println(System.currentTimeMillis()-start);


//这种方式太慢了,简直难以忍受!
//List<String> strList = new ArrayList<>();
//boolean flag = true;
//StringBuilder sb = new StringBuilder();
//
//char[] chs = record.toCharArray();
//for (int i = 0; i < chs.length; i++) {
//	if (chs[i] != ' ') {
//		sb.append(chs[i]);
//		if (i == chs.length - 1) {
//			strList.add(sb.toString());
//			break;
//		}
//		flag = true;
//	} else {
//		if (flag) {
//			strList.add(sb.toString());
//			sb.delete(0, sb.length());
//		}
//		flag = false;
//	}
//}
//strList.forEach(System.out::println);

注意: toString方法是转成符合数据库导入格式的数据,toRedis是转成符号redis导入的格式(我不太确定这种方式的效率,并且我最终没有采用这种方法)。

说明:

1.这里的处理逻辑很简单,就是将文件分行读入内存,然后对每一行切分成4块,然后调用编写的ip2Long方法进行处理即可。
2.如果可以使用正则表达式,尽量用正则表达式,特别是这种数据处理,速度差距非常之大!
3.这个ip2Long方法,是我从jdk的ObjectInpustream里面拿来的,随便修改了一下,原来的代码使用的是byte,但是这里如果传入一个尝试把一个大于127的数转成int会抛出异常,所以使用int数组代替了byte数组,并且由于我这里只有32位,所以我就删除了后32位的处理代码。

执行结果:
这个notepad++打开大文件感觉还行,40多m也能流畅使用。我这里使用excel打开就比较慢了,而且中文乱码了,就不放截图了。如果使用excel打开的话和普通的excel文件显示没有什么区别。我这里转成csv的原因是:
a).csv文件本身就是文本格式,使用程序直接输出即可。
b).我使用的sqlyog社区版无法导入excel数据,只能导入csv格式的数据。

csv格式数据
在这里插入图片描述

redis格式的数据,直接就是一条命令,但是它总共是52万+的数据,想必一条一条的执行,时间上也会接受不了,但是这个给我了启发,我后来在redis中使用了这种格式来存储值(json数据)。

redis格式数据(没有使用)
在这里插入图片描述

数据入库

因为我使用的是sqlyog,数据导入有一点麻烦,如果使用其它的数据库客户端应该会简单一些。

建库建表

这个建表语句参考了别人的博客,做了一些修改(添加了原始的ip起始地址和结束地址、修改了表的字符编码)

CREATE TABLE `ip_data` (
`ipstart`  INT(10) UNSIGNED NOT NULL,                                                # 开始ip地址
`ipend`  INT(10) UNSIGNED NOT NULL,                                                  # 结束ip地址
`area`  VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,       # 区域
`location`  VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,  # 位置
`ipstartstr` VARCHAR(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,  # 开始ip地址的点分表示方式
`ipendstr` VARCHAR(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,    # 结束ip地址的点分表示方式
PRIMARY KEY (`ipstart`),
INDEX `ip` (`ipstart`, `ipend`) USING BTREE 
)
ENGINE=INNODB
DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_general_ci;

导入数据

我直接使用sqlyog的导入csv,但是没有成功。它的图形界面导入功能感觉就很鸡肋,我就没成功过,最后没办法,从网上查找到了如下命令,然后导入了csv文件,速度还行!

LOAD DATA LOCAL INFILE "C:/Users/Alfred/Desktop/data/test/qqwry.csv" 
INTO TABLE  `poem_land`.`qqwry_ip`  FIELDS TERMINATED BY ',' LINES TERMINATED BY '\r\n';

数据查询

数据行数量
在这里插入图片描述
简单查看几行数据
在这里插入图片描述
查询数据
在这里插入图片描述
好了,还算不错!

数据存入redis

关系型数据库的查询效果可能不够理想,这里使用redis这个nosql来提高查询性能,首先要做的还是数据导入。我发现这个数据导入真的是麻烦哎,昨天这个东西搞了我好久,当然也因为我对redis并不熟悉。
上面说了,我没有采用那种一条一条执行命令的方式,而是使用了一种更加快速的方式——redis的管道技术。网上看了好多资料才搞成,第一次听说这个东西,效果确实很好!这里我们要做的是使用redis的管道技术将mysql数据导入到redis。

导入数据命令

sudo mysql -uroot -p'password' poem_land --skip-column-names --raw < data.sql | ./redis-4.0.11/src/redis-cli -h host -p port -a password --pipe

参数:
password:mysql数据库密码、redis密码
host:redis所在主机
port:redis所用端口
data.sql:一个sql文件,执行它会将数据库数据转成适合于redis导入的形式。

data.sql 文件

注意: redis_member 就是存入redis的值,这里我采用数据库的CONCAT函数把所有列拼成了一个json字符串的形式,这样的话查询就可以获取到全部数据了,我是第一次这样用,不知道其它更好的方式(不过,导入时间却耗费了好几分钟,估计是拼接字符串影响了性能)。

SELECT CONCAT(
    '*4\r\n',
    '$',LENGTH(redis_cmd),'\r\n',redis_cmd,'\r\n',
    '$',LENGTH(redis_key),'\r\n',redis_key,'\r\n',
    '$',LENGTH(redis_score),'\r\n',redis_score,'\r\n',
    '$',LENGTH(redis_member),'\r\n',redis_member,'\r'
) FROM (
    SELECT 'ZADD' AS redis_cmd,
    'qqwry' AS redis_key,
    ipend AS redis_score,
    CONCAT('{"ipstart":', ipstart, ',"ipend":', ipend,
    ',"area":"', `area`, '","location":"', location, '"}') AS redis_member 
    FROM `poem_land`.`qqwry_ip`
) AS NAME

导入结果
在这里插入图片描述
查询数据
在这里插入图片描述

Springboot结合使用

我最终的目的,还是要提供一个查询的接口来使用。现在就将它作为服务提供了,让我们开始吧!

控制器代码

这里我使用的是mybatis_plus和它的生成器,所以代码是自动生成的,基本上只需要看这个Controller的代码就行了。

package org.dragon.ip_seek.controller;

import java.util.Map;
import java.util.Objects;
import java.util.Set;

import javax.servlet.http.HttpServletRequest;

import org.dragon.ip_seek.entity.QqwryIp;
import org.dragon.ip_seek.service.IQqwryIpService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;

/**
 * <p>
 *  前端控制器
 * </p>
 *
 * @author Alfred
 * @since 2020-12-08
 */
@RestController
@RequestMapping("/seeker/")
public class QqwryIpController {
	
	private static final Logger logger = LoggerFactory.getLogger(QqwryIpController.class);
	
	private static final String ipRegex = "\\.";   // 匹配ip的正则表达式
	
	@Autowired
	private IQqwryIpService iqqwryIpService;    // mysql 服务调用
	
	@Autowired
	private RedisTemplate<String, String> redisTemplate;  // redis 服务调用
	
	
	
	@GetMapping(value = "/poem1", produces = MediaType.APPLICATION_JSON_VALUE)
	public String test01(HttpServletRequest request) {
		
		// 本地局域网测试,ip地址是内网地址,无法使用,这里我先传递一个模拟的ip地址。
		String host = request.getRemoteHost();
		logger.info("当前用户的ip地址为:{}", host);
		
		Map<String, String> ipInfo = iqqwryIpService.getIpInfo("60.174.231.10");
		ipInfo.forEach((k, v) -> {
			logger.info("K:{} V:{}", k, v);
		});
		
		return "{\"poem\": \"一日不见兮,思之如狂。\"}";
	}
	
	@GetMapping(value = "/poem_mysql", produces = MediaType.APPLICATION_JSON_VALUE)
	public Map<String, Object> test02(HttpServletRequest request) {
		
		String host = request.getRemoteHost();		
		// 本地局域网测试,ip地址是内网地址,无法使用,这里我先传递一个模拟的ip地址。
		// 如果部署到云服务器上,去掉此行即可。
		host = "60.174.231.10";
		
		LambdaQueryWrapper<QqwryIp> wrapper = Wrappers.lambdaQuery();
		// 忘记了,eclipse自己的编译器不支持这个 SFunction接口
		wrapper.select(QqwryIp::getArea, QqwryIp::getLocation)
				.apply("INET_ATON({0}) between ipstart and ipend", host);

		Map<String, Object> info = iqqwryIpService.getMap(wrapper);
		Map<String, Object> ipInfoMap = new java.util.LinkedHashMap<>();   // 我想要指定map序列化的键值对顺序为:ip area location
		ipInfoMap.put("ip", host);
		if (Objects.nonNull(info)) {
			ipInfoMap.putAll(info);
		}
		logger.info("通过mysql查询 ip: {}", ipInfoMap.get("ip"));
		return ipInfoMap; // "{\"poem\": \"一日不见兮,思之如狂。\"}";
	}
	
	
	/**
	 * 采用 Redis 缓存的方式进行服务调用
	 * @param <T>
	 * */
	@GetMapping(value = "/poem_redis", produces = MediaType.APPLICATION_JSON_VALUE)
	public Map<String, Object> test03(HttpServletRequest request) {
		String host = request.getRemoteHost();
		host = "116.62.188.176";   // 本来想测试一个错误数据的:256.256.266.255,结果long都溢出了,直接变成了0
		// 从redis中查询数据
		Set<String> resultSet = redisTemplate.opsForZSet()
					 					     .rangeByScore("qqwry", ip2Long(host), Double.MAX_VALUE, 0, 1);
		logger.info("通过reids查询 ip: {}", host);
		return transfer(resultSet, host);
	}
	
	/**
	 * 对redis查询数据做一个转换,使之格式更加友好
	 * */
	@SuppressWarnings("unchecked")
	private Map<String, Object> transfer(Set<String> resultSet, String host) {
		Map<String, Object> ipInfoMap = new java.util.LinkedHashMap<>();   // 我想要指定map序列化的键值对顺序为:ip area location
		ipInfoMap.put("ip", host);
		
		if (resultSet.isEmpty()) {
			ipInfoMap.put("area", "NAN");
			ipInfoMap.put("location", "NAN");
			return ipInfoMap;
		}
		
		String result = resultSet.toArray(new String[0])[0];
		Map<String, Object> info = null;
		ObjectMapper mapper = new ObjectMapper();
		try {
			info = mapper.readValue(result, Map.class);  // 这里泛型会被擦除,直接赋值会报编译器警告,但是我也不知道Class<T>这种参数该如何传递了,就先抑制一下编译器警告吧
			if (Objects.nonNull(info)) {
				ipInfoMap.putAll(info);
			}
		} catch (JsonMappingException e) {
			e.printStackTrace();
		} catch (JsonProcessingException e) {
			e.printStackTrace();
		}
		
		return ipInfoMap;
	}
	
	
	private long ip2Long(String ip) {
		String[] strs = ip.split(ipRegex);
		int[] bs = new int[4];
		for (int i = 0; i < 4; i++) {
			bs[i] = Integer.parseInt(strs[i]);  // Byte.parseByte(strs[i]); 这个方法转成的字节范围是 -128-127,真是太坑了!
		}
		return ((bs[3] & 0xFFL)      ) +        // 这个转换方法是从jdk源码里面偷来的,挺好用的!
	               ((bs[2] & 0xFFL) <<  8) +
	               ((bs[1] & 0xFFL) << 16) +
	               ((bs[0] & 0xFFL) << 24);
	}
}

说明:

  1. test01是传统的mybatis的写法,后来发现mybatis_plus可以使用更加简单的方法,就换成了test02,但是保留了test01。
  2. test03是从redis中进行数据查询。

效果演示

启动项目
在这里插入图片描述
test01——mysql
在这里插入图片描述
这里查询的结果,我是使用在日志上了,本来是打算在后台使用的,但是暂时也没啥想法,就算了。
在这里插入图片描述
test02——mysql
在这里插入图片描述
test03——redis
在这里插入图片描述

注意:因为我本地测试,它获取的是本地的ip地址(127.0.0.1),所以我就在后台硬编码了一个公网ip地址来查询。


PS

我最近看到了一个基于java的开源软件——JMeter。它可以用来做压力测试,但是我并不会。但是,这并不妨碍我来玩一玩!

下面的内容仅供参考,我只是玩一玩!

步骤:
启动JMeter,然后创建一个线程组(1000个线程,50s,循环两次),添加一个Http请求,添加一个查看结果树,添加一个聚合报告,启动。

接口:poem_mysql
在这里插入图片描述
接口:poem_redis
在这里插入图片描述

说明
这里是两千个请求,从聚合报告里面看似乎redis确实性能是远远超过了mysql(我听别人说也是这样的),但是由于这个测试不严谨,我也不会JMeter,所以就不作为一个有效的参考了。

参数资源:

纯真数据库查询
查询网ip查询
通过管道传输快速将MySQL的数据导入Redis
使用redis有序集合搭建自有ip定位解析库
将mysql表数据批量导入redis zset结构中
IPLook解析纯真数据库,我没有使用这个软件,因为我是手动解析数据
mysql快速导入大量数据问题
一个简单的方式,但是有效率问题
ip location介绍
redis的简单配置
jackson json map object 之间互相转换

评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值