本身動手寫SSO單點登陸服務端和客戶端

本文爲轉載 ,發表在: https://www.jianshu.com/p/79cfab236877

1、前言

咱們本身動手寫單點登陸的服務端目的是爲了加深對單點登陸的理解.若是大家公司想實現單點登陸/單點註銷功能,推薦使用開源的單點登陸框架CAS.咱們後面的章節也會帶同窗們快速搭建CAS Server和CAS Client的環境.css

2、條件

若是沒看前面章節的同窗,請返回去觀看這幾章內容,否則這代碼是不太好理解的.前端

  • SSO單點登陸教程(一)多系統的複雜性
  • SSO單點登陸教程(二)單點登陸流程分析
  • SSO單點登陸教程(三)單點註銷流程分析

3、環境要求

  • JDK1.7+
  • Maven3.3
  • Eclipse/IDEA

4、準備工做

由於咱們主要講的是跨域的單點登陸,因此咱們須要把不一樣項目部署到不一樣域名下.不可能爲了完成這個代碼,讓同窗們去阿里雲買三臺主機,映射三個IP.因此咱們的實驗就在本機來實現.咱們須要修改host文件,讓三個域名映射到本機.
host文件存放的位置:C:\Windows\System32\drivers\etc
打開host文件以後,在最後追加以下配置:

java

127.0.0.1 www.sso.com
127.0.0.1 www.crm.com
127.0.0.1 www.wms.com

這段配置的意思是,咱們在瀏覽器中輸入:
http://www.sso.com
http://www.crm.com
http://www.wms.com
其實訪問的都是本機:127.0.0.1



git

PS:有些同窗打開這個文件以後,保存的時候可能被拒絕.緣由多是權限不夠.解決方法:把host文件拷貝到桌面(有權限的地方便可),修改好以後再把:C:\Windows\System32\drivers\etc的host文件覆蓋.github

5、下載基礎項目

基礎項目代碼下載連接在頁面底部.web

我在github上傳的是maven結構的項目.若是須要導入到Eclipse/IDEA中須要生成對應的Eclipse/IDEA的配置文件.
cmd命令進入到項目的根目錄 $項目存放位置/sso-server-base-project
spring

  • 若是是Eclipse,運行mvn eclipse:eclipse
  • 若是是IDEA,運行mvn idea:idea

處理好以後,把項目導入到工具中,咱們就能夠開始開發了.數據庫

6、項目結構說明

服務端跨域

sso-server-base-project目錄
  src
      main
        java
        resources
           -applicationContext.xml
        webapp
          static
          WEB-INF
              views
                -login.jsp
                -logOut.jsp
              -web.xml
  -pom.xml

服務端項目就只配置了SpringMVC的環境.
pom.xml:項目的pom文件,已經配置的Tomcat插件端口爲:8443
applicationContext.xml:spring配置文件
static:靜態資源目錄,存放css,js
login.jsp:登錄頁面
logOut.jsp:登出頁面
web.xml:web的配置文件,配置前端請求DispatherServlet





瀏覽器

客戶端

sso-client-base-project目錄
  src
      main
        java
          -cn.wolfcode.sso.controller.MainServlet.java
          -cn.wolfcode.sso.controller.LogOutServlet.java
        webapp
          WEB-INF
              views
                -main.jsp
              -web.xml

客戶端沒有使用Spring框架.使用Servlet3.0

@WebServlet(name = "mainServlet", urlPatterns = "/main")

在Servlet類上貼這個註解就能夠進行映射.
MainServlet.java:處理主頁請求/main的servlet.
LogOutServlet.java:處理登出的請求/logOut的servlet
main.jsp:首頁


客戶端項目導入以後,運行tomcat7:run命令,在瀏覽器中輸入
http://www.crm.com:8088/main
會看到以下界面:

 

7、執行流程圖

咱們代碼的開發就參考着單點登陸流程圖來實現,因此我在這也把這張圖放過來.

8、代碼實現

準備階段:

一:在resources目錄建立sso.properties,內容以下:

#統一認證中心的地址
server-url-prefix=http://www.sso.com:8443
#本項目的地址
client-host-url=http://www.crm.com:8088

