前言
通过前⾯课程的学习, 我们掌握了Spring框架和MyBatis的基本使用, 并完成了图书管理系统的常规功能开发, 接下来我们系统的从0到1完成⼀个项⽬的开发.
企业开发的流程
-
需求评审(产品经理(PM)会和运营(想口号),UI,测试,开发等沟通) ,会涉及到背景/目标/怎么做,可能会有多次评审(对于需求不清晰的部分,文档需要补充) -> 需求文档
-
开发阶段(前后端并行开发) 1> 任务分工(对于边界不清晰的任务,再进行探讨). 2> 后端进行方案设计 -> 接口涉及,数据库涉及,ER,UML,流程图等,deadLine(前后端把deadline出了,测试人员才会决定测试时间),复杂项目需要出测试列表,上线计划 3> 方案设计评审 4> 开发-> 代码review 5> 自测
-
测试(联调):各个端一起来(前端调后端,后端调前端..)
-
上线

项目介绍
使⽤SSM框架实现⼀个简单的博客系统 共5个页面
-
用户登录
-
博客发表⻚
-
博客编辑⻚
-
博客列表⻚
-
博客详情⻚
功能描述:
⽤⼾登录成功后, 可以查看所有⼈的博客. 点击 <<查看全⽂>> 可以查看该博客的正⽂内容. 如果该博客作者为当前登录⽤⼾, 可以完成博客的修改和删除操作, 以及发表新博客
页面预览用户登录
博客列表页
博客详情页
博客发表/修改页 
后端需要提供的功能和接口
- 用户登录: 根据用户名和密码判断用户输入的信息是否能从数据库找到

-
登录用户的个人页: 博客列表显示当前登录用户的信息,根据用户的ID,返回用户相关信息(后面再详细设计)
-
博客列表页: 查询你博客的列表(点击查看全文之后)

-
作者个人主页: 根据用户ID,返回作者信息(俩种实现可能: 1> 根据用户ID返回 (调用方要先拿到作者ID) 2> 根据博客ID返回)
-
博客详细: 根据博客ID,返回博客信息

点击编辑之后
- 博客编辑: 1> 根据博客ID,返回博客信息. 2> 根据用户输入的信息,更新博客

如果是点击删除的话
- 博客删除: 根据ID,删除博客

点击写博客
- 写博客: 根据用户输入的信息,添加博客

我们把上面的功能再转化为接口
接口
- 用户相关
根据用户名和密码,判断用户输入的信息是否正确
根据用户的ID,返回用户相关信息
- 博客相关
查询博客列表
根据博客ID(前端根据博客详情,拿到作者信息,在根据作者信息调用用户信息),返回作者信息
根据博客ID,返回博客详情
根据用户输入的信息,更新博客
根据博客ID,删除博客
根据用户输入信息,添加博客
后面的接口我们会更加详细的写,这里只写了大概(会写参数,返回类型,请求方式...)

1. 准备工作
数据库设计
数据的准备工作: 创建用户表和博客表
架构设计(画图)-> 画实体,实体表和关系表 (此时我们直接拿一个属性user_id进行关联)
sql
-- 建表SQL
create database if not exists java_blog_spring charset utf8mb4;
use java_blog_spring;
-- 用户表
DROP TABLE IF EXISTS java_blog_spring.user;
CREATE TABLE java_blog_spring.user(
`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(),
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;
CREATE TABLE java_blog_spring.blog (
`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(),
PRIMARY KEY (id))
ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '博客表';
-- 新增用户信息
insert into java_blog_spring.user (user_name, password,github_url)values("zhangsan","123456","https://gitee.com/bubble-fish666/class-java45");
insert into java_blog_spring.user (user_name, password,github_url)values("lisi","123456","https://gitee.com/bubble-fish666/class-java45");
insert into java_blog_spring.blog (title,content,user_id) values("第一篇博客","111我是博客正文我是博客正文我是博客正文",1);
insert into java_blog_spring.blog (title,content,user_id) values("第二篇博客","222我是博客正文我是博客正文我是博客正文",2);
创建项目
四个依赖加上
前端代码准备(在资源绑定里面)

