仿牛客网项目(帖子项目)

项目介绍:使用技术springboot+thymeleaf+redis+kafka+elasticsearch+git

前期总结:

目录

工具类介绍

1.Page

2.发送邮件MailClient

配置参数

3.HostHolder

一、首页模块

前端代码(帖子列表)

前端代码(分页)

 二、注册模块

UserService

三、登录模块

LoginController(生成验证码部分)

UserService(退出登录)

四、显示登录信息

 五、账号设置(主要的就是上传头像)

 六、检查登录状态

LoginRequired(是否登录的意思)

LoginRequiredInterceptor

 七、发布帖子

index.js

八、帖子详情页面

DiscussPostController(帖子详情部分)


工具类介绍

1.Page

package com.nowcoder.community.entity;

/**
 * 封装分页相关的信息.
 */
public class Page {

    // 当前页码
    private int current = 1;
    // 显示上限
    private int limit = 10;
    // 数据总数(用于计算总页数)
    private int rows;
    // 查询路径(用于复用分页链接)
    private String path;

    public int getCurrent() {
        return current;
    }

    public void setCurrent(int current) {
        if (current >= 1) {
            this.current = current;
        }
    }

    public int getLimit() {
        return limit;
    }

    public void setLimit(int limit) {
        if (limit >= 1 && limit <= 100) {
            this.limit = limit;
        }
    }

    public int getRows() {
        return rows;
    }

    public void setRows(int rows) {
        if (rows >= 0) {
            this.rows = rows;
        }
    }

    public String getPath() {
        return path;
    }

    public void setPath(String path) {
        this.path = path;
    }

    /**
     * 获取当前页的起始行
     *
     * @return
     */
    public int getOffset() {
        // current * limit - limit
        return (current - 1) * limit;
    }

    /**
     * 获取总页数
     *
     * @return
     */
    public int getTotal() {
        // rows / limit [+1]
        if (rows % limit == 0) {
            return rows / limit;
        } else {
            return rows / limit + 1;
        }
    }

    /**
     * 获取起始页码
     *
     * @return
     */
    public int getFrom() {
        int from = current - 2;
        return from < 1 ? 1 : from;
    }

    /**
     * 获取结束页码
     *
     * @return
     */
    public int getTo() {
        int to = current + 2;
        int total = getTotal();
        return to > total ? total : to;
    }

}

总结:current代表当前页号,limit中文译为限制,也就是要限制显示几条数据,固定10条,path中定义的就是当前controller方法的映射地址,然后其他属性看注解,这个项目的分页要求显示五页也就是要求只显示五个页号仿牛客网项目(帖子项目)_第1张图片 实现是通过getFrom和getTo两个方法实现,其余方法作用可以看注解。

2.发送邮件MailClient

package com.nowcoder.community.util;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component;

import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;


@Component
public class MailClient {
    private static final Logger logger= LoggerFactory.getLogger(MailClient.class);
    @Autowired
    private JavaMailSender mailSender;
    @Value("${spring.mail.username}")
    private String from;

    /**
     *
     * @param to       接受者
     * @param subject 主题
     * @param content 内容
     */
    public void sendEmail(String to,String subject,String content) {
        try {
            MimeMessage message=mailSender.createMimeMessage();
            MimeMessageHelper helper=new MimeMessageHelper(message);
            helper.setFrom(from);
            helper.setSubject(subject);
            helper.setTo(to);
            helper.setText(content,true);//第二个参数是设置邮件内容支持html
            mailSender.send(helper.getMimeMessage());
        } catch (MessagingException e) {
            logger.error("发送邮件失败"+e.getMessage());
        }
    }

}

通过springboot发邮件主要就是对MimeMessage进行构造

配置参数

#视频写的是qq密码,这写的是授权码
spring.mail.password=xdxnosdevhtsecgf
spring.mail.protocol=smtp
mail.smtp.auth=true
spring.mail.properties.mail.smtp.ssl.enable=true

总结:依次是授权码,协议,身份验证(true代表身份验证,false代表不身份验证),是否用启用加密传送的协议验证项

3.HostHolder

package com.nowcoder.community.util;

import com.nowcoder.community.entity.User;
import org.springframework.stereotype.Component;

