miniFTP项目实战四

项目简介:
在Linux环境下用C语言开发的Vsftpd的简化版本,拥有部分Vsftpd功能和相同的FTP协议,系统的主要架构采用多进程模型,每当有一个新的客户连接到达,主进程就会派生出一个ftp服务进程来为客户提供服务。同时每个ftp服务进程配套了nobody进程(内部私有进程),主要是为了做权限提升和控制。
实现功能:
除了基本的文件上传和下载功能,还实现模式选择、断点续传、限制连接数、空闲断开、限速等功能。
用到的技术:
socket、I/O复用、进程间通信、HashTable
欢迎技术交流q:2723808286
项目开源!!!

miniFTP项目实战一
miniFTP项目实战二
miniFTP项目实战三
miniFTP项目实战四
miniFTP项目实战五
miniFTP项目实战六

在这里插入图片描述

传输目录列表

在用户登录之后,客户端会与服务器协商,传输的文件类型以及传输的类型,之后再LIST申请目录列表:

在这里插入图片描述

文件的传输类型一般都是ASCII,传输模式需要根据有无NAT防火墙来选择,具体的确保在之前以及介绍过了。

4.1 PASV模式

服务器被动连接,由nobody进程创建监听socket,将创建好的监听socket传递给服务进程,服务进程返回服务器的IP地址及端口号,其处理逻辑如下:

static void do_pasv(session_t *sess)
{
    //由nobody进程创建监听套接字 ,并返回端口 服务进程中通过getlocalip获取IP地址
    priv_sock_send_cmd(sess->child_fd, PRIV_SOCK_PASV_LISTEN);
    unsigned short port = (int)priv_sock_get_int(sess->child_fd);  //获取端口号

    char  ip[16] =  {0};
    int ret = getlocalip(ip);
    if (ret == -1) printf("getlocalip filed\n");

    char tmp[1024] = {0};
    unsigned int v[4];
    sscanf(ip, "%u.%u.%u.%u", &v[0], &v[1], &v[2], &v[3]);
    sprintf(tmp, "Entering Passive Mode (%u,%u,%u,%u,%u,%u)", 
            v[0], v[1], v[2], v[3], port>>8, port&0xff);

    ftp_relply(sess, FTP_PASVOK, tmp);
}

由于getlocalip(ip)获取的是点分形式的IP地址,所以通过sscanf格式化获取IP地址,然后格式化到tmp字符串中返回给客户端。

4.2 PORT模式

PORT模式下,服务器主动连接客户端,客户端的命令参数中说明了客户端的IP地址以及端口号,服务进程解析IP地址及端口号,保存在sess中,后续nobody进程根据sess中的IP地址进行连接,处理逻辑如下:

static void do_port(session_t *sess)
{
    unsigned int v[6] = {0};

    //直接使用sscanf格式化输入,提取相关数据
    sscanf(sess->arg, "%u,%u,%u,%u,%u,%u", &v[2], &v[3], &v[4], &v[5], &v[0], &v[1]);
    sess->port_addr = (struct sockaddr_in*)malloc(sizeof(struct sockaddr_in));
    memset(sess->port_addr, 0, sizeof(struct sockaddr_in));

    sess->port_addr->sin_family = AF_INET;
    unsigned char *p = (unsigned char*)&sess->port_addr->sin_port;
    p[0] = v[0];
    p[1] = v[1];
    p = (unsigned char*)&sess->port_addr->sin_addr;
    p[0] = v[2];
    p[1] = v[3];
    p[2] = v[4];
    p[3] = v[5];

    //收到PORT后要回复
    ftp_relply(sess, FTP_PORTOK, "PORT command sucessful. COnsider using PASV.");
    //接下来客户端会发送LIST命令
}

4.3 LIST 命令中创建数据连接

PASV和PORT已经确定了双方的连接方式,LIST命令是传输当前目录的文件列表,首先应该创建数据传输通道!

数据传输通道创建完成之后,读取目录列表信息,经过提取信息后传输目录列表,最后关闭数据传输通道,回应226

在数据传输完成之后要及时关闭数据传输通道,即socket,因为客户端处于一直接收的状态,只有服务器关闭socket客户端才会停止接收,作出反应,逻辑如下:

static void do_list(session_t *sess)
{
    //获取数据传输通道的fd
    if (get_transfer_fd(sess) == 0) {
        return ;
    }
    //回应150
    ftp_relply(sess, FTP_DATACONN, "Here comes the directory listing.");
    //传输列表
    list_common(sess, 1);  //全部 
    //关闭数据通道 如果不及时关闭通道 客户端是不会接收停止的,即关闭之后客户端才会作出反应
    close(sess->data_fd);
    //回应226
    ftp_relply(sess, FTP_TRANSFEROK, "Directory send ok.");
}

其中最重要的有两部分:

  • 数据传输通道的创建,即nobody通过socket与客户端建立连接
  • 传输目录列表,要读取当前目录的信息,并且获取文件的信息发送

4.4 数据传输通道的建立

会根据PORT还是PASV来创建数据传输通道,所以在连接之前首先判断是PORT模式还是PASV模式,处理逻辑如下:

/* 
 * 根据模式的不同建立数据连接通道
 * PORT:主动连接客户端
 * PASV:被动接受客户端连接
 *  */
int get_transfer_fd(session_t *sess)
{
    int ret = 1;
    //检测 PORT or PASV 是否都没有激活
    if (!port_active(sess) && !pasv_active(sess)) {
        ftp_relply(sess, FTP_BADSENDCONN, "Use PORT or PASV first.");
        return 0;
    }

    //主动模式服务器绑定20端口 创建socket主动connect客户端,调用sysutil.c中实现的tcp_client
    if (port_active(sess)) {
        if (get_port_fd(sess) == 0) { //失败
            ret = 0;
        }
    }
    if (pasv_active(sess)) {
        if (get_pasv_fd(sess) == 0) {  //获取到之后就保存在sess->data_fd
            ret = 0;
        }
        //监听socket作用就是 为数据连接通道做准备,每次数据连接完成之后都会断开,下一次重新连接
        close(sess->pasv_listen_fd);   
    }
    //malloc的地址已经没有利用价值了,以及绑定好啦
    if (sess->port_addr != NULL) {
        free(sess->port_addr);
        sess->port_addr = NULL;
    }
    if (ret) start_data_alarm();   //在数据传输之前重新安装信号 并启动闹钟

    return ret;
}

4.5 PORT模式下数据传输通道的创建

//获取PORT模式下数据传输通道的fd
int get_port_fd(session_t *sess)
{
    //由nobody进程创建数据连接通道,服务进程向nobody发起一个PRIV_SOCK_GET_DATA_SOCK创建数据通道请求
    priv_sock_send_cmd(sess->child_fd, PRIV_SOCK_GET_DATA_SOCK);
    //然后向nobody发送一个int port
    unsigned short port = ntohs(sess->port_addr->sin_port);
    priv_sock_send_int(sess->child_fd, (int)port);  //发送实际是short 强转为int
    //然后向nobody发送IP地址 字符串
    char *ip = inet_ntoa(sess->port_addr->sin_addr); 
    priv_sock_send_buf(sess->child_fd, ip, strlen(ip));

    //接收应答判断
    int res = priv_sock_get_result(sess->child_fd);
    if (res == PRIV_SOCK_RESULT_BAD) {
        printf("create data filed\n");
        return 0;
    } else if (res == PRIV_SOCK_RESULT_OK) {
        sess->data_fd = priv_sock_recv_fd(sess->child_fd);  //接收数据传输通道sock fd
    }

    return 1;
}

服务进程向nobody进程发送PRIV_SOCK_GET_DATA_SOCK,请求nobody进程建立数据传输通道,接着向nobody进程发送客户端的IP地址与端口号:

void privop_pasv_get_data_sock(session_t *sess)
{
    //接收IP地址与端口
    unsigned short port = priv_sock_get_int(sess->parent_fd);
    char ip[16] = {0};
    priv_sock_recv_buf(sess->parent_fd, ip, sizeof(ip));

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);   //转换为网络字节序
    addr.sin_addr.s_addr = inet_addr(ip);

    int fd = tcp_client(20); //绑定20端口
    if (fd == -1) {
        priv_sock_send_result(sess->parent_fd, PRIV_SOCK_RESULT_BAD);
        return ;
    }

    //建立数据连接
    if (connect_timeout(fd, &addr, tunable_connect_timeout) < 0) {
        priv_sock_send_result(sess->parent_fd, PRIV_SOCK_RESULT_BAD);
        return ;
    }

    //传递文件描述符
    priv_sock_send_result(sess->parent_fd, PRIV_SOCK_RESULT_OK);
    priv_sock_send_fd(sess->parent_fd, fd);
    close(fd);
}

