1. 程式人生 > >SSO單點登入教程(四)自己動手寫SSO單點登入服務端和客戶端

SSO單點登入教程(四)自己動手寫SSO單點登入服務端和客戶端

作者:藍雄威,叩丁狼教育高階講師。原創文章,轉載請註明出處。

一、前言

我們自己動手寫單點登入的服務端目的是為了加深對單點登入的理解.如果你們公司想實現單點登入/單點登出功能,推薦使用開源的單點登入框架CAS.我們後面的章節也會帶同學們快速搭建CAS Server和CAS Client的環境.

二、條件

如果沒看前面章節的同學,請返回去觀看這幾章內容,不然這程式碼是不太好理解的.

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

    三、環境要求

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

    四、準備工作

    因為我們主要講的是跨域的單點登入,所以我們需要把不同專案部署到不同域名下.不可能為了完成這個程式碼,讓同學們去阿里雲買三臺主機,對映三個IP.所以我們的實驗就在本機來實現.我們需要修改host檔案,讓三個域名對映到本機.
    host檔案存放的位置:C:\Windows\System32\drivers\etc

    開啟host檔案之後,在最後追加如下配置:
    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

PS:有些同學開啟這個檔案之後,儲存的時候可能被拒絕.原因可能是許可權不夠.解決方法:把host檔案拷貝到桌面(有許可權的地方即可),修改好之後再把:C:\Windows\System32\drivers\etc的host檔案覆蓋.

五、下載基礎專案

基礎專案程式碼下載連結在頁面底部.

我在github上傳的是maven結構的專案.如果需要匯入到Eclipse/IDEA中需要生成對應的Eclipse/IDEA的配置檔案.
cmd命令進入到專案的根目錄 $專案存放位置/sso-server-base-project

  • 如果是Eclipse,執行mvn eclipse:eclipse
  • 如果是IDEA,執行mvn idea:idea

處理好之後,把專案匯入到工具中,我們就可以開始開發了.

六、專案結構說明

服務端
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
會看到如下介面:
CRM專案首頁

七、執行流程圖

我們程式碼的開發就參考著單點登入流程圖來實現,所以我在這也把這張圖放過來.

單點登入原理

八、程式碼實現

準備階段:

一:在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
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系統的首頁.介面如下.
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,發現這次請求就不需要登陸,可以直接訪問了.到此為止,我們就完成單點登入所有的程式碼.可以實現一次登陸,處處穿梭.

九、單點登入步驟梳理:

客戶端
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字串.

十、程式碼下載

0.初始專案Demo

熟悉git命令的同學:

客戶端的基礎專案:

git clone [email protected]:javalanxiongwei/sso-client-base-project.git
cd sso-client-base-project/
git reset --hard 8401333ea845eb32e5f6091e7326ada1983d1ea3

服務頓的基礎專案:

git clone [email protected]: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 [email protected]:javalanxiongwei/sso-client-base-project2.git
cd sso-client-base-project2/
git reset --hard 01db6af390ff9f765121d3f9e9b1895b0e671bd5

服務頓階段三:

git reset --hard 80e7ad5a1d67b5d63d00e3532fed9ef58fe74fd9
不熟悉git命令的同學