源代码:https://gitee.com/qiuyusy/community
1. Spring
在测试类中使用Spring环境
- @RunWith(SpringRunner.class) 让Spring先运行
- @ContextConfiguration 导入配置文件
- implements ApplicationContextAware 后实现方法 获得 applicationContext
@RunWith(SpringRunner.class)
@SpringBootTest
@ContextConfiguration(classes = CommunityApplication.class)
class CommunityApplicationTests implements ApplicationContextAware {
private ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
@Test
public void testApplicationContext() {
System.out.println(applicationContext);
String[] beanDefinitionNames = applicationContext.getBeanDefinitionNames();
for (String beanDefinitionName : beanDefinitionNames) {
System.out.println(beanDefinitionName);
}
}
}
实际开发中没必要这样,直接@Autowired自动装配进来就行
比如
@SpringBootTest
class CommunityApplicationTests {
@Autowired
SimpleDateFormat simpleDateFormat;
@Test
void contextLoads() {
System.out.println(simpleDateFormat.format(new Date()));
}
}
@Primary的作用
如果使用接口获取Bean,这个接口下有多个实现类就会报错
AlphaDao alphaDao = applicationContext.getBean(AlphaDao.class);
可以使用@Primary指定使用哪个是实现类
但是如果我某些地方还是想使用Hibernate的实现类怎么办,可以定义name来解决
@Repository("alphaHibernate")
public class AlphaDaoHibernateImpl implements AlphaDao {
@Override
public 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;
@Test
public void test(){
System.out.println(alphaDao.select());
}
}
@PostConstruct @PreDestroy
@Service
public class AlphaService {
public AlphaService() {
System.out.println("执行构造方法");
}
@PostConstruct
public void init(){
System.out.println("初始化...");
}
@PreDestroy
public void destroy(){
System.out.println("销毁");
}
}
@PostConstruct 在构造器之后调用方法
@PreDestroy 在对象销毁前调用方法
导入外部包的Bean
使用配置类来注入Bean
package com.qiuyu.config;
@Configuration
public class AlphaConfig {
@Bean
public 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
当客户端想要和服务端进行信息交互时(服务端是指最终服务器,或者是一个中间代理),过程表现为下面几步:
-
打开一个 TCP 连接
-
发送一个 HTTP 报文
GET / HTTP/1.1 Host: developer.mozilla.org Accept-Language: fr
-
读取服务端返回的报文信息:
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)
-
关闭连接或者为后续请求重用连接。
请求
响应
2.2 SpringMVC原理
这里的Front Controller 前端控制器其实就是DispatcherServlet
2.3 Thymeleaf
发送ModelAndView给模板
//第一种ModelAndView
@RequestMapping(value = "/teacher", method = RequestMethod.GET)
public ModelAndView getTeacher(){
ModelAndView modelAndView = new ModelAndView();
modelAndView.addObject("name", "qiuyu");
modelAndView.addObject("age", 18);
modelAndView.setViewName("demo/teacher");
return modelAndView;
}
//第二种Model
@RequestMapping(value = "/teacher", method = RequestMethod.GET)
public String getTeacher(Model model){
model.addAttribute("name", "qiuyu");
model.addAttribute("age", 19);
return "demo/teacher";
}
模板读取数据然后写入html,记得放在templates下
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<p th:text="${name}"> </p>
<p th:text="${age}"> </p>
</body>
</html>
3. Mybatis-Plus
4. 开发社区首页
分页工具类
主要目的是为了加入路径,让前端的分页更好的复用
/**
* 我的分页组件
*/
@Data
@NoArgsConstructor
@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 {
@Autowired
private 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 {
@Autowired
private 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对象,所以需要重新查出user
List<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);
@Test
public void testLogger(){
logger.debug("debug");
logger.info("info");
logger.warn("warn");
logger.error("error");;
}
}
存储到文件
简单
logging:
level:
com.qiuyu: debug
file:
name: community.log
复杂
使用logback-spring.xml配置
5.3 发送邮件
如何发邮件
邮箱需要开启SMTP协议
导入jar包
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
配置
spring:
# 邮箱
mail:
#配置邮件消息
host: smtp.qq.com
port: 465
#发送邮件者信箱(也就是你申请POP3/SMTP服务的QQ号)
username: ***@qq.com
#申请PO3/SMTP服务时,给我们的邮箱的授权码
password: *****
default-encoding: UTF-8
protocol: smtp
properties:
mail.smtp.ssl.enable: true
普通邮件
写一个工具类用于发邮件
package com.qiuyu.utils;
@Component
public class MailClient {
private static final Logger logger = LoggerFactory.getLogger(MailClient.class);
@Autowired
private 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 {
@Autowired
private MailClient mailClient;
@Autowired
private TemplateEngine templateEngine;
@Test
public 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 注册实现
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层
输入合法性验证
- 输入user对象不为null
- 输入各项属性不为空(使用字符串工具类)
- 用户名邮箱是否已被注册
注册账户
- 设置salt加密(随机5位数加入密码)
- 设置密码+salt
- 设置UUID随机数激活码
- 初始化status,type=0,时间
- 设置头像(动态)
发送邮件
- 创建Context对象–>context.setVariable(name,value)将name传入前端,为thymeleaf提供变量
- 设置email和url
- templateEngine.process执行相应HTML
- 发送邮件
public Map<String,Object> register(User user){
Map<String,Object> map = new HashMap<>();
//空值处理
if (user == null) {
throw new IllegalArgumentException("参数不能为空");
}
if(StringUtils.isBlank(user.getUsername())){
map.put("usernameMsg", "账号不能为空");
return map;
}
if(StringUtils.isBlank(user.getPassword())){
map.put("passwordMsg", "密码不能为空");
return map;
}
if(StringUtils.isBlank(user.getEmail())){
map.put("emailMsg", "邮箱不能为空");
return map;
}
//判断账号是否被注册
Integer integer = userMapper.selectCount(new LambdaQueryWrapper<User>().eq(User::getUsername, user.getUsername()));
if(integer > 0){
map.put("usernameMsg", "该账号已被注册");
return map;
}
//判断邮箱是否被注册
integer = userMapper.selectCount(new LambdaQueryWrapper<User>().eq(User::getEmail, user.getEmail()));
if(integer > 0){
map.put("emailMsg", "该邮箱已被注册");
return map;
}
//给用户加盐
user.setSalt(CommunityUtil.generateUUID().substring(0,5));
//加密
user.setPassword(CommunityUtil.md5(user.getPassword() + user.getSalt()));
//初始化其他数据
user.setType(0);
user.setStatus(0);
user.setActivationCode(CommunityUtil.generateUUID());
user.setHeaderUrl(String.format("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层
- 如果注册成功,则到一个中转页面
- 不成功则把失败消息传到注册页面,重新注册
package com.qiuyu.controller;
@Controller
public class LoginController {
@Autowired
private UserService userService;
/**
* 跳转到请求页面
* @return
*/
@GetMapping("/register")
public String getRegisterPage() {
return "/site/register";
}
/**
* 注册账号,发送邮箱
*/
@PostMapping("/register")
public String register(Model model, User user) {
Map<String, Object> map = userService.register(user);
if(map == null || map.isEmpty()){
//注册成功,跳转到中转页面
model.addAttribute("msg","注册成功,我们已经向您的邮件发送了一封激活邮件,请尽快激活!");
model.addAttribute("target","/index");
return "/site/operate-result";
}else{
//注册失败,重新注册
model.addAttribute("usernameMsg",map.get("usernameMsg"));
model.addAttribute("passwordMsg",map.get("passwordMsg"));
model.addAttribute("emailMsg",map.get("emailMsg"));
return "/site/register";
}
}
}
激活邮箱
/**
* 激活邮箱 http://localhost:8080/community/activation/101/code 激活链接
* @param model
* @param userId
* @param code
* @return
*/
@GetMapping("/activation/{userId}/{code}")
public String activate(Model model,
@PathVariable("userId") int userId,
@PathVariable("code") String code) {
int result = userService.activate(userId, code);
if (result == ACTIVATION_SUCCESS){
model.addAttribute("msg","激活成功,你的账号已经可以正常使用了!");
model.addAttribute("target","/login");
}else if (result == ACTIVATION_REPEAT){
model.addAttribute("msg","无效操作,该账号已经激活过了!");
model.addAttribute("target","/index");
}else {
model.addAttribute("msg","激活失败,你提供的激活码不正确!");
model.addAttribute("target","/index");
}
return "/site/operate-result";
}
6. 开发登录功能
6.1 会话
Cookie
密码之类隐私数据用Cookie放浏览器不安全
@GetMapping("/cookie/set")
@ResponseBody
public String setCookie(HttpServletResponse response) {
//创建Cookie
Cookie cookie = new Cookie("code", CommunityUtil.generateUUID());
//设置Cookie生效范围
cookie.setPath("/community");
//设置cookie有效时间(s)
cookie.setMaxAge(60 * 10);
//发送Cookie
response.addCookie(cookie);
return "setCookie";
}
@GetMapping("/cookie/get")
@ResponseBody
public String getCookie(@CookieValue("code") String code) {
return code;
}
Session
服务器把sessionId用cookie给浏览器,浏览器只存了sessionId
缺点是耗费内存
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只在第一次请求的服务器中
解决方案
- 让用户的多次请求粘性的访问同一台服务器
- 缺点:破环了负载均衡
- 让每个服务器都同步一份session
- 缺点:耗费内存,耦合高
- 额外专门设置一台服务器用于存放session,其他服务器来这台服务器取session
- 缺点:如果这台存放session的服务器挂了就gg,而如果用session集群架设那又和方案二一样了
- 使用数据库集群(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 {
@Bean
public Producer KaptchaProducer(){
/**
* 手动创建properties.xml配置文件对象*
* 设置验证码图片的样式,大小,高度,边框,字体等
*/
Properties properties=new Properties();
properties.setProperty("kaptcha.border", "yes");
properties.setProperty("kaptcha.border.color", "105,179,90");
properties.setProperty("kaptcha.textproducer.font.color", "black");
properties.setProperty("kaptcha.image.width", "110"); //宽度
properties.setProperty("kaptcha.image.height", "40"); //高度
properties.setProperty("kaptcha.textproducer.font.size", "32"); //字号
properties.setProperty("kaptcha.textproducer.char.string", "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ");
properties.setProperty("kaptcha.textproducer.char.length", "4"); //几个字符
properties.setProperty("kaptcha.textproducer.font.names", "宋体,楷体,微软雅黑");
properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.NoNoise"); //是否干扰
DefaultKaptcha Kaptcha=new DefaultKaptcha();
Config config=new Config(properties);
Kaptcha.setConfig(config);
return Kaptcha;
}
}
使用(注意生成的文本要放入session,等待验证用户的输入)
@GetMapping("/kaptcha")
public void getKaptcha(HttpServletResponse response, HttpSession session){
//生成验证码
String text = kaptchaProducer.createText();
BufferedImage image = kaptchaProducer.createImage(text);
//验证码存入session,用于验证用户输入是否正确
session.setAttribute("kaptcha",text);
//将图片输出到浏览器
response.setContentType("image/png");
try {
OutputStream os = response.getOutputStream();
ImageIO.write(image,"png",os);
os.flush();
} catch (IOException e) {
logger.error("响应验证码失败:"+e.getMessage());
}
}
刷新验证码
HTML
<img th:src="@{/kaptcha}" id="kaptcha" style="width:100px;height:40px;" />
<a href="javascript:refresh_kaptcha();">刷新验证码</a>
JS
有些浏览器认为图片为静态资源,地址没变,就不刷新,带个参数可以解决
<script>
function refresh_kaptcha(){
//用?带个参数欺骗浏览器,让其认为是个新路径
var path = CONTEXT_PATH + "/kaptcha?p=" + Math.random();
$("#kaptcha").attr("src", path);
}
</script>
6.3 登录实现

登录后需要使用cookie或session进行登录凭证的验证,但是上面说到了这两种方案的缺点
- cookie不安全
- session耗资源,分布式不适合
这里使用把凭证存入数据库的方式,先存入mysql,后续转redis
Bean
package com.qiuyu.bean;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginTicket {
private Integer id;
private Integer userId;
private String ticket;
private Integer status;
private Date expired;
}
DAO
MyBatis-Plus生成
Service
- 空值判断
- 根据username查找user,判断是否存在该用户
- 对输入密码进行加盐如何md5加密,然后进行比对
- 写入登录凭证到数据库
package com.qiuyu.service;
@Service
public class LoginService {
@Autowired
private LoginTicketMapper loginTicketMapper;
@Autowired
private UserMapper userMapper;
/**
* 登录
* @param username
* @param password
* @param expiredSeconds
* @return
*/
public Map<String,Object> login(String username, String password, int expiredSeconds){
HashMap<String, Object> map = new HashMap<>();
//空值处理
if (StringUtils.isBlank(username)) {
map.put("usernameMsg","用户名不能为空");
return map;
}
if (StringUtils.isBlank(password)) {
map.put("passwordMsg","密码不能为空");
return map;
}
//验证账号是否存在
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getUsername, username));
if(user == null){
map.put("usernameMsg","该账号不存在");
return map;
}
//验证激活状态
if(user.getStatus() == 0){
map.put("usernameMsg","该账号未激活");
return map;
}
//验证密码(先加密再对比)
String pwdMd5 = CommunityUtil.md5(password + user.getSalt());
if(!pwdMd5.equals(user.getPassword())){
map.put("passwordMsg","密码错误");
return map;
}
//生成登录凭证(相当于记住我这个功能==session)
LoginTicket ticket = new LoginTicket();
ticket.setUserId(user.getId());
ticket.setTicket(CommunityUtil.generateUUID());
ticket.setStatus(0); //有效
//当前时间的毫秒数+过期时间毫秒数
ticket.setExpired(new Date(System.currentTimeMillis() + expiredSeconds * 1000));
Date date = new Date();
loginTicketMapper.insert(ticket);
map.put("ticket",ticket.getTicket());
//map中能拿到ticket说明登录成功了
return map;
}
}
Controller
- 判断验证码是否正确
- 判断登录是否成功
- 成功就发送一个带有登录凭证的cookie给浏览器
- 不成功就重新登录
/**
* 登录功能
* @param username
* @param password
* @param code 验证码
* @param rememberme 是否勾选记住我
* @param model
* @param session 用于获取kaptcha验证码
* @param response 用于浏览器接受cookie
* @return
*/
@PostMapping(path = "/login")
public String login(String username, String password, String code, boolean rememberme,
Model model, HttpSession session, HttpServletResponse response){
//判断验证码
String kaptcha = (String) session.getAttribute("kaptcha");
if(StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)){
//空值或者不相等
model.addAttribute("codeMsg","验证码不正确");
return "site/login";
}
/*
* 1.验证用户名和密码(重点)
* 2.传入浏览器cookie=ticket
*/
int expiredSeconds=rememberme?REMEMBER_EXPIRED_SECONDS:DEFAULT_EXPIRED_SECONDS;
Map<String, Object> map = loginService.login(username, password, expiredSeconds);
//登录成功
if(map.containsKey("ticket")){
Cookie cookie = new Cookie("ticket",map.get("ticket").toString());
cookie.setPath(contextPath);
cookie.setMaxAge(expiredSeconds);
response.addCookie(cookie);
return "redirect:/index";
}else{
//登陆失败
model.addAttribute("usernameMsg",map.get("usernameMsg"));
model.addAttribute("passwordMsg",map.get("passwordMsg"));
return "/site/login";
}
}
前端
输入错误保留之前的输入
<input type="text" name="username"
th:value="${param.username}"
id="username" placeholder="请输入您的账号!" required>
<input type="checkbox" name="rememberme" id="remember-me"
th:checked="${param.rememberme}">
- 注意Controller中username和password并没有放入到model中,所以model中没有这俩,有两种方案
- 手动把username和password放入到model中
- 直接在request中取 在th中 为
param
输入错误的提示
<input type="password" name="password"
th:class="|form-control ${passwordMsg!=null?'is-invalid':''}|"
th:value="${param.password}"
id="password" placeholder="请输入您的密码!" required>
<div class="invalid-feedback" th:text="${passwordMsg}">
密码长度不能小于8位!
</div>
- 判断是否有msg,有的话才加上
is-invalid
样式显示提示
6.4 登出实现
Service层
/**
* 登出
* @param ticket 登录凭证
*/
public void logout(String ticket){
LoginTicket loginTicket = new LoginTicket();
loginTicket.setStatus(1);
loginTicketMapper.update(loginTicket,
new LambdaUpdateWrapper<LoginTicket>().eq(LoginTicket::getTicket,ticket));
}
Controller层
/**
* 退出登录功能
* @CookieValue()注解:将浏览器中的Cookie值传给参数
*/
@GetMapping("/logout")
public String logout(@CookieValue("ticket") String ticket){
userService.logout(ticket);
return "redirect:/login";//重定向
}
@CookieValue
将浏览器中的Cookie值传给参数
6.5 显示登录信息

拦截器demo
- 拦截器需实现HandlerInterceptor接口而配置类需实现WebMvcConfigurer接口。
- preHandle方法在Controller之前执行,若返回false,则终止执行后续的请求。
- postHandle方法在Controller之后、模板页面之前执行。
- afterCompletion方法在模板之后执行。
- 通过addInterceptors方法对拦截器进行配置
1. 创建拦截器类,实现HandlerInterceptor接口
- handle就是在执行的方法,也就是拦截的目标
@Component
public class AlphaInterceptor implements HandlerInterceptor {
private static final Logger logger = LoggerFactory.getLogger(AlphaInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
logger.debug("preHandle");
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
logger.debug("postHandle");
}
@Override
public 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 {
@Autowired
private AlphaInterceptor alphaInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(alphaInterceptor)
.excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg") //不拦截静态资源
.addPathPatterns("/register","/login"); //只拦截部分请求
}
}
1. 首先创建两个工具类降低耦合
Request获取Cookie工具类,获取凭证ticket多线程工具类
package com.qiuyu.utils;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
public class CookieUtil {
/**
* 从request中获取指定cookie对象
* @param request
* @param name
* @return
*/
public static String getValue(HttpServletRequest request, String name){
if (request==null||name==null){
throw new IllegalArgumentException("参数为空!");
}
Cookie[] cookies = request.getCookies();
if (cookies!=null){
for (Cookie cookie : cookies){
if (cookie.getName().equals(name)){
return cookie.getValue();
}
}
}
return null;
}
}
注意:
-
ThreadLocal采用线程隔离的方式存放数据,可以避免多线程之间出现数据访问冲突。
-
ThreadLocal提供set方法,能够以当前线程为key存放数据。get方法,能够以当前线程为key获取数据。
-
ThreadLocal提供remove方法,能够以当前线程为key删除数据。
因为用户登录后,需要把用户信息放入内存之中,而web时多线程的环境,每个用户都会有一个线程
为了避免线程之间干扰,需要采用ThreadLocal进行线程隔离
package com.qiuyu.utils;
import com.qiuyu.bean.User;
import org.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层)
- preHandle: 在进入controller之前,把请求拦下,判断是否有凭证,有的话根据凭证查出用户,存入ThreadLocal
- postHandle:controller处理完之后,到视图之前,把ThreadLocal中的用户存入ModelAndView给前端调用
- afterCompletion: 最后把ThreadLocal中的当前user删除
package com.qiuyu.controller.interceptor;
/**
* 登录凭证拦截器,用于根据凭证号获取用户,并传给视图
*/
@Component
public class LoginTicketInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
@Autowired
private HostHolder hostHolder;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//从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()));
// 把用户存入ThreadLocal
hostHolder.setUser(user);
}
}
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
//在调用模板引擎之前,把user给model
User user = hostHolder.getUser();
if (user != null && modelAndView != null) {
modelAndView.addObject("loginUser",user);
}
}
@Override
public 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 {
@Autowired
private LoginTicketInterceptor loginTicketInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginTicketInterceptor)
.excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");
}
}
5. 前端
<li class="nav-item ml-3 btn-group-vertical" th:if="${loginUser!=null}">
<a href="site/letter.html">消息<span >12</span></a>
</li>
th:if="${loginUser!=null}"
如果有loginUser传过来才显示th:if="${loginUser==null}"
如果有没有loginUser传过来才显示
6.6 拦截未登录页面访问(采用注解)
当前情况下,没登录也能够访问/user/setting,想要不让其访问,可以使用之前的那种拦截器,这里采用注解的方法

