帶你從0搭建一個Springboot+elasticsearch+canal的完整專案 - dailyhub

MarkerHub 發表於 2022-01-26
人工智慧 ElasticSearch Spring

首發公眾號:MarkerHub

作者:呂一明

原文連結:https://www.zhuawaba.com/post...

線上演示地址:https://www.zhuawaba.com/dail...

視訊講解:https://www.bilibili.com/vide...

原始碼地址:請關注公眾號:Java問答社,回覆【678】獲取

1、前言

我們經常瀏覽很多網頁,看到一些覺得有用、或者有意思的網頁時候,我們通常會收藏到書籤。然而當書籤的收藏越來越多,分類越來越多,想找到之前的那條收藏就比較麻煩,雖然也有搜尋功能,但還需要另外點選很多操作。

最重要的是,收藏網頁的時候我往往需要記錄一些瀏覽心得,作為我瀏覽的足跡和記憶。其實對我來說,收藏的分類不是特別重要,這是一個費腦的過程,因為很多網頁可以放到多個資料夾,這時候又出現了選擇困難症了,網頁各式各樣,總不能給每種網頁都起個分類收藏。對我來說有點冗餘。

於是我打算開發一個系統,以時間為記錄線,在未開啟網站的時候就可以快速記錄我當前瀏覽網頁的網址和標題,然後我還可以記錄心得。另外還需要一個很強大的搜尋引擎,快速搜尋記錄。這樣我可以檢視我每天瀏覽了那些網頁,然後還可以分享到收藏廣場上給更多的網友。

那麼,接下來,跟著我,一起去完成這個專案的開發吧

專案功能

  • 公眾號掃碼登入註冊
  • 快速收藏網頁
  • 收藏夾列表
  • 收藏檢索

技術棧

後端:springboot、spring data jpa、mysql、redis、elasticsearch、canal、mapstruct

前端:bootstrap 5

其實之前我在eblog專案中做個搜尋功能,那時候使用的是rabbitmq同步資料到es,這次我為了減少程式碼開發的量,使用了canal基於binlog同步資料到es,這涉及到服務搭建的過程,後續我都會一一講解。

2、線上演示

https://www.zhuawaba.com/dailyhub

圖片

3、新建springboot專案,整合jpa、freemarker

開啟IDEA開發工具,我們先來新建一個springboot專案,很常規的操作,專案名稱dailyhub,我們把需要的jar直接引入,比如jpa、redis、mysql、lombok、dev除錯。

新建專案

圖片

maven匯入相關的jar,原本我是想做一個前後端分離專案的,後來想想話太多時間在前端,我又不太想了,於是我使用了freemarker作為模板引擎。

圖片

專案初建

圖片

對了,因為經常用到一些工具類,我喜歡用hutool,所以記得提前引入哈:

  • pom.xml
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.7.16</version>
</dependency>

接下來,我們整合jpa和freemarker,讓專案可以訪問資料庫和展示頁面內容。

整合jpa

jpa的整合及其簡單,我們只需要配置資料來源的資訊,連線上資料庫,其他的整合工作都已經幫我們配置好的了。

  • application.yml
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost/dailyhub?useSSL=false&characterEncoding=utf8&serverTimezone=GMT%2B8
    username: root
    password: admin
  jpa:
    database: mysql
    show-sql: true
    hibernate:
      ddl-auto: update      

上面配置中,記得要去新建一個dailyhub的資料庫哇,因為後續的使用者名稱稱可能會有頭像等特殊字元,所以新建資料庫字符集記得要用utf8mb4的格式哈。
圖片

然後因為是jpa,表和欄位資訊在專案啟動時候會隨著你定義的bean類屬性資訊自動建立。所以我們不需要手動去建表。

為了測試,我們先來定義使用者表資訊,我打算通過使用者掃描二維碼方式完成登入,所以記錄的資訊不多,我也不需要收集太多使用者資訊,所以欄位非常簡單。

  • com.markerhub.entity.User

    @Data
    @Entity
    @Table(name = "m_user")
    public class User implements Serializable {
      @Id
      @GeneratedValue(strategy = GenerationType.IDENTITY)
      private Long id;
      private String username;
      // 頭像
      private String avatar;
       
      // 微信使用者身份id
      @JsonIgnore
      private String openId;
      
      // 上次登入
      private LocalDateTime lasted;
      private LocalDateTime created;
      
      private Integer statu;
    }

    然後接下來新建UserRepository,當然了,因為我們是專案實戰,所以要求你需要有點jpa的知識哈。UserRepository繼承JpaRepository,JpaRepository是SpringBoot Data JPA提供的非常強大的基礎介面,擁有了基本CRUD功能以及分頁功能。

  • com.markerhub.repository.UserRepository
public interface UserRepository extends JpaRepository<User, Long> {
    User findByOpenId(String openId);
}

然後我們來定義一個測試controller,因為小專案,我不想在test中測試了。

  • com.markerhub.controller.TestController
@Controller
public class TestController {
    @Autowired
    UserRepository userRepository;
    
    @ResponseBody
    @GetMapping("/test")
    public Object test() {
        return userRepository.findAll();
    }
}

專案啟動之後,系統會自動建立表資訊,然後我們手動新增一條資料進去。然後呼叫http://localhost:8080/test 介面,我們就能返回user表中的所有資料了。

圖片

因為openid欄位我新增了@JsonIgnore,所以在返回的json序列號字串中,我們是看不到的。這也是為了隱藏關鍵敏感資訊。

那麼到這裡,jpa我們就已經整合成功了,接下來我們來說一下freemarker。

整合Freemarker

在新版本的freemarker中,字尾已經修改成了.ftlh,為了方便和習慣,我又改回.ftl,然後為了解決頁面出現空值時候會報錯,所以需要設定classic_compatible資訊,那麼配置如下:

  • application.yml

    spring:
    freemarker:
      suffix: .ftl
      settings:
        classic_compatible: true

    然後在templates目錄下新建test.ftl檔案:

  • templates/test.ftl

    <p>你好,${user.username}, 這裡是dailyhub!</p>

    後端我們需要把使用者的資訊傳過去,所以定義後端介面:

  • com.markerhub.controller.TestController#ftl

    @GetMapping("/ftl")
    public String ftl(HttpServletRequest req) {
      req.setAttribute("user", userRepository.getById(1L));
      return "test";
    }

    訪問http://localhost:8080/ftl,結果如下:
    圖片

4、統一結果封裝

每做一個專案,都繞不開的util類,結果封裝,為了讓ajax請求的資料有個統一的格式,所以我們需要封裝一個統一的結果類,可以一下子就能看出請求結果是否正常等。

  • com.markerhub.base.lang.Result

    @Data
    public class Result<T> implements Serializable {
    
      public static final int SUCCESS = 0;
      public static final int ERROR = -1;
      
      private int code;
      private String mess;
      private T data;
      
      public Result(int code, String mess, T data) {
          this.code = code;
          this.mess = mess;
          this.data = data;
      }
      
      public static <T> Result<T> success() {
          return success(null);
      }
      public static <T> Result<T> success(T data) {
          return new Result<>(SUCCESS, "操作成功", data);
      }
      public static <T> Result<T> failure(String mess) {
          return new Result<>(ERROR, mess, null);
      }
    }

    這裡我用到了泛型,也是為了返回結果的時候限定返回某種型別,而不是隨意的一個Object,避免資料返回不一致等問題。

    5、全域性異常處理

