当前位置: 首页 > news >正文

【JavaEE】(23) 综合练习--博客系统

一、功能描述

        用户登录后,可查看所有人的博客。点击 “查看全文” 可查看该博客完整内容。如果该博客作者是登录用户,可以编辑或删除博客。发表博客的页面同编辑页面。

        本练习的博客网站,并没有添加注册功能,以及上传作者头像功能,头像是写死的。

        用户登录:

        博客列表:

        博客详情:

        博客编辑:

二、准备工作

1、数据库

        用户文章数量、文章分类数量不要放到用户表中,因为如果添加在用户表中,文章的删除/添加(博客表)会影响文章数量也改变,导致用户表也跟着改变。文章数量也没办法放到博客表中,应该实时统计才行。

        一个用户对应多个博客,一个博客对应一个用户。用户与博客是一对多的关系,博客 id(多个博客是一个 id 列表,没有列表基础类)无法放到用户表,因此将用户 id 放到博客表。

-- 建表SQL
create database if not exists spring_blog charset utf8mb4;use spring_blog;
-- 用户表
DROP TABLE IF EXISTS spring_blog.user_info;
CREATE TABLE spring_blog.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 spring_blog.blog_info;
CREATE TABLE spring_blog.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 spring_blog.user_info (user_name, password,github_url)values("zhangsan","123456","https://gitee.com/piggy-mi");
insert into spring_blog.user_info (user_name, password,github_url)values("lisi","123456","https://gitee.com/piggy-mi");insert into spring_blog.blog_info (title,content,user_id) values("第一篇博客","111我是博客正文我是博客正文我是博客正文",1);
insert into spring_blog.blog_info (title,content,user_id) values("第二篇博客","222我是博客正文我是博客正文我是博客正文",2);

2、创建项目

        创建 Spring Boot 项目,添加 lombok、Spring Web、MyBatis、MySQL Driver 依赖。配置 MyBatis-Plus 依赖。导入前端代码。配置 yml 数据库连接、MyBatis-Plus 数据库操作日志和自动转驼峰命名、Spring Boot 日志保存目录。

        为了演示依赖冲突排除的过程,创建项目时引入了 MyBatis 依赖(不用),后面又加了 MyBatis-Plus 依赖(用),它们会存在依赖冲突:

        但实际把 mybatis 的依赖去掉就可以了。但是真实项目中,依赖会很多很多,并且我们创建项目的方式是直接复制粘贴旧的项目(避免繁琐地重新配置东西),因此很容易遇到依赖冲突,我们通过这种方式排除不用的即可。

3、创建目录

        controller(表现层)、service(业务层)、mapper(持久层)、pojo(实体类)、config(配置)、common(公共部分,如常量、统一处理、自定义工具包等)。

        实体类:pojo、model、entity 都是实体类。pojo 还细分了 VO(视图对象,返回的实体)、DO(数据对象,数据表对应的实体)、DTO(service 可能存在数据库实体转换成其它)、BO(业务对象)。这些属于阿里的规范,其它公司会模仿,但是具体使用时具有偏差,按照公司之前的项目来就行。

        此项目中只细分出 dataobject(数据库的信息)、request(请求信息)、response(响应信息)。这样写的好处:不会暴露多余信息给前端、让逻辑不混乱。比如 request 实体需要用到 jakarta.validation 参数校验、@JsonProperty 前后端参数命名不一致时的映射;response 实体需要隐藏隐私信息、处理格式化数据(如把 create_time 格式化);dataobject 实体需要用到 @TableId、@TableNmae 等在实体属性与表字段名不一致时的映射。

        SOA 理念:在 service 层通常会先写 service 接口再写多个版本的继承了同一个接口的 service 实体。这样做的好处就是,可以轻松替换 controller 层调用的 service bean 版本(因为实现的同一个接口,所以方法也是一样的,不需要修改调用方法处的源码)(只需要修改 @Resource 中的 service bean 名即可。实际工作中,@Resource 替代了 @AutoWired,好处:@Resource 默认按命名注入,适用于一个类(一个接口类)有多个 bean (多个实现类的 bean)的情况;而 @AutoWired 默认按类注入,存在问题)(当 类只有一个 bean 时,可以不指定 @Resource 中的命名)

4、测试

        运行程序,看前端页面是否能正常访问,排除错误。避免后续加了其它功能后,代码复杂不好排查错误。

三、公共部分代码

        项目主要分为 controller、service、mapper、数据库、实体类、公共部分(如统一处理)。数据库已经建好了,我们先完成公共部分代码。

1、统一数据返回格式

        统一数据返回格式,返回 Result 实例:如果不统一,每个接口的返回结果非常定制化,前端不方便处理;并且想区分业务成功、业务失败、程序异常、未登录等情况还需要查特定接口返回值的含义,如果用 code 表示,含义就清晰很多。

(1)response 实体类

package com.edu.spring.blog.pojo.response;import com.edu.spring.blog.common.enums.ResultCodeEnums;
import lombok.Data;@Data
public class Result <T>{Integer code;String errMsg;T data;public static <T> Result<T> success(T data) {Result<T> result = new Result<>();result.setCode(ResultCodeEnums.SUCCESS.getCode());result.setData(data);return result;}public static <T> Result<T> unLogin() {Result<T> result = new Result<>();result.setCode(ResultCodeEnums.UN_LOGIN.getCode());return result;}public static <T> Result<T> error(String errMsg) {Result<T> result = new Result<>();result.setCode(ResultCodeEnums.ERROR.getCode());result.setErrMsg(errMsg);return result;}public static <T> Result<T> error(Integer code, String errMsg) {Result<T> result = new Result<>();result.setCode(code);result.setErrMsg(errMsg);return result;}
}

        将返回值 code 设计成枚举类,好处是:让无含义的数字具有含义,使用时调用有含义的枚举实例名。