常用的元注解:
- @Target:注解作用目标(方法or类)
- @Retention:注解作用时间(运行时or编译时)
- @Document:注解是否可以生成到文档里
- @Inherited**:注解继承该类的子类将自动使用@Inherited修饰
注意: 若有2个拦截器,拦截器执行顺序为注册在WebMvcConfig配置类中的顺序
1. 写一个注解@LoginRequired
package com.qiuyu.annotation;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequired {
}
2. 给需要的方法加上注解
/**
* 跳转设置页面
* @return
*/
@LoginRequired
@GetMapping("/setting")
public String getUserPage() {
return "/site/setting";
}
/**
*上传头像
*/
@LoginRequired
@PostMapping("/upload")
public String uploadHeader(MultipartFile headerImage, Model model) {
}
3. 写拦截器
拦截有注解,并且没登陆的那些请求
package com.qiuyu.controller.interceptor;
/**
* @LoginRequired的拦截器实现
*/
@Component
public class LoginRequiredInterceptor implements HandlerInterceptor {
@Autowired
private HostHolder hostHolder;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断拦截的是否为方法
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
//获取拦截到的方法对象
Method method = handlerMethod.getMethod();
//获取注解
LoginRequired loginRequired = method.getAnnotation(LoginRequired.class);
//如果这个方法被@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 {
@Autowired
private LoginTicketInterceptor loginTicketInterceptor;
@Autowired
private LoginRequiredInterceptor loginRequiredInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginTicketInterceptor)
.excludePathPatterns("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
registry.addInterceptor(loginRequiredInterceptor)
.excludePathPatterns("/**/*.css","/**/*.js","/**/*.png","/**/*.jpg","/**/*.jpeg");
}
}
7. 账号设置