之前在vueblog和vueadmin兩個專案中,全域性異常處理我都喜歡用註解@[email protected]來處理異常,這次我們使用另外一種方式,我們還可以通過繼承HandlerExceptionResolver,通過重寫resolveException來處理全域性的異常。

  • com.markerhub.base.exception.GlobalExceptionHandler

    @Slf4j
    @Component
    public class GlobalExceptionHandler implements HandlerExceptionResolver {
      @Override
      public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
          if (ex instanceof IllegalArgumentException || ex instanceof IllegalStateException) {
              log.error(ex.getMessage());
          } else {
              log.error(ex.getMessage(), ex);
          }
          // ajax請求
          String requestType = request.getHeader("X-Requested-With");
          if ("XMLHttpRequest".equals(requestType)) {
              try {
                  response.setContentType("application/json;charset=UTF-8");
                  response.getWriter().print(JSONUtil.toJsonStr(Result.failure(ex.getMessage())));
              } catch (IOException e) {
                  // do something
              }
              return new ModelAndView();
          } else {
              request.setAttribute("message", "系統異常,請稍後再試!");
          }
          return new ModelAndView("error");
      }
    }

    注意IllegalArgumentException等資訊通常都是業務校驗資訊是否正常,所以一般我們不會在日誌中列印異常的具體資訊,直接列印異常訊息即可。然後碰到的是ajax請求時候,我們返回的是Result統一封裝結果的json字串。否則就是返回error.ftl頁面,輸出錯誤資訊。所以我們在templates目錄下新建error.ftl頁面,等後面我們可以重寫報錯頁面,現在可以簡單點:

  • templates/error.ftl
    圖片

6、公眾號掃碼登入功能開發

其實我做這個功能就是為了給公眾號引流,讓使用者訪問我網址時候可以順便關注我的公眾號,達到漲粉的目的,我類似的網站還有https://zhuawaba.com/login ,我的公眾號都是認證的企業訂閱號,不知道個人號可不可以,這個還待確認,如果需要個人號使用這個功能可以自己去官網檢視一下相關的介面哈。

掃碼原理

圖片

原理說明

  1. 使用者發起登入請求
  2. 服務端生成code、ticket返回前端
  3. 前端開始每3秒迴圈訪問後端,攜帶code和ticket
  4. 使用者掃碼公眾號,並在公眾號上回復code
  5. 微信端接收到使用者輸入關鍵字,返回關鍵字和openid到指定的配置後端介面
  6. 後端接收到微信端的回撥,使用openid獲取使用者資訊,對使用者進行註冊處理(新使用者),然後把使用者資訊存入redis中,code作為key。
  7. 前端迴圈訪問時候發現後端redis中已經有使用者資訊,驗證碼code和ticket是否匹配,匹配成功之後,後端在session中存入使用者資訊,使用者登入成功,前端跳轉到首頁。

    登入頁面

因為不是前後端分離的專案,所以一般我都喜歡先把頁面寫好,然後需要什麼資料我再填充,這樣省略一些介面除錯的時間。我使用了bootstrap 5的頁面樣式框架,注意同步哈。

根據上面的掃碼邏輯,我們在登入頁面需要的是一個公眾號的二維碼,還有登入的驗證碼,所以頁面就相對比較簡單了。

  • templates/login.ftl
<body class="text-center">
<main class="form-signin">
    <form>
        <img class="mb-4"
             src="/images/logo.jpeg"
             alt="" width="72" height="72"  style="border-radius: 15%;">
        
        <h1 class="h3 mb-3 fw-normal">閱讀收藏 - dailyhub</h1>
        <img src="/images/javawenda.jpeg" alt="公眾號:Java問答社">
        
        <div class="mt-2 mb-2 text-muted">
            登入驗證碼:
            <strong style="background-color: yellow; padding: 2px; font-size: 18px;" id="qrcodeeeee">
                ${code}
            </strong>
        </div>
        
        <p class="text-muted">掃碼關注公眾號,回覆上方驗證碼登入</p>
    </form>
</main>


    var dingshi = setInterval(function () {
        $.get('/login-check' ,{
            code: '${code}',
            ticket: '${ticket}',
        }, function (res) {
            console.log(res)
            if(res.code == 0) {
                location.href = "/";
            }
        });
    }, 3000);
    
    setTimeout(function () {
        clearInterval(dingshi);
        console.log("已關閉定時器~")
        $("#qrcodeeeee").text("驗證碼過期,請重新整理!");
    }, 180000);
    


</body>
</body>
</html>

最終效果:

圖片

因為登入驗證碼是有有效期的,所以我定義了一個js定時器,當超過3分鐘的時候,自動把驗證碼切換成已過期的狀態。另外為了驗證使用者是否已經等了,每3秒就去訪問一下伺服器,這裡我沒有使用websocket,這個請求雖然頻繁,但不需要查庫,對伺服器壓力也不是特別大。當然了,你也可以使用ws的方式。

驗證碼過期效果:

圖片

獲取登入驗證碼

然後在服務段,其實就比較簡單了,就把登入驗證碼生成然後傳到前端就行:

  • com.markerhub.controller.LoginController#login
/**
 * 1、獲取驗證碼
 */
@GetMapping(value = "/login")
public String login(HttpServletRequest req) {
    String code = "DY" + RandomUtil.randomNumbers(4);
    while (redisUtil.hasKey(code)) {
        code = "DY" + RandomUtil.randomNumbers(4);
    }
    String ticket = RandomUtil.randomString(32);
    // 5 min
    redisUtil.set(code, ticket, 5 * 60);
    
    req.setAttribute("code", code);
    req.setAttribute("ticket", ticket);
    log.info(code + "---" + ticket);
    return "login";
}

隨機生成DY開頭的登入驗證碼code以及校驗用的ticket(防止別人偽造登入驗證碼暴力訪問),儲存到redis中,然後返回前端。
前端把登入驗證碼展示給使用者,使用者掃碼公眾號二維碼,然後輸入登入驗證碼。

微信端接收到使用者輸入的關鍵字之後,把使用者輸入的內容原封不動返回,同時還回撥返回openid以及一些使用者相關資訊。回撥連結時我們提前設定在公眾號設定中的哈。

openid將作為使用者的金鑰資訊,後續我們判斷使用者是誰都是通過openid,所以務必要妥善儲存。其實為了隱私,可以加密存庫。

迴圈請求登入結果

當使用者開啟登入頁面的時候,頁面就會發起一個每3秒一次的登入結果請求,歷時3分鐘,當發現使用者已經傳送登入驗證碼到公眾號的時候就會自動跳轉到首頁。

  • com.markerhub.controller.LoginController#loginCheck
/**
 * 驗證code是否已經完成登入
 */
@ResponseBody
@GetMapping("/login-check")
public Result loginCheck(String code, String ticket) {

    if(!redisUtil.hasKey("Info-" + code)) {
        return Result.failure("未登入");
    }
    
    String ticketBak = redisUtil.get(code).toString();
    if (!ticketBak.equals(ticket)) {
        return Result.failure("登入失敗");
    }
    
    String userJson = String.valueOf(redisUtil.get("Info-" + code));
    UserDto user = JSONUtil.toBean(userJson, UserDto.class);
    
    req.getSession().setAttribute(Const.CURRENT_USER, user);
    return Result.success();
}

可以看到,這個檢查業務比價簡單,就檢查redis中是否已經有了對應的key,然後通過key獲取對應的登入使用者資訊,然後儲存到session中,實現使用者登入。

整合WxJava

為了後續的公眾號業務處理方便些,我們這裡引入一個公眾號開發整合包WxJava。

其實掃碼登入已經涉及到了公眾號開發,所以我們使用一個工具來幫我們簡化一些開發工具,這裡我們選擇使用WxJava,

現在版本更新很快,我以前使用的是3.2.0版本,這裡我就不使用最新版本了。

  • pom.xml
<!--微信公眾號開發 https://github.com/Wechat-Group/WxJava-->
<dependency>
    <groupId>com.github.binarywang</groupId>
    <artifactId>weixin-java-mp</artifactId>
    <version>3.2.0</version>