/**
 * 这个类用来代替session,每一个浏览器访问服务器都会创建一个线程,不能用公共类,使用threadlocal来实现线程隔离
 */
@Component
public class HostHolder {
    private ThreadLocal users=new ThreadLocal<>();

    public void setUser(User user) {
        users.set(user);
    }

    public User getUser(){
        return users.get();
    }

    public void clear(){
        users.remove();
    }
}

 起到一个与session类似的作用,主要是使用了ThreadLocal本地线程

一、首页模块

代码:HomeController、DiscussPostService、UserService、LikeService、CommunityConstant、Page

模块介绍:首次编写首页模块功能为,将帖子信息从数据库查出来并返回给客户端,随后有增加点赞功能,还有一个错误跳转页面,用到了分页。

package com.nowcoder.community.controller;

import com.nowcoder.community.entity.DiscussPost;
import com.nowcoder.community.entity.Page;
import com.nowcoder.community.entity.User;
import com.nowcoder.community.service.DiscussPostService;
import com.nowcoder.community.service.LikeService;
import com.nowcoder.community.service.UserService;
import com.nowcoder.community.util.CommunityConstant;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Controller
public class HomeController implements CommunityConstant {

    @Autowired
    private DiscussPostService discussPostService;

    @Autowired
    private UserService userService;

    @Autowired
    private LikeService likeService;

    @RequestMapping(path = "/index", method = RequestMethod.GET)
    public String getIndexPage(Model model, Page page) {
        // 方法调用钱,SpringMVC会自动实例化Model和Page,并将Page注入Model.
        // 所以,在thymeleaf中可以直接访问Page对象中的数据.
        page.setRows(discussPostService.findDiscussPostRows(0));
        page.setPath("/index");

        List list = discussPostService.findDiscussPosts(0, page.getOffset(), page.getLimit());
        List> discussPosts = new ArrayList<>();
        if (list != null) {
            for (DiscussPost post : list) {
                Map map = new HashMap<>();
                map.put("post", post);
                User user = userService.findUserById(post.getUserId());
                map.put("user", user);

                long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, post.getId());
                map.put("likeCount", likeCount);

                discussPosts.add(map);
            }
        }
        model.addAttribute("discussPosts", discussPosts);
        return "/index";
    }

    @RequestMapping(path = "/error", method = RequestMethod.GET)
    public String getErrorPage() {
        return "/error/500";
    }

}

 总结:Page是自己定义的一个分页工具类,当把Page对象或者其他任何对象和model作为controller方法参数时,对象自动注入到model对象当中,客户端可以直接调用并使用,其中返回的list也是一个值得注意的点,之前做的项目list中封装的只是一个实体类对象,而这个项目返回的list大多封装是多个实体类,使用List>实现,例如当前getIndexPage方法,根据这个页面可以判断当前方法要求返回帖子和当前帖子的user信息,所以当把List查出来之后还要遍历这个集合,声明一个List>集合,遍历过程中,会根据每一个DiscussPost中的userId将对应的user信息查询出来,然后将此时的DiscussPost对象和对应的User对象存放进map集合当中然后将map放入到List>中去,直到将List遍历完,最后将List>注入到model中返回客户端

仿牛客网项目(帖子项目)_第2张图片

前端代码(帖子列表)

				
				

