技术栈
- Spring Boot、MySQL
- Spring、Spring MVC、MyBatis
- Redis、Kafka、Elasticsearch、Zookeeper
- Spring Security、Spring Actuator
- Caffeine
- Thymeleaf
创建项目
在intellij IDEA中利用Spring Initializer创建SpringBoot项目,使用Maven管理项目
maven常用命令:
mvn compile : 编译maven项目,会出现target目录
mvn clean : 删除编译后的内容,target目录会被删除
mvn test :执行test中的方法,会首先编译test类
Spring boot的核心作用:起步依赖,自动配置,端点监控
配置测试数据库连接
引入依赖
<!-- mysql数据库连接驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- 引入Spring封装的jdbc,内部默认依赖了 HikariDataSource 数据源-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- mybatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
配置文件application.yml
server:
port: 80
# 应用的上下文路径,也可以称为项目路径,是构成url地址的一部分
# 此时的访问方式为localhost:8088/community/xxxxxx
servlet:
context-path: /community
# Mybatis
mybatis:
configuration:
# 自动转驼峰
map-underscore-to-camel-case: true
# 允许JDBC支持自动生成主键,需要驱动兼容
use-generated-keys: true
# 控制台标准日志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
spring:
# Thymeleaf 模板缓存
thymeleaf:
cache: false
# DataSource
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/community?characterEncoding=utf-8&useSSL=false&serverTimezone=Hongkong
username: root
password: root
type: com.zaxxer.hikari.HikariDataSource
hikari:
maximum-pool-size: 15
minimum-idle: 5
idle-timeout: 30000
创建entity包并创建User类
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class User {
private int id;
private String username;
private String password;
private String salt;
private String email;
private int type;
private int status;
private String activationCode;
private String headerUrl;
private Date createTime;
}
创建mapper包并创建UserMapper接口
@Mapper
public interface UserMapper {
@Select("select * from user where id = #{id}")
User selectById(int id);
@Select("select * from user where username = #{username}")
User selectByName(String username);
}
测试
@SpringBootTest
public class UserMapperTest {
@Autowired
UserMapper userMapper;
@Test
public void selectByIdTest(){
User user = userMapper.selectById(166);
System.out.println(user);
}
@Test
public void selectByUsernameTest(){
User user = userMapper.selectByName("SYSTEM");
System.out.println(user);
}
}
社区首页
开发流程
- 1次请求的执行过程
- 分步实现
- 开发社区首页,显示前10个帖子
- 开发分页组件,分页显示所有的帖子
社区首页
- 开发社区首页,显示前10个帖子
- 创建实体类
- 写对应mapper
- 创建service,因为在首页得到的DiscussPost并不携带userid,所以需要创建DiscusspostService和UserService来实现首页展示10个帖子的功能
- 写controller,将查询到的数据注入到model中
- 修改index.html
- 开发分页组件,分页显示所有的帖子
- 创建page类,在Page中设置当前页码,显示上限,数据总数,查询路径
- 在开发社区首页的controller方法中,第一次访问自动注入page,page会自动注入到model中,然后我们让前端在跳转页面的时候在参数上添加页数,我们的SpringMVC会自动注入Page页面
- index.html首页中修改分页组件
社区首页
创建实体类
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class DiscussPost {
@Id
private int id;
private int userId;
private String title;
private String content;
private int type;
private int status;
private Date createTime;
private int commentCount;
private double score;
}
创建DiscussPostMapper接口
@Mapper
public interface DiscussPostMapper {
@Select("select * from discuss_post where if (#{userId} != 0, status != 2 and user_id = #{userId}, status != 2) " +
"order by type desc,create_time desc " +
"limit #{offset},#{limit}")
List<DiscussPost> selectDiscussPosts(int userId, int offset, int limit);
@Select("select count(id) from discuss_post where if (#{userId} != 0, status != 2 and user_id = #{userId}, status != 2)")
int selectDiscussPostRows(int userId);
}
创建service包
创建DiscussPostService类和UserService类
@Service
public class DiscussPostService {
@Autowired
private DiscussPostMapper discussPostMapper;
public List<DiscussPost> findDiscussPosts(int userId, int offset, int limit){
return discussPostMapper.selectDiscussPosts(userId,offset,limit);
}
public int findDiscussPostRows(int userId){
return discussPostMapper.selectDiscussPostRows(userId);
}
}
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
public User findUserById(int userId) {
return userMapper.selectById(userId);
}
}
将静态资源(css image js等文件)放到static文件下,将模板文件(site index.html)放到templates文件下。Spring MVC将在resource/templates文件夹下查找html页面,返回给客户端
接下来开发视图层,创建controller包,创建HomeController类
@Controller
public class HomeController {
@Autowired
private UserService userService;
@Autowired
private DiscussPostService discussPostService;
/**
* 分页显示首页帖子
*/
@RequestMapping(path = {"/index","/"},method = RequestMethod.GET)
public String getIndexPage(Model model){
List<DiscussPost> discussPostList = discussPostService.findDiscussPosts(0, 0, 10);
List<Map<String, Object>> discussPosts = new ArrayList<>();
if (discussPostList != null) {
for (DiscussPost post : discussPostList) {
Map<String, Object> map = new HashMap<>();
map.put("post", post);
User user = userService.findUserById(post.getUserId());
map.put("user", user);
discussPosts.add(map);
}
}
model.addAttribute("discussPosts", discussPosts);
return "/index";
}
}
更改Index.html文件,使用Thymeleaf对其中相对路径进行更改
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" crossorigin="anonymous">
<link rel="stylesheet" th:href="@{/css/global.css}" />
<title>首页</title>
</head>
......
<ul class="list-unstyled">
<li class="media pb-3 pt-3 mb-3 border-bottom" th:each="map:${discussPosts}">
<a href="site/profile.html">
<img th:src="${map.user.headerUrl}" class="mr-4 rounded-circle" alt="用户头像" style="width:50px;height:50px;">
</a>
<div class="media-body">
<h6 class="mt-0 mb-3">
<a href="#" th:utext="${map.post.title}">备战春招,面试刷题跟他复习,一个月全搞定!</a>
<span class="badge badge-secondary bg-primary" th:if="${map.post.type==1}">置顶</span>
<span class="badge badge-secondary bg-danger" th:if="${map.post.status==1}">精华</span>
</h6>
<div class="text-muted font-size-12">
<u class="mr-3" th:utext="${map.user.username}">寒江雪</u> 发布于 <b th:text="${#dates.format(map.post.createTime,'yyyy-MM-dd HH:mm:ss')}">2019-04-15 15:32:18</b>
<ul class="d-inline float-right">
<li class="d-inline ml-2">赞 11</li>
<li class="d-inline ml-2">|</li>
<li class="d-inline ml-2">回帖 7</li>
</ul>
</div>
</div>
</li>
</ul>
......
<script th:src="@{/js/global.js}"></script>
<script th:src="@{js/index.js}"></script>
</body>
</html>
分页组件
开发分页组件,分页显示所有帖子
在entity包下创建Page类,用于记录分页数据
/**
* 封装分页相关的信息.
*/public class Page {
// 当前页码
private int current = 1;
// 显示上限
private int limit = 10;
// 数据总数(用于计算总页数)
private int rows;
// 查询路径(用于复用分页链接)
private String path;
public int getCurrent() {
return current;
}
public void setCurrent(int current) {
if (current >= 1) {
this.current = current;
}
}
public int getLimit() {
return limit;
}
public void setLimit(int limit) {
if (limit >= 1 && limit <= 100) {
this.limit = limit;
}
}
public int getRows() {
return rows;
}
public void setRows(int rows) {
if (rows >= 0) {
this.rows = rows;
}
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
/**
* 获取当前页的起始行
*
* @return
*/
public int getOffset() {
// current * limit - limit
return (current - 1) * limit;
}
/**
* 获取总页数
*
* @return
*/
public int getTotal() {
// rows / limit [+1]
if (rows % limit == 0) {
return rows / limit;
} else {
return rows / limit + 1;
}
}
/**
* 获取起始页码
*
* @return
*/
public int getFrom() {
int from = current - 2;
return from < 1 ? 1 : from;
}
/**
* 获取结束页码
*
* @return
*/
public int getTo() {
int to = current + 2;
int total = getTotal();
return to > total ? total : to;
}
}
修改HomeController的getIndexPage方法
@RequestMapping(path = {"/index","/"},method = RequestMethod.GET)
public String getIndexPage(Model model, Page page){
page.setRows(discussPostService.findDiscussPostRows(0));
page.setPath("/index");
List<DiscussPost> discussPostList = discussPostService.findDiscussPosts(0, page.getOffset(), page.getLimit());
List<Map<String, Object>> discussPosts = new ArrayList<>();
if (discussPostList != null) {
for (DiscussPost post : discussPostList) {
Map<String, Object> map = new HashMap<>();
map.put("post", post);
User user = userService.findUserById(post.getUserId());
map.put("user", user);
discussPosts.add(map);
}
}
model.addAttribute("discussPosts", discussPosts);
return "/index";
}
更改Index.html中分页组件
<!-- 分页 -->
<nav class="mt-5" th:if="${page.rows>0}">
<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.from,page.to)}">
<a class="page-link" th:href="@{${page.path}(current=${i})}" th:text="${i}">1</a>
</li>
<li th:class="|page-item ${page.current==page.total?'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.total})}">末页</a>
</li>
</ul>
</nav>
发送邮箱
开发流程
- 邮箱设置
- 启用客户端SMTP服务
- Spring Email
- 导入 jar 包
- 邮箱参数配置
- 使用 JavaMailSender 发送邮件
- 模板引擎
- 使用 Thymeleaf 发送 HTML 邮件
Spring Mail配置文件并加入到pom文件中
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
在application.yml文件中配置Mail的参数
需要在个人邮箱的网站设置启用授权码,验证手机
spring:
# Mail
mail:
host: smtp.qq.com
port: 465
username: 10xxxxxx51@qq.com
password: zjnxxxxxxxxxbchb
protocol: smtps
default-encoding: UTF-8
banner:
charset: UTF-8
创建util工具文件包,并在util包中创建MailClient类
@Component
public class MailClient {
private static final Logger logger = LoggerFactory.getLogger(MailClient.class);
@Resource
private JavaMailSender mailSender;
//从配置文件中获取值
@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);
mailSender.send(helper.getMimeMessage());
} catch (MessagingException e) {
logger.error("发送邮件失败:" + e.getMessage());
}
}
}
注册
开发流程
- 访问注册页面
- 点击顶部区域内的链接,打开注册页面。
- 提交注册数据
- 通过表单提交数据。
- 服务端验证账号是否已存在、邮箱是否已注册。
- 服务端发送激活邮件。
- 激活注册账号
- 点击邮件中的链接,访问服务端的激活服务
首先导入一个常用的包 commons lang
主要是字符串判空等功能
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<version>2.4</version>
</dependency>
添加配置信息
因为开发、上线域名不一样所以用户打开邮箱激活的链接也不一样,所以写在yml配置文件中方便更改
Community:
path:
domain: http://127.0.0.1:80
在util中添加CommunityUtil工具类,对用户的密码加密
public class CommunityUtil {
// 生成随机字符串
public static String generateUUID() {
return UUID.randomUUID().toString().replaceAll("-", "");
}
// MD5加密
// hello -> abc123def456
// hello + 3e4a8 -> abc123def456abc public static String md5(String key) {
if (StringUtils.isBlank(key)) {
return null;
}
return DigestUtils.md5DigestAsHex(key.getBytes());
}
}
在UserMapper接口中添加下面几个方法
@Select("select * from user where email = #{email}")
User selectByEmail(String email);
@Insert("insert into user(username, password, salt, email, type, status, activation_code, header_url, create_time) " +
"values(#{username}, #{password}, #{salt}, #{email}, #{type}, #{status}, #{activationCode}, #{headerUrl}, #{createTime})")
int insertUser(User user);
@Update("update user set status = #{status} where id = #{id}")
int updateStatus(int id, int status);
在Service中更新UserService类,用于注册用户业务,并更新激活页面activation.html
@Autowired
private UserMapper userMapper;
@Autowired
private MailClient mailClient;
@Resource
private TemplateEngine templateEngine;
@Value("${Community.path.domain}")
private String domain;
@Value("${server.servlet.context-path}")
private String contextPath;
public User findUserById(int userId) {
return userMapper.selectById(userId);
}
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;
}
// 验证账号
if (userMapper.selectByName(user.getUsername()) != null) {
map.put("usernameMsg", "该账号已存在!");
return map;
}
// 验证邮箱
if (userMapper.selectByEmail(user.getEmail()) != null) {
map.put("emailMsg", "该邮箱已被注册!");
return map;
}
// 注册用户
user.setSalt(CommunityUtil.generateUUID().substring(0, 5));
user.setPassword(CommunityUtil.md5(user.getPassword() + user.getSalt()));
user.setType(0);
user.setStatus(0);
user.setActivationCode(CommunityUtil.generateUUID());
user.setHeaderUrl(String.format("http://images.nowcoder.com/head/%dt.png", new Random().nextInt(1000)));
user.setCreateTime(new Date());
userMapper.insertUser(user);
//重新查询,因为表单没有id字段,默认0,user对象的id不是插入数据库的id
User user1 = userMapper.selectByName(user.getUsername());
// 激活邮件
Context context = new Context();
context.setVariable("email", user.getEmail());
// http://127.0.0.1:80/community/activation/170/code
String url = domain + contextPath + "/activation/" + user1.getId() + "/" + user1.getActivationCode();
context.setVariable("url", url);
String content = templateEngine.process("/mail/activation", context);
mailClient.sendMail(user.getEmail(), "激活账号", content);
return map;
}
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<title>激活账号</title>
</head>
<body>
<div>
<p><b th:text="${email}">xxx@xxx.com</b>, 您好!</p>
<p>您正在注册, 这是一封激活邮件, 请点击<a th:href="${url}">此链接</a>, 激活您的账号!</p>
</div>
</body>
</html>
创建LoginController类
@Controller
public class LoginController implements CommunityConstant {
@Autowired
private UserService userService;
@Value("${server.servlet.context-path}")
private String contextPath;
/**
* 跳转到注册
*/
@RequestMapping(path = "/register", method = RequestMethod.GET)
public String getRegisterPage() {
return "/site/register";
}
/**
* 跳转到登录
*/
@RequestMapping(path = "/login", method = RequestMethod.GET)
public String getLoginPage() {
return "/site/login";
}
/**
* 注册
*/
@RequestMapping(path = "/register", method = RequestMethod.POST)
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";
}
}
}
更改register.html页面
<div class="main">
<div class="container pl-5 pr-5 pt-3 pb-3 mt-3 mb-3">
<h3 class="text-center text-info border-bottom pb-3">注 册</h3>
<form class="mt-5" method="post" th:action="@{/register}">
<div class="form-group row">
<label for="username" class="col-sm-2 col-form-label text-right">账号:</label>
<div class="col-sm-10">
<input type="text"
th:class="|form-control ${usernameMsg!=null?'is-invalid':''}|"
th:value="${user!=null?user.username:''}"
id="username" name="username" placeholder="请输入您的账号!" required>
<div class="invalid-feedback" th:text="${usernameMsg}">
该账号已存在!
</div>
</div>
</div>
<div class="form-group row mt-4">
<label for="password" class="col-sm-2 col-form-label text-right">密码:</label>
<div class="col-sm-10">
<input type="password"
th:class="|form-control ${passwordMsg!=null?'is-invalid':''}|"
th:value="${user!=null?user.password:''}"
id="password" name="password" placeholder="请输入您的密码!" required>
<div class="invalid-feedback" th:text="${passwordMsg}">
密码长度不能小于8位!
</div>
</div>
</div>
<div class="form-group row mt-4">
<label for="confirm-password" class="col-sm-2 col-form-label text-right">确认密码:</label>
<div class="col-sm-10">
<input type="password" class="form-control"
th:value="${user!=null?user.password:''}"
id="confirm-password" placeholder="请再次输入密码!" required>
<div class="invalid-feedback">
两次输入的密码不一致!
</div>
</div>
</div>
<div class="form-group row">
<label for="email" class="col-sm-2 col-form-label text-right">邮箱:</label>
<div class="col-sm-10">
<input type="email"
th:class="|form-control ${emailMsg!=null?'is-invalid':''}|"
th:value="${user!=null?user.email:''}"
id="email" name="email" placeholder="请输入您的邮箱!" required>
<div class="invalid-feedback" th:text="${emailMsg}">
该邮箱已注册!
</div>
</div>
</div>
<div class="form-group row mt-4">
<div class="col-sm-2"></div>
<div class="col-sm-10 text-center">
<button type="submit" class="btn btn-info text-white form-control">立即注册</button>
</div>
</div>
</form>
</div>
</div>
更改operate-result页面
<div class="main">
<div class="container mt-5">
<div class="jumbotron">
<p class="lead" th:text="${msg}">您的账号已经激活成功,可以正常使用了!</p>
<hr class="my-4">
<p> 系统会在 <span id="seconds" class="text-danger">8</span> 秒后自动跳转,
您也可以点此 <a id="target" th:href="@{${target}}" class="text-primary">链接</a>, 手动跳转!
</p>
</div>
</div>
</div>
......
<script>
$(function(){
setInterval(function(){
var seconds = $("#seconds").text();
$("#seconds").text(--seconds);
if(seconds == 0) {
location.href = $("#target").attr("href");
}
}, 1000);
});
</script>
激活注册账号
util包中创建CommunityConstant接口,定义几个激活状态常量
public interface CommunityConstant {
/**
* 激活成功
*/
int ACTIVATION_SUCCESS = 0;
/**
* 重复激活
*/
int ACTIVATION_REPEAT = 1;
/**
* 激活失败
*/
int ACTIVATION_FAILURE = 2;
}
UserService实现此接口,增加新方法
public int activation(int userId, String code) {
User user = userMapper.selectById(userId);
if (user == null){
return ACTIVATION_FAILURE;
}
if (user.getStatus() == 1) {
return ACTIVATION_REPEAT;
} else if (user.getActivationCode().equals(code)) {
userMapper.updateStatus(userId, 1);
return ACTIVATION_SUCCESS;
} else {
return ACTIVATION_FAILURE;
}
}
LoginController类
/**
* 邮箱激活
*/
// http://127.0.0.1:80/community/activation/170/code
@RequestMapping(path = "/activation/{userId}/{code}", method = RequestMethod.GET)
public String activation(Model model, @PathVariable("userId") int userId, @PathVariable("code") String code) {
int result = userService.activation(userId, code);
if (result == ACTIVATION_SUCCESS) {
model.addAttribute("msg", "激活成功,您的账号已经可以正常使用了!");
model.addAttribute("target", "/login");
} else if (result == ACTIVATION_REPEAT) {
model.addAttribute("msg", "无效操作,该账号已经激活过了!");
model.addAttribute("target", "/index");
} else {
model.addAttribute("msg", "激活失败,您提供的激活码不正确!");
model.addAttribute("target", "/index");
}
return "/site/operate-result";
}
会话管理
HTTP的基本性质
- HTTP是简单的
- HTTP是可扩展的
- HTTP是无状态的,有会话的
Cookie
- 是服务器发送到浏览器,并保存在浏览器端的一小块数据。
- 浏览器下次访问该服务器时,会自动携带块该数据,将其发送给服务器。
Session
- 是JavaEE的标准,用于在服务端记录客户端信息。
- 数据存放在服务端更加安全,但是也会增加服务端的内存压力。
- 服务器分布式部署的时候存放session并没有十分完美的解决方案,所以一般我们都把数据存放进数据库中(redis)解决此问题。
生成验证码
Kaptcha
- 导入依赖
- 编写 Kaptcha 配置类
- 生成随机字符、生成图片
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
配置
在config包下新建KaptchaConfig
@Configuration
public class KaptchaConfig {
@Bean
public Producer kaptchaProducer() {
Properties properties = new Properties();
properties.setProperty("kaptcha.image.width", "100");
properties.setProperty("kaptcha.image.height", "40");
properties.setProperty("kaptcha.textproducer.font.size", "32");
properties.setProperty("kaptcha.textproducer.font.color", "0,0,0");
properties.setProperty("kaptcha.textproducer.char.string", "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYAZ");
properties.setProperty("kaptcha.textproducer.char.length", "4");
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;
}
}
LoginController增加新方法
@Autowired
private Producer kaptchaProducer;
@RequestMapping(path="/kaptcha",method = RequestMethod.GET)
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 {
ServletOutputStream outputStream = response.getOutputStream();
ImageIO.write(image,"png",outputStream);
} catch (IOException e) {
logger.error("响应验证码获取失败:"+e.getMessage());
}
}
修改login.html
<div class="col-sm-4">
<img th:src="@{/kaptcha}" id="kaptcha" style="width:100px;height:40px;" class="mr-2"/>
<a href="javascript:refresh_kaptcha();" class="font-size-12 align-bottom">刷新验证码</a>
</div>
......
<script>
// 刷新验证码
function refresh_kaptcha() {
var path = CONTEXT_PATH + "/kaptcha?p=" + Math.random();
$("#kaptcha").attr("src", path);
}
</script>
global.js中
var CONTEXT_PATH = "/community";
登陆、退出
登录
- 访问登录页面
- 点击顶部区域内的链接,打开登录页面。
- 登录
- 验证账号、密码、验证码。
- 成功时,生成登录凭证,发放给客户端。
- 失败时,跳转回登录页。
- 退出
- 将登录凭证修改为失效状态。
- 跳转至网站首页。
登陆凭证目前存到MySQL数据库中
entity包中新建LoginTicket类
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class LoginTicket {
private int id;
private int userId;
private String ticket;
private int status;
private Date expired;
}
mapper包中新建LoginTicketMapper接口
@Mapper
public interface LoginTicketMapper {
@Insert({
"insert into login_ticket(user_id,ticket,status,expired) ",
"values(#{userId},#{ticket},#{status},#{expired})"
})
@Options(useGeneratedKeys = true, keyProperty = "id")
int insertLoginTicket(LoginTicket loginTicket);
@Select({
"select id,user_id,ticket,status,expired ",
"from login_ticket where ticket=#{ticket}"
})
LoginTicket selectByTicket(String ticket);
@Update({
"<script>",
"update login_ticket set status=#{status} where ticket=#{ticket} ",
"<if test=\"ticket!=null\"> ",
"and 1=1 ",
"</if>",
"</script>"
})
int updateStatus(String ticket, int status);
}
UserService中添加新方法
@Autowired
LoginTicketMapper loginTicketMapper;
public Map<String,Object> login(String username, String password,int expiredSeconds){
Map<String,Object> map = new HashMap<>();
//空值的处理
if(StringUtils.isBlank(username)){
map.put("usernameMsg","用户名不能为空");
return map;
}else if(StringUtils.isBlank(password)){
map.put("passwordMsg","密码不能为空");
return map;
}
//验证账号
User user = userMapper.selectByName(username);
if(user==null){
map.put("usernameMsg","用户不存在");
return map;
}
//验证状态
if(user.getStatus()==0){
map.put("usernameMsg","该账号未激活");
return map;
}
//验证密码
password = CommunityUtil.md5(password+user.getSalt());
if(!password.equals(user.getPassword())){
map.put("passwordMsg","密码不正确");
return map;
}
//生成登陆凭证
LoginTicket loginTicket = new LoginTicket();
loginTicket.setUserId(user.getId());
loginTicket.setTicket(CommunityUtil.generateUUID());
loginTicket.setStatus(user.getStatus());
loginTicket.setExpired(new Date(System.currentTimeMillis()+expiredSeconds*1000));
loginTicketMapper.insertLoginTicket(loginTicket);
map.put("loginTicket",loginTicket.getTicket());
return map;
}
LoginController中添加新方法
@RequestMapping(path = "/login", method = RequestMethod.POST)
public String login(String username, String password, String code, boolean rememberMe,
Model model, HttpSession session, HttpServletResponse response) {
// 检查验证码
String kaptcha = (String) session.getAttribute("kaptcha");
//redis检查验证码
String kaptcha = null;
if (StringUtils.isNotBlank(kaptchaOwner)) {
String redisKey = RedisKeyUtil.getKaptchaKey(kaptchaOwner);
kaptcha = (String) redisTemplate.opsForValue().get(redisKey);
}
if (StringUtils.isBlank(kaptcha) || StringUtils.isBlank(code) || !kaptcha.equalsIgnoreCase(code)) {
model.addAttribute("codeMsg", "验证码不正确!");
return "/site/login";
}
// 检查账号,密码
int expiredSeconds = rememberMe ? REMEMBER_EXPIRED_SECONDS : DEFAULT_EXPIRED_SECONDS;
Map<String, Object> map = userService.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);
User user = userService.findUserById((Integer) map.get("userId"));
return "redirect:/index";
} else {
model.addAttribute("usernameMsg", map.get("usernameMsg"));
model.addAttribute("passwordMsg", map.get("passwordMsg"));
return "/site/login";
}
}
login.html页面
<div class="main">
<div class="container pl-5 pr-5 pt-3 pb-3 mt-3 mb-3">
<h3 class="text-center text-info border-bottom pb-3">登 录</h3>
<form class="mt-5" method="post" th:action="@{/login}">
<div class="form-group row">
<label for="username" class="col-sm-2 col-form-label text-right">账号:</label>
<div class="col-sm-10">
<input type="text" th:class="|form-control ${usernameMsg!=null?'is-invalid':''}|"
th:value="${param.username}"
id="username" name="username" placeholder="请输入您的账号!" required>
<div class="invalid-feedback" th:text="${usernameMsg}">
该账号不存在!
</div>
</div> </div> <div class="form-group row mt-4">
<label for="password" class="col-sm-2 col-form-label text-right">密码:</label>
<div class="col-sm-10">
<input type="password" th:class="|form-control ${passwordMsg!=null?'is-invalid':''}|"
th:value="${param.password}"
id="password" name="password" placeholder="请输入您的密码!" required>
<div class="invalid-feedback" th:text="${passwordMsg}">
密码不能为空
</div>
</div> </div> <div class="form-group row mt-4">
<label for="verifycode" class="col-sm-2 col-form-label text-right">验证码:</label>
<div class="col-sm-6">
<input type="text" th:class="|form-control ${codeMsg!=null?'is-invalid':''}|"
id="verifycode" name="code" placeholder="请输入验证码!">
<div class="invalid-feedback" th:text="${codeMsg}">
验证码不正确!
</div>
</div> <div class="col-sm-4">
<img th:src="@{/kaptcha}" id="kaptcha" style="width:100px;height:40px;" class="mr-2"/>
<a href="javascript:refresh_kaptcha();" class="font-size-12 align-bottom">刷新验证码</a>
</div> </div> <div class="form-group row mt-4">
<div class="col-sm-2"></div>
<div class="col-sm-10">
<input type="checkbox" id="remember-me" name="rememberme"
th:checked="${param.rememberme}">
<label class="form-check-label" for="remember-me">记住我</label>
<a href="forget.html" class="text-danger float-right">忘记密码?</a>
</div> </div> <div class="form-group row mt-4">
<div class="col-sm-2"></div>
<div class="col-sm-10 text-center">
<button type="submit" class="btn btn-info text-white form-control">立即登录</button>
</div>
</div>
</form>
</div>
</div>
退出
UserService
public void logout(String ticket) {
loginTicketMapper.updateStatus(ticket, 1);
}
LoginController
@RequestMapping(path = "/logout", method = RequestMethod.GET)
public String logout(@CookieValue("ticket") String ticket, HttpServletResponse response) {
//删除ticket cookie
Cookie cookie = new Cookie("ticket", "");
cookie.setMaxAge(0);
response.addCookie(cookie);
userService.logout(ticket);
return "redirect:/login";
}
登录信息
每次请求携带登录用户的信息,用拦截器实现
拦截器实现
- 定义拦截器,实现HandlerInterceptor
- 配置拦截器,为它指定拦截、排除的路径
拦截器应用
- 在请求开始时查询登录用户
- 在本次请求中持有用户数据
- 在模板视图上显示用户数据
- 在请求结束时清理用户数据
创建interceptor包,新建一个拦截器LoginTicketInterceptor
@Component
public class LoginTicketInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
@Autowired
private HostHolder hostHolder;
// 在Controller之前执行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从cookie中获取凭证
String ticket = CookieUtil.getValue(request, "ticket");
if (ticket != null) {
// 查询凭证
LoginTicket loginTicket = userService.findLoginTicket(ticket);
// 检查凭证是否有效
if (loginTicket != null && loginTicket.getStatus() == 0 && loginTicket.getExpired().after(new Date())) {
// 根据凭证查询用户
User user = userService.findUserById(loginTicket.getUserId());
// 在本次请求中持有用户
hostHolder.setUser(user);
}
}
return true;
}
// 在Controller之后执行,把用户信息传递给模板
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
User user = hostHolder.getUser();
if (user != null && modelAndView != null) {
modelAndView.addObject("loginUser", user);
}
}
// 在TemplateEngine之后执行
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
hostHolder.clear();
}
}
util包下HostHolder类模拟服务端的Session功能,存放是否有登陆的User
ThreadLocal保证线程安全
@Component
public class HostHolder {
private ThreadLocal<User> users = new ThreadLocal<>();
public void setUser(User user) {
users.set(user);
}
public User getUser() {
return users.get();
}
public void clear() {
users.remove();
}
}
根据key取cookie,封装成一个工具类CookieUtil
public class CookieUtil {
public static String getValue(HttpServletRequest request, String name) {
if (request == null || name == null) {
throw new IllegalArgumentException("参数为空!");
}
//从Cookie[]中获取指定name的一个cookie
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(name)) {
return cookie.getValue();
}
}
}
return null;
}
}
在config包下创建配置类WebMvcConfig,配置拦截器
@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");
}
index.html中的header
<ul class="navbar-nav mr-auto">
<li class="nav-item ml-3 btn-group-vertical">
<a class="nav-link" th:href="@{/}">首页</a>
</li>
<li class="nav-item ml-3 btn-group-vertical">
<a class="nav-link position-relative" th:href="@{/letter/list}">
消息<span class="badge badge-danger" th:text="${allUnreadCount!=0?allUnreadCount:''}">12</span>
</a>
</li>
<li class="nav-item ml-3 btn-group-vertical" th:if="${loginUser==null}">
<a class="nav-link" th:href="@{/register}">注册</a>
</li>
<li class="nav-item ml-3 btn-group-vertical" th:if="${loginUser==null}">
<a class="nav-link" th:href="@{/login}">登录</a>
</li>
<li class="nav-item ml-3 btn-group-vertical dropdown" th:if="${loginUser!=null}">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<img th:src="${loginUser.headerUrl}" class="rounded-circle" style="width:30px;"/>
</a>
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
<a class="dropdown-item text-center" th:href="@{|/user/profile/${loginUser.id}|}">个人主页</a>
<a class="dropdown-item text-center" th:href="@{/user/setting}">账号设置</a>
<a class="dropdown-item text-center" th:href="@{/logout}">退出登录</a>
<div class="dropdown-divider"></div>
</div>
</li>
</ul>
检查登录状态
- 使用拦截器
- 在方法前标注自定义注解
- 拦截所有请求,只处理带有该注解的方法
- 自定义注解
解决问题:用户没有登录但是知道url也可直接到相关界面
常用的元注解
- @Target:声明自定义注解可以作用在什么类型上,类上方法上等
- @Retention:声明自定义注解保留的时间
- @Document:声明自定义注解生成文档的时候要不要把注解也带上去
- @Inherited:用于继承的,一个子类继承父类,父类有注解,子类要不要继承这个注解
读取注解的方法
- Method.getDeclaredAnnotations()
- Method.getAnnotation(Class< T > annotationClass)
定义自定义注解,主要作用就是标识
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginRequired {
}
新建拦截器LoginRequiredInterceptor进行处理
@Component
@Deprecated
public class LoginRequiredInterceptor implements HandlerInterceptor {
@Autowired
private HostHolder hostHolder;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//保证拦截的是方法
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
LoginRequired loginRequired = method.getAnnotation(LoginRequired.class);
if (loginRequired != null && hostHolder.getUser() == null) {
response.sendRedirect(request.getContextPath() + "/login");
return false; }
}
return true;
}
}
WebMvcConfig类上加上这个拦截器
@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");
}
}
最后对需要拦截的方法上加此注解
账号设置
- 上传文件
- 请求:必须是POST请求
- 表单:enctype=“multipart/form-data”
- Spring MVC:通过 MultipartFile 处理上传文件
新建UserController类
@Controller
@RequestMapping("/user")
public class UserController {
@RequestMapping(path="/setting",method = RequestMethod.GET)
public String getSettingPage(){
return "/site/setting";
}
}
修改index.html文件
<a class="dropdown-item text-center" th:href="@{/user/setting}">账号设置</a>
上传头像
//上传头像
//@LoginRequired
@RequestMapping(path = "/upload", method = RequestMethod.POST)
public String uploadHeader(MultipartFile headerImage, Model model) {
if (headerImage == null) {
model.addAttribute("error", "您还没有选择图片!");
return "/site/setting";
}
String fileName = headerImage.getOriginalFilename();
model.addAttribute("fileName", fileName);
String suffix = fileName.substring(fileName.lastIndexOf("."));
if (StringUtils.isBlank(suffix)) {
model.addAttribute("error", "文件的格式不正确!");
return "/site/setting";
}
// 生成随机文件名
fileName = CommunityUtil.generateUUID() + suffix;
// 确定文件存放的路径
File dest = new File(uploadPath + "/" + fileName);
try {
// 存储文件
headerImage.transferTo(dest);
} catch (IOException e) {
logger.error("上传文件失败: " + e.getMessage());
throw new RuntimeException("上传文件失败,服务器发生异常!", e);
}
// 更新当前用户的头像的路径(web访问路径)
// http://localhost:8080/community/user/header/xxx.png User user = hostHolder.getUser();
String headerUrl = domain + contextPath + "/user/header/" + fileName;
userService.updateHeader(user.getId(), headerUrl);
return "redirect:/index";
}
@Update("update user set header_url = #{headerUrl} where id = #{id}")
int updateHeader(int id, String headerUrl);
获取头像
//服务器路径显示头像
@RequestMapping(path = "/header/{fileName}", method = RequestMethod.GET)
public void getHeader(@PathVariable("fileName") String fileName, HttpServletResponse response) {
// 服务器存放路径
fileName = uploadPath + "/" + fileName;
// 文件后缀
String suffix = fileName.substring(fileName.lastIndexOf("."));
// 响应图片
response.setContentType("image/" + suffix);
try (
FileInputStream fis = new FileInputStream(fileName);
OutputStream os = response.getOutputStream();
) {
byte[] buffer = new byte[1024];
int b = 0;
while ((b = fis.read(buffer)) != -1) {
os.write(buffer, 0, b);
}
} catch (IOException e) {
logger.error("读取头像失败: " + e.getMessage());
}
}
修改密码
//修改密码
@RequestMapping(value = "/updatePassword", method = RequestMethod.POST)
public String updatePassword(String oldPassword, String newPassword, Model model) {
if (StringUtils.isBlank(newPassword) || StringUtils.isBlank(oldPassword)) {
model.addAttribute("oldPasswordMsg", "密码不能为空");
return "site/setting";
}
if (oldPassword.equals(newPassword)) {
model.addAttribute("oldPasswordMsg", "新旧密码不能一致");
return "site/setting";
}
User user = hostHolder.getUser();
oldPassword = CommunityUtil.md5(oldPassword + user.getSalt());
if (!oldPassword.equals(user.getPassword())) {
model.addAttribute("oldPasswordMsg", "密码错误");
return "site/setting";
}
newPassword = CommunityUtil.md5(newPassword + user.getSalt());
userService.updatePassword(user.getId(), newPassword);
user.setPassword(newPassword);
hostHolder.setUser(user);
return "redirect:/index";
}
@Update("update user set password = #{password} where id = #{id}")
int updatePassword(int id, String password);
修改setting.html
<div class="main">
<div class="container p-5 mt-3 mb-3">
<!-- 上传头像 -->
<h6 class="text-left text-info border-bottom pb-2">上传头像</h6>
<form class="mt-5" method="post" enctype="multipart/form-data" th:action="@{/user/upload}">
<div class="form-group row mt-4">
<label for="head-image" class="col-sm-2 col-form-label text-right">选择头像:</label>
<div class="col-sm-10">
<div class="custom-file">
<input type="file" th:class="|custom-file-input ${error!=null?'is-invalid':''}|"
id="head-image" name="headerImage" lang="es" required="">
<label class="custom-file-label" for="head-image" data-browse="文件" th:text="${fileName}">选择一张图片</label>
<div class="invalid-feedback" th:text="${error}">
该账号不存在!
</div>
</div>
</div>
</div>
<div class="form-group row mt-4">
<div class="col-sm-2"></div>
<div class="col-sm-10 text-center">
<button type="submit" class="btn btn-info text-white form-control">立即上传</button>
</div>
</div>
</form> <!-- 修改密码 -->
<h6 class="text-left text-info border-bottom pb-2 mt-5">修改密码</h6>
<form class="mt-5" th:action="@{/user/updatePassword}" method="post">
<div class="form-group row mt-4">
<label for="old-password" class="col-sm-2 col-form-label text-right">原密码:</label>
<div class="col-sm-10">
<input type="password" th:class="|form-control ${oldPasswordMsg!=null?'is-invalid':''}|" id="old-password" name="oldPassword" placeholder="请输入原始密码!" required>
<div class="invalid-feedback" th:text="${oldPasswordMsg}">
密码长度不能小于8位!
</div>
</div>
</div>
<div class="form-group row mt-4">
<label for="new-password" class="col-sm-2 col-form-label text-right">新密码:</label>
<div class="col-sm-10">
<input type="password" class="form-control" id="new-password" name="newPassword" placeholder="请输入新的密码!" required>
<div class="invalid-feedback">
密码长度不能小于8位!
</div>
</div>
</div>
<div class="form-group row mt-4">
<label for="confirm-password" class="col-sm-2 col-form-label text-right">确认密码:</label>
<div class="col-sm-10">
<input type="password" class="form-control" id="confirm-password" placeholder="再次输入新密码!" required>
<div class="invalid-feedback">
两次输入的密码不一致!
</div>
</div>
</div>
<div class="form-group row mt-4">
<div class="col-sm-2"></div>
<div class="col-sm-10 text-center">
<button type="submit" class="btn btn-info text-white form-control">立即保存</button>
</div>
</div>
</form>
</div>
</div>