校科协的免试题,做的时候除了 Git 其它的都学习过了,但没有实战做过具体的项目,借助 ChatGPT 完成,代码的 AI 味可能有点浓......

完成后的项目地址:soapsama7/SAST-ReadTrack at dev-soapsama

欢迎各位大佬来指出问题和拷打(

# 项目需求分析和准备工作

文档中给了一个项目仓库,里面提供了模板代码,只需要照着 github 仓库里面提供的模板代码和文档需求完成相应的业务逻辑即可

先用 git clone 把项目仓库拷贝到本地,但这里网有点抽风,第一次失败了,改一下代理就行

还得新建一个自己的本地分支,方便代码管理

然后用 IDEA 创建一下数据库,再运行文档里给的 SQL 语句,创建出来 user 和 book 表,这里犯了点糊涂

IDEA 里面最上层的是数据源,相当于告诉 IDEA 要访问哪台数据库服务器,在服务器下面才是各个数据库,只创建数据源就运行 SQL 语句会报错的

建完之后打算去 application.yml 里面导入一下的时候才发现项目里面有创建 sql 的全套操作........

到这里为止,准备工作已经完成了,接下来开始写业务逻辑

# 必做部分

# 基础用户部分

根据标准,完善了一下 Controller、Service、Mapper 三层的代码

Controller 层有一个注册和一个登录方法,返回值都用了 Spring 提供的类 ResponseEntity 作为 HTTP 请求的返回值,响应体用 Map 键值对集合封装一系列键值对,同时用 try-catch 语句为两个方法的结果做了不同处理。这里登录验证的 session 机制我没有学习过,这里了解了一下:

其实就是因为 HTTP 请求它是无状态的,而登录这个操作是需要保存的有状态操作,那么服务端那边就用 session 这个机制来保存用户的 id,登录之后把它返回给客户端的 cookie。之后客户端的每次请求都会自动携带这个 id 传到服务端,服务端那边看到这个 id 就知道是哪个用户,也就能执行对应的操作了

entity 里面的 user 和 book 类仅仅给了成员变量,我自己引入了 lombok 依赖,在上面加了 @Date 等注解,自动生成 get、set 等方法和构造方法等

Service 层有几个注解我之前没见过,这里总结一下

其中有注意点,在现在这个项目里面的 Resource(由 JDK 提供)可以和 Autowired(由 spring 提供)互换,但它们实际上有些区别,这里涉及到 Bean 的名字来源

也就是说在现在这个项目代码里面,这里的”private UserMapper userMapper“属性名就是 userMapper,而 userMapper 接口的名字也是这个,和 Bean 的名字一致,可以直接找到,但如果把 userMapper 换成”Mapper“之类的,根据名字找就会失效。而 Resource 就是优先根据变量的名字来找有没有 Bean 和它一样的,如果没有再根据类型找。Autowired 则是直接根据类型找,若类型多了就直接报错,必须指定名字。

不过现在这个项目代码里面只有一种类型的接口,没有相应的实现类,所以无论用哪个注解都只会找到 MyBatis 生成的那个代理对象(由 Mapper 注解完成)而已,因此用哪个都无所谓

到这为止用户部分的基本操作就弄完了,git commit 一下

后来经过测试发现还没有弄完,这里的 User 有四个属性值,注册的时候如果只给 username 和 password 的话,createAt 那里是 null

我一开始的想法是在 Service 层返回之前手动添加一下 LocalDateTime.now () 来指定一下当前时间,但这里我突然想到了之前仓库提供的建表语句似乎指定了默认时间,看了一下确实如此

但实际上在测试的时候并没有传入,这是为什么?

检查了一下 Mapper 层的代码,发现是 insert 方法出了点问题,这里不应该传入 created_at 参数,因为这里的参数是我们提交的 user 用户类,此时这里就是 null,如果把它作为参数再赋值回去,那么这里的 created_at 就有了值:null,此时建表语句的默认指定就会失效

把这个参数去掉就可以正常插入时间了

还有值得一提的是这里在本地浏览器测试的时候会先强制重定向到一个登录界面,但这个不是我写的,问了一下 ai,发现是 pom.xml 里面引入的一个依赖

它的默认用户名是 user,密码会在每次运行的时候随机生成,必须要先登录进去之后才能正常访问我写的代码逻辑

不知道这个依赖有什么用,文档里也没写,我就把它注释掉了,注释之后测试注册和登录逻辑看起来没什么问题,就到这里吧

# 基础书籍管理部分

这一部分我放在登录验证之后,每一个用户都对应自己可以操作的图书,因此 Controller 层的每一个方法都要用 (session.getAttribute ("user")).getId (),即 session 机制来获得对应用户再操作

回忆一下几个注解:

这里更改页数的相关 service 层代码,ai 一开始给我的很复杂,而且我觉得校验逻辑不够充分,自己修改了一会,最终改成了下面这样:

我在 Mapper 层添加了一个根据书籍 id 和用户 id 查询书籍的方法,这样就能查到一个唯一的书籍。若它不存在,那么直接抛出异常,所存在则可以直接对这个对象进行操作,下面的更新书籍状态也用的是这种方式

ai 一开始给的代码是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
@Transactional
public boolean updateReadProgress(Long bookId, Integer currentPage, Long userId) {
if (currentPage == null currentPage < 0) {
throw new RuntimeException("页数不合法,请重试");
}

Book book = new Book();
book.setId(bookId);
book.setUserId(userId);
book.setCurrentPage(currentPage);

return bookMapper.updateReadProgress(book) > 0;
}

Controller 层也有点小改动,这里不赘述了

为了实现分页查询,这里引入了一个 PageHelper 依赖,它是 Mybatis 的一个分页插件,只要传入起始页码和每页的数据数量,它就会自动帮我们改 SQL 语言,不用动 xml 映射文件,只在 Controller 层和 Service 层改动一下即可

后面的详细查询我做了模糊处理,前端传进来的 keyword 可以是作者名,也可以是书籍名

# 测试部分

这个项目没给前端页面,我用了 postman 测试

先访问 api/user/register 随便注册一个 testuser,注册完之后可以看到已经保存到数据库了

且当程序运行结束后用户依然存在

接下来访问 /api/user/login 登录,假设这里不登录而直接操作书籍,会导致:

这表示没有登录

登录后添加书籍,却发现如下情况

问了下 GPT,这里是数据库那边更新数据的时候出了问题,具体原因是数据库的 status 列类型(ENUM)和 Java 传入的数据类型(int)不兼容。

这里项目给的建表语句为 “status ENUM ('0', '1', '2') NOT NULL DEFAULT '0' -- 阅读状态:0 未读 1 阅读中 2 已读”,所以这里的 status 要接收的是字符串,而不是 int 整型,但项目提供的 Book 类这里的成员 status 却是整形定义,把它改成 String 类型,再修改一下 Service 里面的 setStatus 方法即可

修改完成后再测试:

可以看到数据库里面也正常出现数据:

进度更新也正常:

但是这里的 status 却没有按照代码正确修改,检查了一下发现是 SQL 语句那里并没有修改 status,添加一下

再次测试之后发现还是不行,status 的状态和 currentpage 没法统一

这里找了半天问题也没找出来,似乎是建表语句那里的枚举和我 java 里面的 String 的数据问题?这里没搞太懂。

最后找到的解决办法是,把 Book 类的 status 再变回 Integer,但是修改 MySQL 里面的 status 类型为 tinyint,这样数据类型统一了,只要在代码里面严格限制只能为 0、1、2 三个数字就行,这下逻辑没问题,当修改 currentPage 的时候,status 也会根据其正确修改

接下来的 updateStatus 方法依然出现了修改 status 之后,current_page 没有改变的问题,原因和之前一样,没有在 SQL 语句里面修改 current_page,这里添加一下即可。且这里在 Service 层再添加一段判断

保证数据逻辑正确对应

接下来的 deleteBook、getUserBooks、getBooksByStatus 没什么问题,可以直接跳过

然后这里的 searchBooks,可以正确查询到对应书籍,但是有一个小问题

具体问题就是,当返回为空的时候,我的 java 代码写的是

这个输出无法返回到前端页面,在 postman 里面表现为一个空的数据,这个输出只会在控制台上面输出

一开始我打算把这个方法的返回值也改为 ResponseEntity<Map<String, Object>>,然后作为 HTTP 请求响应,但想了一下似乎不需要,连 books 是否为空也不需要验证,直接返回空值也是一种合理的逻辑

测试到这里,感觉相关的增删查改逻辑都已经完成,没什么大问题了

# 选做部分

其它三部分没什么好说的,前面其实都完成了,安全增强这里多注意了一下

动代码之前先 git commit 一下

BCrypt 似乎理论上可能出现哈希碰撞,但安全性却很高

这里要把前面我注释掉的依赖加回来

然后修改一下 Service 层的代码,Controller 层不用动,接下来测试一下

这里如果把这个依赖又加回来了,会触发一个默认行为,之前提到过的那个,会拦截我访问的任何路径,强制重定向到那里,这里按照 GPT 的提示创建一个配置文件,放在 src/main/java/com/sast/sastreadtrack/config 包下,内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.sast.sastreadtrack.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

// 配置访问权限和禁用默认登录页
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable() // 关闭CSRF保护,前后端分离或者Postman测试可以关闭
.authorizeRequests()
.antMatchers("/api/user/login", "/api/user/register").permitAll() // 登录注册接口放行
.anyRequest().authenticated() // 其他接口需要认证
.and()
.httpBasic().disable(); // 禁用默认Spring Security弹窗登录
return http.build();
}