上传头像
注意:1. 必须是Post请求 2.表单:enctype=“multipart/form-data” 3.参数类型MultipartFile只能封装一个文件
上传路径可以是本地路径也可以是web路径
访问路径必须是符合HTTP协议的Web路径
Service层
/**
* 更新用户头像路径
* @param userId
* @param headerUrl
* @return
*/
public int updateHeaderUrl(int userId, String headerUrl) {
User user = new User();
user.setId(userId);
user.setHeaderUrl(headerUrl);
return userMapper.updateById(user);
}
Controller层
- 把图像传入本地
MultipartFile
读取图片- 对图片进行合法性判断,重命名图像
- 上传到指定位置,并指定url
- 修改用户的头像url
- 从本地读取图像
- 根据url解析得到文件地址
- 用缓冲区读取图片,输出到输出流中
package com.qiuyu.controller;
@Controller
@RequestMapping("/user")
public class UserController {
public static final Logger logger = LoggerFactory.getLogger(UserController.class);
@Value("${community.path.domain}")
private String domain;
@Value("${community.path.upload-path}")
private String uploadPath;
@Value("${server.servlet.context-path}")
private String contextPath;
@Autowired
private UserService userService;
@Autowired
private 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.png
User user = hostHolder.getUser();
String headerUrl = domain + contextPath + "/user/header/" + filename;
userService.updateHeaderUrl(user.getId(), headerUrl);
return "redirect:/index";
}
/**
* 得到服务器图片
* void:返回给浏览器的是特色的图片类型所以用void
*
* @param fileName
* @param response
*/
@GetMapping("/header/{fileName}")
public void getHeader(@PathVariable("fileName") String fileName, HttpServletResponse response) {
// 服务器存放路径(本地路径)
fileName = uploadPath + "/" + fileName;
// 文件后缀
String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);
// 浏览器响应图片
response.setContentType("image/" + suffix);
try (
//图片是二进制用字节流
FileInputStream fis = new FileInputStream(fileName);
OutputStream os = response.getOutputStream();
) {
//设置缓冲区
byte[] buffer = new byte[1024];
//设置游标
int b = 0;
while ((b = fis.read(buffer)) != -1) {
os.write(buffer, 0, b);
}
} catch (IOException e) {
logger.error("读取头像失败: " + e.getMessage());
}
}
}
修改密码

