仿牛客网项目学习笔记(二)

429 阅读9分钟

上传头像功能

注意:1. 必须是Post请求 2.表单:enctype="multipart/form-data" 3.参数类型MultipartFile只能封装一个文件

上传路径可以是本地路径也可以是web路径

访问路径必须是符合HTTP协议的Web路径

1.编写Service和Dao层

//Dao层
<update id="updatePassword">
    update user set password=#{password} where id=#{id}
</update>
int updateHeader(@Param("id") int id,@Param("headerUrl") String headerUrl);

//Service层
/**更换上传头像**/
public int updateHeader(int userId,String headerUrl){
    return userMapper.updateHeader(userId,headerUrl);
}

2.编程Controller层

@Controller
@RequestMapping("/user")
public class UserController {

    private static final Logger logger = LoggerFactory.getLogger(UserController.class);

    //community.path.upload = d:/DemoNowcoder/upload
    @Value("${community.path.upload}")
    private String uploadPath;

    @Value("${community.path.domain}")
    private String domain;

    @Value("${server.servlet.context-path}")
    private String contextPath;

    @Autowired
    private UserService userService;

    @Autowired
    /**获得当前登录用户的信息* */
    private HostHolder hostHolder;

    @RequestMapping(value = "/setting",method = RequestMethod.GET)
    public String getSettingPage(){
        return "/site/setting";
    }
    
    //上传头像
    @RequestMapping(value = "/upload",method = RequestMethod.POST)
    public String uploadHeader(MultipartFile headerImage, Model model){
    //StringUtils.isBlank(headerImage)
        if (headerImage == null){
            model.addAttribute("error","您还没有选择图片!");
            return "/site/setting";
        }
        /*
        * 获得原始文件名字
        * 目的是:生成随机不重复文件名,防止同名文件覆盖
        * 方法:获取.后面的图片类型 加上 随机数
        */
        String filename = headerImage.getOriginalFilename();
        String suffix = filename.substring(filename.lastIndexOf(".") );

        //任何文件都可以上传,根据业务在此加限制
        if (StringUtils.isBlank(suffix)){
            model.addAttribute("error","文件格式不正确!");
            return "/site/setting";
        }

        //生成随机文件名
        filename = CommunityUtil.generateUUID() + suffix;
        //确定文件存放路劲
        File dest = new File(uploadPath + "/" +filename);
        try{
            //将文件存入指定位置
            headerImage.transferTo(dest);
        }catch (IOException e){
            logger.error("上传文件失败: "+ e.getMessage());
            throw new RuntimeException("上传文件失败,服务器发生异常!",e);
        }
        //更新当前用户的头像的路径(web访问路径)
        //http://localhost:8080/community/user/header/xxx.png
        User user = hostHolder.getUser();
        String headerUrl = domain + contextPath + "/user/header/" + filename;
        userService.updateHeader(user.getId(),headerUrl);

        return "redirect:/index";
    }
  //得到服务器图片
  @RequestMapping(path = "/header/{fileName}", method = RequestMethod.GET)
  /**void:返回给浏览器的是特色的图片类型所以用void**/
  public void getHeader(@PathVariable("fileName") String fileName, HttpServletResponse response) {
      // 服务器存放路径(本地路径)
      fileName = uploadPath + "/" + fileName;
      // 文件后缀
      String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);
      // 浏览器响应图片
      response.setContentType("image/" + suffix);
      try (
              //图片是二进制用字节流
              FileInputStream fis = new FileInputStream(fileName);
              OutputStream os = response.getOutputStream();
      ) {
          //设置缓冲区
          byte[] buffer = new byte[1024];
          //设置游标
          int b = 0;
          while ((b = fis.read(buffer)) != -1) {
              os.write(buffer, 0, b);
          }
      } catch (IOException e) {
          logger.error("读取头像失败: " + e.getMessage());
      }
  }

3.前端核心页面

<form class="mt-5" method="post" enctype="multipart/form-data" th:action="@{/user/upload}">
    <div class="custom-file">
      <input type="file"
           th:class="|custom-file-input ${error!=null?'is-invalid':''}|"
           name="headerImage" id="head-image" lang="es" required="">
      <label class="custom-file-label" for="head-image" data-browse="文件">选择一张图片</label>
      <div class="invalid-feedback" th:text="${error}">
        该账号不存在!
      </div>
    </div>
</form>

过滤敏感词

前缀树 :1.根节点不包含字符,除根节点以外的每个节点,只包含一个字符

2.从根节点到某一个节点,路径上经过的字符连接起来,为该节点对应字符串

3.每个节点的所有子节点,包含的字符串不相同

核心 :1.有一个指针指向前缀树,用以遍历敏感词的每一个字符

2.有一个指针指向被过滤字符串,用以标识敏感词的开头

3.有一个指针指向被过滤字符串,用以标识敏感词的结尾

1.过滤敏感词算法

在resources创建sensitive-words.txt文敏感词文本

/**
 * 过滤敏感词工具类
 * 类似于二叉树的算法
 */
@Component
public class SensitiveFilter {

    private static final Logger logger = LoggerFactory.getLogger(SensitiveFilter.class);

    // 替换符
    private static final String REPLACEMENT = "* **";

    // 根节点
    private TrieNode rootNode = new TrieNode();

    // 编译之前运行
    @PostConstruct
    public void init() {
        try (
                // 读取文件流 BufferedReader带缓冲区效率更高
                InputStream is = this.getClass().getClassLoader().getResourceAsStream("sensitive-words.txt");
                BufferedReader reader = new BufferedReader(new InputStreamReader(is));
        ) {
            String keyword;
            // 一行一行读取文件中的字符
            while ((keyword = reader.readLine()) != null) {
                // 添加到前缀树
                this.addKeyword(keyword);
            }
        } catch (IOException e) {
            logger.error("加载敏感词文件失败: " + e.getMessage());
        }
    }
    /**
     * 将一个敏感词添加到前缀树中
     * 类似于空二叉树的插入
     */
    private void addKeyword(String keyword) {
        TrieNode tempNode = rootNode;
        for (int i = 0; i < keyword.length(); i++) {
            //将汉字转化为Char值
            char c = keyword.charAt(i);
            TrieNode subNode = tempNode.getSubNode(c);

            if (subNode == null) {
                // 初始化子节点并加入到前缀树中
                subNode = new TrieNode();
                tempNode.addSubNode(c, subNode);
            }

            // 指向子节点,进入下一轮循环
            tempNode = subNode;

            // 设置结束标识
            if (i == keyword.length() - 1) {
                tempNode.setKeywordEnd(true);
            }
        }
    }

