MVC - 单点登录中间件 (转)

http://www.cnblogs.com/wangrudong003/p/6435013.html

本章将要和大家分享的是一个单点登录中间件,中间件听起来高深其实这里只是吧单点登录要用到的逻辑和处理流程封装成了几个方法而已,默认支持采用redis服务保存session的方式,也可以使用参数Func<>方法来做自定义session存储操作的方式,就不用我默认提供的redis存储的方法了;要说本章内容的来源,其实是我在以前的ShenNiu.MVC管理系统中加入了最近做的调查问卷模块,这个问卷调查和ShenNiu.MVC不是一个站点,但是我的问卷调查系统可定在维护问卷或题目的时候需要登录人的信息,我又不想再单独弄一套账号方面的程序了,所以就采用这种单点登录模式,以此来提供调查问卷的所需要的用户信息,以及为了不久的将来自己写的某个模块也需要管理用户信息的话,就能省略掉用户模块了,不得不说单点登录在此刻发挥的作用之大;本章内容希望大家能够喜欢,也希望各位多多"扫码支持"和"推荐"谢谢!如果您想要和我们交流更多mvc相关信息可以来Ninesky框架作者:洞庭夕照 指定的官方群 428310563交流;

 

» 单点登录验证手画示例图

» ShenNiuApi.SDK封装中间件代码

» 调查问卷系统使用中间件示例

» 推广调查问卷系统

 

下面一步一个脚印的来分享:

» 单点登录验证手画示例图

首先,咋们要做一个简易的单点登录功能,需要明白其执行的流程和运作的原理,这里将图文并茂重点提出我认为关键的地方,先上一幅手工图:

看起来图画的不是很好看,不过我想表达的意思感觉还是表达清楚了;作为一个单点登录验证模块,最主要的流程有:

1. 未登录时:提供统一登录入口=》去数据库验证账号正确性=》存储会话session(这里采用redis存储token和用户登陆信息,利用其数据过期策略充当session会话机制)=》重定向到redirectUrl指定的地址

2. 已登录时:获取站点的cookie存储的sessionId(token)=》调用验证token有效接口=》这里有两种情况(a,b)

    a) 有效token=》获取登录用户的session存储的信息(redis存储的value信息)

    b) 无效token=》返回无效信息,构造登录入口地址

通过上面分析,大致的流程应该很明确了下面我们就来看封装的代码;

 

» ShenNiuApi.SDK封装中间件代码

这里要看的是中间件的3个方法:SsoMiddleWareServer(登录入口操作),SsoMiddleWareClient(Token验证及获取登录信息),SsoMiddleWareLoginOut(注销操作);这里我已经把方法打包放到了nuget上: Install-Package ShenNiuApi.SDK ,只需要下载最新的sdk,就能轻松帮您实现一个单点登录架构,下面来看具体的代码;

SsoMiddleWareServer(登录入口操作):

复制代码
 1         /// <summary>
 2         /// 单点登录操作 SSOMiddleWare服务端(方法功能:
 3         /// 1.生成sessionId 
 4         /// 2.存储session到redis(60分钟失效)或者自定义sessionStoreFunc方法中 
 5         /// 3.构造带有token的重定向地址)
 6         /// 注:默认采用redis保存session,因此需要在conf中配置ReadAndWritePorts和OnlyReadPorts两个appSettings节点:
 7         /// ReadAndWritePorts在conf中配置格式如:pwd@ip:port,多个使用‘|’隔开       实例:shenniubuxing3@127.0.0.1:6377
 8         /// OnlyReadPorts在conf中配置格式如:pwd@ip:port,多个使用‘|’隔开              实例:shenniubuxing3@127.0.0.1:6377
 9         /// </summary>
