文档章节

APIJSON, 让接口和文档见鬼去吧!

孤独的探索号
 孤独的探索号
发布于 2016/12/13 23:07
字数 3298
阅读 3.7W
收藏 8

阿里云携手百名商业领袖、技术大咖,带您一探行进中的数字新基建!>>>

我:

APIJSON,让接口和文档见鬼去吧!

https://github.com/TommyLemon/APIJSON

服务端:

什么鬼?

客户端:

APIJSON是啥?

我:

APIJSON是一种为API而生的JSON网络传输协议。
为 简单的增删改查、复杂的查询、简单的事务操作 提供了完全自动化的API。
能大幅降低开发和沟通成本,简化开发流程,缩短开发周期。
适合中小型前后端分离的项目,尤其是互联网创业项目。

通过自动化API,前端可以定制任何数据、任何结构!
大部分HTTP请求后端再也不用写接口了,更不用写文档了!
前端再也不用和后端沟通接口或文档问题了!再也不会被文档各种错误坑了!
后端再也不用为了兼容旧接口写新版接口和文档了!再也不会被前端随时随地没完没了地烦了!

特点功能

在线解析

  • 自动生成文档,清晰可读永远最新
  • 自动生成请求代码,支持Android和iOS
  • 自动生成所有JavaBean,一键下载
  • 自动管理测试用例,一键共享
  • 自动校验与格式化JSON,支持高亮和收展

对于前端

  • 不用再向后端催接口、求文档
  • 数据和结构完全定制,要啥有啥
  • 看请求知结果,所求即所得
  • 可一次获取任何数据、任何结构
  • 能去除重复数据,节省流量提高速度

对于后端

  • 提供通用接口,大部分API不用再写
  • 自动生成文档,不用再编写和维护
  • 自动校验权限、自动管理版本
  • 查询API无需划分版本,始终保持兼容
  • 支持增删改查、模糊搜索、正则匹配、远程函数等


 

客户端:

这个APIJSON有这么好?怎么做到的?

我:

举个栗子(查询类似微信朋友圈的动态列表):

请求:

{
    "[]": {                               //请求一个array
        "page": 0,                        //array条件
        "count": 2,        
        "User": {                         //请求查询名为User的table,返回名为User的JSONObject
            "sex": 0                      //object条件
        },
        "Moment": {
            "userId@": “/User/id”         //缺省依赖路径,从同级object的路径开始
        },
        "Comment[]": {                    //请求一个名为Comment的array 
            "page": 0,
            "count": 2,
            "Comment": {
                 "momentId@": “[]/Moment/id”  //完整依赖路径
             }
        }
    }
}

点击这里测试

返回:

{
    "[]":[
        {
            "User":{
                "id":38710,
                "sex":0,
                "phone":"1300038710",
                "name":"Name-38710",
                "head":"http://static.oschina.net/uploads/user/1218/2437072_100.jpg?t=1461076033000"
            },
            "Moment":{
                "id":470,
                "title":"Title-470",
                "content":"This is a Content...-470",
                "userId":38710,
                "pictureList":["http://static.oschina.net/uploads/user/585/1170143_50.jpg?t=1390226446000"]
            },
            "Comment[]":[
                {
                    "Comment":{
                        "id":4,
                        "parentId":0,
                        "momentId":470,
                        "userId":310,
                        "targetUserId":14604,
                        "content":"This is a Content...-4",
                        "targetUserName":"targetUserName-14604",
                        "userName":"userName-93781"
                    }
                },
                {
                    "Comment":{
                        "id":22,
                        "parentId":221,
                        "momentId":470,
                        "userId":332,
                        "targetUserId":5904,
                        "content":"This is a Content...-22",
                        "targetUserName":"targetUserName-5904",
                        "userName":"userName-11679"
                    }
                }
            ]
        },
        {
            "User":{
                "id":70793,
                "sex":0,
                "phone":"1300070793",
                "name":"Name-70793",
                "head":"http://static.oschina.net/uploads/user/1174/2348263_50.png?t=1439773471000"
            },
            "Moment":{
                "id":170,
                "title":"Title-73",
                "content":"This is a Content...-73",
                "userId":70793,
                "pictureList":["http://my.oschina.net/img/portrait.gif?t=1451961935000"]
            },
            "Comment[]":[
                {
                    "Comment":{
                        "id":44,
                        "parentId":0,
                        "momentId":170,
                        "userId":7073,
                        "targetUserId":6378,
                        "content":"This is a Content...-44",
                        "targetUserName":"targetUserName-6378",
                        "userName":"userName-88645"
                    }
                },
                {
                    "Comment":{
                        "id":54,
                        "parentId":0,
                        "momentId":170,
                        "userId":3,
                        "targetUserId":62122,
                        "content":"This is a Content...-54",
                        "targetUserName":"targetUserName-62122",
                        "userName":"userName-82381"
                    }
                }
            ]
        }
    ]
}

  