    /**
     * 过滤敏感词
     * @param text 待过滤的文本
     * @return 过滤后的文本
     */
    public String filter(String text) {
        if (StringUtils.isBlank(text)) {
            return null;
        }

        // 指针1
        TrieNode tempNode = rootNode;
        // 指针2
        int begin = 0;
        // 指针3
        int position = 0;
        // 结果(StringBuilder:可变长度的String类)
        StringBuilder sb = new StringBuilder();

        while (position < text.length()) {
            char c = text.charAt(position);
            // 跳过符号
            if (isSymbol(c)) {
                // 若指针1处于根节点,将此符号计入结果,让指针2向下走一步
                if (tempNode == rootNode) {
                    sb.append(c);
                    begin++;
                }
                // 无论符号在开头或中间,指针3都向下走一步
                position++;
                continue;
            }

            // 检查下级节点
            tempNode = tempNode.getSubNode(c);
            if (tempNode == null) {
                // 以begin开头的字符串不是敏感词
                sb.append(text.charAt(begin));
                // 进入下一个位置
                position = ++begin;
                // 重新指向根节点
                tempNode = rootNode;
            } else if (tempNode.isKeywordEnd()) {
                // 发现敏感词,将begin~position字符串替换掉
                sb.append(REPLACEMENT);
                // 进入下一个位置
                begin = ++position;
                // 重新指向根节点
                tempNode = rootNode;
            } else {
                // 检查下一个字符
                position++;
            }
        }

        // 将最后一批字符计入结果
        sb.append(text.substring(begin));

        return sb.toString();
    }

    // 判断是否为符号
    private boolean isSymbol(Character c) {
        // 0x2E80~0x9FFF 是东亚文字范围
        return !CharUtils.isAsciiAlphanumeric(c) && (c < 0x2E80 || c > 0x9FFF);
    }

    // 构造前缀树数据结构
    private class TrieNode {

        // 关键词结束标识
        private boolean isKeywordEnd = false;

        // 子节点(key是下级字符,value是下级节点)
        private Map<Character, TrieNode> subNodes = new HashMap<>();

        public boolean isKeywordEnd() {
            return isKeywordEnd;
        }

        public void setKeywordEnd(boolean keywordEnd) {
            isKeywordEnd = keywordEnd;
        }

        // 添加子节点
        public void addSubNode(Character c, TrieNode node) {
            subNodes.put(c, node);
        }

        // 获取子节点
        public TrieNode getSubNode(Character c) {
            return subNodes.get(c);
        }

    }

}

2.引入第三方Maven,如下:

https://github.com/jinrunheng/sensitive-words-filter

<dependency>
  <groupId>io.github.jinrunheng</groupId>
  <artifactId>sensitive-words-filter</artifactId>
  <version>0.0.1</version>
</dependency>

发布贴子

核心 :ajax异步:整个网页不刷新,访问服务器资源返回结果,实现局部的刷新。

实质:JavaScript和XML(但目前JSON的使用比XML更加普遍)

封装Fastjson工具类

  //使用fastjson,将JSON对象转为JSON字符串(前提要引入Fastjson)
  public static String getJSONString(int code, String msg, Map<String, Object> map) {
      JSONObject json = new JSONObject();
      json.put("code",code);
      json.put("msg",msg);
      if (map != null) {
          //从map里的key集合中取出每一个key
          for (String key : map.keySet()) {
              json.put(key, map.get(key));
          }
      }
      return json.toJSONString();
  }
  public static String getJSONString(int code, String msg) {
    return getJSONString(code, msg, null);
  }
  public static String getJSONString(int code) {
    return getJSONString(code, null, null);
  }

ajax异步Demo示例

  /**
   * Ajax异步请求示例
   */
  @RequestMapping(value = "/ajax", method = RequestMethod.POST)
  @ResponseBody
  public String testAjax(String name, int age) {
      System.out.println(name);
      System.out.println(age);
      return CommunityUtil.getJSONString(200,"操作成功!");
  }
  //异步JS
  <input type="button" value="发送" onclick="send();">
  function send() {
      $.post(
          "/community/test/ajax",
          {"name":"张三","age":25},
          //回调函数返回结果
          function(data) {
              console.log(typeof (data));
              console.log(data);
              //返回json字符串格式(fastJson)
              data = $.parseJSON(data);
              console.log(typeof (data));
              console.log(data.code);
              console.log(data.msg);
          }
      )
  }

1.编写Mapper层

int insertDiscussPost(DiscussPost discussPost);

<sql id="insertFields">
    user_id,title,content,type,status,create_time,comment_count,score