数据库yml配置
sql
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/java_blog_spring?characterEncoding=utf8&useSSL=false
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
configuration: # 配置打印 MyBatis⽇志
map-underscore-to-camel-case: true #配置驼峰⾃动转换
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #打印sql语句
mapper-locations: classpath:mapper/*Mapper.xml
# 设置日志文件
logging:
file:
name: spring-book.log
2. 项目公共模块
项⽬分为控制层(Controller), 服务层(Service), 持久层(Mapper). 各层之间的调⽤关系如下:
公共层和实体层为上面的所有都提供服务.


2.1 实体类
1> 用户实体
sql
package org.xiaobai.blog_system.model;
import lombok.Data;
import java.util.Date;
@Data
public class UserInfo {
private Integer id;
private String userName;
private String password;
private String githubUrl;//和数据库字段对应,默认数据库字段的下划线后面的第一个单词大写
private Integer deleteFlag;
private Date createTime;
private Date updateTime;
}
2> 博客实体
sql
package org.xiaobai.blog_system.model;
import lombok.Data;
import java.util.Date;
@Data
public class BlogInfo {
private Integer id;
private String title;
private Integer userId;
private Integer deleteFlag;
private Date createTime;
private Date updateTime;
}
3> 结果实体
sql
package org.xiaobai.blog_system.model;
import lombok.Data;
import org.xiaobai.blog_system.enums.ResultStatus;
//统一结果,通常是业务码
@Data
public class Result<T> {
//业务码200-成功 -1失败 -2 未登录
private ResultStatus code;//也可以写成枚举类
//错误信息
private String errMsg;
//接口响应的数据
private T data;
//设置成功的时候的信息
public static <T>Result success(T data){
Result result = new Result();
result.setCode(ResultStatus.SUCCESS);
result.setErrMsg("");
result.setData(data);
return result;
}
//失败的时候不返回数据
public static <T>Result fail(String errMsg){
Result result = new Result();
result.setCode(ResultStatus.FAIL);
result.setErrMsg(errMsg);
result.setData(null);
return result;
}
//失败的时候返回数据
public static <T>Result fail(String errMsg,T data){
Result result = new Result();
result.setCode(ResultStatus.FAIL);
result.setErrMsg(errMsg);
result.setData(data);
return result;
}
}
4> 枚举类
sql
package org.xiaobai.blog_system.enums;
public enum ResultStatus {
SUCCESS,
FAIL,
NOLOGIN;
}
5> 统一结果返回
sql
package org.xiaobai.blog_system.ResponseAdvice;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
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.bind.annotation.ResponseBody;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import org.xiaobai.blog_system.model.Result;
//TODO 统一结果返回
@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice {
@Autowired
private ObjectMapper objectMapper;
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
return false;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
//对结果进行进一步的划分
if(body instanceof Result<?>){
return body;
}
if(body instanceof String){
//如果是String就进行序列化处理
try {
return objectMapper.writeValueAsString(Result.success(body));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
return Result.success(body);
}
}
5> 统一异常处理
sql
package org.xiaobai.blog_system.ResponseAdvice;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.xiaobai.blog_system.model.Result;
//TODO 统一异常处理
@Slf4j
@ResponseBody
@ControllerAdvice
public class ExceptionHandle {
@ExceptionHandler
public Result handle(Exception e){
//打印异常信息
log.error("发生异常,e: ",e);
return Result.fail("内部错误,请练习管理员");
}
}
3. 业务代码
3.1 持久层
接口和数据库操作的关系
根据数据操作写mapper
1> 用户相关的mapper
sql
package org.xiaobai.blog_system.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.xiaobai.blog_system.model.UserInfo;
@Mapper
public interface UserInfoMapper {
//1.根据用户名,查询用户信息
@Select("select * from user where delete_flag != 1 and user_name = #{userName}")
UserInfo selectByName(String userName );
//2.根据ID,查询用户信息
@Select("select * from user where delete_flag != 1 and id = #{id}")
UserInfo selectById(Integer id);
}
2> 博客相关的mapper
sql
package org.xiaobai.blog_system.mapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.xiaobai.blog_system.model.BlogInfo;
import java.util.List;
@Mapper
public interface BlogMapper {
//1. 返回博客列表
@Select("select * from blog where delete_flag = 0")
List<BlogInfo> selectAll();
//2. 根据博客ID,返回博客信息
@Select("select * from blog where id = #{id}")
BlogInfo selectById(Integer id);
//3. 更新博客(涉及更新和删除俩部分)
//后面再写,使用xml的形式
Integer updateBlog(BlogInfo blogInfo);
//4. 发表博客(插入博客)
@Insert("insert into blog(title,content,user_id) values (#{title},#{content},#{userId})")
Integer insertBlog(BlogInfo blogInfo);
}
更新/删除操作我们使用update,需要进行判断,因此我们需要使用xml的方式
首先我们先进行配置 对应关系

我们在xml里面要写
sql
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.xiaobai.blog_system.mapper.BlogMapper">
然后我们进行测试(在mapper里面的接口点击生成测试用例)
测试用户的mapper

测试博客的mapper

我们再单独的对更新语句进行测试

修改一下,就是删除语句测试

接下来正式进入接口功能实现
controler -> service -> mapper
- Controller :接收前端请求,调用Service层。
- Service :处理业务逻辑,调用Mapper层进行数据操作。
- Mapper:与数据库交互,执行数据持久化操作。
总结:
- Controller 处理用户请求和响应。
- Service 处理业务逻辑,调用 Mapper 层进行数据库操作。
- Mapper 执行与数据库的直接交互操作。
3.2 实现博客列表
约定前后端交互接口

客⼾端给服务器发送⼀个 /blog/getlist 这样的 HTTP 请求, 服务器给客⼾端返回了⼀个 JSON
格式的数据.
后端代码
controller
service

mapper

我们也可以限制接口请求类型

前端代码
操作过程

编写结果

结果展示

3.3 实现博客详情
接口定义

后端代码
controller

service

mapper

前端代码
编写过程
我们也可以把时间的格式进行设计一下: dateFormat
package org.xiaobai.blog_system.utils;
import java.text.SimpleDateFormat;
import java.util.Date;
//对于时间的公共处理
public class DateUtils {
public static String dateFormat(Date date){
//2025-02-24 21:01
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm");
return format.format(date);
}
public static void main(String[] args) {
System.out.println(dateFormat(new Date()));
}
}
结果:

编写结果

结果展示
3.4 实现登陆
之前我们实现图书管理系统的适合采用的思路

传统思路:
• 登陆⻚⾯把⽤⼾名密码提交给服务器.
• 服务器端验证⽤⼾名密码是否正确, 并返回校验结果给后端
• 如果密码正确, 则在服务器端创建 Session . 通过 Cookie 把 sessionId 返回给浏览器.
问题:
集群环境下⽆法直接使⽤Session.
原因分析:
我们开发的项⽬, 在企业中很少会部署在⼀台机器上, 容易发⽣单点故障. (单点故障: ⼀旦这台服务器挂了, 整个应⽤都没法访问了). 所以通常情况下, ⼀个Web应⽤会部署在多个服务器上, 通过Nginx等进⾏负载均衡(相当于调度器). 此时, 来⾃⼀个⽤⼾的请求就会被分发到不同的服务器上.

假如我们使⽤Session进⾏会话跟踪, 我们来思考如下场景:
-
⽤⼾登录 ⽤⼾登录请求, 经过负载均衡(负载理解为压力和流量), 把请求转给了第⼀台服务器, 第⼀台服务器进⾏账号密码验证, 验证成功后, 把Session存在了第⼀台服务器上
-
查询操作 ⽤⼾登录成功之后, 携带Cookie(⾥⾯有SessionId)继续执⾏查询操作, ⽐如查询博客列表. 此时请求转发到了第⼆台机器, 第⼆台机器会先进⾏权限验证操作(通过SessionId验证⽤⼾是否登录), 此时第⼆台机器上没有该⽤⼾的Session, 就会出现问题, 提⽰⽤⼾登录, 这是⽤⼾不能忍受的.(各个机器都是独立的,因此在第一个机器里面虽然已经set SessionId了,但是和第二个机器没有关系)
` 解决方案

Redis简单介绍: Redis就是一个数据结构,就理解为存数据的

接下来我们介绍第二种⽅案: 令牌技术
令牌技术
令牌的概念
之前我们存储在session里面的信息,现在存储在了token里面
令牌其实就是⼀个⽤⼾⾝份的标识, 名称起的很⾼⼤上, 其实本质就是⼀个字符串.
⽐如我们出⾏在外, 会带着⾃⼰的⾝份证, 需要验证⾝份时, 就掏出⾝份证 ⾝份证不能伪造, 可以辨别真假.
身份证: 公安局发放,公安局来进行鉴别真假
1> 存储信息
2> 不能伪造
3> 可以辨别真假
同理: 我们的令牌token是由服务端生成的,因此我们要在服务端进行鉴别真假.也具备上面身份证的作用
令牌的逻辑
服务器: 要能够生成token和校验token
1> 用户登录,服务器生成token(生成一个字符串)
2> 把token返回给客户端
3> 客户端携带token,再次访问,服务器对token进行校验

服务器具备⽣成令牌和验证令牌的能⼒我们使⽤令牌技术, 继续思考上述场景:
-
⽤⼾登录 ⽤⼾登录请求, 经过负载均衡, 把请求转给了第⼀台服务器, 第⼀台服务器进⾏账号密码验证, 验证成功后, ⽣成⼀个令牌, 并返回给客⼾端.
-
客⼾端收到令牌之后, 把令牌存储起来. 可以存储在Cookie中, 也可以存储在其他的存储空间(⽐如localStorage)
-
查询操作 ⽤⼾登录成功之后, 携带令牌继续执⾏查询操作, ⽐如查询博客列表. 此时请求转发到了第⼆台机器, 第⼆台机器会先进⾏权限验证操作. 服务器验证令牌是否有效, 如果有效, 就说明⽤⼾已经执⾏了登录操作, 如果令牌是⽆效的, 就说明⽤⼾之前未执⾏登录操作.
令牌的优缺点
优点:
• 解决了集群环境下的认证问题
• 减轻服务器的存储压⼒(⽆需在服务器端存储)
缺点:
需要⾃⼰实现(包括令牌的⽣成, 令牌的传递, 令牌的校验)
当前企业开发中, 解决会话跟踪使⽤最多的⽅案就是令牌技术.
小总结

JWT令牌
令牌本质就是⼀个字符串, 他的实现⽅式有很多, 我们采⽤⼀个JWT令牌来实现.JWT是令牌的一种实现方式.
介绍
JWT全称: JSON Web Token官⽹:Auth0 | JWT Handbook
JSON Web Token(JWT)是⼀个开放的⾏业标准(RFC 7519), ⽤于客⼾端和服务器之间传递安全可靠的信息.其本质是⼀个token, 是⼀种紧凑的URL安全⽅法.
JWT组成
JWT由三部分组成, 每部分中间使⽤点 (.) 分隔,⽐如:aaaaa.bbbbb.cccc
• Header(头部) 头部包括令牌的类型(即JWT)及使⽤的哈希算法(如HMAC SHA256或RSA)
• Payload(负载) 负载部分是存放有效信息的地⽅, ⾥⾯是⼀些⾃定义内容.
⽐如: {"userId":"123","userName":"zhangsan"} , 也可以存在jwt提供的现场字段, ⽐如exp(过期时间戳)等.此部分不建议存放敏感信息, 因为此部分可以解码还原原始内容.
• Signature(签名) 此部分⽤于防⽌jwt内容被篡改, 确保安全性.防⽌被篡改, ⽽不是防⽌被解析.
JWT之所以安全, 就是因为最后的签名. jwt当中任何⼀个字符被篡改, 整个令牌都会校验失败. 就好⽐我们的⾝份证, 之所以能标识⼀个⼈的⾝份, 是因为他不能被篡改, ⽽不是因为内容加密.(任何⼈都可以看到⾝份证的信息, jwt 也是)
JWT不是加密的,而是把信息通过Base64的编码表示的

对上⾯部分的信息, 使⽤Base64Url 进⾏编码, 合并在⼀起就是jwt令牌Base64是编码⽅式,⽽不是加密⽅式
JWT令牌生成和校验
- 引⼊JWT令牌的依赖
sql
<!-- 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>
- 使⽤Jar包中提供的API来完成JWT令牌的⽣成和校验
如果设置的签名长度太短了,会报错

我们使用报错信息所提示的方法去创建一个key然后看三个方法的对应关系

令牌解析后, 我们可以看到⾥⾯存储的信息,如果在解析的过程当中没有报错,就说明解析成功了.
令牌解析时, 也会进⾏时间有效性的校验, 如果令牌过期了, 解析也会失败. 修改令牌中的任何⼀个字符, 都会校验失败, 所以令牌⽆法篡改.
但是,我们的登录安全问题,不能只通过JWT令牌来进行保证(当前的JWT只是解决了集群问题(数据不能传递的问题))
代码
package org.xiaobai.blog_system;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;
import org.junit.jupiter.api.Test;
import javax.crypto.SecretKey;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class JwtUtilsTest {
//设置过期时间为1h(毫秒)
private static final long JWT_EXPIRATION = 60*60*1000;
//生成key
private static final String secretStr = "gI3LKBe6f3JDSoeBCONqBt0Ks6qMD6HZxsMadimkJO8=";
private static final Key key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretStr));
//生成 JWT 令牌
@Test
public void genJwt() {
Map<String,Object> claim = new HashMap<>();
claim.put("id",1);
claim.put("name","zhangsan");
//创建解析器,设置签名密钥
String token = Jwts.builder().setClaims(claim)
.setExpiration(new Date(System.currentTimeMillis()+JWT_EXPIRATION)) //设置多久过期
.signWith(key) //设置签名
.compact();
System.out.println(token);
}
//
//生成一个随机的 JWT key,用于签名 JWT。
@Test
public void genKey(){
SecretKey secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
String str = Encoders.BASE64.encode(secretKey.getEncoded());
System.out.println(str);
}
//此方法用于 解析 JWT 令牌 并验证其合法性。
@Test
//根据令牌校验合法性
public void parseToken(){
//此时不管你改哪个部分都不会校验成功,过期了也不能
String token = "eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiemhhbmdzYW4iLCJpZCI6MSwiZXhwIjoxNzQwMzcwMDk0fQ.KNxDj9RV3IOLq9-YnCBbqFigtxqGtLY2Aswr3UqgQ94";
JwtParser build = Jwts.parserBuilder().setSigningKey(key).build();
Claims claims =build.parseClaimsJws(token).getBody(); //解析token
System.out.println(claims);
}
}
学习令牌的使用之后, 接下来我们通过令牌来完成用户的登录
-
登陆⻚⾯把⽤⼾名密码提交给服务器.
-
服务器端验证⽤⼾名密码是否正确, 如果正确, 服务器⽣成令牌, 下发给客⼾端.
-
客⼾端把令牌存储起来(⽐如Cookie, local storage等), 后续请求时, 把token发给服务器
-
服务器对令牌进⾏校验, 如果令牌正确, 进⾏下⼀步操作
把JWT应用到我们的登录接口上
接口定义

后端代码
controller
package org.xiaobai.blog_system.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.xiaobai.blog_system.model.Result;
import org.xiaobai.blog_system.model.UserInfo;
import org.xiaobai.blog_system.service.UserService;
import org.xiaobai.blog_system.utils.JwtUtils;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
@RequestMapping("/login")
public Result login(String username, String password) {
//1. 后端进行参数的校验(合法性)
if (!StringUtils.hasLength(username) || !StringUtils.hasLength(password)) {
return Result.fail("账号或者密码不能为空");
}
//2. 校验密码是否正确
//从数据库中查找用户
UserInfo userInfo = userService.SelectByName(username);
//用户不存在
if (userInfo == null) {
return Result.fail("用户不存在");
}
//密码错误
if (!password.equals(userInfo.getPassword())) {
return Result.fail("密码错误");
}
//3. 密码正确,返回token
Map<String,Object> claim = new HashMap<>();
claim.put("id",userInfo.getId());
claim.put("userName",userInfo.getUserName());
//生成token
String token = JwtUtils.genJwtToken(claim);
//返回token
return Result.success(token);
}
}
service
package org.xiaobai.blog_system.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.xiaobai.blog_system.mapper.UserInfoMapper;
import org.xiaobai.blog_system.model.UserInfo;
@Service
public class UserService {
@Autowired
private UserInfoMapper userInfoMapper;
public UserInfo SelectByName(String username) {
return userInfoMapper.selectByName(username);
}
}
mapper
package org.xiaobai.blog_system.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.xiaobai.blog_system.model.UserInfo;
@Mapper
public interface UserInfoMapper {
//1.根据用户名,查询用户信息
@Select("select * from user where delete_flag != 1 and user_name = #{userName}")
UserInfo selectByName(String userName );
//2.根据ID,查询用户信息
@Select("select * from user where delete_flag != 1 and id = #{id}")
UserInfo selectById(Integer id);
}
utils:
package org.xiaobai.blog_system.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Slf4j
public class JwtUtils {
//设置过期时间为1h(毫秒)
private static final long JWT_EXPIRATION = 60*60*1000;
//生成key
private static final String secretStr = "gI3LKBe6f3JDSoeBCONqBt0Ks6qMD6HZxsMadimkJO8=";
private static final Key key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretStr));
//1. 生成token
//根据载荷信息生成
public static String genJwtToken(Map<String,Object> claim){
claim.put("id",1);
claim.put("name","zhangsan");
//创建解析器,设置签名密钥
String token = Jwts.builder().setClaims(claim)
.setExpiration(new Date(System.currentTimeMillis()+JWT_EXPIRATION)) //设置多久过期
.signWith(key) //设置签名
.compact();
System.out.println(token);
return token;
}
//2. 校验token: claims为null校验失败
public Claims parseToken(String token){
//此时不管你改哪个部分都不会校验成功,过期了也不能
//产生了异常就失败
JwtParser build = Jwts.parserBuilder().setSigningKey(key).build();
Claims claims = null;
try {
claims =build.parseClaimsJws(token).getBody(); //解析token
}catch (Exception e){
log.error("解析token失败:{}",token);
return null;
}
return claims;
}
}
这里注意
claim继承了map

验证账号和密码不能为空的时候,我们返回类型的选择

前端代码
编写过程

编写结果

结果展示

3.5 实现强制要求登陆
当⽤⼾访问 博客列表⻚ 和 博客详情⻚ 时, 如果⽤⼾当前尚未登陆, 就⾃动跳转到登陆⻚⾯.
我们可以采⽤拦截器来完成, token通常由前端放在header中, 我们从header中获取token, 并校验token是否合法.
我们客户端保存了token之后,客户端在发送后端请求的时候会带着token,服务端将会接收这个token. 服务器在接收token的时候会有俩个问题:
-
客户端把token放在哪里比较合适?(header(常常放在这里),参数,url)
-
服务端强制登录: 通过拦截器实现
1> 从header获取token
2> 校验token,判断是否放行
以后我们再发送请求的时候,就会把token放到http里面的header里面传给服务器

具体流程

如果我们的token没用发送到后端
-
在我们的common.js中打印日志,判断是否执行了该方法
-
如果未执行,把这段代码粘到html页面中
-
确认名称的对应关系
接口定义

后端代码
controller
package org.xiaobai.blog_system.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.xiaobai.blog_system.model.Result;
import org.xiaobai.blog_system.model.UserInfo;
import org.xiaobai.blog_system.service.UserService;
import org.xiaobai.blog_system.utils.JwtUtils;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
@RequestMapping("/login")
public Result login(String username, String password) {
//1. 后端进行参数的校验(合法性)
if (!StringUtils.hasLength(username) || !StringUtils.hasLength(password)) {
return Result.fail("账号或者密码不能为空");
}
//2. 校验密码是否正确
//从数据库中查找用户
UserInfo userInfo = userService.SelectByName(username);
//用户不存在
if (userInfo == null) {
return Result.fail("用户不存在");
}
//密码错误
if (!password.equals(userInfo.getPassword())) {
return Result.fail("密码错误");
}
//3. 密码正确,返回token
Map<String,Object> claim = new HashMap<>();
claim.put("id",userInfo.getId());
claim.put("userName",userInfo.getUserName());
//生成token
String token = JwtUtils.genJwtToken(claim);
//返回token
return Result.success(token);
}
}
service
package org.xiaobai.blog_system.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.xiaobai.blog_system.mapper.UserInfoMapper;
import org.xiaobai.blog_system.model.UserInfo;
@Service
public class UserService {
@Autowired
private UserInfoMapper userInfoMapper;
public UserInfo SelectByName(String username) {
return userInfoMapper.selectByName(username);
}
}
mapper
package org.xiaobai.blog_system.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.xiaobai.blog_system.model.UserInfo;
@Mapper
public interface UserInfoMapper {
//1.根据用户名,查询用户信息
@Select("select * from user where delete_flag != 1 and user_name = #{userName}")
UserInfo selectByName(String userName );
//2.根据ID,查询用户信息
@Select("select * from user where delete_flag != 1 and id = #{id}")
UserInfo selectById(Integer id);
}
utils
生成并解析JWT
package org.xiaobai.blog_system.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Slf4j
public class JwtUtils {
//设置过期时间为1h(毫秒)
private static final long JWT_EXPIRATION = 60*60*1000;
//生成key
private static final String secretStr = "gI3LKBe6f3JDSoeBCONqBt0Ks6qMD6HZxsMadimk2dJO8=";
private static final Key key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretStr));
//1. 生成token
//根据载荷信息生成
public static String genJwtToken(Map<String,Object> claim){
claim.put("id",1);
claim.put("name","zhangsan");
//创建解析器,设置签名密钥
String token = Jwts.builder().setClaims(claim)
.setExpiration(new Date(System.currentTimeMillis()+JWT_EXPIRATION)) //设置多久过期
.signWith(key) //设置签名
.compact();
System.out.println(token);
return token;
}
//2. 校验token: claims为null校验失败
public static Claims parseToken(String token){
//此时不管你改哪个部分都不会校验成功,过期了也不能
//产生了异常就失败
JwtParser build = Jwts.parserBuilder().setSigningKey(key).build();
Claims claims = null;
try {
claims =build.parseClaimsJws(token).getBody(); //解析token
}catch (Exception e){
log.error("解析token失败:{}",token);
return null;
}
return claims;
}
}
config
把拦截器应用到项目
package org.xiaobai.blog_system.config;
//把拦截器应用到项目
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;
import org.xiaobai.blog_system.interceptor.LoginInterceptor;
import java.util.Arrays;
import java.util.List;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;//把之前写的拦截器注入进来
private final List excludes = Arrays.asList(//排除路径
"/**/*.html",
"/blog-editormd/**",
"/css/**",
"/js/**",
"/pic/**",
"/user/login"
);
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)//添加拦截器
.addPathPatterns("/**")//对哪些路径生效
.excludePathPatterns(excludes);//排除那些路径
}
}
interceptor
拦截器具体内容
package org.xiaobai.blog_system.interceptor;
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;
import org.xiaobai.blog_system.utils.JwtUtils;
//拦截器的具体内容
@Slf4j
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 从header中获取token
String token = request.getHeader("user_token_header");
//2. 校验token,判断是否放行
log.info("从header中获取token:{}",token);
Claims claims = JwtUtils.parseToken(token);
if(claims == null){
//校验失败
response.setStatus(401);
return false;
}
return true;
}
}
前端代码
编写过程