总结:th:each属性的作用就是遍历controller传来的数据,其中map就是别名,${discussPosts},就是controller方法中model中的key取对应value,th:href="@{|/user/profile/${map.user.id}|}这部分就是映射地址,其中需要注意的时候当需要常量和变量一起使用的时候需要加上两条竖线||给括起来,th:utext会解析html也就是当后端返回的数据有html格式的数据会解析,而th:text则不会,th:if="${map.post.type==1}作用是条件成立标签显示,不成立标签不显示,${#dates.format(map.post.createTime,'yyyy-MM-dd HH:mm:ss')}起到日期格式化的作用,类似的用法等到用时可以查询

前端代码(分页)

				
				

 总结:thymeleaf中th:fragment可以使⽤th:fragment 属性来定义被包含的模版⽚段,以供其他模版包含,其他模板包含的时候需要用到th:replace="index::pagination"其中index是被包含所在html文件名,pagination是th:fragment中定义的参数,th:href="@{${page.path}(current=1)}"其中加()这中写法需要注意作用是相当于在url加上请求参数效果相当于127.0.0.1:8080/community/index?current=1,th:each="i:${#numbers.sequence(page.from,page.to)}"这个内置函数的作用就是根据传入的参数生成一个数列常和th:each结合使用

 二、注册模块

代码:LoginController

模块介绍:用户注册时会向用户注册时填写的邮箱中发送激活邮件,点击激活后完成注册

LoginController

    @RequestMapping(value = "/register",method = RequestMethod.GET)
    public String getRegisterPage(){
        return "/site/register.html";
    }
    @RequestMapping(value = "/register",method = RequestMethod.POST)
    public String register(Model model, User user){//向model中存数据携带给模板,这种user和model全是方法参数的时候model会自动携带user的值
        Map map=userService.register(user);
        if (map==null || map.isEmpty()){
            model.addAttribute("msg","注册成功,我们已向您发送了一个激活邮件,请赶紧激活");
            model.addAttribute("target","/index");
            return "/site/operate-result";
        }else {
            model.addAttribute("usernameMsg", map.get("usernameMsg"));
            model.addAttribute("passwordMsg", map.get("passwordMsg"));
            model.addAttribute("emailMsg", map.get("emailMsg"));
            return "/site/register";//注册不成功还是返回注册页面并返回之前填写的信息返回给模板,方便用户不用再次填写信息
        }
    }
    @RequestMapping(path = "/login", method = RequestMethod.GET)
    public String getLoginPage() {
        return "/site/login";
    }
    // http://localhost:8080/community/activation/101/code
    @RequestMapping(path = "/activation/{userId}/{code}", method = RequestMethod.GET)
    public String activation(Model model, @PathVariable("userId") int userId, @PathVariable("code") String code) {

        int result = userService.activation(userId, code);
        if (result == ACTIVATION_SUCCESS) {
            model.addAttribute("msg", "激活成功,您的账号已经可以正常使用了!");
            model.addAttribute("target", "/login");
        } else if (result == ACTIVATION_REPEAT) {
            model.addAttribute("msg", "无效操作,该账号已经激活过了!");
            model.addAttribute("target", "/index");
        } else {
            model.addAttribute("msg", "激活失败,您提供的激活码不正确!");
            model.addAttribute("target", "/index");
        }
        return "/site/operate-result";
    }

 这部分需要知道其中的代码思想,其中有一个注册不成功,跳转到注册页面,同时注册页面上的内容还是之前填写的内容,这样用户可以只修改而不用重新填写

UserService

public Map register(User user) {
        Map map = new HashMap<>();

        // 空值处理
        if (user == null) {
            throw new IllegalArgumentException("参数不能为空!");
        }
        if (StringUtils.isBlank(user.getUsername())) {
            map.put("usernameMsg", "账号不能为空!");
            return map;
        }
        if (StringUtils.isBlank(user.getPassword())) {
            map.put("passwordMsg", "密码不能为空!");
            return map;
        }
        if (StringUtils.isBlank(user.getEmail())) {
            map.put("emailMsg", "邮箱不能为空!");
            return map;
        }

        // 验证账号
        User u = userMapper.selectByName(user.getUsername());
        if (u != null) {
            map.put("usernameMsg", "该账号已存在!");
            return map;
        }

        // 验证邮箱
        u = userMapper.selectByEmail(user.getEmail());
        if (u != null) {
            map.put("emailMsg", "该邮箱已被注册!");
            return map;
        }

        // 注册用户
        user.setSalt(CommunityUtil.generateUUID().substring(0, 5));
        user.setPassword(CommunityUtil.md5(user.getPassword() + user.getSalt()));
        user.setType(0);
        user.setStatus(0);
        user.setActivationCode(CommunityUtil.generateUUID());
        user.setHeaderUrl(String.format("http://images.nowcoder.com/head/%dt.png", new Random().nextInt(1000)));
        user.setCreateTime(new Date());
        userMapper.insertUser(user);

        // 激活邮件
        Context context = new Context();
        context.setVariable("email", user.getEmail());
        // http://localhost:8080/community/activation/101/code
        String url = domain + contextPath + "/activation/" + user.getId() + "/" + user.getActivationCode();
        context.setVariable("url", url);
        String content = templateEngine.process("/mail/activation", context);
        mailClient.sendEmail(user.getEmail(), "激活账号", content);

        return map;
    }

    public int activation(int userId, String code) {
        User user = userMapper.selectById(userId);
        if (user.getStatus() == 1) {
            return ACTIVATION_REPEAT;
        } else if (user.getActivationCode().equals(code)) {
            userMapper.updateStatus(userId, 1);
            clearCache(userId);
            return ACTIVATION_SUCCESS;
        } else {
            return ACTIVATION_FAILURE;
        }
    }

总结:注册时用户密码是经过MD5加密之后存入数据库中的,MD5加密格式固定,就是一个字符的密码集是固定的,黑客很容易根据固定的密码集合反向推导出密码,所以要在密码后面拼接一个随机字符串然后进行加密,同时将所拼接的字符串和拼接并加密之后的密码存入 数据库,setActivationCode的作用是设置激活码,setHeadUrl是设置用户头像路径目前使用的牛客网的头像随机路径,此时用户状态为0,activation方法是激活码验证方法,不仅要验证激活码是否相等还要验证用户是否已经激活存在重复激活的操作,还有激活失败的情况,如果激活码相同用户状态不为1则激活成功。

发送激活邮件这部分用到了Context和TempleateEngine(模板引擎)这两个对象结合使用,使用模板引擎的作用就是将指定网页模板注入context中的数据之后转为动态字符串(也可以说成html格式的内容),然后作为邮件内容发送给接收者,接收者相当于看到的就是一个网页,一个激活网页

三、登录模块

代码:LoginController、UserService

模块介绍:验证码(存放在redis中),登录验证,登录凭证(存放在redis中,同时存放在cookie中返回给客户端,是一个登录凭证对象),记住用户(拦截器会判断对应的cookie是否存在)

LoginController(登录验证部分)

    @RequestMapping(path = "/login", method = RequestMethod.POST)
    public String login(String username, String password, String code, 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 redisKey=RedisKeyUtil.getKaptchaKey(kaptchaOwner);
            kaptcha=(String) redisTemplate.opsForValue().get(redisKey);
        }
        if (StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)) {//首先判断两个字符串保存的验证码是否为空,不为空再不区分大小写比较验证码
            model.addAttribute("codeMsg", "验证码不正确!");
            return "/site/login";
        }

        // 检查账号,密码
        int expiredSeconds = rememberme ? REMEMBER_EXPIRED_SECONDS : DEFAULT_EXPIRED_SECONDS;//这里是是否记住密码
        Map map = userService.login(username, password, expiredSeconds);
        if (map.containsKey("ticket")) {//查看service方法返回的map是否包含ticket的key有的话就是登录成功
            Cookie cookie = new Cookie("ticket", map.get("ticket").toString());
            cookie.setPath(contextPath);
            cookie.setMaxAge(expiredSeconds);
            response.addCookie(cookie);
            return "redirect:/index";
        } else {
            model.addAttribute("usernameMsg", map.get("usernameMsg"));
            model.addAttribute("passwordMsg", map.get("passwordMsg"));
            return "/site/login";
        }
    }

