当前位置: 首页 > wzjs >正文

数码网站建设佛山网站搜索排名

数码网站建设,佛山网站搜索排名,带做网站绿标,网站的建设与管理系统代码仓库:https://gitee.com/qiuyusy/community 目录1. Spring在测试类中使用Spring环境Primary的作用PostConstruct PreDestroy导入外部包的Bean2. SpringMVC2.1 HTTP请求响应2.2 SpringMVC原理2.3 Thymeleaf3. Mybatis-Plus4. 开发社区首页(业务)分页工具类4.1 Dao4.2 Servic…

代码仓库:https://gitee.com/qiuyusy/community

目录

  • 1. Spring
    • 在测试类中使用Spring环境
    • @Primary的作用
    • @PostConstruct @PreDestroy
    • 导入外部包的Bean
  • 2. SpringMVC
    • 2.1 HTTP
      • 请求
      • 响应
    • 2.2 SpringMVC原理
    • 2.3 Thymeleaf
  • 3. Mybatis-Plus
  • 4. 开发社区首页(业务)
    • 分页工具类
    • 4.1 Dao
    • 4.2 Service
    • 4.3 Controller
    • 4.4 前端页面设计(Thymeleaf)
      • 接受Model中的数据,并循环显示
        • th:each 循环遍历
        • th:src
        • th:utext th:text
        • th: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 会话
      • Cookie
      • Session
    • 6.2 验证码
      • 刷新验证码
        • HTML
        • JS
    • 6.3 登录实现
      • Bean
      • DAO
      • Service
      • Controller
      • 前端
        • 输入错误保留之前的输入
        • 输入错误的提示
    • 6.4 登出实现
      • Service层
      • Controller层
    • 6.5 显示登录信息
      • 拦截器demo
        • 1. 创建拦截器类,实现HandlerInterceptor接口
        • 2. 创建拦截器配置类,实现WebMvcConfigurer接口
      • 1. 首先创建两个工具类降低耦合
      • 2. DAO层
      • 3. 创建登录凭证拦截器(等同于Controller层)
      • 4. 编写拦截配置类
      • 5. 前端
    • 6.6 拦截未登录页面访问(采用注解)
      • 1. 写一个注解@LoginRequired
      • 2. 给需要的方法加上注解
      • 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请求demo
      • Service层
      • Controller层
      • 前端
    • 8.3 查看贴子详情
      • Service
      • Controller
      • 前端
    • 8.4 事务管理
      • 概念
        • 1. 事务特性
        • 2. 事务的隔离性
        • 3.并发异常
      • Spring声明式事务
      • Spring编程式事务
    • 8.5 显示评论
      • Service层
      • Controller层
      • 前端
    • 8.6 添加评论(使用事务)
      • Service层
      • Controller层
      • 前端
  • 9. 私信功能(业务)
    • 私信列表/详情
      • DAO层(难点)
      • Service层
      • Controller
        • 私信列表
        • 私信详情
      • 前端
    • 发送私信
      • Dao
      • Service
      • Controller
        • 设置为已读
        • 发送私信
  • 10. 统一处理异常
    • 1.将404.html或500.html放在templates/error下
    • 2.定义一个控制器通知组件,处理所有Controller所发生的异常
  • 11. 统一处理日志
    • 1.AOP概念(面向切面编程)
    • 2.AOP是如何实现的
    • 3.AOP切面编程demo
    • 4.AOP实现统一记录日志
  • 12.Redis
    • Redis入门
      • Windows下载redis
      • Redis命令
        • 基础命令
        • string字符串类型操作
        • hash哈希操作
        • list列表操作
        • set集合操作
        • sorted set
    • Spring整合Redis
      • 导包
      • 配置
      • 使用
  • 13.点赞功能(Redis+ajax)
    • 点赞/取消点赞
      • 1.工具类
      • 2.Service
      • 3.Controller
      • 4. 前端
    • 我收到的赞
      • 1.工具类
      • 2.Service
      • 3.Controller
      • 4.前端
        • onclick进行修改
        • js进行修改
    • 个人首页
  • 14.关注功能(Redis+ajax)
    • 关注/取关
      • 1.工具类
      • 2.Service
      • 3.Controller
        • 关注与取消关注按钮的实现(FollowController)
        • 用户个人页显示数据UserController
      • 4.前端
    • 关注/粉丝列表
      • 1.Service
      • 2.Controller
      • 3.前端

