1

首发公众号: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,

现在版本更新很快,我以前使用的是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】获取


MarkerHub
538 声望246 粉丝