乐趣区

关于springboot:带你从0搭建一个Springbootelasticsearchcanal的完整项目-dailyhub

首发公众号: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 两个我的项目中,全局异样解决我都喜爱用注解 @ControllerAdvice+@ExceptionHandler 来解决异样,这次咱们应用另外一种形式,咱们还能够通过继承 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,

  • 码云:https://gitee.com/binary/weixin-java-tools
  • GitHub:https://github.com/wechat-group/WxJava
    对应公众号开发相干的文档:https://github.com/Wechat-Group/WxJava/wiki/ 公众号开发文档,咱们要解决的首先就是同步回复音讯的接口。

当初版本更新很快,我以前应用的是 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 的瀑布流数据加载。

  • 非官方网站阐明:http://bmxpx.com/modules/flow.html
    页面的 js 局部:
  • /index.ftl

    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】获取

退出移动版