MVC - 單點登入中介軟體

神牛003發表於2017-02-23

本章將要和大家分享的是一個單點登入中介軟體,中介軟體聽起來高深其實這裡只是吧單點登入要用到的邏輯和處理流程封裝成了幾個方法而已,預設支援採用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,隨便您發多少問卷只要符合法定內容;

 

補充:

2017.03.06

應多個博友的需求,這裡傳送調查問卷原始碼:ShenNiu.Question(問卷調查-原始碼包)

相關文章