# blogSystem **Repository Path**: codecat66s/blog-system ## Basic Information - **Project Name**: blogSystem - **Description**: 《校园论坛交流平台》 一个具有完整功能的校园论坛项目,项目主要功能有:基于邮件激活的注册方式,基于MD5加密与加盐的密码存储方式,登录功能加入了随机验证码的验证。实现登陆状态检查、为游客与已登录用户展示不同界面与功能。支持用户上传头像,实现发布帖子、评论帖子、发送私信与过滤敏感词等功能。实现了点赞,关注与系统通知功能。 - **Primary Language**: Java - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 8 - **Forks**: 0 - **Created**: 2023-03-02 - **Last Updated**: 2026-03-29 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README [TOC] # 校园论坛交流平台 # 1、项目内容梳理 ## 1.1、项目使用指南 用户角色测试账号: > 1、管理员 > > > 账号:admin12 > > > > 密码:123456 > > 2、版主 > > > 账号:moderator21 > > > > 密码:123456 > > 3、普通用户(或者自行注册) > > > 账号:aaa > > > > 密码:123456 ## 1.2、社区首页开发 #### 1.2.1、开发通用的分页组件: ~~~java 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) { if (path != null) { this.path = path; } } /** * 根据当前页码获取起始行数 * @return */ public int getOffset() { return (current - 1) * limit; } /** * 获取总页数 * @return */ public int getTotalPage() { if (rows % limit == 0) { return rows / limit; } else { return rows / limit + 1; } } /** * 显示的起始页码 * @return */ public int getFrom() { //当前页码为第1,2页直接返回第一页 if (current < 3 && current > 0) { return 1; } int from = current - 2; return from < 1 ? current : from; } /** * 显示的终止页码 * @return */ public int getTo() { int to = current + 2; int totalPage = getTotalPage(); if (current < 3 && current > 0 && totalPage >= 5) { return 5; } return to > totalPage ? totalPage : to; } } ~~~ #### 1.2.2、分页显示所有的帖子: 1、分页查询所有的帖子或者根据用户id分页查询指定用户的帖子,默认过滤掉被拉黑的帖子 - mapper接口设计: (1)、需要的方法参数:(用户id:userId)、(数据的下标值offset)、(数据的条数limit) ~~~ java List selectDiscussPosts(int userId, int offset, int limit); ~~~ sql语句编写: (1)、由于需要有查询所有帖子和查询指定用户帖子两种需求,所以要使用动态sql,根据userId是否有值来决定对userId的判断条件是否执行。规定userId为0,表示查询所有的帖子,否则就是查询指定用户的帖子: 【注意:】 判断的是后端传过来的java中的字段所以这里的userId用的是驼峰命名,不要错写成了下划线命名 (2)、由于默认查询的帖子都是没有被拉黑的,所以需要根据帖子的状态字段status进行判断,status为2表示拉黑。所以这里要过滤:status != 2 (3)、由于需要精华帖优先排列,最新创建的帖子优先排列。所以需要使用排序。使用字段type表示是否精华 order by type desc, create_time desc (4)、由于需要分页显示,需要使用limit关键字 limit #{offset}, #{limit} (5)、综上,sql语句为: ~~~ sql select 列名 from discusss_post where status != 2 and user_id = #{userId} order by type desc, create_time desc limit #{offset}, #{limit} ~~~ #### 1.2.3、查询帖子的总条数 - 需求分析: (1)、需要过滤被拉黑的帖子 (2)、同时支持查询某个用户的帖子数或者查询所有用户的帖子数 - mapper接口设计 由于需要支持同时查询某个用户的帖子和所有用户的帖子数,需要传入一个userId参数 ~~~ java int selectDiscussPosts(int userId) ~~~ - sql语句编写 (1)、需要查询帖子数量,使用聚合函数count,用非空字段,主键id来查。count(id) (2)、过滤被拉黑帖子,where status != 2 (3)、查询某个用户的帖子总数还是查询总的帖子数,使用动态sql user_id=#{userId} (4)、综上,sql为: ~~~ sql select count(id) from discuss_post where status != 2 and user_id = #{userId} ~~~ ## 1.3、注册模块开发 ### 1.3.1、代码逻辑分析 1、用户发起注册后,服务器向用户发送一封激活邮件。用户点击激活邮件即可完成激活 2、如果用户已经激活,提示:重复激活 如果激活链接后面的激活码不正确,则提示:激活失败,激活码错误 激活成功,则提示:激活成功。 3、点击激活链接后,跳转到相应的中间操作界面,并给出上述提示。然后8秒后自动跳转到下一个页面: (1)、激活成功则跳转到登录页面 (2)、激活失败或者重复激活则跳转到社区首页 ### 1.3.2、发送邮件功能开发 1、在邮箱中开启SMTP服务 2、导入spring-boot-starter-mail 3、配置邮箱的相关信息 > (1)、发送邮箱的域名:spring.mail.host=smtp.sina.com > > (2)、发送邮箱的端口,一般都是465:spring.mail.port=465 > > (3)、发送邮箱的账号:spring.mail.username=test@sina.com > > (4)、发送邮箱的授权码:spring.mail.password="abcdc" > > (5)、发送邮箱使用的协议:spring.mail.protocol=smtps > > (6)、发送邮箱时采用ssl安全连接:spring.mail.properties.mail.smtp.ssl.enable=true 4、自定义一个MailClient,并用@Component交给spring容器进行管理。编写一个sendMail方法,用于发送邮件。 (1)、注入JavaMailSender,以及配置文件中的邮箱的账号 (2)、编写一个用于发送邮件的方法sendMail,需要传入:接收方邮箱、邮件主题、邮件内容三部分内容。 (3)、sendMail方法的编写: > 1、通过注入的javaMailSender获取mineMessage对象 > > 2、使用mineMessage作为参数构造MimeMessageHelper对象 > > 3、通过MimeMessage对象设置邮件的发送方,接收方法,邮件主题,邮件内容(传入第二个参数true,表示支持html) > > 4、调用javaMailSender的send方法,并通过mimeMessage.getMimeMessage获得的结果作为参数 > > 5、对整个发送邮件的方法做try-catch。如果捕捉到异常,就记录日志:发送邮件失败! ~~~ java //MailClient,自定义的用于发送邮件的组件 @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 sendMail(String to, String subject, String content) { try { MimeMessage mimeMessage = mailSender.createMimeMessage(); MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage); mimeMessageHelper.setFrom(from); mimeMessageHelper.setTo(to); mimeMessageHelper.setSubject(subject); mimeMessageHelper.setText(content,true);//true表示支持html文本 mailSender.send(mimeMessageHelper.getMimeMessage()); } catch (MessagingException e) { logger.error("发送邮件失败:" + e.getMessage()); } } } ~~~ ### 1.3.3、注册功能代码逻辑 1、将注册时需要的各种字段数据封装在User实体中 2、将User实体作为参数传入service层的注册方法中,将判断结果(map)返回给到controller层。controller层根据注册是否成功跳转到中间结果页面或者是重写回到注册页面。并给出相应的提示信息。 3、service层总注册方法的业务逻辑,Map register(User user) > 1、判断user实体是否为null,如果是,直接抛出异常 > > 2、依次对用户名、密码、邮箱进行判空。如果为空就记录相应的异常信息到map集合,并直接返回map集合 > > 3、通过查询数据库,检验账号是否已经存在了,邮箱是否已经被注册过了。如果有异常,则记录相应的异常存入map集合,并知己返回map集合 > > 4、开始注册用户。 > > > 1、通过自定义工具类获取随机字符串设置盐值,对原有密码加盐后进行md5加密。设置用户的type为0表示普通用户,设置用户的status为0表示还没有被激活。通过随机字符串设置账号的激活码。设置头像为随机默认头像。设置账号的创建时间。 > > > > 2、将设置好字段值的user对象,调用mapper存入数据库 > > 5、发送激活邮件 > > > 1、将激活码拼接到激活链接中 > > > > 2、通过templateEngine.process方法传入激活邮件模版和context对象(存储账号邮箱和激活链接)。该方法会将context中的变量和模版共同创建出一个包含具体邮件内容的.html文件 > > > > 3、调用MailClient中的sendMail方法,发送激活邮件 ~~~ java //注册功能的service层代码,用于返回注册结果以及进行具体的注册操作 public class UserService { @Autowired UserMapper userMapper; @Autowired MailClient mailClient; @Autowired TemplateEngine templateEngine; @Value("${pycommunity.path.domain}") String domain; @Value("${server.servlet.context-path}") String contextPath; public User findUserById(int id) { return userMapper.selectById(id); } 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("passwoMsg","密码不能为空!"); 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(PycommunityUtil.generateUUID().substring(0,5)); user.setPassword(PycommunityUtil.md5(user.getPassword()+user.getSalt())); user.setType(0);//0表示普通用户 user.setStatus(0);//0表示没有激活 user.setActivationCode(PycommunityUtil.generateUUID()); //nextInt(1001)产生[0,1000]的整数 user.setHeaderUrl(String.format("http://images.nowcoder.com/head/%dt.png",new Random().nextInt(1001))); user.setCreateTime(new Date()); userMapper.insertUser(user); //激活邮件 Context context = new Context(); context.setVariable("email",user.getEmail()); //指定url为http://localhost:8080/项目路径/功能路径/用户id/激活码 String url = domain + contextPath + "/activation/" + user.getId() + "/" + user.getActivationCode(); context.setVariable("url", url); String process = templateEngine.process("/mail/activation", context); mailClient.sendMail(user.getEmail(),"激活账号",process); return map; } ~~~ ### 1.3.4、激活账号功能的代码逻辑 1、controller层调用service层的激活方法,传入用户id和激活码。然后根据返回的激活操作状态,存入不同的提示信息和跳转的目的地址。通过先跳转到中间页面,然后8秒倒计时接收后,自动跳转到指定的目的地址。 2、激活成功跳转到登录页面,重复激活或者集合失败,跳转到社区首页 ~~~ java //激活功能的controller层代码 @RequestMapping(path = "/activation/{userId}/{code}",method = RequestMethod.GET) public String activation(Model model, @PathVariable("userId") int id, @PathVariable("code") String code) { int result = userService.activation(id, 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"; } ~~~ 3、激活操作后会有三种状态 > 1、用户的status值为1,表示已经激活过了,此时返回“重复激活” > > 2、用户的激活链接中提供的用户id不存在或者激活码错误,则返回“激活失败” > > 3、没有出现上述两种情况,返回“激活成功” ~~~ java public int activation(int userId, String code) { User user = userMapper.selectById(userId); if (user == null) { return ACTIVATION_FAILURE; } if(user.getStatus() == 1) { return ACTIVATION_REPEAT; }else if(!user.getActivationCode().equals(code)) { return ACTIVATION_FAILURE; }else { userMapper.updateStatus(userId,1); return ACTIVATION_SUCCESS; } } ~~~ ## 1.4、登录模块开发 ### 1.4.3、生成验证码 1、导入kaptcha依赖 2、编写kaptcha配置类,交给spring容器管理 ~~~ java @Configuration public class KaptchaConfig { @Bean public Producer kaptchaProducer() { //config依赖于一个properties对象 Properties properties = new Properties(); properties.setProperty("kaptcha.image.width","100"); properties.setProperty("kaptcha.image.height","40"); properties.setProperty("kaptcha.textproducer.font.size","32"); //字符颜色黑色 properties.setProperty("kaptcha.textproducer.font.color","0,0,0"); //随机字符的范围 properties.setProperty("kaptcha.textproducer.char.string","0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"); //生成四个随机字符 properties.setProperty("kaptcha.textproducer.char.length","4"); //生成的图片的噪声类是nonoise即不加噪声 properties.setProperty("kaptcha.noise.impl","com.google.code.kaptcha.impl.NoNoise"); DefaultKaptcha kaptcha = new DefaultKaptcha(); //创建config对象,封装配置kaptcha的参数 Config config = new Config(properties); kaptcha.setConfig(config); return kaptcha; } } ~~~ 3、生成验证码的控制层代码逻辑 > 1、注入kaptchaProducer > > 2、使用kaptchaProducer生成验证码和图片,并将验证码存入session中,然后把图片输出到浏览器 ~~~ java @Configuration public class KaptchaConfig { @Bean public Producer kaptchaProducer() { //config依赖于一个properties对象 Properties properties = new Properties(); properties.setProperty("kaptcha.image.width","100"); properties.setProperty("kaptcha.image.height","40"); properties.setProperty("kaptcha.textproducer.font.size","32"); //字符颜色黑色 properties.setProperty("kaptcha.textproducer.font.color","0,0,0"); //随机字符的范围 properties.setProperty("kaptcha.textproducer.char.string","0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"); //生成四个随机字符 properties.setProperty("kaptcha.textproducer.char.length","4"); //生成的图片的噪声类是nonoise即不加噪声 properties.setProperty("kaptcha.noise.impl","com.google.code.kaptcha.impl.NoNoise"); DefaultKaptcha kaptcha = new DefaultKaptcha(); //创建config对象,封装配置kaptcha的参数 Config config = new Config(properties); kaptcha.setConfig(config); return kaptcha; } } ~~~ > 3、在login.html中添加js代码,实现点击刷新验证码,CONTEXT_PATH是global.js中的常量,为了使得每次点击刷新验证码的路径变化,给路径加上随机参数p > > ~~~ js > > ~~~ ### 1.4.4、登录功能 1、编写一个登录凭证实体类,登录成功后,就给用户颁发一个登录凭证 登录凭证包括: > 1、id,主键 > > 2、userId,用户id > > 3、ticket,登录凭证码 > > 4、status,登录状态,0表示有效,1表示无效 > > 5、expired,过期时间 > > ~~~ java > @Data > @ToString > public class LoginTicket { > private int id; > private int userId; > private String ticket; > private int status; > private Date expired; > } > ~~~ 2、登录方法的service层代码,方法返回结果为Map > 1、依次判断用户名、密码是否为空。如果不合法,则将相应的提示信息存入map并立即返回map > > 2、通过用户名进行查询,判断用户是否存在。如果不存在,则存入相应的提示信息,并立即返回map > > 3、通过status字段判断账号是否已经激活,如果没有激活,则返回相应的提示信息,并立即返回map > > 4、将输入的密码加盐后进行md5加密,然后和数据库中的密码对比,判断密码是否正确。如果密码错误,则存入相应的提示信息,并立即返回map > > 5、以上验证通过,则登录成功,开始为用户颁发登录凭证 > > > 1、将用户id存入登录凭证 > > > > 2、生成一个随机字符串序列,作为登录凭证码存入登录凭证 > > > > 3、设置登录凭证状态为0,即有效 > > > > 4、设置登录凭证过期时间为当前时间加上过期时长的时间戳 > > > > 5、将登录凭证存入数据库,并返回map ~~~ java public Map login(String username, String password, int expiredSeconds) { Map map = new HashMap<>(); //空值处理 if(StringUtils.isBlank(username)) { map.put("usernameMsg","账号不能为空"); return map; } if(StringUtils.isBlank(password)) { map.put("password","密码不能为空"); return map; } //验证账号 User user = userMapper.selectByName(username); if(user == null) { map.put("usernameMsg","该账号不存在"); return map; } //验证状态 if(user.getStatus() == 0) { map.put("usernameMsg","该账号未激活"); return map; } if(!user.getPassword().equals(PycommunityUtil.md5(password + user.getSalt()))) { map.put("passwordMsg","密码错误"); return map; } //生成登录凭证 LoginTicket loginTicket = new LoginTicket(); loginTicket.setUserId(user.getId()); loginTicket.setTicket(PycommunityUtil.generateUUID()); loginTicket.setStatus(0);//o表示有效 loginTicket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds * 1000)); loginTicketMapper.insertLoginTicket(loginTicket); map.put("ticket",loginTicket.getTicket()); return map; } ~~~ 3、登录方法的controller层代码 > 1、首先判断验证码是否正确,如果验证码错误,则直接返回到登录页面 > > 2、通过是否勾选记住密码来确定登录凭证的有效时长 > > 3、判断调用service层登录方法后返回的map,判断map中是否包含ticket,如果包含,说明通过了登录校验。将登录凭证码存入cookie中,然后设置cookie的有效访问路径和最大存活时间。如果不包含ticket,则说明登录校验失败了,将对应的错误信息存入model中,返回到登录页面 ~~~ java /*** * * @param username * @param password * @param code 网页输入的验证码 * @param rememberMe 勾选记住我保存时间久一点 * @param model * @param session 从session中取验证码 * @param response 登录成功了把生成的ticket存到cookie中 * @return */ @RequestMapping(path = "/login", method = RequestMethod.POST) public String login(String username, String password, String code, boolean rememberMe, Model model,HttpSession session,HttpServletResponse response) { //springmvc会把实体参数自动存进model,但是字符串和基本类型不会自动存,字符串和基本类型存在于request域 //检查验证码 String kaptcha = (String) session.getAttribute("kaptcha"); if(StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !code.equalsIgnoreCase(kaptcha)) { 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")) { String ticket = (String) map.get("ticket"); Cookie cookie = new Cookie("ticket",ticket); 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"; } } ~~~ ### 1.4.5、退出登录 1、在方法形参中使用@CookieValue("ticket"),用来接收cookie中的对应的登录凭证 2、通过登录凭证作为where条件,将登录状态改为失效。即将status改为1 ~~~ java //退出登录,controller层代码 @RequestMapping(path = "/logout",method = RequestMethod.GET) public String logout(@CookieValue("ticket") String ticket) { userService.logout(ticket); return "redirect:/login";//重定向是二次请求,必须通过controller来访问templates下的html } ~~~ ~~~ java public void logout(String ticket) { loginTicketMapper.updateStatus(ticket,1); } ~~~ ### 1.4.6、忘记密码 1、忘记密码的业务层代码逻辑 > 1、方法使用一个形参:用户的邮箱地址 > > 2、判断邮箱地址是否为空,如果为空,将提示信息存入map中,然后立即返回map > > 3、根据邮箱地址查找用户,如果用户不存在,说明邮箱没有被注册,将提示信息存入map中,然后立即返回map > > 4、根据status字段判断账号是否激活,如果没有激活,则将提示信息存入map中,然后立即返回map > > 5、以上校验都通过了,则生成一个随机字符串作为验证码,存入map中。然后生成一个过期时间(当前时间加上5分钟) > > 6、将map返回 ~~~ java public Map getForgetActivationCode(String email) { Map map = new HashMap<>(); if(StringUtils.isBlank(email)) { map.put("emailMsg","邮箱不能为空"); return map; } User user = userMapper.selectByEmail(email); if(user == null) { map.put("emailMsg","邮箱未注册"); return map; } if(user.getStatus() == 0) { map.put("emailMsg","账号未激活"); return map; } Context context = new Context(); context.setVariable("email",email); String forgetActivationCode = PycommunityUtil.generateUUID().substring(0,5); context.setVariable("forgetActivationCode",forgetActivationCode); String process = templateEngine.process("/mail/forget", context); mailClient.sendMail(email,"忘记密码",process); map.put("forgetActivationCode",forgetActivationCode);//map中存放一份,为了之后和用户输入的验证码进行对比 map.put("expirationTime", LocalDateTime.now().plusMinutes(5L));//过期时间 return map; } ~~~ 。。。 ### 1.4.7、上传头像 ​ 1、使用post请求,为表单增加enctype属性 2、生成随机文件名,保存文件后,将头像的访问路径保存到用户的url属性中 3、然后重定向到首页 ~~~ java @Value("${pycommunity.path.upload}") private String uploadPath; @Value("${pycommunity.path.domain}") private String domain; @Value("${server.servlet.context-path}") private String contextPath; @RequestMapping(path = "/upload", method = RequestMethod.POST) public String uploadHeader(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 = PycommunityUtil.generateUUID() + suffix; //确定文件存放路径 File file = new File(uploadPath + "/" + fileName); try { //保存文件 headerImg.transferTo(file); } catch (IOException e) { logger.error("文件上传失败:" + e.getMessage()); throw new RuntimeException("文件上传失败",e); } //更新用户头像路径(web访问路径) //http://localhost:8080/pycommunity/user/header/xxx.xx User user = hostHolder.getUser(); String url = domain + "/" + contextPath + "/user/header/" + fileName; userService.updateHeader(user.getId(),url); //重定向到首页 return "redirect:/index"; } ~~~ ### 1.4.8、头像相应到浏览器 1、从路径参数中拿到文件名,然后拼接文件后缀名,最后将头像存储位置拼接,得到最终的头像全路径地址。 2、将文件从磁盘中通过FileInputStream读取到内存,然后通过response对象获取到的输出流对象进行输出。对这个过程进行try-catch。如果操作失败,则记录相应的日志。 ~~~ java @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(".") + 1); response.setContentType("image/" + suffix); try( OutputStream outputStream = response.getOutputStream(); FileInputStream fis = new FileInputStream(fileName); ) { byte[] buffer = new byte[1024]; int b = 0; while((b = fis.read(buffer)) != -1) { outputStream.write(buffer,0,b); } } catch (IOException e) { logger.error("读取头像失败" + e.getMessage()); } } ~~~ ### 1.4.9、修改密码 1、接收旧密码、新密码、新密码确认以及一个Model用于跳转视图 2、对原始密码和新密码,新密码确认分别进行判空,不通过则记录提示信息,然后直接返回map。然后检验密码是否正确。 3、如果修改失败则跳转到设置界面,修改成功则跳转到退出界面 ~~~ java @RequestMapping(path = "/updatePassword",method = RequestMethod.POST) public String updatePassword(Model model, String oldPassword, String newPassword, String checkNewPassword) { if(oldPassword == null) { model.addAttribute("oldPasswordError","原始密码不能为空"); return "/site/setting"; } User user = hostHolder.getUser(); if(!user.getPassword().equals(PycommunityUtil.md5(oldPassword+user.getSalt()))) { model.addAttribute("oldPasswordError","原始密码不正确"); return "/site/setting"; } if(newPassword == null) { model.addAttribute("newPasswordError","新密码不能为空"); return "/site/setting"; } if(!newPassword.equals(checkNewPassword)) { model.addAttribute("checkNewPasswordError","两次密码不一致"); return "/site/setting"; } newPassword = PycommunityUtil.md5(newPassword + user.getSalt()); userService.updatePassword(user.getId(),newPassword); return "redirect:/logout"; } ~~~ ### 1.4.10、使用拦截器和自定义注解进行登录校验 1、自定义一个注解,用于校验登录状态 ~~~ java @Target(ElementType.METHOD)//声明注解作用的位置是方法上 @Retention(RetentionPolicy.RUNTIME)//声明注解生效的时间是程序运行时 public @interface LoginRequired { } ~~~ 2、使用拦截器,对标注了自定义注解的controller方法,进行登录状态校验。如果没有登录,就直接返回登录界面 > 1、实现一个HandlerInterceptor接口,如果注解内容为空或者,登录用户为空,则返回登录界面,同时方法体返回false > > ~~~ java > @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) {//判断拦截到的是不是方法类型 > handler = (HandlerMethod)handler; > Method method = ((HandlerMethod) handler).getMethod(); > LoginRequired annotation = method.getAnnotation(LoginRequired.class); > if(annotation != null && hostHolder.getUser() == null) { > response.sendRedirect(request.getContextPath() + "/login"); > return false; > } > } > return true; > } > } > ~~~ > > 2、在自定义配置类中实现WebMvcConfigurer接口,然后注册自定义的拦截器。同时排除掉css,js等静态资源 > > ~~~ java > @Configuration > public class WebMvcConfig implements WebMvcConfigurer { > @Autowired > private LoginTicketInterceptor loginTicketInterceptor; > @Autowired > private LoginRequiredInterceptor loginRequiredInterceptor; > @Override > public void addInterceptors(InterceptorRegistry registry) { > registry.addInterceptor(loginTicketInterceptor) > .excludePathPatterns("/**/*.css","/**/*.png","/js/*.js","/**/*.jpg","/**/*.jpeg"); > // **表示任意级目录 > registry.addInterceptor(loginRequiredInterceptor) > .excludePathPatterns("/**/*.css","/**/*.png","/js/*.js","/**/*.jpg","/**/*.jpeg"); > //注册顺序决定了执行先后顺序 > } > } > ~~~ ## 1.5、开发社区核心功能 ### 1.5.1、使用前缀树过滤敏感词 1、定义前缀树,一个节点,一个子节点(是一个map,可以存储多个子节点),一个布尔值用来标识当前是否为敏感词结尾 定义一些方法: > 1、设置标识节点是否为敏感词结尾的标记以及获取子节点是否为敏感词结尾的方法 > > 2、添加子节点,将子节点添加到map中 > > 3、获取子节点,从当前节点的map中根据字符获取对应的子节点 ~~~ java //定义前缀树 private class TrieNode { //标志是否是敏感词结尾 public boolean isSensitiveWordEnd = false; //定义子节点 public Map subNodes = new HashMap<>(); public boolean isSensitiveWordEnd() { return isSensitiveWordEnd; } public void setSensitiveWordEnd(boolean sensitiveWordEnd) { isSensitiveWordEnd = sensitiveWordEnd; } //添加子节点 public void addSubNode(Character c, TrieNode node) { subNodes.put(c,node); } //获取子节点 public TrieNode getSubNode(Character c) { return subNodes.get(c); } } } ~~~ 2、定义一个方法用于将一个敏感词添加到前缀树中 > 1、类中初始化好一个根节点root > > 2、每次添加敏感词时,遍历敏感词,依次取其中的每一个字符,然后根据字符获取当前节点的子节点,如果获取到的是null。说明这个字符对应的节点还没有被添加,则新建一个节点,将这个字符添加到节点中。然后将指针移动到当前这个节点上,继续循环。当遍历到敏感词末尾时,将节点标识位设置为true > > ~~~ java > //定义前缀树 > private class TrieNode { > > //标志是否是敏感词结尾 > public boolean isSensitiveWordEnd = false; > //定义子节点 > public Map subNodes = new HashMap<>(); > > public boolean isSensitiveWordEnd() { > return isSensitiveWordEnd; > } > > public void setSensitiveWordEnd(boolean sensitiveWordEnd) { > isSensitiveWordEnd = sensitiveWordEnd; > } > //添加子节点 > public void addSubNode(Character c, TrieNode node) { > subNodes.put(c,node); > } > //获取子节点 > public TrieNode getSubNode(Character c) { > return subNodes.get(c); > } > } > } > ~~~ > > 3、定义一个初始化方法,使用@PostConstruct标注,在构造方法后执行。由于这个自定义类使用@Component注解,交给了spring容器进行管理,所以在服务器启动后就会完成对前缀树的初始化 > > 4、初始化方法从敏感词文件中一行一行的读取敏感词,然后调用添加敏感词的方法,将敏感词添加到前缀树中 > > ~~~ java > @PostConstruct > public void init() { > > try( //读sensitive-words.txt > InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream("sensitive-words.txt"); > //将字节流转为字符流 > InputStreamReader inputStreamReader = new InputStreamReader(inputStream); > //BufferedReader可以一行一行读,为了提升效率将字符流转为缓冲字符流 > BufferedReader reader = new BufferedReader(inputStreamReader); > ) { > String sensitiveWord; > while((sensitiveWord = reader.readLine())!=null) { > //将读到的敏感词添加到前缀树 > this.addSensitiveWord(sensitiveWord); > > } > } catch(IOException e) { > logger.error("加载敏感词文件失败" + e.getMessage()); > } > > } > ~~~ > 5、过滤敏感词 > > > 1、使用三个指针,一个tempNode指向root根节点,另外两个分别指向待过滤字符串的开头和结尾处(begin,position) > > > > 2、新建一个StringBuilder用来存放结果 > > > > 3、循环条件是开始位置和结束位置都没有到达字符串末尾 > > > > 4、依次读取字符,跳过东亚文字 > > > > 5、通过当前字符获取子节点,如果为null,说明这个字符不是敏感词中的字符,直接加入结果集StringBuilder中,然后begin和position都++ > > > > 如果子节点不为空,说明这个字符是敏感词中的某个字符。判断是否为敏感词结尾,如果不是,则直接移动position指针,position++即可。然后将前缀树指针下移一位,即tempNode=subnode。 > > > > 如果是敏感词结尾,则用字***进行替换,然后begin++,position++,并将前缀树的指针tempNode重新指向根节点root > > > > 6、最后返回结果,sb.toString(); > > > > ~~~ java > > /** > > * 过滤敏感词 > > * @param text 带过滤的文本 > > * @return 过滤后的文本 > > */ > > public String filter(String text) { > > if (StringUtils.isBlank(text)) { > > return null; > > } > > //指针1 > > TrieNode tempNode = root; > > //指针2 > > int begin = 0; > > //指针3 > > int position = 0; > > //记录结果 > > StringBuilder sb = new StringBuilder(); > > while (begin < text.length()) { > > if (position < text.length()) { > > char c = text.charAt(position); > > //跳过符号 ——赌-博-- > > if (isSymbol(c)) { > > if (tempNode == root) { > > begin++; > > sb.append(c); > > } > > position++; > > continue; > > } > > TrieNode subNode = tempNode.getSubNode(c); > > if (subNode == null) { > > sb.append(text.substring(begin,position+1)); > > begin++; > > position++; > > } else { > > if (subNode.isSensitiveWordEnd) { > > sb.append(REPLACEMENT); > > begin = position + 1; > > position = begin; > > tempNode = root; > > } else { > > position++; > > tempNode = subNode; > > } > > > > } > > } else { > > begin++; > > position = begin; > > tempNode = root; > > } > > } > > return sb.toString(); > > } > > ~~~ > > > > ### 1.5.2、发送帖子 1、通过Ajax异步请求来发送帖子 > 1、$.post > > 2、传入访问路径和访问参数,然后得到一个回调函数,通过回调函数获取到数据,然后将访问结果console.log()打印在浏览器上 ~~~ html AJAX