编写结果


注意:
前端请求时, header中统⼀添加token, 可以写在common.js中
$(document).ajaxSend(function (e, xhr, opt) {
var user_token = localStorage.getItem("user_token"); xhr.setRequestHeader("user_token", user_token);});
ajaxSend() ⽅法在 AJAX 请求开始时执⾏函数
• event - 包含 event 对象
• xhr - 包含 XMLHttpRequest 对象
• options - 包含 AJAX 请求中使⽤的选项
结果展示
3.6 实现显示用户信息
我们可以看见,这个信息是写死的

我们期望这个信息可以随着⽤⼾登陆⽽发⽣改变.
• 如果当前⻚⾯是博客列表⻚, 则显⽰当前登陆⽤⼾的信息.
• 如果当前⻚⾯是博客详情⻚, 则显⽰该博客的作者⽤⼾信息.
注意: 当前我们只是实现了显⽰⽤⼾名, 没有实现显⽰⽤⼾的头像以及⽂章数量等信息.
接口定义
在博客列表⻚, 获取当前登陆的⽤⼾的⽤⼾信息.

后端代码
controller
//获取当前登录用户信息的信息,这个从token里面去拿
@RequestMapping("/getUserInfo")
public UserInfo getLoginUserInfo(HttpServletRequest request){
//获取token
String token = request.getHeader(Constants.REQUEST_HEADER_TOKEN);
//从token里面获取登录用户的id
Integer userId = JwtUtils.getIdByToken(token);
if(userId == null){
//用户未登录
return null;
}
UserInfo userInfo = userService.selectById(userId);
return userInfo;
}
注意