4.6PASV模式下数据传输通道的创建

int get_pasv_fd(session_t *sess) 
{
    //请求PASV 连接socket fd
    priv_sock_send_cmd(sess->child_fd, PRIV_SOCK_PASV_ACCEPT);
    char ret = priv_sock_get_result(sess->child_fd);
    if (ret == PRIV_SOCK_RESULT_BAD) {
        return 0;
    } else if (ret == PRIV_SOCK_RESULT_OK) {
        sess->data_fd = priv_sock_recv_fd(sess->child_fd);
    }

    return 1;
}

PASV模式下,服务进程向nobody进程发送PRIV_SOCK_PASV_ACCEPT,nobody通过accept_timeout等待客户端的连接请求,当连接建立之后,nobody向服务进程传递数据传输通道的文件描述符,nobody进程的响应如下:

//服务进程请求数据连接socket的时候会向nobody发送accept请求
void privop_pasv_accept(session_t *sess)
{
    int data_fd = accept_timeout(sess->pasv_listen_fd, NULL, tunable_accept_timeout);
    close(sess->pasv_listen_fd);
    sess->pasv_listen_fd = -1;
    if (data_fd == -1) {
        priv_sock_send_result(sess->parent_fd, PRIV_SOCK_RESULT_BAD);
        return ;
    } else {
        priv_sock_send_result(sess->parent_fd, PRIV_SOCK_RESULT_OK);
        priv_sock_send_fd(sess->parent_fd, data_fd);
        close(data_fd);  //nobody进程不进行数据传输 断开
    }
}

4.7目录的传输

无论PORT模式的数据传输,还是PASV模式的数据传输,对服务进程都一样!!!因为服务进程最终拿到的是数据传输通道的socket文件描述符,服务进程可以通过这个socket文件描述符向客户端发送数据。

在处理LIST命令的时候逻辑如下:

static void do_list(session_t *sess)
{
    //获取数据传输通道的fd
    if (get_transfer_fd(sess) == 0) {
        return ;
    }
    //回应150
    ftp_relply(sess, FTP_DATACONN, "Here comes the directory listing.");
    //传输列表
    list_common(sess, 1);  //全部 
    //关闭数据通道 如果不及时关闭通道 客户端是不会接收停止的,即关闭之后客户端才会作出反应
    close(sess->data_fd);
    //回应226
    ftp_relply(sess, FTP_TRANSFEROK, "Directory send ok.");
}

上面已经介绍了get_transfer_fd是如何创建数据传输通道的,下面就说一下list_common如何传输列表信息。

需要传输的列表信息有:

  • 文件类型以及权限
  • 文件连接数、uid、gid、大小
  • 文件日期,分为两种格式
  • 文件名,注意符号链接文件还要显式支持原文件名字

如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VkMvU1PV-1613482997348)(F:\destop\m笔记\图\image-20210216181605784.png)]

打开当前目录

首先要打开当前目录,通过opendir函数可以打开目录,其函数原型如下:

DIR *opendir(const char *name);
//The  opendir()  function  opens  a  directory  stream corresponding to the directory name, and returns a
//pointer to the directory stream.  The stream is positioned at the first entry in the directory.
//即根据路径打开一个目录,返回一个目录流指针,指针指向目录流中的第一个项目

然后通过readdir返回目录流所指向文件的信息,readdir函数原型如下:

struct dirent *readdir(DIR *dirp);
struct dirent {
    ino_t          d_ino;       /* Inode number */
    off_t          d_off;       /* Not an offset; see below */
    unsigned short d_reclen;    /* Length of this record */
    unsigned char  d_type;      /* Type of file; not supported by all filesystem types */
    char           d_name[256]; /* Null-terminated filename */
};

结构体中我们只需要关注d_name,即关注文件的名字,通过文件的名字可以获取文件的状态信息,stat函数原型如下:

int stat(const char *pathname, struct stat *statbuf);
int fstat(int fd, struct stat *statbuf);
int lstat(const char *pathname, struct stat *statbuf);