总结:这部分需要注意其中的代码思想,其中需要注意的是登录凭证部分,cookie中应避免存入像user_id等敏感信息,所以只向cookie中存放ticket属性的内容,后期使用拦截器的时候可以使用ticket来获取和使用对应的ticket对象中的user_id,从而获得user信息

LoginController(生成验证码部分)

    @RequestMapping(path = "/kaptcha", method = RequestMethod.GET)
    public void getKaptcha(HttpServletResponse response/*, HttpSession session*/) {
        // 生成验证码
        String text = kaptchaProducer.createText();//调用kaptcharProducer的方法
        BufferedImage image = kaptchaProducer.createImage(text);//生成图片

        // 将验证码存入session,存放在服务器中,在多个请求之间用是跨请求的用session
//        session.setAttribute("kaptcha", text);
        //验证码的归属,临时生成一个凭证
        String kaptchaOwner= CommunityUtil.generateUUID();
        Cookie cookie=new Cookie("kaptchaOwner",kaptchaOwner);//临时凭证区别用户
        cookie.setMaxAge(60);//60秒
        cookie.setPath(contextPath);//设置cookie有效范围
        response.addCookie(cookie);

        String redisKey= RedisKeyUtil.getKaptchaKey(kaptchaOwner);
        redisTemplate.opsForValue().set(redisKey,text,60, TimeUnit.SECONDS);
        // 将突图片输出给浏览器
        response.setContentType("image/png");
        try {
            OutputStream os = response.getOutputStream();
            ImageIO.write(image, "png", os);//输出图片的工具类,第二哥参数是用什么格式输出,第三个输出需要输出流,所以是输出流参数
        } catch (IOException e) {
            logger.error("响应验证码失败:" + e.getMessage());
        }
    }