</dependency>

然後我們需要配置微信公眾號的金鑰資訊,然後初始化WxMpConfigStorage和WxMpService這兩個類,這樣我們就可以正常使用wxjava的所有api了。

  • WxMpService:微信API的Service
  • WxMpConfigStorage:公眾號客戶端配置儲存

建立config包,然後新建一個com.markerhub.config.WeChatMpConfig配置類。

  • com.markerhub.config.WeChatMpConfig
@Data
@Slf4j
@Configuration
@ConfigurationProperties(prefix = "wechat")
public class WeChatMpConfig {

    private String mpAppId;
    private String mpAppSecret;
    private String token;
    
    @Bean
    public WxMpService wxMpService() {
        WxMpService wxMpService = new WxMpServiceImpl();
        wxMpService.setWxMpConfigStorage(wxMpConfigStorage());
        return wxMpService;
    }
    /**
     * 配置公眾號金鑰資訊
     * @return
     */
    @Bean
    public WxMpConfigStorage wxMpConfigStorage() {
        WxMpInMemoryConfigStorage wxMpConfigStorage = new WxMpInMemoryConfigStorage();
        wxMpConfigStorage.setAppId(mpAppId);
        wxMpConfigStorage.setSecret(mpAppSecret);
        wxMpConfigStorage.setToken(token);
        return wxMpConfigStorage;
    }
    /**
     * 配置訊息路由
     * @param wxMpService
     * @return
     */
    @Bean
    public WxMpMessageRouter router(WxMpService wxMpService) {
        WxMpMessageRouter router = new WxMpMessageRouter(wxMpService);
        // TODO 訊息路由
        return router;
    }
}
  • WxMpMessageRouter:微信訊息路由器,通過程式碼化的配置,把來自微信的訊息交給handler處理
    程式碼中token、mpAppId和mpAppSecret等資訊都是在公眾號中獲得的,如果你沒有公眾號,我們可以去微信公眾平臺介面測試帳號申請,網址:https://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=sandbox/login, 登入之後,就可以得到一個測試號相關的mpAppId和mpAppSecret資訊了,然後token和URL是需要自己設定的。Token可以隨意,保持和程式碼一直就行,URL是需要一個內網穿透工具了,我使用的是natapp.cn這個工具,對映到本地的8080埠,這樣外圍就可以訪問我本地的介面了。
  • 測試環境配置:

圖片

  • 線上環境配置:

圖片

然後我們把金鑰的資訊配置到application.yml檔案中:

  • application.yml
wechat:
  mpAppId: wxf58aec8********5
  mpAppSecret: efacfe9b7c1b*************c954
  token: 111111111

微信訊息回撥

當我們在配置的公眾號輸入內容訊息時候,公眾平臺就會回撥我們配置連結,把使用者輸入的內容傳送給我們的後臺,所以我們這裡需要做內容的接收與處理,然後把處理結果返回給公眾平臺。

  • com.markerhub.controller.LoginController#wxCallback
/**
 * 服務號的回撥
 */
@ResponseBody
@RequestMapping(value = "/wx/back")
public String wxCallback(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    String signature = req.getParameter("signature");
    String timestamp = req.getParameter("timestamp");
    String nonce = req.getParameter("nonce");
    String echoStr = req.getParameter("echostr");//用於驗證伺服器配置
    
    if (StrUtil.isNotBlank(echoStr)) {
        log.info("---------------->驗證伺服器配置");
        return echoStr;
    }
    if (!wxService.checkSignature(timestamp, nonce, signature)) {
        // 訊息不合法
        log.error("------------------> 訊息不合法");
        return null;
    }
    
    String encryptType = StringUtils.isBlank(req.getParameter("encrypt_type")) ?
            "raw" : req.getParameter("encrypt_type");
    WxMpXmlMessage inMessage = null;
    if ("raw".equals(encryptType)) {
        // 明文傳輸的訊息
        inMessage = WxMpXmlMessage.fromXml(req.getInputStream());
    } else if ("aes".equals(encryptType)) {
        // 是aes加密的訊息
        String msgSignature = req.getParameter("msg_signature");
        inMessage = WxMpXmlMessage.fromEncryptedXml(req.getInputStream(), wxMpConfigStorage, timestamp, nonce, msgSignature);
    } else {
        log.error("-------------> 不可識別的加密型別 {}" + encryptType);
        return "不可識別的加密型別";
    }
    
    // 路由到各個handler
    WxMpXmlOutMessage outMessage = wxMpMessageRouter.route(inMessage);
    
    log.info("返回結果 ----------------> " + outMessage);
    String result =  outMessage == null ? "" : outMessage.toXml();
    return result;
}

從上面的程式碼可以看出,其實不是很複雜,都是為了驗證訊息是否合法,真正有用的程式碼是這行:

// 路由到各個handler
WxMpXmlOutMessage outMessage = wxMpMessageRouter.route(inMessage);

這個我們用到了wxjava的路由概念,我們需要提前配置號路由規則,比如當使用者輸入的是文字或者圖片、語音等內容時候,我們需要路由到不同的處理器來處理訊息內容。下面我們設定一下路由以及處理器。

字串處理器

然後根據wxjava官網的說明:

圖片

我們需要處理的主要是文字訊息,所以我們在路由中配置一個處理文字訊息的處理器TextHandler。

所以,我們在WeChatMpConfig中新增一個同步文字訊息路由:

  • com.markerhub.config.WeChatMpConfig#router
@Autowired
TextHandler textHandler;

@Bean
public WxMpMessageRouter router(WxMpService wxMpService) {
    WxMpMessageRouter router = new WxMpMessageRouter(wxMpService);
    // TODO 訊息路由
    router
            .rule()
            .async(false)
            .msgType(WxConsts.XmlMsgType.TEXT)
            .handler(textHandler)
            .end();
    return router;
}

有了上面的配置,當使用者在公眾號回覆文字型別的字串時候,就會路由到textHandler處理資訊。並且設定了時同步回覆。
然後我們來定義TextHandler,我們需要實現WxMpMessageHandler介面重寫handle介面。

登入的字串我們定義成【DY + 4位隨機數字】的格式,所以當公眾號收到DY開頭的字串時候,我們就當成是使用者登入的憑證來處理。這時候我們單獨定義一個LoginHandler類,集中把登入處理的業務寫在裡面。避免後面更多業務的時候程式碼太多。

  • com.markerhub.handler.TextHandler
@Slf4j
@Component
public class TextHandler implements WxMpMessageHandler {

    private final String UNKNOWN =  "未識別字串!";
    
    @Autowired
    LoginHandler loginHandler;
    
    @Override
    public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, Map<String, Object> context, WxMpService wxMpService, WxSessionManager wxSessionManager) throws WxErrorException {
        String openid = wxMessage.getFromUser();
        String content = wxMessage.getContent();
        
        String result = UNKNOWN;
        
        if (StrUtil.isNotBlank(content)) {
            content = content.toUpperCase().trim();
            
            // 處理登入字串
            if (content.indexOf("DY") == 0) {
                result = loginHandler.handle(openid, content, wxMpService);
            }
        }
        
        return WxMpXmlOutMessage.TEXT()
                .content(result)
                .fromUser(wxMessage.getToUser())
                .toUser(wxMessage.getFromUser())
                .build();
    }
}

可以看到,遇到DY開頭登入字串交給LoginHandler處理其他訊息一律返回未識別。

  • LoginHandler
@Slf4j
@Component
public class LoginHandler {

    @Value("${server.domain}")
    String serverDomain;
    @Autowired
    RedisUtil redisUtil;
    @Autowired
    UserService userService;
    