struct stat {
    dev_t     st_dev;         /* ID of device containing file */
    ino_t     st_ino;         /* Inode number */
    mode_t    st_mode;        /* File type and mode */
    nlink_t   st_nlink;       /* Number of hard links */
    uid_t     st_uid;         /* User ID of owner */
    gid_t     st_gid;         /* Group ID of owner */
    dev_t     st_rdev;        /* Device ID (if special file) */
    off_t     st_size;        /* Total size, in bytes */
    blksize_t st_blksize;     /* Block size for filesystem I/O */
    blkcnt_t  st_blocks;      /* Number of 512B blocks allocated */

    /* Since Linux 2.6, the kernel supports nanosecond
                  precision for the following timestamp fields.
                  For the details before Linux 2.6, see NOTES. */

    struct timespec st_atim;  /* Time of last access */
    struct timespec st_mtim;  /* Time of last modification */
    struct timespec st_ctim;  /* Time of last status change */

    #define st_atime st_atim.tv_sec      /* Backward compatibility */
    #define st_mtime st_mtim.tv_sec
    #define st_ctime st_ctim.tv_sec
};

主要就是通过stat结构体来获取要发送的信息!!!

  • st_mode:保存文件类型及权限位,以及可以获取符号链接文件指向的源文件
  • st_size:文件大小
  • st_mtime:文件最后的修改时间

下面一一介绍:

获取文件类型及权限位

通过lstat函数获取文件的状态信息,然后根据statbuf.st_mode判断文件的类型与权限位,通过与宏定义相与的结果来判断。

得到的结果用一个数组来容纳,最后将每个部分的信息格式化到一个字符串中,发送字符串,获取文件类型与权限位的代码如下:

char perms[] = "----------"; //获取文件类型以及权限位 十个字符
mode_t mode = statbuf.st_mode;  //statbuf.st_mode 中保存文件类型以及权限位
switch (mode & S_IFMT) {
    case S_IFREG:perms[0] = '-'; break;
    case S_IFDIR:perms[0] = 'd'; break;
    case S_IFBLK:perms[0] = 'b'; break;
    case S_IFLNK:perms[0] = 'l'; break;
    case S_IFCHR:perms[0] = 'c'; break;
    case S_IFSOCK:perms[0] = 's';break;
    case S_IFIFO:perms[0] = 'p'; break;
    default:break;
}
if (mode & S_IRUSR) perms[1] = 'r';
if (mode & S_IWUSR) perms[2] = 'w';
if (mode & S_IXUSR) perms[3] = 'x';
if (mode & S_IRGRP) perms[4] = 'r';
if (mode & S_IWGRP) perms[5] = 'w';
if (mode & S_IXGRP) perms[6] = 'x';
if (mode & S_IROTH)  perms[7] = 'r';
if (mode & S_IWOTH) perms[8] = 'w';
if (mode & S_IXOTH) perms[9] = 'x';
// special perms
if (mode & S_ISUID) perms[3] = (perms[3] == 'x' ? 's' : 'S');
if (mode & S_ISGID) perms[6] = (perms[6] == 'x' ? 's' : 'S');
if (mode & S_ISVTX) perms[9] = (perms[9] == 'x' ? 's' : 'S');

获取连接数、uid、gid、文件大小

都是通过stat结构体直接获取:

char buf[1024] = {0};  //每次都要重新初始化
off = 0;
off += sprintf(buf, "%s ", perms);  //添加文件类型 权限位
off += sprintf(buf + off, "%3ld %-8d %-8d ", statbuf.st_nlink, statbuf.st_uid, statbuf.st_gid); //连接数、uid、gid
off += sprintf(buf + off, "%8lu ", (unsigned long)statbuf.st_size);  //添加文件大小

所有文件的信息都放在buf数组中,根据off来决定下一中属性存放的位置。

获取时间

先分析一下FTP中日期的格式:

drwxr-xr-x    3 1000     1000         4096 Feb 02 11:37 Desktop
drwxr-xr-x    2 1000     1000         4096 Mar 21  2020 Documents
drwxr-xr-x    2 1000     1000         4096 Mar 21  2020 Downloads
drwxr-xr-x    2 1000     1000         4096 Mar 21  2020 Music
drwxr-xr-x    2 1000     1000         4096 Mar 21  2020 Pictures
drwxr-xr-x    2 1000     1000         4096 Mar 21  2020 Public
drwxr-xr-x    2 1000     1000         4096 Mar 21  2020 Templates
drwxr-xr-x    2 1000     1000         4096 Mar 21  2020 Videos
-rw-r--r--    1 1000     1000         8980 Mar 21  2020 examples.desktop
drwxrwxr-x    9 1000     1000         4096 Feb 06 10:04 learn
-rw-r--r--    1 1000     1000         2193 Mar 28  2020 vimrc

日期分为两种格式:

//如果文件时间新
drwxr-xr-x    3 1000     1000         4096 Feb 02 11:37 Desktop
//如果文件时间旧 或者是半年之前的文件
drwxr-xr-x    2 1000     1000         4096 Mar 21  2020 Documents

首先要获取当前系统的时间,和文件最后一次修改时间进行比较,判断文件的格式

获取当前时间可以通过gettimeofday:

int gettimeofday(struct timeval *tv, struct timezone *tz); //tz为NULL表示当前系统时区

The functions gettimeofday() and settimeofday() can get and set the time as well as a timezone.  The tv argument is a struct timeval (as specified in <sys/time.h>):
           struct timeval {
               time_t      tv_sec;     /* seconds */
               suseconds_t tv_usec;    /* microseconds */
           };

and gives the number of seconds and microseconds since the Epoch (see time(2)).  The tz  argument  is  a  struct timezone:
           struct timezone {
               int tz_minuteswest;     /* minutes west of Greenwich */
               int tz_dsttime;         /* type of DST correction */
           };

然后和stat中的struct timespec st_atim; /* Time of last access */比较

如果文件时间比系统时间大,系统文件比文件时间早半年 表示文件是旧的,采用如下格式:

drwxr-xr-x    2 1000     1000         4096 Mar 21  2020 Documents
p_date_format =%b %e   %Y”;

通过调用size_t strftime(char *s, size_t max, const char *format, const struct tm *tm);格式化时间,但是需要一个struct tm *tm,需要将秒转换为结构体的形式,通过localtime:

size_t strftime(char *s, size_t max, const char *format, const struct tm *tm);  //格式化时间
struct tm *localtime(const time_t *timep);  //将秒 转换为struct tm

代码如下:

//获取时间
char date_buf[64] = {0};
const char *p_date_format =  "%b %e %H:%M";
struct timeval tv;
gettimeofday(&tv, NULL);
time_t local_time = tv.tv_sec;
if (statbuf.st_mtime > local_time || (local_time - statbuf.st_mtime) > 60*60*24*182)
    p_date_format = "%b %e  %Y";
struct tm *p_tm = localtime(&local_time);
strftime(date_buf, sizeof(date_buf), p_date_format, p_tm); //将时间按照对应格式格式化为字符串
off += sprintf(buf + off, "%s ", date_buf);

获取文件名

文件名的获取时要注意,符号链接文件要显式出与指向文件的关系,使用readlink函数获取实际指向的文件,将指向的文件保存在buf中。

所以符号链接文件和一般文件要分开获取文件名:

//获取文件名  符号链接文件要显式指向源文件
if (S_ISLNK(statbuf.st_mode)) {
    char tmp[1024] = {0};
    readlink(dt->d_name, tmp, sizeof(tmp));
    sprintf(buf + off, "%s -> %s\r\n", dt->d_name, tmp);
} else {
    sprintf(buf + off, "%s\r\n", dt->d_name);
}

整体融合

在获取到各个部分的信息之后,整合在buf中,通过writen发送给客户端,如下:

int list_common(session_t *sess, int detail)
{
    DIR *dir = opendir("./");  //打开当前目录
    struct dirent *dt;    //从目录中获取文件
    struct stat statbuf;  //获取文件信息
    int off = 0;          //在整合的时候记录位置

    if (dir == NULL)    return 0;
    //根据readdir遍历目录 使用lstat获取文件状态信息 
    //这里使用lstat,就是在符号链接文件的情况 查看链接文件的状态,而不是产看源文件

    if (detail == 1) {
        while ((dt = readdir(dir)) != NULL) {
            if (lstat(dt->d_name, &statbuf) < 0 || dt->d_name[0] == '.') { //获取文件状态信息 
                continue;
            }
            
            char perms[] = "----------"; //获取文件类型以及权限位 十个字符
            mode_t mode = statbuf.st_mode;  //statbuf.st_mode 中保存文件类型以及权限位
            switch (mode & S_IFMT) {
                case S_IFREG:perms[0] = '-'; break;
                case S_IFDIR:perms[0] = 'd'; break;
                case S_IFBLK:perms[0] = 'b'; break;
                case S_IFLNK:perms[0] = 'l'; break;
                case S_IFCHR:perms[0] = 'c'; break;
                case S_IFSOCK:perms[0] = 's';break;
                case S_IFIFO:perms[0] = 'p'; break;
                default:break;
            }
            if (mode & S_IRUSR) perms[1] = 'r';
            if (mode & S_IWUSR) perms[2] = 'w';
            if (mode & S_IXUSR) perms[3] = 'x';
            if (mode & S_IRGRP) perms[4] = 'r';
            if (mode & S_IWGRP) perms[5] = 'w';
            if (mode & S_IXGRP) perms[6] = 'x';
            if (mode & S_IROTH)  perms[7] = 'r';
            if (mode & S_IWOTH) perms[8] = 'w';
            if (mode & S_IXOTH) perms[9] = 'x';
            // special perms
            if (mode & S_ISUID) perms[3] = (perms[3] == 'x' ? 's' : 'S');
            if (mode & S_ISGID) perms[6] = (perms[6] == 'x' ? 's' : 'S');
            if (mode & S_ISVTX) perms[9] = (perms[9] == 'x' ? 's' : 'S');

            char buf[1024] = {0};  //每次都要重新初始化
            off = 0;
            off += sprintf(buf, "%s ", perms);  //添加文件类型 权限位
            off += sprintf(buf + off, "%3ld %-8d %-8d ", statbuf.st_nlink, statbuf.st_uid, statbuf.st_gid);  //左对齐 添加连接数、uid、gid
            off += sprintf(buf + off, "%8lu ", (unsigned long)statbuf.st_size);  //添加文件大小
            //获取时间
            char date_buf[64] = {0};
            const char *p_date_format =  "%b %e %H:%M";
            struct timeval tv;
            gettimeofday(&tv, NULL);
            time_t local_time = tv.tv_sec;
            if (statbuf.st_mtime > local_time || (local_time - statbuf.st_mtime) > 60*60*24*182)
                p_date_format = "%b %e  %Y";
            struct tm *p_tm = localtime(&local_time);
            strftime(date_buf, sizeof(date_buf), p_date_format, p_tm); //将时间按照对应格式格式化为字符串
            off += sprintf(buf + off, "%s ", date_buf);
            //获取文件名  符号链接文件要显式指向源文件
            if (S_ISLNK(statbuf.st_mode)) {
                char tmp[1024] = {0};
                readlink(dt->d_name, tmp, sizeof(tmp));
                sprintf(buf + off, "%s -> %s\r\n", dt->d_name, tmp);
            } else {
                sprintf(buf + off, "%s\r\n", dt->d_name);
            }
            //通过sess中的数据通道socket发送
            writen(sess->data_fd, buf, strlen(buf));
        }

    } else {
        while ((dt = readdir(dir)) != NULL) {
            if (lstat(dt->d_name, &statbuf) < 0 || dt->d_name[0] == '.') { //获取文件状态信息 
                continue;
            }
            char buf[1024] = {0};  //每次都要重新初始化
            //获取文件名  符号链接文件要显式指向源文件
            if (S_ISLNK(statbuf.st_mode)) {
                char tmp[1024] = {0};
                readlink(dt->d_name, tmp, sizeof(tmp));
                sprintf(buf, "%s -> %s\r\n", dt->d_name, tmp);
            } else {
                sprintf(buf, "%s\r\n", dt->d_name);
            }
            //通过sess中的数据通道socket发送
            writen(sess->data_fd, buf, strlen(buf));
        }
    }
    closedir(dir);
    return 1;
}

其中detail参数表示是否发送文件的详细信息。

posted @ 2021-02-16 22:07  Aspirant-GQ  阅读(70)  评论(0编辑  收藏  举报