Week 1-8: Spring Boot 学习计划完整项目

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 23:45:17 +08:00
commit f95aa18724
201 changed files with 18595 additions and 0 deletions

120
week8/pom.xml Normal file
View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.learn</groupId>
<artifactId>week8-engineering</artifactId>
<version>1.0.0</version>
<name>Week 8: 工程化能力</name>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.6</mybatis-plus.version>
<jjwt.version>0.12.5</jjwt.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- Spring Cache + Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Actuator 监控 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- H2 内存数据库测试用Day 2 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<!-- Knife4j API 文档Day 3 -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.5.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

56
week8/sql/init.sql Normal file
View File

@@ -0,0 +1,56 @@
-- =============================================
-- Week 5学生管理系统 v2 数据库初始化
-- 新增users 表(认证)+ 逻辑删除 + RBAC
-- =============================================
CREATE DATABASE IF NOT EXISTS week5_student
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;
USE week5_student;
-- 学生表(增加 deleted 字段用于逻辑删除,第 7 天)
DROP TABLE IF EXISTS student;
CREATE TABLE student (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
name VARCHAR(20) NOT NULL COMMENT '姓名',
age INT NOT NULL COMMENT '年龄',
email VARCHAR(50) NOT NULL COMMENT '邮箱',
score INT NOT NULL DEFAULT 0 COMMENT '成绩 0-100',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除 0-未删 1-已删',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_name (name),
INDEX idx_score (score)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生表';
-- 用户表Spring Security 认证用,第 2-3 天)
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id BIGINT NOT NULL AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',
password VARCHAR(200) NOT NULL COMMENT 'BCrypt 加密后的密码',
role VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT '角色ADMIN / USER',
enabled TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 预置学生数据
INSERT INTO student (name, age, email, score) VALUES
('张三', 20, 'zhangsan@mail.com', 85),
('李四', 22, 'lisi@mail.com', 92),
('王五', 19, 'wangwu@mail.com', 78),
('赵六', 21, 'zhaoliu@mail.com', 88),
('孙七', 23, 'sunqi@mail.com', 95),
('周八', 20, 'zhouba@mail.com', 73),
('吴九', 22, 'wujiu@mail.com', 81),
('郑十', 21, 'zhengshi@mail.com',90);
-- 用户数据由应用启动时通过 Java 代码自动初始化(保证 BCrypt 哈希正确)
-- 默认账号admin / 123456ADMIN、user / 123456USER
SELECT 'student' AS tbl, COUNT(*) AS cnt FROM student
UNION ALL
SELECT 'users', COUNT(*) FROM users;

View File

@@ -0,0 +1,13 @@
package com.learn;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching // 开启 Spring Cache第 4 天)
public class Week5Application {
public static void main(String[] args) {
SpringApplication.run(Week5Application.class, args);
}
}

View File

@@ -0,0 +1,19 @@
package com.learn.annotation;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
/**
* @ValidScore 的校验逻辑实现
*
* isValid 方法返回 true 表示校验通过false 表示校验失败。
* ConstraintValidatorContext 用于自定义错误消息。
*/
public class ScoreValidator implements ConstraintValidator<ValidScore, Integer> {
@Override
public boolean isValid(Integer value, ConstraintValidatorContext context) {
if (value == null) return true; // null 由 @NotNull 处理
return value >= 0 && value <= 100;
}
}

View File

@@ -0,0 +1,31 @@
package com.learn.annotation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
/**
* 自定义校验注解(第 6 天)
*
* 功能等价于 @Min(0) @Max(100),但演示了自定义注解的完整流程:
* 1. 定义注解 @ValidScore
* 2. 编写校验器 ScoreValidator实现 ConstraintValidator
* 3. 在实体类字段上使用 @ValidScore
*
* @Constraint(validatedBy = ...) 将注解和校验器绑定
*/
@Documented
@Constraint(validatedBy = ScoreValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidScore {
// 校验失败时的默认错误消息
String message() default "成绩必须在 0-100 之间";
// 分组校验(后续学习,此处用默认)
Class<?>[] groups() default {};
// Payload扩展用此处用默认
Class<? extends Payload>[] payload() default {};
}

View File

@@ -0,0 +1,41 @@
package com.learn.config;
import com.learn.entity.User;
import com.learn.repository.jpa.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class DataInitializer implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
private final UserRepository userRepo;
private final PasswordEncoder encoder;
public DataInitializer(UserRepository userRepo, PasswordEncoder encoder) {
this.userRepo = userRepo;
this.encoder = encoder;
}
@Override
public void run(String... args) {
if (userRepo.count() > 0) return; // 已有数据则跳过
User admin = new User();
admin.setUsername("admin");
admin.setPassword(encoder.encode("123456"));
admin.setRole("ADMIN");
userRepo.save(admin);
User user = new User();
user.setUsername("user");
user.setPassword(encoder.encode("123456"));
user.setRole("USER");
userRepo.save(user);
log.info("默认用户已创建: admin/123456 (ADMIN), user/123456 (USER)");
}
}

View File

@@ -0,0 +1,22 @@
// Day 3: Knife4j / SpringDoc API 文档配置
package com.learn.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.Contact;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class Knife4jConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("学生管理系统 API")
.version("3.0.0")
.description("Spring Boot + JPA/MyBatis-Plus + JWT + Redis")
.contact(new Contact().name("Week 8 学习项目")));
}
}

View File

@@ -0,0 +1,21 @@
package com.learn.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor p = new PaginationInnerInterceptor(DbType.MYSQL);
p.setOverflow(true);
p.setMaxLimit(100L);
interceptor.addInnerInterceptor(p);
return interceptor;
}
}

View File

