# community **Repository Path**: Xiao_Yang123/community ## Basic Information - **Project Name**: community - **Description**: Spring Boot论坛项目 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 2 - **Created**: 2020-11-05 - **Last Updated**: 2020-12-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README ## Spring Boot论坛项目(未完结) **项目整体目录结构** image-20201102184128114 **annotation**:注解包 **aspect**:日志记录 **config**:配置类 **controller**:处理器 **dao**:数据访问层 **datasource**:数据源配置 **entity**:实体类 **event**:触发事件(点赞、评论、关注) **service**:业务层 **util**:工具包 ### **技术栈** | 框架 | 版本 | | --------------- | ----- | | Spring Boot | 2.x | | Mybatis-Plus | 3.x | | Thymeleaf | 2.x | | redis | 5.05 | | kafka | xx | | Spring Security | xx | | elasticsearch | 7.x | | MySQL | 5.7 | | Git | laste | ### **数据表** ​ **comment表** | 字段 | 解释 | | ----------- | ------------------------------------------------------------ | | id | 评论ID | | user_id | 评论的用户ID | | entity_type | 评论的类型(对帖子评论还是对评论进行评论) | | entity_id | 如果评论是对帖子评论就是帖子Id,如果是对评论进行评论就是评论的Id | | target_id | 如果是对评论进行回复就是所回复的用户Id,没有就是0 | | content | 评论文本 | | status | 评论状态 | | create_time | 评论创建时间 | ​ **discuss_post表** | 字段 | 解释 | | ------------- | --------------- | | id | 帖子Id | | user_id | 帖子的用户Id | | title | 帖子标题 | | content | 帖子文本 | | type | 0:普通 1:置顶 | | status | 评论文本 | | create_time | 创建时间 | | comment_count | 评论数 | | score | 精度 | ​ **user表** | 字段 | 解释 | | :-------------- | :--------------------------------- | | id | 用户id | | username | 用户名 | | password | 密码 | | salt | 加盐 | | email | 邮箱 | | type | 用户类型(版主、管理员、普通用户) | | status | 用户激活状态 | | activation_code | 激活码 | | header_url | 头像url | | create_time | 用户创建时间 | **message表** | 字段 | 解释 | | --------------- | -------------------------------------- | | id | 消息id | | from_id | 消息来自方 | | to_id | 消息去向(用户id) | | conversation_id | 会话类型(点赞、关注、评论由系统通知) | | content | 文本 | | status | 消息状态(未读、已读、删除) | | create_time | 消息创建时间 | ​ **login_ticket表(已废弃)** | 字段 | 解释 | | ------- | ---------------- | | id | 登录凭证id | | user_id | 用户id | | ticket | 登录凭证(UUID) | | status | 登录状态 | | expired | 过期时间 | ### 1. 首页 展示效果图: image-20201102213132314 首页分页显示帖子列表,显示帖子信息时,需要附带用户信息(用户名、用户图片Url) 帖子表包含用户id字段,所以查询时需要根据userId查询到用户信息 `IndexController`代码如下: ```java @GetMapping("/index") public String getDiscussPost(Model model, Page page){ // 设置分页属性 page.setSize(6); page.setDesc("create_time"); List discussPosts = discussPostService.getDiscussPost(page); // 将文章信息 以及 用户信息 存到Map中,然后循环遍历discussPosts中的文章信息 List> lists = new ArrayList<>(); if (discussPosts != null){ for (DiscussPost discussPost : discussPosts){ Map map = new HashMap<>(); map.put("post",discussPost); User user = userService.selectUserById(Integer.valueOf(discussPost.getUserId())); map.put("user", user); //点赞数 long likeCount = likeService.findEntityLikeCount(1, discussPost.getId()); map.put("likeCount", likeCount); lists.add(map); } } // 封装page对象导PageDTO中,后面可以优化 PageDTO pageDTO = new PageDTO(page); pageDTO = pageDTO.pageDTO(); model.addAttribute("pagedto", pageDTO); log.info("封装Page:" + pageDTO); model.addAttribute("page", page); model.addAttribute("discussPosts", lists); return "index"; } ``` 点赞数时后面加上的 其中`PageDTO`分页信息封装了如下信息 ```java private Page page; private Long from; // 页码显示的头 private Long to; // 页码显示的尾 private String path; //路径 private long offset; //每页起始索引 ``` 然后每个包含有分页功能的都可以重用 ### 2. 注册、登录功能 #### **注册** 注册页面展示: image-20201103162021258 以Post方式提交注册表单,控制器接收User对象,并验证表单中的数据是否符合要求 验证成功后开始向数据库中添加用户对象,并向用户邮箱发送`激活邮件`,在邮箱中点击`激活`超链接方可将用户的激活状态`Status`修改为`1`, 默认为`0`. 注册部分业务代码如下: ```java //注册用户 user.setUsername(user.getUsername()); user.setSalt(CommonUtils.generateUUID().substring(0, 5)); user.setPassword(CommonUtils.MD5(user.getPassword() + user.getSalt())); user.setEmail(user.getEmail()); user.setStatus(0); user.setType(0); user.setCreateTime(new Date().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()); user.setHeaderUrl(String.format("http://images.nowcoder.com/head/%dt.png", new Random().nextInt(1000))); user.setActivationCode(CommonUtils.generateUUID()); userDao.insert(user); //激活邮件 Context context = new Context(); context.setVariable("email", user.getEmail()); String uri = domain + contextPath + "/activecode/" + user.getId() + "/" + user.getActivationCode(); context.setVariable("uri", uri); //解析html文本为字符串 发送邮件 String content = templateEngine.process("/mail/activation", context); mailClient.sendMail(user.getEmail(), "激活账号", content); ``` 这里以Html的格式发送邮件,并将邮箱和uri存到模板中 激活超链接格式为:`localhost:8080/community/activecode/{userId}/{activationCode}` 激活业务逻辑如下: ```java //激活 @Override public int activation(int userId, String code){ User user = userDao.selectUserById(userId); if (user.getStatus() == 1){ return ACTIVATION_REPEAT; }else if (user.getActivationCode().equals(code)){ userDao.updateStatus(userId, 1); return ACTIVATION_SUCCESS; }else{ return ACTIVATION_FAILURE; } } ``` 先通过`userId`在用户表中查到相应的用户信息,然后判断其status状态,再对比激活码和`url激活码`是否一致 #### **登录** 登录页面展示: image-20201103161956238 注册界面包含 `账号`、`密码`、`验证码` 以及 记住我 验证码生成处理器 ```java @GetMapping("/kaptcha") public void getKaptcha(HttpServletResponse response) throws IOException { //生成验证码 String text = producer.createText(); BufferedImage image = producer.createImage(text); //验证码归属 String kaptchaOwner = CommonUtils.generateUUID(); Cookie cookie = new Cookie("kaptchaOwner", kaptchaOwner); cookie.setMaxAge(60); //60秒后cookie到期 cookie.setPath(contextPath); response.addCookie(cookie); //验证码存入redis String kaptchaKey = RedisKeyUtil.getKaptchaKey(kaptchaOwner); redisTemplate.opsForValue().set(kaptchaKey, text, 60, TimeUnit.SECONDS); // redis存放cookie60秒 // 将图片输出给浏览器 response.setContentType("image/png"); try { ServletOutputStream os = response.getOutputStream(); ImageIO.write(image, "png", os); } catch (IOException e) { log.error("响应图片验证码失败:" + e.getMessage()); } } ``` 可见,验证码存入到cookie中,并存入到reids中,过期时间都是60秒,所以60秒后必须刷新验证码,并再次存入到redis中,才能进行输入验证。 cookie的key为`kaptchaOwner`,value为UUID随机字符串。redis中的key为`kaptcha:` + UUID,value就是正确的字符。 登录处理器代码: ```java @PostMapping("/login") public String login(String code, String username, String password, boolean rememberme, Model model,/* HttpSession session,*/ HttpServletResponse response, @CookieValue("kaptchaOwner")String kaptchaOwner){ // 检查验证码 // String kaptcha = (String) session.getAttribute("kaptcha"); // 验证图片验证码 String kaptcha = null; if (StringUtils.isNotBlank(kaptchaOwner)){ String kaptchaKey = RedisKeyUtil.getKaptchaKey(kaptchaOwner); kaptcha = (String) redisTemplate.opsForValue().get(kaptchaKey); } if (StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)){ model.addAttribute("codeMsg", "验证码不正确"); return "/site/login"; } // 检查用户名密码 int expiredSeconds = rememberme ? REMEMBER_ME_LOGIN_TICKET : DEFAULT_LOGIN_TICKET; Map map = userService.login(username, password, expiredSeconds); if (map.containsKey("ticket")){ Cookie cookie = new Cookie("ticket", map.get("ticket").toString()); cookie.setMaxAge(expiredSeconds); cookie.setPath(contextPath); response.addCookie(cookie); return "redirect:/index"; }else{ model.addAttribute("usernameMsg", map.get("usernameMsg")); model.addAttribute("passwordMsg", map.get("passwordMsg")); return "/site/login"; } } ``` 由代码注释可知道,原本的验证码字符信息是存放在session中,但这样会平白给服务器添加压力,后期将字符信息存到redis中,给服务器减少压力。 这里从cookie中先获取到该cookie中key为`kaptchaOwner`的值,然后将输入的值与redis的value做比较,一样则表示验证码正确 根据用户名和密码查询到用户信息,表明登录成功,并将该用户信息的ID保存到`LoginTicket`对象中,`LoginTicket`对象包含,过期时间,随机生成的`ticket`,登录状态(初始为0),用户Id,其中返回给客户端`LoginTicket`对象的只有`ticket`,保存到cookie中,cookie的过期时间取决于是否点`记住我`该复选按钮。 `并且该ticket会存储到redis服务器中,该ticket会永久保存,存储的key为tockit:UUID,value为Login_Tockit对象,只是在退出登录的会修改该对象的status值。 我们看看`UserService`的登录实现: ```java //生成验证凭证 LoginTicket loginTicket = new LoginTicket(); loginTicket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds * 1000L) .toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()); loginTicket.setTicket(CommonUtils.generateUUID()); loginTicket.setStatus(0); loginTicket.setUserId(user.getId()); // loginTicketDao.insertLoginTicket(loginTicket); // 将loginTicket对象保存到redis中 String ticketKey = RedisKeyUtil.getTicketKey(loginTicket.getTicket()); redisTemplate.opsForValue().set(ticketKey, loginTicket); map.put("ticket", loginTicket.getTicket()); return map; ``` 这里将保存到reids的`ticket`传给处理器,然后交由处理器通过cookie返回给前台。 #### **登录拦截器** 代码实现: ```java @Autowired private UserService userService; @Autowired private HostHolder hostHolder; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { log.info("preHandle方法"); String ticket = CookieUtil.getValue(request, "ticket"); if (ticket != null){ // 查询凭证 LoginTicket loginTicket = userService.findLoginTicket(ticket); if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().isAfter(new Date() .toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime())){ User user = userService.selectUserById(loginTicket.getUserId()); hostHolder.setUser(user); } } return true; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { log.info("postHandle方法"); User user = hostHolder.getUser(); if (user != null && modelAndView != null){ modelAndView.addObject("loginUser", user); } } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { hostHolder.clearUser(); } ``` 这里用到了`HostHolder`这个工具类,该类封装了`ThreadLocal`,保证线程的安全。 这里在每次请求访问到处理器之前,都先从请求中获取到cookie为`ticket`的值,然后从redis中查询该登录信息是否存在。如果查询出来的登录凭证对象存在,并且是登录状态,而且cookie没有过期,那么就通过该对象的`userId`属性查询用户的信息并保存到`ThreadLocal`中,以便获取。 然后每次处理请求返回客户端前,都将用户的信息存到`model`中。 再每次请求完成后,都清除该用户对象。 **账号设置** - 上传文件 - 请求:必须是POST请求 - 表单: enctype="multipart/form-data" - Spring MVC:通过MultipartFile处理文件上传 - 开发步骤 - 访问账号设置页面 - 上传头像 - 获取头像 上传图片处理器代码如下: ```java /** * 上传图片 */ @LoginRequired @PostMapping("/upload") public String uploadImg(MultipartFile headerImg, Model model){ if (headerImg == null){ model.addAttribute("error", "还未选择图片"); return "/site/setting"; } // 上传的文件名 String filename = headerImg.getOriginalFilename(); // 后缀名 String suffix = filename.substring(filename.lastIndexOf(".")); if(StringUtils.isBlank(suffix)){ model.addAttribute("error", "文件格式不正确"); return "/site/setting"; } //生成随机文件名 filename = CommonUtils.generateUUID() + suffix; // 文件存放路径 File dist = new File(uploadPath + "/" + filename); //存储文件 try { headerImg.transferTo(dist); } catch (IOException e) { log.error("文件上传失败:" + e.getMessage()); throw new RuntimeException("文件上传失败,服务器异常!" + e); } // 更新用户当前头像,先从holder中获取用户信息 User user = hostHolder.getUser(); String headerUrl = domain + contextPath + "/user/header/" +filename; userService.updateHeader(user.getId(),headerUrl); return "redirect:/index"; } ``` 因为在修改用户信息的时候,用户数据表中的信息与redis中存的`User`对象不一致 由于我们经常要通过用户id查询用户信息,所以查过的用户id将其User对象保存在redis中(key为user:111格式),设置时间为一个小时,但在这时候,如果修改了某用户信息,就需要将存在redis中的该用户信息删除。 这里我们看看redis处理用户信息的几个方法把 ```java // 1.优先从缓存中取值 private User getCache(int userId) { String redisKey = RedisKeyUtil.getUserKey(userId); return (User) redisTemplate.opsForValue().get(redisKey); } // 2.取不到时初始化缓存数据 private User initCache(int userId) { User user = userDao.selectById(userId); String redisKey = RedisKeyUtil.getUserKey(userId); redisTemplate.opsForValue().set(redisKey, user, 3600, TimeUnit.SECONDS); return user; } // 3.数据变更时清除缓存数据 private void clearCache(int userId) { String redisKey = RedisKeyUtil.getUserKey(userId); redisTemplate.delete(redisKey); } ``` **在第一次使用selectUserById方法时,redis中找不到用户对象,数据库中找到存到redist中,而后一个小时若没有修改该用户信息都可以从缓存中读取。** 所以在更新头像信息时需要清除redis缓存中的user对象。 获取头像代码: ```java /** * 相应图片 * @param fileName * @param response */ @GetMapping("/header/{fileName}") public void getHeader(@PathVariable("fileName") String fileName, HttpServletResponse response){ // 服务器存放路径 fileName = uploadPath + "/" + fileName; // 文件后缀 String suffix = fileName.substring(fileName.lastIndexOf(".")); //相应图片 response.setContentType("image/" + suffix); try { InputStream is = new FileInputStream(fileName); OutputStream os = response.getOutputStream(); byte[] bytes = new byte[1024]; int b; while ((b = is.read(bytes)) != -1){ os.write(bytes, 0, b); } } catch (IOException e) { log.error("读取头像失败: " + e.getMessage()); } } ``` ### 3. 帖子的发布、详情 #### **敏感词过滤** 数据结构如下,三个指针,敏感词存在左侧的树中。 image-20201103195244525 #### **帖子发布** 添加帖子处理代码如下: ```java @PostMapping("/add") public @ResponseBody String addDiscussPost(String title, String content){ User user = hostHolder.getUser(); if(user == null){ return CommonUtils.getJSON(403, "你还没有登录哦!"); } DiscussPost post = new DiscussPost(); post.setUserId(user.getId().toString()); post.setTitle(title); post.setContent(content); post.setStatus(0); post.setType(0); post.setCreateTime(new Date().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()); discussPostService.addDiscussPost(post); // 报错的情况,将来统一处理. return CommonUtils.getJSON(0, "发布成功!"); } ``` 前台通过异步请求访问后台处理器,发布成功返回简单的json字符串(例如:{"code":0,"msg","发布成功"})。 #### **帖子详情** 帖子详情处理器代码如下: ```java @GetMapping("/detail/{discussPostId}") public String getDiscussion(@PathVariable("discussPostId") Integer discussPostId, Model model, Page page){ // 帖子 DiscussPost post = discussPostService.findDiscussPostById(discussPostId); model.addAttribute("post", post); // 作者 User user = userService.selectUserById(Integer.valueOf(post.getUserId())); model.addAttribute("user", user); //点赞数量 long likeCount = likeService.findEntityLikeCount(CommunityConstant.ENTITY_TYPE_POST, post.getId()); model.addAttribute("likeCount", likeCount); // 点赞状态 int likeStatus = hostHolder.getUser() == null ? 0 : likeService.findEntityLikeStatus(hostHolder.getUser().getId(), ENTITY_TYPE_POST, post.getId()); model.addAttribute("likeStatus", likeStatus); //评论分页信息 page.setSize(5); page.setDesc("create_time"); // 评论: 给帖子的评论 // 回复: 给评论的评论 // 评论列表 List commentsList = commentService.findCommentsByEntity(page, ENTITY_TYPE_POST, post.getId()); log.info("page:" + page.getTotal() + " - " + page.getPages()); // 封装page对象导PageDTO中,后面可以优化 PageDTO pageDTO = new PageDTO(page); pageDTO = pageDTO.pageDTO(); model.addAttribute("pagedto", pageDTO); log.info("封装Page:" + pageDTO); model.addAttribute("page", page); //重置page,回复无需分页 page = new Page(); page.setSize(Integer.MAX_VALUE); // 评论VO列表 List> commentVoList = new ArrayList<>(); if (commentsList != null){ for (Comment comment : commentsList){ //评论VO Map commentVo = new HashMap<>(); //评论 commentVo.put("comment", comment); //评论用户 commentVo.put("user", userService.selectUserById(comment.getUserId())); // 点赞数量 likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_COMMENT, comment.getId()); commentVo.put("likeCount", likeCount); // 点赞状态 likeStatus = hostHolder.getUser() == null ? 0 : likeService.findEntityLikeStatus(hostHolder.getUser().getId(), ENTITY_TYPE_COMMENT, comment.getId()); commentVo.put("likeStatus", likeStatus); // 回复列表 List replyList = commentService.findCommentsByEntity(page, ENTITY_TYPE_COMMENT, comment.getId()); // 回复VO列表 List> replyVoList = new ArrayList<>(); if (replyList != null) { for (Comment reply : replyList){ Map replyVo = new HashMap<>(); // 回复 replyVo.put("reply", reply); // 作者 replyVo.put("user", userService.selectUserById(reply.getUserId())); // 点赞数量 likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_COMMENT, reply.getId()); replyVo.put("likeCount", likeCount); // 点赞状态 likeStatus = hostHolder.getUser() == null ? 0 : likeService.findEntityLikeStatus(hostHolder.getUser().getId(), ENTITY_TYPE_COMMENT, reply.getId()); replyVo.put("likeStatus", likeStatus); // 回复目标 User target = reply.getTargetId() == 0 ? null : userService.selectUserById(reply.getTargetId()); replyVo.put("target", target); replyVoList.add(replyVo); } } commentVo.put("replys", replyVoList); // 回复数量 commentVo.put("replyCount", page.getTotal()); commentVoList.add(commentVo); } } model.addAttribute("comments", commentVoList); return "/site/discuss-detail"; } ``` 评论和点赞功能的部分代码可以先跳过,评论和点赞、关注都用redis进行数据存储 由评论数据表comment可知,在帖子详情页面我们需要获取帖子的详细信息和贴子下方评论的评论模块 先根据帖子ID查询到评论列表,然后根据查询到的评论Id获取回复列表。 我们对传给前端的model进行整理: ```json model{ "comments":[{ "comment": comment, "user": userService.selectUserById(comment.getUserId()), "likeCount": likeCount, "likeStatus": likeStatus, // 这是对commnet遍历后得到的回复 "replys":[{ "reply": reply, "user": userService.selectUserById(reply.getUserId()), "likeCount": likeCount, "likeStatus": likeStatus, "target": target },{}...] "replyCount": page.getTotal() }, {},...] } ``` ### 4. 评论功能 #### 评论添加 评论提交的是POST表单请求,处理器会接收到一个`Comment`对象,其中包括如下: ```html ``` 如果是对帖子或者对评论进行评论,`targetId`就是0,对帖子评论`entityType`就是1,评论是2,对评论的评论进行回复那target就是目标用户ID,`entityId`是实体的ID,若评论的是帖子就是帖子的Id,若是评论就是评论的Id。 代码如下: ```java @PostMapping("/add/{discussPostId}") public String addComment(@PathVariable("discussPostId")Integer id, Comment comment){ comment.setContent(comment.getContent()); comment.setCreateTime(new Date().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime()); comment.setUserId(hostHolder.getUser().getId()); comment.setStatus(0); commentService.addComment(comment); // 添加评论后 触发系统通知 Event event = new Event() .setTopic(TOPIC_COMMENT) .setEntityType(comment.getEntityType()) .setEntityId(comment.getEntityId()) .setUserId(hostHolder.getUser().getId()) .setData("postId", id); if (comment.getEntityType() == ENTITY_TYPE_POST) { DiscussPost target = discussPostService.findDiscussPostById(comment.getEntityId()); event.setEntityUserId(Integer.valueOf(target.getUserId())); } else if (comment.getEntityType() == ENTITY_TYPE_COMMENT) { Comment target = commentService.findCommentById(comment.getEntityId()); event.setEntityUserId(target.getUserId()); } eventProducer.fireEvent(event); return "redirect:/discuss-post/detail/" + id; } ``` 因为我们在添加评论的同时还要更新帖子评论的数量,所以这里用到了事务机制,代码如下: ```java @Transactional(isolation= Isolation.READ_COMMITTED, propagation= Propagation.REQUIRED) @Override 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 = commentDao.insert(comment); // 更新帖子评论数量 if (comment.getEntityType() == ENTITY_TYPE_POST){ Page page = new Page<>(); commentDao.selectCommentByEntity(page,ENTITY_TYPE_POST, comment.getEntityId()); discussPostDao.updateDiscussPostCommentCount(comment.getEntityId(), (int) page.getTotal()); } return rows; } ``` 先对评论的内容进行敏感词过滤,再添加评论,然后对评论的实体做判断,如果评论的是实体是帖子,那么就更新帖子的评论数量。 ### 5. 私信功能 ### 6. 点赞、关注功能 #### 点赞功能 - 点赞 - 支持对帖子、评论点赞 - 第一次点赞,第二次取消点赞 - 首页点赞数量 - 统计帖子的点赞数量 - 详情页点赞数量 - 统计点赞数量 - 显示点赞状态 此功能完全目前完全依赖redis实现,因此我们创建一个redis工具类统一管理我们的key,该类如下: ```java public class RedisKeyUtil { private static final String SPLIT = ":"; private static final String PREFIX_ENTITY_LIKE = "like:entity"; private static final String PREFIX_USER_LIKE = "like:user"; private static final String PREFIX_FOLLOWEE = "followee"; private static final String PREFIX_FOLLOWER = "follower"; private static final String PREFIX_KAPTCHA = "kaptcha"; private static final String PREFIX_TICKET = "ticket"; private static final String PREFIX_USER = "user"; // 某个实体的赞 // like:entity:entityType:entityId -> set(userId) public static String getEntityLikeKey(int entityType, int entityId){ return PREFIX_ENTITY_LIKE + SPLIT + entityType + SPLIT + entityId; } // 某个用户的赞 // like:user:userId -> int public static String getUserLikeKey(int userId){ return PREFIX_USER_LIKE + SPLIT + userId; } // 某个用户关注的实体 // followee:userId:entityType -> zset(entityId,now) public static String getFolloweeKey(int userId, int entityType){ return PREFIX_FOLLOWEE + SPLIT + userId + SPLIT + entityType; } // 某个实体拥有的粉丝 // follower:entityType:entityId -> zset(userId,now) public static String getFollowerKey(int entityType, int entityId){ return PREFIX_FOLLOWER + SPLIT + entityType + SPLIT + entityId; } // 获取生成验证码的key public static String getKaptchaKey(String owner){ return PREFIX_KAPTCHA + SPLIT + owner; } // 存储ticket登录状态 public static String getTicketKey(String ticket){ return PREFIX_TICKET + SPLIT + ticket; } // 存储用户信息 user:111 public static String getUserKey(int userId){ return PREFIX_USER + SPLIT + userId; } } ``` 其中`getEntityLikeKey`是对某实体点赞,key的格式为`like:entity:entityType:entityId`,并且为`Set`类型,entityType为实体类型,1为帖子,2为评论,然后entityId是实体Id(即帖子或评论的Id),都为可变参数,存储的值为当前用户Id,因为肯定是当前用户进行点赞的操作。 `getUserLikeKey`表示某个用户收到赞的个数,key的格式为`like:user:userId`,类型为string,userId为可变参数,即给某实体点赞都可以通过该实体获取到其userId,然后给该userId进行+1的操作,若取消赞就-1。 其它方法为关注以及其它登录操作的redisKey管理,这里只看like。 我们看看点赞业务类`LikeService`的实现把 ```java @Override public void like(int userId, int entityType, int entityId, int entityUserId) { redisTemplate.execute(new SessionCallback() { @Override public Object execute(RedisOperations redisOperations) throws DataAccessException { String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId); String userLikeKey = RedisKeyUtil.getUserLikeKey(entityUserId); boolean isMember = redisOperations.opsForSet().isMember(entityLikeKey, userId); redisOperations.multi(); if (isMember){ // 集合移除用户ID,并减少用户赞数量 redisOperations.opsForSet().remove(entityLikeKey, userId); redisOperations.opsForValue().decrement(userLikeKey); }else{ // 添加用户ID到集合中,并增加用户赞数量 redisOperations.opsForSet().add(entityLikeKey, userId); redisOperations.opsForValue().increment(userLikeKey); } return redisOperations .exec(); } }); } /** * 查询某实体点赞数量 */ @Override public long findEntityLikeCount(int entityType, int entityId) { String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId); return redisTemplate.opsForSet().size(entityLikeKey); } /** * 点赞状态 1:已赞 0:赞 */ @Override public int findEntityLikeStatus(int userId, int entityType, int entityId) { String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId); return redisTemplate.opsForSet().isMember(entityLikeKey, userId) ? 1 : 0; } /** * 查询某用户获得的赞 */ @Override public int findUserLikeCount(int userId) { String userLikeKey = RedisKeyUtil.getUserLikeKey(userId); Integer count = (Integer) redisTemplate.opsForValue().get(userLikeKey); return count == null ? 0 : count.intValue(); } ``` 当我们调用点赞方法时,会传过来`int userId, int entityType, int entityId, int entityUserId`这四个参数,分别表示当前用户Id, 目标实体类型(帖子或评论), 目标实体Id, 目标用户Id。 由于点赞的时候还要给用户获得赞的数量进行+1操作,这两个操作必须是原子操作,所以这里使用了redis的事务,使它们的操作能够同步。执行同步操作前,先会判断当前用户的Id是否已经存在该Set集合中,如果已存在,表示已赞,就会执行取消点赞的业务,即从`set`集合中删除当前用户Id,然后用户点赞的数量进行-1操作。 `findEntityLikeCount`该方法用来获取实体点赞的数量,其实就是获取帖子或评论点赞的数量,传入实体类型和实体Id,得到redis中实际存储的key,然后直接通过size方法获取集合的大小便是点赞数。 `findUserLikeCount`方法和上一个方法类似,这是传入实体用户id,可以使所有的用户,因为它是string类型,所以直接通过get方法获取里面的值即可。 看一下表现层的代码处理吧 ```java User user = hostHolder.getUser(); //点赞 likeService.like(user.getId(), entityType, entityId, entityUserId); //数量 long likeCount = likeService.findEntityLikeCount(entityType, entityId); // 状态 int likeStatus = likeService.findEntityLikeStatus(user.getId(), entityType, entityId); // 返回的结果 Map map = new HashMap<>(); map.put("likeCount", likeCount); map.put("likeStatus", likeStatus); ``` 点赞链接是异步请求,我们只需要返回给页面一段json对象即可,把map封装传给页面。 帖子详情页面有三个地方可以点赞,分别是帖子、评论、回复,都是通过异步请求进入该方法,先点赞,再次统计数量和状态返回给前台。 map中包含点赞状态和点赞数量。 #### 用户赞的数量 用户获得的赞在个人主页中显示,即前端传入用户Id,拼接得到redis的key,取到该key的value,就是获赞数量。 ```java // 点赞数量 int likeCount = likeService.findUserLikeCount(userId); model.addAttribute("likeCount", likeCount); ``` 获取实体赞数量大致有首页(帖子点赞数)、帖子详情页(帖子、评论点赞数)、个人主页(用户点赞数),不管哪些页面,在对应的表现层将点赞数量查到放到model里即可。 #### 关注功能 ### 7. 系统通知 ### **8. 权限控制** image-20201104103730126 将之前的登录拦截器废弃掉 即将`webConfig`中的拦截器注释掉,全权交给Spring sercurty控制 常量接口加上几个常量,设置用户权限 ```java /** * 权限:普通用户 */ String AUTHORITY_USER = "user"; /** * 权限:管理员 */ String AUTHORITY_ADMIN = "admin"; /** * 权限:版主 */ String AUTHORITY_MODERATOR = "moderator"; ```