调试的时候可以根据这个方法来看是哪个文件不存在

关于技术选型: 为什么选JWT不选其他?

关于我们接口返回私密信息问题

service

mapper

前端代码
编写过程
根据前端的返回值去写

编写结果

结果展示

接口定义
在博客详情⻚, 获取当前⽂章作者的⽤⼾信息
后端代码
controller
//获取作者信息,根据博客id获取作者信息
@RequestMapping("/getAuthorInfo")
public UserInfo getAuthorInfo(Integer blogId){
//1. 根据博客id获取作者id
//2. 根据作者ID,获取作者信息
if(blogId <= 0){
return null;
}
UserInfo userInfo = userService.getAuthorInfo(blogId);
return userInfo;
}
service

mapper

前端代码
编写过程
根据后端返回结果来进行编写

编写结果

结果展示

代码修改只会,前端页面没用变化(查看网页源代码,代码没有更新)解决方案

3.7 实现用户退出
关于用户的登录,我们是通过后端来进行判断的,后端通过拦截器从header里面拿到token
也就是这个值

那如果没有值的话,后端就接收不到了,因此前端直接清除掉token即可.
在js里面使用

3.8 实现发布博客
接口定义
后端代码
controller

service

mapper

前端代码
编写结果

结果展示

代码亮点,学习其他的写博客的网站,可以对字体大小进行编辑,加一些样式.之前的只是一个空的文本框,写不了格式,然后看一下其他的网站博客的编辑格式是怎么实现的,先调研了其他网站用的什么编辑器,然后选择使用哪个编辑器(markdown),如何实现编辑器(1. 自己写 2. 开源的)
editor.md 简单介绍
editor.md 是⼀个开源的⻚⾯ markdown 编辑器组件.
官⽹参见: http://editor.md.ipandao.com/
代码: https://pandao.github.io/editor.md/
我们直接引入进来即可
<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>