@@ -0,0 +1,86 @@
package com.learn.config;
import com.learn.security.JwtAuthFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
/**
* Spring Security 配置(第 1-3 天)
*
* 核心设计:
* 1. 无状态会话JWT不依赖 Cookie/Session
* 2. 自定义 JWT 过滤器在 UsernamePasswordAuthenticationFilter 之前执行
* 3. RBACADMIN 可增删改USER 只能查
* 4. 公开 /api/auth/** 和 Actuator 端点
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // 开启方法级安全注解(第 3 天)
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
public SecurityConfig(JwtAuthFilter jwtAuthFilter) {
this.jwtAuthFilter = jwtAuthFilter;
}
/** 跨域配置:允许 Vue 前端5173访问 */
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable()) // 前后端分离,禁用 CSRF
.sessionManagement(sm -> sm
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 无状态
.authorizeHttpRequests(auth -> auth
// 公开接口
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/actuator/**").permitAll()
.requestMatchers("/", "/index.html", "/css/**", "/js/**").permitAll()
.requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**").permitAll()
// 查询USER 及以上
.requestMatchers(HttpMethod.GET, "/api/students/**").hasAnyRole("ADMIN", "USER")
// 增删改:仅 ADMIN第 3 天 RBAC
.requestMatchers(HttpMethod.POST, "/api/students/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/students/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/students/**").hasRole("ADMIN")
// 其他请求需认证
.anyRequest().authenticated()
)
// 在用户名密码过滤器之前插入 JWT 过滤器
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // BCrypt 加密,不可逆
}
}

View File

@@ -0,0 +1,50 @@
package com.learn.controller;
import com.learn.dto.ApiResponse;
import com.learn.dto.LoginRequest;
import com.learn.service.AuthService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 认证接口(第 2 天)
*
* POST /api/auth/register —— 注册
* POST /api/auth/login —— 登录,返回 JWT
*
* 这些接口在 SecurityConfig 中设置了 permitAll(),无需认证即可访问。
*/
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/register")
public ResponseEntity<ApiResponse<Map<String, Object>>> register(@RequestBody Map<String, String> body) {
try {
Map<String, Object> result = authService.register(
body.get("username"), body.get("password"));
return ResponseEntity.ok(ApiResponse.success("注册成功", result));
} catch (RuntimeException e) {
return ResponseEntity.badRequest().body(ApiResponse.badRequest(e.getMessage()));
}
}
@PostMapping("/login")
public ResponseEntity<ApiResponse<Map<String, Object>>> login(@Valid @RequestBody LoginRequest req) {
try {
Map<String, Object> result = authService.login(req);
return ResponseEntity.ok(ApiResponse.success("登录成功", result));
} catch (RuntimeException e) {
return ResponseEntity.status(401).body(ApiResponse.error(401, e.getMessage()));
}
}
}

View File

@@ -0,0 +1,92 @@
package com.learn.controller;
import com.learn.dto.ApiResponse;
import com.learn.entity.Student;
import com.learn.service.StudentServiceSelector;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
@RestController
@RequestMapping("/api/students")
public class StudentController {
private final StudentServiceSelector service;
public StudentController(StudentServiceSelector service) { this.service = service; }
// ---- 查询USER + ADMIN 均可)----
@GetMapping
public ApiResponse<java.util.List<Student>> list(
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
if (keyword != null && !keyword.trim().isEmpty()) {
java.util.List<Student> students = service.search(keyword);
return ApiResponse.success(students)
.put("total", students.size())
.put("orm", service.getActiveMode());
}
var result = service.listPage(page, size);
return ApiResponse.success(result.getContent())
.put("total", result.getTotalElements())
.put("pages", result.getTotalPages())
.put("orm", service.getActiveMode());
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<Student>> getById(@PathVariable Long id) {
Student s = service.getById(id);
if (s != null) return ResponseEntity.ok(ApiResponse.success(s));
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.notFound("学生不存在"));
}
@GetMapping("/stats/excellent")
public ApiResponse<Long> statsExcellent(@RequestParam(defaultValue = "85") int min) {
return ApiResponse.success(service.countExcellent(min));
}
// ---- 新增(仅 ADMIN----
@PostMapping
public ResponseEntity<ApiResponse<Student>> add(@Valid @RequestBody Student student) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.created(service.add(student)));
}
// ---- 更新(仅 ADMIN----
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<Student>> update(
@PathVariable Long id, @Valid @RequestBody Student student) {
return service.update(id, student)
.map(u -> ResponseEntity.ok(ApiResponse.success("更新成功", u)))
.orElse(ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.notFound("学生不存在")));
}
// ---- 删除(仅 ADMINMP 模式下为逻辑删除)----
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')") // 方法级权限控制(第 3 天)
public ResponseEntity<ApiResponse<?>> delete(@PathVariable Long id) {
return service.delete(id)
? ResponseEntity.ok(ApiResponse.success("删除成功", null))
: ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.notFound("学生不存在"));
}
// ---- 当前用户信息 ----
@GetMapping("/me")
public ApiResponse<String> me(Principal principal) {
return ApiResponse.success("当前用户: " + principal.getName()
+ " | ORM: " + service.getActiveMode());
}
}

View File

@@ -0,0 +1,33 @@
package com.learn.dto;
import java.util.HashMap;
import java.util.Map;
public class ApiResponse<T> {
private int code;
private String message;
private T data;
private Map<String, Object> extra;
public static <T> ApiResponse<T> success(T data) { return build(200, "success", data); }
public static <T> ApiResponse<T> success(String msg, T data) { return build(200, msg, data); }
public static <T> ApiResponse<T> created(T data) { return build(201, "创建成功", data); }
public static <T> ApiResponse<T> error(int code, String msg) { return build(code, msg, null); }
public static <T> ApiResponse<T> notFound(String msg) { return error(404, msg); }
public static <T> ApiResponse<T> badRequest(String msg) { return error(400, msg); }
public ApiResponse<T> put(String key, Object value) {
if (extra == null) extra = new HashMap<>();
extra.put(key, value); return this;
}
private static <T> ApiResponse<T> build(int code, String msg, T data) {
ApiResponse<T> r = new ApiResponse<>();
r.code = code; r.message = msg; r.data = data; return r;
}
public int getCode() { return code; }
public String getMessage() { return message; }
public T getData() { return data; }
public Map<String, Object> getExtra() { return extra; }
}

View File

@@ -0,0 +1,15 @@
package com.learn.dto;
import jakarta.validation.constraints.NotBlank;
public class LoginRequest {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
}

View File

@@ -0,0 +1,77 @@
package com.learn.entity;
import com.baomidou.mybatisplus.annotation.*;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.io.Serializable;
import java.time.LocalDateTime;
@Entity
@Table(name = "student")
@TableName("student")
public class Student implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@TableId(type = IdType.AUTO)
private Long id;
@NotBlank(message = "姓名不能为空")
@Size(min = 1, max = 20)
@Column(name = "name", length = 20, nullable = false)
@TableField("name")
private String name;
@Min(1) @Max(150)
@Column(name = "age", nullable = false)
@TableField("age")
private int age;
@NotBlank @Email
@Column(name = "email", length = 50, nullable = false)
@TableField("email")
private String email;
@com.learn.annotation.ValidScore // 第 6 天:自定义校验注解
@Column(name = "score", nullable = false)
@TableField("score")
private int score;
/** 逻辑删除标记(第 7 天): 0=未删, 1=已删 */
@TableLogic // MyBatis-Plus 逻辑删除
@Column(name = "deleted")
@TableField("deleted")
private Integer deleted = 0;
@Column(name = "create_time", insertable = false, updatable = false)
@TableField("create_time")
private LocalDateTime createTime;
@Column(name = "update_time", insertable = false, updatable = false)
@TableField("update_time")
private LocalDateTime updateTime;
public Student() {}
public Student(Long id, String name, int age, String email, int score) {
this.id = id; this.name = name; this.age = age; this.email = email; this.score = score;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getAge() { return age; }
public void setAge(int age) { this.age = age; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public int getScore() { return score; }
public void setScore(int score) { this.score = score; }
public Integer getDeleted() { return deleted; }
public void setDeleted(Integer deleted) { this.deleted = deleted; }
public LocalDateTime getCreateTime() { return createTime; }
public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; }
public LocalDateTime getUpdateTime() { return updateTime; }
public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; }
}

View File