客户端:

确实是一目了然,不用看文档了啊!

我被文档坑过很多次了都,文档多写或少写了一个字段,字段写错或多个空格,或者字段类型写错,都不知道浪费了多少调试和沟通时间!

有时候上头用app出了问题把我们叫过去,调试半天才发现原来是服务端改了接口!而且并没有及时通知我们!

有一次上头纠结要不要把单层评论改成QQ微信那种多级评论,自己按照以前的接口写了demo演示给上头看,上头很满意决定实现需求,结果后端都没和我商量自己改了接口返回的json结构,导致我这边不得不重构解析代码,真是醉了!

我:

用APIJSON就可以自己按需定制返回的JSON结构,没有接口,没有文档,就不会被文档坑了,也不会有你说的后端拍脑袋定JSON结构导致的客户端重构问题了哈哈!

客户端:

Nice!

服务端:

部分接口需要currentUserId和loginPassword的,你怎么搞?

我:

直接在最外层传,例如:

{
    "currentUserId":100,
    "loginPassword":1234,
    "User":{
        "id":1
    }
}

 

服务端:

返回的状态码和提示信息放哪?

我:

也是在最外层,例如对以上请求的返回结果:

{
    "status":200,
    "message":"success",
    "User":{
        "id":"1",
        "sex":"0",
        "phone":"1234567890",
        "name":"Tommy",
        "head":"http://static.oschina.net/uploads/user/1218/2437072_100.jpg?t=1461076033000"
    }
}

 

客户端:

一次请求任意结构任意数据,方便灵活,不需要专门接口或多次请求?

以前我做了一个界面,上半部分是用户的信息,下半部分是他最近的动态,最多显示3个,类似于微信的详细资料。

我需要分别请求两次:

User:

http://www.aaa.com/get/user?id=100

Moment列表:

http://www.aaa.com/get/moment/list?page=0&count=3&userId=100

现在是不是可以这样:

User和Moment列表:

http://www.aaa.com/get/{"User":{"id":100}, "[]":{"page":0, "count":3, "Moment":{"userId":100}}}

 

我:

对的,就是这样。

客户端:

好的。那重复数据怎么去除呢?

我:

比如QQ空间,里面有一个动态列表,每条动态里都有User和对应的动态内容Moment。

如果你进入一个人的空间,那就都是他的动态。

用传统的方式返回的列表数据里,每条动态里都包含同样的User,造成了数据重复:

请求:

http://www.aaa.com/get/moment/list?page=0&count=5&userId=100

返回:

{
    "status":200,
    "message":"success",
    "data":[
        {
            "id":1,
            "content":"xxx1",
            ...,
            "User":{
                "id":100,
                "name":"Tommy",
                ...
            }
        },
        {
            "id":2,
            "content":"xxx2",
            ...,
            "User":{
                "id":100,
                "name":"Tommy",
                ...
            }
        },
        ...
    ]
}

有5条重复的User。

而使用APIJSON可以这样省去4个重复User:

请求:

http://www.aaa.com/get/{"User":{"id":100}, "[]":{"page":0, "count":5, "Moment":{"userId":100}}}

返回:

{
    "status":200,
    "message":"success",
    "User":{
        "id":100,
        "name":"Tommy",
        ...
    },
    "[]":[
        {
            "Moment":{
                "id":1,
                "content":"xxx1",
                ...
            }
        },
        {
            "Moment":{
                "id":2,
                "content":"xxx2",
                ...
            }
        },
        ...
    ]
}

 

如果之前已经获取到这个User了,还可以这样省去所有重复User:

请求:

http://www.aaa.com/get/{"[]":{"page":0, "count":5, "Moment":{"userId":100}}}