10         /// <typeparam name="TUserBaseInfo">存储登录信息的对象</typeparam>
11         /// <param name="userBaseInfo">登录信息</param>
12         /// <param name="redirectUrl">重定向地址(注:格式应为http://或者https://;并经过UrlEncode转码后的地址;如果是同站点下面的话无需http://标记)</param>
13         /// <param name="token">执行方法无误后ref返回唯一的token(注:token生成规则是唯一的tokenKey+guid+时间戳)</param>
14         /// <param name="tokenKey">生成token的Key(默认:666666)</param>
15         /// <param name="sessionStoreFun">自定义session存储方法(提供自定义操作保存session的方法,覆盖默认的reids存储方式)</param>
16         /// <param name="timeOut">60(分钟)</param>
17         /// <returns>追加有token的重定向地址</returns>
18         public string SsoMiddleWareServer<TUserBaseInfo>(TUserBaseInfo userBaseInfo, string redirectUrl, ref string token, string tokenKey = "666666", Func<TUserBaseInfo, bool> sessionStoreFun = null, int timeOut = 60)
19             where TUserBaseInfo : class,new()
20         {
21             var returnUrl = string.Empty;
22             try
23             {
24                 //非空验证  
25                 if (string.IsNullOrWhiteSpace(redirectUrl) || userBaseInfo == null) { return returnUrl; }
26 
27                 //生成Token
28                 token = Md5Extend.GetSidMd5Hash(tokenKey);
29 
30                 // ShenNiuApi默认的Redis存储session
31                 if (sessionStoreFun == null && userBaseInfo != null)
32                 {
33                     if (!CacheRepository.Current(CacheType.RedisCache).SetCache<TUserBaseInfo>(token, userBaseInfo, timeOut, true)) { return returnUrl; }
34                 }
35                 else { if (!sessionStoreFun(userBaseInfo)) { return returnUrl; } }
36 
37                 //通域名站内系统登录
38                 if (!Uri.IsWellFormedUriString(redirectUrl, UriKind.Absolute))
39                 {
40                     returnUrl = redirectUrl;
41                     return returnUrl;
42                 }
43 
44                 #region 解析并构造跳转链接
45                 redirectUrl = HttpUtility.UrlDecode(redirectUrl);
46                 redirectUrl = redirectUrl.TrimEnd('&');
47                 redirectUrl = Regex.Replace(redirectUrl, "(&)?token=[^&]+(&)?", "");
48                 Uri uri = new Uri(redirectUrl);
49                 var queryStr = uri.Query;
50                 redirectUrl += queryStr.Contains('?') ? "" : "?";
51                 redirectUrl += string.IsNullOrWhiteSpace(queryStr.TrimStart('?')) ? "" : "&";
52                 returnUrl = string.Format("{0}token={1}", redirectUrl, token);
53                 #endregion
54             }
55             catch (Exception ex)
56             {
57                 throw new Exception(ex.Message);
58             }
59             finally
60             {
61                 if (string.IsNullOrWhiteSpace(returnUrl)) { token = string.Empty; }
62             }
63             return returnUrl;
64         }
复制代码

SsoMiddleWareClient(Token验证及获取登录信息):

复制代码
 1   /// <summary>
 2         /// 单点登录操作 SSOMiddleWare客户端(方法功能:
 3         /// 1.验证客户端是否有sid或者url地址中带有最新的token 
 4         /// 2.获取服务端session的基本信息(注:默认直接读取服务端的redis库,同server方法一样需要配置对应的账号节点ReadAndWritePorts和OnlyReadPorts)
 5         /// 3.重新设置客户端cookie有效期和服务端存储session的有效期)
 6         /// </summary>
 7         /// <typeparam name="TUserBaseInfo">登陆用户信息对象</typeparam>
 8         /// <param name="httpContext">上下文HttpContext</param>
 9         /// <param name="ssoLoginUrl">sso统一登陆入口地址</param>
