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

345 阅读14分钟

关注功能(Redis+异步ajax)

关注、取消关注

1.编写工具类RedisKeyUtil统一关注Redis的key

关注:k:v = followee:userId:entityType --> zset(entityId, date)

粉丝:k:v = follower:entityType:entityId -->zset(userId, date)

public class RedisKeyUtil {
    // 关注
    private static final String PREFIX_FOLLOWEE = "followee";
    // 粉丝
    private static final String PREFIX_FOLLOWER = "follower";
    /**
     * 某个用户关注的实体(用户,帖子)
     * followee:userId:entityType --> zset(entityId, date)
     */
    public static String getFolloweeKey(int userId, int entityType) {
        return PREFIX_FOLLOWEE + SPLIT + userId + SPLIT + entityType;
    }

    /**
     * 某个实体拥有的粉丝
     * follower:entityType:entityId -->zset(userId, date)
     */
    public static String getFollowerKey(int entityType, int entityId) {
        return PREFIX_FOLLOWER + SPLIT +entityType + SPLIT +entityId;
    }
}

2.编写Service层业务

    @Autowired
    private RedisTemplate redisTemplate;

    /**关注**/
    public void follow(int userId, int entityType, int entityId) {
        redisTemplate.execute(new SessionCallback() {
            @Override
            public Object execute(RedisOperations redisOperations) throws DataAccessException {
                String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);
                String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);

                // 开启事务
                redisOperations.multi();
                /**
                 * System.currentTimeMillis()->用于获取当前系统时间,以毫秒为单位
                 * 关注时,首先将实体(用户或帖子)id添加用户关注的集合中,再将用户id添加进实体粉丝的集合中
                 */
                redisOperations.opsForZSet().add(followeeKey, entityId, System.currentTimeMillis());
                redisOperations.opsForZSet().add(followerKey, userId, System.currentTimeMillis());

                return redisOperations.exec();
            }
        });
    }

    /**取消关注**/
    public void unfollow(int userId, int entityType, int entityId) {
        redisTemplate.execute(new SessionCallback() {
            @Override
            public Object execute(RedisOperations redisOperations) throws DataAccessException {
                String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);
                String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);
                // 开启事务
                redisOperations.multi();
                /**关注时,首先将实体(用户或帖子)id移除用户关注的集合中,再将用户id移除进实体粉丝的集合中**/
                redisOperations.opsForZSet().remove(followeeKey, entityId);
                redisOperations.opsForZSet().remove(followerKey, userId);

                return redisOperations.exec();
            }
        });
    }

    /**查询关注的实体(用户)数量**/
    public long findFolloweeCount(int userId, int entityType) {
        String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);
        // opsForZSet().zCard获取有序集合中的数量
        return redisTemplate.opsForZSet().zCard(followeeKey);
    }

    /**查询粉丝的实体数量**/
    public long findFollowerCount(int entityType, int entityId) {
        String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);
        return redisTemplate.opsForZSet().zCard(followerKey);
    }

    /**查询当前用户是否已关注该实体**/
    // userId->当前登录用户  entityType->用户类型 entityId->关注的用户id
    public boolean hasFollowed(int userId, int entityType, int entityId) {
        String followeeKey =RedisKeyUtil.getFolloweeKey(userId, entityType);
        /**
         * opsForZSet().score 获取有序集合中指定元素权重分数  followee:userId:entityType = entityId的分数(这里是时间)
         * 若有时间,则表明已关注;
         */
        return redisTemplate.opsForZSet().score(followeeKey, entityId) != null;
    }

3.编写Controller层

3.1关注与取消关注按钮的实现(FollowController)

    /**关注**/
    @RequestMapping(value = "/follow", method = RequestMethod.POST)
    @ResponseBody // 关注是异步请求
    public String follow(int entityType, int entityId) {
        followService.follow(hostHolder.getUser().getId(), entityType, entityId);
        return CommunityUtil.getJSONString(0,"已关注");
    }

    /**取消关注**/
    @RequestMapping(value = "/unfollow", method = RequestMethod.POST)
    @ResponseBody // 关注是异步请求
    public String unfollow(int entityType, int entityId) {
        followService.unfollow(hostHolder.getUser().getId(), entityType, entityId);
        return CommunityUtil.getJSONString(0,"已取消关注");
    }

3.2主页中显示关注数量,粉丝数量(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);
        // 点赞数量
           ....
        // 关注数量(这里只考虑关注用户类型的情况)
        long followeeCount = followService.findFolloweeCount(userId, ENTITY_TYPE_USER);
        model.addAttribute("followeeCount", followeeCount);
        // 粉丝数量
        long followerCount = followService.findFollowerCount(ENTITY_TYPE_USER, userId);
        model.addAttribute("followerCount", followerCount);
        // 是否已关注 (必须是用户登录的情况)
        boolean hasFollowed = false;
        if (hostHolder.getUser() != null) {
            hasFollowed = followService.hasFollowed(hostHolder.getUser().getId(), ENTITY_TYPE_USER, userId);
        }
        model.addAttribute("hasFollowed", hasFollowed);

        return "/site/profile";
    }

4.编写JS异步请求和前端页面(核心部分)

$(function(){
  $(".follow-btn").click(follow);
});

function follow() {
  var btn = this;
  if($(btn).hasClass("btn-info")) {
    // 关注TA
    $.post(
      CONTEXT_PATH + "/follow",
      // "entityId":$(btn).prev().val() 获取btn按钮上一个的值
      {"entityType":3,"entityId":$(btn).prev().val()},
      function (data) {
        data = $.parseJSON(data);
        if(data.code == 0) {
          window.location.reload();
        } else {
          alert(data.msg);
        }});
  } else {
    // 取消关注
    $.post(
      CONTEXT_PATH + "/unfollow",
      {"entityType":3,"entityId":$(btn).prev().val()},
      function(data) {
        data = $.parseJSON(data);
        if(data.code == 0) {
          window.location.reload();
        } else {
          alert(data.msg);
        }});
  }}
<input type="hidden" id="entityId" th:value="${user.id}">
              <!-- hasFollowed为true:已关注 ->按钮变灰 ,false:未关注 ->按钮变蓝  -->
<button type="button" th:class="|btn ${hasFollowed?'btn-secondary':'btn-info'} btn-sm float-right mr-5 follow-btn|"
              <!-- 只有登录过且当前登录用户不是看的自己的主页就显示已关注/关注TA -->
    th:text="${hasFollowed?'已关注':'关注TA'}" th:if="${loginUser!=null && loginUser.id!=user.id}">关注TA</button>
    
<span>关注了 <a th:text="${followeeCount}">5</a></span>
<span>关注者 <a th:text="${followerCount}">123</a></span>

关注列表(同粉丝列表)