~~~ ~~~ js $(function(){ $("#publishBtn").click(publish); }); function publish() { $("#publishModal").modal("hide"); //获取标题和内容 var title = $("#recipient-name").val(); var content = $("#message-text").val(); //发送异步请求 $.post( CONTEXT_PATH+"/discuss/add", {"title":title,"content":content}, function (data) { data = $.parseJSON(data); //在提示框显示返回的消息 $("#hintBody").text(data.msg); //显示提示框 $("#hintModal").modal("show"); //两秒后自动隐藏 setTimeout(function(){ $("#hintModal").modal("hide"); //如果成功则刷新整个页面 if(data.code == 0) { window.location.reload(); } }, 2000); } ); } ~~~ 2、发送帖子service层代码 > 1、帖子为null,直接抛出异常 > > 2、对帖子标题和内容进行转义,防止帖子内容中含有html标签,对帖子内容造成损害 > > 3、对帖子标题和内容进行敏感词过滤 > > 4、调用mapper层,存入帖子 > > ~~~ java > public int addDiscussPost(DiscussPost discussPost) { > if(discussPost == null) { > throw new IllegalArgumentException("参数不能为空"); > } > //转义html标记,防止出现的标题或内容损害页面 > String title = discussPost.getTitle(); > title = HtmlUtils.htmlEscape(title); > String content = discussPost.getContent(); > content = HtmlUtils.htmlEscape(content); > title = filter.filter(title); > content = filter.filter(content); > discussPost.setTitle(title); > discussPost.setContent(content); > return discussPostMapper.insertDiscussPost(discussPost); > } > ~~~ 3、发送帖子controller层代码 > 1、创建帖子对象,设置当前的用户id、帖子标题和内容以及创建时间 > > 2、调用service层代码进行帖子发送 > > 3、通过json格式返回响应结果 ### 1.5.3、帖子详情 直接根据帖子id获取就可以了,将帖子id作为路径参数 ~~~ java //获取帖子详情 @RequestMapping(path = "/detail/{id}",method = RequestMethod.GET) public String selectById(@PathVariable("id")int id, Model model) { DiscussPost discussPost = discussPostService.findDiscussPostById(id); model.addAttribute("discussPost",discussPost); User user = userService.findUserById(discussPost.getUserId()); model.addAttribute("user",user); return "/site/discuss-detail"; } ~~~ ### 1.5.4、事务管理 基础概念: 1、事务:一组操作序列,这组操作,要么全部执行,要么全都不执行 2、事务的特性: > 1、ACID(原子性、一致性、隔离性、持久性) > > (1)、原子性(Atomicity):将事务看成一个不可分割的整体,要么全部执行成功,要么全部失败回滚 > > (2)、一致性(Consistency):事务的执行结果,数据从一个一致性状态转移到另一个一致性状态 > > (3)、隔离性(isolation):其他事务不能影响当前事务 > > (4)、持久性(Durability):事务一旦提交,修改就会永久保存到数据库 3、常见的并发异常 > 1、脏读:一个事务读取了另一个事务还没有提交的数据,如果这时候事务发生回滚,那么这个事务读取到的就是脏数据 > > 2、不可重复读: > > 同一个事务多次读取某个值,结果不一致。事务A读取了一个值,然后事务B对这个值进行了修改,此时事务A再次读取这个值的时候,发现这个值被修改了 > > 3、幻读 > > 对一个表的前后查询的数据行数不一致 > > 一个事务读取了某个范围内的数据,然后另一个事务在这个范围内插入了新的数据。导致事务A再次读取这个范围内的数据发现有之前没有出现过的数据,就像产生了幻觉一样,所以叫做幻读 4、事务的隔离级别以及解决了什么并发异常 > 1、读未提交,不能解决任何并发异常 > > 2、提交读,可以解决脏读问题 > > 3、可重复读,可以解决脏读和不可重复读问题 > > 4、串行化,可以解决上述所有并发异常问题 隔离级别越高,性能越低。 5、事务的传播机制 > 1、REQUIRED,加入当前事务,如果当前事务不存在,则创建新事务 > > 2、REQUIRES_NEW,创建一个新事务,并暂停当前事务 > > 3、NESTED,如果当前存在事务,则在嵌套事务中执行,否则和REQUIRED一样,开启一个事务 6、事务的两种实现方式 > 1、声明式事务控制 > > 直接在方法上使用注解@Transactional,标明事务的隔离级别和传播机制 > > ~~~ java > /** > * propagation是事务的传播机制即多个事务方法相互调用时,事务如何在这些方法间传播 > * a方法调b方法,a的事务就是当前事务 > * 常用事务传播机制的参数REQUIRED——加入当前事务,如果不存在当前事务则自己创建新事务 > * 常用事务传播机制的参数REQUIRES_NEW——创建一个新事务,并暂停当前事务 > * 常用事务传播机制的参数NESTED——如果当前存在事务,则在嵌套事务中执行,否则REQUIRED的操作一样(开启一个事务) > * @return > */ > @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED) > public String save1() { > User user = new User(); > user.setUsername("jerry"); > user.setSalt(PycommunityUtil.generateUUID().substring(0,5)); > user.setPassword(PycommunityUtil.md5("123" + user.getSalt())); > user.setCreateTime(new Date()); > user.setEmail("jerry@qq.com"); > user.setHeaderUrl("http://images.nowcoder.com/head/11t.png"); > userMapper.insertUser(user); > DiscussPost discussPost = new DiscussPost(); > discussPost.setUserId(user.getId()); > discussPost.setCreateTime(new Date()); > discussPost.setTitle("Hello"); > discussPost.setContent("新人报道"); > discussPostMapper.insertDiscussPost(discussPost); > int i = 1/0; > return "ok"; > } > ~~~ > > 2、编程式事务控制 > > > 1、使用TransactionTemplate,指定隔离级别和传播机制,然后在重写的doInTransaction方法中写方法的逻辑 > > > > ~~~ java > > @Autowired > > private TransactionTemplate template; > > public String save2() { > > template.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); > > template.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); > > return template.execute(new TransactionCallback() { > > @Override > > public String doInTransaction(TransactionStatus transactionStatus) { > > User user = new User(); > > user.setUsername("jerry"); > > user.setSalt(PycommunityUtil.generateUUID().substring(0,5)); > > user.setPassword(PycommunityUtil.md5("123" + user.getSalt())); > > user.setCreateTime(new Date()); > > user.setEmail("jerry@qq.com"); > > user.setHeaderUrl("http://images.nowcoder.com/head/11t.png"); > > userMapper.insertUser(user); > > DiscussPost discussPost = new DiscussPost(); > > discussPost.setUserId(user.getId()); > > discussPost.setCreateTime(new Date()); > > discussPost.setTitle("Hello"); > > discussPost.setContent("新人报道"); > > discussPostMapper.insertDiscussPost(discussPost); > > int i = 1/0; > > return "ok"; > > } > > }); > > } > > ~~~ ### 1.5.5、显示评论 1、评论实体的主要字段属性 > 1、user_id:评论的发布者 > > 2、entity_type:被评论实体的类型。1表示对帖子的评论,2表示对评论的评论(回复) > > 3、entity_id:entity_type中评论或者回复对应的id > > 4、target_id:回复时,对方的评论对应的用户id。只有entity_id为2时,才会有target_id > > 5、status:评论的状态,0表示正常,1表示被禁用 2、获取帖子评论,以及获取帖子评论总数,都要依靠entity_type和entity_id ~~~ java @Service public class CommentService { @Autowired private CommentMapper commentMapper; public List findCommentsByEntity(int entityType, int entityId, int offset, int limit) { return commentMapper.selectCommentsByEntity(entityType,entityId,offset,limit); } public int findCountByEntity(int entityType, int entityId) { return commentMapper.selectCountByEntity(entityType,entityId); } } ~~~ 3、通过路径参数获取帖子id 4、显示帖子评论的页面,需要先显示帖子发布作者和帖子内容。然后下面才是具体的针对这个帖子的评论或者评论的回复 > 1、通过帖子id查询到帖子,然后通过帖子中的userId查询到帖子的作者 > > 2、通过帖子实体类型和帖子id分页查询到帖子对应的评论,然后将结果放入到List中。然后新建一个List用于存放最终的结果List> > > 3、遍历每一条评论,然后判断评论是否为null,如果不为null,则将这个评论的作者和评论内容放map中 > > 根据实体回复和评论的id,查询回复。如果不为null,则将回复对应的作者以及回复的评论对应的用户存入map中 > > 4、最后返回整个大的listVo,里边包含了这条帖子的所有评论和回复以及相应的作者信息 ~~~ java //获取帖子详情 @RequestMapping(path = "/detail/{id}",method = RequestMethod.GET) public String selectById(@PathVariable("id")int id, Model model, Page page) { DiscussPost discussPost = discussPostService.findDiscussPostById(id); model.addAttribute("discussPost",discussPost); User user = userService.findUserById(discussPost.getUserId()); model.addAttribute("user",user); //设置分页信息 page.setLimit(5); page.setPath("/discuss/detail/" + id); page.setRows(discussPost.getCommentCount()); //查帖子的评论 List comments = commentService.findCommentsByEntity(ENTITY_TYPE_POST, id,page.getOffset(),page.getLimit()); List> commentVoList = new ArrayList<>(); if (comments != null) { for (Comment comment : comments) { Map commentVoMap = new HashMap<>(); commentVoMap.put("user",userService.findUserById(comment.getUserId())); commentVoMap.put("comment",comment); //查评论的评论 List replys = commentService.findCommentsByEntity(ENTITY_TYPE_COMMENT, comment.getId(), 0, Integer.MAX_VALUE); List> replyVoList = new ArrayList<>(); if (replys != null) { for (Comment reply : replys) { Map replyVoMap = new HashMap<>(); User replyUser = userService.findUserById(reply.getUserId()); User targerUser = reply.getTargetId() == 0 ? null : userService.findUserById(reply.getTargetId()); replyVoMap.put("replyUser",replyUser); replyVoMap.put("targetUser",targerUser); replyVoMap.put("reply",reply); replyVoList.add(replyVoMap); } } commentVoMap.put("replyVoList",replyVoList); //回复的数量 commentVoMap.put("replyCount",commentService.findCountByEntity(ENTITY_TYPE_COMMENT,comment.getId())); commentVoList.add(commentVoMap); } } model.addAttribute("comments",commentVoList); return "/site/discuss-detail"; } ~~~ ### 1.5.6、添加评论 1、设置隔离级别为提交读,传播机制为REQUIRED 2、对评论内容进行转移和敏感词过滤 3、如果添加的评论(EntityType为评论,而不是回复),则帖子的评论数+1,并存入数据库中 注意:帖子的评论数,这个是冗余的字段,因为评论数使用的比较频繁 ~~~ java //添加评论service层代码 @Transactional(isolation = Isolation.READ_COMMITTED,propagation = Propagation.REQUIRED) public int addComment(Comment comment) { if (comment == null) { throw new IllegalArgumentException("参数不能为空"); } //转义html标签 comment.setContent(HtmlUtils.htmlEscape(comment.getContent())); comment.setContent(sensitiveFilter.filter(comment.getContent())); int i = commentMapper.insertComment(comment); if (comment.getEntityType() == ENTITY_TYPE_POST) { int count = commentMapper.selectCountByEntity(ENTITY_TYPE_POST, comment.getEntityId()); discussPostService.updateCount(comment.getEntityId(), count); } return i; } ~~~ ~~~ java //添加评论,controller层代码 @RequestMapping(path = "/add/{discussId}",method = RequestMethod.POST) public String addComment(@PathVariable("discussId") int discussId, Comment comment) { comment.setCreateTime(new Date()); comment.setUserId(hostHolder.getUser().getId()); comment.setStatus(0); commentService.addComment(comment); return "redirect:/discuss/detail/" + discussId; } ~~~ ### 1.5.7、显示私信列表和私信详情 1、消息实体的核心字段 > 1、from_id:消息发送方用户id,1表示系统通知 > > 2、to_id:消息接收方用户id > > 3、status:消息的状态,0表示未读,1表示已读,2表示已经删除 > > 4、conversationId:回话id,冗余字段,方便查询两个用户之间的对话。有from_id和to_id组成。 > > 小的在前面,大的在后面,如:111_222 2、几个核心方法 > ~~~ java > //所有方法,默认过滤掉被删除的帖子 > @Mapper > public interface MessageMapper { > //分页查询,与其他不同用户的对话列表,每个用户都只显示最新一条的消息 > List selectMessages(int userId, int offset, int limit); > //查询当前用户与多少个不同的用户对话 > int selectMessageCount(int userId); > //分页查询某两个用户之间的所有对话消息 > List selectConversation(String conversationId, int offset, int limit); > //查询有多少个不同的用户组在进行对话 > int selectConversationCount(String conversationId); > //当前用户与某个用户对话,总共有多少条未读消息 > int selectUnRead(int userId, String conversationId); > } > ~~~ 3、上述方法对应的sql > 1、//分页查询,与其他不同用户的对话列表,每个用户都只显示最新一条的消息 > List selectMessages(int userId, int offset, int limit); > > > 1、通过子查询,根据conversationId分组,把被删除的消息和系统通知过滤掉。这一步把所有的不同的用户组(两个用户)的对话信息的id查询到了,并且使用max(id),也就是只去其中最新的一个 > > > > 2、然后继续使用where条件筛选,当前用户的对话信息。from_id=#{userId} or to_id=#{userId} > > > > 3、然后根据id倒序,order by id desc > > > > 4、分页查询limit #{offset}, #{limit} > > sql语句: > > ~~~ sql > > ~~~ > > 2、查询与多少个不同的用户对话 > > > 1、子查询,先查询出符合条件的id,然后在使用count进行计数 > > > > 2、子查询里边,根据conversationId分组,也就是每个不同的用户一组,然后过滤掉被删除的用户和系统通知,在使用发送方或者接收方等于当前用户id筛选出当前用户的对话,然后使用max(id)取分组中的其中一个 > > ~~~ sql > > ~~~ > > 3、分页查询某两个用户之间的所有对话消息 > > > 过滤掉被删除消息和系统通知,直接按照conversationId查询,并按id逆序(其实就是时间逆序)即可 > > ~~~ sql > > ~~~ > > 4、查询有多少个不同的用户组在进行对话 > > > 直接过滤掉被删除消息以及系统通知,然后使用conversationId进行筛选,最后对id进行count计数 > > 5、当前用户,总共有多少条未读消息 > > > 直接过滤掉被删除消息以及系统通知,然后筛选接收方是当前用户,以及conversationId为指定的conversationId > > > > 以及读取状态status为0 > > ~~~ sql > > ~~~ 4、私信列表功能 > 1、需要显示与不同用户对话的最新消息、对方的用户名,与这个用户一共有多少条对话,与这个用户的对话有多少条未读消息 > > 2、先通过当前用户id查询到与其他不同用户的所有对话,然后遍历每一条对话,将与这个用户总的对话数,与这个用户的未读消息数存入结果Map中,每一条对话都对应一个Map。最终结果是通过List>来存储的 ~~~ java //私信列表 @RequestMapping(path = "/message/list",method = RequestMethod.GET) public String getMessageList(Model model, Page page) { //分页信息 User user = hostHolder.getUser(); page.setLimit(5); page.setPath("/message/list"); page.setRows(messageService.findMessageCount(user.getId())); //私信列表 List messageList = messageService.findMessages(user.getId(), page.getOffset(), page.getLimit()); List> messages = new ArrayList<>(); if (messageList != null) { for (Message message : messageList) { Map map = new HashMap<>(); map.put("message",message); //共几条会话 map.put("conversationCount",messageService.findConversationCount(message.getConversationId())); //显示与当前用户相对的用户的信息 int friendId = user.getId() == message.getToId() ? message.getFromId() : message.getToId(); User friend = userService.findUserById(friendId); map.put("friend", friend); //显示未读朋友私信的数量 int unRead = messageService.findUnRead(user.getId(), message.getConversationId()); map.put("unreadCount",unRead); messages.add(map); } } model.addAttribute("messages",messages); //共有多少未读消息 model.addAttribute("unread",messageService.findUnRead(user.getId(), null)); return "/site/letter"; } ~~~ ### 1.5.8、发送私信 使用ajax异步请求发送私信 1、发送私信service层代码逻辑 > 对发送的消息进行html过滤和敏感词过滤,然后将私信保存到数据库中 2、发送私信controller层代码 > 1、私信接收方为自己或者为空,都抛出异常 > > 2、设置私信的各个字段,然后返回json提示消息 ### 1.5.9、获取私信详情 1、获取私信详情列表,然后遍历列表,同时将未读消息的id放入集合中。自定义一个方法将这些未读消息的状态改为已读 ~~~ java //私信详情 @RequestMapping(path = "/message/detail/{conversationId}",method = RequestMethod.GET) public String getMessageDetail(@PathVariable("conversationId")String conversationId, Page page, Model model) { page.setLimit(5); page.setPath("/message/detail/" + conversationId); page.setRows(messageService.findConversationCount(conversationId)); List conversationList = messageService.findConversations(conversationId, page.getOffset(), page.getLimit()); List> conversations = new ArrayList<>(); if (conversationList != null) { for (Message conversation : conversationList) { Map map = new HashMap<>(); map.put("conversation",conversation); //显示消息发出者的信息 User user = userService.findUserById(conversation.getFromId()); map.put("user",user); conversations.add(map); } } //显示发信人信息 // 如果当前用户是发送者,则私信者是接收者 int id = hostHolder.getUser().getId() == conversationList.get(0).getToId() ? conversationList.get(0).getFromId() : conversationList.get(0).getToId(); model.addAttribute("conversations",conversations); model.addAttribute("from",userService.findUserById(id)); List ids = findUnreadIds(conversationList); if (!ids.isEmpty()) { messageService.updateReadStatus(ids); } return "/site/letter-detail"; } public List findUnreadIds(List conversationList) { List ids = new ArrayList<>(); if (!conversationList.isEmpty()) { for (Message message : conversationList) { if (message.getStatus() == 0 && hostHolder.getUser().getId() == message.getToId()) { ids.add(message.getId()); } } } return ids; } ~~~ ### 1.5.10、删除私信 异步请求,删除后,返回json格式数据 ### 1.5.11、统一处理异常 1、所有的异常都往上抛,最终汇聚到表现层(controller),进行统一的异常处理 2、@ControllerAdvice:用于修饰类,表示这个类是controller的全局配置类,这样,无论哪个controller中的方法报错,都可以进行统一的异常处理(annotations=Controller.class表示只处理controller层的异常) 3、使用@ExceptionHanlder自定义一个处理异常的方法,Exception.class,表示处理任何类型的异常 处理异常的方法: > 1、通过Excption的getMessage获取异常的概述信息,并记录异常日志 > > 2、通过Exception的getStackTrace()获取异常的详细信息,然后遍历这个异常信息数组,依次记录异常信息日志。 > > 3、通过请求头里边的一个XMLHttpRequest标志判断是异步请求还是同步请求,如果是异步请求,就通过response对象获取的PringWriter写入json格式的错误提示信息 > > 4、如果是同步请求,就直接跳转到自定义的error界面 ~~~ java //只扫描标了@Controller注解的bean @ControllerAdvice(annotations = Controller.class) public class ExceptionAdvice { private static final Logger logger = LoggerFactory.getLogger(ExceptionAdvice.class); //表明可以处理exception类型的异常 @ExceptionHandler(Exception.class) public void handleException(Exception e, HttpServletRequest request, HttpServletResponse response) throws IOException { logger.error("服务器发生异常:" + e.getMessage());//异常的概况信息 StackTraceElement[] stackTrace = e.getStackTrace();//异常的详细信息 for (StackTraceElement element : stackTrace) { logger.error(element.toString()); } //判断浏览器访问服务器的请求,可能是普通请求希望返回页面,可能是异步请求希望返回json String xRequestedWith = request.getHeader("x-requested-with"); if ("XMLHttpRequest".equals(xRequestedWith)) {//说明是异步请求 //application/plain向浏览器返回的是一个字符串,要变成json格式需要$.parseJson进行手动转换 //application/json向浏览器返回的是一个字符串,浏览器会自动把它转为json字符串 response.setContentType("application/plain;charset=utf-8"); PrintWriter writer = response.getWriter(); writer.write(PycommunityUtil.getJSONString(1,"服务器异常")); } else { response.sendRedirect(request.getContextPath() + "/error"); } } } ~~~ ### 1.5.12、统一记录日志 1、前置知识:AOP的写法 > 1、使用@Pointcut定义一个切入点,也就是需要被增强的方法 > > 2、使用@Before或者@After或者@AfterReturning或者@AfterThrowing或者@Around对切入点方法进行增强 > > 3、@Around需要一个ProcedingJoinpoint形参,通过该对象的proceed执行切入点方法 注意:切面类需要同时使用@Aspect和@Component ~~~ java @Component @Aspect public class AlphaAspect { //切点是返回值为任意类型,com.py.pycommunity.service包下的所有类的所有方法,方法参数任意 @Pointcut("execution(* com.py.pycommunity.service.*.*(..))") public void pointCut() { } @Before("pointCut()") public void before() { System.out.println("切点的开始..."); } @After("pointCut()") public void after() { System.out.println("切点的结束..."); } @AfterReturning("pointCut()") public void afterReturning() { System.out.println("切点返回值以后..."); } @AfterThrowing("pointCut()") public void afterThrowing() { System.out.println("抛出异常后..."); } //环绕切点增强 @Around("pointCut()") public Object around(ProceedingJoinPoint point) throws Throwable { System.out.println("切点环绕前"); Object proceed = point.proceed();//切点执行 System.out.println("切点环绕后"); return proceed; } } ~~~ 2、使用AOP记录日志 > 1、通过HttpservletRequest对象获取访问者的ip地址 > > 2、记录当前访问的时间 > > 3、通过joinPoint参数获取切入点对应的类名,以及方法名 > > 4、最后将上述内容记录到日志中 ## 1.6、redis实现高性能数据存取 ### 1.6.1、redis入门知识 1、概述 > 1、redis是一款基于键值对的非关系型数据库,它的键是字符串,值支持多种类型:字符串、哈希表、列表、集合、有序集合等 > > 2、主要应用场景:缓存、排行榜、计数器、消息队列 > > 3、redis默认有16个库 2、常用数据结构 > 1、String > > > 设置set,获取get,增incr,减decr > > 2、哈希表 > > > 设置hset,获取hget > > 3、列表 > > > 1、左边存或者右边存:lpush, rpush > > > > 2、左边取或者右边弹出:lpop,rpop > > > > 3、取索引位置的值:lindex > > > > 4、取指定索引范围内的值(左闭右闭):lrange 列表名 index1 index2 > > > > ![image-20230906093407162](README.assets/image-20230906093407162.png) > > 4、集合 > > > 1、无序,且值不能重复 > > > > 2、添加元素:sadd > > > > 3、随机弹出元素(可用于抽奖):spop > > > > 4、查看集合中所有元素:smembers > > > > 5、 查看集合中元素个数:scard > > 5、有序集合 > > > 1、按照分数由小到大排列 > > > > 2、添加元素:zadd > > > > 3、查看集合元素个数:zcard > > > > 4、查看集合元素的分数:zscore > > > > 5、查看集合中指定范围内的元素(左闭右闭):zrange > > 6、其他指令 > > > 1、查看所有的key:key* > > > > 2、查看以test开头的key:key test* > > > > 3、查看key的类型:type 键名 > > > > 4、判断某个key是否存在:exists 键名 > > > > 5、删除某个key:del key > > > > 6、为key设置过期时间,单位为秒:expire 键名 秒数 > > > > 7、redis变量命名,单词之间用:连接 > > > > 8、启动redis客户端:redis-cli > > > > 9、删除所有数据库:flushdb ### 1.6.2、Spring整合redis 1、导入redis依赖:spring-boot-starter-data-redis 2、配置redis > 1、指定使用16个库中的哪一个库:spring.redis.database=11 > > 2、指定redis服务器的ip:spring.redis.host=localhost > > 3、指定redis服务器连接的端口号:spring.redis.port=6379 3、自定义RedisTemplate,交给spring容器管理 > 1、设置工厂 > > 2、设置key的序列化方式为String,value的方式为json > > 3、hash的key序列化方式为String,hash的value序列化方式为json ~~~ java @Configuration public class RedisConfig { @Bean public RedisTemplate redisTemplate(RedisConnectionFactory factory) { RedisTemplate template = new RedisTemplate<>(); //给template设置工厂,这样才有访问数据库的能力 template.setConnectionFactory(factory); //设置key的序列化,序列化就是数据转换的方式,将java类型的数据转换成可以存入redis数据库中的数据 //RedisSerializer.string()把key序列化为字符串 template.setKeySerializer(RedisSerializer.string()); //设置value的序列化 //把value序列化为json格式 template.setValueSerializer(RedisSerializer.json()); //哈希比较特殊,要单独设置哈希的key的序列化 template.setHashKeySerializer(RedisSerializer.string()); //哈希比较特殊,要单独设置哈希的value的序列化 template.setHashValueSerializer(RedisSerializer.json()); //让以上设置生效 template.afterPropertiesSet(); return template; } } ~~~ 4、访问redis > 注入RedisTemplate即可访问redis > > ~~~ java > @RunWith(SpringRunner.class) > @SpringBootTest > public class RedisTest { > @Autowired > private RedisTemplate redisTemplate; > > @Test > public void testString() { > String key = "test:count"; > //设置string > redisTemplate.opsForValue().set(key,1); > //获取string > System.out.println(redisTemplate.opsForValue().get(key)); > System.out.println(redisTemplate.opsForValue().increment(key)); > System.out.println(redisTemplate.opsForValue().decrement(key)); > } > @Test > public void testList() { > String key = "ids"; > redisTemplate.opsForList().leftPush(key,1); > redisTemplate.opsForList().leftPush(key,2); > redisTemplate.opsForList().leftPush(key,3); > > System.out.println(redisTemplate.opsForList().size(key)); > System.out.println(redisTemplate.opsForList().index(key,0)); > System.out.println(redisTemplate.opsForList().range(key,0,2)); > System.out.println(redisTemplate.opsForList().leftPop(key)); > } > @Test > public void testSet() { > String key = "ids"; > redisTemplate.opsForSet().add(key,"李白","杜甫","苏轼"); > System.out.println(redisTemplate.opsForSet().members(key)); > System.out.println(redisTemplate.opsForSet().pop(key)); > System.out.println(redisTemplate.opsForSet().size(key)); > } > > @Test > public void testSortedSet() { > String key = "test:students"; > redisTemplate.opsForZSet().add(key,"aaa",10); > redisTemplate.opsForZSet().add(key,"bb",20); > redisTemplate.opsForZSet().add(key,"c",30); > > //共有多少数据 > System.out.println(redisTemplate.opsForZSet().zCard(key)); > System.out.println(redisTemplate.opsForZSet().score(key,"aaa")); > //由小到大的排序 > System.out.println(redisTemplate.opsForZSet().rank(key,"c")); > //由大到小的排序 > System.out.println(redisTemplate.opsForZSet().reverseRank(key,"c")); > System.out.println(redisTemplate.opsForZSet().range(key,0,2)); > } > > @Test > public void testKeys() { > redisTemplate.delete("test:students"); > System.out.println(redisTemplate.hasKey("test:students")); > redisTemplate.expire("ids",10, TimeUnit.SECONDS); > } > > //前面每次操作都要传入对应的key,有点麻烦,可以将key进行绑定 > @Test > public void testBound() { > String key = "test:count"; > BoundValueOperations valueOps = redisTemplate.boundValueOps(key); > System.out.println(valueOps.get()); > } > } > ~~~ ### 1.6.3、点赞功能 1、补充: > 1、只有关系型数据库才会保持事务的四大特性,redis不完全满足这四大特性。redis启用事务后,不会立即去执行redis的命令,而是会把命令放到一个队列里,直到提交事务时,才会统一执行队列中的所有命令,这就导致不能在事务的过程中查询,查询出来的值是空值。 > > 2、spring可以对redis进行声明式事务或者编程式事务控制,一般使用的是对redis的编程式事务控制 > > > 编程式事务:传入一个SessionCallback,重写execute方法 > > > > ~~~ java > > @Test > > public void testTransactional() { > > Object obj = redisTemplate.execute(new SessionCallback() { > > @Override > > public Object execute(RedisOperations redisOperations) throws DataAccessException { > > String key = "test:count"; > > redisOperations.multi();//开启事务 > > redisOperations.opsForValue().increment(key); > > return redisOperations.exec();//提交事务 > > } > > }); > > } > > ~~~ 2、点赞功能实现 > 1、可以对帖子或者评论点赞,所以需要定义key。key由自定义前缀like:entity,加上entityType和entityId > > 2、为了后续便于查看是哪些用户点的赞,使用set集合来存储点赞者的用户id > > 3、点赞的业务逻辑: > > > 通过isMember判断是否包含用户id,如果包含则已经点过赞了。如果已经点过赞了,就将这个用户id移除,如果没有点赞,就将这个用户id加入到set集合中 > > > > ~~~ java > > /** > > * 某人如果点了赞,则把该用户的id放进集合 > > * @param userId > > * @param entityType > > * @param entityId > > */ > > public void like(int userId, int entityType, int entityId) { > > String likeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId); > > BoundSetOperations ops = redisTemplate.boundSetOps(likeKey); > > if (ops.isMember(userId)) { > > ops.remove(userId); > > } else { > > ops.add(userId); > > } > > } > > ~~~ > 3、查询帖子或者评论的点赞数量、点赞状态 > > ~~~ java > /** > * 查询点赞数量 > * @param entityType > * @param entityId > * @return > */ > public long findLikeCount(int entityType, int entityId) { > String likeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId); > return redisTemplate.opsForSet().size(likeKey); > } > > /** > * 查询某用户是否对某帖子点了赞 > * @param userId > * @param entityType > * @param entityId > * @return 1表示已赞,0表示未赞 返回值是int方便以后扩展,如增加点踩功能 > */ > public int findLikeStatus(int userId, int entityType, int entityId) { > String likeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId); > return redisTemplate.opsForSet().isMember(likeKey,userId) ? 1 : 0; > } > ~~~ ### 1.6.4、我收到的赞 1、使用String来存储,通过increment和decrement方法对点赞数进行自增或者自减 2、通过前缀和用户id拼接得到用户id 3、重构like方法,增加对用户收到的赞的数量的更新。由于同时需要查询数据和更新数据,需要使用事务,将先查询用户是否已经点过赞,然后对实体被点赞的更新和目标用户收到赞数量的更新 ~~~ java /** * 某人如果点了赞,则把点赞者的id放进集合,并把被点赞者的点赞数量+1 * 因为执行了两次redis操作,所以要用事务管理 * @param userId 点赞的用户 * @param entityType * @param entityId * @param entityUserId 帖子或评论的发出者即被点赞的用户 */ public void like(int userId, int entityType, int entityId, int entityUserId) { String likeEntityKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId); String likePersonKey = RedisKeyUtil.getPersonLikeKey(entityUserId); //查询操作不能放到事务中 Boolean isMember = redisTemplate.opsForSet().isMember(likeEntityKey,userId); redisTemplate.multi(); redisTemplate.execute(new SessionCallback() { @Override public Object execute(RedisOperations redisOperations) throws DataAccessException { if (isMember) { redisOperations.opsForSet().remove(likeEntityKey,userId); redisOperations.opsForValue().decrement(likePersonKey); } else { redisOperations.opsForSet().add(likeEntityKey,userId); redisOperations.opsForValue().increment(likePersonKey); } return redisOperations.exec(); } }); } ~~~ ### 1.6.5、关注和取消关注 1、分为用户关注的实体、实体拥有的粉丝两个维度。与之相关的有三个字段:用户id,实体类型,实体id。 2、用户关注的实体,需要存放实体的id,所以拼接键的时候使用的是前缀加上用户id和实体类型。 实体拥有的粉丝,需要存放用户id,所以拼接键的时候使用的是前缀加上实体类型和实体id ~~~ java /** * 某个用户关注的实体 * @param userId * @param entityType * @return * followee:userId:entityType ----> Zset(entityId,time) */ public static String getFolloweeKey(int userId, int entityType) { return PREFIX_FOLLOWEE + SPILT + userId + SPILT + entityType; } /** * 某个实体拥有的粉丝 * @param entityId * @param entityType * @return * follower:entityType:entityId----->Zset(userId,time) */ public static String getFollowerKey(int entityId, int entityType) { return PREFIX_FOLLOWER + SPILT + entityType + SPILT + entityId; } ~~~ 3、关注、取关的controller层代码 ~~~ java //关注,异步请求 @RequestMapping(path = "/follow",method = RequestMethod.POST) @ResponseBody public String follow(int entityType, int entityId) { followService.follow(hostHolder.getUser().getId(),entityType,entityId); return PycommunityUtil.getJSONString(0,"已关注"); } //取关 @RequestMapping(path = "/unfollow",method = RequestMethod.POST) @ResponseBody public String unfollow(int entityType, int entityId) { followService.unfollow(hostHolder.getUser().getId(),entityType,entityId); return PycommunityUtil.getJSONString(0,"已取关"); } ~~~ ### 1.6.6、关注列表和粉丝列表显示 先拼接key,然后通过key从redis中拿到相应的id数据,遍历id拿到对应的数据 ~~~ java //查询某个用户关注的人 public List> findFollowees(int userId, int offset, int limit) { String followeeKey = RedisKeyUtil.getFolloweeKey(userId, ENTITY_TYPE_USER); Set targetIds = redisTemplate.opsForZSet().reverseRange(followeeKey, offset, offset + limit - 1); if (targetIds == null) return null; List> list = new ArrayList<>(); for (Integer id : targetIds) { Map map = new HashMap<>(); User user = userService.findUserById(id); map.put("user",user); Double score = redisTemplate.opsForZSet().score(followeeKey, id); Date followDate = new Date(score.longValue()); map.put("followDate",followDate); list.add(map); } return list; } //查询某个用户的粉丝 public List> findFollowers(int entityId, int offset, int limit) { String followerKey = RedisKeyUtil.getFollowerKey(entityId, ENTITY_TYPE_USER); Set ids = redisTemplate.opsForZSet().reverseRange(followerKey, offset, offset + limit - 1); if (ids == null) return null; List> list = new ArrayList<>(); for (Integer id : ids) { Map map = new HashMap<>(); User user = userService.findUserById(id); map.put("user",user); Double score = redisTemplate.opsForZSet().score(followerKey, id); Date followDate = new Date(score.longValue()); map.put("followDate",followDate); list.add(map); } return list; } ~~~ ### 1.6.7、使用redis优化登录模块 使用redis存放验证码,登录凭证,以及用户信息 1、存放验证码 > 1、先生成一个随机字符串,作为验证码的凭证,然后将这个凭证存入cookie(cookie需要设置有效时长60秒,cookie的作用路径)。redis中使用前缀和这个凭证拼接得到key,然后用这个key将验证码凭证存入redis中 ~~~ java /** * 原来是将验证码存在了session中,现在重构代码将验证码存在redis中 * @param session * @param response */ @RequestMapping(path = "/kaptcha", method = RequestMethod.GET) public void getKaptcha(HttpSession session, HttpServletResponse response) { //生成验证码和图片 String text = kaptchaProducer.createText(); BufferedImage image = kaptchaProducer.createImage(text); //生成验证码的归属者并存在cookie中发送给用户 String kaptchaOwner = PycommunityUtil.generateUUID(); Cookie cookie = new Cookie("kaptchaOwner", kaptchaOwner); cookie.setMaxAge(60);//cookie生存时间60秒 cookie.setPath(contextPath);//cookie的有效范围 response.addCookie(cookie); //将验证码存入redis String kaptchaKey = RedisKeyUtil.getKaptchaKey(kaptchaOwner); redisTemplate.opsForValue().set(kaptchaKey,text,60, TimeUnit.SECONDS);//60秒后失效 //把图片输出给浏览器 response.setContentType("image/png"); try { ServletOutputStream outputStream = response.getOutputStream(); ImageIO.write(image,"png",outputStream); } catch (IOException e) { logger.error("响应验证码失败" + e.getMessage()); } } ~~~ 登录时,从cookie中获取到验证码的凭证,然后通过凭证从redis中获取验证码的值,然后就可以和用户输入的验证码的值进行比较了 ~~~ java /*** * * @param username * @param password * @param code 网页输入的验证码 * @param rememberMe 勾选记住我保存时间久一点 * @param model * @param kaptchaOwner 从cookie中取kaptchaOwner对应的值 * @param response 登录成功了把生成的ticket存到cookie中 * @return */ @RequestMapping(path = "/login", method = RequestMethod.POST) public String login(String username, String password, String code, boolean rememberMe, Model model,HttpServletResponse response, @CookieValue("kaptchaOwner") String kaptchaOwner) { //springmvc会把实体参数自动存进model,但是字符串和基本类型不会自动存,字符串和基本类型存在于request域 //检查验证码 //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) || !code.equalsIgnoreCase(kaptcha)) { 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")) { String ticket = (String) map.get("ticket"); Cookie cookie = new Cookie("ticket",ticket); 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"; } } ~~~ 2、使用redis存储登录凭证 使用随机字符串作为登录凭证的标识放到cookie中,然后将这个随机字符串和一个前缀拼接作为redis的key,将用户登录的凭证放到redis的这个key中 3、使用redis存储用户信息 将登录后的用户信息序列化后存放到redis中,重构findUserById的方法 策略: > 1、优先从缓存中获取用户,缓存中获取不到则更新缓存。如果用户数据发生变更,则清除缓存。 ## 1.7、kafka实现异步消息队列 ### 1.7.1、阻塞队列介绍 生产者生产消息,将消息放入队列中。消费者从队列中取消息进行消费 ### 1.7.2、kafka前置知识 1、kafka特点: > 1、kafka具有高吞吐量的特征,可以处理TB级别的数据 > > 2、kafka对数据的读写是对硬盘的顺序读写,比对内存的随机读写性能高 > > 3、高可靠性,可以做集群部署,服务器不够时,可以在加 > > 4、kafka使用的是发布订阅模式 2、kafka的术语: > 1、Broker:kafak集群中的每一台服务器都称为一个Broker > > 2、Zookeeper:管理集群的软件 > > 3、Topic:消息的类别,主题 > > 4、partition:对Topic的分区,可以提高并发能力 > > 5、offset:消息在分区中存放的索引 > > 6、主从副本 > > > 1、主副本:用于对消费者做出响应 > > > > 2、从副本:对主副本的备份,提高容错率 > > > > 主副本挂了,会从从副本中选一个作为主副本 3、启动zookeeper和kafka > 1、启动zookeeper:使用配置文件来启动zookeeper > > ~~~ bat > bin\windows\zookeeper-server-start.bat config\zookeeper.properties > ~~~ > > 2、启动kafka:使用配置文件启动kafka > > ~~~ bat > bin\windows\kafka-server-start.bat config\server.properties > ~~~ ### 1.7.3、spring整合kafka 1、导入依赖:spring-kafka 2、配置kafka ~~~ properties #kafka配置,是针对KafkaProperties类做配置 spring.kafka.bootstrap-servers=localhost:9092 #在consumer.properties中找 spring.kafka.consumer.group-id=test-consumer-group #是否自动提交消费者的偏移量offset spring.kafka.consumer.enable-auto-commit=true #每隔3000ms自动提交一次偏移量 spring.kafka.consumer.auto-commit-interval=3000 ~~~ 3、注入KafkaTemplate > 1、消费者类中的方法通过@KafkaListener(topics="test")作为消费者,方法中传入一个ComsumerRecord获取消息对象,监听主题是否有新的消息,有消息就会消费 > > ~~~ java > @Component > class KafkaConsumer { > //服务一启动,spring就会自动开启一个线程作为消费者以阻塞的状态试图去读取test主题下的消息 > @KafkaListener(topics = {"test"}) > public void handleMessage(ConsumerRecord record) { > System.out.println(record.value()); > } > } > ~~~ > > 2、生成者通过注入KafkaTemplate,调用send方法传入主题和消息 > > ~~~ java > @Component > class KafkaProducer { > @Autowired > private KafkaTemplate kafkaTemplate; > > public void sendMessage(String topic, String content) { > kafkaTemplate.send(topic,content); > } > } > ~~~ ### 1.7.4、发送系统通知 1、创建事件对象 > 1、事件的主题 > > 2、触发该事件的用户id > > 3、事件所属的实体类型 > > 4、事件所属的实体id > > 5、事件所属实体对应的用户id > > 6、额外的数据,使用Map存储 然后改造set方法,返回this,这样就可以链式调用来设置值了 ~~~ java import java.util.HashMap; import java.util.Map; public class Event { //事件类型 点赞 评论 关注 private String topic; //触发事件的人 private int userId; //事件发生在哪个实体 事件是评论帖子 评论评论 点赞帖子 点赞评论 关注用户,因此实体可以是帖子评论用户 private int entityType; private int entityId; //实体的作者 实体有帖子 评论 用户,用户实体的作者就是自己 private int entityUserId; //额外的数据,方便以后扩展 private Map data = new HashMap<>(); //setter返回值设置为event,方便连用setter方法 //如 event.setTopic("test").setUserId(1) public String getTopic() { return topic; } 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 getData() { return data; } public Event setData(String key, Object value) { this.data.put(key,value); return this; } } ~~~ 2、新建一个event包,里边放生产者类和消费者类 > 1、生产者 > > > 1、注入KafkaTemplate,通过send方法来发送数据 > > > > 2、传入一个事件对象Event,将Event对象使用json格式序列化之后作为消息发送 > > 2、消费者 > > > 1、方法上使用@KafkaListener监听主题(评论、关注、点赞) > > > > 2、通过Message对象来记录消息各个属性:from_id,to_id,创建时间等 > > > > message的内容就是entityType,entityId,userId等数据存入map中后序列化的内容 3、点赞、关注、评论都需要增加一个生产Event的操作 ### 1.7.5、显示系统通知 ## 1.8、Elasticsearch实现全局搜索 ### 1.8.1、Elasticsearch前置知识 1、ES的特点 > 1、支持分布式,可以多台服务器集群部署 > > 2、搜索速度快,可以提供实时的搜索服务,每秒可以处理PB级别的数据 > > 3、ES的原理是把数据在ES中存储一份,然后通过对数据分词。最终实现搜索的功能 2、ES中的术语 > 1、索引:相当于mysql中的一个数据库(ES6.0之后,索引相当于一个表) > > 2、类型:一个类型相当于mysql中的一个表(ES6.0之后,废弃了类型这个概念) > > 3、文档:相当于mysql表中的一行数据 > > 4、字段:相当于mysql中的一列 > > 5、集群:多台ES服务器组成一个集群 > > 6、节点:集群中的每一个服务器都是一个节点 > > 7、分片:对索引的划分,一个索引可以被划分成多个分片存储 > > 8、副本:对分片的备份,一个分片包含多个副本 ### 1.8.2、spring整合Elasticsearch 1、导包:spring-boot-starter-data-elasticsearch 2、配置ES > 1、配置集群的名字, > > spring.data.elasticsearch.cluster-name=nowcoder > > 2、配置节点。http:9200,tcp:9300 3、解决netty冲突(es是基于netty的,redis底层也是基于netty的,两者同时启动会产生冲突,因此需要解决冲突) ~~~ java @SpringBootApplication //PycommunityApplication是最先被加载的 public class PycommunityApplication { @PostConstruct public void init() { //解决Netty启动冲突的问题 System.setProperty("es.set.netty.runtime.available.processors", "false"); public static void main(String[] args) { SpringApplication.run(PycommunityApplication.class, args); } } ~~~ 4、对需要存储到ES中的实体类进行一些设置 > 1、为类设置索引、类型、分片、副本 > > 2、为类的字段设置字段类型 > > ~~~ java > //spring整合的es底层在访问es服务器时会自动将实体数据和es服务器里面的索引进行映射,没有索引会自动创建索引 > //indexName索引 type类型 shards分片 relpicas副本 > @Document(indexName = "discusspost", type = "_doc", shards = 3, replicas = 2) > public class DiscussPost { > //会把主键对应的数据存到id的字段上 > @Id > private int id; > @Field(type = FieldType.Integer) > private int userId; > //由于搜帖子主要搜的是title和content所以做以下配置 > //analyzer是存储时的解析器,searchAnalyzer是搜索时的解析器 > //ik_max_word是尽可能多的拆分增加搜索范围 ik_smart是拆分出尽可能少的满足需求的 > @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; > //0是普通帖,1是置顶帖 > @Field(type = FieldType.Integer) > private int type; > //0是正常,1是精华,2是拉黑 > @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; > } > ~~~ > > 5、将对象存储到ES中 > 1、使用@Repository自定义接口,实现ElasticsearchRepository接口 > > ~~~ java > //es可以看作特殊的数据库 > //@Mapper是mybatis专有的注解 > //ElasticsearchRepository是声明接口要处理的实体类为Discusspost,实体类的主键类型是Integer > @Repository > public interface DiscussPostRepository extends ElasticsearchRepository { > } > ~~~ > > 2、在需要使用ES的地方,注入这个自定义接口,然后调用相应的方法 > > > save,保存、修改 > > > > deleteById > > 3、使用ES搜索,并高亮显示 > > 通过SearchQuery来实现,可以通过关键词筛选,指定排序字段,指定高亮显示的字段,为该字段添加标签。然后通过css选中这个标签进行高亮显示 > > ~~~ java > //搜索,用discussRepository搜索,无法将高亮数据自动整合 > @Test > public void testSearchByRepository() { > //构造搜索条件,关键词、分页、排序方式、高亮显示 > //es让搜索到的关键词高亮的方式是在关键词外套一层标签,之后想要真正高亮需要在css文件自定义标签对应的样式 > SearchQuery searchQuery = new NativeSearchQueryBuilder() > //搜索题目或内容中包含互联网寒冬相关的 > .withQuery(QueryBuilders.multiMatchQuery("互联网寒冬","title", "content")) > //排序条件 优先看type置顶的优先,再看score,分高的优先,再看createtime,最新的优先 > .withSort(SortBuilders.fieldSort("type").order(SortOrder.DESC)) > .withSort(SortBuilders.fieldSort("score").order(SortOrder.DESC)) > .withSort(SortBuilders.fieldSort("createTime").order(SortOrder.DESC)) > //分页,从第一页开始,显示十条 > .withPageable(PageRequest.of(0,10)) > //指定哪些字段高亮显示,以及高亮的标签 > .withHighlightFields( > new HighlightBuilder.Field("title").preTags("").postTags(""), > new HighlightBuilder.Field("content").postTags("").postTags("") > ).build(); > //discussRepository.search底层调用了elasticsearchTemplate.query(searchQuery,class,SearchResultMapper) > //底层获取到了高亮显示的值,但是没做进一步处理 > //要手动将需要高亮的数据整合到查出来的数据中,比较麻烦 > Page discussPosts = discussRepository.search(searchQuery); > System.out.println("一共查到多少条数据" + discussPosts.getTotalElements()); > System.out.println("一共查到多少页" + discussPosts.getTotalPages()); > System.out.println("当前是第几页" + discussPosts.getNumber()); > System.out.println("每页显示多少数据" + discussPosts.getSize()); > for(DiscussPost post : discussPosts) { > System.out.println(post); > } > } > ~~~ ### 1.8.3、开发全局搜索功能 ## 1.9、SpringSecurity实现项目安全保证和权限控制 ### 1.9.1、权限控制 SpringSecurity概述: > 1、为应用程序提供身份认证和授权 > > 2、防止各种攻击 权限控制 > 1、在User实体类中配置权限 > > ~~~ java > public class User implements UserDetails { > > private int id; > private String username; > private String password; > private String salt; > private String email; > private int type; > private int status; > private String activationCode; > private String headerUrl; > private Date createTime; > //返回true是账号未过期 > @Override > public boolean isAccountNonExpired() { > return true; > } > //返回true是账号未锁定 > @Override > public boolean isAccountNonLocked() { > return true; > } > //返回true是凭证未过期 > @Override > public boolean isCredentialsNonExpired() { > return true; > } > //返回true是账号可用 > @Override > public boolean isEnabled() { > return true; > } > //返回用户具有的权限 > @Override > public Collection getAuthorities() { > List list = new ArrayList<>(); > list.add(new GrantedAuthority() { > @Override > public String getAuthority() { > switch (type){ > case 1: > return "Admain";//管理员 > default: > return "User";//普通用户 > } > } > }); > return list; > } > } > ~~~ > > 2、在userService上实现UserDetailService(SpringSecurity的底层接口) > > ~~~ java > @Service > public class UserService implements UserDetailsService { > > @Autowired > private UserMapper userMapper; > > public User findUserByName(String username) { > return userMapper.selectByName(username); > } > //根据用户名查用户,springsecurity底层在检查登录情况时要用,逻辑是只有用户名对了才进一步检查密码 > @Override > public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException { > return this.findUserByName(s); > } > } > ~~~ > > 3、新建一个配置类,继承WebSecurityConfiguerAdapter类。重写其中的三个configure方法。 > > > 1、忽略对静态资源的访问拦截 > > > > 2、验证用户账号和密码 > > > > 3、认证成功的处理(重定向到首页)、认证失败的处理(返回登录页面) > > > > 配置访问路径和对应的访问权限 > > ~~~ java > @Configuration > public class SecurityConfig extends WebSecurityConfigurerAdapter { > //springsecurity底层依赖UserDetailsService > @Autowired > private UserService userService; > > @Override > public void configure(WebSecurity web) throws Exception { > //忽略对静态资源的访问拦截 > web.ignoring().antMatchers("/resources/**"); > } > > > > //权限管理包括认证和授权 > //先处理认证 > //AuthenticationManager认证的核心接口 > //AuthenticationManagerBuilder用于构建AuthenticationManager对象的工具 > //ProviderManager是AuthenticationManager接口的默认实现类,AuthenticationManagerBuilder默认会构建该类 > @Override > protected void configure(AuthenticationManagerBuilder auth) throws Exception { > //方法中只要写一些配置即可,不用自己写认证的逻辑 > //内置认证规则 > // new Pbkdf2PasswordEncoder("1234")是给密码加盐1234后进行加密的组件 > //auth.userDetailsService(userService).passwordEncoder(new Pbkdf2PasswordEncoder("1234")); > //但是内置认证规则和我们情况不符,盐每个用户不同,加密组件也不是Pbkdf2PasswordEncoder,需要自定义认证规则 > //自定义认证规则 > //AuthenticationProvider:ProviderManager持有一组AuthenticationProvider,每个AuthenticationProvider负责一种认证 > //由于认证的方式多样,不只是用户名密码登录,所以用多个AuthenticationProvider才能兼容所有的登录方式 > //委托模式:ProviderManager是将认证委托给了AuthenticationProvider > //由于当前只有一种认证方式,所以创建一个AuthenticationProvider,实现账号密码认证 > auth.authenticationProvider(new AuthenticationProvider() { > //Authentication:用于封装认证信息的接口,不同的实现类代表不同类型的认证信息 > @Override > public Authentication authenticate(Authentication authentication) throws AuthenticationException { > String name = authentication.getName(); > String credentials = (String) authentication.getCredentials(); > User user = userService.findUserByName(name); > if(user == null) { > throw new UsernameNotFoundException("用户不存在"); > } > String password = CommunityUtil.md5(credentials + user.getSalt()); > if(!password.equals(user.getPassword())) { > throw new BadCredentialsException("密码错误"); > } > //认证成功将结果存到Authentication中 > //第一个参数是认证结果的主信息通常传user,第二个是认证凭证通常传密码或能代替密码的东西,第三个是当前用户的权限 > return new UsernamePasswordAuthenticationToken(user, password, user.getAuthorities()); > } > //当前AuthenticationProvide接口支持哪种认证类型 > //UsernamePasswordAuthenticationToken:Authentication接口的常用实现类 > @Override > public boolean supports(Class aClass) { > return UsernamePasswordAuthenticationToken.class.equals(aClass); > } > }); > } > //处理授权 > @Override > protected void configure(HttpSecurity http) throws Exception { > //登录相关配置 > http.formLogin() > .loginPage("/loginpage")//访问登录页面的请求,这样设置就会用自定义的登录页 > .loginProcessingUrl("/login")//提交登录表单的请求,这样设置遇到该请求就会调用上方认证逻辑进行认证 > //认证成功的处理 > .successHandler(new AuthenticationSuccessHandler() { > @Override > public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { > //重定向到首页 > response.sendRedirect(request.getContextPath() + "/index"); > } > }) > //认证失败的处理 > .failureHandler(new AuthenticationFailureHandler() { > @Override > public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { > //回到登录页面给出提示 > request.setAttribute("error",e.getMessage()); > request.getRequestDispatcher("/loginpage").forward(request,response); > } > }); > //退出相关配置 > http.logout() > .logoutUrl("/logout")//退出路径 > .logoutSuccessHandler(new LogoutSuccessHandler() { > @Override > public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { > response.sendRedirect(request.getContextPath() + "/index"); > } > }); > //授权配置 权限与路径的对应关系 > http.authorizeRequests() > //注意只有登录了才会有权限,不登录什么权限都没有 > .antMatchers("/letter").hasAnyAuthority("Admin","User")// 意思是Admin,User可以访问/leters > .antMatchers("/admin").hasAnyAuthority("Admin") > .and().exceptionHandling().accessDeniedPage("/deny");//处理权限不匹配的错误 > > //增加验证码认证,验证码在账号密码之前验证 > http.addFilterBefore(new Filter() { > @Override > public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { > HttpServletRequest request = (HttpServletRequest) servletRequest; > HttpServletResponse response = (HttpServletResponse) servletResponse; > //只有登录请求才处理验证码认证 > if(request.getServletPath().equals("/login")) { > String varifycode = request.getParameter("varifycode"); > if(varifycode == null || !varifycode.equalsIgnoreCase("1234")) { > request.setAttribute("error","验证码错误"); > request.getRequestDispatcher("/loginpage").forward(request,response); > return; > } > } > //让请求继续向下执行 > filterChain.doFilter(request,response); > } > },UsernamePasswordAuthenticationFilter.class); > //记住我 > http.rememberMe() > .tokenRepository(new InMemoryTokenRepositoryImpl())//把用户信息记到哪里,这里是记到内存里 > .tokenValiditySeconds(3600 * 24)//过期时间 > .userDetailsService(userService);//使用userService查出用户信息以进行自动认证 > } > ~~~ > > ### 1.9.2、帖子置顶、加精、删除功能 1、版主可以置顶(修改帖子的类型就可以了)、加精(修改帖子的状态就可以了) 2、管理员可以删除 ### 1.9.3、redis高级数据结构介绍 1、HyperLogLog > 1、采用一种基数算法,用于独立总数的统计。例如网站的独立访客数量(去重后的访问ip数量) > > 2、占用空间小,无论统计多少数据,都只占12k空间 > > 3、有一定的误差,误差大概在0.81% 2、Bitmap > 1、底层是存储0和1的二进制序列 > > 2、适合大量连续的布尔值,如统计某个用户的签到情况 ### 1.9.4、网站数据统计 1、UV > 1、记录去重后的ip(记录ip,这样把游客也统计进去了) > > 2、使用HyperLogLog,将ip存入redis中 2、DAU > 日活跃用户:只要访问过一次,就认为他活跃 > > 1、记录去重后的用户id(即记录登录后的用户) > > 2、使用BitMap,将用户id对应的位设为1 ### 1.9.5、任务执行和调度 ### 1.9.6、热帖排行 1、帖子分数计算规则:帖子分数 = log(精华分 + 评论数 * 10 + 点赞数 * 2) + (发布时间 - 牛客创建时间) 2、当帖子分数变化的操作发生时,把帖子的id放到redis的set集合中,通过定时任务定期更新帖子分数 ### 1.9.7、优化网站性能 使用caffeine缓存热门帖子 使用caffeine步骤: > 1、导包 > > 2、配置caffeine > > ~~~ properties > #Caffeine的配置,是自定义的 > #缓存空间最多存15个数据 > caffeine.posts.max-size=10 > #缓存数据180秒后过期 > caffeine.posts.expire-seconds=180 > ~~~ > > 3、在本地缓存中储存帖子列表和总行数 > > ~~~ java > //caffeine核心接口 Cache 常用子接口LoadingCache AsyncLoadingCache > //所有的缓存都是按照key缓存value > private LoadingCache> postCache; > private LoadingCache totalRowCache; > @PostConstruct > public void init() { > //初始化缓存 > postCache = Caffeine.newBuilder() > .maximumSize(maxSize) > .expireAfterWrite(expiredSecond, TimeUnit.SECONDS) > .build(new CacheLoader>() { > @Override > //告诉caffeine从数据库中查数据的办法 > public @Nullable List load(@NonNull String s) throws Exception { > String[] strings = s.split(":"); > int offset = Integer.valueOf(strings[0]); > int limit = Integer.valueOf(strings[1]); > return discussPostMapper.selectDiscussPosts(0,offset,limit,1); > } > }); > totalRowCache = Caffeine.newBuilder() > .maximumSize(maxSize) > .expireAfterWrite(expiredSecond,TimeUnit.SECONDS) > .build(new CacheLoader() { > @Override > public @Nullable Integer load(@NonNull Integer integer) throws Exception { > return discussPostMapper.selectDiscussPostRows(0); > } > }); > } > > public List findDiscussPosts(int userId,int offset,int limit, int orderMode) { > if(userId == 0 && orderMode == 1) { > //缓存的是一页数据只与offset和limit有关 > return postCache.get(offset + ":" + limit); > } > return discussPostMapper.selectDiscussPosts(userId,offset,limit, orderMode); > } > public int findDiscussPostRows(int userId) { > if(userId == 0) { > return totalRowCache.get(userId); > } > return discussPostMapper.selectDiscussPostRows(userId); > } > ~~~ 最后,使用jmeter做压力测试,发现使用缓存后比不使用缓存,吞吐量提高了将近10倍 ## 1.10、项目主要问题 ### 1.10.1、kafka启动失败 删除kafka-logs下的所有文件即可 ### 1.10.2、页面加载转圈 经过排查发现原因是远程的js加载失败,将其注释掉 ~~~js ~~~ # 2、项目部署 ## 2.1、安装解压.zip文件的工具 1、回退到root目录: ~~~ bash cd ~ ~~~ 2、使用yum搜索unzip工具 ~~~bash yum list unzip* ~~~ 3、选择一个搜索到的工具,并复制名字,然后使用 ~~~ bash # -y表示一路自动确定 yum install -y 复制的名字 ~~~ ## 2.2、安装jdk 1、搜索jdk ~~~ bash yum list java* ~~~ 2、安装(与安装zip同理,但是还需要安装jdk)(不需要配置环境变量,会自动配置) > 1、如下命令只安装了jre > > ~~~bash > yum install -y 软件名称 > ~~~ > > 2、还需使用如下命令安装jdk(不安装jdk,maven在打包时将无法编译) > > ~~~bash > yum install -y java-devel > ~~~ > > 3、java -version输出jdk版本验证是否安装成功 ## 2.3、安装maven 1、将.tar.gz文件解压到opt目录下 ~~~ bash tar -zxvf apache-maven-3.8.8-bin.tar.gz -C /opt ~~~ 2、进入maven目录下使用pwd获取当前maven的目录 比如:目录为/opt/apache-maven-3.8.8 3、进入配置文件/etc/profile文件中,为maven配置环境变量 ~~~ bash # 注意:etc签名的/不要漏掉,因为这是系统目录。如果是普通目录就不需要在前面加/ vim /etc/profile ~~~ 写入如下内容: ~~~ bash export PATH=$PATH:/opt/apache-maven-3.8.8/bin ~~~ 4、使配置文件生效 ~~~ bash source /etc/profile ~~~ 5、使用echo $PATH打印环境变量,看环境变量映射的具体maven路径 使用如下命令查看maven版本,如果成功打印出版本,则说明配置成功 ~~~ mvn mvn -version ~~~ 6、进入maven的conf/settings.xml文件中,配置阿里云镜像 配置到标签中 ~~~ xml aliyunmaven * 阿里云公共仓库 https://maven.aliyun.com/repository/public ~~~ ## 2.4、安装mysql 1、下载mysql的.rpm文件 Linux版本mysql下载网站:[MySQL :: Download MySQL Yum Repository](https://dev.mysql.com/downloads/repo/yum/) 可以使用Linux服务器远程下载: ~~~ bash wget -O 下载后资源的名字 资源下载地址 ~~~ ~~~ bash wget -O mysql80-community-release-el7-10.noarch.rpm https://dev.mysql.com/downloads/file/?id=521594 ~~~ 注意:有可能链接不对,导致下载的文件不是想要的文件,这时候可以先下到本地,然后再统一上传到服务器上 2、使用yum安装库文件.rpm文件 ~~~ bash yum install -y mysql80-community-release-el8-8.noarch.rpm ~~~ 3、在库文件中搜索mysql 注意:如果搜索不到,则先切换到根路径(cd /)下然后再搜索 ~~~ bash yum list mysql* ~~~ 4、选择一个带有server的mysql进行安装 ~~~ bash yum install -y 软件名 ~~~ ~~~bash yum insatll -y mysql-server.x86_64 ~~~ 注意:阿里云服务器上有的已经安装过了mysql了,不需要再次安装 4、启动mysql服务 ~~~ bash systemctl start mysqld ~~~ 查看myslq服务状态,此时的Active应该是running状态 ~~~ bash systemctl status mysqld ~~~ ![image-20230925205621224](README.assets/image-20230925205621224.png) 5、从mysql的日志中搜索mysql默认生成的密码(由于mysql可能默认没有密码,所以,这一步有可能不需要) ~~~ bash grep 'password' /var/log/mysqld.log # 或者是 grep 'password' /var/log/mysql/mysqld.log # mysql日志具体在哪层目录进入/var/log下逐层查看即可 ~~~ > 直接获取mysql密码 > > ~~~bash > grep "password is generated" /var/log/mysqld.log | awk '{print $NF}' > ~~~ 6、修改用户密码 1、先使用获取到的密码登录 2、先随便设置一个密码(否则后面更改规则的命令无法执行) ~~~bash alter user root@localhost identified by 'root_1234'; ~~~ 3、登录后查看密码设置规则 ~~~bash SHOW VARIABLES LIKE 'validate_password%'; ~~~ 4、将规则改简单 ~~~bash set global validate_password.policy=0; set global validate_password.length=1; ~~~ 5、修改密码 ~~~ bash # 如果还是无法修改为root,就将密码修改为123456 alter user root@localhost identified by 'root'; ~~~ 7、登录mysql ~~~ bash mysql -u root -p # 然后输入密码回车即可 ~~~ ### 2.4.2、卸载已经安装的软件 1、先通过yum list 名称*查出软件 2、rpm -e 软件名 进行卸载软件 rpm -e mysql.x86_64 或者 ~~~bash yum -y remove 软件名 ~~~ ## 2.5、问题:上传文件后在linux的root目录中找不到文件? 可能是在上传文件时处于其他目录,导致文件上传到了其他地方,所以找不到 可以尝试切换到/目录,看是否在这里 ## 2.6、yum list mysql*报错 可尝试cd /etc/yum.repos.d 然后清空其中含mysql的文件 ## 2.7、解压缩.zip文件 unzip -d 解压后放到的目录位置 待 解压文件 ## 2.8、将sql导入mysql数据库 1、先创建数据库 ~~~ sql create database community; ~~~ 2、使用数据库 ~~~ sql use community; ~~~ 3、执行sql文件 ~~~ sql source .sql文件所在的路径 ~~~ ## 2.9、安装redis 1、使用yum list redis*搜索,然后yum install -y 名称 进行安装 2、启动redis服务 ~~~ bash systemctl start redis ~~~ 3、查看redis服务状态 ~~~bash systemctl status redis ~~~ active:runnging表示已运行 ![image-20230926085705400](README.assets/image-20230926085705400.png) 4、启动redis客户端 ~~~redis redis-cli ~~~ 退出redis客户端使用exit ## 2.10、安装kafka 1、将准备好的kafka压缩文件解压到/opt目录下,然后进入kfaka的config目录。修改如下配置文件 > 1、zookeeper.properties 2、在后台启动zookeeper、kafka(窗口关闭后,仍然可以运行) 注意:要先进入bin目录的上一级目录,才能执行如下命令,否则找不到bin目录 ~~~ bash # 启动zookeeper bin/zookeeper-server-start.sh -daemon config/zookeeper.properties # 启动kafka nohup bin/kafka-server-start.sh config/server.properties 1>/dev/null 2>&1 & ~~~ 3、可以尝试查看kafka的主题 ~~~bash bin/kafka-topics.sh --list --bootstrap-server localhost:9092 ~~~ ## 2.11、安装elasticsearch 1、将elasticsearch的压缩包解压到/opt下 ~~~bash tar -zxvf elasticsearch-6.4.3.tar.gz -C /opt ~~~ 2、将中文分词文件解压到es的plugins/ik目录下 ~~~bash unzip -d /opt/elasticsearch-6.4.3/plugins/ik elasticsearch-analysis-ik-6.4.3.zip ~~~ 3、配置elasticsearch > 1、配置elasticsearch.yml文件 > > > 1、配置集群名称 > > > > ![image-20230926095533229](README.assets/image-20230926095533229.png) > > > > 2、配置数据存放位置和日志存放位置 > > > > ![image-20230926095604427](README.assets/image-20230926095604427.png) > > 2、配置jvm.options > > > 将es占用的内存调小一些,否则占用太多服务器内存 > > > > ![image-20230926095813401](README.assets/image-20230926095813401.png) 4、新建用户组(elasticsearch不允许用root用户启动,只能用普通用户启动) ~~~ bash # 新建用户组 groupadd community # 添加一个新的用户到用户组中 useradd c1 -p root -g community # 为community用户组中的用户c1配置/opt目录的访问权限 cd /opt #进入opt目录 chown -R c1:community * #为用户配置当前目录下所有资源的访问权限 cd /tmp # 进入tmp目录 chown -R c1:community * #为用户配置tmp目录下的所有资源权限 ~~~ ~~~ bash # 切换到c1用户 su -c1 # 进入es目录 # 运行命令,在后台启动es bin/elasticsearch -d # 然后切换会root用户 su root ~~~ ~~~ bash # 检查elasticsearch是否健康 curl -X GET "localhost:9200/_cat/health?v" ~~~ ## 2.12、安装wkhtmltopdf 1、先用yum list wkthtmltopdf*搜索 2、然后用yum install -y 名称 进行安装 ## 2.13、安装tomcat 1、先解压到/opt目录,参考es的解压操作 2、配置环境变量 ~~~ bash /opt/apache-tomcat-9.0.80/bin ~~~ 先进入配置文件 ~~~ bash vim /etc/profile ~~~ 然后配置环境变量 ![image-20230926105018911](README.assets/image-20230926105018911.png) 配置后,重写加载配置文件,使配置文件生效 ~~~ bash source /etc/profile ~~~ 3、尝试打印环境变量,看环境变量是否配置成功了 echo $PATH 4、启动tomcat ~~~ bash startup.sh ~~~ 5、关闭tomcat ~~~bash shutdown.sh ~~~ ## 2.14、配置安全组 进入阿里云服务器ECS控制台,进入实例,然后配置安全组,开放端口8080 ![image-20230926111312591](README.assets/image-20230926111312591.png) ## 2.15、配置服务器防火墙 1、查看防火墙状态 ~~~ bash firewall-cmd --state ~~~ 2、开启防火墙 ~~~ bash systemctl start firewalld.service ~~~ 3、关闭防火墙 ~~~bash systemctl stop firewalld.service ~~~ 4、重启防火墙 ~~~bash systemctl restart firewalld.service ~~~ 5、为指定端口开放防火墙 ~~~bash firewall-cmd --zone=public --add-port=80/tcp --permanent ~~~ ## 2.16、安装Nginx 1、yum list nginx*搜索 2、yum install -y 名称 进行安装 3、配置nginx(通过虚拟的nginx服务器,将请求分发给真实的服务器) > 1、进入配置文件 > > ~~~bash > vim /etc/nginx/nginx.conf > ~~~ > > 2、配置 > > ~~~ bash > # 真实的访问路径,最多重试3次,失败后30秒再次重试 > upstream myserver { > server 127.0.0.1:8080 max_fail=3 fail_timeout=30s; > } > # 监听80端口,通过8.134.185.252访问时,分发请求到myserver > server { > listen: 80; > server_name: 8.134.185.252; > location / { > proxy_pass: http://myserver; > } > } > ~~~ 4、启动nginx ~~~bash systemctl start nginx ~~~ ## 2.17、解决nginx报错问题 1、使用如下命令检查nginx配置文件状态 ~~~bash nginx -t ~~~ 2、注意nginx配置文件格式 > 1、{}要成对 > > 2、server配置和upstream配置要写在http{}中 > > 3、include指令写在http{}内,server以及upstream外 > > 4、所有配置以分号;结束 > > 5、events写在http外(必须要有events配置,否则报错) 3、遵循上述规则后,再次使用nginx -t检查nginx配置文件格式,如果正确 则使用如下命令启动nginx ~~~bash systemctl start nginx ~~~ 4、nginx.conf配置文件内容参考 ~~~bash user nginx; worker_processes auto; error_log /var/log/nginx/error.log; pid /run/nginx.pid; # Load dynamic modules. See /usr/share/doc/nginx/README.dynamic. include /usr/share/nginx/modules/*.conf; events { worker_connections 1024; } http { log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 65; types_hash_max_size 2048; include /etc/nginx/mime.types; default_type application/octet-stream; # Load modular configuration files from the /etc/nginx/conf.d directory. # See http://nginx.org/en/docs/ngx_core_module.html#include # for more information. include /etc/nginx/conf.d/*.conf; upstream myserver { server 127.0.0.1:8080 max_fails=3 fail_timeout=30s; } server { listen 80; server_name 服务器公网ip; location / { proxy_pass http://myserver; } } # server { # listen 80 default_server; # listen [::]:80 default_server; # server_name _; # root /usr/share/nginx/html; # Load configuration files for the default server block. # include /etc/nginx/default.d/*.conf; # location / { # } # error_page 404 /404.html; # location = /40x.html { # } # error_page 500 502 503 504 /50x.html; # location = /50x.html { # } # } # Settings for a TLS enabled server. # # server { # listen 443 ssl http2 default_server; # listen [::]:443 ssl http2 default_server; # server_name _; # root /usr/share/nginx/html; # # ssl_certificate "/etc/pki/nginx/server.crt"; # ssl_certificate_key "/etc/pki/nginx/private/server.key"; # ssl_session_cache shared:SSL:1m; # ssl_session_timeout 10m; # ssl_ciphers PROFILE=SYSTEM; # ssl_prefer_server_ciphers on; # # # Load configuration files for the default server block. # include /etc/nginx/default.d/*.conf; # # location / { # } # # error_page 404 /404.html; # location = /40x.html { # } # # error_page 500 502 503 504 /50x.html; # location = /50x.html { # } # } } ~~~ ## 2.18、解决配置nginx分发请求后端口不生效问题 1、检查nignx.conf文件分发请求的端口是否正确 2、在云服务器中安全组为该端口放行 ## 2.19、上传源码 1、多环境配置开发,写一套开发环境,一套上线环境 2、先clean清除target,然后再上传到服务器 3、情况maven并进行编译,同时跳过测试 ~~~ mvn mvn clean package -Dmaven.test.skip=true ~~~ 4、将编译打包后的.war文件从target目录中复制到tomcat的webapps目录下 5、启动tomcat ~~~bash startup.sh ~~~ ## 2.20、查看tomcat运行的日志 1、进入tomcat的 logs目录下 2、进入catalina文件 ## 2.21、unzip解压.zip文件 ~~~bash unzip -d 目录 .zip文件 ~~~ ## 2.22、解决yum安装mysql时报错 报错信息: ![image-20231004111013657](README.assets/image-20231004111013657.png) 解决办法: > 1、先yum clean > > 2、yum install -y libstdc++.so.6 > > 3、yum install -y libc.so.6 > > 4、安装mysql:yum install 包名 ## 2.23、解决mysql -version报错 报错信息: Can't connect to local MySQL server through socket '/var/lib/mysql/mysql.sock 解决办法: 先启动mysql: systemctl start mysqld ## 2.24、卸载nginx 1、查看nginx服务状态 ~~~bash systemctl status nginx ~~~ 2、终止nginx服务 ~~~bash systemctl stop nginx ~~~ 3、查找并删除nginx相关文件 > 1、先查找nginx相关目录 > > ~~~bash > find /-name nginx > ~~~ > > 2、将查找到的目录依次删除(rm -rf 目录名) 4、使用yum清理nginx依赖包 ~~~bash yum remove nginx ~~~ ## 2.25、yum搜索nginx 方法一:yum list nginx* 方法二:yum search nginx ## 2.26、解决Elasticsearch的启动后索引状态为red的问题 > [参考资料]([elasticsearch的status一直是red的解决方案_elasticsearch status red-CSDN博客](https://blog.csdn.net/weixin_43365615/article/details/112607164)) > > 1、查看索引状态 > > ~~~bash > curl -XGET 'http://localhost:9200/_cluster/health?pretty' > ~~~ > > 2、查看所有索引信息 > > ~~~bash > curl -XGET 'HTTP://localhost:9200/_cat/indices?v' > ~~~ > > 3、删除状态为red的索引 > > ~~~bash > curl -XDELETE 'http://localhost:9200/索引名称' > ~~~ > > ~~~bash > # 示例: > curl -XDELETE 'http://localhost:9200/discusspost' > ~~~ > > 4、再次查看索引状态,发现变成了green或者yellow,问题解决 > > ~~~bash > curl -XGET 'http://localhost:9200/_cluster/health?pretty' > ~~~ ## 2.27、解决ActionRequestValidationException: Validation Failed: 1: no requests added问题 问题原因: > 数据库没有导入数据,es使用saveAll传人了空的集合,导致报错 解决办法: > 多个.sql文件导入数据库时,先导入表结构,再导入数据,否则会出现数据没有导入的情况