返回:

{
    "status":200,
    "message":"success",
    "[]":[
        {
            "Moment":{
                "id":1,
                "content":"xxx1",
                ...
            }
        },
        {
            "Moment":{
                "id":2,
                "content":"xxx2",
                ...
            }
        },
        ...
    ]
}

 

客户端:

传统方式也可以服务端在接口增加一个返回格式字段,根据这个字段来决定是否去除重复User啊

我:

确实,不过这会导致以下问题:

1.服务端要新增字段和判断字段来返回不同数据的代码。

2.服务端要在文档里增加相关说明。

3.这个功能不一定用得上,因为客户端的UI需求往往很容易变化,导致缺少使用这个功能的条件,为了一两个版本去让服务端和客户端都折腾不值得。

而使用APIJSON就没这些问题,因为根本不需要接口或文档!而且是否去重只看客户端的意愿,服务端什么都不用做。

客户端:

这样啊,赞!

哦对了,APIJSON相比传统方式有没有缺少的功能啊?

我:

传统方式能做的APIJSON都能做。

客户端和服务端的http json交互:
客户端 - 封装request -> 服务端 - 解析request - 生成response -> 客户端 - 解析response

传统方式request:

base_url/lowercase_table_name?key0=value0&key1=value1...

 

APIJSON request:

base_url/{TableName:{key0:value0, key1:value1...}}

 

TableName对应lowercase_table_name,key:value对应key=value,都是严格对应的,所以传统方式request里包含的信息APIJSON request一样可以包含,传统方式能实现的功能APIJSON肯定也都能实现。

客户端:

好的

服务端:

APIJSON怎么保证服务端返回给不同版本客户端的数据一致?

比如我上一个版本一个接口返回的值是a,现在这个版本要对所有版本客户端返回a+b,用传统方法只需要服务端把这个接口的返回值改下就好了,接口和客户端都不用改。

用APIJSON不就会导致对有些版本返回的是a,有些是a+b,这样就不能统一了?

我:

APIJSON对请求的解析和响应的操作都是在服务端完成的,对应的是APIJSON(Server)里的project。

服务端可以拦截到相关请求,比如请求a的值,把原本返回的a改成a+b就能保证对所有版本客户端返回a+b。也不需要客户端改代码,至于接口就更不用管了,因为根本没有接口。

服务端:

那我要不一致呢?给不同版本客户端返回不同的值。

我:

首先这种需求是极少的,比如降低电影票的价格,你不能让新版客户端里降价了,上个版本还是原价吧?

真有这种需求也可以通过客户端在请求里发送下版本号version,服务端根据版本号返回不同的值。

服务端:

也是啊。那用APIJSON怎么做权限处理?有些数据是要相关权限才能操作的。比如登录账号需要登录权限,付款需要支付权限。

我:

(更新:已支持自动化权限管理,粒度还能细分到 每种角色、每张表、每条记录、每种操作 这种级别。)

服务端获取到客户端的请求request后,在操作对应的table前用一个权限验证类去验证是否有操作权限,通过后才允许操作,否则返回错误信息。

权限验证类可以是这样的:(更新:已支持自动化权限管理,无需再写)

package zuo.biao.apijson.server.sql;

import java.rmi.AccessException;

import com.alibaba.fastjson.JSONObject;

import zuo.biao.apijson.StringUtil;

/**权限验证类
 * @author Lemon
 */
public class AccessVerifyer {
    private static final String TAG = "AccessVerifyer: ";

    private static final int ACCESS_LOGIN = 1;
    private static final int ACCESS_PAY = 2;

    public static final String KEY_CURRENT_USER_ID = "currentUserId";
    public static final String KEY_LOGIN_PASSWORD = "loginPassword";
    public static final String KEY_PAY_PASSWORD = "payPassword";

    public static final String[] LOGIN_ACCESS_TABLE_NAMES = {"Work", "Comment"};
    public static final String[] PAY_ACCESS_TABLE_NAMES = {"Wallet"};

    /**验证权限是否通过
     * @param request
     * @param tableName
     * @return
     */
    public static boolean verify(JSONObject request, String tableName) throws AccessException {
        try {
            verify(request, getAccessId(tableName));
        } catch (AccessException e) {
            throw new AccessException(TAG + "verify  tableName = " + tableName + ", error = " + e.getMessage());
        }
        return true;
    }