1.编写Service层(查询某用户关注的人)

    /**查询某用户关注的人**/
    public List<Map<String, Object>> findFollowees(int userId, int offset, int limit){
        String followeeKey = RedisKeyUtil.getFolloweeKey(userId, ENTITY_TYPE_USER);
        // 按最新时间倒序查询目标用户id封装在set<Integet>中
        Set<Integer> targetIds = redisTemplate.opsForZSet().reverseRange(followeeKey, offset, offset + limit - 1);

        if (targetIds == null) {
            return null;
        }
        // 将user信息Map和redis用户关注时间Map一起封装到list
        ArrayList<Map<String, Object>> list = new ArrayList<>();
        for (Integer targetId: targetIds) {
            HashMap<String, Object> map = new HashMap<>();
            // 用户信息map
            User user = userService.findUserById(targetId);
            map.put("user", user);
            // 目标用户关注时间map(将long型拆箱成基本数据类型)
            Double score = redisTemplate.opsForZSet().score(followeeKey, targetId);
            map.put("followeeTime", new Date(score.longValue()));

            list.add(map);
        }
        return list;
    }

2.编写Controller层

  /** 查询某用户关注列表**/
  @RequestMapping(value = "/followees/{userId}", method = RequestMethod.GET)
  public String getFollowees(@PathVariable("userId")int userId, Page page, Model model) {
      // 当前访问的用户信息
      User user = userService.findUserById(userId);
      // Controller层统一处理异常
      if (user == null) {
          throw new RuntimeException("该用户不存在!");
      }
      model.addAttribute("user", user);
      // 设置分页信息
      page.setLimit(3);
      page.setPath("/followees/" + userId);
      page.setRows((int) followService.findFolloweeCount(userId, ENTITY_TYPE_USER));

      List<Map<String, Object>> userList = followService.findFollowees(userId, page.getOffset(), page.getLimit());
      if (userList != null) {
          for (Map<String, Object> map : userList) {
              User u = (User) map.get("user");
              map.put("hasFollowed", hasFollowed(u.getId()));
          }
      }
      model.addAttribute("users", userList);

      return "/site/followee";
  }
  
  /**判端当前登录用户与关注、粉丝列表的关注关系**/
  private Boolean hasFollowed(int userId) {
      if (hostHolder.getUser() == null) {
          return false;
      }
      // 调用当前用户是否已关注user实体Service
      return followService.hasFollowed(hostHolder.getUser().getId(), ENTITY_TYPE_USER, userId);
  }

3.编写前端页面

3.1 带参数路径跳转

<span>关注了 <a th:href="@{|/followees/${user.id}|}" th:text="${followeeCount}">5</a></span>
<span>关注者 <a th:href="@{|/followers/${user.id}|}" th:text="${followerCount}">123</a></span>

3.2 列表页面

  <li th:each="map:${users}">
    <a th:href="@{|/user/profile/{map.user.id}|}">
      <img th:src="${map.user.headerUrl}"alt="用户头像" >
    </a>
    <div>
      <h6>
        <span th:utext="${map.user.username}">落基山脉下的闲人</span>
        <span>
          关注于 <i th:text="${#dates.format(map.followerTime,'yyyy-MM-dd HH:mm:ss')}">2019-04-28 14:13:25</i>
        </span>
      </h6>
      <div>
        <input type="hidden" id="entityId" th:value="${map.user.id}">
        <button type="button" th:class="|${map.hasFollowed?'btn-secondary':'btn-info'}|"
            th:text="${map.hasFollowed?'已关注':'关注TA'}" th:if="${loginUser!=null && loginUser.id!=map.user.id}">关注TA</button>
      </div>
    </div>
  </li>

系统通知功能(Kafka消息队列)

发送系统通知功能(点赞、关注、评论时通知)

1.编写Kafka消息队列事件Event实体类

/**
 * Kafka消息队列事件(评论、点赞、关注事件
 */
public class Event {

    // Kafka必要的主题变量
    private String topic;
    // 发起事件的用户id
    private int userId;
    // 用户发起事件的实体类型(评论、点赞、关注类型)
    private int entityType;
    // 用户发起事件的实体(帖子、评论、用户)id
    private int entityId;
    // 被发起事件的用户id(被评论、被点赞、被关注用户)
    private int entityUserId;
    // 其他可扩充内容对应Comment中的content->显示用户xxx评论、点赞、关注了xxx
    private Map<String, Object> data = new HashMap<>();

    public String getTopic() {
        return topic;
    }

    // 注意这里所有set方法返回Event类型,变成链式编程
    public Event setTopic(String topic) {
        this.topic = topic;
        return this;
    }

    public int getUserId() {
        return userId;
    }

    public Event setUserId(int userId) {
        this.userId = userId;
        return this;
    }

    public int getEntityType() {
        return entityType;
    }

    public Event setEntityType(int entityType) {
        this.entityType = entityType;
        return this;
    }

    public int getEntityId() {
        return entityId;
    }

    public Event setEntityId(int entityId) {
        this.entityId = entityId;
        return this;
    }

    public int getEntityUserId() {
        return entityUserId;
    }

    public Event setEntityUserId(int entityUserId) {
        this.entityUserId = entityUserId;
        return this;
    }

    public Map<String, Object> getData() {
        return data;
    }

    // 方便外界直接调用key-value,而不用再封装一下传整个Map集合
    public Event setData(String key, Object value) {
        this.data.put(key, value);
        return this;
    }
}

2.编写Kafka生产者

/**
 * Kafka事件生产者(主动调用)相当于一个开关
 */
@Component
public class EventProducer {
    @Autowired
    private KafkaTemplate kafkaTemplate;

    // 处理事件
    public void fireMessage(Event event) {
        // 将事件发布到指定的主题,内容为event对象转化的json格式字符串
        kafkaTemplate.send(event.getTopic(), JSONObject.toJSONString(event));
    }
}

3.编写Kafka消费者

/**
 * QQ:260602448--xumingyu
 * Kafka事件消费者(被动调用)
 * 对Message表扩充:1:系统通知,当生产者调用时,存入消息队列,消费者自动调用将event事件相关信息存入Message表
 */
@Component
public class EventConsumer implements CommunityConstant {

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

    @Autowired
    private MessageService messageService;