    public String handle(String openid, String content, WxMpService wxMpService) {
        
        String result;
        if (content.length() != 6 || !redisUtil.hasKey(content)) {
            return "登入驗證碼過期或不正確!";
        }
        
        // 解決手機端登入
        String token = UUID.randomUUID().toString(true);
        String url = serverDomain + "/autologin?token=" + token ;
        
        WxMpUser wxMapUser = new WxMpUser();
        
        result = "歡迎你!" + "\n\n" +
                    "<a href='" + url + "'>點選這裡完成登入!</a>";
        
        // 註冊操作
        UserDto user = userService.register(wxMapUser);
        
        // 使用者資訊存在redis中5分鐘
        redisUtil.set("Info-" + content, JSONUtil.toJsonStr(user), 5 * 60);
        // 手機端登入
        redisUtil.set("autologin-" + token, JSONUtil.toJsonStr(user), 48 * 60 * 60);
        
        return result;
    }
}

LoginHandler中主要做了幾件事情:

  • 驗證登入驗證碼是否存在和正常
  • 使用openid獲取使用者資訊
  • 使用者註冊
  • 把使用者資訊儲存到redis中
  • 生成隨機tonken,方便手機端登入操作

在使用者註冊register方法中,我們需要做如下存庫處理:

  • com.markerhub.service.impl.UserServiceImpl#register
@Service
public class UserServiceImpl implements UserService {

    @Autowired
    UserRepository userRepository;
    
    @Autowired
    UserMapper userMapper;
    
    @Override
    @Transactional
    public UserDto register(WxMpUser wxMapUser) {
        String openId = wxMapUser.getOpenId();
        User user = userRepository.findByOpenId(openId);
        
        if (user == null) {
            user = new User();
           
            String avatar = "https://image-1300566513.cos.ap-guangzhou.myqcloud.com/upload/images/5a9f48118166308daba8b6da7e466aab.jpg";
            user.setAvatar(avatar);
            user.setUsername("Hub-" + RandomUtil.randomString(5));
            
            user.setCreated(new Date());
            user.setLasted(new Date());
            user.setOpenId(openId);
            user.setStatu(Const.STATUS_SUCCESS);
            
        } else {
            user.setLasted(new Date());
        }
        
        userRepository.save(user);
        log.info("使用者註冊成:------{}", user.getUsername());
        
        UserDto userDto = userMapper.toDto(user);
        return userDto;
    }
}

在2021年12月27日開始,公眾號官方調整了使用者資訊介面,已經獲取不到了頭像和暱稱資訊,所以這裡我就直接寫死了,後續可以提供修改資料頁面讓使用者自行修改,這還是挺簡單的。

  • com.markerhub.base.dto.UserDto
@Data
public class UserDto implements Serializable {

    private Long id;
    private String username;
    private String avatar;
    
    private LocalDateTime lasted;
    private LocalDateTime created;
}

使用mapstruct

儲存使用者資訊之後返回User,需要轉成UserDto,這裡我使用了一個框架mapstruct,真心覺的好用呀。

關於它的介紹,可以去官網看一下:https://mapstruct.org/

MapStruct的原理是生成和我們自己寫的程式碼一樣的程式碼,這意味著這些值是通過簡單的getter/setter呼叫而不是反射或類似的方法從source類複製到target類的。使得MapStruct的效能會比動態框架更加優秀。這其實和lombok其實有點類似。

首先我們需要匯入mapstruct包。需要注意的是,如果專案中有引入lombok,需要解決一下衝突問題哈,plugin像我那樣配置一下就行了。

  • pom.xml
<dependency>
   <groupId>org.mapstruct</groupId>
   <artifactId>mapstruct</artifactId>
   <version>1.4.2.Final</version>
</dependency>

// plugins中新增下面
<plugin>
   <groupId>org.apache.maven.plugins</groupId>
   <artifactId>maven-compiler-plugin</artifactId>
   <version>3.8.1</version>
   <configuration>
      <source>1.8</source> <!-- depending on your project -->
      <target>1.8</target> <!-- depending on your project -->
      <annotationProcessorPaths>
         <path>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct-processor</artifactId>
            <version>1.4.2.Final</version>
         </path>

         <!--為了解決lombok衝突問題-->
         <path>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok-mapstruct-binding</artifactId>
            <version>0.2.0</version>
         </path>
         <path>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.22</version>
         </path>
         
         <!-- other annotation processors -->
      </annotationProcessorPaths>
   </configuration>
</plugin>

然後我們需要做User與UserDto之間的轉換,我們可以新建一個UserMapper,然後

  • com.markerhub.mapstruct.UserMapper
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface UserMapper {
    UserDto toDto(User user);
}

然後屬性相同的欄位就能對映過去了,不同屬性的話,可以通過註解來調整,後續我們也會用到。
然後我們可以看一下生成的對映程式碼:

圖片

手機端登入驗證

之前為了讓手機端能夠實現登入,我們生成了一個token作為key儲存當前使用者的登入資訊,然後我們需要把這個token返回給使用者的手機端。

具體操作就是當使用者輸入登入驗證碼時候,我們返回手機端的登入連結給使用者,同時pc端的網頁自動跳轉到首頁實現自動登入。

訪問登入頁面,在公眾號中回覆登入驗證碼,得到的效果如下:

圖片

然後pc端的登入頁面會自動調跳轉到首頁。首頁的連結我們還沒配置,等下弄。

然後在手機端,我們可以點選提示的【點選這裡完成登入】來實現手機端的登入操作,其實連結是這樣的:http://localhost:8080/autologin?token=4eee0effa21149c68d4e95f8667cef49

服務端已經繫結了token與當前使用者的資訊,所以可以使用該token實現登入操作。

  • com.markerhub.controller.LoginController#autologin
/**
 * 手機端登入
 */
@GetMapping("/autologin")
public String autologin(String token) {
    log.info("-------------->" + token);
    String userJson = String.valueOf(redisUtil.get("autologin-" + token));
    
    if (StringUtils.isNotBlank(userJson)) {
        UserDto user = JSONUtil.toBean(userJson, UserDto.class);
        req.getSession().setAttribute(Const.CURRENT_USER, user);
    }
    return "redirect:/index";
}

// 登出
@GetMapping("/logout")
public String logout() {
    req.getSession().removeAttribute(Const.CURRENT_USER);
    return "redirect:/index";
}

內網穿透

好了,完成了上面程式碼之後我們就可以進行掃碼登入了,記得需要配置內網對映工具的穿透。比如我的:

圖片

我的回撥地址也設定成了 http://yimin.natapp1.cc/wx/back ,只有這樣微信回撥才能訪問到我本地的測試環境哈。

7、登入與許可權攔截

自定義@Login註解

並不是所有的連結都能隨意訪問的,所以需要做一個登入認證。這裡為了方便,讓專案架構更加輕便一些,我沒有使用shiro或者spring security等許可權框架。而是打算直接使用一個攔截器搞定,畢竟這裡只需要做個簡單的登入攔截就行,沒涉及到許可權的問題。

其實不寫做個@Login註解也是可以的,不過考慮到以後新增介面時候不忘記配置登入認證,索性就加了,註解很簡單,在需要登入認證才能訪問的介面上方標識這個註解就行了。

  • com.markerhub.base.annotation.Login
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Login {
}

編寫登入攔截器

登入攔截器的邏輯很簡單,首先需要判斷使用者是否已經登入,然後判斷請求的介面是否有@Login註解,如果有而且使用者未登入就重定向到登入介面;否則就放行。

  • com.markerhub.interceptor.AuthInterceptor