    /**验证权限是否通过
     * @param request
     * @param accessId 可以直接在代码里写ACCESS_LOGIN等,或者建一个Access表。已实现登录密码的自动化处理,不需要写代码。
     * @return
     * @throws AccessException 
     */
    public static boolean verify(JSONObject request, int accessId) throws AccessException {
        if (accessId < 0 || request == null) {
            return true;
        }
        long currentUserId = request.getLongValue(KEY_CURRENT_USER_ID);
        if (currentUserId <= 0) {
            throw new AccessException(TAG + "verify accessId = " + accessId
                    + " >>  currentUserId <= 0, currentUserId = " + currentUserId);
        }
        String password;

        switch (accessId) {
        case ACCESS_LOGIN:
            password = StringUtil.getString(request.getString(KEY_LOGIN_PASSWORD));
            if (password.equals(StringUtil.getString(getLoginPassword(currentUserId))) == false) {
                throw new AccessException(TAG + "verify accessId = " + accessId
                        + " >> currentUserId or loginPassword error"
                        + "  currentUserId = " + currentUserId + ", loginPassword = " + password);
            }
        case ACCESS_PAY:
            password = StringUtil.getString(request.getString(KEY_PAY_PASSWORD));
            if (password.equals(StringUtil.getString(getPayPassword(currentUserId))) == false) {
                throw new AccessException(TAG + "verify accessId = " + accessId
                        + " >> currentUserId or payPassword error"
                        + "  currentUserId = " + currentUserId + ", payPassword = " + password);
            }
        default:
            return true;
        }
    }

    /**获取权限id
     * @param tableName
     * @return
     */
    public static int getAccessId(String tableName) {
        if (StringUtil.isNotEmpty(tableName, true) == false) {
            return -1;
        }
        for (int i = 0; i < LOGIN_ACCESS_TABLE_NAMES.length; i++) {
            if (tableName.equals(LOGIN_ACCESS_TABLE_NAMES[i])) {
                return ACCESS_LOGIN;
            }
        }
        for (int i = 0; i < PAY_ACCESS_TABLE_NAMES.length; i++) {
            if (tableName.equals(PAY_ACCESS_TABLE_NAMES[i])) {
                return ACCESS_PAY;
            }
        }
        return -1;
    }

    /**获取登录密码
     * @param userId
     * @return
     */
    public static String getLoginPassword(long userId) {
        // TODO 查询并返回对应userId的登录密码
        return "123456";//仅测试用
    }

    /**获取支付密码
     * @param userId
     * @return
     */
    public static String getPayPassword(long currentUserId) {
        // TODO 查询并返回对应userId的支付密码
        return "123456";//仅测试用
    }

}

 

服务端:

嗯,的确可行。刚看了项目主页的介绍,感觉APIJSON确实非常强大方便,连接口和文档都不用写了,也不会在健身或者陪女朋友看电影时突然接到客户端的电话了。

不过我还有一个问题,APIJSON是动态拼接SQL的,确实是灵活,但会不会导致SQL注入问题?

我:

APIJSON拼接SQL是在服务端完成的,客户端是不能直接发送SQL给服务端的。整个数据库操作都是服务端完全可控的,服务端可拦截危险注入,风险不比传统方式高。

服务端:

厉害了我的哥!我去下载试试哈哈!

客户端:

哈哈,我也要试试,请问怎么获取源码?免费的吗?

我:

已在Github和开源中国开源,完全免费。

Github: https://github.com/TommyLemon/APIJSON

开源中国:http://git.oschina.net/TommyLemon/APIJSON

服务端:

很棒!已Star!

客户端:

Star +1,顺便还Fork了一份研究嘿嘿!另外文档很详细赞一个!

我:

有什么问题或建议可以提issue或者发我邮件 tommylemon@qq.com,大家一起交流探讨哈!

服务端:

感觉我以后不用写一大堆接口了,不需要写兼容代码了,也不需要写文档了。可以专注于数据的处理、监控、统计、分析了哈哈!

客户端:

我也不用等服务端写好接口才能请求了,现在自己定制返回的JSON,看请求就知道返回的JSON结构,可以直接写解析代码了哈哈!

 

(注:以上是对真实对话的改编。)

 