// 提供 BCrypt 密码加密器
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

能够关闭那个拦截页面,正常访问并测试,里面具体的一些代码功能没有深入理解,感觉暂时不用太深究配置文件的一些设置

测试发现,新创建的这个用户的密码存储已经变成哈希字符串了

接下来引入 JWT

与 session 机制区分,JWT 是服务端生成一个 Token 返回给前端,这个东西存储在前端,然后当前端每次发送请求,需要访问某些限制接口时,就携带这个 Token,服务端只是验证。但 session 机制时存储在服务端的,前端仅仅携带一个 ID,到服务端寻找

先引入三个依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- JWT: JSON Web Token,用于鉴权 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<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>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>

新建一个 src/main/java/com/sast/sastreadtrack/util/JwtUtil.java 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package com.sast.sastreadtrack.util;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;

import java.security.Key;
import java.util.Date;

public class JwtUtil {

// 建议使用安全随机生成的密钥,演示用简单字符串(长度至少 32)
private static final String SECRET_KEY = "sastreadtrack-secret-key-2025-super-safe";

// Token 有效时间(毫秒)——例如 1 天
private static final long EXPIRATION_TIME = 24 * 60 * 60 * 1000;

private static Key getSigningKey() {
return Keys.hmacShaKeyFor(SECRET_KEY.getBytes());
}

/** 生成 JWT Token */
public static String generateToken(Long userId, String username) {
return Jwts.builder()
.setSubject(username)
.claim("userId", userId)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}

/** 验证 Token 是否有效 */
public static boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token);
return true;
} catch (JwtException e) {
return false; // 过期、伪造等都属于无效
}
}