</sql>
<insert id="insertDiscussPost" parameterType="DiscussPost">
    insert into discuss_post(<include refid="insertFields"></include>)
    values (#{userId}, #{title}, #{content}, #{type}, #{status}, #{createTime}, #{commentCount}, #{score})
</insert>

2.编写Service层

  public int addDiscussPost(DiscussPost post){
      if(post == null){
          //不用map直接抛异常
          throw new IllegalArgumentException("参数不能为空!");
      }
      //转义HTML标签,Springboot自带转义工具HtmlUtils.htmlEscape()
      post.setTitle(HtmlUtils.htmlEscape(post.getTitle()));
      post.setContent(HtmlUtils.htmlEscape(post.getContent()));
      //过滤敏感词
      post.setTitle(sensitiveFilter.filter(post.getTitle()));
      post.setContent(sensitiveFilter.filter(post.getContent()));

      return discussPostMapper.insertDiscussPost(post);
  }

3.编写Controller层(异步请求要加@ResponseBody,且不用在Controller层用Model,用Js)

  @Autowired
  private DiscussPostService discussPostService;
  @Autowired
  private HostHolder hostHolder;
  
  @RequestMapping(value = "/add", method = RequestMethod.POST)
  @ResponseBody    //返回Json格式,一定要加@ResponseBody
  public String addDiscussPost(String title, String content){
      //获取当前登录的用户
      User user = hostHolder.getUser();
      if (user == null){
          //403权限不够
          return CommunityUtil.getJSONString(403,"你还没有登录哦!");
      }
      DiscussPost post = new DiscussPost();
      post.setUserId(user.getId());
      post.setTitle(title);
      post.setContent(content);
      post.setCreateTime(new Date());
      //业务处理,将用户给的title,content进行处理并添加进数据库
      discussPostService.addDiscussPost(post);

      //返回Json格式字符串给前端JS,报错的情况将来统一处理
      return CommunityUtil.getJSONString(0,"发布成功!");
  }

4.编写前端异步JS

注意:$.parseJSON(data) →通过jQuery,将服务端返回的JSON格式的字符串转为js对象

$(function(){
  $("#publishBtn").click(publish);
});

function publish() {
  $("#publishModal").modal("hide");
  /**
  * 服务器处理
  */
  // 获取标题和内容
  var title = $("#recipient-name").val();
  var content = $("#message-text").val();
  // 发送异步请求(POST)
  $.post(
    CONTEXT_PATH + "/discuss/add",
    //与Controller层两个属性要一致!!!
    {"title":title,"content":content},
    function(data) {
      //把json字符串转化成Js对象,后面才可以调用data.msg
      data = $.parseJSON(data);
      // 在提示框中显示返回消息
      $("#hintBody").text(data.msg);
      // 显示提示框
      $("#hintModal").modal("show");
      // 2秒后,自动隐藏提示框
      setTimeout(function(){
        $("#hintModal").modal("hide");
        // 刷新页面
        if(data.code == 0) {
          window.location.reload();
        }
      }, 2000);
    }
  );
}

查看帖子详情

1.编写Mapper层

DiscussPost selectDiscussPostById(int id);
<---------------------->
<select id="selectDiscussPostById" resultType="DiscussPost">
    select <include refid="selectFields"></include>
    from discuss_post
    where id = #{id}
</select>

2.编写Service层

  public DiscussPost findDiscussPostById(int id){
      return discussPostMapper.selectDiscussPostById(id);
  }

3.编写Controller层

  @RequestMapping(value = "/detail/{discussPostId}", method = RequestMethod.GET)
  public String getDiscusspost(@PathVariable("discussPostId") int discussPostId, Model model){
      //通过前端传来的Id查询帖子
      DiscussPost post = discussPostService.findDiscussPostById(discussPostId);
      model.addAttribute("post",post);

      //用以显示发帖人的头像及用户名
      User user = userService.findUserById(post.getUserId());
      model.addAttribute("user",user);
      return "/site/discuss-detail";
  }

4.编写前端核心部分(进入详情链接及Controller层中的model)

<!--前端点击进入详情的链接-->
<li th:each="map:${discussPosts}">
  <a th:href="@{|/discuss/detail/${map.post.id}|}" th:utext="${map.post.title}">标题链接</a>
</li>

th:utext="${post.getTitle()}"     <!--标题-->
th:src="${user.getHeaderUrl()}"   <!--用户头像-->
th:utext="${user.getUsername()}"  <!--用户名字-->
th:text="${#dates.format(post.getCreateTime(),'yyyy-MM-dd HH:mm:ss')}"   <!--发帖时间-->
th:utext="${post.getContent()}"   <!--发帖内容-->

事务管理

1.概念

1.1事务的特性

原子性:即事务是应用中不可再分的最小执行体。

一致性:即事务执行的结果,必须使数据从一个一致性状态,变为另一个一致性状态。

隔离性:即各个事务的执行互不干扰,任何事务的内部操作对其他的事务都是隔离的。

持久性:事务一旦提交,对数据所做的任何改变都要记录到永久存储器。

1.2事务的四种隔离级别

Read Uncommitted: 读未提交(级别最低

Read Committed: 读已提交

Repeatable Read: 可重复读

Serializable: 串行化(级别最高性能最低,因为要加锁)

1.3并发异常

  • 第一类丢失更新

  • 第二类丢失更新

  • 脏读

  • 不可重复读

  • 幻读

2.Spring声明式事务

方法: 1.通过XML配置 2.通过注解@Transaction,如下:

/* REQUIRED: 支持当前事务(外部事务),如果不存在则创建新事务
 * REQUIRED_NEW: 创建一个新事务,并且暂停当前事务(外部事务)
 * NESTED: 如果当前存在事务(外部事务),则嵌套在该事务中执行(独立的提交和回滚),否则就会和REQUIRED一样
 * 遇到错误,Sql回滚  (A->B)
 */
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)

3.Spring编程式事务(通常用来管理中间某一小部分事务)

方法: 通过TransactionTemplate组件执行SQL管理事务,如下:

  public Object save2(){
      transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
      transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
      
      return transactionTemplate.execute(new TransactionCallback<Object>() {
          @Override
          public Object doInTransaction(TransactionStatus status) {
              User user = new User();
              user.setUsername("Marry");
              user.setSalt(CommunityUtil.generateUUID().substring(0,5));
              user.setPassword(CommunityUtil.md5("123123")+user.getSalt());
              user.setType(0);
              user.setHeaderUrl("http://localhost:8080/2.png");
              user.setCreateTime(new Date());
              userMapper.insertUser(user);
              //设置error,验证事务回滚
              Integer.valueOf("abc");
              return "ok"; }
      });
 }

评论功能

显示评论(评论和评论中的回复)

1.编写Dao层接口

  /** 
   * QQ:260602448 -->xumingyu
   * 根据评论类型(帖子评论和回复评论)和评论Id--分页查询评论
   * @return Comment类型集合
   */
  List<Comment> selectCommentsByEntity(@Param("entityType") int entityType, @Param("entityId") int entityId,
                                       @Param("offset") int offset, @Param("limit") int limit);

  int selectCountByEntity(@Param("entityType") int entityType, @Param("entityId") int entityId);
  <!------------------Mapper.xml----------------------> 
    <select id="selectCommentsByEntity" resultType="Comment">
        select <include refid="selectFields"></include>
        from comment
        where status = 0
        and entity_type = #{entityType}
        and entity_Id = #{entityId}
        order by create_time asc
        limit  #{offset}, #{limit}
    </select>

    <select id="selectCountByEntity" resultType="int">
        select count(id)
        from comment
        where status = 0
        and entity_type = #{entityType}
        and entity_id = #{entityId}
    </select>
  

2.编写业务Service层

  public List<Comment> findCommentsByEntity(int entityType, int entityId, int offset, int limit){
      return commentMapper.selectCommentsByEntity(entityType, entityId, offset, limit);
  }
  public int findCommentCount(int entityType, int entityId){
      return commentMapper.selectCountByEntity(entityType, entityId);
  }

3.编写Controller控制层(接查看帖子详情,如上)难点(类似于套娃)!

  @RequestMapping(value = "/detail/{discussPostId}", method = RequestMethod.GET)
  public String getDiscusspost(@PathVariable("discussPostId") int discussPostId, Model model, Page page) {
      //通过前端传来的Id查询帖子
      DiscussPost post = discussPostService.findDiscussPostById(discussPostId);
      model.addAttribute("post", post);

      //查询发帖人的头像及用户名
      User user = userService.findUserById(post.getUserId());
      model.addAttribute("user", user);
      
      // 点赞数量
      long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, discussPostId);
      model.addAttribute("likeCount", likeCount);

      // 点赞状态 (没登录就显示0)
      int likeStatus = hostHolder.getUser() == null ? '0' : likeService.findEntityLikeStatus(hostHolder.getUser().getId(), ENTITY_TYPE_POST, discussPostId);
      model.addAttribute("likeStatus", likeStatus);

      //设置评论分页信息
      page.setLimit(3);
      page.setPath("/discuss/detail/"+discussPostId);
      page.setRows(post.getCommentCount());
      
      // 评论: 给帖子的评论
      // 回复: 给评论的评论
      // 评论列表集合
      List<Comment> commentList = commentService.findCommentsByEntity(ENTITY_TYPE_POST, post.getId(), page.getOffset(), page.getLimit());

      // 评论VO(viewObject)列表 (将comment,user信息封装到每一个Map,每一个Map再封装到一个List中)
      List<Map<String, Object>> commentVoList = new ArrayList<>();
      if (commentList != null){
          // 每一条评论及该评论的用户封装进map集合
          for (Comment comment : commentList){
              // 评论Map-->commentVo
              HashMap<String, Object> commentVo = new HashMap<>();
              // 评论
              commentVo.put("comment", comment);
              // 作者(由comment表中 entity = 1 查user表)
              commentVo.put("user", userService.findUserById(comment.getUserId()));
              
              // 点赞数量
              likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_COMMENT, comment.getId());
              commentVo.put("likeCount", likeCount);
              // 点赞状态 (没登录就显示0)
              likeStatus = hostHolder.getUser() == null ? '0' : likeService.findEntityLikeStatus(hostHolder.getUser().getId(), ENTITY_TYPE_COMMENT, comment.getId());
              commentVo.put("likeStatus", likeStatus);

              // 回复列表集合(每一条评论的所有回复,不分页)
              List<Comment> replyList = commentService.findCommentsByEntity(ENTITY_TYPE_COMMENT, comment.getId(), 0, Integer.MAX_VALUE);

              // 回复VO
              List<Map<String, Object>> replyVoList = new ArrayList<>();
              if (replyList !=null){
                  for (Comment reply : replyList){
                      // 回复Map
                      HashMap<String, Object> replyVo = new HashMap<>();
                      // 回复
                      replyVo.put("reply", reply);
                      // 作者 (由comment表中 entity = 2 查user表)
                      replyVo.put("user", userService.findUserById(reply.getUserId()));
                      // 回复目标 (有2种:1.直接回复 2.追加回复)
                      User target = reply.getTargetId() == 0 ? null : userService.findUserById(reply.getTargetId());
                      replyVo.put("target", target);
                      
                      // 点赞数量
                      likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_COMMENT, reply.getId());
                      replyVo.put("likeCount", likeCount);
                      // 点赞状态 (没登录就显示0)
                      likeStatus = hostHolder.getUser() == null ? '0' : likeService.findEntityLikeStatus(hostHolder.getUser().getId(), ENTITY_TYPE_COMMENT, reply.getId());
                      replyVo.put("likeStatus", likeStatus);
                      
                      // 将每一个回复Map放在回复List中
                      replyVoList.add(replyVo);
                  }
              }
              // 将每一个回复List放在评论Map中
              commentVo.put("replys", replyVoList);

              // 回复数量统计
              int replyCount = commentService.findCommentCount(ENTITY_TYPE_COMMENT, comment.getId());
              commentVo.put("replyCount", replyCount);

              // 再将每一个评论Map放在评论List中
              commentVoList.add(commentVo);
          }
      }
      // 最后将整个List传给前端model渲染
      model.addAttribute("comments", commentVoList);

      return "/site/discuss-detail";
  }

