这个项目是一个基于SSM的个人博客系统项目,项目一个前后端分离的项目,目前已经完工。它的主要技术包含SpringAOP、手动实现的加盐算法、登录验证码、Redis存储Session等.
这个项目已开源,我会将项目的gitee连接放到最下面。
- common 目录 : 存放工具类、统一返回格式和全局变量
- config 目录 : 配置相关的东西,例如:登录拦截器
- controller 目录 : 处理前端返回的数据
- entity 目录 : 存放实体类
- mapper 目录 : 里面是提供给 MyBatis 的接口
- service 目录 : 这个是统一调用 mapper 的接口
- resources/Mybatis 目录 : 实现 mapper 中接口,对接数据库
- resources/static 目录 : 存放前端的内容
- dp.sql: 存放使用的sql语句
Lombok、Spring Web、Spring Session、MyBatis Framework、MySQL Driver、Hutool、Slf4j
# 数据库连接
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/mycnblog?characterEncoding=utf8&useSSL=false&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=9root
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
## 设置时间格式 对 LocalDateTime 和 LocalDate 不起作用
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8
# mybatis xml 配置
mybatis.mapper-locations=classpath:Mybatis/*Mapper.xml
# 控制台打印mybatis 执行的sql语句
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
logging.level.com.example.demo=debug
# redisd配置代码
spring.redis.port=6379
spring.redis.password=
spring.redis.database=0 #默认数据库编号是0
spring.session.store-type=redis
server.servlet.session.timeout=1800
spring.session.redis.flush-mode=on_save
spring.session.redis.namespace=spring:session
spring.redis.host=127.0.0.1
数据库中一共有三个表,分别是:用户表(userinfo)、文章表(articleinfo)。用户表用来存储用户信息,文章表就是用来存储文章。
该类是对返回数据进行统一的封装,为什么要统一封装呢?对于前端来说,它们是不懂后端的代码的,因此我们需要将它的数据统一进行封装返回前端,这里使用的方法就是将内容封装成一个类,在需要返回的数据的时候,将这个类实例化,然后将它转化为 Json
的形式传给前端。
package com.example.demo.common;
import lombok.Data;
import java.io.Serializable;
/**
* 统一数据返回类型
*
*/
@Data
public class AjaxResult implements Serializable {
//Serializable接口是为了实现序列化和反序列化不报错
//状态码
private Integer code;
//状态码描述
private String msg;
//返回的数据
private Object data;
/**
* 操作成功返回的结果
* 进行多次重载提供选择
*/
public static AjaxResult success(Object data) {
AjaxResult result = new AjaxResult();
result.setCode(200);
result.setMsg("");
result.setData(data);
return result;
}
public static AjaxResult success(int code,Object data) {
AjaxResult result = new AjaxResult();
result.setCode(code);
result.setMsg("");
result.setData(data);
return result;
}
public static AjaxResult success(int code,String msg,Object data) {
AjaxResult result = new AjaxResult();
result.setCode(code);
result.setMsg(msg);
result.setData(data);
return result;
}
/**
* 返回失败结果
* 进行多次重载提供选择
*
*/
public static AjaxResult fail(int code,String msg) {
AjaxResult result = new AjaxResult();
result.setCode(code);
result.setMsg(msg);
result.setData(null);
return result;
}
public static AjaxResult fail(int code,String msg,Object data) {
AjaxResult result = new AjaxResult();
result.setCode(code);
result.setMsg(msg);
result.setData(data);
return result;
}
}
这个就类就简单描述;主要就是存储一些全局变量;为啥要单独使用一个类?这样是为了降低代码的耦合度。
package com.example.demo.common;
/**
* 保存全局变量
*/
public class AppVariable {
// 定义 session 的key值
public static final String USER_SESSION_KEY = "USER_SESSION_KEY";
// 图片存储在 session 的key值
public static final String CAPTCHA_SESSION_KEY = "CAPTCHA";
}
这个类是配合Hutool
库使用的,主要的作用是生成验证码图片和验证验证码的正确性。
package com.example.demo.common;
import cn.hutool.captcha.CaptchaUtil;
import cn.hutool.captcha.LineCaptcha;
import javax.imageio.ImageIO;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class CaptchaUtils {
// 生成验证码
public static void generateCaptcha(HttpServletRequest request, HttpServletResponse response) throws IOException {
LineCaptcha captcha = CaptchaUtil.createLineCaptcha(200, 50);
// 将验证码存储在Session中用于验证
request.getSession().setAttribute(AppVariable.CAPTCHA_SESSION_KEY, captcha.getCode());
// 设置响应头
response.setContentType("image/png");
response.setHeader("Pragma", "no-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expires", 0L);
// 将验证码图片写入响应流
ServletOutputStream outputStream = response.getOutputStream();
ImageIO.write(captcha.getImage(), "png", outputStream);
outputStream.flush();
outputStream.close();
}
/**
* 判断输入的验证码的正确性
*
* @param request
* @param userInput 用户输入的密码
* @return
*/
public static boolean validateCaptcha(HttpServletRequest request, String userInput) {
String storedCaptcha = (String) request.getSession().getAttribute(AppVariable.CAPTCHA_SESSION_KEY);
return userInput != null && userInput.equalsIgnoreCase(storedCaptcha);
}
}
这个类中实现了加盐算法,这里解释一下加盐算法。我们规定的加盐算法是65位,前32为是盐值通过UUID
生成,中间加一个分隔符,之后后面是使用盐值加上明文(密码)再使用MD5
加密。具体看下图:
对于解密,我们可以通过分隔符提取出盐值,将盐值传给辅助方法,通过之前一样加盐的方式进行加盐,因为我们同一个数据,我们使用相同的操作,故如果输入的密码和设置的密码一样,那么两次操作后的数据应该是一样的。
package com.example.demo.common;
import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;
import java.util.UUID;
/**
* 实现加盐算法
*
*/
public class PasswordUtils {
/**
* 1.给用户传过来的明文密码加密
*
* @param password 明文密码
* @return 保存在数据库中的密码
*/
public static String encrypt(String password) {
// 1.生成盐值(32位)
String salt = UUID.randomUUID().toString().replace("-","");
// 2.加盐后的密码:(盐值+明文)再用MD5加密
String saltPassword = DigestUtils.md5DigestAsHex((salt+password).getBytes());
// 3.最终密码:【盐值+$+加盐后的密码】(65位)
String finalPassword = salt+"$"+saltPassword;
return finalPassword;
}
/**
* 2.辅助解密方法;方法1的重载
*
* @param password 用户输入的密码
* @param salt 盐值(需要从数据库中的密码提取出来)
* @return
*/
private static String encrypt(String password,String salt) {
// 1.加盐后的密码 (32位)
String saltPassword = DigestUtils.md5DigestAsHex((salt+password).getBytes());
// 2.最终密码:【盐值+$+加盐后的密码】(65位)
String finalPassword = salt+"$"+saltPassword;
return finalPassword;
}
/**
* 3.解密方法
*
* @param inputPassword // 用户输入的密码
* @param sqlPassword // 数据库中的密码
* @return
*/
public static boolean check(String inputPassword,String sqlPassword) {
// 参数校验
if(StringUtils.hasLength(inputPassword) && StringUtils.hasLength(sqlPassword)
&& sqlPassword.length() == 65) {
// 获取盐值
String salt = sqlPassword.substring(0,32);
// 通过方法1的方式加密
String confirmPassword = encrypt(inputPassword,salt);
// 比较两次的结果
if(confirmPassword.equals(sqlPassword)) {
return true;
}
}
return false;
}
}
这个类辅助我们获得session
中的用户,以及判断是否登录。
package com.example.demo.common;
import com.example.demo.entity.UserInfo;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
/**
* 操作当前session的工具类
*/
public class UserSessionUtils {
/**
* 得到当前用户
*
* @param request
* @return
*/
public static UserInfo getSessUser(HttpServletRequest request) {
HttpSession session = request.getSession(false);
// 判断是否登录
if (session!=null && session.getAttribute(AppVariable.USER_SESSION_KEY)!= null) {
// 两个都不为空,标准登录了
return (UserInfo) session.getAttribute(AppVariable.USER_SESSION_KEY);
}
return null;
}
}
这个目录中主要是实现了一个登录拦截器和 统一返回数据的保底类
它是实现原理用到了AOP
的思想;这个类需要实现HandlerInterceptor
接口,并且重写preHandle
方法,判断是否用户登录,如果没有登录,无法访问我们配置之外的接口和网页。
这里的配置是指的AppConfig
这个类中的数据
package com.example.demo.config;
import com.example.demo.common.AppVariable;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
* 登录拦截器
*
*/
public class LoginInterceptor implements HandlerInterceptor {
/**
* true -》 用户已登录
* false -》用户未登录
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession session =request.getSession(false);
//判断session不为空,并且session的key不为空,说明用户已登录
if(session != null && session.getAttribute(AppVariable.USER_SESSION_KEY) != null) {
//用户已登录
return true;
}
// 未登录,跳转到登录页
response.sendRedirect("/login.html");
return false;
}
}
这个类是统一返回类型的保底机制,如果我们忘了统一返回类型,那么他会帮我们包装成Json
格式。
他是相当于一个控制增强器@ControllerAdvice
,我们通过实现ResponseBodyAdvice接口
来完成这个功能,它主要重写两个方法,一个是supports
方法:它相当于一个开关,true表示需要执行beforeBodyWrite
方法,false表现不需要执行beforeBodyWrite
方法;然后beforeBodyWrite
方法内就是逻辑的具体实现了。
package com.example.demo.config;
import com.example.demo.common.AjaxResult;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
/**
* 实现统一返回数据的保底类
* 说明:如果返回数据时,检查是否为统一返回类型,如果不是就改为统一类型
*
*/
@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice {
@Autowired
private ObjectMapper objectMapper;
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return true;
}
/**
* 对数据格式校验和封装
*
*/
@SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
// 判断数据是否封装
if(body instanceof AjaxResult) return body;
// 如果是String类型的特殊处理,手动将String转换成json格式
if(body instanceof String) {
return objectMapper.writeValueAsString(AjaxResult.success(body));
}
//如果不是ajax的格式,并且不上String类型
return AjaxResult.success(body);
}
}
前端通过Ajax
的方式以Json
形式将数据发送给后端,如果注册成功就会询问你是否跳转页面。
/**
* 注册功能
*
* @param userInfo
* @return
*/
@RequestMapping("/reg")
public AjaxResult reg(UserInfo userInfo){
//非空校验和参数有效性校验
if(userInfo==null || !StringUtils.hasLength(userInfo.getUsername())
|| !StringUtils.hasLength(userInfo.getPassword())) {
return AjaxResult.fail(-1,"参数非法");
}
// 对密码加盐加密
userInfo.setPassword(PasswordUtils.encrypt(userInfo.getPassword()));
return AjaxResult.success(userService.reg(userInfo));
}
登录我们需要输入用户名和密码和验证码,必须三个都正确才能登录,登录后会直接跳转到个人博客的列表页。
/**
* 登录功能
*
* @param username
* @param password
* @param request
* @return
*/
@RequestMapping("/login")
public AjaxResult login(String username,String password,HttpServletRequest request,String captcha) {
// 非空验证和参数合法性验证
if(!StringUtils.hasLength(username) || !StringUtils.hasLength(password)) {
return AjaxResult.fail(-1,"参数非法");
}
// 查询出来数据库中的对象
UserInfo userInfo = userService.getUserByName(username);
// 判断验证码的正确性
boolean isCaptchaValid = CaptchaUtils.validateCaptcha(request, captcha);
// 判断用户有效性
if(userInfo !=null && userInfo.getId()>0 && isCaptchaValid) {
if(PasswordUtils.check(password,userInfo.getPassword())) {
// 登录成功
userInfo.setPassword("");//返回数据,隐藏敏感(密码)信息
// 存储 session
HttpSession session = request.getSession(true);
session.setAttribute(AppVariable.USER_SESSION_KEY,userInfo);
return AjaxResult.success(userInfo);
}
}
return AjaxResult.success(0,null);
}
我们通过session读取当前登录的用户,之后再统计这个用户写的文章用户,我们创建一个新的实体类UserInfoVO
,这个类主要是辅助我们统计用户的文章数量。
直接删除session
中的键就算是注销了,我们通过调用session
中的removeAttribute
方法实现
/**
* 注销功能
*
* @param session
* @return
*/
@RequestMapping("/logout")
public AjaxResult logOut(HttpSession session) {
session.removeAttribute(AppVariable.USER_SESSION_KEY);
return AjaxResult.success(1);
}
当我们点击查看全文的时候,我们需要加载根据文章作者加载作者,我们下方这个方法实现。
/**
* 根据文章中UID查询用户
*
* @param uid
* @return
*/
@RequestMapping("/getuserbyid")
public AjaxResult getUserById(Integer uid) {
if(uid==null && uid<=0) {
return AjaxResult.fail(-1,"用户id非法!");
}
UserInfoVO userInfoVO = new UserInfoVO();
// 通过前端传的uid查出用户
UserInfo userInfo = userService.getUserById(uid);
// 传值方便查询文章篇数
BeanUtils.copyProperties(userInfo,userInfoVO);
// 存储文章篇数
userInfoVO.setArtCount(articleService.getArtCountByUid(uid));
userInfoVO.setPassword("");// 屏蔽密码
return AjaxResult.success(userInfoVO);
}
这个类中主要是配合Hutool
库来使用,调用工具类来生成验证码。
package com.example.demo.controller;
import com.example.demo.common.CaptchaUtils;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Controller
@RequestMapping("/captcha")
public class CaptchaController {
@GetMapping("/generate")
public void generateCaptcha(HttpServletRequest request, HttpServletResponse response) throws IOException {
CaptchaUtils.generateCaptcha(request, response);
}
@GetMapping("/validate")
@ResponseBody
public boolean validateCaptcha(HttpServletRequest request, String userInput) {
return CaptchaUtils.validateCaptcha(request, userInput);
}
}
通过session
获得当前登录的用户,然后查询他的所有文章,并返回给前端。
/**
* 获取登录用户的所有文章
*
* @param request
* @return
*/
@PostMapping("/mylist")
public AjaxResult getMyList(HttpServletRequest request) {
// 1.得到用户
UserInfo userInfo = UserSessionUtils.getSessUser(request);
// 判断用户是否成功登录
if (userInfo == null) {
// 登录失败的情况
return AjaxResult.fail(-1,"非法请求");
}
// 2.查询用户所有文章
List<ArticleInfo> list = articleService.getAllArtByUId(userInfo.getId());
for (ArticleInfo a:list) {
String s = a.getContent();
s=s.replaceAll("#","");
if(s.length()>50) {
a.setContent(s.substring(0,50)+"...");
}else {
a.setContent(s);
}
}
return AjaxResult.success(list);
}
删除文章时,我们需要判断登录的这个人是不是这篇文章的作者,如果这篇文章是张三写的,李四把他删了,那就杯具了!!
/**
* 这里需要注意,我们在删除的时候,需要确认删除的这篇文章是现在登录用户的文章
*
* @param request
* @param id 文章id
* @return
*/
@RequestMapping("/del")
public AjaxResult delArtById(HttpServletRequest request,Integer id) {
// 1.得到用户
UserInfo userInfo = UserSessionUtils.getSessUser(request);
// 判断用户是否成功登录
if (userInfo == null || userInfo.getId()<=0) {
// 登录失败的情况
return AjaxResult.fail(-1,"非法请求");
}
// 判断文章id 的合法性
if(id<=0 || id==null) {
return AjaxResult.fail(-1,"非法参数");
}
// 这个表示影响的行数
int res = articleService.delArtById(id,userInfo.getId());
return AjaxResult.success(res);
}
前端发送过来文章的id,我们通过文章id 返回文章。
/**
* 根据 文章id 返回文章
*
* @param id 文章id
* @return
*/
@RequestMapping("/detail")
public AjaxResult getArtDetailById(Integer id) {
if(id==null || id<=0) {
return AjaxResult.fail(-1,"文章id非法");
}
// 通过文章id 查询文章
ArticleInfo articleInfo = articleService.getArtDetailById(id);
return AjaxResult.success(articleInfo);
}
前端发过来文章id,为了保证操作的原子性,我们通过数据库操作来对阅读量加一。
/**
* 修改文章阅读量
*
* @param id 文章id
* @return
*/
@RequestMapping("/updatercount")
public AjaxResult inCrRCount(Integer id) {
if(id==null || id<=0) {
return AjaxResult.fail(-1,"文章id非法");
}
return AjaxResult.success(articleService.inCrRCount(id));
}
我们直接使用使用通过ArticleInfo
类来接收前端发送的信息,这样的好处是不管别人传的什么,我们都不要取修改参数的个数。
/**
* 用户写博客添加博客
*
* @param request
* @param articleInfo
* @return
*/
@RequestMapping("/add")
public AjaxResult add(HttpServletRequest request,ArticleInfo articleInfo) {
// 1.参数效验
if(articleInfo ==null || !StringUtils.hasLength(articleInfo.getTitle())
|| !StringUtils.hasLength(articleInfo.getContent())) {
return AjaxResult.fail(-1,"非法参数");
}
// 2.给文章添加上uid
// a.获得user对象
UserInfo userInfo = UserSessionUtils.getSessUser(request);
// b.user的合法性验证
if(userInfo==null || userInfo.getId()<=0){
return AjaxResult.fail(-1,"参数非法");
}
// b.赋值art的uid
articleInfo.setUid(userInfo.getId());
return AjaxResult.success(articleService.add(articleInfo));
}
我们得先获得当前的登录对象,得到登录对象是为了给修改后的文章添加UID
,为啥不让前端直接传输UID
呢,这是因为有风险(防止别人抓包恶搞),因此我们通过session
获取用户再写入UID
/**
* 修改博客
*
* @param articleInfo
* @param request
* @return
*/
@RequestMapping("/update")
public AjaxResult upDate(ArticleInfo articleInfo,HttpServletRequest request) {
// 1.参数效验
if(articleInfo==null || !StringUtils.hasLength(articleInfo.getTitle())
|| !StringUtils.hasLength(articleInfo.getContent()) || articleInfo.getId()==null) {
return AjaxResult.fail(-1,"参数非法");
}
// 2.修改数据库中的数据
// a.获得 user 对象
UserInfo userInfo = UserSessionUtils.getSessUser(request);
// 判断 user 对象和合法性
if(userInfo == null || userInfo.getId()<=0) {
return AjaxResult.fail(-1,"无效用户");
}
// 这里不能让前端传uid,别人抓包可以抓到,有风险
articleInfo.setUid(userInfo.getId());
// 修改时间
articleInfo.setUpdatetime(LocalDateTime.now());
// b.将数据保存到数据库中
return AjaxResult.success(articleService.upDate(articleInfo));
}
前端需要给我们传过来页码和一页的文章数量,这样是为了方便我们计算到底需要共几页。
/**
* 分页功能的实现
*
* @param pindex 页码(从一开始)
* @param psize 一页所展现的文章数量
* @return
*/
@RequestMapping("/listbypage")
public AjaxResult getListByPage(Integer pindex,Integer psize) {
// 1.参数矫正
if(pindex==null || pindex<=1) {
pindex =1;
}
if(psize==null || psize<=1) {
psize = 2;
}
// 分页公式
int offsize =(pindex-1)*psize;
List<ArticleInfo> list = articleService.getListByPage(psize,offsize);
// 获取总页数
// a.获得文章总条数
int artCount = articleService.getCountALLArt();
// b.计算页数: 文章文章总条数/一页所展现的文章数量(无论获得的值小数点多小,都需要进1)
double pcountdb = artCount/(psize*1.0);
int pcount = (int) Math.ceil(pcountdb);
HashMap<String,Object> result = new HashMap<>();
result.put("list",list);
result.put("pcount",pcount);
return AjaxResult.success(result);
}
总的来说,这个项目内容的覆盖率非常大,项目的亮点在于登录拦截器的实现、登录验证码的实现、文章分页、手动实现加盐算法、Redis存储Session。
最后附上代码仓库:点击跳转:个人博客项目代码
如果你有任何疑问,可以添加下方微信!