1. Spring

在测试类中使用Spring环境

  1. @RunWith(SpringRunner.class) 让Spring先运行
  2. @ContextConfiguration 导入配置文件
  3. 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自动装配进来就行

比如

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

@Primary的作用

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

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

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

image-20230115231111578

但是如果我某些地方还是想使用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

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

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

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

image-20230115232447051

导入外部包的Bean

使用配置类来注入Bean

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

2. SpringMVC

2.1 HTTP

https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Overview#http_%E6%B5%81

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

  1. 打开一个 TCP 连接

  2. 发送一个 HTTP 报文

    GET / HTTP/1.1
    Host: developer.mozilla.org
    Accept-Language: fr
    
  3. 读取服务端返回的报文信息:

    HTTP/1.1 200 OK
    Date: Sat, 09 Oct 2010 14:28:02 GMT
    Server: Apache
    Last-Modified: Tue, 01 Dec 2009 20:18:22 GMT
    ETag: "51142bc1-7449-479b075b2891b"
    Accept-Ranges: bytes
    Content-Length: 29769
    Content-Type: text/html<!DOCTYPE html... (here comes the 29769 bytes of the requested web page)
    
  4. 关闭连接或者为后续请求重用连接。

请求

image-20230115235447974

响应

image-20230115235504923

2.2 SpringMVC原理

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

image-20230115235828307

2.3 Thymeleaf

image-20230116000134252

发送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下

image-20230116004612588

<!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>

image-20230116004549489

3. Mybatis-Plus

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

分页工具类

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

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

4.1 Dao

直接Mybatis-Plus生成

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

4.2 Service

package com.qiuyu.service;@Service
public 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;@Service
public 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="http://www.thymeleaf.org">

<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" href="https://static.nowcoder.com/images/logo_87_87.png"/>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/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 found
  • 500 服务器遇到不知道如何处理的情况

5.2 日志

trace>debug>info>warn>error

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

#logger
logging: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协议

image-20230117161549865

导入jar包

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

配置

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

普通邮件

写一个工具类用于发邮件

package com.qiuyu.utils;@Component
public 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 {}}
}@Test
public void testSendMail() {String to = "****@qq.com";String subject = "测试邮件";String content = "测试邮件内容";mailClient.sendMail(to, subject, content);
}

使用Thymeleaf模板发邮件

写一个模板

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<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@qq.com";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 注册实现

image-20230117171832699

1. 访问注册页面

@Controller
public 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>org.apache.commons</groupId><artifactId>commons-lang3</artifactId>
</dependency>

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

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

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

密码在存入数据库时,需要进行md5加密
但是如果简单密码经过md5加密后,也可能会被黑客撞库攻击

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

package com.qiuyu.utils;import org.apache.commons.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 2023-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层

输入合法性验证

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

注册账户

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