4.编写前端Thymeleaf页面(核心部分)

注意: xxxStat—>Thymeleaf内置对象

<!-- 回帖列表 -->
<li class="media pb-3 pt-3 mb-3 border-bottom" th:each="cvo:${comments}">
  <img th:src="${cvo.user.getHeaderUrl()}" alt="用户头像">
  <div>
    <span th:utext="${cvo.user.getUsername()}">用户姓名</span>
    <span>
      <i th:text="${page.offset + cvoStat.count}">1 评论楼层</i>#
    </span>
  </div>
  <div th:utext="${cvo.comment.content}">
    评论内容
  </div>
  <span>发布于 <b th:text="${#dates.format(cvo.comment.createTime,'yyyy-MM-dd HH:mm:sss')}">时间</b></span>
  <ul>
    <li><a href="#">回复(<i th:text="${cvo.replyCount}">2</i>)</a></li>
  </ul>
  
  <!-- 回复列表 -->
  <li th:each="rvo:${cvo.replys}">
    <div>
      <!--直接回复-->
      <span th:if="${rvo.target==null}">
        <b th:utext="${rvo.user.username}">回复人姓名</b>
      </span>
      <!--追加回复-->
      <span th:if="${rvo.target!=null}">
        <i th:text="${rvo.user.username}">回复人姓名</i> 回复
        <b th:text="${rvo.target.username}">被回复人姓名</b>
      </span>
      <span th:utext="${rvo.reply.content}">回复内容</span>
    </div>
    
    <div>
      <span th:text="${#dates.format(rvo.reply.createTime,'yyyy-MM-dd HH:mm:ss')}">回复时间</span>
      <ul>
        <li><a href="#">赞(1)</a></li>
        <li>|</li>
        <!--关联id对应回复  动态拼接-->
        <li><a th:href="|#huifu-${rvoStat.count}|" data-toggle="collapse">回复</a></li>
      </ul>
      
      <div th:id="|huifu-${rvoStat.count}|">
        <input type="text" th:placeholder="|回复${rvo.user.username}|"/>
        <button type="button" onclick="#">回复</button>                   
      </div>
    </div>                
  </li>