二:添加工具類.
咱們在後續的開發中須要使用這個工具類,寫得比較簡單,能夠先看看,咱們用到再給同窗們解釋啥意思.

SSOClientUtil.java

package cn.wolfcode.sso.util;
import java.io.IOException;
import java.util.Properties;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class SSOClientUtil {
    private static Properties ssoProperties = new Properties();
    public static String SERVER_URL_PREFIX;//統一認證中心地址:http://www.sso.com:8443,在sso.properties配置
    public static String CLIENT_HOST_URL;//當前客戶端地址:http://www.crm.com:8088,在sso.properties配置
    static{
        try {
            ssoProperties.load(SSOClientUtil.class.getClassLoader().getResourceAsStream("sso.properties"));
        } catch (IOException e) {
            e.printStackTrace();
        }
        SERVER_URL_PREFIX = ssoProperties.getProperty("server-url-prefix");
        CLIENT_HOST_URL = ssoProperties.getProperty("client-host-url");
    }
    /**
     * 當客戶端請求被攔截,跳往統一認證中心,須要帶redirectUrl的參數,統一認證中心登陸後回調的地址
     * 經過Request獲取此次請求的地址 http://www.crm.com:8088/main
     * 
     * @param request
     * @return
     */
    public static String getRedirectUrl(HttpServletRequest request){
        //獲取請求URL
        return CLIENT_HOST_URL+request.getServletPath();
    }
    /**
     * 根據request獲取跳轉到統一認證中心的地址 http://www.sso.com:8443//checkLogin?redirectUrl=http://www.crm.com:8088/main
     * 經過Response跳轉到指定的地址
     * @param request
     * @param response
     * @throws IOException
     */
    public static void redirectToSSOURL(HttpServletRequest request,HttpServletResponse response) throws IOException {
        String redirectUrl = getRedirectUrl(request);
        StringBuilder url = new StringBuilder(50)
                .append(SERVER_URL_PREFIX)
                .append("/checkLogin?redirectUrl=")
                .append(redirectUrl);
        response.sendRedirect(url.toString());
    }    
    /**
     * 獲取客戶端的完整登出地址 http://www.crm.com:8088/logOut
     * @return
     */
    public static String getClientLogOutUrl(){
        return CLIENT_HOST_URL+"/logOut";
    }
    /**
     * 獲取認證中心的登出地址 http://www.sso.com:8443/logOut
     * @return
     */
    public static String getServerLogOutUrl(){
        return SERVER_URL_PREFIX+"/logOut";
    }
}

HttpUtil.java

package cn.wolfcode.sso.util;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.Charset;
import java.util.Map;
import java.util.Map.Entry;
import org.springframework.util.StreamUtils;
public class HttpUtil {
    /**
     * 模擬瀏覽器的請求
     * @param httpURL 發送請求的地址
     * @param params  請求參數
     * @return
     * @throws Exception
     */
    public static String sendHttpRequest(String httpURL,Map<String,String> params) throws Exception{
        //創建URL鏈接對象
        URL url = new URL(httpURL);
        //建立鏈接
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        //設置請求的方式(須要是大寫的)
        conn.setRequestMethod("POST");
        //設置須要輸出
        conn.setDoOutput(true);
        //判斷是否有參數.
        if(params!=null&&params.size()>0){
            StringBuilder sb = new StringBuilder();
            for(Entry<String,String> entry:params.entrySet()){
                sb.append("&").append(entry.getKey()).append("=").append(entry.getValue());
            }
            //sb.substring(1)去除最前面的&
            conn.getOutputStream().write(sb.substring(1).toString().getBytes("utf-8"));
        }
        //發送請求到服務器
        conn.connect();
        //獲取遠程響應的內容.
        String responseContent = StreamUtils.copyToString(conn.getInputStream(),Charset.forName("utf-8"));
        conn.disconnect();
        return responseContent;
    }
    /**
     * 模擬瀏覽器的請求
     * @param httpURL 發送請求的地址
     * @param jesssionId 會話Id
     * @return
     * @throws Exception
     */
    public static void sendHttpRequest(String httpURL,String jesssionId) throws Exception{
        //創建URL鏈接對象
        URL url = new URL(httpURL);
        //建立鏈接
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        //設置請求的方式(須要是大寫的)
        conn.setRequestMethod("POST");
        //設置須要輸出
        conn.setDoOutput(true);
        conn.addRequestProperty("Cookie","JSESSIONID="+jesssionId);
        //發送請求到服務器
        conn.connect();
        conn.getInputStream();
        conn.disconnect();
    }
}