    @KafkaListener(topics = {TOPIC_COMMENT, TOPIC_LIKE, TOPIC_FOLLOW})
    public void handleCommentMessage(ConsumerRecord record) {
        if (record == null || record.value() == null) {
            logger.error("消息的内容为空!");
            return;
        }
        // 将record.value字符串格式转化为Event对象
        Event event = JSONObject.parseObject(record.value().toString(), Event.class);
        // 注意:event中若data = null,是fastjson依赖版本的问题(不能太高1.0.xx)
        if (event == null) {
            logger.error("消息格式错误!");
            return;
        }

        Message message = new Message();
        message.setFromId(SYSTEM_USER_ID);
        // Message表中ToId设置为被发起事件的用户id
        message.setToId(event.getEntityUserId());
        // ConversationId设置为事件的主题(点赞、评论、关注)
        message.setConversationId(event.getTopic());
        message.setCreateTime(new Date());

        // 设置content为可扩展内容,封装在Map集合中,用于显示xxx评论..了你的帖子
        HashMap<String, Object> content = new HashMap<>();
        content.put("userId", event.getUserId());
        content.put("entityId", event.getEntityId());
        content.put("entityType", event.getEntityType());

        // 将event.getData里的k-v存到context这个Map中,再封装进message
        // Map.Entry是为了更方便的输出map键值对,Entry可以一次性获得key和value者两个值
        if (!event.getData().isEmpty()) {
            for (Map.Entry<String, Object> entry : event.getData().entrySet()) {
                content.put(entry.getKey(), entry.getValue());
            }
        }
        // 将content(map类型)转化成字符串类型封装进message
        message.setContent(JSONObject.toJSONString(content));
        messageService.addMessage(message);

    }
}

4.在CommunityConstant添加Kafka主题静态常量

public interface CommunityConstant {
     /**
     * Kafka主题: 评论
     */
    String TOPIC_COMMENT = "comment";
    /**
     * Kafka主题: 点赞
     */
    String TOPIC_LIKE = "like";
    /**
     * Kafka主题: 关注
     */
    String TOPIC_FOLLOW = "follow";
    /**
     * 系统用户ID
     */
    int SYSTEM_USER_ID = 1;
}

5.处理触发评论事件CommentController

    @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);
        /**
         * 触发评论事件
         * 评论完后,调用Kafka生产者,发送系统通知
         */
        Event event = new Event()
                .setTopic(TOPIC_COMMENT)
                .setEntityId(comment.getEntityId())
                .setEntityType(comment.getEntityType())
                .setUserId(hostHolder.getUser().getId())
                .setData("postId", discussPostId);
        /**
         * event.setEntityUserId要分情况设置被发起事件的用户id
         * 1.评论的是帖子,被发起事件(评论)的用户->该帖子发布人id
         * 2.评论的是用户的评论,被发起事件(评论)的用户->该评论发布人id
         */
        if (comment.getEntityType() == ENTITY_TYPE_POST) {
            // 先找评论表对应的帖子id,在根据帖子表id找到发帖人id
            DiscussPost target = discussPostService.findDiscussPostById(comment.getEntityId());
            event.setEntityUserId(target.getUserId());
        } else if (comment.getEntityType() == ENTITY_TYPE_COMMENT) {
            Comment target = commentService.findCommentById(comment.getEntityId());
            event.setEntityUserId(target.getUserId());

        }
        eventProducer.fireMessage(event);
        
        return "redirect:/discuss/detail/" + discussPostId;
    }

6.处理触发关注事件FollowController

    @RequestMapping(value = "/follow", method = RequestMethod.POST)
    @ResponseBody // 关注是异步请求
    public String follow(int entityType, int entityId) {
        followService.follow(hostHolder.getUser().getId(), entityType, entityId);
        /**
         * 触发关注事件
         * 关注完后,调用Kafka生产者,发送系统通知
         */
        Event event = new Event()
            .setTopic(TOPIC_FOLLOW)
            .setUserId(hostHolder.getUser().getId())
            .setEntityType(entityType)
            .setEntityId(entityId)
            .setEntityUserId(entityId);
        // 用户关注实体的id就是被关注的用户id->EntityId=EntityUserId
        eventProducer.fireMessage(event);
        
        return CommunityUtil.getJSONString(0, "已关注");
    }

7.处理触发点赞事件LikeController

    @RequestMapping(value = "/like", method = RequestMethod.POST)
    @ResponseBody
    // 加了一个postId变量,对应的前端和js需要修改
    public String like(int entityType, int entityId, int entityUserId, int postId) {
        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);
        /**
         * 触发点赞事件
         * 只有点赞完后,才会调用Kafka生产者,发送系统通知,取消点赞不会调用事件
         */
        if (likeStatus == 1) {
            Event event = new Event()
                    .setTopic(TOPIC_LIKE)
                    .setEntityId(entityId)
                    .setEntityType(entityType)
                    .setUserId(user.getId())
                    .setEntityUserId(entityUserId)
                    .setData("postId", postId);
            // 注意:data里面存postId是因为点击查看后链接到具体帖子的页面
            eventProducer.fireMessage(event);
        }
        return CommunityUtil.getJSONString(0, null, map);
    }
<!--对应的前端postId变量以及js的修改-->
<a 
  th:onclick="|like(this,1,${post.id},${post.userId},${post.id});|">
</a>
function like(btn, entityType, entityId, entityUserId, postId) {
  $.post(
      CONTEXT_PATH + "/like",
      {"entityType": entityType, "entityId": entityId, "entityUserId": entityUserId, "postId":postId},
      function(data) {
      .....}
  );}

查询系统通知

1.编写Dao层接口(及Mapper.xml)

/**
 * 查询某个主题最新通知
 */
Message selectLatestNotice(@Param("userId")int userId, @Param("topic")String topic);
/**
 * 查询某个主题通知个数
 */
int selectNoticeCount(@Param("userId")int userId, @Param("topic")String topic);
/**
 * 查询某个主题未读个数(topic可为null,若为null:查询所有类系统未读通知个数)
 */
int selectNoticeUnreadCount(@Param("userId")int userId, @Param("topic")String topic);
/**
* 分页查询某个主题的详情
*/
List<Message> selectNotices(@Param("userId")int userId, @Param("topic")String topic, @Param("offset")int offset, @Param("limit")int limit);

    <!--系统通知-->
    <select id="selectLatestNotice" resultType="Message">
        select <include refid="selectFields"></include>
        from message
        where id in (
          select max(id) from message
          where status != 2
          and from_id = 1
          and to_id = #{userId}
          and conversation_id = #{topic}
        )
    </select>

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

    <!--topic为null时查询所有类系统未读通知--->
    <select id="selectNoticeUnreadCount" resultType="int">
        select count(id) from message
        where status = 0
        and from_id = 1
        and to_id = #{userId}
        <if test="topic!=null">
            and conversation_id = #{topic}
        </if>
    </select>

    <select id="selectNotices" resultType="Message">
        select <include refid="selectFields"></include>
        from message
        where status != 2
        and from_id = 1
        and to_id = #{userId}
        and conversation_id = #{topic}
        order by create_time desc
        limit #{offset}, #{limit}
    </select>

2.编写Service业务层

    public Message findLatestNotice(int userId, String topic) {
        return messageMapper.selectLatestNotice(userId, topic);
    }

    public int findNoticeCount(int userId, String topic) {
        return messageMapper.selectNoticeCount(userId, topic);
    }

    public int findNoticeUnreadCount(int userId, String topic) {
        return messageMapper.selectNoticeUnreadCount(userId, topic);
    }

    public List<Message> findNotices(int userId, String topic, int offset, int limit) {
        return messageMapper.selectNotices(userId, topic, offset, limit);
    }