10         /// <param name="redirectUrl">待重定向的地址</param>
11         /// <param name="userBaseInfo">获取的登陆用户信息</param>
12         /// <param name="token">唯一token(即:sid)</param>
13         /// <param name="getOrsetSessionFun">自定义获取服务端用户信息方法并且同时要满足重新设置新的session有效时间</param>
14         /// <param name="sidName">cookie保存的sid名称</param>
15         /// <param name="timeOut">过期时间</param>
16         /// <returns></returns>
17         public string SsoMiddleWareClient<TUserBaseInfo>(HttpContext httpContext, string ssoLoginUrl, string redirectUrl, ref TUserBaseInfo userBaseInfo, ref string token, Func<string, int, TUserBaseInfo> getAndsetSessionFun = null, string sidName = "sid", int timeOut = 60)
18                where TUserBaseInfo : class,new()
19         {
20             var returnUrl = string.Empty;
21             try
22             {
23                 userBaseInfo = default(TUserBaseInfo);
24                 token = string.Empty;
25                 if (string.IsNullOrWhiteSpace(ssoLoginUrl) || string.IsNullOrWhiteSpace(redirectUrl) || string.IsNullOrWhiteSpace(sidName)) { return returnUrl; }
26 
27                 //设置过期后验证url串 
28                 returnUrl = string.Format("{0}?returnUrl={1}", ssoLoginUrl, HttpUtility.UrlEncode(redirectUrl));
29 
30                 //获取token
31                 var cookie = httpContext.Request.Cookies.Get(sidName);
32                 token = httpContext.Request.Params["token"];
33                 token = string.IsNullOrWhiteSpace(token) ? (cookie == null ? "" : cookie.Value) : token;
34                 if (string.IsNullOrWhiteSpace(token)) { return returnUrl; }
35 
36                 //获取用户基本信息
37                 if (getAndsetSessionFun != null)
38                 {
39                     userBaseInfo = getAndsetSessionFun(token, timeOut);
40                 }
41                 else
42                 {
43                     userBaseInfo = CacheRepository.Current(CacheType.RedisCache).GetCache<TUserBaseInfo>(token, true);
44                 }
45                 if (userBaseInfo == null)
46                 {
47                     //过期cookie,清空
48                     if (cookie != null)
49                     {
50                         cookie.Expires = DateTime.Now.AddDays(-1);
51                         httpContext.Response.SetCookie(cookie);
52                     }
53                     return returnUrl;
54                 }
55 
56                 //cookie被清除,需要重新设置
57                 if (cookie == null)
58                 {
59                     cookie = new HttpCookie(sidName, token);
60                     cookie.Expires = DateTime.Now.AddMinutes(timeOut);
61                     httpContext.Response.AppendCookie(cookie);
62                 }
63                 else
64                 {
65                     //登陆验证都成功后,需要重新设置cookie中的toke失效时间
66                     cookie.Value = token;
67                     cookie.Expires = DateTime.Now.AddMinutes(timeOut);
68                     httpContext.Response.SetCookie(cookie);
69                 }
70 
71                 //设置服务端session的失效时间
72                 if (getAndsetSessionFun == null)
73                 {
74                     CacheRepository.Current(CacheType.RedisCache).AddExpire(token, timeOut);
75                 }
76                 returnUrl = string.Empty;
77             }
78             catch (Exception ex)
79             {
80                 throw new Exception(ex.Message);
81             }
82             finally { if (!string.IsNullOrWhiteSpace(returnUrl)) { token = string.Empty; } }
83             return returnUrl;
84         }
复制代码

SsoMiddleWareLoginOut(注销操作):

复制代码
 1  /// <summary>
 2         /// 单点登录操作 SSOMiddleWare 退出登陆
 3         /// </summary>
 4         /// <param name="httpContext">Http向下文</param>
 5         /// <param name="removeSession">自定义移除方法</param>
 6         /// <param name="sidName">cookie保存的sid名称</param>
 7         /// <returns>true或false</returns>
 8         public bool SsoMiddleWareLoginOut(HttpContext httpContext, Func<string, bool> removeSession = null, string sidName = "sid")
 9         {
10             var isfalse = true;
11             try
12             {
13                 if (string.IsNullOrWhiteSpace(sidName)) { sidName = "sid"; }
14 
15                 //获取cookie中的token
16                 var cookie = httpContext.Request.Cookies.Get(sidName);
17                 if (cookie == null) { return isfalse; }
18 
19                 //设置过期cookie(先过期cookie)
20                 var key = cookie.Value;
21                 cookie.Expires = DateTime.Now.AddDays(-1);
22                 httpContext.Response.SetCookie(cookie);
23 
24                 //移除session
25                 if (removeSession != null)
26                 {
27                     isfalse = removeSession(key);
28                 }
29                 else
30                 {
31                     isfalse = CacheRepository.Current(CacheType.RedisCache).Remove(key);
32                 }
33             }
34             catch (Exception ex)
35             {
36 
37                 throw new Exception(ex.Message);
38             }
39             return isfalse;
40         }
复制代码

每个方法的参数及作用,每行逻辑代码的都有注释,各位不妨研读下;这里要说的是每个方法都默认有操作redis存储session的步骤,因此能够看出此中间件默认采用的是redis服务存储session;