階段一:

階段一代碼下載連接在頁面底部.
第一階段咱們先完成,攔截客戶端的請求,判斷是否有局部會話,沒有局部會話就重定向到統一認證中心的登錄界面.
需求分析:
咱們要在客戶端攔截請求,應該使用啥技術呢?若是使用的是Spring框架,咱們可使用攔截器.但咱們的客戶端啥框架都沒用.要攔截請求,可使用過濾器Filter.


客戶端

建立:SSOClientFilter.java,實現javax.servlet.Filter接口,並貼上Servlet3.0的註解

@WebFilter(filterName="SSOClientFilter",urlPatterns="/*")
public class SSOClientFilter implements Filter {
  ....
}

步驟:
1.判斷是否有局部會話
2.若是有局部會話,直接放行
3.若是沒有,重定向到統一認證中心的checkLogin方法,檢查是否有全局會話.


package cn.wolfcode.sso.filter;
import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import cn.wolfcode.sso.util.SSOClientUtil;
@WebFilter(filterName="SSOClientFilter",urlPatterns="/*")
public class SSOClientFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {} 
    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;
        HttpSession session = req.getSession();
        //1.判斷是否有局部的會話
        Boolean isLogin = (Boolean) session.getAttribute("isLogin");
        if(isLogin!=null && isLogin){
            //有局部會話,直接放行.
            chain.doFilter(request, response);
            return;
        }
        //沒有局部會話,重定向到統一認證中心,檢查是否有其餘的系統已經登陸過.
        // http://www.sso.com:8443/checkLogin?redirectUrl=http://www.crm.com:8088
        //這是咱們本身寫工具類的方法,同窗們能夠本身看一下,很簡單能看懂的.
        SSOClientUtil.redirectToSSOURL(req, resp);
    }
    @Override
    public void destroy() {}
}

服務端

步驟:
1.接受重定向過來的checkLogin請求.判斷是否有全局的會話
2.若是沒有全局會話,獲取地址欄的redirectUrl參數,放入到request域中.並轉發到登錄頁面.
3.若是有全局會話,目前還沒到這個階段,這個邏輯咱們先不寫.咱們先按執行流程來寫代碼.


在java目錄建立SSOServerController.java,並貼上@Controller註解

@Controller
public class SSOServerController {
}

編寫checkLogin方法.

package cn.wolfcode.sso.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.servlet.http.HttpSession;
/**
 * Created by wolfcode-lanxw
 */
@Controller
public class SSOServerController {
    /**
     * 檢查是否有全局會話.
     * @param redirectUrl 客戶端被攔截的請求地址
     * @param session      統一認證中心的會話對象
     * @param model        數據模型
     * @return              視圖地址
     */
    @RequestMapping("/checkLogin")
    public String checkLogin(String redirectUrl, HttpSession session, Model model){
        //1.判斷是否有全局的會話
        //從會話中獲取令牌信息,若是取不到說明沒有全局會話,若是能取到說明有全局會話
        String token = (String) session.getAttribute("token");
        if(StringUtils.isEmpty(token)){
            //表示沒有全局會話
            model.addAttribute("redirectUrl",redirectUrl);
            //跳轉到統一認證中心的登錄頁面.已經配置視圖解析器,
            // 會找/WEB-INF/views/login.jsp視圖
            return "login";
        }else{
            //有全局會話
            //目前這段邏輯咱們先不寫,按着圖解流程編寫代碼
            return "";
        }
    }
}

測試:

服務端和客戶端代碼寫好以後,兩個項目都運行tomcat7:run的命令.
在瀏覽器地址欄輸入:
www.crm.com:8088/main
發現咱們的這個請求被攔截了,跳轉到了統一認證中心的登錄界面.以下圖所示:


 

階段二:

基礎項目代碼下載連接在頁面底部.

服務端:

步驟:
1.編寫登錄方法,實現認證功能.
2.認證經過,建立令牌.
3.建立全局會話存儲令牌信息
4.把令牌存入到數據庫t_token表中.



爲了減低學習的難度,咱們這個案例裏面就不去鏈接數據庫(固然要鏈接數據庫也不難),咱們的認證就使用靜態的認證(帳戶名:zhangsan,密碼:666).
咱們使用java中的Set集合來模擬t_token表.
建立MockDatabaseUtil.java來模擬數據庫

package cn.wolfcode.sso.util;
import java.util.*;
/**
 * Created by wolfcode-lanxw
 */
public class MockDatabaseUtil {
    //模擬數據庫中的t_token表
    public static Set<String> T_TOKEN = new HashSet<String>();
}

編寫統一認證中心的登錄方法,在SSOServerController.java中添加login方法.

/**
     * 登錄方法
     * @param username      前臺登錄的用戶名
     * @param password      前臺登錄的密碼
     * @param redirectUrl   客戶端被攔截的地址
     * @param session       服務端會話對象
     * @param model         模型數據
     * @return               響應的視圖地址
     */
    @RequestMapping("/login")
    public String login(String username,String password,String redirectUrl,HttpSession session,Model model){
        if("zhangsan".equals(username)&&"666".equals(password)){
            //帳號密碼匹配
            //1.建立令牌信息,只要保證惟一便可,咱們就使用UUID.
            String token = UUID.randomUUID().toString();
            //2.建立全局的會話,把令牌信息放入會話中.
            session.setAttribute("token",token);
            //3.須要把令牌信息放到數據庫中.
            MockDatabaseUtil.T_TOKEN.add(token);
            //4.重定向到redirectUrl,把令牌信息帶上.  http://www.crm.com:8088/main?token=
            model.addAttribute("token",token);
            return "redirect:"+redirectUrl;
        }
        //若是帳號密碼有誤,從新回到登陸頁面,還須要把redirectUrl放入request域中.
        model.addAttribute("redirectUrl",redirectUrl);
        return "login";
    }

客戶端:

1.統一認證中心登陸成功以後,會重定向到以前客戶端被攔截的地址,並會把令牌信息在地址欄中做爲參數http://www.crm.com:8088/main?token=VcnVMguCDWJX5zHa
此時訪問的是客戶端的地址,這個地址會被SSOClientFilter攔截到.
咱們在Filter裏面須要判斷用戶地址欄中是否有攜帶token信息,若是有,說明擁有令牌信息.可是咱們得校驗令牌token的有效性,使用HttpUrlConnection發送請求到統一認證中心進行校驗.
2.若是統一認證中心給咱們返回true,表示令牌有效.
3.咱們建立局部會話,並放行請求.



SSOClientFilter.java中添加以下代碼

public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse resp = (HttpServletResponse) response;
        HttpSession session = req.getSession();
        //1.判斷是否有局部的會話
        Boolean isLogin = (Boolean) session.getAttribute("isLogin");
        if(isLogin!=null && isLogin){
            //有局部會話,直接放行.
            chain.doFilter(request, response);
            return;
        }
        /**-------------------------階段二添加的代碼start---------------------------------**/
        //判斷地址欄中是否有攜帶token參數.
        String token = req.getParameter("token");
        if(StringUtils.isNoneBlank(token)){
            //token信息不爲null,說明地址中包含了token,擁有令牌.
            //判斷token信息是否由認證中心產生的.
            //驗證地址爲:http://www.sso.com:8443/verify
            String httpURL = SSOClientUtil.SERVER_URL_PREFIX+"/verify";
            Map<String,String> params = new HashMap<String,String>();
            //把客戶端地址欄添加到的token信息傳遞給統一認證中心進行校驗
            params.put("token", token);
            try {
                String isVerify = HttpUtil.sendHttpRequest(httpURL, params);
                if("true".equals(isVerify)){
                    //若是返回的字符串是true,說明這個token是由統一認證中心產生的.
                    //建立局部的會話.
                    session.setAttribute("isLogin", true);
                    //放行該次的請求
                    chain.doFilter(request, response);
                    return;
                }
            } catch (Exception e) {
                //這裏能夠完善,好比出現異常在前臺顯示具體頁面
                //咱們這個案例就不作這個哈.
                e.printStackTrace();
            }
        }
        /**-------------------------階段二添加的代碼end---------------------------------**/
        //沒有局部會話,重定向到統一認證中心,檢查是否有其餘的系統已經登陸過.
        // http://www.sso.com:8443/checkLogin?redirectUrl=http://www.crm.com:8088
        SSOClientUtil.redirectToSSOURL(req, resp);
    }