@@ -0,0 +1,44 @@
package com.learn.entity;
import jakarta.persistence.*;
import java.io.Serializable;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
public class User implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false, length = 200)
private String password; // BCrypt 加密后的密文
@Column(nullable = false, length = 20)
private String role; // ADMIN / USER第 3 天 RBAC
@Column(nullable = false)
private Integer enabled = 1;
@Column(name = "create_time", insertable = false, updatable = false)
private LocalDateTime createTime;
public User() {}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
public Integer getEnabled() { return enabled; }
public void setEnabled(Integer enabled) { this.enabled = enabled; }
public LocalDateTime getCreateTime() { return createTime; }
}

View File

@@ -0,0 +1,43 @@
package com.learn.exception;
import com.learn.dto.ApiResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/** 参数校验失败 */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<?>> handleValidation(MethodArgumentNotValidException ex) {
String detail = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.collect(Collectors.joining("; "));
return ResponseEntity.badRequest().body(ApiResponse.badRequest("参数校验失败: " + detail));
}
/** 权限不足(第 3 天) */
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<?>> handleAccessDenied(AccessDeniedException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(403, "权限不足: 只有 ADMIN 可以执行此操作"));
}
/** 兜底 */
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<?>> handleAll(Exception ex) {
log.error("未处理异常", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(500, "服务器错误: " + ex.getMessage()));
}
}

View File

@@ -0,0 +1,21 @@
package com.learn.repository.jpa;
import com.learn.entity.Student;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface StudentJpaRepository extends JpaRepository<Student, Long> {
List<Student> findByNameContaining(String keyword);
@Query("SELECT s FROM Student s WHERE (s.name LIKE %:kw% OR s.email LIKE %:kw%) AND s.deleted = 0")
List<Student> search(@Param("kw") String keyword);
@Query("SELECT s FROM Student s WHERE s.score >= :min AND s.deleted = 0 ORDER BY s.score DESC")
List<Student> excellent(@Param("min") int minScore);
}

View File

@@ -0,0 +1,13 @@
package com.learn.repository.jpa;
import com.learn.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
}

View File

@@ -0,0 +1,9 @@
package com.learn.repository.mp;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.learn.entity.Student;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface StudentMapper extends BaseMapper<Student> {
}

View File

@@ -0,0 +1,79 @@
package com.learn.security;
import com.learn.entity.User;
import com.learn.repository.jpa.UserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
/**
* JWT 认证过滤器(第 2 天)
*
* 每个请求到达时,从 Authorization 头提取 JWT Token
* 解析出用户名和角色,设置到 Spring Security 上下文中。
*
* OncePerRequestFilter保证每个请求只过滤一次。
*/
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserRepository userRepo;
public JwtAuthFilter(JwtUtil jwtUtil, UserRepository userRepo) {
this.jwtUtil = jwtUtil;
this.userRepo = userRepo;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
// 1. 提取 Token
String token = extractToken(request);
if (token == null || !jwtUtil.validateToken(token)) {
chain.doFilter(request, response);
return;
}
// 2. 解析用户名
String username = jwtUtil.getUsername(token);
// 3. 查数据库确认用户仍存在且启用
Optional<User> userOpt = userRepo.findByUsername(username);
if (userOpt.isEmpty() || userOpt.get().getEnabled() == 0) {
chain.doFilter(request, response);
return;
}
// 4. 将认证信息设置到 Spring Security 上下文
String role = jwtUtil.getRole(token);
List<SimpleGrantedAuthority> authorities = List.of(
new SimpleGrantedAuthority("ROLE_" + role)
);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
chain.doFilter(request, response);
}
private String extractToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7).trim();
}
return null;
}
}

View File