APIJSON,让接口和文档见鬼去吧!

源码及文档(记得给个Star哦^_^)

Github: https://github.com/TommyLemon/APIJSON

开源中国: http://git.oschina.net/TommyLemon/APIJSON

下载试用(测试服务器地址:http://apijson.cn:8080

APIJSONClientApp.apk

© 著作权归作者所有

孤独的探索号

孤独的探索号

粉丝 157
博文 23
码字总数 29911
作品 7
深圳
私信 提问
加载中

评论(105)

御风林海
御风林海
复杂查询还是很麻烦,json请求体过重,合适难以阅读
孤独的探索号
孤独的探索号 博主
什么样的复杂查询呢?能给一个例子吗?
JSON 请求主要就是堆积木,把需要的对象放到对应的层级就行了。
JSON 这么简单易用、几乎统治了全球数据交换市场的协议,怎么还会难以阅读呢?

如果你是指 APIJSON 的一些功能符,某些看不懂,可以复制粘贴到自动话接口管理工具 APIAuto,
会自动显示注释,还有自动静态检查、自动生成代码、自动化测试、代码高亮 等功能。
http://apijson.org/auto/
孤独的探索号
孤独的探索号 博主
APIJSON 的请求 JSON 确实比传统 RESTful 的 JSON 参数重一些,但是返回的数据 Response JSON 只有前端/客户端 需要的,不多不少刚刚好。
而传统 RESTful API 因为后端不知道前端要什么,往往会返回一大堆不需要的字段,不仅浪费性能和流量,还更难以阅读,难以找到需要的字段。
https://github.com/APIJSON/APIJSON/blob/master/Document.md#2
孤独的探索号
孤独的探索号 博主
已经有热心的开发者实现了 Python 版的 APIJSON,
经测试,除了基本的查询(分页、排序等),还实现了自动化的权限控制。
最近作者又新增了自动化 API post。

创作不易,给作者点 ⭐Star 支持下吧^_^
https://github.com/zhangchunlin/uliweb-apijson
孤独的探索号
孤独的探索号 博主

引用来自“布尔值”的评论

GraphQL有被拖库的风险,APIJSON有解决这方面的经验么?
GraphQL 不管实现,而且很多教程的例子都是 resolver 手写 SQL 查询,当然容易通过 SQL 注入直接或间接(非正确密码也能登录)来实现拖库。
APIJSON 直接提供了自动化的增删改查 API,且有预编译、白名单等多种方式自动化防 SQL 注入,安全性是有保障的。
https://github.com/TommyLemon/APIJSON/issues/12
布尔值
布尔值
GraphQL有被拖库的风险,APIJSON有解决这方面的经验么?
孤独的探索号
孤独的探索号 博主
APIJSON -Java版: https://github.com/TommyLemon/APIJSON
APIJSON -Node版: https://github.com/TEsTsLA/apijson
APIJSON - C# 版: https://github.com/liaozb/APIJSON.NET
APIJSON - PHP版: https://github.com/orchie/apijson

APIJSON 接口工具: https://github.com/TommyLemon/APIJSONAuto
孤独的探索号
孤独的探索号 博主

引用来自“hi-fuifui”的评论

404了,测试地址
试过可以啊
http://apijson.cn:8080/get/%7B%7D
还可以用APIJSON自动化接口管理工具
http://apijson.cn/
hi-fuifui
hi-fuifui
404了,测试地址
孤独的探索号
孤独的探索号 博主

引用来自“李海珍”的评论

让我想起了 GraphQL
完爆Facebook/GraphQL,APIJSON全方位对比解析(一)-基础功能
https://juejin.im/post/5ae80edd51882567277433cf
完爆Facebook/GraphQL,APIJSON全方位对比解析(二)-权限控制
https://juejin.im/post/5b17518c6fb9a01e75463096
孤独的探索号
孤独的探索号 博主

引用来自“nwangwei”的评论

类似脸书的GraphQL?
完爆Facebook/GraphQL,APIJSON全方位对比解析(一)-基础功能
https://juejin.im/post/5ae80edd51882567277433cf
完爆Facebook/GraphQL,APIJSON全方位对比解析(二)-权限控制
https://juejin.im/post/5b17518c6fb9a01e75463096
孤独的探索号
孤独的探索号 博主

引用来自“孙某”的评论

数据结构过于罗嗦,为什么我还需要声明返回值是一个数组?
用RESTful思想和限定好参数模式我根本不需要关心接口文档有什么,只要了解资源有哪些就可以了。
GraphQL是一个好东西,他解决了RESTful带来的多资源整合的问题,那楼主到底想解决什么呢?

PS:千万不要忘记,如果我是一个前端工程师我最终需要操作的是JS的Object而不是JSON或其他东西。
完爆Facebook/GraphQL,APIJSON全方位对比解析(一)-基础功能
https://juejin.im/post/5ae80edd51882567277433cf
完爆Facebook/GraphQL,APIJSON全方位对比解析(二)-权限控制
https://juejin.im/post/5b17518c6fb9a01e75463096
3步创建APIJSON后端新表及配置

1.MySQLWorkbench新增Table 2.写一个Table对应的Model并配置权限 可以不写,直接用 APIJSONAuto 下载自动生成的文件。 这里用的是默认的权限配置,可以这样自定义: 3.DemoVerifier加一行代码...

孤独的探索号
2017/04/28
6.6K
11
【转】APIJSON,让接口见鬼去吧!

我: APIJSON,让接口和文档见鬼去吧! https://github.com/TommyLemon/APIJSON 服务端: 什么鬼? 客户端: APIJSON是啥? 我: APIJSON是一种JSON传输结构协议。 客户端可以定义任何JSON结...

osc_8b50jzrj
2018/07/04
5
0
自动生成API和文档 - APIJSON

APIJSON English 通用文档 视频教程 在线工具 APIJSON是一种为API而生的JSON网络传输协议。 为 简单的增删改查、复杂的查询、简单的事务操作 提供了完全自动化的API。 能大幅降低开发和沟通成...

孤独的探索号
2016/12/08
1.9W
31
uliweb_apijson 0.1.2 发布,自动化接口和文档 Python 实现

uliweb_apijson 0.1.1-0.1.2 更新内容: 新增自动化权限管理,支持 UNKNOWN, LOGIN, OWNER, ADMIN 4 种角色; 新增自动化数据和结构校验,支持 ADD, DISALLOW, NECESSARY 3 中操作方法; 新增...

孤独的探索号
2019/08/14
1.6K
0
TommyLemon/APIJSON

APIJSON Java-Server Android JavaScript Vue.js English Document 在线测试 1.简介 2.对比传统方式 2.1 开发流程 2.2 客户端请求 2.3 服务端操作 2.4 客户端解析 2.5 对应不同需求的请求 2....

TommyLemon
2016/12/09
0
0

没有更多内容

加载失败,请刷新页面

加载更多

数据分析 | 数据可视化图表,BI工具构建逻辑

本文源码:GitHub·点这里 || GitEE·点这里 一、数据可视化 1、基础概念 数据可视化,是关于数据视觉表现形式的科学技术研究。其中,这种数据的视觉表现形式被定义为,一种以某种概要形式抽...

知了一笑
26分钟前
6
0
Flutter 动画鼻祖之CustomPaint

老孟导读:CustomPaint可以称之为动画鼻祖,它可以实现任何酷炫的动画和效果。CustomPaint本身没有动画属性,仅仅是绘制属性,一般情况下,CustomPaint会和动画控制配合使用,达到理想的效果...

老孟Flutter
26分钟前
22
0
如何使用Git将标签推送到远程存储库? - How do you push a tag to a remote repository using Git?

问题: I have cloned a remote Git repository to my laptop, then I wanted to add a tag so I ran 我已经将一个远程Git存储库克隆到了我的笔记本电脑,然后我想添加一个标签,所以我跑了 ...

javail
29分钟前
18
0
Failed at the node-sass@4.14.1 postinstall script. npm ERR! This is probably not a problem with npm

报错信息: npm ERR! code ELIFECYCLEnpm ERR! errno 1npm ERR! node-sass@4.14.0 postinstall: `node scripts/build.js`npm ERR! Exit status 1npm ERR!npm ERR! Failed at the n......

SummerGao
44分钟前
15
0
电商微服务架构调研

参考案例: https://gitee.com/catshen/zscat_sw/tree/master/mall-gateway/zuul-gateway

郭恩洲_OSC博客
51分钟前
18
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部