服務端:

1.須要在統一認證中心添加一個認證令牌信息的方法.
SSOServerController.java中添加verifyToken方法,具體代碼以下:

/**
     * 校驗客戶端傳過來的令牌信息是否有效
     * @param token 客戶端傳過來的令牌信息
     * @return
     */
    @RequestMapping("/verify")
    @ResponseBody
    public String verifyToken(String token){
        //在模擬的數據庫表t_token中查找是否有這條記錄
        if(MockDatabaseUtil.T_TOKEN.contains(token)){
            //說明令牌有效,返回true
            return "true";
        }
        return "false";
    }

測試:

到這裏爲止,階段二代碼就搞定了.單點登陸功能的95%代碼完成.
客戶端和服務端都運行tomcat7:run命令
在瀏覽器中按下Ctrl+Shift+Delete按鍵清楚cookie和緩存,避免干擾.
在瀏覽器中輸入:http://www.crm.com:8088/main,瀏覽器跳轉到統一認證中心的登錄頁面.輸入zhangsan:666,點擊登錄.此時就訪問到了CRM系統的首頁.界面以下.


 

階段三:

階段三代碼下載連接在頁面底部.
在前面的代碼咱們完成了單系統的登錄,如今咱們先看看若是在多系統的環境下,咱們是否能實現多系統的下一次登錄,到處運行的功能.

客戶端:

1.拷貝sso-client-base-project項目,命名爲sso-client-base-project2
2.修改新項目的pom.xml文件第41行,Tomcat插件的啓動端口,修改成:8089
3.修改sso.properties文件,修改以下:

server-url-prefix=http://www.sso.com:8443
client-host-url=http://www.wms.com:8089

4.修改/WEB-INF/views/main.jsp的標題,和內容,主要方便測試的時候看到不一樣的效果.(可改可不改)

服務端:

須要完善checkLogin方法,添加若是有全局會話的邏輯.

@RequestMapping("/checkLogin")
    public String checkLogin(String redirectUrl, HttpSession session, Model model){
        //1.判斷是否有全局的會話
        //從會話中獲取令牌信息,若是取不到說明沒有全局會話,若是能取到說明有全局會話
        String token = (String) session.getAttribute("token");
        if(StringUtils.isEmpty(token)){
            //表示沒有全局會話
            model.addAttribute("redirectUrl",redirectUrl);
            //跳轉到統一認證中心的登錄頁面.已經配置視圖解析器,
            // 會找/WEB-INF/views/login.jsp視圖
            return "login";
        }else{
            /**---------------------------階段三添加的代碼start--------------------**/
            //有全局會話
            //取出令牌信息,重定向到redirectUrl,把令牌帶上  
            // http://www.wms.com:8089/main?token=
            model.addAttribute("token",token);
            /**---------------------------階段三添加的代碼end-----------------------**/
            return "redirect:"+redirectUrl;
        }
    }

測試:

在服務端和兩個客戶端運行tomcat7:run命令.
在瀏覽器中按下Ctrl+Shift+Delete按鍵清楚cookie和緩存,避免干擾.
在瀏覽器中輸入:http://www.crm.com:8088/main,瀏覽器跳轉到統一認證中心的登錄頁面.輸入zhangsan:666,點擊登錄.此時就訪問到了CRM系統的首頁.說明已經登陸成功.
接着瀏覽器中輸入:http://www.wms.com:8089/main,發現此次請求就不須要登錄,能夠直接訪問了.到此爲止,咱們就完成單點登陸全部的代碼.能夠實現一次登錄,到處穿梭.