总结:验证码使用的是一个插件详见pom.xml用引入的验证码依赖,springboot使用kaptcha前要先配置一些常用的参数,详见KaptchaConfig,生成验证码图片显示在客户端,将验证码内容存入redis中,其中存入在redis存入验证码所使用的redisKey需要注意,首先获取了一个uuid作为临时凭证,然后使用此uuid拼接上固定的内容作为key,这样做是为了让登录验证码每个用户一个验证码,不同的用户验证码都不一样,所以使用一个临时凭证,然后将临时凭证存入cookie中返回客户端,在后端对应的登录方法中会通过cookie中的验证码凭证从redis中获取验证码然后进行验证。

UserService(退出登录)

    public void logout(String ticket) {
//        loginTicketMapper.updateStatus(ticket, 1);
        String redisKey= RedisKeyUtil.getTicketKey(ticket);
        LoginTicket loginTicket=(LoginTicket) redisTemplate.opsForValue().get(redisKey);
        loginTicket.setStatus(1);
        redisTemplate.opsForValue().set(redisKey,loginTicket);//更新完之后覆盖原来的key

    }

 总结:没有删除内容,只是将ticket状态设置为1(即无效)

四、显示登录信息

代码: LoginTicketInterceptor、CookieUtil(来获取Cookie中信息的工具类)、

介绍:

package com.nowcoder.community.controller.interceptor;

import com.nowcoder.community.entity.LoginTicket;
import com.nowcoder.community.entity.User;
import com.nowcoder.community.service.UserService;
import com.nowcoder.community.util.CookieUtil;
import com.nowcoder.community.util.HostHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;

@Component
public class LoginTicketInterceptor implements HandlerInterceptor {

    @Autowired
    private UserService userService;

    @Autowired
    private HostHolder hostHolder;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 从cookie中获取凭证
        String ticket = CookieUtil.getValue(request, "ticket");

        if (ticket != null) {
            // 查询凭证
            LoginTicket loginTicket = userService.findLoginTicket(ticket);
            // 检查凭证是否有效
            if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {
                // 根据凭证查询用户
                User user = userService.findUserById(loginTicket.getUserId());
                // 在本次请求中持有用户
                hostHolder.setUser(user);
            }
        }

        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        User user = hostHolder.getUser();
        if (user != null && modelAndView != null) {
            modelAndView.addObject("loginUser", user);
        }
    }
//模板加载完执行此方法,将本地线程中保存的user信息清除,hostHolder存在的目的就是在本次请求中持有用户,模板一加载完就清除hostHolder,没有用到session,user持久化保存在浏览器的cookie中
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        hostHolder.clear();
    }
}

 总结:这个拦截器的作用是在方法之前获取user,在本次请求中持有user数据,之后在模板中显示user数据,最后视图渲染完之后清除user数据,这个拦截器在每一次请求都会触发,将user存入到hostHolder

 五、账号设置(主要的就是上传头像)

代码:UserController