有人会问为什么会这样做,您单点登录难道最底层用的不是接口来操作登录或验证的吗?这里考虑有这样一个实用场景,作为一位中小型公司的员工来说,接触到服务器通常部署了整个公司的站点比如:站点1,站点2...尽管域名不一样但是都在同一台服务器上,再试想下如果用redis来存储session会话,此刻是不是就能认为我这台服务器就具有直接访问redis的读写权限(当然如果redis服务也在这台服务器上的话就更不用说了),那我直接在中间件中嵌入公共操作redis获取session,存储session等操作是不是都没问题,如此这般那我们还需要单独弄一个session(token)验证的api么,没必要的事情(对于单点登录站点和重定向站点而言没必要),因此我就这么干了,嵌入一个默认的redis操作哈哈(不服可以来辨);尽管如此不得不考虑更多的业务场景,万一登录账单和其他站点不在一个服务器(或者说无法直接访问redis呢),这里在3个中间件方法参数中提供了一个Func<>参数,每个方法的Func<>代表额意思有点差别,各位可以看下注释;有了这个自定义Func,中间件就能识别如果客户端有传递此方法,那么以Func为主,没有就采用默认的方式操作redis,这样允许使用者自定义方法扩展了使用者自己认为调用token验证的api或者其他合理的方式,这也保证了方法的通用性。

 

» 调查问卷系统使用中间件示例

下面我将使用真实的实例来使用ShenNiuApi.SDK中的中间件方法,这里例子是在我调查问卷系统中如何使用;首先通过nuget下载 Install-Package ShenNiuApi.SDK 最新的sdk,然后需要在做登录验证的Filter中或者继承Controller的父类中(我这里是后者)添加如下代码:

复制代码
 1 public class BaseController : Controller
 2     {
 3 
 4         protected StageModel.MoUserData _userData;
 5 
 6         protected override void OnActionExecuting(ActionExecutingContext filterContext)
 7         {
 8 
 9             #region 采用ShenNiuApiClient的SsoClient中间件
10 
11             ShenNiuApi.SDK.ShenNiuApiClient client = new ShenNiuApi.SDK.ShenNiuApiClient();
12 
13             var ssoLogin="http://www.lovexins.com:8081/User/Login";
14             var redirectUrl = filterContext.HttpContext.Request.Url.AbsoluteUri;
15             var token = string.Empty;
16             var returnUrl = client.SsoMiddleWareClient<StageModel.MoUserData>(System.Web.HttpContext.Current, ssoLogin, redirectUrl, ref this._userData, ref token);
17             if (string.IsNullOrWhiteSpace(token) )
18             {
19                 filterContext.Result = new RedirectResult(returnUrl);
20                 return;
21             }
22             #endregion
23         }
24 
25         protected void ShowMsg(string msg)
26         {
27 
28             ModelState.AddModelError(string.Empty, msg);
29         }
30     }
复制代码