因为我的后端返回过来的数据是json,因此我前端取值也要设置接收的为json,前端字符串和json相互转换.

修改详情⻚⻚⾯显示
此时会发现详情⻚会显⽰markdown的格式符号, 我们需要把markdown格式转换为html格式

指定对谁进行渲染

可以拓展的地方

3.9 实现删除/编辑博客
进⼊⽤⼾详情⻚时, 如果当前登陆⽤⼾正是⽂章作者, 则在导航栏中显示 [编辑] [删除] 按钮, ⽤⼾点击时则进⾏相应处理.(作者的userId和登录的userId是一样的那么就可以显示)

需要实现两件事:
• 判定当前博客详情⻚中是否要显示[编辑] [删除] 按钮
• 实现编辑/删除逻辑.
删除采⽤逻辑删除, 所以和编辑其实为同⼀个接⼝.
约定前后端交互接口
- 判定是否要显⽰[编辑] [删除] 按钮
修改之前的 获取博客 信息的接⼝, 在响应中加上⼀个字段.
• loginUser 为 1 表⽰当前博客就是登陆⽤⼾⾃⼰写的.


后端代码
controller

service

mapper

前端代码
编写结果

结果展示

修改博客接口

后端代码
controller

service

mapper

前端代码
代码
结果演示(此时更新时间没同步,需要改)