介绍:账号设置主要的就是上传头像

   @LoginRequired
    @RequestMapping(path = "/upload", method = RequestMethod.POST)
    public String uploadHeader(MultipartFile headerImage, Model model) {
        if (headerImage == null) {
            model.addAttribute("error", "您还没有选择图片!");
            return "/site/setting";
        }

        String fileName = headerImage.getOriginalFilename();
        String suffix = fileName.substring(fileName.lastIndexOf("."));
        if (StringUtils.isBlank(suffix)) {
            model.addAttribute("error", "文件的格式不正确!");
            return "/site/setting";
        }

        // 生成随机文件名
        fileName = CommunityUtil.generateUUID() + suffix;
        // 确定文件存放的路径
        File dest = new File(uploadPath + "/" + fileName);
        try {
            // 存储文件
            headerImage.transferTo(dest);
        } catch (IOException e) {
            logger.error("上传文件失败: " + e.getMessage());
            throw new RuntimeException("上传文件失败,服务器发生异常!", e);
        }

        // 更新当前用户的头像的路径(web访问路径)
        // http://localhost:8080/community/user/header/xxx.png
        User user = hostHolder.getUser();
        String headerUrl = domain + contextPath + "/user/header/" + fileName;
        userService.updateHeader(user.getId(), headerUrl);

        return "redirect:/index";
    }

    @RequestMapping(path = "/header/{fileName}", method = RequestMethod.GET)
    public void getHeader(@PathVariable("fileName") String fileName, HttpServletResponse response) {
        // 服务器存放路径
        fileName = uploadPath + "/" + fileName;
        // 文件后缀
        String suffix = fileName.substring(fileName.lastIndexOf("."));
        // 响应图片
        response.setContentType("image/" + suffix);
        try (
                FileInputStream fis = new FileInputStream(fileName);
                OutputStream os = response.getOutputStream();
        ) {
            byte[] buffer = new byte[1024];
            int b = 0;
            while ((b = fis.read(buffer)) != -1) {
                os.write(buffer, 0, b);
            }
        } catch (IOException e) {
            logger.error("读取头像失败: " + e.getMessage());
        }
    }

总结:主要使用了Spring MVC提供的MutipartFile对象,如果客户端传来一个文件就定义一个对象,如果传来多个文件就定义一个MutipartFile数组 ,其中主要的就是上传图片的代码流程,getHeader方法作用就是获取图片的作用,也要注意这部分输入和响应图片的,注意上方是try()将输入输出流包裹这种写法,这是java7的写法,这种写法会自动加上finally,在finally中将输入输出流关闭前提是输入输出流有close方法

 六、检查登录状态

代码:

介绍:就是不登陆无法直接通过url访问别的功能,与平常使用不同的是这次使用的是自定义注解,在需要拦截的方法上添加上自己定义的注解进行拦截

LoginRequired(是否登录的意思)

package com.nowcoder.community.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)//元注解,表明注解能在那快使用
@Retention(RetentionPolicy.RUNTIME)//元注解表明注解在何时能够使用
public @interface LoginRequired {

}

总结:自定义注解需要使用元注解,自定义注解前两个是必须要用的,后面两个的意思是生成文档是否把自定义注解也带上,子类继承父类的时候是否把父类的自定义注解给一块继承

仿牛客网项目(帖子项目)_第3张图片

LoginRequiredInterceptor

package com.nowcoder.community.controller.interceptor;

import com.nowcoder.community.annotation.LoginRequired;
import com.nowcoder.community.util.HostHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;

@Component
public class LoginRequiredInterceptor implements HandlerInterceptor {

    @Autowired
    private HostHolder hostHolder;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            Method method = handlerMethod.getMethod();
            LoginRequired loginRequired = method.getAnnotation(LoginRequired.class);
            if (loginRequired != null && hostHolder.getUser() == null) {
                response.sendRedirect(request.getContextPath() + "/login");
                return false;
            }
        }
        return true;
    }
}

 总结:if中判断的是是否拦截的是方法,除了方法也可能是拦截的静态资源,当拦截的是方法时才有效,然后会获取方法上的注解,如果有自定义注解且HostHolder没有user信息就代表没有登录且需要拦截跳转到登录页面

 七、发布帖子

代码:DiscussPostController、index.js