package com.edu.spring.blog.common.enums;import lombok.AllArgsConstructor;
import lombok.Getter;@Getter
@AllArgsConstructor
public enum ResultCodeEnums {SUCCESS(200, "业务处理成功"),UN_LOGIN(-1, "未登录"),ERROR(-2, "后端出错");private final Integer code;private final String message;
}

(2)统一处理代码

package com.edu.spring.blog.common.advice;import com.edu.spring.blog.pojo.response.Result;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
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<Object> {@Resourceprivate ObjectMapper mapper;@Overridepublic boolean supports(MethodParameter returnType, Class converterType) {return true;}@SneakyThrows@Overridepublic Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {if (body instanceof Result<?>) {return body;}// body 通常不用 String,因为还得设置 response 的 content-type 为 application/json,比较麻烦if (body instanceof String) {return mapper.writeValueAsString(Result.success(body));}return Result.success(body);}
}

2、统一异常处理

        定义自己的 Blog 异常类,也可以细分定义更多的异常,比如参数异常、内部错误异常等。我仅定义了 Blog 异常,通过 code、message 区分不同的异常。这里只是为了示范自定义异常。

        因为父类也有 message 属性,所以 getMessage 获得的父类的 massage,所以要 @Getter 重写 get 方法。

package com.edu.spring.blog.common.exception;import lombok.Getter;@Getter
public class BlogException extends RuntimeException {Integer code;String message;public BlogException(String message) {this.message = message;}public BlogException(Integer code, String message) {this.code = code;this.message = message;}
}
package com.edu.spring.blog.common.advice;import com.edu.spring.blog.common.exception.BlogException;
import com.edu.spring.blog.pojo.response.Result;
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;@ControllerAdvice
@ResponseBody
@Slf4j
public class ExceptionAdvice {@ExceptionHandlerpublic Result<?> error(Exception e) {log.error("服务器内部发生异常,e: ", e);return Result.error("服务器内部错误,请联系管理员");}@ExceptionHandlerpublic Result<?> error(BlogException e) {log.error("发生异常,e: ", e);return Result.error(e.getMessage());}
}

3、拦截器

        最后添加,因为其它功能开发过程中被拦截很麻烦,需要反复登录。

       

四、业务代码

1、持久层

 (1)dataobject 实体类

        按照数据库表创建:

package com.edu.spring.blog.pojo.dataobject;import lombok.Data;import java.util.Date;@Data
public class UserInfo {private Integer id;private String userName;private String password;private String githubUrl;private Byte deleteFlag;private Date createTime;private Date updateTime;
}
package com.edu.spring.blog.pojo.dataobject;import lombok.Data;import java.util.Date;@Data
public class BlogInfo {private Integer id;private String title;private String content;private Integer userId;private Byte deleteFlag;private Date createTime;private Date updateTime;
}

(2)mapper 接口

        继承 MayBatis-Plus 框架提供的 BaseMapper<T> 类,包含基础的 mapper 操作方法。T 是操作的数据对象,一个表一个 mapper。

package com.edu.spring.blog.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.edu.spring.blog.pojo.dataobject.UserInfo;@Mapper
public interface UserInfoMapper extends BaseMapper<UserInfo> {
}
package com.edu.spring.blog.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.edu.spring.blog.pojo.dataobject.BlogInfo;@Mapper
public interface BlogInfoMapper extends BaseMapper<BlogInfo> {
}

2、博客列表

(1)接口设计

请求:
/blog/getList    GET参数:
无响应
{code: 200,errMsg: null,data: [{"id": 1, // 需要根据 id 查询博客详情 "title": "我的第一篇博客","content": "我可正文博客正文...不能超过 100 字""createTime": "2025-09-04 18:44"},......]
}

(2)response 实体类(@JsonFormat)

        实际开发中,关于时间数据,后端更倾向于返回时间戳,这样的好处是:前端自行处理格式,若后续需要修改格式也很方便,与后端无关。使用 .getTime 便可以获得时间戳。

        为了学习后端的时间格式化,我们返回格式化的时间字符串。可以用 SimpleDateFormat 类,也可以使用 @JsonFormat 注解,更加方便。

        格式查询 Java8 官方文档 SimpleDateFormat 类:SimpleDateFormat (Java Platform SE 8 )

        在列表中,content 是显示不全的。content 可以由前端处理,也可以由后端处理。后端处理更好,因为传输的数据量更少,性能更好。

package com.edu.spring.blog.pojo.response;import com.edu.spring.blog.pojo.dataobject.BlogInfo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import org.springframework.beans.BeanUtils;import java.text.SimpleDateFormat;
import java.util.Date;@Data
public class BlogListResponse {private Integer id;private String title;private String content;@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") // 纠正时区private Date createTime;// 后端更倾向于返回时间戳,前端可以自行转换
//    public Long getCreateTime() {
//        return createTime.getTime();
//    }// 2025-01-01 00:00:00
//    public String getCreateTime() {
//        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
//        return sdf.format(createTime);
//    }// 将 content 字段的长度限制为 100public String getContent() {return content.length() > 100? content.substring(0, 100) + "..." : content;}// 创建对象时,传入 dataobject,自动转换为 BlogListResponse 对象public BlogListResponse(BlogInfo blogInfo) {BeanUtils.copyProperties(blogInfo, this);}
}

(3)controller

package com.edu.spring.blog.controller;import com.edu.spring.blog.pojo.response.BlogListResponse;
import com.edu.spring.blog.service.BlogInfoService;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;import java.util.List;@RestController
@RequestMapping("/blog")
public class BlogInfoController {@Resource(name = "blogInfoServiceImpl")private BlogInfoService blogInfoService;@GetMapping("/getList")public List<BlogListResponse> getBlogList() {return blogInfoService.getBlogList();}
}

(4)service

        接口:

package com.edu.spring.blog.service;import com.edu.spring.blog.pojo.response.BlogListResponse;import java.util.List;public interface BlogInfoService {List<BlogListResponse> getBlogList();
}

        实现类:

  • .map:对流中的每个元素应用一个函数,将其映射成另一个元素,从而生成一个新的流。
  • .collect():将流中的元素累积到一个可变的结果容器中,通过一个Collector来指定如何进行累积操作。
  • Collectors.toList():将流中的元素收集到一个List中。

        BeanUtils.copyProperties 会自动将 blogInfo 复制到 response 对应属性中。每次 new 了 response 都要转换一次,代码冗余。不如直接传入 blogInfo 在构造函数里进行转换。

package com.edu.spring.blog.service.impl;import java.util.List;
import java.util.stream.Collectors;@Service("blogInfoServiceImpl")
public class BlogInfoServiceImpl implements BlogInfoService {@Resource(name = "blogInfoMapper")private BlogInfoMapper blogInfoMapper;@Overridepublic List<BlogListResponse> getBlogList() {// 查询出所有未删除的博客信息,按创建时间倒序排列List<BlogInfo> blogInfoList = blogInfoMapper.selectList(new LambdaQueryWrapper<BlogInfo>().eq(BlogInfo::getDeleteFlag, Constants.NOT_DELETE).orderByDesc(BlogInfo::getCreateTime));// 将 BlogInfo 转换为 BlogListResponsereturn blogInfoList.stream().map(blogInfo -> {
//            BlogListResponse response = new BlogListResponse();
//            // response.setId(blogInfo.getId());  这种方法太麻烦了,还要一个个设置属性
//            // 使用 BeanUtils 工具类
//            BeanUtils.copyProperties(blogInfo, response);
//            return response;// 直接在构造方法里转换return new BlogListResponse(blogInfo);}).collect(Collectors.toList());}
}

        常量类:

package com.edu.spring.blog.common.constant;public class Constants {public static final Byte IS_DELETE = 1;public static final Byte NOT_DELETE = 0;
}

(5)前端 JS

    <script>getList();function getList() {$.ajax({url: "/blog/getList",type: "GET",success: function (result) {if(result.code === 200 && result.data != null) {let blogs = result.data;let html = "";for(let blog of blogs) {html += "<div class=\"blog\">"html += "<div class=\"title\">" + blog.title + "</div>"html += "<div class=\"date\">" + blog.createTime + "</div>"html += "<div class=\"desc\">" + blog.content + "</div>"html += "<a class=\"detail\" href=\"blog_detail.html?id=" + blog.id + "\">查看全文&gt;&gt;</a>"html += "</div>"}$(".right").html(html);} else {alert(result.errMsg)}}});}</script>

(6)测试

3、博客详情

(1)接口设计

请求:
/blog/getBlogDetail?id=1    GET参数:
无响应:
{code: 200,errMsg: null,data: {"id": 1,"title": "我的第一篇博客","content": "我可正文博客正文...不能超过 100 字""userId": "zhangsan","createTime": "2025-09-04 18:44"}
}

(2)response 实体类

package com.edu.spring.blog.pojo.response;import com.edu.spring.blog.pojo.dataobject.BlogInfo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import org.springframework.beans.BeanUtils;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;public BlogDetailResponse(BlogInfo blogInfo) {BeanUtils.copyProperties(blogInfo, this);}
}

(3)controller

        Java 在编译时默认 不会把参数名编译进 .class 文件只保留参数类型,所以访问时找不到参数名去绑定,所以会报错:

        第一种方法:加 @RequestParam("id") 显示绑定,但这个方法要求每个参数都要绑定,很麻烦。第二中方法配置 idea:给项目配置 -parameters,还不行就 clean 一下 target。

        关于参数校验,用 if-else 校验很麻烦。我们使用 jakarta.validation 工具里的注解帮我们校验。需要加入依赖:

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId>
</dependency>

        常见注解:

注解说明适用类型
@NotBlank不能为 null,而且调用 trim() 后,长度必须大于 0,即必须有实际字符。String 类型
@NotEmpty等不能为 null,且长度必须大于 0。字符串、集合、数组
@NotNull不为空。任何类型
@Min大等于指定的值Number 、 String
@Size(min=, max=)长度在给定的范围之内字符串、集合、数组
@Length(min=, max=) 长度在给定的范围之内String 类型

        controller:

    @GetMapping("/getBlogDetail")public BlogDetailResponse getBlogDetail(@NotNull(message = "blogId 不能为 null")@Min(value = 1, message = "blogId 不能小于 1")Integer id) {log.info("获得博客详情,blogId: {}", id);return blogInfoService.getBlogDetail(id);}

        前端参数不符合规范:抛出 HandlerMethodValidationException

        异常信息:

        统一异常处理:

    @ExceptionHandlerpublic Result<?> error(HandlerMethodValidationException e) {List<String> errors = e.getAllErrors().stream().map(error -> error.getDefaultMessage()).toList();log.error("发生参数校验异常,errors: {}", errors);return Result.error(Constants.REQUEST_PARAM_ERROR, String.join("; ", errors));}@ExceptionHandlerpublic Result<?> error(MethodArgumentNotValidException e) {List<String> errors = e.getBindingResult().getFieldErrors().stream().map(error -> error.getDefaultMessage()).toList();log.error("发生参数校验异常,errors: {}", errors);return Result.error(Constants.REQUEST_PARAM_ERROR, String.join("; ", errors));}

(4)service

    @Overridepublic BlogDetailResponse getBlogDetail(Integer id) {// 查询出指定 id 的,未删除的博客信息BlogInfo blogInfo = blogInfoMapper.selectOne(new LambdaQueryWrapper<BlogInfo>().eq(BlogInfo::getId, id).eq(BlogInfo::getDeleteFlag, Constants.NOT_DELETE));// 将 BlogInfo 转换为 BlogDetailResponsereturn new BlogDetailResponse(blogInfo);}

(5)前端 JS

    <script>getBlogDetail();function getBlogDetail() {$.ajax({url: "/blog/getBlogDetail" + location.search,type: "GET",success: function(result) {if (result.code === 200 && result.data != null) {let blog = result.data;$(".title").text(blog.title);$(".date").text(blog.createTime);$(".detail").text(blog.content);// TODO 显示博客作者信息// TODO 编辑和删除} else {alert(result.errMsg);}}});}</script>

(6)测试

4、用户登录

        Http 是无状态的,客户端第一次请求服务器,后续再请求,服务器无法识别该客户端是否请求过。会话跟踪就是为了让服务器 “有记忆”。

(1)Session&Cookie 存在的问题

  • Session 存储在服务器内存中,服务器重启后,内存中的 Session 就会丢失。(对于现实项目中,因程序版本更新而重启服务器是很正常的需求,如果 session 丢失,某些用户刚登录又要求重新登陆,在用户看来就是 bug。)
  • Session 存储在服务器内存中,增加了服务器的负担。(登陆的用户量庞大,session 占用内存大)
  • 无法在集群环境下实现会话跟踪。(现实中,一个公司至少有两台服务器,并且最好不要在同一机房甚至同一城市。一个单体应用的多个实例分别在这多个服务器上运行,这样做的目的一是分担服务器负担、二是避免单服务器故障导致整个应用无法访问。为了合理分配请求给不同的服务器上的应用,请求会先经过负载均衡算法,根据不同服务器的性能、请求访问的接口重量等进行分配。还有就是微服务,将整个项目按功能、重量等拆分成多个微服务,一般服务中的每个接口越重,划分的接口就越少。)(在此条件下,客户端第一次的请求可能被分配到服务器1,session 保存在服务器1的内存中。客户端第二次的请求可能被分配到其它服务器,其他服务器内存不含该 session,会话跟踪失败)

        因此我们需要解决两个问题:1、session 持久化(如果放到 MySQL 数据库,即硬盘,硬盘存取速度慢。更优的是 Redis 缓存中间件,session 有缓存在内存提速,也有持久化防止丢失。但这些方法仍占用服务器内存,增加负担)。2、集群环境共享 session(session 持久化后,也就解决了该问题。比如每个服务器都能从数据库中获取 session)。

(2)JWT 令牌

        令牌就是用户身份的标识,本质是一个字符串 token,类似身份证。

        优点

  • session 存在客户端(cookie 或者 localStorage 浏览器提供的客户端本地存储技术),减轻服务器压力。
  • 解决了集群环境下的会议跟踪问题(客户端第一次请求分配给服务器1,生成令牌返回,存储在客户端;客户端第二次请求携带令牌分配给服务器2,令牌不可篡改,因为只有它持有密钥加密成签名,篡改了,当前令牌的签名和之前的签名就对不上)。

        缺点

  • 需要自己实现令牌生成、传输、校验技术

        常见的有 JWT 令牌,是第三方工具,帮我们实现了令牌。

JSON Web Tokens - jwt.iohttps://www.jwt.io/        JWT 令牌组成

        参考之前写的 HTTPS 证书,令牌类似于证书:Header  + Payload + 仅服务端持有的密钥加密校验和生成唯一的签名=令牌。因此令牌的 Header、Payload 无法篡改,改了的话校验和就变了;校验和也不能改,因为无法获取服务端持有的密钥加密。

        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>

        使用示例:

@SpringBootTest
public class JwtUtilTest {// 密钥字符串String secretString = "1mF4QaoRhmt82qmv0fqP1BJ80OmRLI+8sFwUtscTLMM=";// 密钥字符串转为密钥对象Key key = Keys.hmacShaKeyFor(secretString.getBytes(StandardCharsets.UTF_8));// 过期时间,单位:毫秒long EXPIRATION_TIME = 1000 * 60 * 60 * 24;// 测试生成令牌@Testpublic void generateToken() {// 自定义 PayloadMap<String, Object> payload = new HashMap<>();payload.put("id", 1);payload.put("username", "admin");// 生成令牌 TokenString token = Jwts.builder().setClaims(payload).signWith(key, SignatureAlgorithm.HS256) // 使用 HS256 算法进行签名.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) // 设置过期时间.compact();System.out.println(token);}// 测试生成随机密钥字符串@Testpublic void generateSecretString() {// 生成随机密钥对象SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);// 将二进制密钥转换为 Base64 编码的密钥字符串String secretString = Encoders.BASE64.encode(key.getEncoded());System.out.println(secretString);}// 测试检验令牌@Testpublic void checkToken() {String token = "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsImV4cCI6MTc1NzE1NTMzMX0.Kc0Bc41u5eYKJBN397Y9mV12PjwVHaK4ed1GpzGOBZE";// 检验令牌,密钥不匹配、令牌被篡改、过期等都会抛出异常JwtParser builder = Jwts.parserBuilder().setSigningKey(key) // 设置签名密钥.build();// 解析令牌,获取 PayloadClaims body = builder.parseClaimsJws(token).getBody();System.out.println(body);}
}

生成 token:

随机生成密钥字符串:

检验 token 并获取 payload:Claims 继承了 Map,可当作 Map 使用。

将生成的 token 解析:

(3)实现用户登录

        思路:客户端请求登录,服务器访问数据库,验证用户名、密码是否匹配,匹配则生成令牌,响应给客户端,客户端把令牌存到本地。

        令牌保存在客户端,服务器重启不会丢失、不占内存,不同服务器上的应用实例都可以获取到该令牌,实现集群环境下的登录。

        客户端登陆后请求服务器,会携带本地存储的令牌,服务器执行拦截器,解析令牌是否正确,正确则不拦截。解析令牌时需要获取用户信息,在令牌的 payload 中,用于识别不同的用户会话,需要使用 TreadLocal 存储 payload。因为在 Java Web 容器(如 Tomcat)中,每个请求会分配一个线程,请求处理完毕后线程归还线程池,而 ThreadLocal 中的数据仅在当前请求的线程处理周期内有效

        接口设计:

请求:
/user/login    POST参数:
{"userName": "zhangsan","password": "123456"
}响应:
{code: 200,errMsg: null,data: null
}token 放在 header 的 set-token 字段

        使用 JWT 实现的令牌生成、校验工具:

public class JwtUtil {// 密钥字符串private static final String secretString = "1mF4QaoRhmt82qmv0fqP1BJ80OmRLI+8sFwUtscTLMM=";// 密钥字符串转为密钥对象private static final Key key = Keys.hmacShaKeyFor(secretString.getBytes(StandardCharsets.UTF_8));// 过期时间,单位:毫秒,24小时private static final long EXPIRATION_TIME = 1000 * 60 * 60 * 24;// 根据自定义 payload 生成令牌public static String generateToken(Map<String, Object> payload) {return Jwts.builder().setClaims(payload).signWith(key, SignatureAlgorithm.HS256) // 使用 HS256 算法进行签名.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) // 设置过期时间.compact();}// 检验令牌,返回 payload 中的用户信息public static Claims checkToken(String token) {JwtParser builder = Jwts.parserBuilder().setSigningKey(key) // 设置签名密钥.build();try {// 解析令牌,密钥不匹配、令牌被篡改、过期等都会抛出异常。并获取 Payloadreturn builder.parseClaimsJws(token).getBody();} catch (Exception e) {return null;}}// 将原数据 UserInfo 转换为 Mappublic static Map<String, Object> convertMap(UserInfo userInfo) {Map<String, Object> map = new HashMap<>();map.put("userId", userInfo.getId());map.put("username", userInfo.getUserName());return map;}
}

        单线程内共享(所有方法和接口)当前会话用户信息工具:

package com.edu.spring.blog.common.util;import java.util.Map;public class UserContextUtil {private static final ThreadLocal<Map<String, Object>> userContext = new ThreadLocal<>();public static void setContext(Map<String, Object> context) {userContext.set(context);}public static Map<String, Object> getContext() {return userContext.get();}public static void clearContext() {userContext.remove();}
}

         request 实体类:使用参数校验注解

package com.edu.spring.blog.pojo.request;import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import org.hibernate.validator.constraints.Length;@Data
public class UserLoginRequest {@NotBlank(message = "用户名不能为空")@Length(max = 20, message = "用户名长度不能超过20个字符")private String userName;@Length(max = 20, message = "密码长度不能超过20个字符")@NotBlank(message = "密码不能为空")private String password;
}

         controller:将令牌写到 response 的 header 中,对象参数检验要加 @Validated

    @PostMapping("/login")public Result<?> login(@Validated @RequestBody UserLoginRequest request, HttpServletResponse response) {log.info("用户登录请求 request: {}", request);String token = userInfoService.login(request);response.setHeader(Constants.RESPONSE_HEADER_TOKEN, token);return Result.success(null);}

        service:

    @Overridepublic String login(UserLoginRequest request) {UserInfo userInfo = userInfoMapper.selectOne(new LambdaQueryWrapper<UserInfo>().eq(UserInfo::getUserName, request.getUserName()).eq(UserInfo::getDeleteFlag, Constants.NOT_DELETE));// 校验登录信息if(userInfo == null) {throw new BlogException("用户不存在");}if(!userInfo.getPassword().equals(request.getPassword())){throw new BlogException("密码错误");}// 校验正确,根据 userInfo 生成令牌return JwtUtil.generateToken(JwtUtil.convertMap(userInfo));}

        前端 JS:

        function login() {$.ajax({url: "/user/login",type: "post",contentType: "application/json",data: JSON.stringify({"userName": $("#username").val(),"password": $("#password").val()}),success: function(result, textStatus, xhr) {if (result.code === 200) {// 把 token 存到 localStorage 中localStorage.setItem("token", xhr.getResponseHeader("set-token"));location.href = "blog_list.html";} else {alert(result.errMsg);}}});}

(4)实现强制登陆 

        拦截器定义:

package com.edu.spring.blog.common.interceptor;@Component
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 从 request 的 header 中获取令牌String token = request.getHeader(Constants.REQUEST_HEADER_TOKEN);// 校验令牌,获取 payloadClaims payload = JwtUtil.checkToken(token);// 校验无效,拦截请求if (payload == null) {log.error("无效的令牌 {},拦截请求", token);// 设置响应头状态码,告诉浏览器未授权response.setStatus(HttpStatus.UNAUTHORIZED.value());return false;}// 校验有效,将 payload 存入 ThreadLocal 中,后续可通过 ThreadLocal 获取当前用户信息UserContextUtil.setContext(payload);// 放行请求return true;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {// 请求处理之后,清除 ThreadLocal 中的用户信息,防止内存泄漏UserContextUtil.clearContext();}
}

        拦截器注册:

package com.edu.spring.blog.common.config;@Configuration
public class WebConfig implements WebMvcConfigurer {@Resourceprivate LoginInterceptor loginInterceptor;@Overridepublic void addInterceptors(InterceptorRegistry registry) {List<String> excludePathPatterns = List.of("/user/login","/**/*.html","/blog-editormd/**","/css/**","/js/**","/pic/**","/**/*.ico");registry.addInterceptor(loginInterceptor).addPathPatterns("/**").excludePathPatterns(excludePathPatterns);}
}

        前端 JS:因为每次请求都要经过拦截器,都可能触发 401 状态码,前端需要统一处理。登录后,每次请求都要携带 token,也需要统一处理。放到 common.js 中,所有 html 都要加 common.js:

// 统一异常状态码处理
$(document).ajaxError(function(event,xhr){if(xhr.status === 401){location.href = "/blog_login.html";}
});// 统一携带 token
$(document).ajaxSend(function (e, xhr) {let userToken = localStorage.getItem("token");xhr.setRequestHeader("token", userToken);
});

5、显示用户信息

        用户发表文章数:每次直接用 SQL 查询比较慢。优化方向:使用 redis 缓存。缓存没有文章数量,则 SQL 查询,有则读取缓存。新增数据时,更新缓存值;删除数据时,更新缓存值或者直接删除缓存值。

        可扩展,用户博客分类:如果一个博客只有一个分类,则把分类加入博客表。如果一个博客可对应多个分类,则抽出一个分类表,分类 id、博客 id、分类名。

        可扩展,用户头像:重写 WebMvcConfigurer 类的 addResourceHandlers 方法,将 url 的路径映射到存放静态资源的路径。有条件可以将文件单独存放在一个服务器。然后在用户表中加上图片路径字段。

public void addResourceHandlers(ResourceHandlerRegistry registry) {// 映射自定义静态资源registry.addResourceHandler("/img/**").addResourceLocations("file:D:/pic/");
}访问 url:http://127.0.0.1:8080/img/头像.jpg获取到文件资源:
本地找 D:/pic/头像.jpg

(1)接口设计

请求:
/user/getUserInfo    GET参数:
无响应:
{"code": 200,"errMsg": null,"data": {"userName": "zhangsan","githubUrl": "https://gitee.com/zhangsan","blogNum": 2}
}

(2)response 实体类

package com.edu.spring.blog.pojo.response;@Data
public class UserInfoResponse {private String userName;private String githubUrl;private Long blogNum;public UserInfoResponse(UserInfo userInfo, Long blogNum) {BeanUtils.copyProperties(userInfo, this);this.blogNum = blogNum;}
}

(3)后端

        controller:

    @GetMapping("/getUserInfo")public UserInfoResponse getUserInfo() {return userInfoService.getUserInfo();}

        service:

    @Overridepublic UserInfoResponse getUserInfo() {// 从上下文中获取 userIdInteger userId = (Integer) UserContextUtil.getContext().get("userId");return getUserInfoById(userId);}public UserInfoResponse getUserInfoById(Integer userId) {// 根据 userId 查询用户信息UserInfo userInfo = userInfoMapper.selectById(userId);// 根据 userId 查询博客数量Long blogNum = blogInfoMapper.selectCount(new LambdaQueryWrapper<BlogInfo>().eq(BlogInfo::getUserId, userId).eq(BlogInfo::getDeleteFlag, Constants.NOT_DELETE));return new UserInfoResponse(userInfo, blogNum);}

(4)前端 JS

        function getUserInfo() {$.ajax({url: "/user/getUserInfo",type: "GET",success: function (result) {if(result.code === 200 && result.data != null) {let userInfo = result.data;$(".container .left .card h3").text(userInfo.userName);$(".container .left .card a").attr("href", userInfo.githubUrl);$(".container .left .card .blog-count").text(userInfo.blogNum);} else {alert(result.errMsg)}}});}

        同理,显示作者信息、编辑删除按钮:

        controller:

    @GetMapping("/getAuthorInfo")public UserInfoResponse getAuthorInfo(Integer authorId) {return userInfoService.getAuthorInfo(authorId);}

        service:

    @Overridepublic UserInfoResponse getAuthorInfo(Integer authorId) {// 获得作者信息UserInfoResponse userInfoResponse = getUserInfoById(authorId);// 判断当前登录用户是否为作者userInfoResponse.setIsUserVerified(authorId.equals(UserContextUtil.getContext().get("userId")));return userInfoResponse;}

        前端 JS:

        function getAuthorInfo(authorId) {$.ajax({url: "/user/getAuthorInfo?authorId=" + authorId,type: "GET",success: function(result) {if (result.code === 200 && result.data != null) {let author = result.data;$(".container .left .card h3").text(author.userName);$(".container .left .card a").attr("href", author.githubUrl);$(".container .left .card .blog-count").text(author.blogNum);// 如果登录用户是博客作者,显示编辑、删除按钮if (author.isUserVerified === true) {let html = "<div class=\"operating\">";html += "<button onclick=\"window.location.href='blog_update.html'\">编辑</button>";html += "<button onclick=\"deleteBlog()\">删除</button></div>";$(".right .content").append(html);}} else {alert(result.errMsg);}}});}

6、用户退出

        删除本地 token,转到登陆页面。前端退出方法,放到 common.js:

// 注销登录
function logout() {localStorage.removeItem("token");location.href = "/blog_login.html";
}

7、发布博客

(1)接口设计

请求:
/blog/publishBlog    POST参数:
application/json
{"id": 1,"title": "我的第一篇博客","content": "博客正文博客正文博客正文博客正文"
}响应:
{"code": 200,"errMsg": null,"data": true
}

(2)request 实体类

@Data
public class BlogPublishRequest {@NotBlank(message = "博客标题不能为空")private String title;@NotBlank(message = "博客内容不能为空")private String content;
}

(3)后端

        controller:

    @PostMapping("/publishBlog")public Boolean publishBlog(@Validated @RequestBody BlogPublishRequest blogPublishRequest) {log.info("发布博客,blogPublishRequest: {}", blogPublishRequest);return blogInfoService.publishBlog(blogPublishRequest) == 1;}

        service:

    @Overridepublic Integer publishBlog(BlogPublishRequest blogPublishRequest) {// blog 数据库对象BlogInfo blogInfo = new BlogInfo();blogInfo.setUserId(UserContextUtil.getUserId());// 复制 blogPublishRequest 到 blogInfo 对象BeanUtils.copyProperties(blogPublishRequest, blogInfo);return blogInfoMapper.insert(blogInfo);}

(4)前端

        editor.md 是⼀个开源的⻚⾯ markdown 编辑器组件。

Editor.md - 开源在线 Markdown 编辑器http://editor.md.ipandao.com/        使用:

        Markdown 文本转 HTML 本文:

editormd.markdownToHTML("detail", markdown: blog.content});

        同理编辑博客:

请求实体类:
@Data
public class BlogUpdateRequest {@NotNull(message = "博客ID不能为空")private Integer id;@NotBlank(message = "博客标题不能为空")private String title;@NotBlank(message = "博客内容不能为空")private String content;
}controller:@PostMapping("/updateBlog")public Boolean updateBlog(@Validated @RequestBody BlogUpdateRequest blogUpdateRequest) {log.info("更新博客,blogUpdateRequest: {}", blogUpdateRequest);return blogInfoService.updateBlog(blogUpdateRequest) == 1;}service:@Overridepublic Integer updateBlog(BlogUpdateRequest blogUpdateRequest) {BlogInfo blogInfo = new BlogInfo();// 按 id 更新博客BeanUtils.copyProperties(blogUpdateRequest, blogInfo);return blogInfoMapper.updateById(blogInfo);}// 获取博客详情并显示function getBlogInfo() {$.ajax({type: "get",url: "/blog/getBlogDetail" + location.search,success: function (result) {if (result.code === 200 && result.data != null) {let blogInfo = result.data;$("#blogId").val(blogInfo.id);$("#title").val(blogInfo.title);// $("#content").val(blogInfo.content);let editor = editormd("editor", {width: "100%",height: "550px",path: "blog-editormd/lib/",// 刷新,避免缓存导致显示不正确onload: function () {this.watch();this.setMarkdown(blogInfo.content);}});} else {alert(result.errMsg);}}});}getBlogInfo();前端JS:        // 更新博客function submit() {$.ajax({url: "/blog/updateBlog",type: "POST",contentType: "application/json;charset=UTF-8",data: JSON.stringify({"id": $('#blogId').val(),"title": $('#title').val(),"content": $('#content').val()}),success: function (result) {if (result.code === 200 && result.data === true) {location.href = "blog_list.html";} else if (result.code === 200 && result.data === false) {alert("更新失败!")} else {alert(result.errMsg)}}});}

8、删除博客

(1)接口设计

请求:
/blog/deleteBlog?id=1参数:
无响应:
{"data": 200,"errMsg": null,"data": true
}

(2)代码

controller:@DeleteMapping("/deleteBlog")public Boolean deleteBlog(@NotNull(message = "blogId 不能为 null") Integer id) {log.info("删除博客,blogId: {}", id);return blogInfoService.deleteBlog(id) == 1;}service:@Overridepublic Integer deleteBlog(Integer id) {return blogInfoMapper.update(new LambdaUpdateWrapper<BlogInfo>().set(BlogInfo::getDeleteFlag, Constants.IS_DELETE).eq(BlogInfo::getId, id));}前端:function deleteBlog() {$.ajax({url: "/blog/deleteBlog" + location.search,type: "DELETE",success: function(result) {if (result.code === 200 && result.data === true) {location.href = "blog_list.html";} else if (result.code === 200 && result.data === false) {alert("删除失败!");} else {alert(result.errMsg);}}});}

9、加密/加盐

(1)加密的作用

        数据库的信息非常隐私及重要,为了防止黑客获取到数据库信息后利用隐私信息,我们需要对隐私信息加密(比如身份证、密码等。我们把项目部署到服务器上后,很可能就被黑客侵入了数据库,以此找你要钱。)。

        加密算法分为三类:

  • 对称加密:加密/解密的密钥相同,常见的算法有:AES、DES。
  • 非对称加密:加密/解密的密钥不同,通常用公钥加密、私钥解密,常见的算法有:RSE、DSE。
  • 摘要算法:把任意长度的消息加密为固定长度的字符串,不论平台、语言,相同消息加密后的摘要相同的(排除小概率事件:消息不同,也可能摘要相同)。常见算法有:MD5、CRC。

        对称、非对称加密算法可逆,摘要算法不可逆,但简单消息的摘要可破解。破解过程:通过枚举得到数字、英文字母组合的信息,然后计算相应的摘要,存储到 map 中。只要服务器无限大,就能破解摘要。但代价很高,不法分子在利益和代价的权衡中,只能破解较简单的信息的摘要。可以尝试简单/复杂信息的加/解密:

MD5在线加密/解密/破解—MD5在线https://www.sojson.com/encrypt_md5.html

(2)基础加密思路       

        基础加密思路:可能用户注册时,使用了简单密码,容易被破解。但我们作为服务提供者,需要帮助用户保护信息,增强密码的复杂度

(3)加盐加密思路        

        给用户密码加上一段随机字符串,即加盐,让密码更复杂,计算出来的摘要更难破解。为什么不用固定的盐值?如果所有用户密码加上同一个盐值,一旦黑客破解了这一个盐值,就能破解所有简单用户的密码了,因此每个密码应加随机盐值,安全性更高。

        既然是随机的,那么就需要把随机盐值存到用户表中,因为后续登录需要用该盐值计算摘要对比是否与数据库存储的摘要相同。如果直接存放在表中的一个字段,显然不行,这跟不加盐没区别。我们需要根据自定义的规则将加盐后的密码摘要、盐值摘要拼接,这个规则只有自己人知道:

(4)Spring 内置 MD5 工具使用

        加密/解密工具包:

public class Md5Util {// 加盐加密public static String md5(String password) {// 生成随机盐值// UUID 是唯一的标识,会生成带"-"的字符串// 去掉"-"后,与 md5 加密后的摘要长度相同,无法分辨是盐值还是摘要String salt = UUID.randomUUID().toString().replace("-", "");// md5(盐值 + 明文)String md5 = DigestUtils.md5DigestAsHex((password + salt).getBytes(StandardCharsets.UTF_8));// 按自定义规则,拼接盐值和摘要:盐值 + 密文return salt + md5;}// 检验密码是否正确public static Boolean checkPassword(String password, String sqlPassword) {// 密码不能为空if (!StringUtils.hasLength(password)) {return false;}// 取出盐值String salt = sqlPassword.substring(0, 32);// 取出数据库摘要String md5 = sqlPassword.substring(32);// 生成输入密码的摘要String md5Input = DigestUtils.md5DigestAsHex((password + salt).getBytes(StandardCharsets.UTF_8));// 比较两个摘要是否相同return md5.equals(md5Input);}
}

        service 登录业务:

    @Overridepublic String login(UserLoginRequest request) {UserInfo userInfo = userInfoMapper.selectOne(new LambdaQueryWrapper<UserInfo>().eq(UserInfo::getUserName, request.getUserName()).eq(UserInfo::getDeleteFlag, Constants.NOT_DELETE));// 校验登录信息if(userInfo == null) {throw new BlogException("用户不存在");}
//        if(!userInfo.getPassword().equals(request.getPassword())){
//            throw new BlogException("密码错误");
//        }if(!Md5Util.checkPassword(request.getPassword(), userInfo.getPassword())) {throw new BlogException("密码错误");}// 校验正确,根据 userInfo 生成令牌return JwtUtil.generateToken(JwtUtil.convertMap(userInfo));}

        补充:什么是 UUId? uuid 是唯一标识符,重复的概率很低,应用中,它可以用来标识未登陆的用户。比如购物平台,对于登陆的用户,可以用 userId 来标识他,从而根据它的喜好推荐商品。但对于未登录的用户,也能推荐商品,就是靠 uuid,相当于 mac 地址根据设备标识。比如一个设备有一个 uuid,未登录时会记录搜索喜好;登陆时,也会把 userId 的喜好绑定给 uuid。


文章转载自:

http://jwLqX2J8.Lbcfj.cn
http://xUnTnmtL.Lbcfj.cn
http://P2YuMVAm.Lbcfj.cn
http://cx5LPJk6.Lbcfj.cn
http://Kt7HDF7b.Lbcfj.cn
http://srMaTa2G.Lbcfj.cn
http://qA3WUuUW.Lbcfj.cn
http://5EDHSTMu.Lbcfj.cn
http://NIgHwrU7.Lbcfj.cn
http://mUQhKqDW.Lbcfj.cn
http://nvAh0slN.Lbcfj.cn
http://a38DeS5g.Lbcfj.cn
http://0gMxn2F4.Lbcfj.cn
http://5SDCu5jU.Lbcfj.cn
http://EHlMKZkh.Lbcfj.cn
http://f1r4x825.Lbcfj.cn
http://A4Ay9cRg.Lbcfj.cn
http://38KhPHEF.Lbcfj.cn
http://jibkzveD.Lbcfj.cn
http://C8ObO7g3.Lbcfj.cn
http://X0GCpOJI.Lbcfj.cn
http://WaQdMqJm.Lbcfj.cn
http://F542FQJx.Lbcfj.cn
http://kh8vsmDr.Lbcfj.cn
http://QV891c6F.Lbcfj.cn
http://jrTpLF3d.Lbcfj.cn
http://Km1oc8MH.Lbcfj.cn
http://2Ziv5RIL.Lbcfj.cn
http://ID5HK6Ik.Lbcfj.cn
http://Qfs1dio3.Lbcfj.cn
http://www.dtcms.com/a/371793.html

相关文章:

  • 第五十四天(SQL注入数据类型参数格式JSONXML编码加密符号闭合复盘报告)
  • Kotlin 协程之 突破 Flow 限制:Channel 与 Flow 的结合之道
  • RabbitMQ 确认机制
  • DrissionPage 优化天猫店铺商品爬虫:现代化网页抓取技术详解
  • 腾讯云服务器 监控系统 如何查看服务器的并发数量?
  • Qt---对话框QDialog
  • 5G NR-NTN协议学习系列:NR-NTN介绍(1)
  • 9.7需求
  • 43. 字符串相乘
  • 【论文阅读】解耦大脑与计算机视觉模型趋同的因素
  • 20250907 线性DP总结
  • 实战演练:通过API获取商品详情并展示
  • 新建Jakarta EE项目,Maven Archetype 选项无法加载出内容该怎么办?
  • 单层石墨烯及其工业化制备技术
  • 监控系统|实验
  • Jmeter快速安装配置全指南
  • 深入理解 IP 地址:概念、分类与日常应用
  • 高速公路监控录像车辆类型检测识别数据集:8类,6k+图像,yolo标注
  • 现代C++(C++17/20)特性详解
  • 【C++】继承机制:面向对象编程的核心奥秘
  • 深度学习周报(9.1~9.7)
  • Spring 日志文件
  • 【HARP 第二期】HARP 的数据组织“约定”规范
  • 钾元素:从基础认知到多元应用与前沿探索
  • 如何短时间内精准定位指标异动根源
  • Geogebra 绘制 电磁波反射折射+斯涅尔定律+半波损失
  • Mia for Gmail for Mac 邮件管理软件
  • EXCEL VBA 清空Excel工作表(Sheet)的方法
  • kafka如何保证消息的顺序性
  • Python快速入门专业版(十):字符串特殊操作:去除空格、判断类型与编码转换