- 原密码加盐,md5加密
- 判断新密码密码和原密码是否相等
- 新密码加盐,md5加密
- 写入数据库(Service层)
Service层
/**
* 更新密码
* @param userId
* @param oldPassword
* @param newPassword
* @return map返回信息
*/
public Map<String, Object> updatePassword(int userId, String oldPassword,
String newPassword) {
Map<String, Object> map = new HashMap<>();
//空值判断
if(StringUtils.isBlank(oldPassword)){
map.put("oldPasswordMsg","原密码不能为空");
return map;
}
if(StringUtils.isBlank(newPassword)){
map.put("newPasswordMsg","新密码不能为空");
return map;
}
//根据userId获取对象
User user = userMapper.selectById(userId);
//旧密码加盐,加密
oldPassword = CommunityUtil.md5(oldPassword+user.getSalt());
//判断密码是否相等
if(!user.getPassword().equals(oldPassword)){
//不相等,返回
map.put("oldPasswordMsg","原密码错误");
return map;
}
//新密码加盐,加密
newPassword = CommunityUtil.md5(newPassword+user.getSalt());
user.setPassword(newPassword);
userMapper.updateById(user);
//map为空表示修改成功
return map;
}
Controller层
从ThreadLocal中拿userid
/**
* 更新密码
* @param oldPassword
* @param newPassword
* @param model
* @return
*/
@LoginRequired
@PostMapping("/updatePassword")
public String updatePassword(String oldPassword, String newPassword,Model model){
User user = hostHolder.getUser();
Map<String, Object> map =
userService.updatePassword(user.getId(), oldPassword, newPassword);
if(map == null || map.isEmpty()){
//成功!使用重定向,不然报错
return "redirect:/index";
}else{
//失败
model.addAttribute("oldPasswordMsg",map.get("oldPasswordMsg"));
model.addAttribute("newPasswordMsg",map.get("newPasswordMsg"));
return "/site/setting";
}
}
前端
<input type="password" th:class="|form-control ${oldPasswordMsg!=null?'is-invalid':''}|"
th:value="${param.oldPassword!=null?param.oldPassword:''}"
id="old-password" name="oldPassword" placeholder="请输入原始密码!" required>
th:value="${param.oldPassword!=null?param.oldPassword:''}"
用于修改失败后保存之前的输入密码
8. 论坛功能开发
8.1 过滤敏感词