3.编写MessageController层

3.1查询系统通知接口(评论类通知、点赞类通知、关注类通知三种类似)

    /**
     * 查询系统通知
     */
    @RequestMapping(value = "/notice/list", method = RequestMethod.GET)
    public String getNoticeList(Model model) {
        User user = hostHolder.getUser();
        /**查询评论类通知**/
        Message message = messageService.findLatestNotice(user.getId(), TOPIC_COMMENT);

        if (message != null) {
            HashMap<String, Object> messageVO = new HashMap<>();
            messageVO.put("message", message);

            // 转化message表中content为HashMap<k,v>类型
            String content = HtmlUtils.htmlUnescape(message.getContent());
            Map<String, Object> data = JSONObject.parseObject(content, HashMap.class);
            // 将content数据中的每一个字段都存入map
            // 用于显示->用户[user] (评论、点赞、关注[entityType])...了你的(帖子、回复、用户[entityId]) 查看详情连接[postId]
            messageVO.put("user", userService.findUserById((Integer) data.get("userId")));
            messageVO.put("entityType", data.get("entityType"));
            messageVO.put("entityId", data.get("entityId"));
            messageVO.put("postId", data.get("postId"));

            // 共几条会话
            int count = messageService.findNoticeCount(user.getId(), TOPIC_COMMENT);
            messageVO.put("count", count);
            // 评论类未读数
            int unreadCount = messageService.findNoticeUnreadCount(user.getId(), TOPIC_COMMENT);
            messageVO.put("unreadCount", unreadCount);

            model.addAttribute("commentNotice", messageVO);
        }

        /**查询点赞类通知**/
        message = messageService.findLatestNotice(user.getId(), TOPIC_LIKE);

        if (message != null) {
            HashMap<String, Object> messageVO = new HashMap<>();
            messageVO.put("message", message);
            // 转化message表中content为HashMap<k,v>类型
            String content = HtmlUtils.htmlUnescape(message.getContent());
            Map<String, Object> data = JSONObject.parseObject(content, HashMap.class);
            // 将content数据中的每一个字段都存入map
            // 用于显示->用户[user] (评论、点赞、关注[entityType])...了你的(帖子、回复、用户[entityId]) 查看详情连接[postId]
            messageVO.put("user", userService.findUserById((Integer) data.get("userId")));
            messageVO.put("entityType", data.get("entityType"));
            messageVO.put("entityId", data.get("entityId"));
            messageVO.put("postId", data.get("postId"));

            // 共几条会话
            int count = messageService.findNoticeCount(user.getId(), TOPIC_LIKE);
            messageVO.put("count", count);
            // 点赞类未读数
            int unreadCount = messageService.findNoticeUnreadCount(user.getId(), TOPIC_LIKE);
            messageVO.put("unreadCount", unreadCount);
            model.addAttribute("likeNotice", messageVO);
        }

        /**查询关注类通知**/
        message = messageService.findLatestNotice(user.getId(), TOPIC_FOLLOW);

        if (message != null) {
            HashMap<String, Object> messageVO = new HashMap<>();
            messageVO.put("message", message);
            // 转化message表中content为HashMap<k,v>类型
            String content = HtmlUtils.htmlUnescape(message.getContent());
            Map<String, Object> data = JSONObject.parseObject(content, HashMap.class);
            // 将content数据中的每一个字段都存入map
            // 用于显示->用户[user] (评论、点赞、关注)...了你的(帖子、回复、用户[entityType]) 查看详情连接[postId]
            messageVO.put("user", userService.findUserById((Integer) data.get("userId")));
            messageVO.put("entityType", data.get("entityType"));
            messageVO.put("entityId", data.get("entityId"));
            messageVO.put("postId", data.get("postId"));

            // 共几条会话
            int count = messageService.findNoticeCount(user.getId(), TOPIC_FOLLOW);
            messageVO.put("count", count);
            // 关注类未读数
            int unreadCount = messageService.findNoticeUnreadCount(user.getId(), TOPIC_FOLLOW);
            messageVO.put("unreadCount", unreadCount);
            model.addAttribute("followNotice", messageVO);
        }

        // 查询未读私信数量
        int letterUnreadCount = messageService.findLetterUnreadCount(user.getId(), null);
        model.addAttribute("letterUnreadCount", letterUnreadCount);
        // 查询所有未读系统通知数量
        int noticeUnreadCount = messageService.findNoticeUnreadCount(user.getId(), null);
        model.addAttribute("noticeUnreadCount", noticeUnreadCount);

        return "/site/notice";
    }

3.2查询系统通知详情页接口

    /**
     * 查询系统通知详情页(分页)
     */
    @RequestMapping(value = "/notice/detail/{topic}", method = RequestMethod.GET)
    public String getNoticeDetail(@PathVariable("topic")String topic, Page page, Model model) {
        User user = hostHolder.getUser();

        page.setLimit(5);
        page.setPath("/notice/detail/" + topic);
        page.setRows(messageService.findNoticeCount(user.getId(), topic));

        List<Message> noticeList = messageService.findNotices(user.getId(), topic, page.getOffset(), page.getLimit());
        // 聚合拼接User
        List<Map<String, Object>> noticeVoList = new ArrayList<>();
        if (noticeList != null) {
            for (Message notice : noticeList) {
                HashMap<String, Object> map = new HashMap<>();
                // 将查询出来的每一个通知封装Map
                map.put("notice", notice);
                // 发起事件的user
                map.put("user", userService.findUserById(user.getId()));

                // 把message中的content内容转化Object
                String content = HtmlUtils.htmlUnescape(notice.getContent());
                Map<String, Object> data = JSONObject.parseObject(content, HashMap.class);
                map.put("entityType", data.get("entityType"));
                map.put("entityId", data.get("entityId"));
                map.put("postId", data.get("postId"));
                // 系统通知->id=1的系统用户
                map.put("fromUser", userService.findUserById(notice.getFromId()));

                noticeVoList.add(map);
            }
        }
        model.addAttribute("notices", noticeVoList);

        //设置已读(当打开这个页面是就更改status =1)
        List<Integer> ids = getLetterIds(noticeList);
        if (!ids.isEmpty()) {
            messageService.readMessage(ids);
        }

        return "/site/notice-detail";
    }

4.通过AOP编程实现查询未读消息总数(私信消息+系统消息)

4.1编写MessageInterceptor拦截器

@Component
public class MessageInterceptor implements HandlerInterceptor {
    @Autowired
    private HostHolder hostHolder;
    @Autowired
    private MessageService messageService;
    // 查询未读消息总数(AOP),controller之后,渲染模板之前
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        User user = hostHolder.getUser();
        if (user != null && modelAndView != null) {
            int letterUnreadCount = messageService.findLetterUnreadCount(user.getId(), null);
            int noticeUnreadCount = messageService.findNoticeUnreadCount(user.getId(), null);

            modelAndView.addObject("allUnreadCount", letterUnreadCount + noticeUnreadCount);
        }
}}