@Slf4j
public class AuthInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        UserDto userDto = (UserDto)request.getSession().getAttribute(Const.CURRENT_USER);
        if (userDto == null) {
            userDto = new UserDto();
            userDto.setId(-1L);
        }
        request.setAttribute("current", userDto);
        
        Login annotation;
        if(handler instanceof HandlerMethod) {
            annotation = ((HandlerMethod) handler).getMethodAnnotation(Login.class);
        }else{
            return true;
        }
        
        if(annotation == null){
            // 沒有@Login註解,說明是公開介面,直接放行
            return true;
        }
        
        if (userDto.getId() == null || userDto.getId() == -1L) {
            response.sendRedirect("/login");
            return false;
        }
        log.info("歡迎您:{}", userDto.getUsername());
        return true;
    }
}

然後我們需要配置一下攔截器,注入到springboot中,這個比較簡單,學過springboot的人都應該懂:

  • com.markerhub.config.WebConfig
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new AuthInterceptor()).addPathPatterns("/**")
                .excludePathPatterns(
                        "/js/**"
                        , "/css/**"
                        , "/images/**"
                        , "/layui/**"
                        );
    }
}

@Login作用於方法上,在需要登入認證的地方新增此註解就行了。效果啥的我就不截圖展示了,應該都能想象到。
圖片

作者:呂一明

原文連結:https://www.zhuawaba.com/post...

線上演示地址:https://www.zhuawaba.com/dail...

視訊講解:https://www.bilibili.com/vide...

原始碼地址:https://github.com/MarkerHub/...

8、我的收藏

實體設計

我們先來設計一個實體類,應用於儲存對應的收藏記錄,其實這個實體還是很簡單的,收藏主要的屬性就幾個:

  • id
  • userId - 收藏使用者
  • title - 標題
  • url - 收藏對應的連結
  • note - 筆記、收藏想法
  • personal - 是否僅本人可見

然後我們再加上一些必要的建立時間啥的,就可以了。因為是spring data jpa,我們需要一對多的這些關係,收藏對於使用者來說,是多對一的關係。所以實體類可以設計成這樣:

  • com.markerhub.entity.Collect
@Data
@Entity
@Table(name = "m_collect")
public class Collect implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String title;
    private String url;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;
    
    // 筆記想法
    private String note;
    
    // 是否公開,0公開,1私有,預設公開
    private Integer personal = 0;
    
    // 收藏日期,不存時間部分
    private LocalDate collected;
    
    private LocalDateTime created;

然後專案啟動之後,表結構會自動生成。對應到頁面的Dto,可以是這樣:

  • com.markerhub.base.dto.CollectDto
@Data
public class CollectDto implements Serializable {

    private Long id;
    private String title;
    private String url;
    private String note;
    
    // 是否公開,0公開,1私有,預設公開
    private Integer personal = 0;
    
    // 收藏日期
    private LocalDate collected;
    private LocalDateTime created;
    
    private UserDto user;
}

公共頁面抽取

接下來,我們完成系統的主要業務功能,收藏功能。首先我還是喜歡先去完成頁面,我的收藏就是整個系統的首頁。所以我在templates下新建index.ftl頁面。因為每個頁面都有公共的引用或者相同的元件部分,所以我們利用freemarker的巨集的概念,定義每個頁面的模組內容,把公共部分抽取出來。

於是抽取出來之後的公共模板內容是這樣的:

  • /inc/layout.ftl
<#macro layout title>
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>${title} - dailyhub</title>
        
        ...js 和 css
        
    </head>
    <body>
    <div class="container" style="max-width: 960px;">
        <#include "/inc/header.ftl" />
        <#nested>
    </div>
    
    
        $(function () {
            layui.config({
                version: false
                , debug: false
                , base: ''
            });
        });
    
    </body>
    </html>
</#macro>
  • macro 標籤就是用來定義巨集的

  • nested 引用標籤的內容主體位置。

我們看中間的body部分,include引入了header.ftl頁面,這個頁面我就不貼程式碼了貼個圖吧,就是logo、頂部導航、搜尋框、以及登入按鈕或使用者頭像資訊等。

  • /inc/header.ftl

圖片

頭部效果如下:

圖片

頁面排版