使用前缀树来存储敏感词 :
-
根节点不包含字符,除根节点以外的每个节点,只包含一个字符
-
从根节点到某一个节点,路径上经过的字符连接起来,为该节点对应字符串
-
每个节点的所有子节点,包含的字符串不相同
核心 :
-
有一个指针1指向前缀树,用以遍历敏感词的每一个字符
-
有一个指针2指向被过滤字符串,用以标识敏感词的开头
-
有一个指针3指向被过滤字符串,用以标识敏感词的结尾

过滤算法实现
在resources创建sensitive-words.txt文敏感词文本
package com.qiuyu.utils;
/**
* 敏感词过滤器
*/
@Component
public class SensitiveFilter {
private static final Logger logger = LoggerFactory.getLogger(SensitiveFilter.class);
// 替换符
private static final String REPLACEMENT = "***";
// 根节点
private TrieNode rootNode = new TrieNode();
// 构造器之后运行
@PostConstruct
public 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;
}
// 指针1
TrieNode tempNode = rootNode;
// 指针2
int begin = 0;
// 指针3
int 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集合中取出每一个key
for (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标签为 < > 让浏览器认为是普通字符,防止被注入
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.msg
data = $.parseJSON(data);
// 在提示框中显示返回消息
$("#hintBody").text(data.msg);
// 显示提示框
$("#hintModal").modal("show");
// 2秒后,自动隐藏提示框
setTimeout(function(){
$("#hintModal").modal("hide");
// 成功,刷新页面
if(data.code == 0) {
window.location.reload();
}
}, 2000);
}
);
}
8.3 查看贴子详情