// index页前端对应代码
<li th:if="${loginUser!=null}">
  <a th:href="@{/letter/list}">消息
    <span th:text="${allUnreadCount!=0?allUnreadCount:''}">消息未读总数</span>
  </a>
</li>

4.2注册拦截器

  @Autowired
  private MessageInterceptor messageInterceptor;
  public void addInterceptors(InterceptorRegistry registry) {
      registry.addInterceptor(messageInterceptor)
          .excludePathPatterns("/* */*.css", "/**/ *.js", "/* */*.png", "/ **/ *.jpg", "/* */*.jpeg");
  }

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

5.1系统通知页

<li>
  <a class="active" th:href="@{/notice/list}">
    系统通知<span th:text="${noticeUnreadCount}" th:if="${noticeUnreadCount!=0}">系统通知未读数</span>
  </a>
</li>

<!-- 通知列表 -->
<ul class="list-unstyled">
   <!--评论类通知-->
   <li th:if="${commentNotice!=null}">
     <span th:text="${commentNotice.unreadCount!=0?commentNotice.unreadCount:''}">评论通知未读数</span>
     <img src="http://xxx.png" alt="通知图标">
      <h6>
        <span>评论</span>
        <span th:text="${#dates.format(commentNotice.message.createTime,'yyyy-MM-dd HH:mm:ss')}">2019-04-28 14:13:25</span>
      </h6>
      <div>
        <a th:href="@{/notice/detail/comment}">
          用户 <i th:utext="${commentNotice.user.username}">评论发起人用户名</i>
          评论了你的<b th:text="${commentNotice.entityType==1?'帖子':'回复'}">帖子</b> ...</a>
        <ul>
          <span><i th:text="${commentNotice.count}">3</i> 条会话</span>
        </ul>
      </div>
   </li>
   <!--点赞类通知-->
   <li th:if="${likeNotice!=null}">
        <span th:text="${likeNotice.unreadCount!=0?likeNotice.unreadCount:''}">3</span>
        <img src="http://like.png" alt="通知图标">
        <div>
          <h6>
            <span></span>
            <span th:text="${#dates.format(likeNotice.message.createTime,'yyyy-MM-dd HH:mm:ss')}">2019-04-28 14:13:25</span>
          </h6>
          <div>
            <a th:href="@{/notice/detail/like}">
              用户
              <i th:utext="${likeNotice.user.username}">nowcoder</i>
              点赞了你的<b th:text="${likeNotice.entityType==1?'帖子':'回复'}">帖子</b> ...
            </a>
            <ul>
              <span class="text-primary"><i th:text="${likeNotice.count}">3</i> 条会话</span>
            </ul>
          </div>
        </div>
      </li>
   <!--关注类通知-->
   <li th:if="${followNotice!=null}">
        <span th:text="${followNotice.unreadCount!=0?followNotice.unreadCount:''}">3</span>
        <img src="http://follow.png" class="mr-4 user-header" alt="通知图标">
        <div>
          <h6>
            <span>关注</span>
            <span th:text="${#dates.format(followNotice.message.createTime,'yyyy-MM-dd HH:mm:ss')}">2019-04-28 14:13:25</span>
          </h6>
          <div>
            <a th:href="@{/notice/detail/follow}">
              用户
              <i th:utext="${followNotice.user.username}">nowcoder</i>
              关注了你 ...
            </a>
            <ul>
              <span class="text-primary"><i th:text="${followNotice.count}">3</i> 条会话</span>
            </ul>
          </div>
        </div>
      </li>
</ul>

5.2系统通知详情页

<div class="col-4 text-right">
  <button type="button" class="btn btn-secondary btn-sm" onclick="back();">返回</button>
</div>

<!-- 通知列表 -->
<ul>
  <li th:each="map:${notices}">
    <img th:src="${map.fromUser.headerUrl}" alt="系统图标">
    <div>
      <div>
        <strong th:utext="${map.fromUser.username}">系统名</strong>
        <small th:text="${#dates.format(map.notice.createTime,'yyyy-MM-dd HH:mm:ss')}">2019-04-25 15:49:32</small>
      </div>
      <div>
        <!--显示评论信息-->
        <span th:if="${topic.equals('comment')}">
          用户
          <i th:utext="${map.user.username}">发起事件人</i>
          评论了你的<b th:text="${map.entityType==1?'帖子':'回复'}">帖子</b>,
          <a th:href="@{|/discuss/detail/${map.postId}|}">点击查看</a> !
        </span>
        <!--显示点赞信息-->
        <span th:if="${topic.equals('like')}">
          用户
          <i th:utext="${map.user.username}">发起事件人</i>
          点赞了你的<b th:text="${map.entityType==1?'帖子':'回复'}">帖子</b>,
          <a th:href="@{|/discuss/detail/${map.postId}|}">点击查看</a> !
        </span>
        <!--显示关注信息-->
        <span th:if="${topic.equals('follow')}">
          用户
          <i th:utext="${map.user.username}">发起事件人</i>
          关注了你,
          <a th:href="@{|/user/profile/${map.user.id}|}">点击查看</a> !
        </span>
      </div>
    </div>
  </li>
</ul>
<script>
  function back() {
    location.href = CONTEXT_PATH + "/notice/list";
  }
</script>

搜索功能(Elasticsearch+Kafka)

1.编写实体类映射到Elasticsearch服务器

// Elasticsearch表名
@Document(indexName = "discusspost", type = "_doc", shards = 6, replicas = 3)
@Data
@AllArgsConstructor
@NoArgsConstructor
public class DiscussPost {
    @Id
    private int id;

    // Elaticsearch与数据库表映射
    @Field(type = FieldType.Integer)
    private int userId;

    // analyzer:最大中文分词解析器, searchAnalyzer:智能中文分词解析器
    @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
    private String title;

    @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
    private String content;

    @Field(type = FieldType.Integer)
    private int type;

    @Field(type = FieldType.Integer)
    private int status;

    @Field(type = FieldType.Date)
    private Date createTime;

    @Field(type = FieldType.Integer)
    private int commentCount;

    @Field(type = FieldType.Double)
    private double score;

2.编写xxxRepository接口继承ElasticsearchRepository<Class, Integer>

/**
 * ElasticsearchRepository<DiscussPost, Integer>
 * DiscussPost:接口要处理的实体类
 * Integer:实体类中的主键是什么类型
 * ElasticsearchRepository:父接口,其中已经事先定义好了对es服务器访问的增删改查各种方法。Spring会给它自动做一个实现,我们直接去调就可以了。
 */
@Repository
public interface DiscussPostRepository extends ElasticsearchRepository<DiscussPost, Integer> {
}

3.编写ElasticsearchService业务层

/**
 * 用Elasticsearch服务器搜索帖子service
 */
@Service
public class ElasticsearchService {