9、單點登陸步驟梳理:

客戶端

1.攔截客戶端的請求判斷是否有局部的session    
    2.1若是有局部的session,放行請求.    
    2.2若是沒有局部session        
          2.2.1請求中有攜帶token參數
                    2.2.1.1若是有,使用HttpURLConnection發送請求校驗token是否有效.       
                                  2.2.1.1.1若是token有效,創建局部的session.
                                  2.2.1.1.2若是token無效,重定向到統一認證中心頁面進行登錄.
                    2.2.1.2若是沒有,重定向到統一認證中心頁面進行登錄.
         2.2.2請求中沒有攜帶token參數,重定向到統一認證中心頁面進行登錄.

服務端

1.檢測客戶端在服務端是否已經登陸了.(checkLogin方法)
    1.1獲取session中的token.
    1.2若是token不爲空,說明服務端已經登陸過了,此時重定向到客戶端的地址,並把token帶上
    1.3若是token爲空,跳轉到統一認證中心的的登陸頁面,並把redirectUrl放入到request域中.

2.統一認證中心的登陸方法(login方法)
    2.1判斷用戶提交的帳號密碼是否正確.
    2.2若是正確
        2.2.1建立token(可使用UUID,保證惟一就能夠)
        2.2.2把token放入到session中,還須要把token放入到數據庫表t_token中
        2.2.3這個token要知道有哪些客戶端登錄了,存入數據庫t_client_info表中.);
        2.2.4轉發到redirectUrl地址,把token帶上.
    2.3若是錯誤
        轉發到login.jsp,還須要把redirectUrl參數放入到request域中.

3.統一認證中心認證token方法(verifyToken方法),返回值爲String,貼@ResponseBody
    3.1若是MockDatabaseUtil.T_TOKEN.contains(token)結果爲true,說明token是有效的.
        3.1.1返回true字符串.
    3.1若是MockDatabaseUtil.T_TOKEN.contains(token)結果爲false,說明token是無效的,返回false字符串.

10、代碼下載

0.初始項目Demo

熟悉git命令的同窗:

客戶端的基礎項目:

git clone git@github.com:javalanxiongwei/sso-client-base-project.git
cd sso-client-base-project/
git reset --hard 8401333ea845eb32e5f6091e7326ada1983d1ea3

服務頓的基礎項目:

git clone git@github.com:javalanxiongwei/sso-server-base-project.git
cd sso-server-base-project/
git reset --hard 6334d9afa08b3d5fc886ad212b3ec62376f5ff32

不熟悉git命令的同窗

客戶端的基礎項目
服務端的基礎項目

1.階段一Demo

熟悉git命令的同窗:

客戶端階段一:

git reset --hard b53e0234895b2044ed3042f8f856676c69160281

服務頓階段一:

git reset --hard 0ee718f408ef82d230fbc61c63b07b29b1277e45

不熟悉git命令的同窗

客戶端階段一
服務頓階段一

2.階段二Demo

熟悉git命令的同窗:

客戶端階段二:

git reset --hard b53e0234895b2044ed3042f8f856676c69160281

服務頓階段二:

git reset --hard 0ee718f408ef82d230fbc61c63b07b29b1277e45

不熟悉git命令的同窗

客戶端階段二
服務端階段二

3.階段三Demo

熟悉git命令的同窗:

客戶端2階段三下載:

git clone git@github.com:javalanxiongwei/sso-client-base-project2.git
cd sso-client-base-project2/
git reset --hard 01db6af390ff9f765121d3f9e9b1895b0e671bd5

服務頓階段三:

git reset --hard 80e7ad5a1d67b5d63d00e3532fed9ef58fe74fd9

不熟悉git命令的同窗

客戶端2階段三
服務端階段三

相關文章
相關標籤/搜索