</li>
最后复用分页:th:replace="index::pagination"

添加评论 (用到事务管理)

1.编写Dao层 (1.增加评论数据CommentMapper 2.修改帖子评论数量DiscussPostMapper)

 //CommentMapper 
 int insertComment(Comment comment);
 <insert id="insertComment" parameterType="Comment">
    insert into comment(<include refid="insertFields"></include>)
    values (#{userId}, #{entityType}, #{entityId}, #{targetId}, #{content}, #{status}, #{createTime})
 </insert>
 
 //DiscussPostMapper
 int updateCommentCount(@Param("id") int id,@Param("commentCount") int commentCount);
 <update id="updateCommentCount">
    update discuss_post set comment_count = #{commentCount}
    where id = #{id}
 </update>

2.编写业务Service层

  //DiscussPostService
   public int updateCommentCount(int id, int commentCount){
      return discussPostMapper.updateCommentCount(id, commentCount);
   }
   
  //CommentService
  /**
   * 添加评论(涉及事务)
   * 先添加评论,后修改discuss_post中的评论数(作为一个整体事务,出错需要整体回滚!)
   */
  @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
  public int addComment(Comment comment){
      if (comment == null){
          throw new IllegalArgumentException("参数不能为空!");
      }
      /**添加评论**/
      //过滤标签
      comment.setContent(HtmlUtils.htmlEscape(comment.getContent()));
      //过滤敏感词
      comment.setContent(sensitiveFilter.filter(comment.getContent()));
      int rows =commentMapper.insertComment(comment);
      /**
       * 更新帖子评论数量
       * 如果是帖子类型才更改帖子评论数量,并且获取帖子评论的id
       */
      if (comment.getEntityType() == ENTITY_TYPE_POST){
          int count = commentMapper.selectCountByEntity(comment.getEntityType(), comment.getEntityId());
          discussPostService.updateCommentCount(comment.getEntityId(), count);
      }
      return rows;
  }

3.编写Controller层

  //需要从前端带一个参数
  @RequestMapping(value = "/add/{discussPostId}", method = RequestMethod.POST)
  public String addComment(@PathVariable("discussPostId") int discussPostId, Comment comment){
      comment.setUserId(hostHolder.getUser().getId());
      comment.setStatus(0);
      comment.setCreateTime(new Date());
      commentService.addComment(comment);
      return "redirect:/discuss/detail/" + discussPostId;
  }

4.编写Thymleaf前端页面(核心)

<!--帖子评论框-->
<form method="post" th:action="@{|/comment/add/${post.id}|}">
  <p>
    <textarea placeholder="帖子评论框!" name="content"></textarea>
    <input type="hidden" name="entityType" value="1">
    <input type="hidden" name="entityId" th:value="${post.id}">
  </p>
  <button type="submit">回帖</button>
</form>

<!--回复输入框-->
<form method="post" th:action="@{|/comment/add/${post.id}|}">
  <div>
    <input type="text" name="content" placeholder="回复输入框"/>
    <input type="hidden" name="entityType" value="2">
    <!--回复评论id,即entityType=2的评论id-->
    <input type="hidden" name="entityId" th:value="${cvo.comment.id}">
  </div>
  <button type="submit" onclick="#">回复</button>
</form>

<!--追加回复框-->
<form method="post" th:action="@{|/comment/add/${post.id}|}">
  <div>
    <input type="text" name="content" th:placeholder="|回复${rvo.user.username}|"/>
    <input type="hidden" name="entityType" value="2">
    <input type="hidden" name="entityId" th:value="${cvo.comment.id}">
    <!--回复评论的作者id-->
    <input type="hidden" name="targetId" th:value="${rvo.user.id}">
  </div>
  <button type="submit" onclick="#">回复</button>
</form>

私信功能

显示私信列表(难度在写SQL)

1.编写Dao层

  /**查询当前用户的会话列表,针对每个会话只返回一条最新的私信**/
  List<Message> selectConversations(@Param("userId") int userId,@Param("offset") int offset,@Param("limit") int limit);

  /**查询当前用户的会话数量**/
  int selectConversationCount(@Param("userId") int userId);

  /**查询某个会话所包含的私信列表**/
  List<Message> selectLetters(@Param("conversationId") String conversationId,@Param("offset") int offset,@Param("limit") int limit);

  /**查询某个会话所包含的私信数量**/
  int selectLetterCount(@Param("conversationId") String conversationId);
  /**
   * 查询未读的数量
   * 1.带参数conversationId :私信未读数量
   * 2.不带参数conversationId :当前登录用户 所有会话未读数量
   */
  int selectLetterUnreadCount(@Param("userId")int userId,@Param("conversationId") String conversationId);

2.编写Mapper.xml(难度)

<sql id="selectFields">
    id, from_id, to_id, conversation_id, content, status, create_time
</sql>

<select id="selectConversations" resultType="Message">
    select <include refid="selectFields"></include>
    from message
    where id in (
        //子句根据id大小查与每个用户最新的私信(同一会话id越大,私信越新)
        //也可根据时间戳判断
        select max(id) from message
        where status != 2
        and from_id != 1
        and (from_id = #{userId} or to_id = #{userId})
        group by conversation_id   //同一会话只显示一条
    )
    order by id desc
    limit #{offset}, #{limit}
</select>

<select id="selectConversationCount" resultType="int">
    select count(m.maxid) from (
       select max(id) as maxid from message
       where status != 2
       and from_id != 1
       and (from_id = #{userId} or to_id = #{userId})
       group by conversation_id
   ) as m
</select>

<select id="selectLetters" resultType="Message">
    select <include refid="selectFields"></include>
    from message
    where status != 2
    and from_id != 1
    and conversation_id = #{conversationId}
    order by id asc
    limit #{offset}, #{limit}
</select>

<select id="selectLetterCount" resultType="int">
    select count(id)
    from message
    where status != 2
    and from_id != 1
    and conversation_id = #{conversationId}
</select>

<select id="selectLetterUnreadCount" resultType="int">
    select count(id)
    from message
    where status = 0
    and from_id != 1
    and to_id = #{userId}
    <if test="conversationId!=null"> //=null:所有会话未读数 !=null:每条会话未读数
        and conversation_id = #{conversationId}
    </if>
</select>

3.编写Service层

  @Autowired
  private MessageMapper messageMapper;

  public List<Message> findConversations(int userId, int offset, int limit){
      return messageMapper.selectConversations(userId, offset, limit);
  }
  public int findConversationCount(int userId) {
      return messageMapper.selectConversationCount(userId);
  }
  public List<Message> findLetters(String conversationId, int offset, int limit) {
      return messageMapper.selectLetters(conversationId, offset, limit);
  }
  public int findLetterCount(String conversationId) {
      return messageMapper.selectLetterCount(conversationId);
  }
  public int findLetterUnreadCount(int userId, String conversationId) {
      return messageMapper.selectLetterUnreadCount(userId, conversationId);
  }

4.编写Controller层

4.1私信列表Controller

  /**私信列表**/
  @RequestMapping(value = "/letter/list", method = RequestMethod.GET)
  public String getLetterList(Model model, Page page){
      // 获取当前登录用户
      User user = hostHolder.getUser();
      // 分页信息
      page.setLimit(5);
      page.setPath("/letter/list");
      page.setRows(messageService.findConversationCount(user.getId()));
      // 会话列表
      List<Message> conversationList = messageService.findConversations(user.getId(), page.getOffset(), page.getLimit());
      List<Map<String, Object>> conversations = new ArrayList<>();
      if (conversationList != null){
          for (Message message : conversationList){
              HashMap<String, Object> map = new HashMap<>();
              // 与当前登录用户每一条会话的所有信息
              map.put("conversation", message);
              // 当前登录用户与每一个会话人的私信条数
              map.put("letterCount", messageService.findLetterCount(message.getConversationId()));
              // 当前登录用户与每一个会话人的未读条数
              map.put("unreadCount", messageService.findLetterUnreadCount(user.getId(), message.getConversationId()));
              // 当前登录用户若与当前会话信息中fromId相同,则目标id为ToId;
              int targetId = user.getId() == message.getFromId() ? message.getToId() : message.getFromId();
              User target = userService.findUserById(targetId);
              map.put("target", target);
              
              conversations.add(map);
          }
      }
      model.addAttribute("conversations", conversations);
      // 当前登录用户总未读条数
      int letterUnreadCount = messageService.findLetterUnreadCount(user.getId(), null);
      model.addAttribute("letterUnreadCount", letterUnreadCount);

      return "/site/letter";
  }

4.2私信详情Controller

  /**私信详情**/
  @RequestMapping(value = "/letter/detail/{conversationId}", method = RequestMethod.GET)
  public String getLetterDetail(@PathVariable("conversationId")String conversationId, Model model, Page page){
      //分页信息
      page.setLimit(5);
      page.setPath("/letter/detail/"+conversationId);
      page.setRows(messageService.findLetterCount(conversationId));

      //获取私信信息
      List<Message> letterlist = messageService.findLetters(conversationId, page.getOffset(), page.getLimit());
      List<Map<String, Object>> letters = new ArrayList<>();
      if (letterlist != null){
          for(Message message : letterlist){
              HashMap<String, Object> map = new HashMap<>();
              //map封装每条私信
              map.put("letter", message);
              map.put("fromUser",userService.findUserById(message.getFromId()));

              letters.add(map);
          }
      }
      model.addAttribute("letters",letters);
      //私信目标
      model.addAttribute("target",getLetterTarget(conversationId));
      return "/site/letter-detail";
  }

  /**封装获取目标会话用户(将如:101_107拆开) **/
  private User getLetterTarget(String conversationId) {
      String[] ids = conversationId.split(" _");
      int id0 = Integer.parseInt(ids[0]);
      int id1 = Integer.parseInt(ids[1]);

      if (hostHolder.getUser().getId() == id0) {
          return userService.findUserById(id1);
      } else {
          return userService.findUserById(id0);
      }
  }

5.编写Thymeleaf前端页面(核心)

5.1私信列表页面

<a th:href="@{/letter/list}">
  朋友私信<span th:text="${letterUnreadCount}" th:if="${letterUnreadCount!=0}">总私信未读数</span>
</a>

<li th:each="map:${conversations}">
  <span th:text="${map.unreadCount}" th:if="${map.unreadCount!=0}">单个会话未读数</span>
  <a th:href="@{/profile}">
    <img th:src="${map.target.headerUrl}" alt="用户头像" >
  </a>
  <div>
    <span th:utext="${map.target.username}">会话目标姓名</span>
    <span th:text="${#dates.format(map.conversation.createTime,'yyyy-MM-dd HH:mm:ss')}">会话最新时间</span>
    <a th:href="@{|/letter/detail/${map.conversation.conversationId}|}" th:utext="${map.conversation.content}">会话内容,可进入详情页</a>
    <ul>
      <li><a href="#"><i th:text="${map.letterCount}">5</i>条会话</a></li>
    </ul>
  </div>
</li>

5.2私信详情页面

<h6>来自 <i th:utext="${target.username}">目标会话用户</i> 的私信</h6>

<li th:each="map:${letters}">
  <img th:src="${map.fromUser.headerUrl}" alt="用户头像" >
<div>
  <strong th:utext="${map.fromUser.username}">会话发起人姓名</strong>
  <small th:text="${#dates.format(map.letter.createTime,'yyyy-MM-dd HH:mm:ss')}">时间</small>
</div>
<div th:utext="${map.letter.content}">
   私信内容
</div>
</li>

发送私信功能(异步)

1.编写Dao层

  /**插入会话**/
  int insertMessage(Message message);
  /**批量更改每个会话的所有未读消息为已读**/
  int updateStatus(@Param("id") List<Integer> ids,@Param("status") int status);
  
  -----------------------Mapper.xml-----------------------------
  <insert id="insertMessage" parameterType="Message" keyProperty="id">
    insert into message(<include refid="insertFields"></include>)
    values(#{fromId},#{toId},#{conversationId},#{content},#{status},#{createTime})
  </insert>
  
  <update id="updateStatus">
      update message set status = #{status}
      where id in
      -----批量传入id写法
      <foreach collection="ids" item="id" open="(" separator="," close=")">
          #{id}
      </foreach>
  </update>
  

2.编写Service层

  public int addMessage(Message message){
      //转义标签
      message.setContent(HtmlUtils.htmlEscape(message.getContent()));
      //过滤敏感词
      message.setContent(sensitiveFilter.filter(message.getContent()));
      return messageMapper.insertMessage(message);
  }
  public int readMessage(List<Integer> ids){
      return messageMapper.updateStatus(ids, 1);
  }

3.编写Controller层

3.1设置已读

  @RequestMapping(value = "/letter/detail/{conversationId}", method = RequestMethod.GET)
  public String getLetterDetail(@PathVariable("conversationId")String conversationId, Model model, Page page){
        /**
        * 以上省略。。。。。。
        */
        //设置已读(当打开这个页面是就更改status =1)
        List<Integer> ids = getLetterIds(letterlist);
        if (!ids.isEmpty()) {
            messageService.readMessage(ids);
       }
   }

  /**获得批量私信的未读数id* */
  private List<Integer> getLetterIds(List<Message> letterList){
      List<Integer> ids = new ArrayList<>();

      if (letterList != null) {
          for (Message message : letterList) {
              //只有当前登录用户与message列表中目标用户一致并且staus = 0 时才是未读数,加入未读私信集合
              if (hostHolder.getUser().getId() == message.getToId() && message.getStatus() == 0) {
                  ids.add(message.getId());
              }
          }
      }
      return ids;
  }

3.2 发送私信

  /**发送私信* */
  @RequestMapping(value = "/letter/send", method = RequestMethod.POST)
  @ResponseBody
  public String sendLetter(String toName, String content){
      //根据目标发送人姓名获取其id
      User target = userService.findUserByName(toName);
      if (target == null){
          return CommunityUtil.getJSONString(1,"目标用户不存在!");
      }

      //设置message属性
      Message message = new Message();
      message.setFromId(hostHolder.getUser().getId());
      message.setToId(target.getId());
      message.setContent(content);
      message.setCreateTime(new Date());
      // conversationId (如101_102: 小_大)
      if (message.getFromId() < message.getToId()) {
          message.setConversationId(message.getFromId() + " _" +message.getToId());
      }else{
          message.setConversationId(message.getToId() + "_" +message.getFromId());
      }
      messageService.addMessage(message);

      return CommunityUtil.getJSONString(0);
  }

4.编写前端JS异步请求(ajax)

function send_letter() {
  $("#sendModal").modal("hide");
  //若用JS异步请求,前端参数不用name= "xxx",用如下方法
  var toName = $("#recipient-name").val();
  var content = $("#message-text").val();

  $.post(
    // 接口路径(与@RequestMapping(value = "/letter/send", method = RequestMethod.POST)路径一致)
    CONTEXT_PATH + "/letter/send",
    // 接口参数(与public String sendLetter(String toName, String content)参数一致)
    {"toName":toName, "content":content},
    function (data) {
      // 把{"toName":toName, "content":content}转换成JS对象
      data = $.parseJSON(data);
      // 与CommunityUtil.getJSONString(0,"msg")匹配--0:成功
      if (data.code == 0){
        $("#hintBody").text("发送成功!");
      }else {
        $("#hintBody").text(data.msg);
      }

      $("#hintModal").modal("show");
      setTimeout(function(){
        $("#hintModal").modal("hide");
        //刷新页面
        location.reload();
      }, 2000);
    }
  );
}

点赞功能(Redis+异步ajax)

点赞、取消点赞

注意:1引入pom,配置Yaml

2.因为访问的是Redis,无需编写Dao层

1.创建RedisKeyUtil工具类(统一格式化redis的key)

k:v = like:entity:entityType:entityId -> set(userId)

    private static final String SPLIT = ":";
    private static final String PREFIX_ENTITY_LIKE = "like:entity";
    private static final String PREFIX_USER_LIKE = "like:user";
    /**
    * 某个实体的赞
    * key= like:entity:entityType:entityId -> value= userId
    */
    public static String getEntityLikeKey(int entityType, int entityId){
        return PREFIX_ENTITY_LIKE + SPLIT + entityType + SPLIT + entityId;
    }

2.直接编写Service业务层

    @Autowired
    private RedisTemplate redisTemplate;

    // 点赞 (记录谁点了哪个类型哪个留言/帖子id)
    public void like(int userId, int entityType, int entityId){
        String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);
        //判断like:entity:entityType:entityId 是否有对应的 userId
        Boolean isMember = redisTemplate.opsForSet().isMember(entityLikeKey, userId);

        // 第一次点赞,第二次取消点赞
        if (isMember){
            // 若已被点赞(即entityLikeKey里面有userId)则取消点赞->将userId从中移除
            redisTemplate.opsForSet().remove(entityLikeKey, userId);
        }else {
            redisTemplate.opsForSet().add(entityLikeKey, userId);
        }
    }

    // 查询某实体(帖子、留言)点赞的数量 --> scard like:entity:1:110
    public long findEntityLikeCount(int entityType, int entityId){
        String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);
        return redisTemplate.opsForSet().size(entityLikeKey);
    }

    // 显示某人对某实体的点赞状态
    public int findEntityLikeStatus(int userId, int entityType, int entityId){
        String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);
        // 1:已点赞 , 0:赞
        return redisTemplate.opsForSet().isMember(entityLikeKey, userId) ? 1 : 0;
    }

3.编写点赞Controller层接口(异步)

返回:CommunityUtil.getJSONString(0,null, map) —>对应响应的js的ajax

@Controller
public class LikeController {

    @Autowired
    private HostHolder hostHolder;
    @Autowired
    private LikeService likeService;

    @RequestMapping(value = "/like", method = RequestMethod.POST)
    @ResponseBody
    public String like(int entityType, int entityId){
        User user = hostHolder.getUser();
        // 点赞
        likeService.like(user.getId(), entityType ,entityId);
        // 获取对应帖子、留言的点赞数量
        long likeCount = likeService.findEntityLikeCount(entityType, entityId);
        // 获取当前登录用户点赞状态(1:已点赞 0:赞)
        int likeStatus = likeService.findEntityLikeStatus(user.getId(), entityType, entityId);

        // 封装结果到Map
        Map<String, Object> map = new HashMap<>();
        map.put("likeCount", likeCount);
        map.put("likeStatus", likeStatus);

        return CommunityUtil.getJSONString(0,null, map);  
    }
}

4.编写异步js

// btn -->对应this
function like(btn, entityType, entityId) {
    $.post(
        CONTEXT_PATH + "/like",
        {"entityType":entityType,"entityId":entityId},
        function (data) {
            data = $.parseJSON(data);
            if (data.code == 0) {
                $(btn).children("i").text(data.likeCount);
                $(btn).children("b").text(data.likeStatus==1?'已赞':"赞");
            }else {
                alert(data.msg);
            }
        }
    );
}

5.前端—详情页点赞数量

对应的Controll层,显示点赞在主页Controller层显示评论功能Controller层

  <!--引入Js-->
  <script th:src="@{/js/discuss.js}"></script>
  
  <!---href="javascript:;"弃用href使用onclick按钮
   th:onclick="|like(this,1,${post.id})|", this指代当前按钮,1指代帖子类型--->
  <!--帖子点赞-->
  <a href="javascript:;" th:onclick="|like(this,1,${post.id});|" class="text-primary">
    <b th:text="${likeStatus==1?'已赞':'赞'}"></b> <i th:text="${likeCount}">11</i>
  </a>
  
  <!--评论点赞-->
  <a href="javascript:;" th:onclick="|like(this,2,${cvo.comment.id});|" class="text-primary">
    <b th:text="${cvo.likeStatus==1?'已赞':'赞'}"></b>(<i th:text="${cvo.likeCount}">1</i>)
  </a>
  
  <!--回复点赞-->
  <a href="javascript:;" th:onclick="|like(this,2,${rvo.reply.id});|" class="text-primary">
    <b th:text="${rvo.likeStatus==1?'已赞':'赞'}"></b>(<i th:text="${rvo.likeCount}">1</i>)
  </a>

我收到的赞(基于点赞基础上修改)

注意:1. 以用户为key, 记录点赞数量 2.opsForValue.increment(key) /decrement(key)

1.在工具类RedisKeyUtil添加方法

k:v = like:user:userId -> set(int)

    private static final String PREFIX_USER_LIKE = "like:user";
    
    /**
     * 某个用户的赞
     * like:user:userId -> int
     */
    public static String getUserLikeKey(int userId){
        return PREFIX_USER_LIKE + SPLIT + userId;
    }

2.修改Service业务层(添加entityUserId属性,事务和查询获用户赞个数)

  @Autowired
  private RedisTemplate redisTemplate;

  // 点赞 (记录谁点了哪个类型哪个留言/帖子id)
  public void like(int userId, int entityType, int entityId, int entityUserId){
      /**因为要用到两个redis操作,需使用事务**/
      redisTemplate.execute(new SessionCallback() {
          @Override
          public Object execute(RedisOperations redisOperations) throws DataAccessException {
              String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);
              String userLikeKey = RedisKeyUtil.getUserLikeKey(entityUserId);

              //判断like:entity:entityType:entityId 是否有对应的 userId
              Boolean isMember = redisOperations.opsForSet().isMember(entityLikeKey, userId);

              // 先查再开启事务
              redisOperations.multi();
              if (isMember) {
                  // 若已被点赞(即entityLikeKey里面有userId)则取消点赞->将userId从中移除
                  redisOperations.opsForSet().remove(entityLikeKey, userId);
                  // 该帖子的用户收到的点赞-1
                  redisOperations.opsForValue().decrement(userLikeKey);
              }else {
                  redisOperations.opsForSet().add(entityLikeKey, userId);
                  redisOperations.opsForValue().increment(userLikeKey);
              }

              return redisOperations.exec();
          }
      });
  }
    // 查询某个用户获得的赞
    public int findUserLikeCount(int userId) {
        String userLikeKey = RedisKeyUtil.getUserLikeKey(userId);
        // 注意这里Integet封装类型!!!!
        Integer count = (Integer) redisTemplate.opsForValue().get(userLikeKey);
        return count == null ? 0 : count.intValue();
    }