    @Autowired
    private DiscussPostRepository discussRepository;

    @Autowired
    private ElasticsearchTemplate elasticTemplate;

    public void saveDiscussPost(DiscussPost post) {
        discussRepository.save(post);
    }

    public void deleteDiscussPost(int id) {
        discussRepository.deleteById(id);
    }

    /**
     * Elasticsearch高亮搜索
     * current:当前页(不是offset起始页)
     */
    public Page<DiscussPost> searchDiscussPost(String keyword, int current, int limit) {
        SearchQuery searchQuery = new NativeSearchQueryBuilder()
                .withQuery(QueryBuilders.multiMatchQuery(keyword, "title", "content"))
                .withSort(SortBuilders.fieldSort("type").order(SortOrder.DESC))
                .withSort(SortBuilders.fieldSort("score").order(SortOrder.DESC))
                .withSort(SortBuilders.fieldSort("createTime").order(SortOrder.DESC))
                .withPageable(PageRequest.of(current, limit))
                .withHighlightFields(
                        new HighlightBuilder.Field("title").preTags("<em>").postTags("</em>"),
                        new HighlightBuilder.Field("content").preTags("<em>").postTags("</em>")
                ).build();

        // new SearchResultMapper()匿名类,处理高亮
        return elasticTemplate.queryForPage(searchQuery, DiscussPost.class, new SearchResultMapper() {
            @Override
            public <T> AggregatedPage<T> mapResults(SearchResponse response, Class<T> aClass, Pageable pageable) {
                SearchHits hits = response.getHits();
                if (hits.getTotalHits() <= 0) {
                    return null;
                }

                List<DiscussPost> list = new ArrayList<>();
                for (SearchHit hit : hits) {
                    DiscussPost post = new DiscussPost();

                    // elasticsearch中将json格式数据封装为了map,在将map字段存进post中
                    String id = hit.getSourceAsMap().get("id").toString();
                    post.setId(Integer.valueOf(id));

                    String userId = hit.getSourceAsMap().get("userId").toString();
                    post.setUserId(Integer.valueOf(userId));

                    String title = hit.getSourceAsMap().get("title").toString();
                    post.setTitle(title);

                    String content = hit.getSourceAsMap().get("content").toString();
                    post.setContent(content);

                    String status = hit.getSourceAsMap().get("status").toString();
                    post.setStatus(Integer.valueOf(status));

                    // createTime字符串是Long类型
                    String createTime = hit.getSourceAsMap().get("createTime").toString();
                    post.setCreateTime(new Date(Long.valueOf(createTime)));

                    String commentCount = hit.getSourceAsMap().get("commentCount").toString();
                    post.setCommentCount(Integer.valueOf(commentCount));

                    // 处理高亮显示的结果
                    HighlightField titleField = hit.getHighlightFields().get("title");
                    if (titleField != null) {
                        // [0]->搜寻结果为多段时,取第一段
                        post.setTitle(titleField.getFragments()[0].toString());
                    }

                    HighlightField contentField = hit.getHighlightFields().get("content");
                    if (contentField != null) {
                        post.setContent(contentField.getFragments()[0].toString());
                    }

                    list.add(post);
                }

                return new AggregatedPageImpl(list, pageable,
                        hits.getTotalHits(), response.getAggregations(), response.getScrollId(), hits.getMaxScore());
            }
        });
    }
}

4.修改发布帖子和增加评论Controller

发布帖子时,将帖子异步提交到Elasticsearch服务器

增加评论时,将帖子异步提交到Elasticsearch服务器

     /**
      * Kafka主题: 发布帖子(常量接口)
      */
    String TOPIC_PUBILISH = "publish";
    
