博客系统(Java 实现详解)
项目介绍
使用 SSM 框架实现一个简单的博客系统
共 5 个页面
- 用户登录
- 博客发表页
- 博客编辑页
- 博客列表页
- 博客详情页
功能描述:
用户登录成功后,可以查看所有人的博客,点击“查看原文”可以查看该博客的正文内容,如果该博客作者为当前登录用户,可以完成博客的修改和删除操作,以及发表新博客
页面预览
用户登录
博客列表页
博客详情页
博客发表/修改页
1. 准备工作
1.1 数据准备
建表 SQL
-- 建表SQL
create database if not exists java_blog_spring charset utf8mb4;
use java_blog_spring;
-- 用户表
DROP TABLE IF EXISTS java_blog_spring.user_info;
CREATE TABLE java_blog_spring.user_info(
`id` INT NOT NULL AUTO_INCREMENT,
`user_name` VARCHAR ( 128 ) NOT NULL,
`password` VARCHAR ( 128 ) NOT NULL,
`github_url` VARCHAR ( 128 ) NULL,
`delete_flag` TINYINT ( 4 ) NULL DEFAULT 0,
`create_time` DATETIME DEFAULT now(),
`update_time` DATETIME DEFAULT now() ON UPDATE now(),
PRIMARY KEY ( id ),
UNIQUE INDEX user_name_UNIQUE ( user_name ASC )) ENGINE = INNODB DEFAULT CHARACTER
SET = utf8mb4 COMMENT = '用户表';
-- 博客表
drop table if exists java_blog_spring.blog_info;
CREATE TABLE java_blog_spring.blog_info (
`id` INT NOT NULL AUTO_INCREMENT,
`title` VARCHAR(200) NULL,
`content` TEXT NULL,
`user_id` INT(11) NULL,
`delete_flag` TINYINT(4) NULL DEFAULT 0,
`create_time` DATETIME DEFAULT now(),
`update_time` DATETIME DEFAULT now() ON UPDATE now(),
PRIMARY KEY (id))
ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '博客表';
-- 新增用户信息
insert into java_blog_spring.user_info (user_name, password,github_url)values("zhangsan","123456","https://gitee.com/bubble-fish666/class-java45");
insert into java_blog_spring.user_info (user_name, password,github_url)values("lisi","123456","https://gitee.com/bubble-fish666/class-java45");
insert into java_blog_spring.blog_info (title,content,user_id) values("第一篇博客","111我是博客正文我是博客正文我是博客正文",1);
insert into java_blog_spring.blog_info (title,content,user_id) values("第二篇博客","222我是博客正文我是博客正文我是博客正文",2);
1.2 创建项目
创建 Spring Boot 项目,添加 Lombok、Spring Web、MySQL Dirver 依赖
1.3 添加 MyBatis-Plus 依赖
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.9</version>
</dependency>
1.4 配置文件
spring:
application:
name: spring-boot-blog
datasource:
url: jdbc:mysql://127.0.0.1:3306/java_blog_spring?characterEncoding=utf8&useSSL=false
username: root
password: "111111"
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
file:
name: spring-blog.log
1.5 创建对应目录
1.6 前端代码
2. 项目公共模块
项目分为控制层(Controller),服务层(Service),持久层(Mapper),各层之间的调用关系如下:
根据需求完成公共层代码的编写
2.1 统一返回结果实体类
(1) code:业务状态码
200:业务处理成功
-1:业务处理失败
后续有其他异常信息再补充
(2) errMsg:业务处理失败时,返回的错误信息
(3) data:业务返回数据
定义业务状态枚举类
package com.example.blog.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
public enum ResultStatusEnum {
// 200 成功 -1 失败
SUCCESS(200),
FAIL(-1);
@Getter
int code;
}
package com.example.blog.common.pojo;
import com.example.blog.common.enums.ResultStatusEnum;
import lombok.Data;
@Data
public class Result<T> {
private int code;
private String errMsg;
private T data;
// 成功时
public static <T> Result<T> ok(T data) {
Result result = new Result();
result.setCode(ResultStatusEnum.SUCCESS.getCode());
result.setErrMsg("");
result.setData(data);
return result;
}
// 失败且无数据传输时
public static <T> Result<T> fail(String errMsg) {
Result result = new Result();
result.setCode(ResultStatusEnum.FAIL.getCode());
result.setErrMsg(errMsg);
return result;
}
// 失败但有数据传输
public static <T> Result<T> fail(String errMsg, T data) {
Result result = new Result();
result.setCode(ResultStatusEnum.FAIL.getCode());
result.setErrMsg(errMsg);
result.setData(data);
return result;
}
}
2.2 统一结果返回
package com.example.blog.common.advice;
import com.example.blog.common.pojo.Result;
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; // 利用其方法将 String 类型的返回值转换为 Result 类型
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return true;
}
// 该注解相当于在整个方法上加上了 try catch
@SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
// 若返回值是 Result 类型,则直接返回
if (body instanceof Result<?>) {
return body;
}
// 对 String 类型进行处理,通常情况下,不会返回 String 类型
if (body instanceof String) {
return objectMapper.writeValueAsString(Result.ok(body));
}
return Result.ok(body);
}
}
2.3 定义项目异常
package com.example.blog.common.exception;
import lombok.Data;
@Data
public class BlogException extends RuntimeException{
private int code;
private String message;
// 无参异常
public BlogException() {
}
// 根据 code 码报的异常
public BlogException(int code) {
this.code = code;
}
// 根据错误信息报的异常
public BlogException(String message) {
this.message = message;
}
public BlogException(int code, String message) {
this.code = code;
this.message = message;
}
}
2.4 统一异常处理
package com.example.blog.common.advice;
import com.example.blog.common.exception.BlogException;
import com.example.blog.common.pojo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.servlet.resource.NoResourceFoundException;
/**
* 统一异常处理
*/
@Slf4j
@ResponseBody
@ControllerAdvice
public class ExceptionAdvice {
@ExceptionHandler(Exception.class)
public Result handler(Exception e) {
log.error("发生异常,e:{}", e);
return Result.fail("发生错误" + e.getMessage());
}
// 该注解可以定义其捕获某个或某几个具体的异常
@ExceptionHandler(BlogException.class)
public Result blogException(Exception e) {
log.error("发生错误,e:{}", e);
return Result.fail(e.getMessage());
}
@ResponseStatus(HttpStatus.NOT_FOUND)
@ExceptionHandler
public Result handler(NoResourceFoundException e) {
log.error("文件不存在,e:{}", e);
return Result.fail("文件不存在" + e.getResourcePath());
}
}
3. 业务代码
3.1 持久层
使用 MyBatis-Plus 来完成持久层代码的开发,创建 mapper 实现 BaseMapper 即可
实体类
package com.example.blog.common.pojo.dataobject;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
public class BlogInfo {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
private String title;
private String content;
private Integer userId;
private Integer deleteFlag;
private LocalDate createTime;
private LocalDate updateTime;
}
package com.example.blog.common.pojo.dataobject;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import java.time.LocalDate;
@Data
public class UserInfo {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
private String userName;
private String password;
private String githubUrl;
private Integer deleteFlag;
private LocalDate createTime;
private LocalDate updateTime;
}
Mapper
package com.example.blog.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.blog.common.pojo.dataobject.UserInfo;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserInfoMapper extends BaseMapper<UserInfo> {
}
package com.example.blog.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.blog.common.pojo.dataobject.BlogInfo;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BlogInfoMaper extends BaseMapper<BlogInfo> {
}
3.2 实现博客列表
约定前后端交互接口
[请求]
/bolg/getList GET
[响应]
{ "code": 200, "errMsg": "", "data": [ { "id": 2, "title": "第二篇博客", "content": "222我是博客正文我是博客正文我是博客正文", "createTime": "2024-12-13" }, ...... ] }
客户端给服务器发送一个 /bolg/getList 这样的 HTTP 请求,服务器给客户端返回一个 JSON 格式的数据
实现服务器代码
1. 定义接口返回实体
package com.example.blog.common.pojo.response;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Date;
/**
* 响应实体类,前端需要什么,就返回什么,不返回无用属性
*/
@Data
public class BlogInfoResponse {
private Integer id;
private String title;
private String content;
// private Integer userId;
private Date createTime;
}
有三种日期返回格式,如下:
都不是前端想要接收的类型,需要手动修改返回的日期格式,有以下两种方法:
1. 传统方式:
2. 使用注解 @JsonFormat
tip:若是 Date 类型,还需要修改时区,如下:
通过 @JsonFormat 设置页面返回的日期格式
格式参考 java.text.SimpleDateFormat 官方文档
tip:上面不管是博客列表页还是博客详情页,返回都是一样的,现想在博客列表页展示全文的前 20 个字,而博客详情页要展示全文,仅需定义两种返回类即可,如下:
返回给博客列表页的类
package com.example.blog.common.pojo.response;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.util.Date;
/**
* 返回给 “博客列表页” 的数据,正文只返回前20个字
*/
@Data
public class BlogListResponse {
private Integer id;
private String title;
private String content;
// private Integer userId;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime;
public String getContent() {
if (content == null) return "";
String ret = content.replaceAll("#", ""); // 替换掉文章中的 #,使其不在列表页中显示
if (ret.length() >= 20) return ret.substring(0, 20) + "..."; // 若是字符串不够20长度会报 StringIndexOutOfBoundsException
return ret;
}
}
返回给博客详情页的类
package com.example.blog.common.pojo.response;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.util.Date;
/**
* 响应实体类,前端需要什么,就返回什么,不返回无用属性
* 这里是博客详情页返回的数据,全文会全部显示
*/
@Data
public class BlogDetailResponse {
private Integer id;
private String title;
private String content;
// private Integer userId;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") // 方式二:注解
private Date createTime;
}
2. 实现 Controller
package com.example.blog.controller;
import com.example.blog.common.pojo.response.BlogListResponse;
import com.example.blog.service.BlogService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Slf4j
@RequestMapping("/blog")
@RestController
public class BlogController {
@Resource(name = "blogServiceImpl")
private BlogService blogService;
@RequestMapping("/getList")
public List<BlogListResponse> getList() {
log.info("获取博客列表");
return blogService.getList();
}
}
3. 实现 Service
基于 SOA 理念,Service 采用接口对外提供服务,实现类用 Impl 的后缀与接口区别
Service 接口
package com.example.blog.service;
import com.example.blog.common.pojo.response.BlogListResponse;
import java.util.List;
public interface BlogService {
List<BlogListResponse> getList();
}
Service 实现类
package com.example.blog.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.blog.common.pojo.dataobject.BlogInfo;
import com.example.blog.common.pojo.response.BlogListResponse;
import com.example.blog.mapper.BlogInfoMaper;
import com.example.blog.service.BlogService;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class BlogServiceImpl implements BlogService {
@Autowired
private BlogInfoMaper blogInfoMaper;
@Override
public List<BlogListResponse> getList() {
/**
* 查询数据库
* 从数据库中查询 BlogInfo 表内 deleteFlag 字段值为 0 的记录,并按照 id 字段降序排列
* 最后将查询到的多条符合条件的 BlogInfo 记录以列表形式返回,存储在 blogInfos 变量中
*/
List<BlogInfo> blogInfos = blogInfoMaper.selectList(new LambdaQueryWrapper<BlogInfo>()
.eq(BlogInfo::getDeleteFlag, 0).orderByDesc(BlogInfo::getId));
// 将一个 List<BlogInfo> 集合中的元素批量转换为 List<BlogListResponse> 集合元素
List<BlogListResponse> blogListResponses = blogInfos.stream().map(blogInfo -> {
BlogListResponse blogListResponse = new BlogListResponse();
BeanUtils.copyProperties(blogInfo, blogListResponse);
return blogListResponse;
}).collect(Collectors.toList());
return blogListResponses;
}
}
部署程序,验证服务器是否能正确返回数据
通过 postman 访问 http://127.0.0.1:8080/blog/getList
实现客户端代码
使用 ajax 给服务器发送 HTTP 请求
服务器返回的响应是一个 JSON 格式的数据,根据这个响应数据使用 DOM API 构造页面内容
跳转到博客详情页的 url 形如 blog_detail.html?blogId=1 这样就可以让博客详情页知道当前是要访问哪篇博客
<script>
$.ajax({
type: "get",
url: "blog/getList",
success: function (result) {
if (result != null && result.code == 200 && result.data != null) {
let finalHtml = "";
for (var blog of result.data) {
finalHtml += '<div class="blog">';
finalHtml += '<div class="title">'+blog.title+'</div>';
finalHtml += '<div class="date">'+blog.createTime+'</div>';
finalHtml += '<div class="desc">'+blog.content+'</div>';
finalHtml += '<a class="detail" href="blog_detail.html?blogId='+blog.id+'">查看全文>></a>';
finalHtml += '</div>';
}
$(".right").html(finalHtml);
}
}
});
</script>
通过 url:http://127.0.0.1:8080/blog_list.html 访问服务器验证效果
3.3 实现博客详情
目前点击博客列表页的“查看全文”能进入博客详情页,但是这个博客详情页是写死的内容,现在期望能够根据当前的博客 id 从服务器动态获取博客内容
约定前后端交互接口
[请求]
/bolg/getBlogDetail?blogId=1
[响应]
{ "code": 200, "errMsg": "", "data": [ { "id": 2, "title": "第二篇博客", "content": "222我是博客正文我是博客正文我是博客正文", "createTime": "2024-12-13" }, ...... ] }
实现服务器代码
在 BlogController 中添加 getBlogDeatail 方法
@GetMapping("/getBlogDetail")
public BlogDetailResponse getBlogDetail(Integer blogId) {
log.info("获取博客详情, blogId: {}", blogId);
return blogService.getBlogDetail(blogId);
}
在 BlogService 中添加BlogDetailResponse 方法
BlogDetailResponse getBlogDetail(Integer blogId);
在 BlogServiceImpl 中实现
@Override
public BlogDetailResponse getBlogDetail(Integer blogId) {
BlogInfo blogInfo = selectBlogById(blogId);
BlogDetailResponse blogDetailResponse = new BlogDetailResponse();
BeanUtils.copyProperties(blogInfo, blogDetailResponse);
return blogDetailResponse;
}
public BlogInfo selectBlogById(Integer blogId) {
return blogInfoMaper.selectOne(new LambdaQueryWrapper<BlogInfo>()
.eq(BlogInfo::getId, blogId).eq(BlogInfo::getDeleteFlag, 0));
}
验证服务器能否正确返回数据:http://127.0.0.1:8080/blog/getBlogDetail?blogId=1
修改,将代码中关于转换的部分提取为一个 utils 方法:
package com.example.blog.common.utils;
import com.example.blog.common.pojo.dataobject.BlogInfo;
import com.example.blog.common.pojo.response.BlogDetailResponse;
import com.example.blog.common.pojo.response.BlogListResponse;
import org.springframework.beans.BeanUtils;
public class BeanConver {
/**
* 将 BlogInfo 类型转换为 BlogListResponse 类型返回
* @param blogInfo
* @return
*/
public static BlogListResponse listConver(BlogInfo blogInfo) {
BlogListResponse blogListResponse = new BlogListResponse();
if (blogInfo != null) {
BeanUtils.copyProperties(blogInfo, blogListResponse);
}
return blogListResponse;
}
/**
* 将 BlogInfo 类型转换为 BlogDetailResponse 类型返回
* @param blogInfo
* @return
*/
public static BlogDetailResponse detailConver(BlogInfo blogInfo) {
BlogDetailResponse blogDetailResponse = new BlogDetailResponse();
if (blogInfo != null) {
BeanUtils.copyProperties(blogInfo, blogDetailResponse);
}
return blogDetailResponse;
}
}
BlogServiceImpl 中代码修改为:
参数校验:使用 jakarta.validation
在查询博客详情的接口中,blogId 不能为空,只能传入 Integer 类型,可以借助 jakarta.validation 帮我们完成参数校验,免去繁琐的串行校验,
注解 | 数据类型 | 说明 |
@NotBlank | CharSequence 子类型 | 验证注解的元素值不为空(不为 null、去除首位空格后长度为 0) |
@NotEmpty | CharSequence 子类型、Collection、Map、数组 | 验证注解的元素值不为 null 且不为空(字符串长度不为 0、集合大小不为 0) |
@NotNull | 任意类型 | 验证注解的元素不是 bull |
Spring Boot 项目使用时,添加以下依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
方法声明:
针对校验出现的异常,进行处理
实现客户端代码
修改 blog_detail.html
- 根据当前页面 URL 中的 blogId 参数(使用 location.search 即可得到形如 ?blogId=1 的数据),给服务器发送 GET /blog 请求
- 根据获取到的响应数据,显示在页面上 1
1. 修改 html 页面,去掉原来写死的博客标题,日期和正文部分
<div class="right">
<div class="content">
<div class="title"></div>
<div class="date"></div>
<div class="detail">
// 写死部分删掉
</div>
<div class="operating">
<button onclick="window.location.href='blog_update.html'">编辑</button>
<button onclick="deleteBlog()">删除</button>
</div>
</div>
</div>
2. js 代码,从服务器获取博客详情数据
$.ajax({
type: "get",
url: "/blog/getBlogDetail" + location.search,
success: function (result) {
if (result != null && result.code == 200 && result.data != null) {
let blog = result.data;
$(".right .content .title").text(blog.title);
$(".right .content .date").text(blog.createTime);
$(".right .content .detail").text(blog.content);
}
}
});
测试:
3.4 实现登录
分析
传统思路:
登录页面把用户名密码提交给服务器
服务器端验证用户名密码是否正确,并返回校验结果给后端
如果密码正确,则在服务器端创建 Session,通过 Cookie 把 Session 返回给浏览器
问题:
- 集群环境下无法直接使用 Session
- Session 默认是存储在内存中的,服务器重启后,Session 就丢失了
原因分析:
企业中的项目很少会部署在一台机器上,容易发生单点故障(单点故障:一旦这台服务器挂了,整个应用都没法访问了),所以通常情况下,一个 Web 应用会部署在多个服务器上,通过 Nginx 等进行负载均衡,此时,来自一个用户的请求就会被分发到不同的服务器上
假如我们使用 Seesion 进行会话跟踪,可能会出现以下场景:
- 用户登录:用户登录请求,经过负载均衡,把请求转给了第一台服务器,第一台服务器进行账号密码验证,验证成功后,把 Session 存在了第一台服务器上
- 查询操作:用户登录成功后,携带 Cookie(里面有 Session)继续执行查询操作,比如查询博客列表,此时请求转发给了第二台机器,第二台机器会先进行权限验证操作(通过 SessionId 验证用户是否登录),此时第二台机器上没有该用户的 Session,就会出现问题,提示用户登录,这样用户是不能接受的
解决方案:令牌技术
令牌其实就是一个用户身份的标识,名称很高大上,其实本质就是一个字符串
比如我们出行在外,会带着自己的身份证,需要验证身份时,就掏出身份证(身份证不能伪造,可以辨别真假)
服务器具备生成令牌和验证令牌的能力
我们使用令牌技术,继续思考上述场景:
- 用户登录:用户登录请求,经过负载均衡,把请求转给了第一台服务器,第一台服务器进行账号密码验证,验证成功后,生成一个令牌,并返回给客户端
- 客户端收到令牌后,把令牌存储起来,可以存储在 Cookie 中,也可以存储在其他的存储空间(比如:localStorage)
- 查询操作:用户登录成功后,携带令牌继续执行查询操作,比如查询博客列表,此时请求转发给了第二台机器,第二台机器会先进行权限验证操作,服务器验证令牌是否有效,如果有效,则说明用户已经登录,如果令牌无效,则说明用户之前未执行登录操作
令牌的优缺点:
优点:
解决了集群环境下的认证问题
减轻服务器的存储压力(无需在服务器端存储)
缺点:
需要自己实现(包括令牌的生成、令牌的传递、令牌的校验)
JWT 令牌
令牌本质就是一个字符串,它的实现方式有很多,下面采用 JWT 令牌来实现
介绍
JWT 全称 JSON Web Token
官网:JSON Web Tokens - jwt.io
JSON Web Token(JWT) 是一个开放的行业标准(RFC 7519),用于客户端和服务器之间传递安全可靠的信息。
其本质是一个 token,是一种紧凑的 URL 安全方法。
JWT组成
JWT 由三部分组成,每部分中间使用点(.)分隔,比如:aaaaa.bbbbb.ccccc
- Header(头部) 头部包括令牌的类型(即 JWT)及使用的哈希算法(如 HMAC SHA256 或 RSA)
- Payload(负载) 负载部分是存放有效信息的地方,里面是一些自定义内容。比如: {"userId":"123", "userName":"zhangsan"},也可以存在 JWT 提供的现场字段,比如 exp(过期时间戳)等。此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。
- Signature(签名) 此部分用于防止 JWT 内容被篡改,确保安全性。 防止被篡改,而不是防止被解析。 JWT 之所以安全,就是因为最后的签名。JWT 当中任何一个字符被篡改,整个令牌都会校验失败。 就好比我们的身份证,之所以能标识一个人的身份,是因为他不能被篡改,而不是因为内容加密。(任何人都可以看到身份证的信息,JWT 也是)
对上面部分的信息,使用 Base64Url 进行编码,合并在一起就是 JWT 令牌
Base64 是编码方式,不是加密方式
JWT 令牌生成和校验
1. 引入 JWT 令牌的依赖
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
2. 使用 Jar 包中提供的 API 来完成 JWT 令牌的生成和校验
生成令牌
package com.example.blog;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.junit.jupiter.api.Test;
import java.security.Key;
import java.util.HashMap;
import java.util.Map;
public class JwtUtilTest {
// 1. 生成 token
@Test
public void genToken() {
String secret = "qwertasdfg";
Key key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
Map<String, Object> map = new HashMap<>();
map.put("id", 1);
map.put("name", "zhangsan");
String compact = Jwts.builder()
.setClaims(map)
.signWith(key)
.compact();
System.out.println(compact);
}
// 2. 校验 token
}
运行时会遇到以下报错:
翻译:指定的密钥字节数组为 56 位,对于任何 JWT HMAC-SHA 算法来说都不够安全。JWT JWA 规范(RFC 7518,第3.2节)规定,与 HMAC-SHA 算法一起使用的密钥必须具有 >=256 位的大小(密钥大小必须大于或等于哈希输出大小)。考虑使用 io.jsonwebtoken.security。Keys#secretKeyFor(SignatureAlgorithm)方法,用于创建保证足够安全的密钥,以用于您首选的 HMAC-SHA 算法。
使用上述建议方法:
// 3. 生成 token
@Test
public void genKey() {
SecretKey secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
String encode = Encoders.BASE64.encode(secretKey.getEncoded());
System.out.println(encode);
}
运行后:
使用该签名:
// 1. 生成 token
@Test
public void genToken() {
String secret = "UebxLoYTUUDAtNoQGGJ/XaYQsKGqaIGl+pH2UeyDqiw=";
Key key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
Map<String, Object> map = new HashMap<>();
map.put("id", 1);
map.put("name", "zhangsan");
String compact = Jwts.builder()
.setClaims(map)
.signWith(key)
.compact();
System.out.println(compact);
}
生成 token:
输出的内容就是 JWT 令牌
通过 . 对三个部分进行分割,我们把生成的令牌通过官网进行解析,就可以看到我们存储的信息了
- HEADER 部分可以看到使用的算法为 HS256
- PAYLOAD 部分是我们自定义的内容
- VERIFYSIGNATURE 部分是经过签名算法计算出来的,如果不填,左下角显示 Invalid Signature;当我们填写了以后,才会显示 Signature Verified
校验令牌
完成了令牌的生成,我们需要根据令牌来校验令牌的合法性(防止客户端伪造)
// 2. 校验 token
@Test
public void parseToken() {
String token = "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiemhhbmdzYW4iLCJpZCI6MX0.q97BEfAq0ACcf5y59NXetdVOTIoSnH0ev8ib9Nc-X4k";
// JWT 解析器
JwtParser build = Jwts.parserBuilder().setSigningKey(key).build();
try {
Object body = build.parse(token).getBody();
System.out.println(body);
} catch (Exception e) {
System.out.println("token 非法");
}
}
tip:由于 secret 和 key 是通用的,所以此处改为了静态成员变量:
运行结果:
令牌解析后,我们可以看到里面存储的信息,如果在解析的过程当中没有报错,就说明解析成功了。
令牌解析时,也会进行时间有效性的校验,如果令牌过期了,解析也会失败。
修改令牌中的任何一个字符,都会校验失败,所以令牌无法篡改。
了解令牌的使用之后,接下来我们通过令牌来完成用户的登录
- 登陆页面把用户名密码提交给服务器。
- 服务器端验证用户名密码是否正确,如果正确,服务器生成令牌,下发给客户端。
- 客户端把令牌存储起来(比如 Cookie,local_storage 等),后续请求时,把 token 发给服务器。
- 服务器对令牌进行校验,如果令牌正确,进行下一步操作。
约定前后端交互接口
[请求]
/user/login
[参数]
{
"userName": "test",
"password": "123456"
}
[响应]
{
"code": 200,
"errMsg": "",
"data": {
"userId": 1,
"token": "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiemhhbmdzYW4iLCJpZCI6MSwiaWF0IjoxNzM0NTEwNDY4LCJleHAiOjE3MzQ1OTY4Njh9.eJPaPEld7ULJp6rLSI3mH1Qyzp80yFBgyDRMLfsYp3s"
}
}
// 验证成功,返回 token,验证失败返回 ""
实现服务器代码
创建 JWT 工具类
package com.example.blog.common.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import java.security.Key;
import java.util.Date;
import java.util.Map;
@Slf4j
public class JwtUtil {
private static String secret = "UebxLoYTUUDAtNoQGGJ/XaYQsKGqaIGl+pH2UeyDqiw=";
private static Key key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
// 过期时间,单位毫秒
private static final long expiration = 24 * 60 * 60 * 1000; // 24 小时过期
// 1. 生成 token
public static String genToken(Map<String, Object> map) {
return Jwts.builder()
.setClaims(map)
.signWith(key)
.setIssuedAt(new Date()) // 签发时间
.setExpiration(new Date(System.currentTimeMillis() + expiration)) // 过期时间
.compact();
}
// 2. 校验 token
public static Claims parseToken(String token) {
// token 不能为空
if (!StringUtils.hasLength(token)) {
return null;
}
// JWT 解析器
JwtParser build = Jwts.parserBuilder().setSigningKey(key).build();
Claims body = null;
try {
body = build.parseClaimsJws(token).getBody();
return body;
} catch (ExpiredJwtException e) {
log.error("token 过期,token: {}", token);
} catch (SignatureException e) {
log.error("签名不匹配,token: {}", token);
} catch (Exception e) {
log.error("token 解析失败,token: {}", token);
}
return null;
}
}
创建请求和响应实体类
package com.example.blog.common.pojo.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
@Data
public class UserLoginParam {
@NotBlank(message = "用户名不能为空")
@Length(max = 20, message = "用户名长度不能超过 20")
private String userName;
@NotBlank(message = "密码不能为空")
private String password;
}
package com.example.blog.common.pojo.response;
import lombok.Data;
@Data
public class UserLoginResponse {
private Integer userId;
private String token;
}
实现 Controller
package com.example.blog.controller;
import com.example.blog.common.pojo.request.UserLoginParam;
import com.example.blog.common.pojo.response.UserLoginResponse;
import com.example.blog.service.UserService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RequestMapping("/user")
@RestController
public class UserController {
@Resource(name = "userServiceImpl")
private UserService userService;
/**
* 用户登录访问方法,@Validated 是校验 UserLoginParam 这个实体类中的参数用;@RequestBody 是传过来的参数是 JSON
* @param userLoginParam
* @return
*/
@PostMapping("/login")
public UserLoginResponse login(@Validated @RequestBody UserLoginParam userLoginParam) {
log.info("用户登录,userName:{}", userLoginParam.getUserName());
return userService.login(userLoginParam);
}
}
实现 Service
package com.example.blog.service;
import com.example.blog.common.pojo.request.UserLoginParam;
import com.example.blog.common.pojo.response.UserLoginResponse;
public interface UserService {
UserLoginResponse login(UserLoginParam userLoginParam);
}
package com.example.blog.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.blog.common.exception.BlogException;
import com.example.blog.common.pojo.dataobject.UserInfo;
import com.example.blog.common.pojo.request.UserLoginParam;
import com.example.blog.common.pojo.response.UserLoginResponse;
import com.example.blog.common.utils.JwtUtil;
import com.example.blog.mapper.UserInfoMapper;
import com.example.blog.service.UserService;
import jakarta.annotation.Resource;
import org.apache.catalina.security.SecurityUtil;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
public class UserServiceImpl implements UserService {
@Resource(name = "userInfoMapper")
private UserInfoMapper userInfoMapper;
@Override
public UserLoginResponse login(UserLoginParam userLoginParam) {
// 判断用户是否存在
// 根据用户名,去查询用户信息
UserInfo userInfo = userInfoMapper.selectOne(new LambdaQueryWrapper<UserInfo>()
.eq(UserInfo::getUserName, userLoginParam.getUserName()).eq(UserInfo::getDeleteFlag, 0));
if (userInfo == null) {
throw new BlogException("用户不存在");
}
// 用户存在,校验密码是否正确
if (!userLoginParam.getPassword().equals(userInfo.getPassword())) {
throw new BlogException("密码错误");
}
// 密码正确
UserLoginResponse response = new UserLoginResponse();
response.setUserId(userInfo.getId());
// 载荷
Map<String, Object> map = new HashMap<>();
map.put("id", userInfo.getId());
map.put("name", userInfo.getUserName());
response.setToken(JwtUtil.genToken(map));
return response;
}
}
实现客户端代码
修改 login.html,完善登录方法
前端收到 userId 和 token 后,保存在 localstorage 中
function login() {
$.ajax({
type: "post",
url: "/user/login",
contentType: "application/json",
data: JSON.stringify({ // JSON.stringify 作用:将对象转为 json
"userName": $("#username").val(),
"password": $("#password").val()
}),
success: function (result) {
if (result != null && result.code == 200 && result.data != null){
let userInfo = result.data;
// 存储 token
localStorage.setItem("user_token", userInfo.token);
localStorage.setItem("login_user_id", userInfo.userId);
location.href = "blog_list.html";
} else {
alert(result.errMsg);
}
}
});
}
local storage 相关操作
存储数据
localStorage.setItem("user_token","value");
读取数据
localStorage.getItem("user_token");
删除数据
localStorage.removeItem("user_token");
运行程序:
3.5 实现强制要求登录
当用户访问博客列表页和博客详情页时,如果用户当前尚未登录,就自动跳转到登录页面
我们可以使用拦截器来完成该功能,token 通常由前端放在 header 中,我们从 header 中获取 token,并校验 token 是否合法
添加拦截器
package com.example.blog.common.interceptor;
import com.example.blog.common.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 拦截器
*/
@Slf4j
@Component
public class LoginInteceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 校验 token 是否正确
// 获取 token
String token = request.getHeader("user_header_token");
log.info("从 header 中获取到 token,token:{}", token);
// 校验 token 是否存在
Claims claims = JwtUtil.parseToken(token);
// token 不合法
if (claims == null) {
response.setStatus(401);
return false;
}
// TODO 这里可以做的更细一点,比如 id 和 name 是否对应的上...
return true;
}
}
package com.example.blog.common.config;
import com.example.blog.common.interceptor.LoginInteceptor;
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 WebConfig implements WebMvcConfigurer {
@Autowired
private LoginInteceptor loginInteceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInteceptor)
.addPathPatterns("/user/**", "/blog/**")
.excludePathPatterns("/user/login");
}
}
实现客户端代码
1. 前端请求时,header 中统一添加 token,后端返回异常时,跳转到登录页面,代码逻辑写在 common.js 中
$(document).ajaxSend(function (e, xhr, opt) {
let token = localStorage.getItem("user_token");
xhr.setRequestHeader("user_header_token", token)
});
$(document).ajaxError(function(event,xhr,options,exc){
if (xhr.status == 401) {
alert("用户未登录,请先登录");
location.href = "blog_login.html";
} else if (xhr.status == 404) {
// TODO 自由发挥
} else if (xhr.status == 500) {
// TODO
}
});
ajaxSend() 在 ajax 请求开始时执行函数,具体使用方法参考:jQuery ajaxSend() 方法 | 菜鸟教程
ajaxError() 在 ajax 发生异常时,进行统一处理,具体使用方法参考:jQuery ajaxError() 方法 | 菜鸟教程
运行程序,验证结果:
3.6 实现显示用户信息
目前页面的用户信息部分是写死的,如:
现在希望这个信息可以随着用户登录而发生改变:
如果当前页面是博客列表页,则显示当前登录用户的信息
如果当前页面是博客详情页,则显示该博客作者的信息
约定前后端交互接口
在博客列表页,获取当前登录的用户的信息
[请求]
/user/getUserInfo?userId=1
[响应]
{
"code": 200,
"errMsg": "",
"data": {
"id": 1,
"userName": "zhangsan",
"githubUrl": "https://gitee.com/bucolic_y"
}
}
在博客详情页,获取当前文章作者的用户信息
[请求]
/user/getAuthorInfo?blogId=1
{
"code": 200,
"errMsg": "",
"data": {
"id": 1,
"userName": "zhangsan",
"githubUrl": "https://gitee.com/bucolic_y"
}
}
[响应]
{
"code": 200,
"errMsg": "",
"data": {
"id": 1,
"userName": "zhangsan",
"githubUrl": "https://gitee.com/bucolic_y"
}
}
实现服务器代码
定义返回接口实体类
package com.example.blog.common.pojo.response;
import lombok.Data;
@Data
public class UserInfoResponse {
private Integer id;
private String userName;
private String githubUrl;
}
在 UserController 中添加代码
@GetMapping("/getUserInfo")
public UserInfoResponse getUserInfo(@NotNull Integer userId) {
log.info("获取用户信息,userId: {}", userId);
return userService.getUserInfoById(userId);
}
@GetMapping("/getAuthorInfo")
public UserInfoResponse getAuthorInfo(@NotNull Integer blogId) {
log.info("获取作者信息,userId: {}", blogId);
return userService.getAuthorInfo(blogId);
}
在 UserService 中添加代码
UserInfoResponse getUserInfoById(@NotNull Integer userId);
UserInfoResponse getAuthorInfo(@NotNull Integer blogId);
在 UserServiceImpl 中添加代码
@Override
public UserInfoResponse getUserInfoById(Integer userId) {
UserInfo userInfo = selectUserInfoById(userId);
return BeanConver.trans(userInfo);
}
@Override
public UserInfoResponse getAuthorInfo(Integer blogId) {
// 1. 根据博客 id,拿到作者 id
BlogInfo blogInfo = blogInfoMaper.selectOne(new LambdaQueryWrapper<BlogInfo>()
.eq(BlogInfo::getId, blogId).eq(BlogInfo::getDeleteFlag, 0));
// 2. 根据作者 id,拿到作者详情
if (blogInfo == null) {
throw new BlogException("博客不存在");
}
UserInfo userInfo = selectUserInfoById(blogInfo.getUserId());
return BeanConver.trans(userInfo);
}
public UserInfo selectUserInfoById(Integer userId) {
return userInfoMapper.selectOne(new LambdaQueryWrapper<UserInfo>()
.eq(UserInfo::getId, userId).eq(UserInfo::getDeleteFlag, 0));
}
在 BeanConver 中添加代码
/**
* 将 UserInfo 类转为 UserInfoResponse
* @param userInfo
* @return
*/
public static UserInfoResponse trans(UserInfo userInfo) {
UserInfoResponse userInfoResponse = new UserInfoResponse();
if (userInfo != null) {
BeanUtils.copyProperties(userInfo, userInfoResponse);;
}
return userInfoResponse;
}
实现客户端代码
1. 修改 blog_list.html
在响应回调函数中,根据响应中的用户名,更新界面的显示
getUserInfo();
function getUserInfo() {
$.ajax({
type: "get",
url: "/user/getUserInfo?userId=" + localStorage.getItem("login_user_id"),
success: function (result) {
if (result != null && result.code == 200 && result.data != null) {
let userInfo = result.data;
$(".card h3").text(userInfo.userName);
$(".card a").attr("href", userInfo.githubUrl);
} else {
// 可写可不写
}
}
});
}
测试:
2. 修改 blog_detail.html
function getUserInfo() {
$.ajax({
type: "get",
url: "/user/getAuthorInfo" + location.search,
success: function (result) {
if (result != null && result.code == 200 && result.data != null) {
let userInfo = result.data;
$(".card h3").text(userInfo.userName);
$(".card a").attr("href", userInfo.githubUrl);
} else {
// 可写可不写
}
}
});
}
测试:
由于这两者代码重合较多,将其整合提取到 common.js 中
function getUserInfo() {
$.ajax({
type: "get",
url: "/user/getUserInfo?userId=" + localStorage.getItem("login_user_id"),
success: function (result) {
if (result != null && result.code == 200 && result.data != null) {
let userInfo = result.data;
$(".card h3").text(userInfo.userName);
$(".card a").attr("href", userInfo.githubUrl);
} else {
// 可写可不写
}
}
});
}
引入 common.js
<script src="js/common.js"></script>
blog_list.html 代码修改
// 获取用户信息
let url = "/user/getUserInfo?userId=" + localStorage.getItem("login_user_id");
getUserInfo(url);
blog_detail.html 代码修改
// 显示博客作者信息
let url = "/user/getAuthorInfo" + location.search;
getUserInfo(url);
3.7 实现用户退出
前端直接清除掉 token 即可
实现客户端代码
所有页面的 <<注销>> 链接已经添加了 onclick 事件,如下:
仅需在 common.js 中完善 logout 方法即可:
// 点击注销,统一清除掉 token 和 login_user_id
function logout() {
let logout = confirm("是否确认退出");
if (logout) {
localStorage.removeItem("user_token");
localStorage.removeItem("login_user_id");
location.href = "blog_login.html";
}
}
3.8 实现发布博客
约定前后端交互接口
[请求]
/blog/add
[参数]
{}
[响应]
{}
// true 成功
// false 失败
实现服务器代码
定义实体类
package com.example.blog.common.pojo.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class AddBlogParam {
@NotNull(message = "用户ID 不能为空")
private Integer userId;
@NotBlank(message = "标题不能为空")
private String title;
@NotBlank(message = "内容不能为空")
private String content;
}
BlogController 新增 add 方法
@PostMapping("/add")
public Boolean addBlog(@Validated @RequestBody AddBlogParam param) {
log.info("添加博客,标题:{}", param.getTitle());
return blogService.addBlog(param);
}
实现 Service
BlogService
Boolean addBlog(AddBlogParam param);
BlogServiceImpl
@Override
public Boolean addBlog(AddBlogParam param) {
// 对象转换 AddBlogParam -> BlogInfo
BlogInfo blogInfo = new BlogInfo();
BeanUtils.copyProperties(param, blogInfo);;
// 插入博客
try {
int result = blogInfoMaper.insert(blogInfo);
if (result == 1) {
return true;
}
} catch (Exception e) {
log.error("博客插入失败,e:{}", e);
}
return false;
}
测试:
Editor.md 介绍
Editor.md 是一个开源的页面 markdown 编辑器组件
官网:Editor.md - 开源在线 Markdown 编辑器
使用示例:
<link rel="stylesheet" href="editormd/css/editormd.css" />
<div id="test-editor">
<textarea style="display:none;">### 关于 Editor.md
**Editor.md** 是一款开源的、可嵌入的 Markdown 在线编辑器(组件),基于 CodeMirror、jQuery 和 Marked 构建。
</textarea>
</div>
<script src="https://cdn.bootcss.com/jquery/1.11.3/jquery.min.js"></script>
<script src="editormd/editormd.min.js"></script>
<script type="text/javascript">
$(function() {
var editor = editormd("test-editor", {
// width : "100%",
// height : "100%",
path : "editormd/lib/"
});
});
</script>
使用时引入对应的依赖(官网下载后,将存储位置路径放到 path 中)
"test-editor" 是 markdown 编辑器所在的 div 的 id 名称
path 是 editor.md 依赖所在的路径
实现客户端代码
修改 blog_edit.html submit 方法
function submit() {
$.ajax({
type: "post",
url: "/blog/add",
data: JSON.stringify({
userId: localStorage.getItem("login_user_id"),
title: $("#title").val(),
content: $("#content").val()
}),
contentType: "application/json",
success: function (result) {
if (result != null && result.code == 200 && result.data != null) {
alert("发表博客成功");
location.href = "blog_list.html";
} else {
// 补充
}
}
});
}
测试,可以发布成功
修改详情页页面显示
虽然发布成功了,但是详情页会显示 markdown 的格式符号,如下图:
下面进行处理
1. 修改 blog_detail.html 中博客正文的 div 标签
2. 修改博客正文内容的显示
3.9 实现删除/编辑博客
进入用户详情页时,如果当前登录用户正是文章作者,则在导航栏中显示【编辑】【删除】按钮,用户点击时则进行相应处理
需要实现两件事:
判定当前博客详情页中是否要显示【编辑】【删除】按钮
实现编辑/删除逻辑(此处的删除采用逻辑删除)
约定前后端交互接口
1. 修改博客
[请求]
/blog/update
[参数]
Content-Type: application/json
[响应]
2. 删除博客
[请求]
/blog/delete?blogId=1
[响应]
实现服务器代码
定义修改接口实体类
package com.example.blog.common.pojo.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class UpBlogParam {
@NotNull(message = "博客id不能为空")
private Integer id;
@NotBlank(message = "标题不能为空")
private String title;
@NotBlank(message = "内容不能为空")
private String content;
}
1. BlogController
增加 update/delete 方法,处理修改/删除逻辑
/**
* 更新博客
* @param param
* @return
*/
@PostMapping("/update")
public Boolean updateBlog(@Validated @RequestBody UpBlogParam param) {
log.info("更新博客,博客ID:{}", param.getId());
return blogService.updateBlog(param);
}
/**
* 删除博客(逻辑删除)
* @param blogId
* @return
*/
@PostMapping("/delete")
public Boolean deleteBlog(@NotNull Integer blogId) {
log.info("删除博客,博客ID:{}", blogId);
return blogService.deleteBlog(blogId);
}
定义接口
Boolean updateBlog(UpBlogParam param);
Boolean deleteBlog(@NotNull Integer blogId);
实现接口
/**
* 更新博客
* @param param
* @return
*/
@Override
public Boolean updateBlog(UpBlogParam param) {
BlogInfo blogInfo = BeanConver.trans(param);
// 更新博客
return update(blogInfo);
}
/**
* 删除博客
* @param blogId
* @return
*/
@Override
public Boolean deleteBlog(Integer blogId) {
BlogInfo blogInfo = new BlogInfo();
blogInfo.setId(blogId);
blogInfo.setDeleteFlag(1); // 将 DeleteFlag 设为 1 表示逻辑删除
return update(blogInfo);
}
public BlogInfo selectBlogById(Integer blogId) {
return blogInfoMaper.selectOne(new LambdaQueryWrapper<BlogInfo>()
.eq(BlogInfo::getId, blogId).eq(BlogInfo::getDeleteFlag, 0));
}
/**
* 更新/删除 博客数据
* @param blogInfo
* @return
*/
public Boolean update(BlogInfo blogInfo) {
// 更新博客
try {
Integer result = blogInfoMaper.updateById(blogInfo);
if (result == 1) {
return true;
}
} catch (Exception e){
log.error("博客 更新/删除 失败, e:{}", e);
}
return false;
}
实现客户端代码
1. 判断是否显示【编辑】【删除】按钮
2. 删除博客
// blog_detail.html
function deleteBlog() {
let c = confirm("是否确认删除");
if (c) {
$.ajax({
type: "post",
url: "/blog/delete" + location.search,
success: function (result) {
if (result != null && result.code == 200 && result.data != null) {
alert("博客删除成功");
location.href = "blog_list.html";
} else {
// 补充
}
}
});
}
}
3. 修改博客逻辑(blog_update.html)
页面加载时,请求博客详情
function getBlogInfo() {
$.ajax({
type: "get",
url: "/blog/getBlogDetail" + location.search,
success: function (result) {
console.log(result);
if (result != null && result.code == 200 && result.data != null) {
let blogInfo = result.data;
$("#blogId").val(blogInfo.id);
$("#title").val(blogInfo.title);
$("#content").val(blogInfo.content);
editormd("editor", {
width: "100%",
height: "550px",
path: "blog-editormd/lib/",
onload: function () {
this.watch();
this.setMarkdown(blogInfo.content);
}
});
}
}
});
}
getBlogInfo();
4. 完善发表博客的逻辑
function submit() {
$.ajax({
type: "post",
url: "/blog/update",
data: JSON.stringify({
id: $("#blogId").val(),
title: $("#title").val(),
content: $("#content").val()
}),
contentType: "application/json",
success: function (result) {
if (result != null && result.code == 200 && result.data != null) {
alert("博客更新成功");
location.href = "blog_list.html";
} else {
// 补充
}
}
});
}
运行验证修改/删除效果
3.10 加密/加盐
加密介绍
在 MySQL 数据库中,我们常常需要对密码、身份证号、手机号等敏感信息进行加密,以保证数据的安全性。 如果使用明文存储,当黑客入侵了数据库时,就可以轻松获取到用户的相关信息,从而对用户或者企业造成信息泄漏或者财产损失。
目前我们用户的密码还是明文设置的,为了保护用户的密码信息,我们需要对密码进行加密
密码算法分类
密码算法主要分为三类:对称密码算法,非对称密码算法,摘要算法
1. 对称密码算法 是指加密密钥和解密密钥相同的密码算法。常见的对称密码算法有:AES,DES,3DES, RC4,RC5,RC6等。
2. 非对称密码算法 是指加密密钥和解密密钥不同的密码算法。该算法使用一个密钥进行加密,用另外一个 密钥进行解密。
- 加密密钥可以公开,又称为 公钥
- 解密密钥必须保密,又称为 私钥
常见的非对称密码算法有:RSA,DSA,ECDSA,ECC 等
3. 摘要算法 是指把任意长度的输入消息数据转化为固定长度的输出数据的一种密码算法。摘要算法是 不可逆的,也就是无法解密。通常用来检验数据的完整性的重要技术,即对数据进行哈希计算然后比较摘要值,判断是否一致。常见的摘要算法有:MD5,SHA 系列(SHA1,SHA2 等),CRC(CRC8,CRC16, CRC32)
加密思路
博客系统中,我们采用 MD5 算法来进行加密。
问题:虽然经过 MD5 加密后的密文无法解密,但由于相同的密码经过 MD5 哈希之后的密文是相同的,当存 储用户密码的数据库泄露后,攻击者会很容易便能找到相同密码的用户,从而降低了破解密码的难度。 因此,在对用户密码进行加密时,需要考虑对密码进行包装,即使是相同的密码,也保存为不同的密文。 即使用户输入的是弱密码,也考虑进行增强,从而增加密码被攻破的难度。
解决方案:采用为一个密码拼接一个随机字符来进行加密,这个随机字符我们称之为“盐”。假如有一个加 盐后的加密串,黑客通过一定手段这个加密串,他拿到的明文并不是我们加密前的字符串,而是加密前 的字符串和盐组合的字符串,这样相对来说又增加了字符串的安全性。
解密流程:MD5 是不可逆的,通常采用“判断哈希值是否一致”来判断密码是否正确
如果用户输入的密码和盐值一起拼接后的字符串经过加密算法,得到的密文相同,我们就认为密码正确(密文相同,盐值相同,推测明文相同)
写加密/解密工具类
package com.example.blog.common.utils;
import com.example.blog.common.exception.BlogException;
import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
public class SecurityUtil {
// 加密
public static String encrypt(String password) {
if (!StringUtils.hasLength(password)) {
throw new BlogException("密码不能为空");
}
// 生成盐值
String salt = UUID.randomUUID().toString().replace("-", "");
// md5 加密(盐值+password),得到32位16进制的密文
String securityPassword = DigestUtils.md5DigestAsHex((salt + password).getBytes(StandardCharsets.UTF_8));
// 数据库中存储 盐值和密文,得到 64位 16 进制的数据
return salt + securityPassword;
}
// 验证 inputPassword 用户输入的明文 sqlPassword 数据库中存储的密文
public static boolean verify(String inputPassword, String sqlPassword) {
// 参数校验
if (!StringUtils.hasLength(inputPassword) || !StringUtils.hasLength(sqlPassword)) {
return false;
}
if (sqlPassword.length() != 64) {
return false;
}
// 获取 salt
String salt = sqlPassword.substring(0, 32);
// md5 加密(盐值+inputPassword),得到32位16进制的密文
String securityPassword = DigestUtils.md5DigestAsHex((salt + inputPassword).getBytes(StandardCharsets.UTF_8));
return sqlPassword.equals(salt + securityPassword);
}
}
修改数据库密码
使用测试类给密码 123456 生成密文
package com.example.blog;
import com.example.blog.common.utils.SecurityUtil;
import org.junit.jupiter.api.Test;
import org.springframework.util.DigestUtils;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
public class SecurityUtilTest {
// 加密
@Test
void encrypt() {
// 明文(用户密码)
String password = "12345";
// String s = DigestUtils.md5DigestAsHex(password.getBytes(StandardCharsets.UTF_8)); // DigestUtils 是 Spring 带的 md5 加密工具类
// System.out.println(s);
// 生成随机盐值
String uuid = UUID.randomUUID().toString().replace("-", ""); // UUID 有极极极极低的概率会重复,近似认为其不会重复
System.out.println(uuid);
// 明文 + 盐值 得到一个较复杂的密码
String finalPassword = uuid + password;
// 再对该密码进行 md5 加密,得到密文
System.out.println(DigestUtils.md5DigestAsHex(finalPassword.getBytes(StandardCharsets.UTF_8)));
// 存储盐值和密文
// 盐值+密文 或 密文+盐值 或者其他方式拼接
}
// 生成一些 123456 位密码的密文
@Test
void genPassword() {
String encrypt = SecurityUtil.encrypt("123456");
System.out.println(encrypt);
}
// 验证
}
修改登录接口(UserServiceImpl.java)
运行程序,验证结果