# nowcoderCommunity **Repository Path**: Trace001/nowcoder-community ## Basic Information - **Project Name**: nowcoderCommunity - **Description**: 仿牛客网社区论坛 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 1 - **Created**: 2023-08-19 - **Last Updated**: 2023-08-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README 技术架构: - Spring Boot - Spring、Spring MVC、MyBatis。 - Redis、Kafka、Elasticsearch - Spring Security、Spring Actuator 开发工具: - 构建工具: Apache Maven - 集成开发工具: IntelliJ IDEA - 数据库:MySQL、Redis - 应用服务器:Apache Tomcat - 版本控制工具: Git 分页:th:href="@{${page.path}(current=${i})}" # 一、用户增删改查 # 二、社区首页 # 三、发送邮件 ![image-20220808201051573](README.assets/image-20220808201051573.png) ```java @Autowired private JavaMailSender mailSender; //发件人 @Value("${spring.mail.username}") private String from; /** * 发送邮件 * @param to 收件人 * @param subject 主题 * @param content 内容 */ public void sendMail(String to, String subject, String content) { try { //得到邮件主体对象 MimeMessage message = mailSender.createMimeMessage(); //构建邮件主体 MimeMessageHelper helper = new MimeMessageHelper(message); //设置发件人 helper.setFrom(from); //设置收件人 helper.setTo(to); //设置主题 helper.setSubject(subject); //设置内容 helper.setText(content, true); //发送邮件 mailSender.send(helper.getMimeMessage()); log.info("邮件发送成功..."); } catch (MessagingException e) { log.error("发送邮件失败:" + e.getMessage()); } } ``` ```java @Autowired private MailClient mailClient; //模板引擎 @Autowired private TemplateEngine templateEngine; public void testHtmlMail() { //获取Thymeleaf模版 Context context = new Context(); //构建参数 context.setVariable("username", "sunday"); //将模版内容转为字符串类型并将参数传入 String content = templateEngine.process("/mail/demo", context); System.out.println(content); mailClient.sendMail("1620389556@qq.com", "HTML", content); } ``` ```html 邮件示例

欢迎你, !