    /**--------------------------------------------------------**/
    @RequestMapping(value = "/add/{discussPostId}", method = RequestMethod.POST)
    public String addComment(@PathVariable("discussPostId") int discussPostId, Comment comment) {
      // ............
      
     /**
      * 增加评论时,将帖子异步提交到Elasticsearch服务器
      * 通过Kafka消息队列去提交,修改Elasticsearch中帖子的评论数
      */
      //若评论为帖子类型时,才需要加入消息队列处理
      if (comment.getEntityType() == ENTITY_TYPE_POST) {
          event = new Event()
                  .setTopic(TOPIC_PUBILISH)
                  .setUserId(comment.getUserId())
                  .setEntityType(ENTITY_TYPE_POST)
                  .setEntityId(discussPostId);
          eventProducer.fireMessage(event);
      }
     return "redirect:/discuss/detail/" + discussPostId;
    }
    @RequestMapping(value = "/add", method = RequestMethod.POST)
    @ResponseBody
    // 异步请求要加@ResponseBody,且不要在Controller层用Model
    public String addDiscussPost(String title, String content) {
    //.................
    
    /**
     * 发布帖子时,将帖子异步提交到Elasticsearch服务器
     * 通过Kafka消息队列去提交,将新发布的帖子存入Elasticsearch
     */
    Event event = new Event()
            .setTopic(TOPIC_PUBILISH)
            .setUserId(user.getId())
            .setEntityType(ENTITY_TYPE_POST)
            .setEntityId(post.getId());
    eventProducer.fireMessage(event);

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

5.在消费组件中增加方法(消费帖子发布事件)

    /**
     * 消费帖子发布事件,将新增的帖子和添加评论后帖子评论数通过消息队列的方式save进Elastisearch服务器中
     */
    @KafkaListener(topics = {TOPIC_PUBILISH})
    public void handleDiscussPostMessage(ConsumerRecord record) {
        if (record == null || record.value() == null) {
            logger.error("消息的内容为空!");
            return;
        }
        // 将record.value字符串格式转化为Event对象
        Event event = JSONObject.parseObject(record.value().toString(), Event.class);
        // 注意:event若data=null,是fastjson依赖版本的问题
        if (event == null) {
            logger.error("消息格式错误!");
            return;
        }

        DiscussPost post = discussPostService.findDiscussPostById(event.getEntityId());
        elasticsearchService.saveDiscussPost(post);
    }

6.编写SearchController类

@Controller
public class SearchController implements CommunityConstant {
    @Autowired
    private UserService userService;
    @Autowired
    private LikeService likeService;
    @Autowired
    private ElasticsearchService elasticsearchService;

    // search?keyword=xxx
    @RequestMapping(value = "/search", method = RequestMethod.GET)
    public String search(String keyword, Page page, Model model) {
        // 搜索帖子
        // 在调用elasticsearchService完成搜索的时候,查询条件设置的是从第几页开始,所以要填getCurrent,填getOffset会导致翻页的时候查询错误
        org.springframework.data.domain.Page<DiscussPost> searchResult =
                elasticsearchService.searchDiscussPost(keyword, page.getCurrent() - 1, page.getLimit());
        // 聚合数据
        List<Map<String, Object>> discussPosts = new ArrayList<>();

        if (searchResult != null) {
            for (DiscussPost post : searchResult) {
                Map<String, Object> map = new HashMap<>();
                // 帖子
                map.put("post", post);
                // 作者
                map.put("user", userService.findUserById(post.getUserId()));
                // 点赞数量
                map.put("likeCount", likeService.findEntityLikeCount(ENTITY_TYPE_POST, post.getId()));

                discussPosts.add(map);
            }
        }
        model.addAttribute("discussPosts", discussPosts);
        // 为了页面上取的默认值方便
        model.addAttribute("keyword", keyword);

        page.setPath("/search?keyword=" + keyword);
        page.setRows(searchResult == null ? 0 :(int) searchResult.getTotalElements());
        return "/site/search";
    }
}

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

  <!-- 搜索表单 -->
  <form method="get" th:action="@{/search}">
    <!--th:value->设置默认值  model.addAttribute("keyword", keyword) -->
    <input type="search" aria-label="Search" name="keyword" th:value="${keyword}"/>
    <button type="submit">搜索</button>
  </form>
<li th:each="map:${discussPosts}">
  <img th:src="${map.user.headerUrl}" alt="用户头像" style="width:50px;height:50px">
  <div>
    <a th:href="@{|/discuss/detail/${map.post.id}|}" th:utext="${map.post.title}"><em>搜索词高亮显示</em>可链接的帖子标题</a>
    <div th:utext="${map.post.content}">
       帖子内容<em>搜索词高亮显示</em>
    </div>
    <div>
      <u th:utext="${map.user.username}">寒江雪</u>
      发布于 <b th:text="${#dates.format(map.post.createTime,'yyyy-MM-dd HH:mm:ss')}">帖子发布时间</b>
      <ul>
        <li><i th:text="${map.likeCount}">11</i></li>
        <li>|</li>
        <li>回复 <i th:text="${map.post.commentCount}">7</i></li>
      </ul>
    </div>
  </div>
</li>

权限控制

部署SpringSecurity权限控制

1.配置SecurityConfig类

登录检查:废弃之前的拦截器配置,采用SpringSecurity

权限配置:对所有请求分配访问权限

/**
 * springsecurity配置
 * 之所以没有configure(AuthenticationManagerBuilder auth),是因为要绕过security自带的方案
 */
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter implements CommunityConstant {

    @Override
    public void configure(WebSecurity web) throws Exception {
        // 忽略静态资源
        web.ignoring().antMatchers("/resources/* *");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 授权
        http.authorizeRequests()
                // 需要授权的请求
                .antMatchers(
                        "/user/setting",
                        "/user/upload",
                        "/discuss/add",
                        "/comment/add/* *",
                        "/letter/* *",
                        "/notice/* *",
                        "/like",
                        "/follow",
                        "/unfollow"
                )
                // 这3中权限可以访问以上请求
                .hasAnyAuthority(
                        AUTHORITY_USER,
                        AUTHORITY_ADMIN,
                        AUTHORITY_MODERATOR
                )
                // 其他请求方行
                .anyRequest().permitAll()
                // 禁用 防止csrf攻击功能
                .and().csrf().disable();


        // 权限不够时的处理
        http.exceptionHandling()
                .authenticationEntryPoint(new AuthenticationEntryPoint() {
                    // 没有登录
                    @Override
                    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
                        // 同步请求重定向返回HTML,异步请求返回json
                        String xRequestedWith = request.getHeader("x-requested-with");
                        if ("XMLHttpRequest".equals(xRequestedWith)) {
                            // 处理异步请求
                            response.setContentType("application/plain;charset=utf-8");
                            PrintWriter writer = response.getWriter();
                            writer.write(CommunityUtil.getJSONString(403, "你还没有登录哦!"));
                        } else {
                            response.sendRedirect(request.getContextPath() + "/login");
                        }
                    }
                })
                // 权限不足
                .accessDeniedHandler(new AccessDeniedHandler() {
                    @Override
                    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
                        String xRequestedWith = request.getHeader("x-requested-with");
                        if ("XMLHttpRequest".equals(xRequestedWith)) {
                            response.setContentType("application/plain;charset=utf-8");
                            PrintWriter writer = response.getWriter();
                            writer.write(CommunityUtil.getJSONString(403, "你没有访问此功能的权限!"));
                        } else {
                            response.sendRedirect(request.getContextPath() + "/denied");
                        }
                    }
                });

        // Security底层默认会拦截/logout请求,进行退出处理.
        // 覆盖它默认的逻辑,才能执行我们自己的退出代码.
        //底层:private String logoutUrl = "/logout";
        http.logout().logoutUrl("/securitylogout");
    }
}

2.编写UserService增加自定义登录认证方法绕过security自带认证流程

    /**绕过Security认证流程,采用原来的认证方案,封装认证结果**/
    public Collection<? extends GrantedAuthority> getAuthorities(int userId) {
        User user = this.findUserById(userId);

        List<GrantedAuthority> list = new ArrayList<>();
        list.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                switch (user.getType()) {
                    case 1:
                        return AUTHORITY_ADMIN;
                    case 2:
                        return AUTHORITY_MODERATOR;
                    default:
                        return AUTHORITY_USER;
                }
            }
        });
        return list;
    }

3.编写登录凭证拦截器LoginTicketInterceptor

构建用户认证结果,并存入SecurityContext,以便于Security进行授权

    @Override
    /**在Controller访问所有路径之前获取凭证**/
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
      //...................................
      
      if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {
        // ...............................
        /**
         * QQ:260602448
         * 构建用户认证结果,并存入SecurityContext,以便于Security进行授权
         */
        Authentication authentication = new UsernamePasswordAuthenticationToken(
                user, user.getPassword(), userService.getAuthorities(user.getId()));
        SecurityContextHolder.setContext(new SecurityContextImpl(authentication));
      }
    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 释放线程资源
        hostHolder.clear();
        // 释放SecurityContext资源
        SecurityContextHolder.clearContext();
    }

4.退出登录时释放SecurityContext资源

    /**
     * 退出登录功能
     */
    @RequestMapping(value = "/logout", method = RequestMethod.GET)
    public String logout(@CookieValue("ticket") String ticket) {
        userService.logout(ticket);
        // 释放SecurityContext资源
        SecurityContextHolder.clearContext();
        return "redirect:/login";
    }

5.注意:防止CSRF攻击

CSRF攻击原理

由于服务端SpringSecurity自带防止CSRF攻击,因此只要编写前端页面防止CSRF攻击即可 \ (常发生在提交表单时)

  <!--访问该页面时,在此处生成CSRF令牌.-->
  <meta name="_csrf" th:content="${_csrf.token}">
  <meta name="_csrf_header" th:content="${_csrf.headerName}">

Ajax异步请求时携带该参数