发送邮件

  1. 创建Context对象–>context.setVariable(name,value)将name传入前端,为thymeleaf提供变量
  2. 设置email和url
  3. templateEngine.process执行相应HTML
  4. 发送邮件
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("http://images.nowcoder.com/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层

  1. 如果注册成功,则到一个中转页面
  2. 不成功则把失败消息传到注册页面,重新注册
package com.qiuyu.controller;@Controller
public 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 会话

image-20230118000123296

Cookie

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

@GetMapping("/cookie/set")
@ResponseBody
public 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")
@ResponseBody
public String getCookie(@CookieValue("code") String code) {return code;
}

Session

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

缺点是耗费内存

image-20230118000610740

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

  • 存的是sessionID
@GetMapping("/session/set")
@ResponseBody
public String setSession(HttpSession session) {session.setAttribute("id",1);session.setAttribute("name","Test");session.setAttribute("pwd","ASDASDDADASD");return "setSession";
}@GetMapping("/session/get")
@ResponseBody
public 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只在第一次请求的服务器中

解决方案

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

6.2 验证码

参考网站 :http://code.google.com/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;@Configuration
public 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 登录实现

image-20230118140643375

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

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

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

Bean

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

DAO

MyBatis-Plus生成

Service

  1. 空值判断
  2. 根据username查找user,判断是否存在该用户
  3. 对输入密码进行加盐如何md5加密,然后进行比对
  4. 写入登录凭证到数据库
package com.qiuyu.service;@Service
public 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

  1. 判断验证码是否正确
  2. 判断登录是否成功
  3. 成功就发送一个带有登录凭证的cookie给浏览器
  4. 不成功就重新登录
/*** 登录功能* @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中没有这俩,有两种方案
    1. 手动把username和password放入到model中
    2. 直接在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 显示登录信息

image-20230118164557688

拦截器demo

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

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

  • handle就是在执行的方法,也就是拦截的目标
@Component
public 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;@Configuration
public 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;}
}

注意:

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

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

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

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

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

package com.qiuyu.utils;import com.qiuyu.bean.User;
import org.springframework.stereotype.Component;/*** 持有用户信息,代替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层)

  1. preHandle: 在进入controller之前,把请求拦下,判断是否有凭证,有的话根据凭证查出用户,存入ThreadLocal
  2. postHandle:controller处理完之后,到视图之前,把ThreadLocal中的用户存入ModelAndView给前端调用
  3. afterCompletion: 最后把ThreadLocal中的当前user删除
package com.qiuyu.controller.interceptor;/*** 登录凭证拦截器,用于根据凭证号获取用户,并传给视图*/
@Component
public 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;
/*** 拦截器配置类*/
@Configuration
public 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,想要不让其访问,可以使用之前的那种拦截器,这里采用注解的方法

image-20230118222722899

常用的元注解:

  • @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的拦截器实现*/
@Component
public 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;/*** 拦截器配置类*/@Configuration
public 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优化登录

image-20230123232122785

1.验证码优化

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

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

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

image-20230124000825092

  • 生成一个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);
}
  1. 优先从缓存中取值

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

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

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. 账号设置(业务)

image-20230118205703762

上传头像

注意: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层

  • 把图像传入本地
    1. MultipartFile读取图片
    2. 对图片进行合法性判断,重命名图像
    3. 上传到指定位置,并指定url
    4. 修改用户的头像url
  • 从本地读取图像
    1. 根据url解析得到文件地址
    2. 用缓冲区读取图片,输出到输出流中
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());}}
}

修改密码

image-20230118232307654
  1. 原密码加盐,md5加密
  2. 判断新密码密码和原密码是否相等
  3. 新密码加盐,md5加密
  4. 写入数据库(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 过滤敏感词

image-20230119151024259

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

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

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

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

核心 :

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

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

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

image-20230119152023464

过滤算法实现

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

package com.qiuyu.utils;/*** 敏感词过滤器*/@Component
public 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)
@ResponseBody
public 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
// @LoginRequired
public 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 查看贴子详情

image-20230119174529922

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. 事务特性

image-20230119222706666

2. 事务的隔离性

image-20230119223006755

3.并发异常

  • 第一类丢失更新

  • 第二类丢失更新

  • 脏读

  • 不可重复读

  • 幻读

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

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

img

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;@Service
public 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. 私信功能(业务)

image-20230120171453112

私信列表/详情

DAO层(难点)

package com.qiuyu.dao;@Mapper
public 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 "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/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;@Service
public 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")
@ResponseBody
public 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层就行了