``` # 四、开发注册、登录 ## 1、注册 - 访问注册页面 - 点击顶部区域内的链接,打开注册页面。 - 提交注册数据 - 通过表单提交数据。 - 服务端验证账号是否已存在、邮箱是否已注册。 - 服务端发送激活邮件。 - 激活注册账号 - 点击邮件中的链接,访问服务端的激活服务。 ## 2、生成验证码 使用Kaptcha - 导入jar包 - 编写Kaptcha配置类-生成随机字符、生成图片 ## 3、登录 - 访问登录页面 - 点击顶部区域内的链接,打开登录页面。 - 登录 - 验证账号、密码、验证码。 - 成功时,生成登录凭证,发放给客户端。 - 失败时,跳转回登录页。 - 退出 - 将登录凭证修改为失效状态。 - 跳转至网站首页。 ## 4、登录优化(主要检验登录凭证时间、前端根据登录状态改变页面) - 拦截器示例 - 定义拦截器,实现Handlerlnterceptor - 配置拦截器,为它指定拦截、排除的路径。 - 拦截器应用 - 在请求开始时查询登录用户:根据存储的Cookie中的凭证号ticket查询对应的登录凭证信息->判断凭证状态、凭证时间是否还有效->有效则查询对应的用户信息,将用户信息存储到模板信息中,前端检验用户是否登录。 - 在本次请求中持有用户数据-在模板视图上显示用户数据 - 在请求结束时清理用户数据 ![image-20220810103955527](开发笔记.assets/image-20220810103955527.png) ## 5、账号设置 - 上传文件 - 请求:必须是POST请求 - 表单: enctype= “multipart/form-data" - Spring MVC : 通过MultipartFile处理上传文件 - 开发步骤 - 访问账号设置页面 - 上传头像 - 获取头像 - 修改密码 ## 6、检查登录状态 - 使用拦截器 - 在方法前标注自定义注解 - 拦截所有请求,只处理带有该注解的方法 - 自定义注解 - 常用的元注解: - @Target、@Retention、@Document、@Inherited - 如何读取注解: - Method.getDeclaredAnnotations () - Method.getAnnotation (Class annotationClass) ```java /** * 标记自定义注解:加入这个注解的方法需要登录才能使用 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface LoginRequired { } ``` # 五、开发社区核心功能 ## 1、过滤敏感词 - 前缀树 - 名称:Trie、字典树、查找树 - 特点:查找效率高,消耗内存大 - 应用:字符串检索、词频统计、字符串排序等。 image-20220811112311214 - 敏感词过滤器 - 定义前缀树 - 根据敏感词,初始化前缀树 - 编写过滤敏感词的方法 特殊符号跳过image-20220811142519940 ```java /** * 敏感词过滤 */ @Slf4j @Component public class SensitiveFilter { /** * 定义前缀树 */ private class TrieNode { // 关键词结束标识 private boolean isKeywordEnd = false; // 子节点(key是下级字符,value是下级节点) private Map 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); } } // 替换符 private static final String REPLACEMENT = "***"; // 根节点 private TrieNode rootNode = new TrieNode(); /** * 当前bean被容器初始化时调用:初始化前缀树 */ @PostConstruct public void init() { try ( //获取敏感词文件字节流 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) { log.error("加载敏感词文件失败: " + e.getMessage()); } } /** * 将一个敏感词添加到前缀树中 * @param keyword */ private void addKeyword(String keyword) { TrieNode tempNode = rootNode;//根节点 //遍历字符串字符 for (int i = 0; i < keyword.length(); i++) { char c = keyword.charAt(i); //获取子节点 TrieNode subNode = tempNode.getSubNode(c); //子节点为空:即当前没有子节点是存储着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 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 { // 检查下一个字符 if(position 0x9FFF); } } ``` ## 2、发布帖子 - AJAX Asynchronous JavaScript and XML - 异步的JavaScript与XML,不是一门新技术,只是一个新的术语。 - 使用AJAX,网页能够将增量更新呈现在页面上,而不需要刷新整个页面。 - 虽然X代表XML,但目前JSON的使用比XML更加普遍。 - https://developer.mozilla.org/zh-CN/docs/Web/Guide/AJAX。 - 示例 - 使用jQuery发送AJAX请求。 - 实践 - 采用AJAX请求,实现发布帖子的功能。 ```java public int addDiscussPost(DiscussPost post) { //传入的帖子为空 if (post == null) { throw new IllegalArgumentException("参数不能为空!"); } // 转义HTML标记:题目和内容出现一些标签将其转义 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); } ``` ```java public String addDiscussPost(String title, String content) { //获取当前用户数据 User user = hostHolder.getUser(); if (user == null) { //未登录 return CommunityUtil.getJSONString(403, "你还没有登录哦!"); } //封装帖子对象 DiscussPost post = new DiscussPost(); post.setUserId(user.getId()); post.setTitle(title); post.setContent(content); post.setCreateTime(new Date()); //存入数据库 discussPostService.addDiscussPost(post); // 报错的情况,将来统一处理. return CommunityUtil.getJSONString(Code.OK, "发布成功!"); } ``` ## 3、帖子详情 - DiscussPostMapper - DiscussPostService - DiscussPostControlle - index.html - 在帖子标题上增加访问详情页面的链接。 - discuss-detail.html - 处理静态资源的访问路径 - 复用index.html的header区域 - 显示标题、作者、发布时间、帖子正文等内容 ## 4、显示评论 - 数据层 - 根据实体查询一页评论数据。 - 根据实体查询评论的数量。 - 业务层 - 处理查询评论的业务。 - 处理查询评论数量的业务。 - 表现层 - 显示帖子详情数据时, - 同时显示该帖子所有的评论数据。 ```java public String getDiscussPost(@PathVariable("discussPostId") int discussPostId, Model model, Page page) { // 查询出帖子 DiscussPost post = discussPostService.findDiscussPostById(discussPostId); //将数据给到模板 model.addAttribute("post", post); // 查询出帖子的作者 User user = userService.findUserById(post.getUserId()); model.addAttribute("user", user); // 评论分页信息 page.setLimit(5); page.setPath("/discuss/detail/" + discussPostId); page.setRows(post.getCommentCount());//这个帖子下的评论总数 // 评论: 给帖子的评论 // 回复: 给评论的评论 // 评论列表:给帖子的评论 List commentList = commentService.findCommentsByEntity( Code.ENTITY_TYPE_POST, post.getId(), page.getOffset(), page.getLimit()); // 评论VO列表 List> commentVoList = new ArrayList<>(); if (commentList != null) { //将给帖子的评论存入一个map中 for (Comment comment : commentList) { // 评论VO Map commentVo = new HashMap<>(); // 评论内容 commentVo.put("comment", comment); // 评论帖子作者信息 commentVo.put("user", userService.findUserById(comment.getUserId())); // 回复列表:给评论的评论 List replyList = commentService.findCommentsByEntity( Code.ENTITY_TYPE_COMMENT, comment.getId(), 0, Integer.MAX_VALUE); // 回复VO列表 List> replyVoList = new ArrayList<>(); if (replyList != null) { for (Comment reply : replyList) { Map replyVo = new HashMap<>(); // 回复的内容 replyVo.put("reply", reply); // 回复评论的作者信息 replyVo.put("user", userService.findUserById(reply.getUserId())); // 回复目标用户信息 User target = reply.getTargetId() == 0 ? null : userService.findUserById(reply.getTargetId()); replyVo.put("target", target); replyVoList.add(replyVo); } } commentVo.put("replys", replyVoList); // 回复数量 int replyCount = commentService.findCommentCount(Code.ENTITY_TYPE_COMMENT, comment.getId()); commentVo.put("replyCount", replyCount); commentVoList.add(commentVo); } } model.addAttribute("comments", commentVoList); return "/site/discuss-detail"; } ``` ## 5、添加评论 - 数据层 - 增加评论数据。 - 修改帖子的评论数量。 - 业务层 - 处理添加评论的业务: 先增加评论、再更新帖子的评论数量。 ```java @Transactional @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 = commentMapper.insertComment(comment); // 如果是给帖子评论:更新帖子评论数量 if (comment.getEntityType() == Code.ENTITY_TYPE_POST) { //原数量 int count = commentMapper.selectCountByEntity(comment.getEntityType(), comment.getEntityId()); //更新 discussPostService.updateCommentCount(comment.getEntityId(), count); } return rows; } ``` - 表现层 - 处理添加评论数据的请求。 - 设置添加评论的表单。 ```java @PostMapping("/add/{discussPostId}") public String addComment(@PathVariable("discussPostId") int discussPostId, Comment comment) { if(StringUtils.isBlank(comment.getContent())) return "redirect:/discuss/detail/" + discussPostId; //补充评论信息其余信息 comment.setUserId(hostHolder.getUser().getId()); comment.setStatus(0); comment.setCreateTime(new Date()); commentService.addComment(comment); return "redirect:/discuss/detail/" + discussPostId; } ``` ## 6、私信列表 - 私信列表 - 查询当前用户的会话列表, 每个会话只显示一条最新的私信。 - 支持分页显示。 ```xml ``` ```java @GetMapping( "/letter/list") 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 conversationList = messageService.findConversations( user.getId(), page.getOffset(), page.getLimit()); //map集合封装:会话列表、会话中的未读消息数量、会话中消息数量 List> conversations = new ArrayList<>(); if (conversationList != null) { for (Message message : conversationList) { Map map = new HashMap<>(); //会话列表 map.put("conversation", message); //会话中的消息数量 map.put("letterCount", messageService.findLetterCount(message.getConversationId())); //会话中的未读消息数量 map.put("unreadCount", messageService.findLetterUnreadCount(user.getId(), message.getConversationId())); //获取接收方id:若当前用户id等于会话中的fromid,则接收方id为toid,否则接收方id为fromid //因为显示的会话列表为该会话的最新消息,最新消息可能是我发出去的也可能是别人发给我的 int targetId = user.getId() == message.getFromId() ? message.getToId() : message.getFromId(); //接收方数据信息 map.put("target", userService.findUserById(targetId)); conversations.add(map); } } model.addAttribute("conversations", conversations); // 查询未读消息数量:当前用户的所有未读消息数量 int letterUnreadCount = messageService.findLetterUnreadCount(user.getId(), null); model.addAttribute("letterUnreadCount", letterUnreadCount); return "/site/letter"; } ``` - 私信详情 - 查询某个会话所包含的私信。 - 支持分页显示。 ```xml ``` ```java @GetMapping("/letter/detail/{conversationId}") public String getLetterDetail(@PathVariable("conversationId") String conversationId, Page page, Model model) { // 分页信息 page.setLimit(5); page.setPath("/letter/detail/" + conversationId); page.setRows(messageService.findLetterCount(conversationId)); // 私信列表:会话的详细信息 List letterList = messageService.findLetters(conversationId, page.getOffset(), page.getLimit()); //信息封装、补充信息:发送人信息 List> letters = new ArrayList<>(); if (letterList != null) { for (Message message : letterList) { Map map = new HashMap<>(); map.put("letter", message); map.put("fromUser", userService.findUserById(message.getFromId())); letters.add(map); } } model.addAttribute("letters", letters); // 私信目标:与当前用户私信的人 model.addAttribute("target", getLetterTarget(conversationId)); // 设置已读 List ids = getLetterIds(letterList); if (!ids.isEmpty()) { messageService.updateMessageStatus(ids); } return "/site/letter-detail"; } ``` ## 7、发送私信 - 发送私信 - 采用异步的方式发送私信。 - 发送成功后刷新私信列表。 ```java @PostMapping("/letter/send") @ResponseBody public String sendLetter(String toName, String content) { //获取私信对象 User target = userService.findUserByName(toName); if (target == null) { return CommunityUtil.getJSONString(Code.ERR, "目标用户不存在!"); } //构建消息对象 Message message = new Message(); message.setFromId(hostHolder.getUser().getId());//发送方id message.setToId(target.getId());//接收方id //拼接会话id:发送方和接收方id小的在前 if (message.getFromId() < message.getToId()) { message.setConversationId(message.getFromId() + "_" + message.getToId()); } else { message.setConversationId(message.getToId() + "_" + message.getFromId()); } message.setContent(content);//内容 message.setCreateTime(new Date());//发送时间 //发送:数据存入数据库 messageService.addMessage(message); return CommunityUtil.getJSONString(Code.OK); } ``` - 设置已读 - 访问私信详情时, 将显示的私信设置为已读状态。 ```java // 设置已读 List ids = getLetterIds(letterList); if (!ids.isEmpty()) { messageService.updateMessageStatus(ids); } ``` ## 8、统一异常处理 - @ControllerAdvice - 用于修饰类,表示该类是Controller的全局配置类。 - 在此类中,可以对Controller进行如下三种全局配置: 异常处理方案、绑定数据方案、绑定参数方案。 - ExceptionHandler - 用于修饰方法,该方法会在Controller出现异常后被调用,用于处理捕获到的异常。 ```java @ExceptionHandler({Exception.class}) public void handleException(Exception e, HttpServletRequest request, HttpServletResponse response) throws IOException { log.error("服务器发生异常: " + e.getMessage()); for (StackTraceElement element : e.getStackTrace()) { log.error(element.toString()); } //判断请求是否为异步请求 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(Code.ERR, "服务器异常!")); } else { response.sendRedirect(request.getContextPath() + "/error"); } } ``` - @ModelAttribute - 用于修饰方法,该方法会在Controller方法执行前被调用,用于为Model对象绑定参数。 - @DataBinder - 用于修饰方法,该方法会在Controller方法执行前被调用,用于绑定参数的转换器。 ## 9、统一记录日志 Spring AOP:在运行时通过代理的方式织入代码,只支持方法类型的连接点。 - JDK动态代理 - Java提供的动态代理技术可以在运行时创建接口的代理实例。 - Spring AOP默认采用此种方式,在接口的代理实例中织入代码。 ```java public class ServiceLogAspect { @Pointcut("execution(* com.myself.community.service.*.*(..))") public void pointcut() {} @Before("pointcut()") public void before(JoinPoint joinPoint) { // 用户[1.2.3.4],在[xxx],访问了[com.myself.community.service.xxx()]. //获取request对象 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); //获取ip String ip = request.getRemoteHost(); //获取日期 String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); //反射获取了类名、方法名 String target = joinPoint.getSignature().getDeclaringTypeName() + "." + joinPoint.getSignature().getName(); //打印日志 log.info(String.format("用户[%s],在[%s],访问了[%s].", ip, now, target)); } } ``` - CGLib动态代理 - 采用底层的字节码技术,在运行时创建子类代理实例。 - 当目标对象不存在接口时,Spring AOP会采用此种方式,在子类实例中织入代码。 # 六、Redis:高性能存储 ## 1、点赞 - 点赞: 对应键值:KEY:`like:entity:entityType:entityId` value:`userId` 数据类型:`集合set` - 支持对帖子、评论点赞。 - 第1次点赞,第2次取消点赞。 ```java //存储赞的key String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId); //判断这个key中是否已经存有用户id:即当前用户已对其点过赞 boolean isMember = operations.opsForSet().isMember(entityLikeKey, userId); if (isMember) { //点过赞了:则执行取消赞,即删除对应的key中存放的用户id operations.opsForSet().remove(entityLikeKey, userId); } else { //没有点赞:执行点赞 operations.opsForSet().add(entityLikeKey, userId); } ``` - 首页点赞数量 - 统计帖子的点赞数量。 ```java //赞的数量 long likeCount = likeService.findEntityLikeCount(Code.ENTITY_TYPE_POST, post.getId()); ``` - 详情页点赞数量 - 统计点赞数量。 ```java //帖子赞的数量 long likeCount = likeService.findEntityLikeCount(Code.ENTITY_TYPE_POST, discussPostId); model.addAttribute("likeCount", likeCount); ``` - 显示点赞状态。 ```java // 当前用户对这个帖子点赞状态 int likeStatus = hostHolder.getUser() == null ? 0 : likeService.findEntityLikeStatus(hostHolder.getUser().getId(), Code.ENTITY_TYPE_POST, discussPostId); model.addAttribute("likeStatus", likeStatus); ``` ## 2、我收到的赞 - 重构点赞功能 - 以用户为key,记录点赞数量 ```java // 某个用户的赞 // like:user:userId -> int public static String getUserLikeKey(int userId) { return PREFIX_USER_LIKE + SPLIT + userId; } ``` - **increment(key), decrement(key)** 点赞或取消赞时,同时更改被点赞对象的点赞数量,需要使用Redis事务 ```java redisTemplate.execute(new SessionCallback() { @Override public Object execute(RedisOperations operations) throws DataAccessException { //存储赞的key String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId); //被点赞实体的作者的总共赞数量的key:like:user:userId String userLikeKey = RedisKeyUtil.getUserLikeKey(entityUserId); //判断存储赞的key中是否已经存有用户id:即当前用户已对其点过赞 boolean isMember = operations.opsForSet().isMember(entityLikeKey, userId); //开启Redis事务 operations.multi(); if (isMember) { //点过赞了:则执行取消赞,即删除对应的key中存放的用户id operations.opsForSet().remove(entityLikeKey, userId); //实体作者的点赞总数自增1 operations.opsForValue().decrement(userLikeKey); } else { //没有点赞:执行点赞 operations.opsForSet().add(entityLikeKey, userId); //实体作者的点赞总数减1 operations.opsForValue().increment(userLikeKey); } //提交事务 return operations.exec(); } }); ``` - 开发个人主页 - 以用户为key,查询点赞数量 ![image-20220814104343675](开发笔记.assets/image-20220814104343675.png) ```java //获取需要查看主页的用户对象 User user = userService.findUserById(userId); if (user == null) { throw new RuntimeException("该用户不存在!"); } // 点赞数量 int likeCount = likeService.findUserLikeCount(userId); ``` ## 3、关注、取消关注 - 需求 - 开发关注、取消关注功能。 - 统计用户的关注数、粉丝数。 ```java //查询关注的实体的数量 public long findFolloweeCount(int userId, int entityType) { String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType); return redisTemplate.opsForZSet().zCard(followeeKey); } //查询实体的粉丝的数量 public long findFollowerCount(int entityType, int entityId) { String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId); return redisTemplate.opsForZSet().zCard(followerKey); } ``` - 关键 - 若A关注了B,则A是B的Follower (粉丝) ```java // 某个实体拥有的粉丝:key:follower:实体类型:实体对象id value:(粉丝id,粉丝关注的开始时间) // follower:entityType:entityId -> zset(userId,now) public static String getFollowerKey(int entityType, int entityId) { return PREFIX_FOLLOWER + SPLIT + entityType + SPLIT + entityId; } ``` B是A的Followee (目标)。 ```java // 某个用户关注的实体。key:followee:用户:关注的实体 value:(关注的对象实体id,关注的开始时间)当前时间作为排序 // followee:userId:entityType -> zset(entityId,now) public static String getFolloweeKey(int userId, int entityType) { return PREFIX_FOLLOWEE + SPLIT + userId + SPLIT + entityType; } ``` - 关注的目标可以是用户、帖子、题目等,在实现时将这些目标抽象为实体。 ```java //关注 public void follow(int userId, int entityType, int entityId) { redisTemplate.execute(new SessionCallback() { @Override public Object execute(RedisOperations operations) throws DataAccessException { //某个用户关注的实体key:followee:userId:entityType -> zset(entityId,now) String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType); //某个实体拥有的粉丝:follower:entityType:entityId -> zset(userId,now) String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId); //开启事务 operations.multi(); //存关注数据 operations.opsForZSet().add(followeeKey, entityId, System.currentTimeMillis()); //存粉丝数据 operations.opsForZSet().add(followerKey, userId, System.currentTimeMillis()); //提交事务 return operations.exec(); } }); } ``` ## 4、关注列表、粉丝列表 - 业务层 - 查询某个用户关注的人,支持分页。 ```java //关注的实体key:followee:userId:entityType,确定对象是人 String followeeKey = RedisKeyUtil.getFolloweeKey(userId, Code.ENTITY_TYPE_USER); //根据时间倒叙查询对象id:即关注的所有用户的id Set targetIds = redisTemplate.opsForZSet().reverseRange(followeeKey, offset, offset + limit - 1); if (targetIds == null) { return null; } List> list = new ArrayList<>(); for (Integer targetId : targetIds) { Map map = new HashMap<>(); User user = userService.findUserById(targetId); map.put("user", user); //关注时间 Double score = redisTemplate.opsForZSet().score(followeeKey, targetId); map.put("followTime", new Date(score.longValue())); list.add(map); } return list; ``` - 查询某个用户的粉丝,支持分页。 ```java String followerKey = RedisKeyUtil.getFollowerKey(Code.ENTITY_TYPE_USER, userId); Set targetIds = redisTemplate.opsForZSet().reverseRange(followerKey, offset, offset + limit - 1); if (targetIds == null) { return null; } List> list = new ArrayList<>(); for (Integer targetId : targetIds) { Map map = new HashMap<>(); User user = userService.findUserById(targetId); map.put("user", user); Double score = redisTemplate.opsForZSet().score(followerKey, targetId); map.put("followTime", new Date(score.longValue())); list.add(map); } return list; ``` - 表现层 - 处理“查询关注的人”、“查询粉丝”请求。 ```java @GetMapping("/followees/{userId}") public String getFollowees(@PathVariable("userId") int userId, Page page, Model model) { //获取用户数据 User user = userService.findUserById(userId); if (user == null) { throw new RuntimeException("该用户不存在!"); } model.addAttribute("user", user); page.setLimit(5); page.setPath("/followees/" + userId); page.setRows((int) followService.findFolloweeCount(userId, Code.ENTITY_TYPE_USER)); List> userList = followService.findFollowees(userId, page.getOffset(), page.getLimit()); //获取当前登录用户对这个关注列表页面用户的关注状态 if (userList != null) { for (Map map : userList) { User u = (User) map.get("user"); map.put("hasFollowed", hasFollowed(u.getId())); } } model.addAttribute("users", userList); return "/site/followee"; } ``` - 编写“查询关注的人”、“查询粉丝”模板。 ```java @GetMapping("/followers/{userId}") public String getFollowers(@PathVariable("userId") int userId, Page page, Model model) { User user = userService.findUserById(userId); if (user == null) { throw new RuntimeException("该用户不存在!"); } model.addAttribute("user", user); page.setLimit(5); page.setPath("/followers/" + userId); page.setRows((int) followService.findFollowerCount(Code.ENTITY_TYPE_USER, userId)); List> userList = followService.findFollowers(userId, page.getOffset(), page.getLimit()); if (userList != null) { for (Map map : userList) { User u = (User) map.get("user"); map.put("hasFollowed", hasFollowed(u.getId())); } } model.addAttribute("users", userList); return "/site/follower"; } ``` ## 5、优化登录模块 - 使用Redis存储验证码 - 验证码需要频繁的访问与刷新,对性能要求较高。 - 验证码不需永久保存,通常在很短的时间后就会失效。 - 分布式部署时,存在Session共享的问题。 ```java //将验证码存入redis //1.验证码归属 String kaptchaOwner= CommunityUtil.generateUUID(); Cookie cookie=new Cookie("kaptchaOwner",kaptchaOwner); cookie.setMaxAge(60); cookie.setPath(contextPath); response.addCookie(cookie); //2.存验证码 String key= RedisKeyUtil.getKaptchaKey(kaptchaOwner); redisTemplate.opsForValue().set(key,text,66, TimeUnit.SECONDS); ``` ```java //从Redis中获取验证码 String kaptcha=null; if(StringUtils.isBlank(kaptchaOwner)){ String key=RedisKeyUtil.getKaptchaKey(kaptchaOwner); kaptcha=(String) redisTemplate.opsForValue().get(key); } else{ model.addAttribute("codeMsg", "验证码不正确!请检查浏览器Cookie是否打开!"); return "/site/login"; } ``` - 使用Redis存储登录凭证 - 处理每次请求时,都要查询用户的登录凭证,访问的频率非常高。 ```java /*在登录的服务层方法中*/ //登录凭证信息存入Redis String key= RedisKeyUtil.getTicketKey(loginTicket.getTicket()); redisTemplate.opsForValue().set(key,loginTicket,expiredSeconds, TimeUnit.SECONDS); ``` ```java /*退出登录*/ //将Redis登录凭证状态改为1:无效 String key= RedisKeyUtil.getTicketKey(ticket); LoginTicket loginTicket = (LoginTicket) redisTemplate.opsForValue().get(key); loginTicket.setStatus(1); redisTemplate.opsForValue().set(key,loginTicket); ``` ```java //从Redis中取当前用户登录凭证信息 String key= RedisKeyUtil.getTicketKey(ticket); return (LoginTicket) redisTemplate.opsForValue().get(key); ``` - 使用Redis缓存用户信息 - 处理每次请求时,都要根据凭证查询用户信息,访问的频率非常高。 ```java /** * 1.优先从缓存中取值 * @param userId * @return */ private User getCache(int userId) { String redisKey = RedisKeyUtil.getUserKey(userId); return (User) redisTemplate.opsForValue().get(redisKey); } /** * 2.取不到时初始化缓存数据 * @param userId * @return */ private User initCache(int userId) { //从数据库中获取用户数据保存到Redis User user = userMapper.selectById(userId); String redisKey = RedisKeyUtil.getUserKey(userId); redisTemplate.opsForValue().set(redisKey, user, 3600, TimeUnit.SECONDS); return user; } /** * 3.用户数据变更时清除缓存数据 * @param userId */ private void clearCache(int userId) { String redisKey = RedisKeyUtil.getUserKey(userId); redisTemplate.delete(redisKey); } ```