700字范文,内容丰富有趣,生活中的好帮手!
700字范文 > 仿牛客论坛项目(上)

仿牛客论坛项目(上)

时间:2022-07-20 07:03:59

相关推荐

仿牛客论坛项目(上)

代码仓库:/qiuyusy/community

仿牛客论坛项目(下)

仿牛客论坛项目上

1. Spring在测试类中使用Spring环境@Primary的作用@PostConstruct @PreDestroy导入外部包的Bean 2. SpringMVC2.1 HTTP请求响应 2.2 SpringMVC原理2.3 Thymeleaf 3. Mybatis-Plus4. 开发社区首页(业务)分页工具类4.1 Dao4.2 Service4.3 Controller4.4 前端页面设计(Thymeleaf)接受Model中的数据,并循环显示th:each 循环遍历th:srcth:utext th:textth:if 判断时间格式化获取list长度th:href 前端向后端发送带参请求th:class class样式判断th:value 分页 5. 开发注册功能(业务)5.1 项目调试状态码 5.2 日志存储到文件 5.3 发送邮件如何发邮件普通邮件使用Thymeleaf模板发邮件 5.4 注册实现1. 访问注册页面复用头部header 2. 编写工具类3. 编写Service层激活邮箱 4. 编写Controller层激活邮箱 6. 开发登录功能(业务)6.1 会话CookieSession 6.2 验证码刷新验证码HTMLJS 6.3 登录实现BeanDAOServiceController前端输入错误保留之前的输入输入错误的提示 6.4 登出实现Service层Controller层 6.5 显示登录信息拦截器demo1. 创建拦截器类,实现HandlerInterceptor接口2. 创建拦截器配置类,实现WebMvcConfigurer接口 1. 首先创建两个工具类降低耦合2. DAO层3. 创建登录凭证拦截器(等同于Controller层)4. 编写拦截配置类5. 前端 6.6 拦截未登录页面访问(采用注解)1. 写一个注解@LoginRequired2. 给需要的方法加上注解3. 写拦截器4. 注册到拦截器配置类 使用Redis优化登录1.验证码优化1.1配置redis前缀1.2 优化LoginController验证码相关代码(优化前是存在session中的) 2.登录凭证优化2.1配置redis前缀2.2优化LoginService中相关代码 3.缓存用户信息3.1 配置3.2修改 7. 账号设置(业务)上传头像Service层Controller层 修改密码Service层Controller层前端 8. 帖子功能(业务)8.1 过滤敏感词过滤算法实现 8.2 发布帖子封装**Fastjson**工具类ajax请求demoService层Controller层前端 8.3 查看贴子详情ServiceController前端 8.4 事务管理概念1. 事务特性2. 事务的隔离性3.并发异常 Spring声明式事务Spring编程式事务 8.5 显示评论Service层Controller层前端 8.6 添加评论(使用事务)Service层Controller层前端 9. 私信功能(业务)私信列表/详情DAO层(难点)Service层Controller私信列表私信详情 前端 发送私信DaoServiceController设置为已读发送私信 10. 统一处理异常1.将404.html或500.html放在templates/error下2.定义一个控制器通知组件,处理所有Controller所发生的异常 11. 统一处理日志1.AOP概念(面向切面编程)2.AOP是如何实现的3.AOP切面编程demo4.AOP实现统一记录日志 12.RedisRedis入门Windows下载redisRedis命令基础命令string字符串类型操作hash哈希操作list列表操作set集合操作sorted set Spring整合Redis导包配置使用 13.点赞功能(Redis+ajax)点赞/取消点赞1.工具类2.Service3.Controller4. 前端 我收到的赞1.工具类2.Service3.Controller4.前端onclick进行修改js进行修改 个人首页 14.关注功能(Redis+ajax)关注/取关1.工具类2.Service3.Controller关注与取消关注按钮的实现(FollowController)用户个人页显示数据UserController 4.前端 关注/粉丝列表1.Service2.Controller3.前端

1. Spring

在测试类中使用Spring环境

@RunWith(SpringRunner.class) 让Spring先运行@ContextConfiguration 导入配置文件implements ApplicationContextAware 后实现方法 获得 applicationContext

@RunWith(SpringRunner.class)@SpringBootTest@ContextConfiguration(classes = CommunityApplication.class)class CommunityApplicationTests implements ApplicationContextAware {private ApplicationContext applicationContext;@Overridepublic void setApplicationContext(ApplicationContext applicationContext) throws BeansException {this.applicationContext = applicationContext;}@Testpublic void testApplicationContext() {System.out.println(applicationContext);String[] beanDefinitionNames = applicationContext.getBeanDefinitionNames();for (String beanDefinitionName : beanDefinitionNames) {System.out.println(beanDefinitionName);}}}

实际开发中没必要这样,直接@Autowired自动装配进来就行

比如

@SpringBootTestclass CommunityApplicationTests {@AutowiredSimpleDateFormat simpleDateFormat;@Testvoid contextLoads() {System.out.println(simpleDateFormat.format(new Date()));}}

@Primary的作用

如果使用接口获取Bean,这个接口下有多个实现类就会报错

AlphaDao alphaDao = applicationContext.getBean(AlphaDao.class);

可以使用@Primary指定使用哪个是实现类

但是如果我某些地方还是想使用Hibernate的实现类怎么办,可以定义name来解决

@Repository("alphaHibernate")public class AlphaDaoHibernateImpl implements AlphaDao {@Overridepublic String select() {return "Hibernate";}}

AlphaDao alphaDao = applicationContext.getBean("alphaHibernate", AlphaDao.class);AlphaDao alphaDao = (AlphaDao) applicationContext.getBean("alphaHibernate");

实际开发中没必要用@Primary 直接 @Qualifier(“alphaHibernate”)指定就行了

class CommunityApplicationTests {@Autowired@Qualifier("alphaHibernate")AlphaDao alphaDao;@Testpublic void test(){System.out.println(alphaDao.select());}}

@PostConstruct @PreDestroy

@Servicepublic class AlphaService {public AlphaService() {System.out.println("执行构造方法");}@PostConstructpublic void init(){System.out.println("初始化...");}@PreDestroypublic void destroy(){System.out.println("销毁");}}

@PostConstruct 在构造器之后调用方法

@PreDestroy 在对象销毁前调用方法

导入外部包的Bean

使用配置类来注入Bean