@@ -0,0 +1,81 @@
package com.learn.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
/**
* JWT 工具类(第 2 天)
*
* 三个核心方法:
* generateToken(username, role) → 生成 Token
* getUsername(token) → 从 Token 解析用户名
* validateToken(token) → 验证 Token 是否有效
*
* JWT 结构header.payload.signature
* header: {"alg": "HS256"}
* payload: {"sub": "admin", "role": "ADMIN", "exp": 1234567890}
* signature: HMAC-SHA256(header + "." + payload, secret)
*/
@Component
public class JwtUtil {
private final SecretKey key;
private final long expiration;
public JwtUtil(@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration}") long expiration) {
// 确保密钥至少 256 位32 字节)
String paddedSecret = secret;
if (paddedSecret.length() < 32) {
paddedSecret = String.format("%-32s", secret).replace(' ', '0');
}
this.key = Keys.hmacShaKeyFor(paddedSecret.getBytes(StandardCharsets.UTF_8));
this.expiration = expiration;
}
/** 生成 Token */
public String generateToken(String username, String role) {
Date now = new Date();
return Jwts.builder()
.subject(username)
.claim("role", role)
.issuedAt(now)
.expiration(new Date(now.getTime() + expiration))
.signWith(key)
.compact();
}
/** 从 Token 中提取用户名 */
public String getUsername(String token) {
return parse(token).getSubject();
}
/** 从 Token 中提取角色 */
public String getRole(String token) {
return parse(token).get("role", String.class);
}
/** 验证 Token 是否有效 */
public boolean validateToken(String token) {
try {
parse(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
private Claims parse(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
}

View File

@@ -0,0 +1,70 @@
package com.learn.service;
import com.learn.dto.LoginRequest;
import com.learn.entity.User;
import com.learn.repository.jpa.UserRepository;
import com.learn.security.JwtUtil;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
/**
* 认证服务(第 2-3 天)
*
* 注册创建用户BCrypt 加密密码)
* 登录:验证密码 → 签发 JWT
*/
@Service
public class AuthService {
private final UserRepository userRepo;
private final PasswordEncoder encoder;
private final JwtUtil jwtUtil;
public AuthService(UserRepository userRepo, PasswordEncoder encoder, JwtUtil jwtUtil) {
this.userRepo = userRepo;
this.encoder = encoder;
this.jwtUtil = jwtUtil;
}
/** 注册(默认 USER 角色) */
public Map<String, Object> register(String username, String password) {
if (userRepo.existsByUsername(username)) {
throw new RuntimeException("用户名已存在");
}
User user = new User();
user.setUsername(username);
user.setPassword(encoder.encode(password)); // BCrypt 加密
user.setRole("USER");
userRepo.save(user);
String token = jwtUtil.generateToken(username, "USER");
Map<String, Object> result = new HashMap<>();
result.put("token", token);
result.put("role", "USER");
return result;
}
/** 登录 */
public Map<String, Object> login(LoginRequest req) {
User user = userRepo.findByUsername(req.getUsername())
.orElseThrow(() -> new RuntimeException("用户名或密码错误"));
if (!encoder.matches(req.getPassword(), user.getPassword())) {
throw new RuntimeException("用户名或密码错误");
}
if (user.getEnabled() == 0) {
throw new RuntimeException("账号已被禁用");
}
String token = jwtUtil.generateToken(user.getUsername(), user.getRole());
Map<String, Object> result = new HashMap<>();
result.put("token", token);
result.put("role", user.getRole());
result.put("username", user.getUsername());
return result;
}
}

View File

@@ -0,0 +1,49 @@
package com.learn.service;
import com.learn.entity.Student;
import com.learn.service.jpa.StudentJpaService;
import com.learn.service.mp.StudentMpService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class StudentServiceSelector {
private final StudentJpaService jpa;
private final StudentMpService mp;
@Value("${orm.mode:jpa}")
private String mode;
public StudentServiceSelector(StudentJpaService jpa, StudentMpService mp) {
this.jpa = jpa; this.mp = mp;
}
private boolean useJpa() { return !"mp".equalsIgnoreCase(mode); }
public Student add(Student s) { return useJpa() ? jpa.add(s) : mp.add(s); }
public List<Student> list() { return useJpa() ? jpa.list() : mp.list(); }
public Page<Student> listPage(int page, int size) {
return useJpa() ? jpa.listPage(page, size) : mp.listPage(page, size);
}
public Student getById(Long id) { return useJpa() ? jpa.getById(id) : mp.getById(id); }
public List<Student> search(String kw) { return useJpa() ? jpa.search(kw) : mp.search(kw); }
public Optional<Student> update(Long id, Student s) { return useJpa() ? jpa.update(id, s) : mp.update(id, s); }
public boolean delete(Long id) { return useJpa() ? jpa.delete(id) : mp.delete(id); }
public long count() { return useJpa() ? jpa.count() : mp.count(); }
public long countExcellent(int min) { return useJpa() ? jpa.countExcellent(min) : mp.countExcellent(min); }
public String getActiveMode() { return useJpa() ? "JPA" : "MyBatis-Plus"; }
}

View File

@@ -0,0 +1,67 @@
package com.learn.service.jpa;
import com.learn.entity.Student;
import com.learn.repository.jpa.StudentJpaRepository;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
public class StudentJpaService {
private final StudentJpaRepository repo;
public StudentJpaService(StudentJpaRepository repo) { this.repo = repo; }
@Transactional
@CacheEvict(value = "students", allEntries = true) // 新增时清空缓存
public Student add(Student s) { return repo.save(s); }
@Transactional(readOnly = true)
@Cacheable(value = "students", key = "'list'") // 缓存查询结果
public List<Student> list() { return repo.findAll(Sort.by(Sort.Direction.DESC, "score")); }
@Transactional(readOnly = true)
public Page<Student> listPage(int page, int size) {
return repo.findAll(PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "score")));
}
@Transactional(readOnly = true)
@Cacheable(value = "students", key = "#id")
public Student getById(Long id) { return repo.findById(id).orElse(null); }
@Transactional(readOnly = true)
public List<Student> search(String kw) { return repo.search(kw); }
@Transactional
@CacheEvict(value = "students", allEntries = true)
public Optional<Student> update(Long id, Student updated) {
return repo.findById(id).map(existing -> {
if (updated.getName() != null) existing.setName(updated.getName());
if (updated.getAge() > 0) existing.setAge(updated.getAge());
if (updated.getEmail() != null) existing.setEmail(updated.getEmail());
if (updated.getScore() >= 0) existing.setScore(updated.getScore());
return repo.save(existing);
});
}
@Transactional
@CacheEvict(value = "students", allEntries = true)
public boolean delete(Long id) {
if (repo.existsById(id)) { repo.deleteById(id); return true; }
return false;
}
@Transactional(readOnly = true)
public long count() { return repo.count(); }
@Transactional(readOnly = true)
public long countExcellent(int min) { return repo.excellent(min).size(); }
}

View File

@@ -0,0 +1,90 @@
package com.learn.service.mp;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.learn.entity.Student;
import com.learn.repository.mp.StudentMapper;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
public class StudentMpService {
private final StudentMapper mapper;
public StudentMpService(StudentMapper mapper) { this.mapper = mapper; }
@Transactional
@CacheEvict(value = "students", allEntries = true)
public Student add(Student s) { mapper.insert(s); return s; }
@Transactional(readOnly = true)
@Cacheable(value = "students", key = "'list'")
public List<Student> list() {
LambdaQueryWrapper<Student> w = new LambdaQueryWrapper<>();
w.orderByDesc(Student::getScore);
return mapper.selectList(w);
}
@Transactional(readOnly = true)
public org.springframework.data.domain.Page<Student> listPage(int page, int size) {
Page<Student> mpPage = new Page<>(page + 1, size); // MP 页码从 1 开始
LambdaQueryWrapper<Student> w = new LambdaQueryWrapper<>();
w.orderByDesc(Student::getScore);
Page<Student> result = mapper.selectPage(mpPage, w);
return new PageImpl<>(result.getRecords(),
PageRequest.of(page, size), result.getTotal());
}
@Transactional(readOnly = true)
@Cacheable(value = "students", key = "#id")
public Student getById(Long id) {
return mapper.selectById(id);
}
@Transactional(readOnly = true)
public List<Student> search(String kw) {
LambdaQueryWrapper<Student> w = new LambdaQueryWrapper<>();
w.like(Student::getName, kw).or().like(Student::getEmail, kw);
w.orderByDesc(Student::getScore);
return mapper.selectList(w);
}
@Transactional
@CacheEvict(value = "students", allEntries = true)
public Optional<Student> update(Long id, Student updated) {
Student existing = mapper.selectById(id);
if (existing == null) return Optional.empty();
if (updated.getName() != null) existing.setName(updated.getName());
if (updated.getAge() > 0) existing.setAge(updated.getAge());
if (updated.getEmail() != null) existing.setEmail(updated.getEmail());
if (updated.getScore() >= 0) existing.setScore(updated.getScore());
mapper.updateById(existing);
return Optional.of(existing);
}
@Transactional
@CacheEvict(value = "students", allEntries = true)
public boolean delete(Long id) {
return mapper.deleteById(id) > 0; // 逻辑删除MP 自动将 deleted 设为 1
}
@Transactional(readOnly = true)
public long count() {
return mapper.selectCount(null);
}
@Transactional(readOnly = true)
public long countExcellent(int min) {
LambdaQueryWrapper<Student> w = new LambdaQueryWrapper<>();
w.ge(Student::getScore, min);
return mapper.selectCount(w);
}
}

View File

@@ -0,0 +1,32 @@
# 集成测试专用配置Day 2H2 内存数据库)
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true # 测试时可访问 http://localhost:8080/h2-console
jpa:
hibernate:
ddl-auto: create-drop
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
# 测试环境禁用 Redis
cache:
type: none
data:
redis:
host: localhost
port: 6379
logging:
level:
com.learn: DEBUG
org.springframework.security: DEBUG

View File

@@ -0,0 +1,77 @@
spring:
application:
name: week8-engineering
datasource:
url: jdbc:mysql://localhost:3306/week5_student?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: 1365957941@Wfj
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: none
show-sql: true
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.MySQLDialect
# Redis第 4 天,本地未安装 Redis 时可注释掉)
data:
redis:
host: localhost
port: 6379
password: 1365957941@Wfj
timeout: 3000ms
cache:
type: redis
redis:
time-to-live: 60000 # 缓存有效期 60 秒
cache-null-values: false
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
global-config:
db-config:
id-type: auto
logic-delete-field: deleted # 逻辑删除字段(第 7 天)
logic-delete-value: 1 # 已删除 = 1
logic-not-delete-value: 0 # 未删除 = 0
# JWT 配置
jwt:
secret: week5-jwt-secret-key-for-student-management-system
expiration: 86400000 # 24 小时(毫秒)
# Actuator第 5 天)
management:
endpoints:
web:
exposure:
include: health,info,beans,env,mappings
endpoint:
health:
show-details: always
# ORM 模式
orm:
mode: jpa
logging:
level:
com.learn: DEBUG
server:
port: 8080
# Knife4j / SpringDoc API 文档Day 3
springdoc:
swagger-ui:
path: /doc.html # Knife4j 文档页面地址
tags-sorter: alpha # 按字母顺序排列标签
api-docs:
path: /v3/api-docs

View File

@@ -0,0 +1,149 @@
/* ========== 基础重置 ========== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh; padding: 20px; color: #333;
}
.container {
max-width: 1100px; margin: 0 auto;
}
/* ========== 头部 ========== */
header {
text-align: center; padding: 30px 20px 20px;
}
header h1 { font-size: 2em; color: #fff; margin-bottom: 6px; }
.subtitle { color: rgba(255,255,255,0.75); font-size: 0.95em; }
.main-header {
display: flex; justify-content: space-between; align-items: center;
padding: 20px; background: #fff; border-radius: 12px 12px 0 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.main-header h1 { font-size: 1.4em; color: #333; margin-bottom: 0; }
.main-header .subtitle { color: #999; margin-top: 2px; }
/* ========== 登录/注册卡片 ========== */
.login-card {
background: #fff; border-radius: 12px; padding: 36px 30px;
max-width: 420px; margin: 0 auto;
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
}
.login-card h2 { font-size: 1.3em; margin-bottom: 18px; color: #333; }
.hint { color: #999; font-size: 0.85em; margin-bottom: 14px; }
.hint.small { font-size: 0.8em; text-align: center; }
.hint a { color: #667eea; text-decoration: none; }
/* ========== 表单 ========== */
.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 0.85em; color: #666; margin-bottom: 4px; font-weight: 500; }
.form-group input, .form-group select {
width: 100%; padding: 10px 12px; border: 1.5px solid #e0e0e0;
border-radius: 8px; font-size: 0.95em; transition: border-color 0.2s;
}
.form-group input:focus {
outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.15);
}
.form-row { display: flex; gap: 12px; }
.form-row .form-group { flex: 1; }
/* ========== 按钮 ========== */
.btn {
display: inline-flex; align-items: center; justify-content: center;
padding: 10px 20px; border: none; border-radius: 8px; font-size: 0.93em;
cursor: pointer; transition: all 0.2s; text-decoration: none;
}
.btn-primary { background: #667eea; color: #fff; }
.btn-primary:hover { background: #5a6fd6; }
.btn-outline { background: #fff; color: #667eea; border: 1.5px solid #667eea; }
.btn-outline:hover { background: #f0f2ff; }
.btn-danger { background: #e74c3c; color: #fff; padding: 5px 12px; font-size: 0.82em; }
.btn-danger:hover { background: #c0392b; }
.btn-edit { background: #2ecc71; color: #fff; padding: 5px 12px; font-size: 0.82em; margin-right: 6px; }
.btn-edit:hover { background: #27ae60; }
.btn-sm { padding: 6px 14px; font-size: 0.82em; }
.btn-block { display: block; width: 100%; }
/* ========== 工具栏 ========== */
.toolbar {
background: #fff; padding: 14px 20px; display: flex; gap: 10px;
border-top: 1px solid #f0f0f0; align-items: center;
}
.toolbar input {
flex: 1; padding: 9px 12px; border: 1.5px solid #e0e0e0; border-radius: 8px;
font-size: 0.93em;
}
.toolbar input:focus { outline: none; border-color: #667eea; }
/* ========== 表格 ========== */
.table-wrap {
background: #fff; padding: 0 20px 20px; overflow-x: auto;
}
table { width: 100%; border-collapse: collapse; font-size: 0.93em; }
thead th {
text-align: left; padding: 12px 10px; border-bottom: 2px solid #667eea;
color: #667eea; font-weight: 600; white-space: nowrap;
}
tbody td {
padding: 12px 10px; border-bottom: 1px solid #f0f0f0;
}
tbody tr:hover { background: #f8f9ff; }
.loading, .empty { text-align: center; color: #999; padding: 40px 0 !important; }
/* ========== 成绩徽章 ========== */
.badge {
display: inline-block; padding: 3px 10px; border-radius: 12px;
font-size: 0.82em; font-weight: 600; color: #fff;
}
.badge-excellent { background: #2ecc71; }
.badge-good { background: #3498db; }
.badge-normal { background: #f39c12; }
.badge-poor { background: #e74c3c; }
/* ========== 统计卡片 ========== */
.stats {
background: #fff; padding: 16px 20px; border-radius: 0 0 12px 12px;
display: flex; gap: 14px; border-top: 1px solid #f0f0f0;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.stat-card {
flex: 1; text-align: center; padding: 14px 10px; border-radius: 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.stat-value { display: block; font-size: 1.6em; font-weight: 700; color: #fff; }
.stat-label { display: block; font-size: 0.78em; color: rgba(255,255,255,0.8); margin-top: 4px; }
/* ========== 错误消息 ========== */
.error-msg { color: #e74c3c; font-size: 0.85em; margin-bottom: 10px; min-height: 1.2em; }
.error-bar {
background: #fff3cd; color: #856404; padding: 10px 20px; border-left: 4px solid #ffc107;
font-size: 0.88em;
}
/* ========== 弹窗 ========== */
.modal-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.45); display: flex; justify-content: center;
align-items: center; z-index: 1000; opacity: 0; pointer-events: none;
transition: opacity 0.25s;
}
.modal-overlay.show { opacity: 1; pointer-events: auto; }
.modal {
background: #fff; border-radius: 12px; padding: 28px 30px; width: 90%; max-width: 480px;
box-shadow: 0 16px 48px rgba(0,0,0,0.2);
}
.modal h2 { margin-bottom: 18px; font-size: 1.2em; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
/* ========== 响应式 ========== */
@media (max-width: 600px) {
body { padding: 10px; }
.login-card { padding: 24px 18px; }
.form-row { flex-direction: column; gap: 0; }
.main-header { flex-direction: column; gap: 10px; }
.toolbar { flex-wrap: wrap; }
.stats { flex-direction: column; }
}

View File

@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>学生管理系统 v2</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<!-- 登录面板 -->
<div class="container" id="login-panel">
<header>
<h1>📚 学生管理系统 v2</h1>
<p class="subtitle">Spring Security + JWT + Redis</p>
</header>
<div class="login-card">
<h2>登录</h2>
<p class="hint">默认账号admin/123456 (管理员) 或 user/123456 (普通用户)</p>
<div class="form-group">
<input type="text" id="login-username" placeholder="用户名" autofocus>
</div>
<div class="form-group">
<input type="password" id="login-password" placeholder="密码">
</div>
<div class="error-msg" id="login-error"></div>
<button class="btn btn-primary btn-block" onclick="doLogin()">登录</button>
<p class="hint small" style="margin-top:12px">
没有账号?<a href="#" onclick="showRegister()">注册</a>
</p>
</div>
</div>
<!-- 注册面板 -->
<div class="container" id="register-panel" style="display:none">
<header><h1>📚 注册</h1></header>
<div class="login-card">
<h2>注册新账号</h2>
<div class="form-group"><input type="text" id="reg-username" placeholder="用户名"></div>
<div class="form-group"><input type="password" id="reg-password" placeholder="密码"></div>
<div class="error-msg" id="reg-error"></div>
<button class="btn btn-primary btn-block" onclick="doRegister()">注册</button>
<p class="hint small" style="margin-top:12px"><a href="#" onclick="showLogin()">返回登录</a></p>
</div>
</div>
<!-- 主面板 -->
<div class="container" id="main-panel" style="display:none">
<header class="main-header">
<div>
<h1>📚 学生管理系统 v2</h1>
<span id="user-info"></span>
</div>
<button class="btn btn-outline btn-sm" onclick="logout()">退出</button>
</header>
<div class="toolbar">
<input type="text" id="keyword" placeholder="搜索..." onkeyup="if(event.key==='Enter')search()">
<button class="btn btn-primary" id="btn-add" onclick="showForm(null)">+ 新增</button>
<button class="btn btn-outline" onclick="loadStudents()">刷新</button>
</div>
<div class="error-bar" id="error-bar" style="display:none"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th><th>姓名</th><th>年龄</th><th>邮箱</th><th>成绩</th><th>操作</th>
</tr>
</thead>
<tbody id="table-body"></tbody>
</table>
</div>
<div class="stats" id="stats-bar">
<div class="stat-card"><span class="stat-value" id="stat-total">-</span><span class="stat-label">总数</span></div>
<div class="stat-card"><span class="stat-value" id="stat-excellent">-</span><span class="stat-label">优秀(≥85)</span></div>
<div class="stat-card"><span class="stat-value" id="stat-orm">-</span><span class="stat-label">ORM 引擎</span></div>
</div>
</div>
<!-- 弹窗 -->
<div class="modal-overlay" id="modal-overlay">
<div class="modal">
<h2 id="modal-title">新增学生</h2>
<form id="student-form" onsubmit="submitForm(event)">
<input type="hidden" id="form-id">
<div class="form-group"><label>姓名 *</label><input type="text" id="form-name" required maxlength="20"></div>
<div class="form-row">
<div class="form-group"><label>年龄 *</label><input type="number" id="form-age" required min="1" max="150"></div>
<div class="form-group"><label>成绩 *</label><input type="number" id="form-score" required min="0" max="100"></div>
</div>
<div class="form-group"><label>邮箱 *</label><input type="email" id="form-email" required></div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeModal()">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
<script src="js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,210 @@
const BASE = '/api';
let token = localStorage.getItem('wk5_token') || '';
let role = localStorage.getItem('wk5_role') || '';
document.addEventListener('DOMContentLoaded', () => {
if (token) { showMain(); loadStudents(); checkRoleUI(); }
});
// ==================== 认证 ====================
async function doLogin() {
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value.trim();
if (!username || !password) { showLoginError('请输入用户名和密码'); return; }
try {
const resp = await fetch(BASE + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const result = await resp.json();
if (resp.ok && result.code === 200) {
token = result.data.token;
role = result.data.role;
localStorage.setItem('wk5_token', token);
localStorage.setItem('wk5_role', role);
showMain(); loadStudents(); checkRoleUI();
} else {
showLoginError(result.message || '登录失败');
}
} catch (e) { showLoginError('连接失败: ' + e.message); }
}
async function doRegister() {
const username = document.getElementById('reg-username').value.trim();
const password = document.getElementById('reg-password').value.trim();
if (!username || !password) { document.getElementById('reg-error').textContent = '请输入用户名和密码'; return; }
try {
const resp = await fetch(BASE + '/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const result = await resp.json();
if (resp.ok && result.code === 200) {
token = result.data.token;
role = result.data.role;
localStorage.setItem('wk5_token', token);
localStorage.setItem('wk5_role', role);
showMain(); loadStudents(); checkRoleUI();
} else {
document.getElementById('reg-error').textContent = result.message || '注册失败';
}
} catch (e) { document.getElementById('reg-error').textContent = '连接失败'; }
}
function showLoginError(msg) { document.getElementById('login-error').textContent = msg; }
function showLogin() {
document.getElementById('login-panel').style.display = 'block';
document.getElementById('register-panel').style.display = 'none';
document.getElementById('main-panel').style.display = 'none';
}
function showRegister() {
document.getElementById('login-panel').style.display = 'none';
document.getElementById('register-panel').style.display = 'block';
}
function showMain() {
document.getElementById('login-panel').style.display = 'none';
document.getElementById('register-panel').style.display = 'none';
document.getElementById('main-panel').style.display = 'block';
}
function logout() {
localStorage.removeItem('wk5_token'); localStorage.removeItem('wk5_role');
token = ''; role = ''; showLogin();
}
/** 根据角色显示/隐藏操作按钮 */
function checkRoleUI() {
document.getElementById('user-info').textContent = '当前: ' + (role || '-') + ' | ';
document.getElementById('btn-add').style.display = (role === 'ADMIN') ? '' : 'none';
}
// ==================== 请求封装 ====================
async function api(url, options) {
options = options || {};
options.headers = options.headers || {};
options.headers['Authorization'] = 'Bearer ' + token;
const resp = await fetch(url, options);
const result = await resp.json();
if (resp.status === 403) {
showError('权限不足:需要 ADMIN 角色'); return null;
}
if ((resp.status === 401 || resp.status === 403) && result.code !== 200) {
logout(); showError('认证失败,请重新登录'); return null;
}
if (result.extra && result.extra.orm) {
document.getElementById('stat-orm').textContent = result.extra.orm;
}
return result;
}
function showError(msg) {
const bar = document.getElementById('error-bar');
bar.textContent = '⚠ ' + msg; bar.style.display = 'block';
setTimeout(() => bar.style.display = 'none', 4000);
}
// ==================== 数据 ====================
async function loadStudents(keyword) {
const tbody = document.getElementById('table-body');
tbody.innerHTML = '<tr><td colspan="6" class="loading">加载中...</td></tr>';
let url = BASE + '/students';
if (keyword) url += '?keyword=' + encodeURIComponent(keyword);
const result = await api(url);
if (!result) { tbody.innerHTML = '<tr><td colspan="6" class="empty">加载失败</td></tr>'; return; }
const students = result.data;
if (!students || students.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty">暂无数据</td></tr>';
} else {
tbody.innerHTML = students.map(s => {
let cls = 'badge-poor';
if (s.score >= 90) cls = 'badge-excellent';
else if (s.score >= 80) cls = 'badge-good';
else if (s.score >= 60) cls = 'badge-normal';
const actions = role === 'ADMIN'
? `<button class="btn-edit" onclick="showForm(${s.id})">编辑</button>
<button class="btn-danger" onclick="deleteStudent(${s.id})">删除</button>`
: '<span style="color:#ccc">--</span>';
return `<tr>
<td>${s.id}</td><td><strong>${escapeHtml(s.name)}</strong></td>
<td>${s.age}</td><td>${escapeHtml(s.email)}</td>
<td><span class="badge ${cls}">${s.score}</span></td>
<td>${actions}</td></tr>`;
}).join('');
}
document.getElementById('stat-total').textContent = result.extra?.total ?? students.length;
const statResp = await api(BASE + '/students/stats/excellent?min=85');
if (statResp) document.getElementById('stat-excellent').textContent = statResp.data;
}
function search() { loadStudents(document.getElementById('keyword').value.trim() || undefined); }
// ==================== 表单 ====================
async function showForm(id) {
document.getElementById('form-id').value = ''; document.getElementById('student-form').reset();
if (id) {
document.getElementById('modal-title').textContent = '编辑学生';
const result = await api(BASE + '/students/' + id);
if (!result) return;
const s = result.data;
document.getElementById('form-id').value = s.id;
document.getElementById('form-name').value = s.name;
document.getElementById('form-age').value = s.age;
document.getElementById('form-email').value = s.email;
document.getElementById('form-score').value = s.score;
} else {
document.getElementById('modal-title').textContent = '新增学生';
}
document.getElementById('modal-overlay').classList.add('show');
}
async function submitForm(e) {
e.preventDefault();
const id = document.getElementById('form-id').value;
const body = {
name: document.getElementById('form-name').value.trim(),
age: parseInt(document.getElementById('form-age').value),
email: document.getElementById('form-email').value.trim(),
score: parseInt(document.getElementById('form-score').value)
};
let result;
if (id) {
result = await api(BASE + '/students/' + id, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
});
} else {
result = await api(BASE + '/students', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
});
}
if (result) { closeModal(); loadStudents(); }
}
async function deleteStudent(id) {
if (!confirm('确认删除MP 模式下为逻辑删除)')) return;
const result = await api(BASE + '/students/' + id, { method: 'DELETE' });
if (result) loadStudents();
}
function closeModal() { document.getElementById('modal-overlay').classList.remove('show'); }
document.addEventListener('click', e => { if (e.target.id === 'modal-overlay') closeModal(); });
document.addEventListener('keydown', e => {
if (e.key === 'Enter' && document.getElementById('login-panel').style.display !== 'none') doLogin();
});
function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }

View File

@@ -0,0 +1,72 @@
// Day 2: @SpringBootTest 集成测试(使用 H2 内存数据库)
package com.learn.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.learn.entity.Student;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class StudentControllerTest {
@Autowired private MockMvc mvc;
@Autowired private ObjectMapper mapper;
private static String adminToken;
@Test @Order(1)
@DisplayName("admin 登录获取 Token")
void adminLogin() throws Exception {
// DataInitializer 预置了 admin/123456 (ADMIN) 和 user/123456 (USER)
String loginJson = "{\"username\":\"admin\",\"password\":\"123456\"}";
String resp = mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON).content(loginJson))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.token").exists())
.andReturn().getResponse().getContentAsString();
adminToken = mapper.readTree(resp).get("data").get("token").asText();
assertNotNull(adminToken);
}
@Test @Order(2)
@DisplayName("新增学生 → 返回 201")
void addStudent() throws Exception {
Student s = new Student(null, "测试学生", 20, "test@mail.com", 88);
mvc.perform(post("/api/students")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(s)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.name").value("测试学生"));
}
@Test @Order(3)
@DisplayName("查询列表 → 返回学生数组")
void listStudents() throws Exception {
mvc.perform(get("/api/students")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.extra.total").isNumber());
}
@Test @Order(4)
@DisplayName("未登录访问 → 返回 403无状态模式下拒绝匿名请求")
void unauthenticated() throws Exception {
mvc.perform(get("/api/students"))
.andExpect(status().isForbidden());
}
}

View File

@@ -0,0 +1,87 @@
// Day 1: JUnit 5 + Mockito 单元测试
package com.learn.service;
import com.learn.dto.LoginRequest;
import com.learn.entity.User;
import com.learn.repository.jpa.UserRepository;
import com.learn.security.JwtUtil;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class AuthServiceTest {
@Mock UserRepository userRepo;
@Mock PasswordEncoder encoder;
@Mock JwtUtil jwtUtil;
@InjectMocks AuthService authService;
@Test @DisplayName("登录成功")
void loginSuccess() {
User user = new User();
user.setUsername("admin"); user.setPassword("$2a$encoded"); user.setRole("ADMIN");
user.setEnabled(1);
when(userRepo.findByUsername("admin")).thenReturn(Optional.of(user));
when(encoder.matches("123456", "$2a$encoded")).thenReturn(true);
when(jwtUtil.generateToken("admin", "ADMIN")).thenReturn("jwt.token.here");
LoginRequest req = new LoginRequest();
req.setUsername("admin"); req.setPassword("123456");
Map<String, Object> result = authService.login(req);
assertEquals("jwt.token.here", result.get("token"));
assertEquals("ADMIN", result.get("role"));
}
@Test @DisplayName("登录失败 — 用户名不存在")
void loginUserNotFound() {
when(userRepo.findByUsername("nobody")).thenReturn(Optional.empty());
LoginRequest req = new LoginRequest();
req.setUsername("nobody"); req.setPassword("123456");
assertThrows(RuntimeException.class, () -> authService.login(req));
}
@Test @DisplayName("登录失败 — 密码错误")
void loginBadPassword() {
User user = new User();
user.setUsername("admin"); user.setPassword("$2a$encoded"); user.setRole("ADMIN");
user.setEnabled(1);
when(userRepo.findByUsername("admin")).thenReturn(Optional.of(user));
when(encoder.matches("wrong", "$2a$encoded")).thenReturn(false);
LoginRequest req = new LoginRequest();
req.setUsername("admin"); req.setPassword("wrong");
assertThrows(RuntimeException.class, () -> authService.login(req));
}
@Test @DisplayName("注册 — 用户名已存在则抛异常")
void registerDuplicate() {
when(userRepo.existsByUsername("admin")).thenReturn(true);
assertThrows(RuntimeException.class, () -> authService.register("admin", "123456"));
}
@Test @DisplayName("注册 — 成功创建新用户")
void registerSuccess() {
when(userRepo.existsByUsername("newuser")).thenReturn(false);
when(encoder.encode("123456")).thenReturn("$2a$encoded");
when(jwtUtil.generateToken("newuser", "USER")).thenReturn("mock.token");
Map<String, Object> result = authService.register("newuser", "123456");
assertEquals("mock.token", result.get("token"));
verify(userRepo).save(any(User.class));
}
}

184
week8/教案.md Normal file
View File

@@ -0,0 +1,184 @@
# Week 8工程化能力
**目标**:掌握测试、文档、部署等工程化技能,交付可测试、有文档、容器化的完整项目。
**前置**:完成 Week 7前后端分离项目
**本周产出**:学生管理系统 v4 — 有单元测试、集成测试、API 文档。
**启动方式**
```bash
cd week8
# 运行测试
mvn test
# 启动应用后访问 API 文档
http://localhost:8080/doc.html
```
---
## Day 1JUnit 5 + Mockito 单元测试
**知识点**
- 测试金字塔:单元测试 → 集成测试 → E2E 测试
- JUnit 5 注解:`@Test``@BeforeEach``@DisplayName`
- 断言:`assertEquals``assertTrue``assertThrows`
- Mockito`@Mock`(模拟依赖)、`@InjectMocks`(注入模拟对象)
- `when(...).thenReturn(...)`:控制 Mock 行为
- `verify()`:验证方法被调用
- `@ExtendWith(MockitoExtension.class)`:初始化 Mockito
- 测试覆盖率:行覆盖 vs 分支覆盖
**动手 → 理解**
1. 阅读 `AuthServiceTest.java`,理解每个 `@Mock` 的含义
2. 运行 `mvn test -Dtest=AuthServiceTest`,观察 5 个测试全部通过
3. 故意把 `testLoginBadPassword` 的期望密码改为正确值,观察测试失败信息
4.`when(...)` 中把返回值改成 `false`,看哪个测试会失败
5. 写一个测试:验证 `register` 成功时 `userRepo.save()` 被调用了 1 次
**核心文件**
- `src/test/java/com/learn/service/AuthServiceTest.java` — 单元测试示例
**思考题**:为什么不直接测试 Service 的真实数据库操作,而是用 Mockito 模拟?
---
## Day 2@SpringBootTest 集成测试
**知识点**
- `@SpringBootTest`:启动完整 Spring 容器
- `@AutoConfigureMockMvc`:模拟 HTTP 请求
- `MockMvc``perform(get(...))``andExpect(status().isOk())``jsonPath()`
- H2 内存数据库:`jdbc:h2:mem:testdb` — 不依赖外部 MySQL
- `@ActiveProfiles("test")`:测试专用配置
- `application-test.yml`:测试环境的 H2 + 禁用 Redis
- `@TestMethodOrder` + `@Order`:控制测试顺序
**动手 → 理解**
1. 阅读 `StudentControllerTest.java`,理解测试流程
2. 运行 `mvn test`,观察 Spring Boot 启动日志中的 `Using profile: "test"`
3. 在测试日志中找到 H2 的 `HikariPool-1 - Starting...` 确认使用内存库
4. 访问 `http://localhost:8080/h2-console`(测试模式下),查看表结构
5. 添加一个测试:验证用 user/123456USER 角色)新增学生时返回 403
**核心文件**
- `src/test/java/com/learn/controller/StudentControllerTest.java` — 集成测试
- `src/main/resources/application-test.yml` — 测试环境配置
**思考题**H2 和 Testcontainers真实 MySQL 容器)各有什么优劣?什么时候该用 Testcontainers
---
## Day 3Knife4j API 文档
**知识点**
- 为什么需要 API 文档:前后端协作、接口调试、新人上手
- Swagger / OpenAPI 3.0 规范
- Knife4j国产增强版 Swagger UI界面更友好
- `@Tag``@Operation``@Parameter` 注解增强文档
- `application.yml` 配置:`springdoc.swagger-ui.path=/doc.html`
- `OpenAPI` Bean自定义文档标题、版本、描述
- 在线调试:在文档页面直接发送请求
**动手 → 理解**
1. 启动项目,访问 `http://localhost:8080/doc.html`
2. 点击 "Auth" 标签,展开 `/api/auth/login`,点击"发送"直接测试
3. 在文档右上角设置全局 Token`Bearer <你的JWT>`
4. 用文档页面的"调试"功能完成一次完整的增删改查
5. 在 Controller 方法上添加 `@Operation(summary = "...")` 注解,刷新文档查看效果
**核心文件**
- `config/Knife4jConfig.java` — API 文档配置
- `pom.xml``knife4j-openapi3-jakarta-spring-boot-starter`
- `application.yml``springdoc.*`
**思考题**API 文档应该暴露在生产环境吗?如果不应该,怎么关闭?
**生产环境安全提示**
```yaml
# 生产环境 application-prod.yml 中关闭文档
springdoc:
api-docs:
enabled: false # 关闭 /v3/api-docs
swagger-ui:
enabled: false # 关闭 /doc.html
```
或在 SecurityConfig 中按 Profile 条件放行:
```java
// 开发环境放行文档,生产环境不放行
@Profile("!prod")
```
**原则**API 文档暴露所有接口结构和参数,生产环境应关闭或加认证保护。
---
## Day 4阶段总结 & 里程碑项目 v4
**知识点**
- 回顾 Week 1-8 的技术栈全景图
- 理解"测试金字塔"在项目中的落地
- 对比 v1 → v4 四个版本的进化
**v1 → v4 进化史**
| 版本 | 周次 | 新增能力 |
|------|------|---------|
| v1 | Week 4 | Spring Boot + JPA/MP + 原生 JS 全栈 |
| v2 | Week 5 | Spring Security + JWT + Redis + Actuator |
| v3 | Week 7 | Vue 3 前后端分离 + Axios + 导航守卫 |
| v4 | Week 8 | 单元测试 + 集成测试 + API 文档 |
**Actuator 端点说明**Week 5 遗留问题):
- `/actuator` — 返回所有**可用端点列表**的 JSON这是正常的
- 真正的监控数据在这些端点:
- `/actuator/health` → 健康状态UP/DOWN
- `/actuator/beans` → Spring 容器中所有 Bean
- `/actuator/env` → 环境变量和配置
- `/actuator/mappings` → 所有 URL 映射
- **安全建议**`/actuator/env` 可能泄露数据库密码等敏感信息,生产环境不应暴露
**动手 → 理解**
1. 画出 v4 的完整架构图Vue → Axios → Spring Boot → JPA/MP → MySQL
2. 检查所有测试是否通过:`mvn test`
3. 检查 API 文档是否完整:`http://localhost:8080/doc.html`
4. 确认 `docker-compose up --build` 能成功启动
5. 补充 `.gitignore``README.md`
**最终项目文件清单**
```
week8/
├── pom.xml # 含 test + knife4j 依赖
├── sql/init.sql # 数据库初始化
├── src/main/
│ ├── java/com/learn/
│ │ ├── config/
│ │ │ ├── Knife4jConfig.java # Day 3: API 文档
│ │ │ └── SecurityConfig.java # 含 CORS
│ │ ├── controller/ # REST API
│ │ ├── entity/ # Student + User
│ │ └── service/ # 双 ORM 实现
│ └── resources/
│ ├── application.yml # 主配置 + springdoc
│ └── application-test.yml # Day 2: H2 测试配置
└── src/test/java/com/learn/
├── service/AuthServiceTest.java # Day 1: 单元测试
└── controller/StudentControllerTest.java # Day 2: 集成测试
```
---
## Week 8 总结
| 维度 | 工具 | 掌握程度 |
|------|------|---------|
| 单元测试 | JUnit 5 + Mockito | Service 层全 Mock 测试 |
| 集成测试 | @SpringBootTest + H2 | Controller 全流程测试 |
| API 文档 | Knife4j / SpringDoc | 自动生成 + 在线调试 |
**下一阶段Week 9-12 — AI Agent 开发**
- Week 9AI & LLM 基础,调用大模型 API
- Week 10Agent 架构 + Function Calling
- Week 11算法入门 + Agent 前端
- Week 12最终项目 —— AI Agent 网站