3.10 加密/加盐
加密介绍(JWT,和这个都是学习思想)
在MySQL数据库中, 我们常常需要对密码, ⾝份证号, ⼿机号等敏感信息进⾏加密, 以保证数据的安全性. 如果使⽤明⽂存储, 当⿊客⼊侵了数据库时, 就可以轻松获取到⽤⼾的相关信息, 从⽽对⽤⼾或者企业造成信息泄漏或者财产损失.⽬前我们⽤⼾的密码还是明⽂设置的, 为了保护⽤⼾的密码信息, 我们需要对密码进⾏加密.
比如我们现在数据库里面的信息(密码)是明文显示的,因此我们需要对敏感信息进行加密

密码算法分类
密码算法主要分为三类: 对称密码算法, ⾮对称密码算法, 摘要算法.
加密: y = f(x) ,x是明文,y是密文,f(x)是加密算法. 也就是一个算法按照一定的规则把一个字符串(一个信息)->另外一个信息.
对称密码: y = f(x), x=f(y)
非对称密码: y=f(x),x=m(y)

-
对称密码算法 是指加密秘钥和解密秘钥相同的密码算法. 常⻅的对称密码算法有: AES, DES, 3DES,RC4, RC5, RC6 等.(了解即可)
-
⾮对称密码算法 是指加密秘钥和解密秘钥不同的密码算法. 该算法使⽤⼀个秘钥进⾏加密, ⽤另外⼀个秘钥进⾏解密.
◦ 加密秘钥可以公开,⼜称为 公钥
◦ 解密秘钥必须保密,⼜称为 私钥
比如我给我的箱子上锁,锁是大家都能看见的,因此叫公钥,但是只能用我自己的钥匙打开,要是就是私钥
常见的⾮对称密码算法有: RSA, DSA, ECDSA, ECC 等
上面俩种都可以通过密文得到明文
- 摘要算法是指把任意⻓度的输⼊消息数据转化为固定⻓度的输出数据的⼀种密码算法 . 摘要算法是不可逆的(无法解密), 也就是⽆法解密. 通常⽤来检验数据的完整性的重要技术, 即对数据进⾏哈希计算然后**⽐较摘要值, 判断是否⼀致(如果一致,数据就是一致的)**. 常⻅的摘要算法有: MD5, SHA系列(SHA1, SHA2等), CRC(CRC8, CRC16,CRC32)
加密思路
博客系统中, 我们采⽤MD5算法来进⾏加密.
同样的明文,通过MD5进行加密得到的密文都是一样的(不论什么语言,什么网站)
问题: 虽然经过MD5加密后的密⽂⽆法解密, 但由于相同的密码经过MD5哈希之后的密⽂是相同的, 当存储⽤⼾密码的数据库泄露后, 攻击者会很容易便能找到相同密码的⽤⼾, 从⽽降低了破解密码的难度. 因此, 在对用户密码进⾏加密时,需要考虑对密码进⾏包装, 即使是相同的密码, 也保存为不同的密⽂. 即使⽤⼾输⼊的是弱密码, 也考虑进⾏增强, 从⽽增加密码被攻破的难度.
那么如何把简单的密码改成复杂的密码?
-
加上复杂的字符串, md5(明文+ 复杂字符串)
-
加上随机的复杂字符串,md5(明文+随机复杂字符串)
解决⽅案: 采⽤为⼀个密码拼接⼀个随机字符来进⾏加密, 这个随机字符我们称之为"盐". 假如有⼀个加盐后的加密串,⿊客通过⼀定⼿段这个加密串, 他拿到的明⽂并不是我们加密前的字符串, ⽽是加密前的字符串和盐组合的字符串, 这样相对来说⼜增加了字符串的安全性.
加密过程图