Service
/**
* 通过id查找帖子
* @param id
* @return
*/
public DiscussPost findDiscussPostById(int id){
return discussPostMapper.selectById(id);
}
Controller
/**
* 查看帖子详细页
* @param discussPostId
* @param model
* @return
*/
@GetMapping( "/detail/{discussPostId}")
public String getDiscussPost(@PathVariable("discussPostId") int discussPostId, Model model){
//通过前端传来的Id查询帖子
DiscussPost post = discussPostService.findDiscussPostById(discussPostId);
model.addAttribute("post",post);
//用以显示发帖人的头像及用户名
User user = userService.findUserById(post.getUserId());
model.addAttribute("user",user);
return "/site/discuss-detail";
}
- 这里查询了两次,后面使用redis优化
前端
<a th:href="@{|/discuss/detail/${map.post.id}|}" th:utext="${map.post.title}">标题</a>
- 如果在
@{ }
中想要常量和变量的拼接需要用两个| |
<b th:text="${#dates.format(post.createTime,'yyyy-MM-dd HH:mm:ss')}">时间</b>
#dates.format()
用于格式化时间
8.4 事务管理
概念
1. 事务特性

2. 事务的隔离性

3.并发异常
-
第一类丢失更新
-
第二类丢失更新
-
脏读
-
不可重复读
-
幻读
-
事务1的回滚导致事务2更新的数据丢失了

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