3.修改LikeController层(添加entityUserId属性)

    @RequestMapping(value = "/like", method = RequestMethod.POST)
    @ResponseBody
    public String like(int entityType, int entityId, int entityUserId){
        User user = hostHolder.getUser();
        // 点赞
        likeService.like(user.getId(), entityType, entityId, entityUserId);
        // 获取对应帖子、留言的点赞数量
        long likeCount = likeService.findEntityLikeCount(entityType, entityId);
        // 获取当前登录用户点赞状态(1:已点赞 0:赞)
        int likeStatus = likeService.findEntityLikeStatus(user.getId(), entityType, entityId);

        // 封装结果到Map
        Map<String, Object> map = new HashMap<>();
        map.put("likeCount", likeCount);
        map.put("likeStatus", likeStatus);

        return CommunityUtil.getJSONString(0,null, map);
    }

4.同样在JS添加entityUserId属性

function like(btn, entityType, entityId, entityUserId) {
    $.post(
        CONTEXT_PATH + "/like",
        {"entityType":entityType,"entityId":entityId,"entityUserId":entityUserId},
        function (data) {
            data = $.parseJSON(data);
            if (data.code == 0) {
                $(btn).children("i").text(data.likeCount);
                $(btn).children("b").text(data.likeStatus==1?'已赞':"赞");
            }else {
                alert(data.msg);
            }
        }
    );
}

5.编写个人主页UserController层

    /**
     * 个人主页
     */
    @RequestMapping(value = "/profile/{userId}", method = RequestMethod.GET)
    public String getProfilePage(@PathVariable("userId") int userId, Model model) {
        User user = userService.findUserById(userId);
        if (user == null) {
            throw new RuntimeException("该用户不存在!");
        }
        model.addAttribute("user", user);

        // 进入某用户主页获取他(我)的点赞数量
        int likeCount = likeService.findUserLikeCount(userId);
        model.addAttribute("likeCount", likeCount);
        return "/site/profile";
    }

6.编写前端个人主页(核心部分)

<span>获得了 <i th:text="${likeCount}">87</i> 个赞</span>

<a th:href="@{|/user/profile/${map.user.id}|}">
  点击头像进入某用户主页
</a>