介绍:使用的ajax异步提交的方式发送帖子,与之前的项目不同的是,此时用的不是增量更新,而是整个页面刷新,发帖的时候还会使用kafka进行消息通知

    @RequestMapping(path = "/add", method = RequestMethod.POST)
    @ResponseBody
    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);

        //触发发帖事件
        Event event=new Event()
                .setTopic(TOPIC_PUBLISH)
                .setUserId(user.getId())
                .setEntityType(ENTITY_TYPE_POST)
                .setEntityId(post.getId());//实体id就是那个帖子id
        eventProducer.fireEvent(event);//触发事件

        // 报错的情况,将来统一处理.
        return CommunityUtil.getJSONString(0, "发布成功!");

 总结:在进行完insert操作之后会自动返回帖子的id(id是自增的)给post对象,其中的向kafka存消息的时候封装了一个Event对象,然后将event转换为json字符串格式传入kafka,当消费者监听到开始消费消息,其中json格式转为对象也需要学习

EventConsumer(消费事件)

    @KafkaListener(topics = {TOPIC_COMMENT, TOPIC_LIKE, TOPIC_FOLLOW})
    public void handleCommentMessage(ConsumerRecord record) {
        if (record == null || record.value() == null) {
            logger.error("消息的内容为空!");
            return;
        }

        Event event = JSONObject.parseObject(record.value().toString(), Event.class);//将json字符串转换成指定的实体类对象
        if (event == null) {
            logger.error("消息格式错误!");
            return;
        }

        // 发送站内通知
        Message message = new Message();
        message.setFromId(SYSTEM_USER_ID);
        message.setToId(event.getEntityUserId());
        message.setConversationId(event.getTopic());
        message.setCreateTime(new Date());

        Map content = new HashMap<>();
        content.put("userId", event.getUserId());//前端还需要拼成一个字符串用户xx评论了xx这些需要以下三个信息,所以需要存起来
        content.put("entityType", event.getEntityType());
        content.put("entityId", event.getEntityId());

        if (!event.getData().isEmpty()) {
            for (Map.Entry entry : event.getData().entrySet()) {//遍历map的其中一个方式
                content.put(entry.getKey(), entry.getValue());
            }
        }
        message.setContent(JSONObject.toJSONString(content));
        messageService.addMessage(message);
    }

index.js

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

function publish() {
	$("#publishModal").modal("hide");

	// 获取标题和内容
	var title = $("#recipient-name").val();
	var content = $("#message-text").val();
	// 发送异步请求(POST)
	$.post(
	    CONTEXT_PATH + "/discuss/add",
	    {"title":title,"content":content},
	    function(data) {
	        data = $.parseJSON(data);
	        // 在提示框中显示返回消息
	        $("#hintBody").text(data.msg);
	        // 显示提示框
            $("#hintModal").modal("show");
            // 2秒后,自动隐藏提示框
            setTimeout(function(){
                $("#hintModal").modal("hide");
                // 刷新页面
                if(data.code == 0) {
                    window.location.reload();
                }
            }, 2000);
	    }
	);

}

 使用的是jquery的异步提交方法其中需要注意的是data = $.parseJSON(data);,data原本是string格式数据通过这行代码会将data转换为js对象

八、帖子详情页面

代码:DiscussPostController

介绍: 主要是对各种数据的集合,其中有帖子的信息,评论的信息和评论的评论信息,还有与之相关的点赞信息,将这些信息封装成集合返回到客户端

DiscussPostController(帖子详情部分)

    @RequestMapping(path = "/detail/{discussPostId}", method = RequestMethod.GET)
    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);
        // 点赞数量
        long likeCount = likeService.findEntityLikeCount(ENTITY_TYPE_POST, discussPostId);
        model.addAttribute("likeCount", likeCount);
        // 点赞状态
        int likeStatus = hostHolder.getUser() == null ? 0 :
                likeService.findEntityLikeStatus(hostHolder.getUser().getId(), ENTITY_TYPE_POST, discussPostId);
        model.addAttribute("likeStatus", likeStatus);

        // 评论分页信息
        page.setLimit(5);
        page.setPath("/discuss/detail/" + discussPostId);
        page.setRows(post.getCommentCount());

        // 评论: 给帖子的评论
        // 回复: 给评论的评论
        // 评论列表
        List commentList = commentService.findCommentsByEntity(
                ENTITY_TYPE_POST, post.getId(), page.getOffset(), page.getLimit());
        // 评论VO列表
        List> commentVoList = new ArrayList<>();
        if (commentList != null) {
            for (Comment comment : commentList) {
                // 评论VO
                Map commentVo = new HashMap<>();
                // 评论
                commentVo.put("comment", comment);
                // 作者
                commentVo.put("user", userService.findUserById(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(
                        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);
                        // 点赞数量
                        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);

                        replyVoList.add(replyVo);
                    }
                }
                commentVo.put("replys", replyVoList);

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

                commentVoList.add(commentVo);
            }
        }

        model.addAttribute("comments", commentVoList);

        return "/site/discuss-detail";
    }