Spring声明式事务
方法:
**1.通过XML配置 **
2.通过注解@Transaction,如下:
/* REQUIRED: 支持当前事务(外部事务),如果不存在则创建新事务
* REQUIRED_NEW: 创建一个新事务,并且暂停当前事务(外部事务)
* NESTED: 如果当前存在事务(外部事务),则嵌套在该事务中执行(独立的提交和回滚),否则就会和REQUIRED一样
* 遇到错误,Sql回滚 (A->B)
*/
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
-
propagation
用于配置事务传播机制,既两个带事务的方法AB,方法A调用方法B,事务以哪个为准 -
REQUIRED
外部事务为准,比如A调用B,以A为准,如果A有事务就按照A的事务来,如果A没有事务就创建一个新的事务 -
REQUIRED_NEW
创建一个新的事务,比如A调用B,直接无视(暂停)A的事务,B自己创建一个新的事务 -
NESTED
嵌套,如果当前存在事务(外部事务),则嵌套在该事务中执行(独立的提交和回滚),否则自己创建新事务
Spring编程式事务
控制粒度更低,比如一个方法要访问10次数据库,只有5次需要保证事务,就可以用编程式来控制,声明式会10次全都放入事务中
方法: 通过TransactionTemplate组件执行SQL管理事务,如下:
public Object save2(){
transactionTemplate.
setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
transactionTemplate.
setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
//回调函数
return transactionTemplate.execute(new TransactionCallback<Object>() {
@Override
public 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 {
@Autowired
private 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) {
//一条评论的VO
Map<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) {
//一条回复的VO
Map<String, Object> replyVo = new HashMap<>(10);
//回复
replyVo.put("reply",reply);
//作者
replyVo.put("user",user);
//回复的目标
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绑定