其實我css並不是很強,所以我都喜歡在boostrap(https://v5.bootcss.com/)上找模板套進來。

對於首頁:我的收藏的排版,我的構思是這樣的,上方是導航,下方左邊是使用者所有收藏的日期列表,右邊是收藏列表。

左邊的收藏日期列表每個人都是不一樣的,它應該是你所有收藏列表整合出來的一個無重複的日期列表,所以這裡需要從庫中查詢出來並且去重。

左邊打算用一個列表,我看中了bootstrap5中的這個https://v5.bootcss.com/docs/examples/cheatsheet/,於是直接把內容抽取過來。

圖片

右側的話,可以使用一個瀑布流的卡片,我看中了這個:https://v5.bootcss.com/docs/examples/masonry/。效果是這樣的:

圖片

整合之後,得到的頁面程式碼如下:

  • index.ftl
<#import 'inc/layout.ftl' as Layout>
<@Layout.layout "我的收藏">
    <div id="app" class="row justify-content-md-center">
        <#--側邊日期-->
        <div class="col col-3">
            <div class="flex-shrink-0 p-3 bg-white" style="width: 280px;">
                <ul class="list-unstyled ps-0">
                    <#list datelines as dateline>
                        <li class="mb-1">
                        
                            <button class="dateline btn btn-toggle align-items-center rounded collapsed"
                                    data-bs-toggle="collapse"
                                    data-bs-target="#collapse-${dateline.title}" aria-expanded="true">
                                ${dateline.title}
                            </button>
                            
                            <div class="collapse show" id="collapse-${dateline.title}">
                                <ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
                                    <#list dateline.children as child>
                                        <li><a href="hendleDateline('${child.title}')" class="link-dark rounded">${child.title}</a></li>
                                    </#list>
                                </ul>
                            </div>
                            
                        </li>
                    </#list>
                </ul>
            </div>
        </div>
        <!---->
        <div class="col col-9" id="collects-col">
            <#include "/inc/collect-tpl.ftl">
            <div class="row" id="masonry"></div>
        </div>
    </div>
    
    // ...js
    
</@Layout.layout>

因為多個地方需要用到收藏卡片,所以提取出來作為一個單獨模組,然後又因為後面我想弄成js瀑布流的模式,所以需要定義模板。於是就有了這卡片模板。

  • /inc/collect-tpl.ftl


    {{# layui.each(d.content, function(index, item){ }}
    <div class="col-sm-6 col-lg-6 mb-4 masonry-item" id="masonry-item-{{item.id}}">
        <div class="card p-3">
            <div class="card-body">
                <blockquote class="blockquote">
                
                    {{# if(item.personal == 1){ }}
                    <span class="badge bg-info text-dark">私有</span>
                    {{# } }}
                    
                    <a target="_blank" class="text-decoration-none" href="{{item.url}}"><span
                                class="card-title text-black">{{ item.title }}</span></a>
                </blockquote>
                
                <p class="card-text text-muted">
                    <img src="{{item.user.avatar}}" alt="mdo" width="32" height="32" class="rounded-circle">
                    <span>{{ item.user.username }}</span>
                    {{# if(item.user.id == ${current.id}){ }}
                    <a class="text-reset" href="/collect/edit?id={{item.id}}">編輯</a>
                    <a class="text-reset" href="handleDel({{item.id}})">刪除</a>
                    {{# } }}
                </p>
                
                <p class="card-text text-muted">{{ item.note }}</p>
                <figcaption class="blockquote-footer mb-0 text-muted text-end">
                    {{ item.collected }}
                </figcaption>
            </div>
        </div>
    </div>
    {{# }); }}
    

頁面模板的編寫和渲染都是按照layui的格式來的,所以可以提前去layui的網站上去了解一下layui定義頁面模板。

日期側邊欄載入

首先是需要一個controller跳轉到頁面,需要編寫IndexController

  • com.markerhub.controller.IndexController
@Slf4j
@Controller
public class IndexController extends BaseController{

    @Login
    @GetMapping(value = {"", "/"})
    public String index() {
        // 時間線
        List<DatelineDto> datelineDtos = collectService.getDatelineByUserId(getCurrentUserId());
        req.setAttribute("datelines", datelineDtos);
        
        // 為了搜尋自己的收藏
        req.setAttribute("userId", getCurrentUserId());
        return "index";
    }
    
}

其中DatelineDto是有上下級關係的,所以寫成這樣:

  • com.markerhub.base.dto.DatelineDto
@Data
public class DatelineDto {
    private String title;
    private List<DatelineDto> children = new ArrayList<>();
}

然後collectService.getDatelineByUserId就是為了篩選出使用者的所有收藏的日期集合。獲取使用者收藏日期列表的步驟如下:

  1. 查詢資料庫,獲取使用者所有收藏的所有日期,並且去重。
  2. 日期按照(XXXX年XX月XX日)格式,上級的格式就是(XXXX年XX月)
  3. 把月份相同的日期自動排到一起
  • com.markerhub.service.impl.CollectServiceImpl
@Service
public class CollectServiceImpl implements CollectService {

    @Autowired
    CollectRepository collectRepository;
    
    /**
     * 獲取使用者的收藏日期列表
     */
    @Override
    public List<DatelineDto> getDatelineByUserId(long userId) {
        List<Date> collectDates = collectRepository.getDateLineByUserId(userId);
        List<DatelineDto> datelineDtos = new ArrayList<>();
        
        for (Date date : collectDates) {
            // 獲取上級、當前日期的標題
            String parent = DateUtil.format(date, "yyyy年MM月");
            String title = DateUtil.format(date, "yyyy年MM月dd日");
            
            datelineDtos = handleDateline(datelineDtos, parent, title);
        }
        return datelineDtos;
    }
    
    /**
     * 如果上級存在就直接新增到子集中,如果不存在則新建父級再新增子集
     */
    private List<DatelineDto> handleDateline(List<DatelineDto> datelineDtos, String parent, String title) {
        DatelineDto dateline = new DatelineDto();
        dateline.setTitle(title);
        // 查詢是否有上級存在
        Optional<DatelineDto> optional = datelineDtos.stream().filter(vo -> vo.getTitle().equals(parent)).findFirst();
        if (optional.isPresent()) {
            optional.get().getChildren().add(dateline);
            
        } else {
            // 沒有上級,那麼就新建一個上級
            DatelineDto parentDateline = new DatelineDto();
            parentDateline.setTitle(parent);
            
            // 並且把自己新增到上級
            parentDateline.getChildren().add(dateline);
            
            // 上級新增到列表中
            datelineDtos.add(parentDateline);
        }
        return datelineDtos;
    }
}
  • com.markerhub.repository.CollectRepository
public interface CollectRepository extends JpaRepository<Collect, Long>, JpaSpecificationExecutor<Collect> {

    @Query(value = "select distinct collected from m_collect where user_id = ? order by collected desc", nativeQuery = true)
    List<Date> getDateLineByUserId(long userId);
}

注意distinct去重哈。得出來的效果如下:
圖片

瀑布流資料載入

然後中間內容部分,我們使用layui的瀑布流資料載入。


    var userId = '${userId}'
    if (userId == null || userId == '') {
        userId = '${current.id}'
    }
    
    var laytpl, flow
    // 初始化layui的模板和瀑布流模組
    layui.use(['laytpl', 'flow'], function () {
        laytpl = layui.laytpl;
        flow = layui.flow;
    });
    
    // layui的瀑布流載入資料
    function flowLoad(dateline) {
        flow.load({
            elem: '#masonry'
            , isAuto: false
            , end: '哥,這回真的沒了~'
            , done: function (page, next) {
            
                $.get('${base}/api/collects/' + userId + '/'+ dateline, {
                    page: page,
                    size: 10
                }, function (res) {
                    var lis = [];
                    
                    var gettpl = $('#collect-card-tpl').html();
                    laytpl(gettpl).render(res.data, function (html) {
                        $(".layui-flow-more").before(html);
                    });
                    
                    next(lis.join(''), page < res.data.totalPages);
                })
                
            }
        });
    }
    // 點選時間篩選,重新重新整理瀑布流資料
    function hendleDateline(dateline) {
        $('#masonry').html('');
        flowLoad(dateline)
    }
    // 刪除操作
    function handleDel(id) {
        layer.confirm('是否確認刪除?', function (index) {
            $.post('${base}/api/collect/delete?id=' + id, function (res) {
                if (res.code == 0) {
                    $('#masonry-item-' + id).remove()
                }
                layer.msg(res.mess)
            })
            layer.close(index);
        });
    }
    $(function () {
        // 初始化載入,all表示全部
        flowLoad('all')
    });

頁面載入的時候開始執行flowload('all'),載入全部當前使用者的資料。
接下來,我們來完成內容主體部分的資料載入,在js中,我們使用的是資料瀑布流的方式,所以在定義介面時候注意要分頁哈。

@Slf4j
@Controller
public class CollectController extends BaseController{

    @Login
    @ResponseBody
    @GetMapping("/api/collects/{userId}/{dateline}")
    public Result userCollects (@PathVariable(name = "userId") Long userId,
                              @PathVariable(name = "dateline") String dateline) {
        Page<CollectDto> page = collectService.findUserCollects(userId, dateline, getPage());
        return Result.success(page);
    }
    
}

除了分頁資訊,對應的引數還有使用者Id和收藏日期可以作為引數,查詢對應使用者的某個日期的收藏列表,當日期引數為all時候查詢該使用者的全部,同時當查處的使用者是自己的時候,可以查出私有的收藏。
BaseController中的getPage方法,預設都是按照收藏日期排序:

  • com.markerhub.controller.BaseController#getPage
Pageable getPage() {
    int page = ServletRequestUtils.getIntParameter(req, "page", 1);
    int size = ServletRequestUtils.getIntParameter(req, "size", 10);
    
    return PageRequest.of(page - 1, size,
            Sort.by(Sort.Order.desc("collected"), Sort.Order.desc("created")));
}

我們來重點看看findUserCollects方法。

  • com.markerhub.service.impl.CollectServiceImpl#findUserCollects
/**
 * 查詢某使用者的某個日期的收藏
 */
@Override
public Page<CollectDto> findUserCollects(long userId, String dateline, Pageable pageable) {
    Page<Collect> page = collectRepository.findAll((root, query, builder) -> {
        Predicate predicate = builder.conjunction();
        
        // 關聯查詢
        Join<Collect, User> join = root.join("user", JoinType.LEFT);
        predicate.getExpressions().add(builder.equal(join.get("id"), userId));

        // all表示查詢全部
        if (!dateline.equals("all")) {
            // 轉日期格式
            LocalDate localDate = LocalDate.parse(dateline, DateTimeFormatter.ofPattern("yyyy年MM月dd日"));
            predicate.getExpressions().add(
                    builder.equal(root.<Date>get("collected"), localDate));
        }
        
        UserDto userDto = (UserDto)httpSession.getAttribute(Const.CURRENT_USER);
        boolean isOwn = (userDto != null && userId == userDto.getId().longValue());
        
        // 非本人,只能檢視公開的
        if (!isOwn) {
            predicate.getExpressions().add(
                    builder.equal(root.get("personal"), Const.collect_opened));
        }
        
        return predicate;
    }, pageable);
    
    // 實體轉Dto
    return page.map(collectMapper::toDto);
}

這裡面,有關聯查詢,收藏與使用者是多對一的關係,所以相對還是比較簡單的,只需要左連線使用者表,然後讓使用者表的id為指定的使用者ID即可。
收藏日期這裡,傳進來的引數格式是這樣的:yyyy年MM月dd日,所以需要轉格式,讓資料庫能識別對比。

然後非本人只能看公開的這裡,需要從HttpSession中檢視當前使用者的資訊,對比ID是否是同一人,非本人只能檢視公開的收藏。

最後,需要查詢出來page中的內容是實體Collect的列表,需要把Collect專場CollectDto,這時候我們又需要用到mapstruct了。

  • com.markerhub.mapstruct.CollectMapper
@Mapper(componentModel = "spring", uses = {UserMapper.class}, unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface CollectMapper {

    CollectDto toDto(Collect collect);
    List<CollectDto> toDto(List<Collect> collects);
}

需要注意CollectDto裡面有UserDto,所以我們在@Mapper註解中需要加上UserMapper.class做對應的轉化。可以看到mapstruct自動幫我們生成的實現程式碼是這樣的:
圖片

ok,這樣我們就完成了資料載入的介面,我們先來看下效果:

圖片

分頁效果:

圖片

這個載入更多就是layui的瀑布流資料載入給我們生成的。

刪除操作

刪除操作比較簡單,刪除時候注意對比一下是否是當前使用者的收藏!

  • com.markerhub.controller.CollectController#delCollect
@Login
@ResponseBody
@PostMapping("/api/collect/delete")
public Result delCollect (long id) {
    Collect collect = collectService.findById(id);
    
    Assert.notNull(collect, "不存在該收藏");
    Assert.isTrue(getCurrentUserId() == collect.getUser().getId(), "無許可權刪除!");
    
    collectService.deleteById(id);
    return Result.success();
}

刪除提示效果:
圖片

9、新增、編輯收藏

對於新增和編輯,業務處理差不多的,所以我們放在同一個方法中處理。

  • com.markerhub.controller.CollectController#editCollect
@Value("${server.domain}")
String serverDomain;

@Login
@GetMapping("/collect/edit")
public String editCollect(Collect collect) throws UnsupportedEncodingException {
    
    // 這段js是為了放在瀏覽器書籤中方便後面直接收藏某頁面。
    // 編碼這段js:
    String js = "(function(){" +
          "var site='" + serverDomain +
          "/collect/edit?chatset='" +
          "+document.charset+'&title='+encodeURIComponent(document.title)" +
          "+'&url='+encodeURIComponent(document.URL);" +
          "var win = window.open(site, '_blank');" +
          "win.focus();})();";
          
    // javascript後面的這個冒號不能編碼
    js = "" + URLUtil.encode(js);
    
    if (collect.getId() != null) {
        Collect temp = collectService.findById(collect.getId());
        // 只能編輯自己的收藏
        Assert.notNull(temp, "未找到對應收藏!");
        Assert.isTrue(getCurrentUserId() == temp.getUser().getId(), "無許可權操作!");
        BeanUtil.copyProperties(temp, collect);
    }
    
    req.setAttribute("js", js);
    req.setAttribute("collect", collect);
    return "collect-edit";
}

頁面的話,就是一個表單:

  • collect-edit.ftl
<#import "inc/layout.ftl" as Layout>
<@Layout.layout "收藏操作">
    <div id="app" class="row justify-content-md-center">
        <div class="alert alert-info" role="alert">
            請把此連結:<a href="${js}" class="alert-link">立即收藏</a>,固定到瀏覽器的書籤欄。
        </div>
        
        <form class="row g-3" style="width: 500px;" id="collect-form">
            <input type="hidden" name="id" value="${collect.id}">
            <div class="col-12">
                <label for="title" class="form-label">標題 *</label>
                <input type="text" name="title" class="form-control" id="title" value="${collect.title}" required>
            </div>
            <div class="col-12">
                <label for="url" class="form-label">連結 *</label>
                <input type="text" name="url" class="form-control" id="url" value="${collect.url}" required>
            </div>
            <div class="col-12">
                <label for="validationDefault04" class="form-label">筆記</label>
                <textarea class="form-control" name="note" id="validationDefault04">${collect.note}
            </div>
            <div class="col-12">
                <div class="form-check">
                    <input class="form-check-input" type="checkbox" name="personal" value="1" id="personal" <#if collect.personal == 1>checked</#if>>
                    <label class="form-check-label" for="personal">
                        私有的,不在收藏廣場中展示此收藏!
                    </label>
                </div>
            </div>
            <div class="col-12">
                <button class="btn btn-primary" type="submit">提交收藏</button>
            </div>
        </form>
    </div>

然後js部分:

    
    $(function () {
        $("#collect-form").submit(function (event) {
            // 阻止提交
            event.preventDefault()
            
            // 非同步提交表單
            $.ajax({
                type: "POST",
                url: "/collect/save",
                data: $("#collect-form").serialize(),
                success: function(res){
                    layer.msg(res.mess, {
                        time: 2000
                    },  function(){
                        location.href = "/";
                    });
                }
            });
        })
    });

</@Layout.layout>

為了保留boostrap的校驗效果,然後又實現非同步提交表單,所以寫了js的submit()表單方法。
頁面效果如下:

圖片

然後提交儲存的方法:

  • com.markerhub.controller.CollectController#saveCollect
@Login
@ResponseBody
@PostMapping("/collect/save")
public Result saveCollect(Collect collect) {

   Assert.hasLength(collect.getTitle(), "標題不能為空");
   Assert.hasLength(collect.getUrl(), "URL不能為空");
   
   if (collect.getId() != null) {
      Collect temp = collectService.findById(collect.getId());
      // 只能編輯自己的收藏
      Assert.notNull(temp, "未找到對應收藏!");
      Assert.isTrue(getCurrentUserId() == temp.getUser().getId(), "無許可權操作!");
   }
   
   User user = new User();
   user.setId(getCurrentUser().getId());
   collect.setUser(user);
   
   collectService.save(collect);
   
   return Result.success();
}

還有service中的儲存方法:

  • com.markerhub.service.impl.CollectServiceImpl#save
@Override
@Transactional(rollbackFor = Exception.class)
public void save(Collect collect) {
    if (collect.getId() == null) {
        collect.setCreated(new Date());
        collect.setCollected(new Date());
        collectRepository.save(collect);
    } else {
        Collect temp = collectRepository.getById(collect.getId());
        // 屬性複製
        temp.setTitle(collect.getTitle());
        temp.setUrl(collect.getUrl());
        temp.setNote(collect.getNote());
        temp.setUser(collect.getUser());
        temp.setPersonal(collect.getPersonal());
        temp.setCollected(new Date());
        collectRepository.save(temp);
    }
}

10、收藏廣場

收藏廣場,就是所有使用者公開分享收藏的地方,只需要把所有的公開收藏都按照分頁查詢出來即可。前面做過我的收藏,其實差不多。

  • com.markerhub.controller.IndexController#collectSquare
@GetMapping("/collect-square")
public String collectSquare () {
    return "collect-square";
}

因為後面我做了個搜尋功能,搜尋頁面和這個收藏廣場的頁面是差不多了,為了重複工作,所以這裡面我加入了一下搜尋的元素。

  • com.markerhub.controller.CollectController#allCollectsSquare
@ResponseBody
@GetMapping("/api/collects/square")
public Result allCollectsSquare() {
      Page<CollectDto> page = collectService.findSquareCollects(getPage());

      return Result.success(page);
   }
}
  • com.markerhub.service.impl.CollectServiceImpl#findSquareCollects
@Override
public Page<CollectDto> findSquareCollects(Pageable pageable) {

    Page<Collect> page = collectRepository.findAll((root, query, builder) -> {
        Predicate predicate = builder.conjunction();
        
        // 只查公開分享的
        predicate.getExpressions().add(
                builder.equal(root.get("personal"), 0));
        
        return predicate;
    }, pageable);
    return page.map(collectMapper::toDto);
}

頁面:

  • collect-square.ftl
<#import 'inc/layout.ftl' as Layout>
<@Layout.layout "收藏廣場">
   <div id="app" class="row justify-content-md-center">
      <#--搜尋提示-->
        <#if searchTip>
         <div class="alert alert-info" role="alert">${searchTip}</div>
        </#if>
        
      <div class="col">
            <#include "/inc/collect-tpl.ftl">
         <div class="row" id="masonry"></div>
      </div>
   </div>
   
      var laytpl, flow
      layui.use(['laytpl', 'flow'], function () {
         laytpl = layui.laytpl;
         flow = layui.flow;
      });
      function flowLoad(keyword, userId) {
         flow.load({
            elem: '#masonry'
            , isAuto: false
            , end: '哥,這回真的沒了~'
            , done: function (page, next) {
               $.get('/api/collects/square', {
                  page: page,
                  size: 10,
                  q: keyword,
                  userId: userId
               }, function (res) {
                  var lis = [];
                  var gettpl = $('#collect-card-tpl').html();
                  laytpl(gettpl).render(res.data, function (html) {
                     $(".layui-flow-more").before(html);
                  });
                  next(lis.join(''), page < res.data.totalPages);
               })
            }
         });
      }
      function handleDel(id) {
         layer.confirm('是否確認刪除?', function (index) {
            $.post('/api/collect/delete?id=' + id, function (res) {
               if (res.code == 0) {
                  $('#masonry-item-' + id).remove()
               }
               layer.msg(res.mess)
            })
            layer.close(index);
         });
      }
      $(function () {
         flowLoad('${q}', ${userId})
      });
   
</@Layout.layout>

頁面效果如下:

圖片

11、搜尋功能

搜尋是個很常用也很重要的功能,為了提高搜尋的響應速度,常用的搜尋中介軟體有elasticsearch和solr,這次我們來使用elasticsearch來配合我們的專案完成搜尋功能。

那麼mysql裡面的資料如何與elasticsearch進行同步呢?其實解決方式還是挺多的,我們之前在eblog專案中,就藉助rabbitmq來配合資料同步,當後臺資料發生變化時候,我們傳送訊息到mq,消費端消費訊息然後更新elasticsearch,從而讓資料達成同步。這樣程式碼的開發量就聽多了。

這次,我們使用canal來完成資料的同步,canal是偽裝成mysql的備份機基於binlog來完成資料同步的,所以在程式碼中,我們就不再需要管理資料同步的問題。因此,我們程式直接連線elasticsearch進行搜尋功能開發就行,然後伺服器上canal等中介軟體的安裝,我們在另外一篇文章中手把手教大家完成搭建。完整文件:https://shimo.im/docs/TWTTkTTXGyRDcYjk

對搜尋功能的需求如下:

  • 可以對某個使用者進行單獨搜尋
  • 對搜尋廣場的所有公開收藏進行搜尋

下面我們編寫搜尋介面:

@Slf4j
@Controller
public class SearchController extends BaseController {

   @GetMapping("/search")
   public String search (String q, Long userId) {
   
      req.setAttribute("q", q);
      
      // 單獨搜尋某個使用者的收藏記錄
      req.setAttribute("userId", userId);
      
      String message = "正在搜尋公開【收藏廣場】的收藏記錄";
      if (userId != null) {
         UserDto userDto = userService.getDtoById(userId);
         if (userDto != null) {
            message = "正在搜尋使用者【" + userDto.getUsername()  + "】的收藏記錄";
         }
      }
      req.setAttribute("searchTip", message);
      
      return "collect-square";
   }
}

頁面依然是收藏廣場的頁面。
圖片

基於我的收藏頁面搜尋的時候,搜尋的就是我自己的收藏

基於收藏廣場頁面搜尋的時候,搜尋的就是所有公開搜尋的積累。

當然了,也可以點選某個使用者的頭像進去,搜尋的就是該使用者的記錄

私有的記錄,非本人都是無法搜尋出來的。

@Slf4j
@Service
public class SearchServiceImpl implements SearchService {

   @Autowired
   CollectDocRepository collectDocRepository;
   @Autowired
   ElasticsearchRestTemplate elasticsearchRestTemplate;
   @Autowired
   CollectDocMapper collectDocMapper;
   @Autowired
   HttpSession httpSession;
   
   public Page<CollectDto> search(String keyword, Long userId, Pageable pageable) {
      Criteria criteria = new Criteria();
      
      if (userId != null && userId > 0) {
         // 新增userId的條件查詢
         criteria.and(new Criteria("userId").is(userId));
      }
      
      UserDto userDto = (UserDto)httpSession.getAttribute(Const.CURRENT_USER);
     
       if (userDto != null && userId != null) {
         boolean isOwn = userId.longValue() == userDto.getId().longValue();
         if (isOwn) {
            // 如果是搜尋自己的,公開私有都可以搜尋
            criteria.and(new Criteria("personal").in(0, 1));
         } else {
            // 如果是搜尋別人的,那麼只能搜尋公開的
            criteria.and(new Criteria("personal").is(0));
         }
      } else {
         // 未登入使用者、或者搜尋廣場的,只能搜尋公開
         criteria.and(new Criteria("personal").is(0));
      }
      
      CriteriaQuery criteriaQuery = new CriteriaQuery(criteria
            .and(new Criteria("title").matches(keyword))
            .or(new Criteria("note").matches(keyword))
      ).setPageable(pageable);
      
      SearchHits<CollectDoc> searchHits = elasticsearchRestTemplate.search(criteriaQuery, CollectDoc.class);
      
      List<CollectDoc> result = searchHits.get().map(e -> {
         CollectDoc element = e.getContent();
         return element;
      }).collect(Collectors.toList());
      
      Page<CollectDoc> docPage = new PageImpl<>(result, pageable, searchHits.getTotalHits());
      log.info("共查出 {} 條記錄", docPage.getTotalElements());
      
      return docPage.map(collectDocMapper::toDto);
   }
   
   @Override
   public Page<CollectDto> searchH(String q, Pageable page) {
      // jpa 無法做到條件查詢,所以把userId去掉了
      Page<CollectDoc> docPage = collectDocRepository.findByPersonalAndTitleLikeOrNoteLike(0, q, q, page);
      return docPage.map(collectDocMapper::toDto);
   }
}

除了使用elasticsearchRestTemplate,我還藉助jpa的命名規範寫了一條搜尋功能searchH,但是這樣的寫法是在無法做到條件搜尋,所以乾脆放棄了這種寫法。
在search方法中對於是不是搜尋本人的記錄做了很多判斷,這裡大家需要注意,其他沒啥說的,都是一些條件的加入,其實elasticsearchRestTemplate還有很種搜尋寫法,感興趣的可以多多百度搜尋,不過最新版本的elasticsearch資料還是相對比較少的,很多老版本的寫法已經不適用了。

12、結束語

好啦,廢了好長的時間才把專案和所有文件寫完,這還不值得你關注我的公眾號:Java問答社、MarkerHub 這兩個號嗎,哈哈,順便給我點個贊吧,感謝,我是呂一明,此時我的原創專案,轉載請註明出處,感謝!

首發公眾號:MarkerHub

作者:呂一明

原文連結:https://www.zhuawaba.com/post...

線上演示地址:https://www.zhuawaba.com/dail...

視訊講解:https://www.bilibili.com/vide...

原始碼地址:請關注公眾號:Java問答社,回覆【678】獲取