package com.qiuyu.config;@Configurationpublic class AlphaConfig {@Beanpublic SimpleDateFormat simpleDateFormat(){return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");}}

2. SpringMVC

2.1 HTTP

/zh-CN/docs/Web/HTTP/Overview#http_%E6%B5%81

当客户端想要和服务端进行信息交互时(服务端是指最终服务器,或者是一个中间代理),过程表现为下面几步:

打开一个 TCP 连接

发送一个 HTTP 报文

GET / HTTP/1.1Host: Accept-Language: fr

读取服务端返回的报文信息:

HTTP/1.1 200 OKDate: Sat, 09 Oct 14:28:02 GMTServer: ApacheLast-Modified: Tue, 01 Dec 20:18:22 GMTETag: "51142bc1-7449-479b075b2891b"Accept-Ranges: bytesContent-Length: 29769Content-Type: text/html<!DOCTYPE html... (here comes the 29769 bytes of the requested web page)

关闭连接或者为后续请求重用连接。

请求

响应

2.2 SpringMVC原理

这里的Front Controller 前端控制器其实就是DispatcherServlet

2.3 Thymeleaf

发送ModelAndView给模板

//第一种ModelAndView@RequestMapping(value = "/teacher", method = RequestMethod.GET)public ModelAndView getTeacher(){ModelAndView modelAndView = new ModelAndView();modelAndView.addObject("name", "qiuyu");modelAndView.addObject("age", 18);modelAndView.setViewName("demo/teacher");return modelAndView;}//第二种Model@RequestMapping(value = "/teacher", method = RequestMethod.GET)public String getTeacher(Model model){model.addAttribute("name", "qiuyu");model.addAttribute("age", 19);return "demo/teacher";}

模板读取数据然后写入html,记得放在templates下

<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Title</title></head><body><p th:text="${name}"> </p><p th:text="${age}"> </p></body></html>

3. Mybatis-Plus

4. 开发社区首页(业务)

分页工具类

主要目的是为了加入路径,让前端的分页更好的复用

/*** 我的分页组件*/@Data@NoArgsConstructor@AllArgsConstructorpublic class MyPage<T> extends Page<T> {/*** 分页跳转的路径*/protected String path;}

4.1 Dao

直接Mybatis-Plus生成

@Mapperpublic interface DiscussPostMapper extends BaseMapper<DiscussPost> {}@Mapperpublic interface UserMapper extends BaseMapper<User> {}

4.2 Service

package com.qiuyu.service;@Servicepublic class DiscussPostService {@Autowiredprivate DiscussPostMapper discussPostMapper;/*** 查询不是被拉黑的帖子,并且userId不为0按照type排序* @param userId* @Param page* @return*/public IPage<DiscussPost> findDiscussPosts(int userId, IPage<DiscussPost> page) {LambdaQueryWrapper<DiscussPost> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.ne(DiscussPost::getStatus, 2).eq(userId != 0, DiscussPost::getUserId, userId).orderByDesc(DiscussPost::getType, DiscussPost::getCreateTime);discussPostMapper.selectPage(page, queryWrapper);return page;}/*** userId=0查所有;userId!=0查个人发帖数** @param userId* @return*/public int findDiscussPostRows(int userId) {LambdaQueryWrapper<DiscussPost> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.ne(DiscussPost::getStatus, 2).eq(userId != 0, DiscussPost::getUserId, userId);int nums = discussPostMapper.selectCount(queryWrapper);return nums;}}

package com.qiuyu.service;@Servicepublic class UserService {@Autowiredprivate UserMapper userMapper;public User findUserById(String id) {return userMapper.selectById(Integer.parseInt(id));}}

4.3 Controller

这里查询贴子,找到的只是userid,所以需要用userid找出user

采用Map的形式是为了之后Redis更方便

@GetMapping("/index")public String getIndexPage(Model model, MyPage<DiscussPost> page) {page.setSize(10);page.setPath("/index");//查询到分页的结果page = (MyPage<DiscussPost>) discussPostService.findDiscussPosts(0, page);List<DiscussPost> list = page.getRecords();//因为这里查出来的是userid,而不是user对象,所以需要重新查出userList<Map<String, Object>> discussPorts = new ArrayList<>();if (list != null) {for (DiscussPost post : list) {Map<String, Object> map = new HashMap<>(15);map.put("post", post);User user = userService.findUserById(post.getUserId());map.put("user", user);discussPorts.add(map);}}model.addAttribute("discussPorts", discussPorts);model.addAttribute("page", page);return "/index";}

按理说MyPage会自动放入model中,但是这里得手动加入,否则前端读取不到,不知道为啥

4.4 前端页面设计(Thymeleaf)

@表示前面加个项目路径(/community)

先导入thymeleaf<html lang="en" xmlns:th="">

<!doctype html><html lang="en" xmlns:th=""><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"><link rel="icon" href="/images/logo_87_87.png"/><link rel="stylesheet" href="/bootstrap/4.3.1/css/bootstrap.min.css" crossorigin="anonymous"><link rel="stylesheet" href="css/global.css" /><title>牛客网-首页</title>

注意,因为把网页划分到了static和templates中,相对路径可能会找不到,可以加th解决,意思为让其到static下找资源

下面的js也要这么处理

<link rel="stylesheet" th:href="@{/css/global.css}" /><script th:src="@{/js/global.js}"></script><script th:src="@{/js/index.js}"></script>

接受Model中的数据,并循环显示

th:each 循环遍历

<li th:each="map:${discussPorts}">

th:each用于循环输出数据${discussPorts}discussPorts为Model传过来的数据名称map为在这里使用的属性名

<li th:each="i:${#numbers.sequence(page.current-2,page.current+2)}"><a th:text="${i}" th:if="${i} > 0 and ${i} <= ${page.pages} "></a></li>

#numbers.sequence按照给定的开始和结束的数,从开始循环到结束

<p th:text="mapStat.count"></p

mapStat.count属性名+Stat.count获取当前循环到第几个

th:src

<img th:src="${map.user.headerUrl}" >

${map.user.headerUrl}拿到map中的user属性的headerUrl属性

th:utext th:text

<a href="#" th:utext="${map.post.title}">

${map.post.title}拿到map中的post属性的title属性th:utext 会将转义字符转义th:text 不会转义,直接输出

th:if 判断

<span th:if="${map.post.type==1}">置顶</span>

如果map.post.type等于 1 才显示置顶

时间格式化

使用dates工具格式化

<b th:utext="${#dates.format(map.post.createTime,'yyyy-MM-dd HH:mm:ss')}">

获取list长度

<p th:text="${#lists.size(discussPorts)}"></p>

th:href 前端向后端发送带参请求

<a th:href="@{/community/index(currentPage=1,pageSize=10)}">首页</a>

<a th:href="@{${path}(currentPage=${page.getPages()},pageSize=10)}">末页</a>

th:class class样式判断

先用| |把class内括起来 然后写判断

<li th:class="|page-item ${page.current==1?'disabled':''}|">

th:value

设置默认值

<input type="text" th:value="${user!=null ? user.username : ''}"id="username" name="username" placeholder="请输入您的账号!" required>

分页

<!-- 分页 --><nav class="mt-5" th:if="${page.pages > 1}" th:fragment="pagination"><ul class="pagination justify-content-center"><li class="page-item"><a class="page-link" th:href="@{${page.path}(current=1)}">首页</a></li><li th:class="|page-item ${page.current==1?'disabled':''}|"><a class="page-link" th:href="@{${page.path}(current=${page.current}-1)}">上一页</a></li><li th:class="|page-item ${i==page.current?'active':''}|" th:each="i:${#numbers.sequence(page.current-2,page.current+2)}"><a class="page-link" th:href="@{${page.path}(current=${i})}" th:text="${i}" th:if="${i} > 0 and ${i} <= ${page.pages} "></a></li><li class="page-item" th:class="|page-item ${page.current==page.pages?'disabled':''}|"><a class="page-link" th:href="@{${page.path}(current=${page.current}+1)}">下一页</a></li><li class="page-item"><a class="page-link" th:href="@{${page.path}(current=${page.pages})}">末页</a></li></ul></nav>

5. 开发注册功能(业务)

5.1 项目调试

状态码

200 成功302 重定向

返回302和一个url,建议你去访问这个url

比如你删除完,想查询一下,就可以在删除后重定向到查询页,降低耦合400 请求参数有误403 服务器拒绝执行请求404 not found500 服务器遇到不知道如何处理的情况

5.2 日志

trace>debug>info>warn>error

level下写的是包,填写最低显示级别

#loggerlogging:level:com.qiuyu: warn

然后创建一个Logger就行(注意是 org.slf4j.Logger )

@SpringBootTest@RunWith(SpringRunner.class)public class LoggerTest {private static final Logger logger = LoggerFactory.getLogger(LoggerTest.class);@Testpublic void testLogger(){logger.debug("debug");logger.info("info");logger.warn("warn");logger.error("error");;}}

存储到文件

简单

logging:level:com.qiuyu: debugfile:name: community.log

复杂

使用logback-spring.xml配置

5.3 发送邮件

如何发邮件

邮箱需要开启SMTP协议

导入jar包

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-mail</artifactId></dependency>

配置

spring: # 邮箱mail:#配置邮件消息host: port: 465#发送邮件者信箱(也就是你申请POP3/SMTP服务的QQ号)username: ***@#申请PO3/SMTP服务时,给我们的邮箱的授权码password: *****default-encoding: UTF-8protocol: smtpproperties:mail.smtp.ssl.enable: true

普通邮件

写一个工具类用于发邮件

package com.qiuyu.utils;@Componentpublic class MailClient {private static final Logger logger = LoggerFactory.getLogger(MailClient.class);@Autowiredprivate JavaMailSender mailSender;/*** 从yml中读取发件人*/@Value("${spring.mail.username}")private String from;public void sendMail(String to, String subject, String content) {try {MimeMessage message = mailSender.createMimeMessage();MimeMessageHelper helper = new MimeMessageHelper(message);//设置收发件人helper.setFrom(from);helper.setTo(to);//设置邮件helper.setSubject(subject);helper.setText(content,true); //true表示支持html格式//发送mailSender.send(helper.getMimeMessage());} catch (MessagingException e) {logger.error("发送邮件失败" + e.getMessage());} finally {}}}@Testpublic void testSendMail() {String to = "****@";String subject = "测试邮件";String content = "测试邮件内容";mailClient.sendMail(to, subject, content);}

使用Thymeleaf模板发邮件

写一个模板

<!DOCTYPE html><html lang="en" xmlns:th=""><head><meta charset="UTF-8"><title>邮箱示例</title></head><body><p>欢迎你,<span style="color: red" th:text="${username}"></span></p></body></html>

发送邮件

package com.qiuyu.utils;import org.junit.Test;import org.junit.runner.RunWith;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.test.context.junit4.SpringRunner;import org.thymeleaf.TemplateEngine;import org.thymeleaf.context.Context;@SpringBootTest@RunWith(SpringRunner.class)public class MailClientTest {@Autowiredprivate MailClient mailClient;@Autowiredprivate TemplateEngine templateEngine;@Testpublic void testSendHtmlMail() {String to = "2448567284@";String subject = "测试邮件";//创建数据Context context = new Context();context.setVariable("username", "qiuyu");//根据模板,放入数据String content = templateEngine.process("/mail/demo", context);System.out.println(content);//发送mailClient.sendMail(to, subject, content);}}

5.4 注册实现

1. 访问注册页面

@Controllerpublic class LoginController {@GetMapping("/register")public String getRegisterPage() {return "/site/register";}}

复用头部header

index.html

<header class="bg-dark sticky-top" th:fragment="header">

th:fragment="header"被复用的部分,取名header

register.html

<header class="bg-dark sticky-top" th:replace="index::header"></header>

th:replace="index::header"复用index的header

2. 编写工具类

导入一个字符串处理工具类依赖

<dependency><groupId>mons</groupId><artifactId>commons-lang3</artifactId></dependency>

配置一下域名,让邮箱访问(key可以自定义)

# communitycommunity:path:domain: http://localhost:80

写一个工具类,包括获得随机字符串和md5加密

密码在存入数据库时,需要进行md5加密

但是如果简单密码经过md5加密后,也可能会被黑客撞库攻击

所以先将密码进行加盐(salt)后再进行md5加密

package com.qiuyu.utils;import mons.lang3.StringUtils;import org.springframework.util.DigestUtils;import java.util.UUID;public class CommunityUtil {/** 生成随机字符串* 用于邮件激活码,salt5位随机数加密**/public static String generateUUID(){return UUID.randomUUID().toString().replaceAll("-","");}/* MD5加密* hello-->abc123def456* hello + 3e4a8-->abc123def456abc*/public static String md5(String key){//检查时候为null 空 空格 if (StringUtils.isBlank(key)){return null;}//MD5加密方法return DigestUtils.md5DigestAsHex(key.getBytes());//参数是bytes型}}

还有常量接口(实现接口使用)

package com.qiuyu.utils;/*** @author QiuYuSY* @create -01-17 22:06* 一些常量*/public interface CommunityConstant {/*以下用于注册功能*//** 激活成功*/int ACTIVATION_SUCCESS=0;/** 重复激活 */int ACTIVATION_REPEAT=1;/** 激活失败 */int ACTIVATION_FAILURE=2;/*以下用于登录功能*//*** 默认状态的登录凭证的超时时间*/int DEFAULT_EXPIRED_SECONDS=3600*12;/*** 记住状态的登录凭证超时时间*/int REMEMBER_EXPIRED_SECONDS=3600*24*7;}

3. 编写Service层

输入合法性验证

输入user对象不为null输入各项属性不为空(使用字符串工具类)用户名邮箱是否已被注册

注册账户

设置salt加密(随机5位数加入密码)设置密码+salt设置UUID随机数激活码初始化status,type=0,时间设置头像(动态)

发送邮件

创建Context对象–>context.setVariable(name,value)将name传入前端,为thymeleaf提供变量设置email和urltemplateEngine.process执行相应HTML发送邮件

public Map<String,Object> register(User user){Map<String,Object> 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;}//判断账号是否被注册Integer integer = userMapper.selectCount(new LambdaQueryWrapper<User>().eq(User::getUsername, user.getUsername()));if(integer > 0){map.put("usernameMsg", "该账号已被注册");return map;}//判断邮箱是否被注册integer = userMapper.selectCount(new LambdaQueryWrapper<User>().eq(User::getEmail, user.getEmail()));if(integer > 0){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("/head/%dt.png", new Random().nextInt(1000)));user.setCreateTime(new Date());//注册用户userMapper.insert(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.sendMail(user.getEmail(), "激活账号", content);//map为空则注册成功return map;}

激活邮箱

/*** 激活账号* @param userId* @param activationCode* @return*/public int activate(int userId, String activationCode) {//根据userid获取用户信息User user = userMapper.selectById(userId);if(user.getStatus() == 1){//已经激活,则返回重复return ACTIVATION_REPEAT;} else if (user.getActivationCode() .equals(activationCode)) {//如果未激活,判断激活码是否相等//激活账号user.setStatus(1);userMapper.updateById(user);return ACTIVATION_SUCCESS;} else {//不相等return ACTIVATION_FAILURE;}}

4. 编写Controller层

如果注册成功,则到一个中转页面不成功则把失败消息传到注册页面,重新注册

package com.qiuyu.controller;@Controllerpublic class LoginController {@Autowiredprivate UserService userService;/*** 跳转到请求页面* @return*/@GetMapping("/register")public String getRegisterPage() {return "/site/register";}/*** 注册账号,发送邮箱*/@PostMapping("/register")public String register(Model model, User user) {Map<String, Object> 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";}}}

激活邮箱

/*** 激活邮箱 http://localhost:8080/community/activation/101/code 激活链接* @param model* @param userId* @param code* @return*/@GetMapping("/activation/{userId}/{code}")public String activate(Model model,@PathVariable("userId") int userId,@PathVariable("code") String code) {int result = userService.activate(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";}

6. 开发登录功能(业务)

6.1 会话

Cookie

密码之类隐私数据用Cookie放浏览器不安全

@GetMapping("/cookie/set")@ResponseBodypublic String setCookie(HttpServletResponse response) {//创建CookieCookie cookie = new Cookie("code", CommunityUtil.generateUUID());//设置Cookie生效范围cookie.setPath("/community");//设置cookie有效时间(s)cookie.setMaxAge(60 * 10);//发送Cookieresponse.addCookie(cookie);return "setCookie";}@GetMapping("/cookie/get")@ResponseBodypublic String getCookie(@CookieValue("code") String code) {return code;}

Session

服务器把sessionId用cookie给浏览器,浏览器只存了sessionId

缺点是耗费内存

Set-Cookie: JSESSIONID=71B1E0DDFA9BD595C5E7F584AD56E7F6; Path=/community; HttpOnly

存的是sessionID

@GetMapping("/session/set")@ResponseBodypublic String setSession(HttpSession session) {session.setAttribute("id",1);session.setAttribute("name","Test");session.setAttribute("pwd","ASDASDDADASD");return "setSession";}@GetMapping("/session/get")@ResponseBodypublic String getSession(HttpSession session) {System.out.println(session.getAttribute("id"));System.out.println(session.getAttribute("name"));System.out.println(session.getAttribute("pwd"));return "getSession";}

为什么Session在分布式情况下尽量少使用

因为负载均衡无法保证同个用户的多次请求都能到同一台服务器,而session只在第一次请求的服务器中

解决方案

让用户的多次请求粘性的访问同一台服务器 缺点:破环了负载均衡 让每个服务器都同步一份session 缺点:耗费内存,耦合高 额外专门设置一台服务器用于存放session,其他服务器来这台服务器取session 缺点:如果这台存放session的服务器挂了就gg,而如果用session集群架设那又和方案二一样了使用数据库集群(Ridds)

6.2 验证码

参考网站 :/archive/p/kaptcha/

注意:

1.Producer是Kaptcha的核心接口

2.DefaultKaptcha是Kaptcha核心接口的默认实现类

3.Spring Boot没有为Kaptcha提供自动配置

导入

<dependency><groupId>com.github.penggle</groupId><artifactId>kaptcha</artifactId><version>2.3.2</version></dependency>

配置

package com.qiuyu.config;import com.google.code.kaptcha.Producer;import com.google.code.kaptcha.impl.DefaultKaptcha;import com.google.code.kaptcha.util.Config;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import java.util.Properties;@Configurationpublic class KaptchaConfig {@Beanpublic Producer KaptchaProducer(){/*** 手动创建properties.xml配置文件对象** 设置验证码图片的样式,大小,高度,边框,字体等*/Properties properties=new Properties();properties.setProperty("kaptcha.border", "yes");properties.setProperty("kaptcha.border.color", "105,179,90");properties.setProperty("kaptcha.textproducer.font.color", "black");properties.setProperty("kaptcha.image.width", "110"); //宽度properties.setProperty("kaptcha.image.height", "40"); //高度properties.setProperty("kaptcha.textproducer.font.size", "32"); //字号properties.setProperty("kaptcha.textproducer.char.string", "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ");properties.setProperty("kaptcha.textproducer.char.length", "4"); //几个字符properties.setProperty("kaptcha.textproducer.font.names", "宋体,楷体,微软雅黑");properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.NoNoise"); //是否干扰DefaultKaptcha Kaptcha=new DefaultKaptcha();Config config=new Config(properties);Kaptcha.setConfig(config);return Kaptcha;}}

使用(注意生成的文本要放入session,等待验证用户的输入)

@GetMapping("/kaptcha")public void getKaptcha(HttpServletResponse response, HttpSession session){//生成验证码String text = kaptchaProducer.createText();BufferedImage image = kaptchaProducer.createImage(text);//验证码存入session,用于验证用户输入是否正确session.setAttribute("kaptcha",text);//将图片输出到浏览器response.setContentType("image/png");try {OutputStream os = response.getOutputStream();ImageIO.write(image,"png",os);os.flush();} catch (IOException e) {logger.error("响应验证码失败:"+e.getMessage());}}

刷新验证码

HTML

<img th:src="@{/kaptcha}" id="kaptcha" style="width:100px;height:40px;" /><a href="javascript:refresh_kaptcha();">刷新验证码</a>

JS

有些浏览器认为图片为静态资源,地址没变,就不刷新,带个参数可以解决

<script>function refresh_kaptcha(){//用?带个参数欺骗浏览器,让其认为是个新路径var path = CONTEXT_PATH + "/kaptcha?p=" + Math.random();$("#kaptcha").attr("src", path);}</script>

6.3 登录实现

登录后需要使用cookie或session进行登录凭证的验证,但是上面说到了这两种方案的缺点

cookie不安全session耗资源,分布式不适合

这里使用把凭证存入数据库的方式,先存入mysql,后续转redis

Bean

package com.qiuyu.bean;@Data@NoArgsConstructor@AllArgsConstructorpublic class LoginTicket {private Integer id;private Integer userId;private String ticket;private Integer status;private Date expired;}

DAO

MyBatis-Plus生成

Service

空值判断根据username查找user,判断是否存在该用户对输入密码进行加盐如何md5加密,然后进行比对写入登录凭证到数据库

package com.qiuyu.service;@Servicepublic class LoginService {@Autowiredprivate LoginTicketMapper loginTicketMapper;@Autowiredprivate UserMapper userMapper;/*** 登录* @param username* @param password* @param expiredSeconds* @return*/public Map<String,Object> login(String username, String password, int expiredSeconds){HashMap<String, Object> map = new HashMap<>();//空值处理if (StringUtils.isBlank(username)) {map.put("usernameMsg","用户名不能为空");return map;}if (StringUtils.isBlank(password)) {map.put("passwordMsg","密码不能为空");return map;}//验证账号是否存在User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUsername, username));if(user == null){map.put("usernameMsg","该账号不存在");return map;}//验证激活状态if(user.getStatus() == 0){map.put("usernameMsg","该账号未激活");return map;}//验证密码(先加密再对比)String pwdMd5 = CommunityUtil.md5(password + user.getSalt());if(!pwdMd5.equals(user.getPassword())){map.put("passwordMsg","密码错误");return map;}//生成登录凭证(相当于记住我这个功能==session)LoginTicket ticket = new LoginTicket();ticket.setUserId(user.getId());ticket.setTicket(CommunityUtil.generateUUID());ticket.setStatus(0); //有效//当前时间的毫秒数+过期时间毫秒数ticket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds * 1000));Date date = new Date();loginTicketMapper.insert(ticket);map.put("ticket",ticket.getTicket());//map中能拿到ticket说明登录成功了return map;}}

Controller

判断验证码是否正确判断登录是否成功成功就发送一个带有登录凭证的cookie给浏览器不成功就重新登录

/*** 登录功能* @param username* @param password* @param code 验证码* @param rememberme 是否勾选记住我* @param model* @param session 用于获取kaptcha验证码* @param response 用于浏览器接受cookie* @return*/@PostMapping(path = "/login")public String login(String username, String password, String code, boolean rememberme,Model model, HttpSession session, HttpServletResponse response){//判断验证码String kaptcha = (String) session.getAttribute("kaptcha");if(StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)){//空值或者不相等model.addAttribute("codeMsg","验证码不正确");return "site/login";}/** 1.验证用户名和密码(重点)* 2.传入浏览器cookie=ticket*/int expiredSeconds=rememberme?REMEMBER_EXPIRED_SECONDS:DEFAULT_EXPIRED_SECONDS;Map<String, Object> map = loginService.login(username, password, expiredSeconds);//登录成功if(map.containsKey("ticket")){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";}}

前端

输入错误保留之前的输入

<input type="text" name="username"th:value="${param.username}"id="username" placeholder="请输入您的账号!" required>

<input type="checkbox" name="rememberme" id="remember-me"th:checked="${param.rememberme}">

注意Controller中username和password并没有放入到model中,所以model中没有这俩,有两种方案 手动把username和password放入到model中直接在request中取 在th中 为param

输入错误的提示

<input type="password" name="password"th:class="|form-control ${passwordMsg!=null?'is-invalid':''}|"th:value="${param.password}"id="password" placeholder="请输入您的密码!" required><div class="invalid-feedback" th:text="${passwordMsg}">密码长度不能小于8位!</div>

判断是否有msg,有的话才加上is-invalid样式显示提示

6.4 登出实现

Service层

/*** 登出* @param ticket 登录凭证*/public void logout(String ticket){LoginTicket loginTicket = new LoginTicket();loginTicket.setStatus(1);loginTicketMapper.update(loginTicket,new LambdaUpdateWrapper<LoginTicket>().eq(LoginTicket::getTicket,ticket));}

Controller层

/*** 退出登录功能* @CookieValue()注解:将浏览器中的Cookie值传给参数*/@GetMapping("/logout")public String logout(@CookieValue("ticket") String ticket){userService.logout(ticket);return "redirect:/login";//重定向}

@CookieValue将浏览器中的Cookie值传给参数

6.5 显示登录信息

拦截器demo

拦截器需实现HandlerInterceptor接口而配置类需实现WebMvcConfigurer接口。preHandle方法在Controller之前执行,若返回false,则终止执行后续的请求。postHandle方法在Controller之后、模板页面之前执行。afterCompletion方法在模板之后执行。通过addInterceptors方法对拦截器进行配置

1. 创建拦截器类,实现HandlerInterceptor接口

handle就是在执行的方法,也就是拦截的目标

@Componentpublic class AlphaInterceptor implements HandlerInterceptor {private static final Logger logger = LoggerFactory.getLogger(AlphaInterceptor.class);@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {logger.debug("preHandle");return true;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {logger.debug("postHandle");}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {logger.debug("afterCompletion");}}

2. 创建拦截器配置类,实现WebMvcConfigurer接口

package com.qiuyu.config;import com.qiuyu.controller.interceptor.AlphaInterceptor;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Configuration;import org.springframework.web.servlet.config.annotation.InterceptorRegistry;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;@Configurationpublic class WebMvcConfig implements WebMvcConfigurer {@Autowiredprivate AlphaInterceptor alphaInterceptor;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(alphaInterceptor).excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg") //不拦截静态资源.addPathPatterns("/register","/login"); //只拦截部分请求}}

1. 首先创建两个工具类降低耦合

Request获取Cookie工具类,获取凭证ticket多线程工具类

package com.qiuyu.utils;import javax.servlet.http.Cookie;import javax.servlet.http.HttpServletRequest;public class CookieUtil {/*** 从request中获取指定cookie对象* @param request* @param name* @return*/public static String getValue(HttpServletRequest request, String name){if (request==null||name==null){throw new IllegalArgumentException("参数为空!");}Cookie[] cookies = request.getCookies();if (cookies!=null){for (Cookie cookie : cookies){if (cookie.getName().equals(name)){return cookie.getValue();}}}return null;}}

注意:

ThreadLocal采用线程隔离的方式存放数据,可以避免多线程之间出现数据访问冲突。

ThreadLocal提供set方法,能够以当前线程为key存放数据。get方法,能够以当前线程为key获取数据。

ThreadLocal提供remove方法,能够以当前线程为key删除数据。

因为用户登录后,需要把用户信息放入内存之中,而web时多线程的环境,每个用户都会有一个线程

为了避免线程之间干扰,需要采用ThreadLocal进行线程隔离

package com.qiuyu.utils;import com.qiuyu.bean.User;import org.ponent;/*** 持有用户信息,代替session对象*/@Component //放入容器里不用设为静态方法public class HostHolder {//key就是线程对象,值为线程的变量副本private ThreadLocal<User> users = new ThreadLocal<>();/*** 以线程为key存入User* @param user*/public void setUser(User user){users.set(user);}/*** 从ThreadLocal线程中取出User* @return*/public User getUser(){return users.get();}/*** 释放线程*/public void clear(){users.remove();}}

2. DAO层

/*** 通过凭证号找到凭证* @param ticket* @return*/public LoginTicket findLoginTicket(String ticket){return loginTicketMapper.selectOne(new LambdaQueryWrapper<LoginTicket>().eq(LoginTicket::getTicket, ticket));}

3. 创建登录凭证拦截器(等同于Controller层)

preHandle: 在进入controller之前,把请求拦下,判断是否有凭证,有的话根据凭证查出用户,存入ThreadLocalpostHandle:controller处理完之后,到视图之前,把ThreadLocal中的用户存入ModelAndView给前端调用afterCompletion: 最后把ThreadLocal中的当前user删除

package com.qiuyu.controller.interceptor;/*** 登录凭证拦截器,用于根据凭证号获取用户,并传给视图*/@Componentpublic class LoginTicketInterceptor implements HandlerInterceptor {@Autowiredprivate UserService userService;@Autowiredprivate HostHolder hostHolder;@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//从request中获取cookie 凭证String ticket = CookieUtil.getValue(request, "ticket");if (!StringUtils.isBlank(ticket)) {// 查询凭证LoginTicket loginTicket = userService.findLoginTicket(ticket);// 检查凭证是否有效if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {// 根据凭证查询用户User user = userService.findUserById(String.valueOf(loginTicket.getUserId()));// 把用户存入ThreadLocalhostHolder.setUser(user);}}return true;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {//在调用模板引擎之前,把user给modelUser user = hostHolder.getUser();if (user != null && modelAndView != null) {modelAndView.addObject("loginUser",user);}}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {//最后把ThreadLocal中的当前user删除hostHolder.clear();}}

4. 编写拦截配置类

package com.qiuyu.config;/*** 拦截器配置类*/@Configurationpublic class WebMvcConfig implements WebMvcConfigurer {@Autowiredprivate LoginTicketInterceptor loginTicketInterceptor;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(loginTicketInterceptor).excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");}}

5. 前端

<li class="nav-item ml-3 btn-group-vertical" th:if="${loginUser!=null}"><a href="site/letter.html">消息<span >12</span></a></li>

th:if="${loginUser!=null}"如果有loginUser传过来才显示th:if="${loginUser==null}"如果有没有loginUser传过来才显示

6.6 拦截未登录页面访问(采用注解)

当前情况下,没登录也能够访问/user/setting,想要不让其访问,可以使用之前的那种拦截器,这里采用注解的方法

常用的元注解:

@Target:注解作用目标(方法or类)@Retention:注解作用时间(运行时or编译时)@Document:注解是否可以生成到文档里@Inherited**:注解继承该类的子类将自动使用@Inherited修饰

注意:若有2个拦截器,拦截器执行顺序为注册在WebMvcConfig配置类中的顺序

1. 写一个注解@LoginRequired

package com.qiuyu.annotation;@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface LoginRequired {}

2. 给需要的方法加上注解

/*** 跳转设置页面* @return*/@LoginRequired@GetMapping("/setting")public String getUserPage() {return "/site/setting";}/***上传头像*/@LoginRequired@PostMapping("/upload")public String uploadHeader(MultipartFile headerImage, Model model) {}

3. 写拦截器

拦截有注解,并且没登陆的那些请求

package com.qiuyu.controller.interceptor;/*** @LoginRequired的拦截器实现*/@Componentpublic class LoginRequiredInterceptor implements HandlerInterceptor {@Autowiredprivate HostHolder hostHolder;@Overridepublic 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);//如果这个方法被@LoginRequired注解,并且未登录,跳转并拦截!if (loginRequired != null && hostHolder.getUser() == null) {response.sendRedirect(request.getContextPath()+"/login");return false;}}return true;}}

4. 注册到拦截器配置类

package com.qiuyu.config;/*** 拦截器配置类*/@Configurationpublic class WebMvcConfig implements WebMvcConfigurer {@Autowiredprivate LoginTicketInterceptor loginTicketInterceptor;@Autowiredprivate LoginRequiredInterceptor loginRequiredInterceptor;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(loginTicketInterceptor).excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");registry.addInterceptor(loginRequiredInterceptor).excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");}}

使用Redis优化登录

1.验证码优化

之前验证码使用kaptcha生成后,就将字符存入了session中,等待验证

//生成验证码String text = kaptchaProducer.createText();BufferedImage image = kaptchaProducer.createImage(text);//验证码存入session,用于验证用户输入是否正确session.setAttribute("kaptcha",text);

如果使用分布式,分布式session会出现问题,这里使用redis存储

生成一个uuid后作为key存入redis, value为验证码的正确答案

1.1配置redis前缀

// 验证码private static final String PREFIX_KAPTCHA = "kaptcha";/*** 登录验证码* @param owner* @return*/public static String getKaptchaKey(String owner) {return PREFIX_KAPTCHA + SPLIT + owner;}

1.2 优化LoginController验证码相关代码(优化前是存在session中的)

/*** 验证码生成* @param response*/@GetMapping("/kaptcha")public void getKaptcha(HttpServletResponse response){//生成验证码String text = kaptchaProducer.createText();BufferedImage image = kaptchaProducer.createImage(text);//优化前:将验证码存入session.....//session.setAttribute("kaptcha",text);//优化后:生成验证码的归属传给浏览器CookieString kaptchaOwner = CommunityUtil.generateUUID();Cookie cookie = new Cookie("kaptchaOwner", kaptchaOwner);cookie.setMaxAge(60); //scookie.setPath(contextPath);response.addCookie(cookie);//优化后:将验证码存入RedisString 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);os.flush();} catch (IOException e) {logger.error("响应验证码失败:"+e.getMessage());}}

/*** 登录功能* @param username* @param password* @param code 验证码* @param rememberme 是否勾选记住我* @param model* @param response 用于浏览器接受cookie* @return*/@PostMapping(path = "/login")public String login(String username, String password, String code, boolean rememberme,Model model, HttpServletResponse response,@CookieValue("kaptchaOwner") String kaptchaOwner){//优化前:首先检验验证码(从session取验证码)//String kaptcha = (String) session.getAttribute("kaptcha");String kaptcha = null;// 优化后:从redis中获取kaptcha的keyif(!StringUtils.isBlank(kaptchaOwner)){String redisKey = RedisKeyUtil.getKaptchaKey(kaptchaOwner);//获取redis中的验证码答案kaptcha = (String) redisTemplate.opsForValue().get(redisKey);System.out.println(kaptcha);}if(StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)){//空值或者不相等model.addAttribute("codeMsg","验证码不正确");return "site/login";}/** 1.验证用户名和密码(重点)* 2.传入浏览器cookie=ticket*/int expiredSeconds=rememberme?REMEMBER_EXPIRED_SECONDS:DEFAULT_EXPIRED_SECONDS;Map<String, Object> map = loginService.login(username, password, expiredSeconds);//登录成功if(map.containsKey("ticket")){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";}}

2.登录凭证优化

之前在登录凭证拦截器中,每次用户访问都需要查询一次数据库,效率太低

//从request中获取cookie 凭证String ticket = CookieUtil.getValue(request, "ticket");if (!StringUtils.isBlank(ticket)) {// 查询凭证LoginTicket loginTicket = userService.findLoginTicket(ticket);}

直接使用redis,mysql中的凭证表无需使用

2.1配置redis前缀

// 登录凭证private static final String PREFIX_TICKET = "ticket";/*** 登录凭证* @param ticket* @return*/public static String getTicketKey(String ticket) {return PREFIX_TICKET + SPLIT + ticket;}

2.2优化LoginService中相关代码

废弃LoginTicket数据库表,使用redis

登录时

//生成登录凭证(相当于记住我这个功能==session)LoginTicket ticket = new LoginTicket();ticket.setUserId(user.getId());ticket.setTicket(CommunityUtil.generateUUID());ticket.setStatus(0); //有效//当前时间的毫秒数+过期时间毫秒数ticket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds * 1000));Date date = new Date();// 优化前:loginTicketMapper.insertLoginTicket(ticket);// 优化后:loginticket对象放入redis中String redisKey = RedisKeyUtil.getTicketKey(ticket.getTicket());// opsForValue将ticket对象序列化为json字符串redisTemplate.opsForValue().set(redisKey, ticket);

登出时(不直接删,是因为需要保留用户的登录记录,比如可以用于查看用户的每月登录次数)

/*** 登出* @param ticket 登录凭证*/public void logout(String ticket) {//优化前:找到数据库中的ticket,把状态改为1//优化后:loginticket对象从redis中取出后状态设为1后放回String redisKey = RedisKeyUtil.getTicketKey(ticket);LoginTicket loginTicket = (LoginTicket) redisTemplate.opsForValue().get(redisKey);loginTicket.setStatus(1);//放回redisTemplate.opsForValue().set(redisKey,loginTicket);}

通过凭证号找到凭证

/*** 通过凭证号找到凭证** @param ticket* @return*/public LoginTicket findLoginTicket(String ticket) {// return loginTicketMapper.selectOne(new LambdaQueryWrapper<LoginTicket>()//.eq(LoginTicket::getTicket, ticket));//redis优化后:从redis中取出String redisKey = RedisKeyUtil.getTicketKey(ticket);return (LoginTicket) redisTemplate.opsForValue().get(redisKey);}

3.缓存用户信息

每次请求都需要根据凭证来获取用户信息,访问的频率非常高

// 检查凭证是否有效if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {// 根据凭证查询用户User user = userService.findUserById(String.valueOf(loginTicket.getUserId()));// 把用户存入ThreadLocalhostHolder.setUser(user);}

优先从缓存中取值

取不到时,从数据库中取,初始化缓存数据(redis存值)

数据变更时清除缓存(也可更新缓存,但是多线程时有并发的问题)

3.1 配置

private static final String PREFIX_USER = "user";/*** 用户缓存* @param userId* @return*/public static String getUserKey(int userId) {return PREFIX_USER + SPLIT + userId;}

3.2修改

// 1.优先从缓存中取值private User getCache(int userId) {String redisKey = RedisKeyUtil.getUserKey(userId);return (User) redisTemplate.opsForValue().get(redisKey);}// 2.取不到时初始化缓存数据(redis存值)private User initCache(int userId) {User user = userMapper.selectById(userId);String redisKey = RedisKeyUtil.getUserKey(userId);redisTemplate.opsForValue().set(redisKey, user, 3600, TimeUnit.SECONDS);return user;}// 3.数据变更时清除缓存(删除redis的key)private void clearCache(int userId) {String redisKey = RedisKeyUtil.getUserKey(userId);redisTemplate.delete(redisKey);}

public User findUserById(String id) {// return userMapper.selectById(Integer.parseInt(id));//优先从缓存中取值User user = getCache(Integer.parseInt(id));if(user == null){//取不到时,从数据库中取,然后初始化缓存数据(redis存值)user = initCache(Integer.parseInt(id)); //乌鱼子,忘了写user=找bug找了一小时}return user;}

/*** 激活账号** @param userId* @param activationCode* @return*/public int activate(int userId, String activationCode) {//根据userid获取用户信息User user = userMapper.selectById(userId);if (user.getStatus() == 1) {//已经激活,则返回重复return ACTIVATION_REPEAT;} else if (user.getActivationCode().equals(activationCode)) {//如果未激活,判断激活码是否相等//激活账号user.setStatus(1);// userMapper.updateById(user);//redis优化后clearCache(userId);return ACTIVATION_SUCCESS;} else {//不相等return ACTIVATION_FAILURE;}}

/*** 更新用户头像路径** @param userId* @param headerUrl* @return*/public int updateHeaderUrl(int userId, String headerUrl) {User user = new User();user.setId(userId);user.setHeaderUrl(headerUrl);int rows = userMapper.updateById(user);clearCache(userId);return rows;}

7. 账号设置(业务)

上传头像

注意:1. 必须是Post请求 2.表单:enctype=“multipart/form-data” 3.参数类型MultipartFile只能封装一个文件

上传路径可以是本地路径也可以是web路径

访问路径必须是符合HTTP协议的Web路径

Service层

/*** 更新用户头像路径* @param userId* @param headerUrl* @return*/public int updateHeaderUrl(int userId, String headerUrl) {User user = new User();user.setId(userId);user.setHeaderUrl(headerUrl);return userMapper.updateById(user);}

Controller层

把图像传入本地MultipartFile读取图片对图片进行合法性判断,重命名图像上传到指定位置,并指定url修改用户的头像url 从本地读取图像 根据url解析得到文件地址用缓冲区读取图片,输出到输出流中

package com.qiuyu.controller;@Controller@RequestMapping("/user")public class UserController {public static final Logger logger = LoggerFactory.getLogger(UserController.class);@Value("${community.path.domain}")private String domain;@Value("${community.path.upload-path}")private String uploadPath;@Value("${server.servlet.context-path}")private String contextPath;@Autowiredprivate UserService userService;@Autowiredprivate HostHolder hostHolder;/*** 上传头像** @param headerImage* @param model* @return*/@PostMapping("/upload")public String uploadHeader(MultipartFile headerImage, Model model) {if (headerImage == null) {model.addAttribute("error", "您还没有选择图片!");return "/site/setting";}/** 获得原始文件名字* 目的是:生成随机不重复文件名,防止同名文件覆盖* 方法:获取.后面的图片类型 加上 随机数*/String filename = headerImage.getOriginalFilename();int index = filename.lastIndexOf(".");String suffix = filename.substring( index+1);//任何文件都可以上传,根据业务在此加限制.这里为没有后缀不合法if (StringUtils.isBlank(suffix) || index < 0) {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.pngUser user = hostHolder.getUser();String headerUrl = domain + contextPath + "/user/header/" + filename;userService.updateHeaderUrl(user.getId(), headerUrl);return "redirect:/index";}/*** 得到服务器图片* void:返回给浏览器的是特色的图片类型所以用void** @param fileName* @param response*/@GetMapping("/header/{fileName}")public void getHeader(@PathVariable("fileName") String fileName, HttpServletResponse response) {// 服务器存放路径(本地路径)fileName = uploadPath + "/" + fileName;// 文件后缀String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);// 浏览器响应图片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());}}}

修改密码

原密码加盐,md5加密判断新密码密码和原密码是否相等新密码加盐,md5加密写入数据库(Service层)

Service层

/*** 更新密码* @param userId* @param oldPassword* @param newPassword* @return map返回信息*/public Map<String, Object> updatePassword(int userId, String oldPassword,String newPassword) {Map<String, Object> map = new HashMap<>();//空值判断if(StringUtils.isBlank(oldPassword)){map.put("oldPasswordMsg","原密码不能为空");return map;}if(StringUtils.isBlank(newPassword)){map.put("newPasswordMsg","新密码不能为空");return map;}//根据userId获取对象User user = userMapper.selectById(userId);//旧密码加盐,加密oldPassword = CommunityUtil.md5(oldPassword+user.getSalt());//判断密码是否相等if(!user.getPassword().equals(oldPassword)){//不相等,返回map.put("oldPasswordMsg","原密码错误");return map;}//新密码加盐,加密newPassword = CommunityUtil.md5(newPassword+user.getSalt());user.setPassword(newPassword);userMapper.updateById(user);//map为空表示修改成功return map;}

Controller层

从ThreadLocal中拿userid

/*** 更新密码* @param oldPassword* @param newPassword* @param model* @return*/@LoginRequired@PostMapping("/updatePassword")public String updatePassword(String oldPassword, String newPassword,Model model){User user = hostHolder.getUser();Map<String, Object> map =userService.updatePassword(user.getId(), oldPassword, newPassword);if(map == null || map.isEmpty()){//成功!使用重定向,不然报错return "redirect:/index";}else{//失败model.addAttribute("oldPasswordMsg",map.get("oldPasswordMsg"));model.addAttribute("newPasswordMsg",map.get("newPasswordMsg"));return "/site/setting";}}

前端

<input type="password" th:class="|form-control ${oldPasswordMsg!=null?'is-invalid':''}|"th:value="${param.oldPassword!=null?param.oldPassword:''}"id="old-password" name="oldPassword" placeholder="请输入原始密码!" required>

th:value="${param.oldPassword!=null?param.oldPassword:''}"

用于修改失败后保存之前的输入密码

8. 帖子功能(业务)

8.1 过滤敏感词

使用前缀树来存储敏感词 :

根节点不包含字符,除根节点以外的每个节点,只包含一个字符

从根节点到某一个节点,路径上经过的字符连接起来,为该节点对应字符串

每个节点的所有子节点,包含的字符串不相同

核心 :

有一个指针1指向前缀树,用以遍历敏感词的每一个字符

有一个指针2指向被过滤字符串,用以标识敏感词的开头

有一个指针3指向被过滤字符串,用以标识敏感词的结尾

过滤算法实现

在resources创建sensitive-words.txt文敏感词文本

package com.qiuyu.utils;/*** 敏感词过滤器*/@Componentpublic class SensitiveFilter {private static final Logger logger = LoggerFactory.getLogger(SensitiveFilter.class);// 替换符private static final String REPLACEMENT = "***";// 根节点private TrieNode rootNode = new TrieNode();// 构造器之后运行@PostConstructpublic void init() {try (// 读取文件流 BufferedReader带缓冲区效率更高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值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);}}}/*** 过滤敏感词* @param text 待过滤的文本* @return 过滤后的文本*/public String filter(String text) {if (StringUtils.isBlank(text)) {return null;}// 指针1TrieNode tempNode = rootNode;// 指针2int begin = 0;// 指针3int position = 0;// 结果(StringBuilder:可变长度的String类)StringBuilder sb = new StringBuilder();//用position做结尾判断比begin指针少几次循环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();}// 判断是否为符号private boolean isSymbol(Character c) {// isAsciiAlphanumeric判断是否为字母或数字// 0x2E80~0x9FFF 是东亚文字范围return !CharUtils.isAsciiAlphanumeric(c) && (c < 0x2E80 || c > 0x9FFF);}// 内部类构造前缀树数据结构private class TrieNode {// 关键词结束标识private boolean isKeywordEnd = false;// 子节点(key是下级字符,value是下级节点)private Map<Character, TrieNode> subNodes = new HashMap<>();public boolean isKeywordEnd() {return isKeywordEnd;}public void setKeywordEnd(boolean keywordEnd) {isKeywordEnd = keywordEnd;}// 添加子节点public void addSubNode(Character c, TrieNode node) {subNodes.put(c, node);}// 获取子节点public TrieNode getSubNode(Character c) {return subNodes.get(c);}}}

8.2 发布帖子

核心:ajax异步:整个网页不刷新,访问服务器资源返回结果,实现局部的刷新。

实质:JavaScript和XML(但目前JSON的使用比XML更加普遍)

封装Fastjson工具类

导入FastJson

<dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>1.2.76</version></dependency>

/*** 使用fastjson,将JSON对象转为JSON字符串(前提要引入Fastjson)* @param code* @param msg* @param map* @return*/public static String getJSONString(int code, String msg, Map<String,Object> map){JSONObject json = new JSONObject();json.put("code",code);json.put("msg",msg);if (map != null) {//从map里的key集合中取出每一个keyfor (String key : map.keySet()) {json.put(key, map.get(key));}}return json.toJSONString();}public static String getJSONString(int code, String msg) {return getJSONString(code, msg, null);}public static String getJSONString(int code) {return getJSONString(code, null, null);}

ajax请求demo

<input type="button" value="发送" onclick="send();">//异步JS function send() {$.post("/community/test/ajax",{"name":"张三","age":25},//回调函数返回结果function(data) {console.log(typeof (data));console.log(data);//json字符串转js对象data = $.parseJSON(data);console.log(typeof (data));console.log(data.code);console.log(data.msg);})}

/*** Ajax异步请求示例*/@RequestMapping(value = "/ajax", method = RequestMethod.POST)@ResponseBodypublic String testAjax(String name, int age) {System.out.println(name);System.out.println(age);return CommunityUtil.getJSONString(200,"操作成功!");}

Service层

/*** 新增一条帖子* @param post 帖子* @return*/public int addDiscussPost(DiscussPost post){if(post == null){//不用map直接抛异常throw new IllegalArgumentException("参数不能为空!");}//转义< >等HTML标签为 &lt; &gt 让浏览器认为是普通字符,防止被注入post.setTitle(HtmlUtils.htmlEscape(post.getTitle()));post.setContent(HtmlUtils.htmlEscape(post.getContent()));//过滤敏感词post.setTitle(sensitiveFilter.filter(post.getTitle()));post.setContent(sensitiveFilter.filter(post.getContent()));return discussPostMapper.insert(post);}

Controller层

/*** 添加帖子* @param title 标题* @param content 内容* @return*/@PostMapping("/add")@ResponseBody// @LoginRequiredpublic String addDiscussPost(String title, String content){//获取当前登录的用户User user = hostHolder.getUser();if (user == null){//403权限不够return CommunityUtil.getJSONString(403,"你还没有登录哦!");}if(StringUtils.isBlank(title) || StringUtils.isBlank(content)){return CommunityUtil.getJSONString(222,"贴子标题或内容不能为空!");}DiscussPost post = new DiscussPost();post.setUserId(user.getId().toString());post.setTitle(title);post.setContent(content);post.setType(0);post.setStatus(0);post.setCreateTime(new Date());//业务处理,将用户给的title,content进行处理并添加进数据库discussPostService.addDiscussPost(post);//返回Json格式字符串给前端JS,报错的情况将来统一处理return CommunityUtil.getJSONString(0,"发布成功!");}

前端

注意:$.parseJSON(data) →通过jQuery,将服务端返回的JSON格式的字符串转为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",//与Controller层两个属性要一致!!!{"title":title,"content":content},function(data) {//把json字符串转化成Js对象,后面才可以调用data.msgdata = $.parseJSON(data);// 在提示框中显示返回消息$("#hintBody").text(data.msg);// 显示提示框$("#hintModal").modal("show");// 2秒后,自动隐藏提示框setTimeout(function(){$("#hintModal").modal("hide");// 成功,刷新页面if(data.code == 0) {window.location.reload();}}, 2000);});}

8.3 查看贴子详情

Service

/*** 通过id查找帖子* @param id* @return*/public DiscussPost findDiscussPostById(int id){return discussPostMapper.selectById(id);}

Controller

/*** 查看帖子详细页* @param discussPostId* @param model* @return*/@GetMapping( "/detail/{discussPostId}")public String getDiscussPost(@PathVariable("discussPostId") int discussPostId, Model model){//通过前端传来的Id查询帖子DiscussPost post = discussPostService.findDiscussPostById(discussPostId);model.addAttribute("post",post);//用以显示发帖人的头像及用户名User user = userService.findUserById(post.getUserId());model.addAttribute("user",user);return "/site/discuss-detail";}

这里查询了两次,后面使用redis优化

前端

<a th:href="@{|/discuss/detail/${map.post.id}|}" th:utext="${map.post.title}">标题</a>

如果在@{ }中想要常量和变量的拼接需要用两个| |

<b th:text="${#dates.format(post.createTime,'yyyy-MM-dd HH:mm:ss')}">时间</b>

#dates.format()用于格式化时间

8.4 事务管理

概念

1. 事务特性

2. 事务的隔离性

3.并发异常

第一类丢失更新

第二类丢失更新

脏读

不可重复读

幻读

事务1的回滚导致事务2更新的数据丢失了

事务1的提交导致事务2更新的数据丢失了

Spring声明式事务

方法:

**1.通过XML配置 **

2.通过注解@Transaction,如下:

/* REQUIRED: 支持当前事务(外部事务),如果不存在则创建新事务* REQUIRED_NEW: 创建一个新事务,并且暂停当前事务(外部事务)* NESTED: 如果当前存在事务(外部事务),则嵌套在该事务中执行(独立的提交和回滚),否则就会和REQUIRED一样* 遇到错误,Sql回滚 (A->B)*/@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)

propagation用于配置事务传播机制,既两个带事务的方法AB,方法A调用方法B,事务以哪个为准

REQUIRED外部事务为准,比如A调用B,以A为准,如果A有事务就按照A的事务来,如果A没有事务就创建一个新的事务

REQUIRED_NEW创建一个新的事务,比如A调用B,直接无视(暂停)A的事务,B自己创建一个新的事务

NESTED嵌套,如果当前存在事务(外部事务),则嵌套在该事务中执行(独立的提交和回滚),否则自己创建新事务

Spring编程式事务

控制粒度更低,比如一个方法要访问10次数据库,只有5次需要保证事务,就可以用编程式来控制,声明式会10次全都放入事务中

方法:通过TransactionTemplate组件执行SQL管理事务,如下:

public Object save2(){transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);//回调函数return transactionTemplate.execute(new TransactionCallback<Object>() {@Overridepublic Object doInTransaction(TransactionStatus status) {User user = new User();user.setUsername("Marry");user.setSalt(CommunityUtil.generateUUID().substring(0,5));user.setPassword(CommunityUtil.md5("123123")+user.getSalt());user.setType(0);user.setHeaderUrl("http://localhost:8080/2.png");user.setCreateTime(new Date());userMapper.insertUser(user);//设置error,验证事务回滚Integer.valueOf("abc");return "ok"; }});}

8.5 显示评论

Service层

package com.qiuyu.service;@Servicepublic class CommentService {@Autowiredprivate CommentMapper commentMapper;/*** 分页获得指定帖子的评论* @param entityType* @param entityId* @param page* @return*/public IPage<Comment> findCommentsByEntity(int entityType, int entityId, IPage<Comment> page) {LambdaQueryWrapper<Comment> wrapper = new LambdaQueryWrapper<>();wrapper.eq(Comment::getEntityType, entityType).eq(Comment::getEntityId, entityId);commentMapper.selectPage(page,wrapper);return page;}/*** 获取某个帖子评论的数量* @param entityType* @param entityId* @return*/public int findCommentCount(int entityType, int entityId){Integer count = commentMapper.selectCount(new LambdaQueryWrapper<Comment>().eq(Comment::getEntityType, entityType).eq(Comment::getEntityId, entityId));return count;}}

Controller层

/*** 查看帖子详细页* @param discussPostId* @param model* @return*/@GetMapping( "/detail/{discussPostId}")public String getDiscussPost(@PathVariable("discussPostId") int discussPostId, Model model, MyPage<Comment> page){//通过前端传来的Id查询帖子DiscussPost post = discussPostService.findDiscussPostById(discussPostId);model.addAttribute("post",post);//用以显示发帖人的头像及用户名User user = userService.findUserById(post.getUserId());model.addAttribute("user",user);//得到帖子的评论page.setSize(5);page.setPath("/discuss/detail/"+discussPostId);page = (MyPage<Comment>) commentService.findCommentsByEntity(ENTITY_TYPE_POST, post.getId(), page);//评论列表List<Comment> commentList = page.getRecords();// 评论: 给帖子的评论// 回复: 给评论的评论// 评论VO(viewObject)列表 (将comment,user信息封装到每一个Map,每一个Map再封装到一个List中)List<Map<String,Object>> commentVoList = new ArrayList<>();if(commentList != null){for (Comment comment : commentList) {//一条评论的VOMap<String, Object> commentVo = new HashMap<>(10);//评论commentVo.put("comment",comment);//评论作者commentVo.put("user",userService.findUserById(comment.getUserId().toString()));//回复Page<Comment> replyPage = new Page<>();replyPage.setCurrent(1);replyPage.setSize(Integer.MAX_VALUE);replyPage = (Page<Comment>) commentService.findCommentsByEntity(ENTITY_TYPE_COMMENT, comment.getId(), replyPage);//回复列表List<Comment> replyList = replyPage.getRecords();//回复的VO列表List<Map<String,Object>> replyVoList = new ArrayList<>();if(replyList != null){for (Comment reply : replyList) {//一条回复的VOMap<String, Object> replyVo = new HashMap<>(10);//回复replyVo.put("reply",reply);//回复的作者replyVo.put("user",userService.findUserById(reply.getUserId().toString()));//回复给谁User target = reply.getTargetId() == 0 ? null : userService.findUserById(reply.getTargetId().toString());replyVo.put("target",target);replyVoList.add(replyVo);}}//回复列表放入评论commentVo.put("reply",replyVoList);//评论的回复数量int replyCount = commentService.findCommentCount(ENTITY_TYPE_COMMENT, comment.getId());commentVo.put("replyCount",replyCount);commentVoList.add(commentVo);}}model.addAttribute("comments",commentVoList);model.addAttribute("page",page);return "/site/discuss-detail";}

前端

<li class="media pb-3 pt-3 mb-3 border-bottom" th:each="commentvo:${comments}">

th:each循环

<span th:text="${(page.current-1) * page.size + commentvoStat.count}">1</span>#

commentvoStat.count循环中默认带一个循环属性名+Stat的对象,使用count可以得到目前循环到第几个

<a th:href="|#huifu-${replyvoStat.count}|" data-toggle="collapse" >回复</a><div th:id="|huifu-${replyvoStat.count}|" class="mt-4 collapse"></div>

这俩进行了一个id绑定

8.6 添加评论(使用事务)

Service层

DiscussPostService

/*** 根据帖子id修改帖子的评论数量* @param id* @param commentCount* @return*/public int updateCommentCount(int id, int commentCount) {DiscussPost discussPost = new DiscussPost();discussPost.setId(id);discussPost.setCommentCount(commentCount);return discussPostMapper.updateById(discussPost);}

CommentService

/*** 添加评论(涉及事务)* 先添加评论,后修改discuss_post中的评论数(作为一个整体事务,出错需要整体回滚!)* @param comment* @return*/@Transactional(isolation = Isolation.REPEATABLE_READ,propagation = Propagation.REQUIRED)public int addComment(Comment comment){if(comment == null){throw new IllegalArgumentException("参数不能为空!");}/**添加评论**///过滤标签comment.setContent(HtmlUtils.htmlEscape(comment.getContent()));//过滤敏感词comment.setContent(sensitiveFilter.filter(comment.getContent()));//添加评论int rows =commentMapper.insert(comment);/*** 更新帖子评论数量* 如果是帖子类型才更改帖子评论数量,并且获取帖子评论的id*/if(comment.getEntityType() == ENTITY_TYPE_POST){//评论数int count = findCommentCount(comment.getEntityType(), comment.getEntityId());//更新数量discussPostService.updateCommentCount(comment.getEntityId(),count);}return rows;}

Controller层

前端

评论帖子

<form class="replyform" method="post" th:action="@{|/comment/add/${post.id}|}"><textarea placeholder="在这里畅所欲言你的看法吧!" name="content"></textarea><input type="hidden" name="entityType" th:value="1"><input type="hidden" name="entityId" th:value="${post.id}"><input type="hidden" name="targetId" th:value="0"></form>

使用hidden类型input进行传参

9. 私信功能(业务)

私信列表/详情

DAO层(难点)

package com.qiuyu.dao;@Mapperpublic interface MessageMapper extends BaseMapper<Message> {/*** 分页查询出当前用户的所有会话,以及会话中最新的一条消息* @param userId* @param page* @return*/IPage<Message> selectConversations(@Param("userId") Integer userId, IPage<Message> page);/*** 查询当前用户的会话数量* @param userId* @return*/int selectConversationCount(@Param("userId") int userId);/*** 查询某个会话所包含的私信列表* @param conversationId* @param page* @return*/IPage<Message> selectLetters(@Param("conversationId") String conversationId, IPage<Message> page);/*** 查询某个会话所包含的私信数量* @param conversationId* @return*/int selectLetterCount(@Param("conversationId") String conversationId);/*** 查询未读的数量* 1.带参数conversationId :私信未读数量* 2.不带参数conversationId :当前登录用户 所有会话未读数量*/int selectLetterUnreadCount(@Param("userId")int userId,@Param("conversationId") String conversationId);}

status = 2表示被删除from_id = 1表示为管理员发的(通知)

<?xml version="1.0" encoding="UTF-8" ?><!DOCTYPE mapperPUBLIC "-////DTD Mapper 3.0//EN""/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.qiuyu.dao.MessageMapper"><sql id="selectFields">id, from_id, to_id, conversation_id, content, status, create_time</sql><select id="selectConversations" resultType="Message">select<include refid="selectFields"></include>from community.messagewhere id in (select max(id)from community.messagewhere status != 2 and from_id != 1 and (from_id = #{userId} or to_id = #{userId})group by conversation_id)order by id DESC</select><select id="selectConversationCount" resultType="int">select count(m.maxid)from (select max(id) as maxidfrom community.messagewhere status != 2and from_id != 1and (from_id = #{userId} or to_id = #{userId})group by conversation_id) as m</select><select id="selectLetters" resultType="Message">select<include refid="selectFields"></include>from community.messagewhere status != 2 and from_id != 1and conversation_id = #{conversationId}order by id asc</select><select id="selectLetterCount" resultType="int">select count(id)from community.messagewhere status != 2and from_id != 1and conversation_id = #{conversationId}</select><select id="selectLetterUnreadCount" resultType="int">select count(id)from community.messagewhere status = 0 and from_id != 1and to_id = #{userId}<if test="conversationId!=null">and conversation_id = #{conversationId}</if></select></mapper>

Service层

package com.qiuyu.service;@Servicepublic class MessageService {@Autowiredprivate MessageMapper messageMapper;/*** 查询当前用户的会话列表,每个会话只返回一条最新消息* @param userId* @param page* @return*/public IPage<Message> findConversations(int userId, IPage<Message> page) {return page = messageMapper.selectConversations(userId, page);}/*** 查询当前用户的会话数量* @param userId* @return*/public int findConversationCount(int userId) {return messageMapper.selectConversationCount(userId);}/*** 查询某个会话中包含的所有消息* @param conversationId* @param page* @return*/public IPage<Message> findLetters(String conversationId, IPage<Message> page) {return messageMapper.selectLetters(conversationId, page);}/*** 查询某个会话中包含的消息数量* @param conversationId* @return*/public int findLetterCount(String conversationId) {return messageMapper.selectLetterCount(conversationId);}/*** 查询未读的私信的数量* @param userId* @param conversationId* @return*/public int findLetterUnreadCount(int userId, String conversationId) {return messageMapper.selectLetterUnreadCount(userId, conversationId);}}

Controller

私信列表

package com.qiuyu.controller;@Controller@RequestMapping("/letter")public class MessageController {@Autowiredprivate MessageService messageService;@Autowiredprivate HostHolder hostHolder;@Autowiredprivate UserService userService;@LoginRequired@GetMapping("/list")public String getLetterList(Model model, MyPage<Message> page){User user = hostHolder.getUser();//分页信息page.setSize(5);page.setPath("/letter/list");//会话列表page = (MyPage<Message>) messageService.findConversations(user.getId(),page);List<Message> conversationList = page.getRecords();//VOList<Map<String,Object>> conversationVo = new ArrayList<>();if(conversationList != null){for (Message message : conversationList) {Map<String, Object> map = new HashMap<>();map.put("conversation",message);//会话中的消息数map.put("letterCount", messageService.findLetterCount(message.getConversationId()));//未读消息数map.put("unreadCount", messageService.findLetterUnreadCount(user.getId(), message.getConversationId()));//显示的目标用户Integer targetId = user.getId().equals(message.getFromId()) ? message.getToId() : message.getFromId();map.put("target",userService.findUserById(targetId.toString()));conversationVo.add(map);}}model.addAttribute("conversations", conversationVo);// 当前登录用户总未读条数int letterUnreadCount = messageService.findLetterUnreadCount(user.getId(), null);model.addAttribute("letterUnreadCount", letterUnreadCount);model.addAttribute("page",page);return "/site/letter";}}

私信详情

@LoginRequired@GetMapping("/detail/{conversationId}")public String getLetterDetail(@PathVariable("conversationId") String conversationId, Model model, MyPage<Message> page) {//分页信息page.setSize(5);page.setPath("/letter/detail/" + conversationId);//获取私信信息page = (MyPage<Message>) messageService.findLetters(conversationId, page);List<Message> letterList = page.getRecords();//VOList<Map<String, Object>> letterVo = new ArrayList<>();if (letterList != null) {for (Message message : letterList) {Map<String, Object> map = new HashMap<>();map.put("letter", message);map.put("fromUser", userService.findUserById(message.getFromId().toString()));letterVo.add(map);}}model.addAttribute("letters", letterVo);//获取私信目标model.addAttribute("target", getLetterTarget(conversationId));model.addAttribute("page", page);return "/site/letter-detail";}/*** 封装获取目标会话用户(将如:101_107拆开)** @param conversationId* @return*/private User getLetterTarget(String conversationId) {String[] s = conversationId.split("_");Integer id0 = Integer.parseInt(s[0]);Integer id1 = Integer.parseInt(s[1]);//不是会话中的用户int userId = hostHolder.getUser().getId();if(userId != id0 && userId != id1){throw new IllegalArgumentException("无权限查看");}//当前用户是哪个就选另一个Integer target = hostHolder.getUser().getId().equals(id0) ? id1 : id0;return userService.findUserById(target.toString());}

前端

<button type="button" onclick="back();">返回</button><script>function back(){location.href = CONTEXT_PATH + "/letter/list"}</script>

发送私信

Dao

/*** 插入会话* @param message* @return*/int insertMessage(Message message);/*** 批量更改每个会话的所有未读消息为已读* @param ids* @param statuss* @return*/int updateStatus(@Param("ids") List<Integer> ids,@Param("status") int status);

<insert id="insertMessage" parameterType="Message" keyProperty="id">insert into community.message(<include refid="insertFields"></include>)values(#{fromId},#{toId},#{conversationId},#{content},#{status},#{createTime})</insert><update id="updateStatus">update community.message set status = #{status}where id in<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach></update>

foreach 批量插入id

Service

/*** 添加消息* @param message* @return*/public int addMessage(Message message){//转义标签message.setContent(HtmlUtils.htmlEscape(message.getContent()));//过滤敏感词message.setContent(sensitiveFilter.filter(message.getContent()));return messageMapper.insertMessage(message);}/*** 把多个消息都设为已读* @param ids* @return*/public int readMessage(List<Integer> ids){return messageMapper.updateStatus(ids, 1);}

Controller

设置为已读

@LoginRequired@GetMapping("/detail/{conversationId}")public String getLetterDetail(@PathVariable("conversationId") String conversationId, Model model, MyPage<Message> page) {/*** 以上省略。。。。。。*///消息设置已读(当打开这个页面是就更改status =1)List<Integer> ids = getLetterIds(letterList);if (!ids.isEmpty()) {messageService.readMessage(ids);}}/*** 获得批量私信的未读数id* @param letterList* @return*/private List<Integer> getLetterIds(List<Message> letterList){List<Integer> ids = new ArrayList<>();if (letterList != null) {for (Message message : letterList) {//只有当前登录用户与message列表中目标用户一致并且staus = 0 时才是未读数,加入未读私信集合if (hostHolder.getUser().getId().equals(message.getToId()) && message.getStatus() == 0) {ids.add(message.getId());}}}return ids;}

发送私信

/*** 发送消息* @param toName* @param content* @return*/@PostMapping("/send")@ResponseBodypublic String sendLetter(String toName, String content){//获得发送目标User target = userService.findUserByName(toName);if (target == null){return CommunityUtil.getJSONString(1,"目标用户不存在!");}//设置message属性Message message = new Message();message.setFromId(hostHolder.getUser().getId());message.setToId(target.getId());message.setContent(content);message.setCreateTime(new Date());// conversationId (如101_102: 小_大)if (message.getFromId() < message.getToId()) {message.setConversationId(message.getFromId() + "_" +message.getToId());}else{message.setConversationId(message.getToId() + "_" +message.getFromId());}messageService.addMessage(message);return CommunityUtil.getJSONString(0,"发送消息成功!");}

10. 统一处理异常

异常都会扔到表现层中,所以只要处理Controller层就行了

1.将404.html或500.html放在templates/error下

注意:springboot默认在templates资源路径下面新建error目录,添加404.html和500.html页面就会自动配置上错误页面自动跳转

2.定义一个控制器通知组件,处理所有Controller所发生的异常

//annotations只扫描带Controller注解的Bean@ControllerAdvice(annotations = Controller.class)public class ExceptionAdvice {public static final Logger logger = LoggerFactory.getLogger(ExceptionAdvice.class);//发生异常时会被调用@ExceptionHandlerpublic void handleException(Exception e, HttpServletRequest request, HttpServletResponse response) throws IOException {logger.error("服务器发生异常:" + e.getMessage());// 循环打印异常栈中的每一条错误信息并记录for (StackTraceElement element : e.getStackTrace()) {logger.error(element.toString());}// 判断请求返回的是一个页面还是异步的JSON格式字符串String xRequestedWith = request.getHeader("x-requested-with");// XMLHttpRequest: Json格式字符串if ("XMLHttpRequest".equals(xRequestedWith)) {// 要求以JSON格式返回response.setContentType("application/plain;charset=utf-8");PrintWriter writer = response.getWriter();writer.write(CommunityUtil.getJSONString(1, "服务器异常!"));} else {//普通请求直接重定向到错误页面 response.sendRedirect(request.getContextPath() + "/error");}}}

11. 统一处理日志

为什么不用@ExceptionHandler异常处理来做日志 没有异常的时候,也需要做日志 为什么不用拦截器做日志 拦截器只能拦截Controller层,Service和Dao可能也需要做日志 为什么不在每个写个日志类放入Spring中,在需要写日志的时候直接用 耦合性太高,日志属于系统需求,和业务需求应该分开

1.AOP概念(面向切面编程)

编译,类装载,运行时,都能进行织入

我们想要插入的代码放在**切面(Aspect)**中

切面中的代码放入目标对象的过程称为织入(Weaving)

切面中的代码织入目标对象的位置称为连接点(Joinpoint)

Pointcut用来指明切面中的代码要放到目标对象的哪些地方(连接点)

**通知(Advice)**指明织入到目标对象时的逻辑(在连接点的前后左右这些)

常见的使用场景有:权限检查、记录日志、事务管理

连接点(Joinpoint):目标对象上织入代码的位置叫做joinpointPointcut:是用来定义当前的横切逻辑准备织入到哪些连接点上 (如service所有方法)通知(Advice):用来定义横切逻辑,即在连接点上准备织入什么样的逻辑切面(Aspect):是一个用来封装切点和通知的组件织入(Weaving):就是将方面组件中定义的横切逻辑,织入到目标对象的连接点的过程

2.AOP是如何实现的

AspectJ是一门新的语言,在编译期织入代码Spring AOP 纯Java ,通过代理运行时织入代码 JDK动态代理需要目标类实现了接口才行CGLib 采用创建子类来进行代理

3.AOP切面编程demo

先导包

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>

@Aspect代表这个类是个切面@Pointcut定义一下织入的位置

@Component@Aspectpublic class DemoAspect {// 返回值 包名.类名.方法名(方法参数) *表示所有 ..表示全部参数@Pointcut("execution(* com.qiuyu.demonowcoder.service.*.*(..))")public void pointcut(){}//切点方法之前执行(常用)@Before("pointcut()")public void before(){System.out.println("before");}@After("pointcut()")public void after(){System.out.println("after");}/**返回值以后执行**/@AfterReturning("pointcut()")public void afterRetuning() {System.out.println("afterRetuning");}/**抛出异常以后执行**/@AfterThrowing("pointcut()")public void afterThrowing() {System.out.println("afterThrowing");}/**切点的前和后都可以执行**/@Around("pointcut()")public Object around(ProceedingJoinPoint joinPoint) throws Throwable{System.out.println("around before");Object obj = joinPoint.proceed();System.out.println("around after");return obj;}}

4.AOP实现统一记录日志

实现需求:用户ip[1.2.3.4],在[时间],访问了[com.qiuyu.service.xxx()].

package com.qiuyu.aspect;@Component@Aspectpublic class ServiceLogAspect {public static final Logger logger = LoggerFactory.getLogger(ServiceLogAspect.class);@Pointcut("execution(* com.qiuyu.service.*.*(..))")public void pointCut(){}@Before("pointCut()")public void before(JoinPoint joinPoint){// 用户ip[1.2.3.4],在[时间],访问了[com.qiuyu.service.xxx()].// 通过RequestContextHolder工具类获取requestServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();HttpServletRequest request = attributes.getRequest();// 通过request.getRemoteHost获取当前用户ipString ip = request.getRemoteHost();String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());/*** joinPoint.getSignature().getDeclaringTypeName()-->得到类名com.qiuyu.service.** joinPoint.getSignature().getName() -->方法名*/String target = joinPoint.getSignature().getDeclaringTypeName() + "." +joinPoint.getSignature().getName();// String.format()加工字符串logger.info(String.format("用户[%s],在[%s],访问了[%s]业务.", ip, time, target));}}

12.Redis

Redis入门

Key都是String类型 Value支持多种数据结构快照方式存储(rdb),体积小,但是不适合实时去做,速度较慢,适合几个小时做一次日志方式存储(aof),体积大,每执行一个redis目录就以日志方式存一次,适合实时去做,恢复的时候把所有命令跑一遍

Windows下载redis

redis官方只提供linux版本

下载微软提供的redis

/microsoftarchive/redis/releases/tag/win-3.2.100

Redis命令

基础命令

redis-cli连接redis

select [0-11]选择使用的库,redis一共12个库

flushdb把数据刷新(删除)

keys *查看所有的key

keys test*查看test开头的key

type test:teachers查看某个key的类型

exists test:teachers查看某个key是否存在

del test:teachers删除某个key

expire test:ids 5五秒后key过期,删除

string字符串类型操作

redis中两个单词之间的分割不是驼峰也不是下划线,建议使用冒号:

set test:count 1 #添加数据,设置test:count的值为1,1的类型为字符串get test:count#获取数据,得到结果为"1"incr test:count#指定数据加一,结果为(integer) 2decr test:count#指定数据减一,结果为(integer) 1

hash哈希操作

Redis hash 是一个 string 类型的 field(字段) 和 value(值) 的映射表,hash 特别适合用于存储对象。

127.0.0.1:6379> hset test:user id 1(integer) 1127.0.0.1:6379> hset test:user username zhangsan(integer) 1127.0.0.1:6379> hget test:user id"1"127.0.0.1:6379> hget test:user username"zhangsan"

list列表操作

redis中的list为双端队列,左右都可存取

127.0.0.1:6379> lpush test:ids 101 102 103 #左侧依次放入(integer) 3127.0.0.1:6379> llen test:ids #列表长度(integer) 3127.0.0.1:6379> lindex test:ids 0 #根据索引查找"103"127.0.0.1:6379> lrange test:ids 0 2 #查看索引范围内的元素1) "103"2) "102"3) "101"127.0.0.1:6379> rpush test:ids 100 #右端插入(integer) 4127.0.0.1:6379> lpop test:ids #左侧弹出一个元素"103"127.0.0.1:6379> rpop test:ids #右侧弹出一个元素"100"

set集合操作

Redis 的 Set 是 String 类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。

127.0.0.1:6379> sadd test:teachers aaa bbb ccc #放入集合3127.0.0.1:6379> scard test:teachers #查看个数3127.0.0.1:6379> spop test:teachers #随机弹出一个ccc127.0.0.1:6379> smembers test:teachers #查看所有元素bbbaaa

sorted set

Redis 有序集合和集合(set)一样也是 string 类型元素的集合,且不允许重复的成员。

不同的是每个元素都会关联一个 double 类型的分数。redis 正是通过分数来为集合中的成员进行从小到大的排序。

127.0.0.1:6379> zadd test:students 10 aaa 20 bbb 30 ccc 40 ddd 50 eee # 插入需要写分数5127.0.0.1:6379> zcard test:students #查看个数5127.0.0.1:6379> zscore test:students bbb #查看指定的元素的分数7.0.0.1:6379> zrank test:students bbb #查看指定元素的排名(从0开始)1127.0.0.1:6379> zrange test:students 0 2 #按照分数,由小到大排序,第0-2个aaabbbccc

Spring整合Redis

导包

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency

配置

spring:#redisredis:database: 11 #16个库用哪个host: localhostport: 6379

自带的RedisTemplate为Objtct,Object类型 我们这里使用String,Object就行

package com.qiuyu.config;@Configurationpublic class RedisConfig {@Beanpublic RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {RedisTemplate<String,Object> template = new RedisTemplate<>();template.setConnectionFactory(redisConnectionFactory);//设置key的序列化方法template.setKeySerializer(RedisSerializer.string());//设置value的序列化方法template.setKeySerializer(RedisSerializer.json());//设置hash的key序列化方式template.setKeySerializer(RedisSerializer.string());//设置hash的value序列化方式template.setHashValueSerializer(RedisSerializer.json());//让配置生效template.afterPropertiesSet();return template;}}

建议用下面这个

package com.qiuyu.config;@Configurationpublic class RedisConfig {@Beanpublic RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {RedisTemplate<String,Object> template = new RedisTemplate<>();template.setConnectionFactory(redisConnectionFactory);Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);ObjectMapper om = new ObjectMapper();om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);jackson2JsonRedisSerializer.setObjectMapper(om);StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();// key采用String的序列化方式template.setKeySerializer(stringRedisSerializer);// hash的key也采用String的序列化方式template.setHashKeySerializer(stringRedisSerializer);// value序列化方式采用jacksontemplate.setValueSerializer(jackson2JsonRedisSerializer);// hash的value序列化方式采用jacksontemplate.setHashValueSerializer(jackson2JsonRedisSerializer);//让配置生效template.afterPropertiesSet();return template;}}

使用

@SpringBootTest@RunWith(SpringRunner.class)public class RedisTest {@Autowiredprivate RedisTemplate redisTemplate;@Testpublic void testRedis(){String redisKey = "test:redis";redisTemplate.opsForValue().set(redisKey,1);System.out.println(redisTemplate.opsForValue().get(redisKey));}//Hash@Testpublic void testHash(){String redisKey = "test:redis2";redisTemplate.opsForHash().put(redisKey,"id",6);redisTemplate.opsForHash().put(redisKey,"username","qiuyu");Object id = redisTemplate.opsForHash().get(redisKey, "id");Object username = redisTemplate.opsForHash().get(redisKey, "username");System.out.println(id);System.out.println(username);}//List@Testpublic void testList(){String redisKey = "test:redis3";redisTemplate.opsForList().leftPush(redisKey,101);redisTemplate.opsForList().leftPush(redisKey,102);redisTemplate.opsForList().leftPush(redisKey,103);System.out.println(redisTemplate.opsForList().size(redisKey));System.out.println(redisTemplate.opsForList().index(redisKey,0));System.out.println(redisTemplate.opsForList().range(redisKey,0,2));System.out.println(redisTemplate.opsForList().rightPop(redisKey));System.out.println(redisTemplate.opsForList().rightPop(redisKey));/*3103[103, 102, 101]101102*/}//Set@Testpublic void testSet(){String redisKey = "test:redis4";redisTemplate.opsForSet().add(redisKey,"bbb","ccc","aaa");System.out.println(redisTemplate.opsForSet().size(redisKey));System.out.println(redisTemplate.opsForSet().pop(redisKey));System.out.println(redisTemplate.opsForSet().members(redisKey));/*3bbb[aaa, ccc]*/}//Zset@Testpublic void testZSet(){String redisKey = "test:redis5";redisTemplate.opsForZSet().add(redisKey,"aaa",80);redisTemplate.opsForZSet().add(redisKey,"bbb",90);redisTemplate.opsForZSet().add(redisKey,"ccc",60);redisTemplate.opsForZSet().add(redisKey,"ddd",100);redisTemplate.opsForZSet().add(redisKey,"eee",50);System.out.println(redisTemplate.opsForZSet().size(redisKey));System.out.println(redisTemplate.opsForZSet().score(redisKey,"bbb"));System.out.println(redisTemplate.opsForZSet().rank(redisKey,"bbb"));System.out.println(redisTemplate.opsForZSet().reverseRank(redisKey,"bbb"));System.out.println(redisTemplate.opsForZSet().range(redisKey,0,2));System.out.println(redisTemplate.opsForZSet().reverseRange(redisKey,0,2));/*590.031[eee, ccc, aaa][ddd, bbb, aaa]*/}//Keys操作@Testpublic void testKeys(){redisTemplate.delete("aaa");System.out.println(redisTemplate.hasKey("aaa"));redisTemplate.expire("test:redis",10, TimeUnit.SECONDS);}//多次复用Key@Testpublic void testBoundOperations(){String redisKey = "test:count3";BoundValueOperations operations = redisTemplate.boundValueOps(redisKey);operations.set(1);//报错// operations.increment();// operations.increment();// operations.increment();System.out.println(operations.get());}//编程式事务@Testpublic void testTransaction(){Object obj = redisTemplate.execute(new SessionCallback() {@Overridepublic Object execute(RedisOperations operations) throws DataAccessException {String redisKey = "test:tx";//启用事务operations.multi();operations.opsForSet().add(redisKey,"zhangsan");operations.opsForSet().add(redisKey,"lisi");operations.opsForSet().add(redisKey,"wangwu");//redis会把这些操作放在队列中.提交事务时才执行,所以此时还没有数据System.out.println(operations.opsForSet().members(redisKey));//提交事务return operations.exec();}});System.out.println(obj);//[]//[1, 1, 1, [lisi, zhangsan, wangwu]]}}

13.点赞功能(Redis+ajax)

点赞/取消点赞

1.工具类

用于获取统一格式化的Key

package com.qiuyu.utils;public class RedisKeyUtil {private static final String SPLIT = ":";private static final String PREFIX_ENTITY_LIKE = "like:entity";/*** 某个实体的赞* key= like:entity:entityType:entityId -> set(userId)*/public static String getEntityLikeKey(int entityType, int entityId){return PREFIX_ENTITY_LIKE + SPLIT + entityType + SPLIT + entityId;}}

2.Service

package com.qiuyu.service;@Servicepublic class LikeService {@Autowiredprivate RedisTemplate redisTemplate;// 点赞 (记录谁点了哪个类型哪个留言/帖子id)public void like(int userId, int entityType, int entityId){String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);//判断like:entity:entityType:entityId 是否有对应的 userIdBoolean isMember = redisTemplate.opsForSet().isMember(entityLikeKey, userId);// 第一次点赞,第二次取消点赞if (isMember){// 若已被点赞(即entityLikeKey里面有userId)则取消点赞->将userId从中移除redisTemplate.opsForSet().remove(entityLikeKey, userId);}else {redisTemplate.opsForSet().add(entityLikeKey, userId);}}// 查询某实体(帖子、留言)点赞的数量 --> scard like:entity:1:110public long findEntityLikeCount(int entityType, int entityId){String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);return redisTemplate.opsForSet().size(entityLikeKey);}// 显示某人对某实体的点赞状态public int findEntityLikeStatus(int userId, int entityType, int entityId){String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);// 1:已点赞 , 0:赞return redisTemplate.opsForSet().isMember(entityLikeKey, userId) ? 1 : 0;}}

3.Controller

package com.qiuyu.controller;@Controllerpublic class LikeController {@Autowiredprivate LikeService likeService;@Autowiredprivate HostHolder hostHolder;@GetMapping("/like")@ResponseBodypublic String like(int entityType, int entityId){User user = hostHolder.getUser();// 点赞likeService.like(user.getId(), entityType,entityId);// 获取对应帖子、留言的点赞数量long entityLikeCount = likeService.findEntityLikeCount(entityType, entityId);// 获取当前登录用户点赞状态(1:已点赞 0:赞)int entityLikeStatus = likeService.findEntityLikeStatus(user.getId(), entityType, entityId);Map<String,Object> map = new HashMap<>();map.put("likeCount",entityLikeCount);map.put("likeStatus",entityLikeStatus);return CommunityUtil.getJSONString(0,null,map);}}

4. 前端

弃用href,使用href="javascript:;"写法,如果直接删掉,鼠标放上去不会变为手形

使用onclick()

<a href="javascript:;" th:οnclick="|like(this,1,${post.id})|" class="text-primary" ><b>赞</b> <span>11</span></a>

function like(btn, entityType, entityId) {$.post(CONTEXT_PATH + "/like",{"entityType":entityType,"entityId":entityId},function(data) {data = $.parseJSON(data);if(data.code == 0){//点赞成功,通过子节点得到span$(btn).children("span").text(data.likeCount);$(btn).children("b").text(data.likeStatus==1?"已赞":"点赞");}else{alert(data.msg);}});}

还需要把首页和帖子页面进行修改,在进入页面时候读取点赞数和状态

我收到的赞

如果要查询某个人的被点赞数量,需要查到这个人的所有帖子,然后把每个帖子点赞数加起来,有点麻烦

我们可以添加一个维度,点赞的时候在redis中记录被点赞用户的被点赞个数

1.工具类

获取统一格式的key

k:v = like:user:userId -> set(userId)

public class RedisKeyUtil {...private static final String PREFIX_USER_LIKE = "like:user";.../*** 某个用户收到的赞* @param userId* @return*/public static String getUserLikeKey(int userId){return PREFIX_USER_LIKE + SPLIT + userId;}}

2.Service

一是更新帖子/评论点赞数而是更新用户的被点赞数

使用事务进行控制

/*** 点赞 (记录谁点了哪个类型哪个留言/帖子id)* 同时给用户的点赞数加一* 因为要进行多个操作,采用事务* @param userId* @param entityType* @param entityId* @param entityUserId 实体的作者的Id,这里在页面直接传进来,不然使用数据库查太慢了*/public void like(int userId, int entityType, int entityId, int entityUserId){redisTemplate.execute(new SessionCallback() {@Overridepublic Object execute(RedisOperations operations) throws DataAccessException {String entityLikeKey = RedisKeyUtil.getEntityLikeKey(entityType, entityId);String userLikeKey = RedisKeyUtil.getUserLikeKey(entityUserId);//查询需要在事务之外Boolean isMember = redisTemplate.opsForSet().isMember(entityLikeKey, userId);//开启事务operations.multi();// 第一次点赞,第二次取消点赞if (isMember){// 若已被点赞,实体类移除点赞者,实体作者点赞数-1redisTemplate.opsForSet().remove(entityLikeKey, userId);redisTemplate.opsForValue().decrement(userLikeKey);}else {redisTemplate.opsForSet().add(entityLikeKey, userId);redisTemplate.opsForValue().increment(userLikeKey);}//提交事务return operations.exec();}});}//查询用户的点赞数public long findUserLikeCount(int userId){String userLikeKey = RedisKeyUtil.getUserLikeKey(userId);Long count = (Long) redisTemplate.opsForValue().get(userLikeKey);return count == null ? 0 : count;}

3.Controller

修改LikeController,加入entityUserId参数

@PostMapping("/like")@ResponseBodypublic String like(int entityType, int entityId,int entityUserId){User user = hostHolder.getUser();// 点赞likeService.like(user.getId(), entityType,entityId,entityUserId);// 获取对应帖子、留言的点赞数量long entityLikeCount = likeService.findEntityLikeCount(entityType, entityId);// 获取当前登录用户点赞状态(1:已点赞 0:赞)int entityLikeStatus = likeService.findEntityLikeStatus(user.getId(), entityType, entityId);Map<String,Object> map = new HashMap<>();map.put("likeCount",entityLikeCount);map.put("likeStatus",entityLikeStatus);return CommunityUtil.getJSONString(0,null,map);}

4.前端

onclick进行修改

th:onclike传入两个参数会报错

定义多个th:data-*

<a href="javascript:;" th:data-id="${post.id}" th:data-userId="${post.userId}"th:onclick="|like(this,1,this.getAttribute('data-id'),this.getAttribute('data-userId'))|">

||去掉,用[[ ]]将参数包围

<a href="javascript:;" th:onclick="like(this,1,[[${post.id}]],[[${post.userId}]])">

js进行修改

function like(btn, entityType, entityId, entityUserId) {$.post(CONTEXT_PATH + "/like",{"entityType": entityType, "entityId": entityId, "entityUserId": entityUserId},function (data) {data = $.parseJSON(data);if (data.code == 0) {//点赞成功,通过子节点得到span$(btn).children("span").text(data.likeCount);$(btn).children("b").text(data.likeStatus == 1 ? "已赞" : "赞");} else {alert(data.msg);}});}

个人首页

@GetMapping("/profile/{userId}")public String getProfilePage(@PathVariable("userId") int userId, Model model) {User user = userService.findUserById(String.valueOf(userId));if (user == null) {throw new RuntimeException("该用户不存在!");}model.addAttribute("user", user);// 进入某用户主页获取他(我)的点赞数量int likeCount = likeService.findUserLikeCount(userId);model.addAttribute("likeCount", likeCount);return "/site/profile";}

14.关注功能(Redis+ajax)

关注/取关

1.工具类

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

2.Service

package com.qiuyu.service;@Servicepublic class FollowService {@Autowiredprivate RedisTemplate redisTemplate;/*** 关注某个实体* @param userId* @param entityType* @param entityId*/public void follow(int userId, int entityType, int entityId) {redisTemplate.execute(new SessionCallback(){@Overridepublic Object execute(RedisOperations operations) throws DataAccessException {String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);operations.multi();/*** System.currentTimeMillis()->用于获取当前系统时间,以毫秒为单位* 关注时,首先将实体(用户或帖子)id添加用户关注的集合中,再将用户id添加进实体粉丝的集合中*/redisTemplate.opsForZSet().add(followeeKey,entityId,System.currentTimeMillis());redisTemplate.opsForZSet().add(followerKey,userId,System.currentTimeMillis());return operations.exec();}});}/*** 取消关注* @param userId* @param entityType* @param entityId*/public void unfollow(int userId, int entityType, int entityId) {redisTemplate.execute(new SessionCallback(){@Overridepublic Object execute(RedisOperations operations) throws DataAccessException {String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);operations.multi();//关注时,首先将实体(用户或帖子)id移除用户关注的集合中,再将用户id移除进实体粉丝的集合中redisTemplate.opsForZSet().remove(followeeKey,entityId);redisTemplate.opsForZSet().remove(followerKey,userId);return operations.exec();}});}/*** 某个用户的关注的实体数量* @param userId* @param entityType* @return*/public long findFolloweeCount(int userId, int entityType) {String followeeKey = RedisKeyUtil.getFolloweeKey(userId, entityType);return redisTemplate.opsForZSet().zCard(followeeKey);}/*** 查询某个实体的粉丝数* @param entityType* @param entityId* @return*/public long findFollowerCount(int entityType, int entityId) {String followerKey = RedisKeyUtil.getFollowerKey(entityType, entityId);return redisTemplate.opsForZSet().zCard(followerKey);}/*** 当前用户是否关注了该实体* userId->当前登录用户 entityType->用户类型 entityId->关注的用户id* @param userId* @param entityType* @param entityId* @return*/public boolean hasFollowed(int userId, int entityType, int entityId) {String followeeKey =RedisKeyUtil.getFolloweeKey(userId, entityType);//查下score是否为空return redisTemplate.opsForZSet().score(followeeKey, entityId) != null;}}

3.Controller

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

package com.qiuyu.controller;@Controllerpublic class FollowController {@Autowiredprivate FollowService followService;@Autowiredprivate HostHolder hostHolder;/*** 关注* @param entityType* @param entityId* @return*/@PostMapping("/follow")@ResponseBodypublic String follow(int entityType, int entityId) {followService.follow(hostHolder.getUser().getId(), entityType, entityId);return CommunityUtil.getJSONString(0,"已关注");}/*** 取消关注* @param entityType* @param entityId* @return*/@PostMapping("/unfollow")@ResponseBodypublic String unfollow(int entityType, int entityId) {followService.unfollow(hostHolder.getUser().getId(), entityType, entityId);return CommunityUtil.getJSONString(0,"已取消关注");}}

用户个人页显示数据UserController

// 关注数量(这里只考虑关注用户类型的情况)long followeeCount = followService.findFolloweeCount(userId, ENTITY_TYPE_USER);model.addAttribute("followeeCount", followeeCount);// 粉丝数量long followerCount = followService.findFollowerCount(ENTITY_TYPE_USER, userId);model.addAttribute("followerCount", followerCount);// 是否已关注 (必须是用户登录的情况)boolean hasFollowed = false;if (hostHolder.getUser() != null) {hasFollowed = followService.hasFollowed(hostHolder.getUser().getId(), ENTITY_TYPE_USER, userId);}model.addAttribute("hasFollowed", hasFollowed);

4.前端

<input type="hidden" id="entityId" th:value="${user.id}"><button type="button" th:class="|btn ${hasFollowed?'btn-secondary':'btn-info'} btn-sm float-right mr-5 follow-btn|"th:text="${hasFollowed?'已关注':'关注他'}" th:if="${loginUser!=null && loginUser.id!=user.id}">关注TA</button>

使用样式btn-info来判断关注还是取关

$(function () {$(".follow-btn").click(follow);});function follow() {var btn = this;if ($(btn).hasClass("btn-info")) {// 关注TA$.post(CONTEXT_PATH + "/follow",// "entityId":$(btn).prev().val() 获取btn按钮上一个的值{"entityType": 3, "entityId": $(btn).prev().val()},function (data) {data = $.parseJSON(data);if (data.code == 0) {window.location.reload();} else {alert(data.msg);}});} else {console.log(123);// 取消关注$.post(CONTEXT_PATH + "/unfollow",{"entityType": 3, "entityId": $(btn).prev().val()},function (data) {data = $.parseJSON(data);if (data.code == 0) {window.location.reload();} else {alert(data.msg);}});}}

关注/粉丝列表

1.Service

/*** 查询某用户关注的人* @param userId* @return*/public List<Map<String, Object>> findFollowees(int userId, int offset, int limit){String followeeKey = RedisKeyUtil.getFolloweeKey(userId, ENTITY_TYPE_USER);// 按最新时间倒序查询目标用户id封装在set<Integet>中Set<Integer> targetIds = redisTemplate.opsForZSet().reverseRange(followeeKey, offset, offset + limit - 1);if (targetIds == null) {return null;}// 将user信息Map和redis用户关注时间Map一起封装到listArrayList<Map<String, Object>> list = new ArrayList<>();for (Integer targetId: targetIds) {HashMap<String, Object> map = new HashMap<>();// 用户信息mapUser user = userService.findUserById(String.valueOf(targetId));map.put("user", user);// 目标用户关注时间map(将long型拆箱成基本数据类型)Double score = redisTemplate.opsForZSet().score(followeeKey, targetId);map.put("followTime", new Date(score.longValue()));list.add(map);}return list;}/*** 查询某用户粉丝列表* @param userId* @param offset* @param limit* @return*/public List<Map<String, Object>> findFollowers(int userId, int offset, int limit){String followerKey = RedisKeyUtil.getFollowerKey(ENTITY_TYPE_USER, userId);// 按最新时间倒序查询目标用户id封装在set<Integet>中Set<Integer> targetIds = redisTemplate.opsForZSet().reverseRange(followerKey, offset, offset + limit - 1);if (targetIds == null) {return null;}// 将user信息Map和redis用户关注时间Map一起封装到listArrayList<Map<String, Object>> list = new ArrayList<>();for (Integer targetId: targetIds) {HashMap<String, Object> map = new HashMap<>();// 用户信息mapUser user = userService.findUserById(targetId.toString());map.put("user", user);// 目标用户关注时间map(将long型拆箱成基本数据类型)Double score = redisTemplate.opsForZSet().score(followerKey, targetId);map.put("followTime", new Date(score.longValue()));list.add(map);}return list;}

2.Controller

//查询某用户关注列表@GetMapping("/followees/{userId}")public String getFollowees(@PathVariable("userId")int userId, Page page, Model model) {// 当前访问的用户信息User user = userService.findUserById(String.valueOf(userId));// Controller层统一处理异常if (user == null) {throw new RuntimeException("该用户不存在!");}model.addAttribute("user", user);// 设置分页信息page.setLimit(3);page.setPath("/followees/" + userId);page.setRows((int) followService.findFolloweeCount(userId, ENTITY_TYPE_USER));List<Map<String, Object>> userList = followService.findFollowees(userId, page.getOffset(), page.getLimit());if (userList != null) {for (Map<String, Object> map : userList) {User u = (User) map.get("user");map.put("hasFollowed", hasFollowed(u.getId()));}}model.addAttribute("users", userList);return "/site/followee";}/*** 判端当前登录用户与关注、粉丝列表的关注关系* @param userId* @return*/private Boolean hasFollowed(int userId) {if (hostHolder.getUser() == null) {return false;}// 调用当前用户是否已关注user实体Servicereturn followService.hasFollowed(hostHolder.getUser().getId(), ENTITY_TYPE_USER, userId);}//查询某用户粉丝列表@GetMapping("/followers/{userId}")public String getFollowers(@PathVariable("userId")int userId, Page page, Model model) {// 当前访问的用户信息User user = userService.findUserById(String.valueOf(userId));// Controller层统一处理异常if (user == null) {throw new RuntimeException("该用户不存在!");}model.addAttribute("user", user);// 设置分页信息page.setLimit(3);page.setPath("/followers/" + userId);page.setRows((int) followService.findFollowerCount(ENTITY_TYPE_USER, userId));List<Map<String, Object>> userList = followService.findFollowers(userId, page.getOffset(), page.getLimit());if (userList != null) {for (Map<String, Object> map : userList) {User u = (User) map.get("user");map.put("hasFollowed", hasFollowed(u.getId()));}}model.addAttribute("users", userList);return "/site/follower";}}

3.前端

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

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。