image-20230121010754054

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概念(面向切面编程)

img

img

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

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

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

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

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

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

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

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

2.AOP是如何实现的

img

  • AspectJ是一门新的语言,在编译期织入代码
  • Spring AOP 纯Java ,通过代理 运行时织入代码

img

  • JDK动态代理需要目标类实现了接口才行
  • CGLib 采用创建子类来进行代理

3.AOP切面编程demo

先导包

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId>
</dependency>
  • @Aspect代表这个类是个切面
  • @Pointcut定义一下织入的位置
@Component
@Aspect
public 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
@Aspect
public 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入门

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

Windows下载redis

redis官方只提供linux版本

下载微软提供的redis

https://github.com/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) 2
decr test:count      #指定数据减一,结果为(integer) 1

hash哈希操作

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

127.0.0.1:6379> hset test:user id 1
(integer) 1
127.0.0.1:6379> hset test:user username zhangsan
(integer) 1
127.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) 3
127.0.0.1:6379> llen test:ids  #列表长度
(integer) 3
127.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) 4
127.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 #放入集合
3
127.0.0.1:6379> scard test:teachers #查看个数
3
127.0.0.1:6379> spop test:teachers #随机弹出一个
ccc
127.0.0.1:6379> smembers test:teachers #查看所有元素
bbb
aaa

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 # 插入需要写分数
5
127.0.0.1:6379> zcard test:students #查看个数
5
127.0.0.1:6379> zscore test:students bbb #查看指定的元素的分数
20
127.0.0.1:6379> zrank test:students bbb #查看指定元素的排名(从0开始)
1
127.0.0.1:6379> zrange test:students 0 2 #按照分数,由小到大排序,第0-2个
aaa
bbb
ccc

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;@Configuration
public 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;@Configuration
public 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)

image-20230122233708401

点赞/取消点赞

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;@Service
public 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;@Controller
public 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);}});
}

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

我收到的赞

image-20230123013725378

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

我们可以添加一个维度,点赞的时候在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")
@ResponseBody
public 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)

image-20230123194114222

关注/取关

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;@Service
public 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;@Controller
public 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>
http://www.dtcms.com/wzjs/366213.html

相关文章:

  • 个人网站建设价格seo每日工作内容
  • wordpress 中文文件重命名百度快速排名优化工具
  • 安康鼎盛网站建设临沂网站seo
  • 专门做nba评论的网站链接网
  • 怎么做整蛊网站java成品网站
  • linux安装wordpressseo推广培训学费
  • 建设网银登录网站本地推广平台
  • 昌平网站建设公司百度号码查询平台
  • 淘宝网站建设服务类目选择如何解决网站只收录首页的一些办法
  • 近几年的网络营销案例优化大师电脑版官方
  • ps网站怎么做滑动背景图片下载优化大师安装桌面
  • 那家公司做网站百度推广开户费用标准
  • 电子商务建立网站前期准备百度百科推广费用
  • 去除 做网站就用建站之星外链推广软件
  • 自己做网站引用别人的电影优化关键词怎么做
  • 网站建设的流程图示网站优化排名推荐
  • 网站系统维护免费发布信息网平台
  • 公司的网站建设价格低广东seo网站优化公司
  • 专业网站制作公司案例优化网站的步骤
  • 北京网站建设公司册网站设计公司哪家专业
  • 做外贸上阿里巴巴什么网站口碑推广
  • wordpress主题合并插件网络推广优化招聘
  • 免费下载网站软件seo什么意思简单来说
  • 上海市住房与城乡建设委员会网站专业seo外包
  • 做网站的中标公司百度云网盘搜索引擎
  • 企业网站制作教程视频百度投广告怎么收费
  • 数据分析和网站开发松松软文
  • 凡科网站源码下载长春网站建设技术托管
  • 微网站建设比较全面的是谷歌广告联盟怎么做
  • 高端网站建设公司排行电商营销策划方案范文