只需要一句 client.SsoMiddleWareClient<StageModel.MoUserData>(System.Web.HttpContext.Current, ssoLogin, redirectUrl, ref this._userData, ref token); 即可完成问卷系统单点登录的验证和获取登录用户的信息,各种解析和设置sid的cookie信息都已经在中间件方法中完成了,是不是极大减少了您的编码量;为了对比下面我直接贴出没有使用SsoMiddleWareClient方法时候的代码量:

 1 protected override void OnActionExecuting(ActionExecutingContext filterContext)
 2         {
 3 
 4 
 5             var returnUrl = filterContext.HttpContext.Request.Url.AbsoluteUri;
 6             returnUrl = HttpUtility.UrlEncode(returnUrl);
 7             // var result = new RedirectResult(string.Format("http://www.lovexins.com:8081/User/Login?returnUrl={0}", returnUrl));
 8             var result = new RedirectResult(string.Format("http://172.16.9.6:4040/User/Login?returnUrl={0}", returnUrl));
 9             var key = "Sid";
10             var timeOut = 30;
11             try
12             {
13                 var cookie = filterContext.HttpContext.Request.Cookies.Get(key);
14                 var token = filterContext.HttpContext.Request.Params["token"];
15                 token = string.IsNullOrWhiteSpace(token) ? (cookie == null ? "" : cookie.Value) : token;
16                 if (string.IsNullOrWhiteSpace(token))
17                 {
18                     filterContext.Result = result;
19                     return;
20                 }
21 
22                 this._userData = CacheRepository.Current(CacheType.RedisCache).GetCache<StageModel.MoUserData>(token, true);
23                 if (this._userData == null && cookie != null)
24                 {
25                     //清空cookie
26                     cookie.Expires = DateTime.Now.AddDays(-1);
27                     filterContext.HttpContext.Response.SetCookie(cookie);
28                     filterContext.Result = result;
29                     return;
30                 }
31                 else if (this._userData == null)
32                 {
33                     filterContext.Result = result;
34                     return;
35                 }
36 
37                 if (cookie == null)
38                 {
39                     cookie = new HttpCookie(key, token);
40                     cookie.Expires = DateTime.Now.AddMinutes(timeOut);
41                     filterContext.HttpContext.Response.AppendCookie(cookie);
42                 }
43                 else
44                 {
45                     cookie.Value = token;
46                     //登陆验证都成功后,需要重新设置cookie中的toke失效时间
47                     cookie.Expires = DateTime.Now.AddMinutes(timeOut);
48                     filterContext.HttpContext.Response.SetCookie(cookie);
49                 }
50 
51                 //设置session失效时间
52                 CacheRepository.Current(CacheType.RedisCache).AddExpire(token, timeOut);
53             }
54             catch (Exception ex)
55             {
56                 filterContext.Result = result;
57                 return;
58             }
59         }
View Code

从代码量看前者简单多了,有人会说了您这不就是弄了一个方法而已嘛,说什么代码量少了哈哈;这不得不说通常咋们哎使用第三方的插件或者类库,这样极大减少了咋们工作量和提升了开发速度的好处,有了ShenNiuApi.SDK您还需要担心什么呢;不过研究里面的具体步骤,逻辑代码我嘶吼非常赞成的;

有了在调查问卷的自定义Controller父类后,咋们还需要有一个登录的地方,这里我新创建的项目Stage.Web,在其登录get请求的Action中增加了如下代码:

复制代码
 1    #region 采用ShenNiuApiClient的SsoClient中间件
 2 
 3             ShenNiuApi.SDK.ShenNiuApiClient client = new ShenNiuApi.SDK.ShenNiuApiClient();
 4             var ssoLogin = loginUrl;
 5             var redirectUrl = context.Request.Path;
 6 
 7             var token = string.Empty;
 8             t = default(T);
 9             var returnUrl = client.SsoMiddleWareClient<T>(System.Web.HttpContext.Current, ssoLogin, redirectUrl, ref t, ref token, sidName: UserLoginExtend.CookieName);
10             if (string.IsNullOrWhiteSpace(token))
11             {
12                 return new RedirectResult(returnUrl);
13             }
14             return null;
15             #endregion
复制代码

直接通过中间件提供的 SsoMiddleWareClient 方法获取登录的token并验证是否已经登陆过,如果登录过了直接通过 return new RedirectResult(returnUrl); 重定向到returnUrl的地址中去;如果没有那么进入登录界面,录入账号信息后:

提交登录,进入咋们post的Action中进过数据库对账号匹配成功了,然后直接调用中间件方法来存储session并提供唯一的token值,再进行重定向跳转:

复制代码
 1  #region 采用ShenNiuApiClient的SsoServer中间件
 2 
 3                     ShenNiuApi.SDK.ShenNiuApiClient client = new ShenNiuApi.SDK.ShenNiuApiClient();
 4 
 5                     var timeOut = 60; //分钟
 6                     var token = string.Empty;
 7                     var redirectUrl = client.SsoMiddleWareServer<StageModel.MoUserData>(userData, returnUrl, ref token, timeOut: timeOut);
 8                     sbLog.AppendFormat("redirectUrl:{0},token:{1},", redirectUrl, token);
 9                     if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(redirectUrl))
10                     {
11                         //登陆失败
12                         sbLog.Append("登陆失败,");
13                     }
14                     else
15                     {
16                         //写入Sso统一登陆站点的sid到cookie
17                         var cookie = new HttpCookie(UserLoginExtend.CookieName, token);
18                         cookie.Expires = DateTime.Now.AddMinutes(timeOut);
19                         cookie.Domain = Request.Url.Host;
20                         HttpContext.Response.AppendCookie(cookie);
21                     }
22                     var isAddLog = await StageClass._WrigLogAsync(sbLog.ToString());
23                     return new RedirectResult(string.Format("{0}", redirectUrl));
24                     #endregion
复制代码