总结:这部分除了将user和discusspost信息查出来,还需要从redis中取出点赞的数量和当前用户对帖子的内容是否点赞的状态,这部分的流程:先查这个帖子是否有回帖(实体状态为帖子但是存在comment表中,而帖子存放在discusspost中)然后查每一个回帖的每一个评论,在查这个评论的每一个回复评论,其中需要注意的是每一个属性在进行集合时单独集合的内容如回复评论在集合时需要另外集合一个回复目标,其中还有帖子详情页点赞功能后续单独进行总结,前端代码见discuss-detail.html

九、事务管理

十、私信列表

代码:MessageController、

模块介绍:仿牛客网项目(帖子项目)_第4张图片

    // 私信列表
    @RequestMapping(path = "/letter/list", method = RequestMethod.GET)
    public String getLetterList(Model model, Page page) {
        User user = hostHolder.getUser();
        // 分页信息
        page.setLimit(5);
        page.setPath("/letter/list");
        page.setRows(messageService.findConversationCount(user.getId()));

        // 会话列表
        List conversationList = messageService.findConversations(
                user.getId(), page.getOffset(), page.getLimit());
        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()));
                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";
    }

总结:私信涉及message表,其中message表中的conversation_id需要注意,userId为111给userId为112的人发私信时就会存储为111_112无论这两个是谁给谁发消息都是111_112小的在前,大的在后,这部分需要注意其中的代码思想和这部分业务联系起来。

十一、统一记录日志 、统一处理异常

十二、redis部分

代码:LikeController、LikeService、FollowService、FollowController

介绍:项目使用到redis有二个功能,点赞、关注,详细见代码注释

十三、kafka

十四、过滤敏感词

代码:SensitiveFilter、SensitiveFilter-text

介绍:过滤敏感词主要使用的是前缀树,前缀树的特点是根节点为空,其余节点存放一个数据,这里面有三个指针

SensitiveFilter(构造前缀树阶段)

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

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

    @PostConstruct//当spring初始化这个bean时执行方法,当服务启动时就会初始化bean,也就是服务启动就会执行方法
    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) {
            logger.error("加载敏感词文件失败: " + e.getMessage());
        }
    }

    // 将一个敏感词添加到前缀树中
    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);

            if (subNode == null) {
                // 初始化子节点
                subNode = new TrieNode();
                tempNode.addSubNode(c, subNode);
            }

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

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

总结:@PostConstruct作用当spring初始化这个bean时执行方法,当服务启动时就会初始化bean,也就是服务启动就会执行方法,每一个TrieNode节点有两个属性,一个标志数据意为是否是铭感字符末尾,一个是map,key为下级字符,value为下级TrieNode节点,使用map存放下级节点这种方式构建前缀树结构,这样设计怎么使用见下段

SensitiveFilter(过滤敏感词阶段) 

 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 {
                // 检查下一个字符
                position++;
            }
        }

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

        return sb.toString();
    }

总结:使用前缀树判断铭感词,这里相当于有三个指针,1指针每回向下判断,当判断不是敏感词的时候指针会重新指向根节点(比如abf这段,当查到f不是敏感词的时候指针1会重新反到根节点),2和3指针这里使用的是索引号来代替是int类型,因为字符串可以使用charAt通过索引来获取,2指针不回走,3指针范围回走,通过charAt将字符获取,再通过TireNode的get方法通过字符为key获取value,获取到说明疑似敏感词,获取不到2和3指针往下走,同时要先将x这个字符拼接好再往下走,最后结束标志是指针3的索引位置等于字符串长度-1时循环判断结束

仿牛客网项目(帖子项目)_第5张图片

你可能感兴趣的:(java,github,开发语言)