/** 从 Token 中获取用户ID */
public static Long getUserIdFromToken(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
return claims.get("userId", Long.class);
}

/** 从 Token 中获取用户名 */
public static String getUsernameFromToken(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
}

这里为了简单项目开发,让 ai 随便给了我一个密钥,且直接明文存储了,这段代码看起来操作有点多,其实实际上理解一下 JWT 发送的 Token 都表示什么,以及客户端和服务端之间是怎么用 JWT 鉴权的就可以理解代码了

JWT 生成的 Token 一共有三段

1
header.payload.signature

每一段都是 base64 编码的字符串,其中各个部分内容表示如下

前两段都是固定的明文,在 Token 传输过程中可以直接通过 base64 解码恢复。第三段 signature 是服务端根据前两段和存储的密钥进行签名算法生成的哈希值,再经 base64 产生的字符串,然后一并发送给客户端。客户端发送的 Token,服务端再根据前两段和自己的密钥进行签名算法,将生成的哈希值与客户端传过来的 signature 进行比对,若一致则予以通过。

也就是说,JWT 的安全性几乎完全来自于其密钥,若密钥泄露,安全性也就不复存在,如何保护密钥涉及到密码学和 web 安全相关范畴,这里就略过了

也就是说上面的 java 文件其实只是一个工具类,还没有实际运用于项目

在登录逻辑里面加一个生成 JWT Token 和返回 Token 就可以将 Token 传给前端,但要真正使其发挥作用则需要写一个过滤器

在这里的作用就是当访问 URL 的时候拦截并验证 Token,其实也是一段 java 代码而已

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.sast.sastreadtrack.filter;

import com.sast.sastreadtrack.util.JwtUtil;
import io.jsonwebtoken.JwtException;
import org.springframework.stereotype.Component;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class JwtFilter implements Filter {

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {

HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;

String authHeader = httpRequest.getHeader("Authorization");

// 登录接口不拦截
String path = httpRequest.getRequestURI();
if (path.startsWith("/api/user/login") path.startsWith("/api/user/register")) {
chain.doFilter(request, response);
return;
}

if (authHeader == null !authHeader.startsWith("Bearer ")) {
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpResponse.getWriter().write("缺少或无效的 Token");
return;
}

String token = authHeader.substring(7);
try {
if (!JwtUtil.validateToken(token)) {
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpResponse.getWriter().write("Token 无效或已过期");
return;
}
// ✅ 验证通过,放行
chain.doFilter(request, response);

} catch (JwtException e) {
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpResponse.getWriter().write("Token 校验失败");
}
}
}

然后我使用 Token 的时候出现了一点问题,传 Token 的话就报 400 错误码,不传就报 401(这个正常)

问了一下 AI,似乎和我前面用的 session 机制有些冲突,如果要用 JWT 鉴权的话,就得给前面用 session 机制的相关代码全部改为 JWT

其实只需要修改两个 Controller 层方法就行,方法里面添加参数”@RequestHeader ("Authorization") String authHeader“,获取 UserId 时使用”getUserIdFromToken (authHeader)“方法,相关方法为:

1
2
3
4
5
6
7
private Long getUserIdFromToken(String authHeader) {
if (authHeader == null !authHeader.startsWith("Bearer ")) {
throw new RuntimeException("缺少或无效的 Token");
}
String token = authHeader.substring(7);
return JwtUtil.getUserIdFromToken(token);
}

其实就是从传入的 Token 里面获取一下 UserId,也要使用 JwtUtil 工具类

现在再测试就一切正常,若不传 Token 则会出现:

传了 Token 就正常:

到这里就写的差不多了,后面问问 ai 传到 github 仓库就可以