function publish() {
   $("#publishModal").modal("hide");
   // 发送AJAX请求之前,将CSRF令牌设置到请求的消息头中.
   var token = $("meta[name='_csrf']").attr("content");
   var header = $("meta[name='_csrf_header']").attr("content");
   $(document).ajaxSend(function(e, xhr, options){
       xhr.setRequestHeader(header, token);
   });
   // ...............................
}

置顶、加精、删除

1.编写Mapper、Service层

思路:改变帖子状态

置顶:type = (0-正常,1-置顶) 加精:status = (0-正常,1-加精,2-删除)

    int updateType(@Param("id")int id,@Param("type") int type);
    int updateStatus(@Param("id")int id,@Param("status") int status);
    
    <!--------------------Mapper.xml------------------------->
    <update id="updateType">
        update discuss_post set type = #{type} where id = #{id}
    </update>
    <update id="updateStatus">
        update discuss_post set status = #{status} where id = #{id}
    </update>
    
    <!--------------------Service层------------------------->
    public int updateType(int id, int type) {
        return discussPostMapper.updateType(id, type);
    }

    public int updateStatus(int id, int status) {
        return discussPostMapper.updateStatus(id, status);
    }

2.编写DiscussPostController层

    // 置顶、取消置顶(与以下类似)
    @RequestMapping(value = "/top", method = RequestMethod.POST)
    @ResponseBody
    public String setTop(int id) {
        DiscussPost post = discussPostService.findDiscussPostById(id);
        // 获取置顶状态,1为置顶,0为正常状态,1^1=0 0^1=1
        int type = post.getType() ^ 1;
        discussPostService.updateType(id, type);
        // 返回结果给JS异步请求
        HashMap<String, Object> map = new HashMap<>();
        map.put("type", type);

        // 触发事件,修改Elasticsearch中的帖子type
        Event event = new Event()
                .setTopic(TOPIC_PUBILISH)
                .setUserId(hostHolder.getUser().getId())
                .setEntityType(ENTITY_TYPE_POST)
                .setEntityId(id);
        eventProducer.fireMessage(event);

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

    // 加精、取消加精
    @RequestMapping(value = "/wonderful", method = RequestMethod.POST)
    @ResponseBody
    public String setWonderful(int id) {
        DiscussPost post = discussPostService.findDiscussPostById(id);
        int status = post.getStatus() ^ 1;
        discussPostService.updateStatus(id, status);
        // 返回结果给JS异步请求
        HashMap<String, Object> map = new HashMap<>();
        map.put("status", status);

        // 触发事件,修改Elasticsearch中的帖子status
        Event event = new Event()
                .setTopic(TOPIC_PUBILISH)
                .setUserId(hostHolder.getUser().getId())
                .setEntityType(ENTITY_TYPE_POST)
                .setEntityId(id);
        eventProducer.fireMessage(event);

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

    // 删除
    @RequestMapping(value = "/delete", method = RequestMethod.POST)
    @ResponseBody
    public String setDelete(int id) {
        discussPostService.updateStatus(id, 2);

        // 触发删帖事件,将帖子从Elasticsearch中删除
        Event event = new Event()
                .setTopic(TOPIC_DELETE)
                .setUserId(hostHolder.getUser().getId())
                .setEntityType(ENTITY_TYPE_POST)
                .setEntityId(id);
        eventProducer.fireMessage(event);

        return CommunityUtil.getJSONString(0);
    }

3.编写Kafka消费者中删除(TOPIC_DELETE)的主题事件

    /**帖子删除事件**/
    @KafkaListener(topics = {TOPIC_DELETE})
    public void handleDeleteMessage(ConsumerRecord record) {
        if (record == null || record.value() == null) {
            logger.error("消息的内容为空!");
            return;
        }
        // 将record.value字符串格式转化为Event对象
        Event event = JSONObject.parseObject(record.value().toString(), Event.class);
        // 注意:event若data=null,是fastjson依赖版本的问题
        if (event == null) {
            logger.error("消息格式错误!");
            return;
        }
        elasticsearchService.deleteDiscussPost(event.getEntityId());
    }

4.在SecurityConfig中给予(置顶、加精、删除)权限

  // 授权
  http.authorizeRequests()
          // 需要授权的请求
          // ...............
          )
          .antMatchers(
                  "/discuss/top",
                  "/discuss/wonderful"
          )
          .hasAnyAuthority(
                  AUTHORITY_MODERATOR // 版主授予加精、置顶权限
          )
          .antMatchers(
                  "/discuss/delete"
          )
          .hasAnyAuthority(
                  AUTHORITY_ADMIN // 管理员授予删除帖子权限
          )
          // 其他请求方行
          .anyRequest().permitAll()
          // 禁用 防止csrf攻击功能
          .and().csrf().disable();

5.编写前端代码(核心部分)

5.1引用pom.xml,使用sec:xxx

  <dependency>
      <groupId>org.thymeleaf.extras</groupId>
      <artifactId>thymeleaf-extras-springsecurity5</artifactId>
  </dependency>

5.2 引入thymeleaf支持security的头文件

<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<div>
  <input type="hidden" id="postId" th:value="${post.id}">
  <button type="button" class="btn" id="topBtn"
      th:text="${post.type==1?'取消置顶':'置顶'}" sec:authorize="hasAnyAuthority('moderator')">置顶</button>
  <button type="button" class="btn" id="wonderfulBtn"
      th:text="${post.status==1?'取消加精':'加精'}" sec:authorize="hasAnyAuthority('moderator')">加精</button>
  <button type="button" class="btn" id="deleteBtn"
      th:disabled="${post.status==2}" sec:authorize="hasAnyAuthority('admin')">删除</button>
</div>

5.3 编写JS中的异步Ajax请求

// 页面加载完以后调用
$(function(){
    $("#topBtn").click(setTop);
    $("#wonderfulBtn").click(setWonderful);
    $("#deleteBtn").click(setDelete);
});

// 置顶、取消置顶
function setTop() {
    $.post(
        CONTEXT_PATH + "/discuss/top",
        {"id":$("#postId").val()},
        function(data) {
            data = $.parseJSON(data);
            if(data.code == 0) {
                $("#topBtn").text(data.type == 1 ? '取消置顶':'置顶');
            } else {
                alert(data.msg);
            }
        }
    );
}

// 加精、取消加精
function setWonderful() {
    $.post(
        CONTEXT_PATH + "/discuss/wonderful",
        {"id":$("#postId").val()},
        function(data) {
            data = $.parseJSON(data);
            if(data.code == 0) {
                $("#wonderfulBtn").text(data.status == 1 ? '取消加精':'加精');
            } else {
                alert(data.msg);
            }
        }
    );
}

// 删除
function setDelete() {
    $.post(
        CONTEXT_PATH + "/discuss/delete",
        {"id":$("#postId").val()},
        function(data) {
            data = $.parseJSON(data);
            if(data.code == 0) {
                location.href = CONTEXT_PATH + "/index";
            } else {
                alert(data.msg);
            }
        }
    );
}