到此出sso的代码基本完成了就这么简单,不过这里默认采用的是我嵌入的redis服务来存储session信息的,所以还需要配置一个redis相关账号密码等的节点,这里只需要您在 C:\Conf\ShenNiuApi.xml 磁盘下面增加如下名称的xml文件,文件内容也简单:

复制代码
 1 <ShenNiuApi>
 2     <RedisCache>
 3         <!--读写权限服务地址,多个使用'|'隔开(格式如:pwd@ip:port)-->
 4         <UserName>shenniubuxing3@111.111.111.152:1111</UserName>
 5         <!--只读权限服务地址,多个使用'|'隔开-->
 6         <UserPwd>shenniubuxing3@111.111.111.152:1111|shenniubuxing3@127.0.0.1:6377</UserPwd>
 7         <ApiUrl></ApiUrl>
 8         <ApiKey></ApiKey>
 9     </RedisCache>
10 </ShenNiuApi>
复制代码

把内容里面的redis账号,密码,端口,地址改成您自己的就行了;因为是在C盘中所以您服务器的其他站点也能够访问,假如您默认使用redis的方式存储session,那么只需要按照上面步骤就能快速的搭建一个单点登录架构;这里我提供下调查问卷使用单点登录测试的地址:www.lovexins.com:1001/Subject 测试账号:shenniu003 密码:123123,注意登录界面的域名和问卷调查的域名一样,只是端口不一样而已,如果您要看效果可以在浏览器F12,然后如图操作:

能够看到这个sid就是地址栏中的token值,这就是咋们定义的sessionId拉,您不想试试吗。

 

» 推广调查问卷系统

调查问卷我想很多公司都会用到,大家一般都会自己做一套,我这里要为大家推荐的是神牛问卷,具体怎么试用呢,可以登录地址http://www.lovexins.com:8081/User/Login 账号:shenniiu003 密码:123123,进入系统后直接点击“问卷管理”=>"调查问卷",在这里您就可以添加您想调查的问卷信息和选项:

如果您添加完成问卷信息后,可以直接点击“阅览”查看您的问卷展示内容和方式(支持移动手机浏览访问),这也是填写调查问卷的人看到的界面,目前支持的题目类型有(单选,多选,文本输入),测试地址:http://www.lovexins.com:1001/shenniu003/wenjuan7,地址中的shenniu003是根据账号来显示的,如果您是某个企业的hr或者老板这里地址栏可以直接注册成您公司的拼音名称或者汉字(是不是感觉还可以呢):

关键点来了,有了填写的用户咋们需要分析并做统计,这个时候只需要您点击问卷列表中的"统计",就能看到如下名目的图表:

您可以点击某一个问题选项对应的“红色”条,直接进入用户选项的分析报表:

看起来效果还是比较不错的吧,关键有数据统计给老板或者其他朋友看的时候,让人感觉“高大上”,这是选项样式的统计图,那么如果是用户填写类的统计呢,是如下这样的列表:

特点:

1. 富含单选,多选,用户填写类的题目类型

2. 单点登录架构,能快速嵌入到其他系统中

3. 手机web也能访问调查问问卷,问答问题

4. 详细的报表统计

5. 专业的维护人员哈哈

说明:最后要说的是此调查问卷系统是为了方便需要用到此功能的朋友和企业,如果您觉得还可以想发一两个问卷调查内容,可以联系我并让我给您单独分配一个管理者账号,当然如果您是某个企业带头人也想长久使用此调查系统可以联系邮箱:841202396@qq.com,随便您发多少问卷只要符合法定内容;

我的服装店,欢迎给位来捧场:神牛衣柜3 如果您觉的此文还可以,或是新眼界,新知识,新眉脚等收获,请打赏下作者的分享,微信号:
 
分类: .net,webapi
 
好文要顶             关注我     收藏该文         
        
 
 
+加关注    
18    
0    
 
 
 
« 上一篇:MVC - 云服务器部署

posted on 2017-02-25 18:48  风雨者2  阅读(760)  评论(0编辑  收藏  举报

导航