解密流程: MD5是不可逆的, 通常采⽤"判断哈希值是否⼀致"来判断密码是否正确.
如用户输⼊的密码, 和盐值⼀起拼接后的字符串经过加密算法, 得到的密⽂相同, 我们就认为密码正确(密⽂相同, 盐值相同, 推测明⽂相同)
解密过程图

整体流程:
因为 明文 + 盐值 = 密文 ,当我们的盐值和密文一致的时候=>我们的明文是一样的
当我们输入明文的时候,我们拿一个盐值通过加密算法得到密文,和我们数据库里面的密码加上同样的盐值得到的密文是一样的时候,我们就能够验证我们输入的明文就是一样的.(这个就是验证用户密码是否正确的过程)

结论: 需要存储的信息: 密文+盐值

我们先学习怎么使用

此时提出一个问题,我们该怎么存储盐值?
我们把盐值和密文存储在一起存,盐值和我们的密文一起进行拼接(怎么拼接看我们自己)
我们此时就这么实现: 盐值+密文=>存到数据库里面
数据库存储 盐值 + 密文
盐值 + md5(明文+盐值)

总体流程

具体的实现:
package org.xiaobai.blog_system.utils;
import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;
import java.util.UUID;
public class SecurityUtils {
//加密
//password用户注册的时候输入的密码
//return: 数据库中存储的信息: 盐值 + md5(明文+盐值)
public static String encrypt(String password){
//生成随机盐值
String salt = UUID.randomUUID().toString().replace("-","");
//对 明文 + 盐值 进行MD5加密 =>(明文+盐值)
String finalPassword = DigestUtils.md5DigestAsHex((password+salt).getBytes());
//返回信息
return salt + finalPassword;
}
//验证密码是否正确
//inputPassword用户登录的时候需要确认的密码
//sqlPassword 数据库中password字段存储的信息 盐值 + md5(明文+盐值)
public static boolean verify(String inputPassword,String sqlPassword){
if(!StringUtils.hasLength(inputPassword)){
return false;
}
if(sqlPassword == null || sqlPassword.length() != 64){
return false;
}
//获取盐值
String salt = sqlPassword.substring(0,32);
//根据用户输入的密码和盐值,进行加密 md5(明文+盐值)和上面对应
String finalPassword = DigestUtils.md5DigestAsHex((inputPassword + salt).getBytes());
return (salt + finalPassword).equals(sqlPassword);
}
public static void main(String[] args) {
System.out.println(encrypt("123456"));
System.out.println(verify("123456", "de32ecf4414243229b11e1b9370d3a530c96c03766fae52a469cf658eb94b9d1"));
System.out.println(verify("123457", "de32ecf4414243229b11e1b9370d3a530c96c03766fae52a469cf658eb94b9d1"));
}
}
测试结果

我们引入到项目中去使用

修改⼀下数据库密码
使⽤测试类给密码123456⽣成密⽂,
然后把数据库的密码改成我们生成的密文.

最后进行部署,把项目搞到云服务器里面(这个后面我们学习了Linux之后再部署)