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

73
week3/pom.xml Normal file
View File

@@ -0,0 +1,73 @@
<?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>week3-orm</artifactId>
<version>1.0.0</version>
<name>Week 3: ORM 双轨 —— JPA + MyBatis-Plus</name>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.6</mybatis-plus.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Bean Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

48
week3/sql/init.sql Normal file
View File

@@ -0,0 +1,48 @@
-- =============================================
-- Week 3学生管理系统数据库初始化脚本
-- 使用方式:
-- 1. 打开 MySQL 客户端(命令行或 Navicat/DataGrip
-- 2. 复制本文件全部内容并执行
-- 3. 或mysql -u root -p < init.sql
-- =============================================
-- 创建数据库
CREATE DATABASE IF NOT EXISTS week3_student
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;
USE week3_student;
-- 创建学生表
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',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
INDEX idx_name (name),
INDEX idx_score (score)
) 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),
('钱十一', 19, 'qianshiyi@mail.com', 67),
('陈十二', 23, 'chenshier@mail.com', 88);
-- 验证数据
SELECT COUNT(*) AS total_students FROM student;
SELECT * FROM student ORDER BY id;

View File

@@ -0,0 +1,11 @@
package com.learn;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Week3Application {
public static void main(String[] args) {
SpringApplication.run(Week3Application.class, args);
}
}

View File

@@ -0,0 +1,39 @@
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;
/**
* MyBatis-Plus 配置类
*
* 主要配置两件事:
* 1. 分页插件 —— MP 的分页功能需要显式注册才能生效
* 2. 其他可选插件(乐观锁、防全表更新等,后续学习)
*/
@Configuration
public class MyBatisPlusConfig {
/**
* MP 拦截器链
*
* PaginationInnerInterceptor分页插件拦截 SQL 并自动追加 LIMIT 子句。
* 不需要你手动写 LIMITMP 会根据 Page 对象自动处理。
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页插件,指定数据库类型为 MySQL
PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL);
// 溢出处理:页码超过最大页时回到首页(默认 false会返回空
pagination.setOverflow(true);
// 单页最大限制(防止恶意请求一次性查百万条)
pagination.setMaxLimit(100L);
interceptor.addInnerInterceptor(pagination);
return interceptor;
}
}

View File

@@ -0,0 +1,133 @@
package com.learn.controller.jpa;
import com.learn.entity.Student;
import com.learn.service.jpa.StudentJpaService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* JPA 版本的学生管理 REST API
*
* 访问路径全部以 /api/jpa/students 开头,
* 与 MyBatis-Plus 版本(/api/mp/students区分方便对比测试。
*/
@RestController
@RequestMapping("/api/jpa/students")
public class StudentJpaController {
private final StudentJpaService service;
public StudentJpaController(StudentJpaService service) {
this.service = service;
}
/** 查询全部 / 搜索 */
@GetMapping
public ResponseEntity<Map<String, Object>> list(
@RequestParam(required = false) String keyword) {
java.util.List<Student> students;
if (keyword != null && !keyword.trim().isEmpty()) {
students = service.searchByKeyword(keyword);
} else {
students = service.list();
}
return ok(students);
}
/** 分页查询(第 5 天) */
@GetMapping("/page")
public ResponseEntity<Map<String, Object>> page(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "5") int pageSize) {
Page<Student> page = service.page(pageNum, pageSize);
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("total", page.getTotalElements());
result.put("pages", page.getTotalPages());
result.put("current", page.getNumber() + 1);
result.put("data", page.getContent());
return ResponseEntity.ok(result);
}
/** 根据 ID 查询 */
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> getById(@PathVariable Long id) {
return service.getById(id)
.map(s -> ok(s))
.orElse(notFound(id));
}
/** 新增 */
@PostMapping
public ResponseEntity<Map<String, Object>> add(@Valid @RequestBody Student student) {
Student saved = service.add(student);
Map<String, Object> result = new HashMap<>();
result.put("code", 201);
result.put("message", "添加成功");
result.put("data", saved);
return ResponseEntity.status(HttpStatus.CREATED).body(result);
}
/** 更新 */
@PutMapping("/{id}")
public ResponseEntity<Map<String, Object>> update(
@PathVariable Long id, @Valid @RequestBody Student student) {
return service.update(id, student)
.map(updated -> {
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("message", "更新成功");
result.put("data", updated);
return ResponseEntity.ok(result);
})
.orElse(notFound(id));
}
/** 删除 */
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
if (service.delete(id)) {
return ResponseEntity.ok(Map.of("code", 200, "message", "删除成功"));
}
return notFound(id);
}
/** 分数统计 */
@GetMapping("/stats")
public ResponseEntity<Map<String, Object>> stats(
@RequestParam(defaultValue = "80") int min,
@RequestParam(defaultValue = "100") int max) {
long count = service.countByScoreRange(min, max);
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("range", min + "-" + max);
result.put("count", count);
return ResponseEntity.ok(result);
}
// ==================== 工具方法 ====================
private ResponseEntity<Map<String, Object>> ok(Object data) {
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("data", data);
return ResponseEntity.ok(result);
}
private ResponseEntity<Map<String, Object>> notFound(Long id) {
Map<String, Object> error = new HashMap<>();
error.put("code", 404);
error.put("message", "学生不存在ID: " + id);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
}

View File

@@ -0,0 +1,142 @@
package com.learn.controller.mp;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.learn.entity.Student;
import com.learn.service.mp.StudentMpService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* MyBatis-Plus 版本的学生管理 REST API
*
* 访问路径以 /api/mp/students 开头。
* 你可以用 Postman 分别测试 /api/jpa/students 和 /api/mp/students
* 的同一操作,对比两者的响应和 SQL 输出。
*/
@RestController
@RequestMapping("/api/mp/students")
public class StudentMpController {
private final StudentMpService service;
public StudentMpController(StudentMpService service) {
this.service = service;
}
/** 查询全部 / 搜索 */
@GetMapping
public ResponseEntity<Map<String, Object>> list(
@RequestParam(required = false) String keyword) {
java.util.List<Student> students;
if (keyword != null && !keyword.trim().isEmpty()) {
students = service.searchByKeyword(keyword);
} else {
students = service.list();
}
return ok(students);
}
/** 分页查询(第 5 天) */
@GetMapping("/page")
public ResponseEntity<Map<String, Object>> page(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "5") int pageSize) {
IPage<Student> page = service.page(pageNum, pageSize);
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("total", page.getTotal());
result.put("pages", page.getPages());
result.put("current", page.getCurrent());
result.put("data", page.getRecords());
return ResponseEntity.ok(result);
}
/** 根据 ID 查询 */
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> getById(@PathVariable Long id) {
return service.getById(id)
.map(s -> ok(s))
.orElse(notFound(id));
}
/** 新增 */
@PostMapping
public ResponseEntity<Map<String, Object>> add(@Valid @RequestBody Student student) {
Student saved = service.add(student);
Map<String, Object> result = new HashMap<>();
result.put("code", 201);
result.put("message", "添加成功");
result.put("data", saved);
return ResponseEntity.status(HttpStatus.CREATED).body(result);
}
/** 更新 */
@PutMapping("/{id}")
public ResponseEntity<Map<String, Object>> update(
@PathVariable Long id, @Valid @RequestBody Student student) {
return service.update(id, student)
.map(updated -> {
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("message", "更新成功");
result.put("data", updated);
return ResponseEntity.ok(result);
})
.orElse(notFound(id));
}
/** 删除 */
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
if (service.delete(id)) {
return ResponseEntity.ok(Map.of("code", 200, "message", "删除成功"));
}
return notFound(id);
}
/** 分数统计(使用 LambdaQueryWrapper 的 between */
@GetMapping("/stats")
public ResponseEntity<Map<String, Object>> stats(
@RequestParam(defaultValue = "80") int min,
@RequestParam(defaultValue = "100") int max) {
long count = service.countByScoreRange(min, max);
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("range", min + "-" + max);
result.put("count", count);
return ResponseEntity.ok(result);
}
/** 高分学生(演示 LambdaQueryWrapper 的 ge + orderByDesc */
@GetMapping("/excellent")
public ResponseEntity<Map<String, Object>> excellent(
@RequestParam(defaultValue = "85") int threshold) {
java.util.List<Student> students = service.findExcellentStudents(threshold);
return ok(students);
}
// ==================== 工具方法 ====================
private ResponseEntity<Map<String, Object>> ok(Object data) {
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("data", data);
return ResponseEntity.ok(result);
}
private ResponseEntity<Map<String, Object>> notFound(Long id) {
Map<String, Object> error = new HashMap<>();
error.put("code", 404);
error.put("message", "学生不存在ID: " + id);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
}

View File

@@ -0,0 +1,107 @@
package com.learn.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.time.LocalDateTime;
/**
* 学生实体 —— 同时兼容 JPA 和 MyBatis-Plus
*
* 对比观察:
* @Entity (JPA) ↔ @TableName (MP) —— 声明这是一个表映射类
* @Table (JPA) ↔ @TableName (MP) —— 指定对应的表名
* @Id (JPA) ↔ @TableId (MP) —— 主键
* @GeneratedValue (JPA) ↔ @TableId(type=...) (MP) —— 主键生成策略
* @Column (JPA) ↔ @TableField (MP) —— 字段映射
*
* 注意JPA 和 MP 的注解可以共存于同一个实体类上,互不冲突。
* 因为它们分属不同的框架,各自读取各自的注解。
*/
@Entity
@Table(name = "student")
@TableName("student") // MP 注解:指定表名
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // JPA数据库自增
@TableId(type = IdType.AUTO) // MP数据库自增
private Long id;
@NotBlank(message = "姓名不能为空")
@Size(min = 1, max = 20, message = "姓名长度 1-20")
@Column(name = "name", length = 20, nullable = false)
@TableField("name") // MP 注解(当字段名=列名时可省略,此处显式写出便于学习)
private String name;
@Min(value = 1, message = "年龄必须大于 0")
@Max(value = 150, message = "年龄必须小于 150")
@Column(name = "age", nullable = false)
@TableField("age")
private int age;
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
@Column(name = "email", length = 50, nullable = false)
@TableField("email")
private String email;
@Min(value = 0, message = "成绩不能为负数")
@Max(value = 100, message = "成绩不能超过 100")
@Column(name = "score", nullable = false)
@TableField("score")
private int score;
// ---- 审计字段(由数据库自动管理)----
@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;
}
// ==================== Getter / Setter ====================
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 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; }
@Override
public String toString() {
return String.format("Student{id=%d, name='%s', age=%d, email='%s', score=%d}",
id, name, age, email, score);
}
}

View File

@@ -0,0 +1,36 @@
package com.learn.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
String errors = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.collect(Collectors.joining("; "));
Map<String, Object> body = new HashMap<>();
body.put("code", 400);
body.put("message", "参数校验失败: " + errors);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleAll(Exception ex) {
Map<String, Object> body = new HashMap<>();
body.put("code", 500);
body.put("message", "服务器内部错误: " + ex.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
}

View File

@@ -0,0 +1,55 @@
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;
/**
* JPA 数据访问层
*
* 继承 JpaRepository<实体类, 主键类型> 后,自动获得以下方法(零代码):
* save() —— 新增/更新
* findById() —— 按主键查询
* findAll() —— 查询全部
* deleteById() —— 按主键删除
* count() —— 计数
* existsById() —— 判断是否存在
*
* 还可以通过"方法命名规则"定义查询JPA 会自动生成 SQL
* findByName() → SELECT ... WHERE name = ?
* findByNameLike() → SELECT ... WHERE name LIKE ?
* findByScoreGreaterThan() → SELECT ... WHERE score > ?
*
* 复杂查询用 @Query 写 JPQL面向对象的 SQL
*/
@Repository
public interface StudentJpaRepository extends JpaRepository<Student, Long> {
// ---- 方法命名查询JPA 自动生成 SQL----
/** 根据姓名精确查找 */
List<Student> findByName(String name);
/** 根据姓名模糊查找(需要自己拼接 %*/
List<Student> findByNameContaining(String keyword);
/** 成绩大于指定值 */
List<Student> findByScoreGreaterThan(int score);
/** 成绩降序排列 */
List<Student> findAllByOrderByScoreDesc();
// ---- JPQL 自定义查询 ----
/** 模糊搜索:姓名或邮箱包含关键词 */
@Query("SELECT s FROM Student s WHERE s.name LIKE %:keyword% OR s.email LIKE %:keyword%")
List<Student> searchByKeyword(@Param("keyword") String keyword);
/** 统计某个分数段的人数 */
@Query("SELECT COUNT(s) FROM Student s WHERE s.score BETWEEN :min AND :max")
long countByScoreRange(@Param("min") int min, @Param("max") int max);
}

View File

@@ -0,0 +1,60 @@
package com.learn.repository.mp;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.learn.entity.Student;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* MyBatis-Plus 数据访问层
*
* 继承 BaseMapper<实体类> 后,自动获得以下方法(零代码):
* insert() —— 新增
* updateById() —— 按主键更新
* selectById() —— 按主键查询
* selectList() —— 条件查询
* deleteById() —— 按主键删除
* selectPage() —— 分页查询
*
* 对比 JPA
* JPA 用"方法命名"定义查询 → findByScoreGreaterThan()
* MP 用"Lambda 条件构造器"定义查询 → selectList(wrapper) 在 Service 层使用
* MP 也可以用 @Select 直接写 SQL原生 SQL灵活性最高
*
* @Mapper 注解MyBatis 识别这个接口并生成实现类
*/
@Mapper
public interface StudentMapper extends BaseMapper<Student> {
/**
* 模糊搜索:姓名或邮箱包含关键词
*
* 这是 MP 的原生 SQL 方式,类似于 MyBatis 的传统用法。
* 参数较多时可以用 @Param 指定名称。
*/
@Select("SELECT * FROM student WHERE name LIKE CONCAT('%', #{keyword}, '%') OR email LIKE CONCAT('%', #{keyword}, '%')")
List<Student> searchByKeyword(@Param("keyword") String keyword);
/**
* 成绩大于指定值,按成绩降序
*/
@Select("SELECT * FROM student WHERE score > #{minScore} ORDER BY score DESC")
List<Student> findByScoreGreaterThan(@Param("minScore") int minScore);
/**
* 统计分数段人数
*/
@Select("SELECT COUNT(*) FROM student WHERE score BETWEEN #{min} AND #{max}")
long countByScoreRange(@Param("min") int min, @Param("max") int max);
/**
* 分页查询(使用 MP 的分页插件 + 自定义 SQL
*/
@Select("SELECT * FROM student WHERE name LIKE CONCAT('%', #{keyword}, '%')")
IPage<Student> searchByKeywordPage(Page<Student> page, @Param("keyword") String keyword);
}

View File

@@ -0,0 +1,101 @@
package com.learn.service.jpa;
import com.learn.entity.Student;
import com.learn.repository.jpa.StudentJpaRepository;
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;
/**
* JPA 版本的学生服务
*
* @Transactional声明式事务。方法执行过程中如果抛出 RuntimeException
* 所有数据库操作会自动回滚。保证数据一致性。
* readOnly=true 表示只读,性能更好(跳过脏检查)。
*/
@Service
public class StudentJpaService {
private final StudentJpaRepository repository;
public StudentJpaService(StudentJpaRepository repository) {
this.repository = repository;
}
// ==================== 增删改查 ====================
@Transactional
public Student add(Student student) {
return repository.save(student); // JPA: save() 同时用于新增和更新
}
@Transactional(readOnly = true)
public List<Student> list() {
return repository.findAll(Sort.by(Sort.Direction.DESC, "score"));
}
@Transactional(readOnly = true)
public Optional<Student> getById(Long id) {
return repository.findById(id);
}
@Transactional
public Optional<Student> update(Long id, Student updated) {
return repository.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());
// JPA 的 save: 有 ID 且存在则更新,无 ID 则新增
return repository.save(existing);
});
}
@Transactional
public boolean delete(Long id) {
if (repository.existsById(id)) {
repository.deleteById(id);
return true;
}
return false;
}
// ==================== 搜索 ====================
@Transactional(readOnly = true)
public List<Student> searchByKeyword(String keyword) {
return repository.searchByKeyword(keyword);
}
@Transactional(readOnly = true)
public List<Student> findByName(String name) {
return repository.findByNameContaining(name);
}
// ==================== 分页(第 5 天) ====================
@Transactional(readOnly = true)
public Page<Student> page(int pageNum, int pageSize) {
// JPA 分页PageRequest.of(页码从0开始, 每页条数, 排序)
PageRequest pageRequest = PageRequest.of(pageNum - 1, pageSize,
Sort.by(Sort.Direction.DESC, "score"));
return repository.findAll(pageRequest);
}
// ==================== 统计 ====================
@Transactional(readOnly = true)
public long count() {
return repository.count();
}
@Transactional(readOnly = true)
public long countByScoreRange(int min, int max) {
return repository.countByScoreRange(min, max);
}
}

View File

@@ -0,0 +1,131 @@
package com.learn.service.mp;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.learn.entity.Student;
import com.learn.repository.mp.StudentMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
/**
* MyBatis-Plus 版本的学生服务
*
* 重点对比与 JPA 版本的差异:
* 1. 新增MP 用 insert()JPA 用 save()
* 2. 查询MP 用 LambdaQueryWrapper 构建条件JPA 用方法命名或 JPQL
* 3. 分页MP 用 Page + IPageJPA 用 PageRequest + Page
* 4. 更新MP 用 updateById()JPA 用 save()
*
* 关键武器LambdaQueryWrapper
* 用它构建条件,不用担心列名写错(基于 Lambda 表达式,编译期安全)
* 例如wrapper.eq(Student::getName, "张三") → WHERE name = '张三'
*/
@Service
public class StudentMpService {
private final StudentMapper mapper;
public StudentMpService(StudentMapper mapper) {
this.mapper = mapper;
}
// ==================== 增删改查 ====================
@Transactional
public Student add(Student student) {
mapper.insert(student); // MP: insert() 只用于新增
return student; // insert 后 student.id 已被自动回填
}
@Transactional(readOnly = true)
public List<Student> list() {
// MP 条件构造器:空的 wrapper → 查全部
// orderByDesc 让结果按成绩降序
LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>();
wrapper.orderByDesc(Student::getScore);
return mapper.selectList(wrapper);
}
@Transactional(readOnly = true)
public Optional<Student> getById(Long id) {
return Optional.ofNullable(mapper.selectById(id));
}
@Transactional
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); // MP 用 updateById
return Optional.of(existing);
}
@Transactional
public boolean delete(Long id) {
return mapper.deleteById(id) > 0;
}
// ==================== 搜索 ====================
@Transactional(readOnly = true)
public List<Student> searchByKeyword(String keyword) {
return mapper.searchByKeyword(keyword);
}
@Transactional(readOnly = true)
public List<Student> findByName(String name) {
// MP 的 Lambda 方式构建条件
LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>();
wrapper.like(Student::getName, name); // WHERE name LIKE '%name%'
return mapper.selectList(wrapper);
}
// ==================== 分页(第 5 天) ====================
@Transactional(readOnly = true)
public IPage<Student> page(int pageNum, int pageSize) {
// MP 分页Page<Student>(页码, 每页条数)
Page<Student> page = new Page<>(pageNum, pageSize);
// 按成绩降序
LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>();
wrapper.orderByDesc(Student::getScore);
return mapper.selectPage(page, wrapper);
}
// ==================== 统计 ====================
@Transactional(readOnly = true)
public long count() {
return mapper.selectCount(null); // null = 无筛选条件
}
@Transactional(readOnly = true)
public long countByScoreRange(int min, int max) {
// MP 的 Lambda 方式构建范围条件
LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>();
wrapper.between(Student::getScore, min, max); // WHERE score BETWEEN min AND max
return mapper.selectCount(wrapper);
}
// ==================== 高级查询(演示 LambdaQueryWrapper 的威力) ====================
@Transactional(readOnly = true)
public List<Student> findExcellentStudents(int threshold) {
LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>();
wrapper.ge(Student::getScore, threshold) // WHERE score >= ?
.orderByDesc(Student::getScore);
return mapper.selectList(wrapper);
}
}

View File

@@ -0,0 +1,43 @@
# ==========================================
# Week 3 配置文件 —— JPA + MyBatis-Plus 双轨
# ==========================================
spring:
application:
name: week3-orm
# ---- 数据源配置(连接你本地的 MySQL----
datasource:
url: jdbc:mysql://localhost:3306/week3_student?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: 1365957941@Wfj
driver-class-name: com.mysql.cj.jdbc.Driver
# ---- JPA 配置 ----
jpa:
hibernate:
ddl-auto: none # 手动管理表结构SQL 脚本执行)
show-sql: true # 打印 JPA 生成的 SQL
properties:
hibernate:
format_sql: true # 格式化输出的 SQL
dialect: org.hibernate.dialect.MySQLDialect
# ---- MyBatis-Plus 配置 ----
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印 MP 生成的 SQL
map-underscore-to-camel-case: true # 下划线转驼峰create_time → createTime
global-config:
db-config:
id-type: auto # 主键自增
logic-delete-field: deleted # 逻辑删除字段(预习)
# ---- 日志 ----
logging:
level:
com.learn: DEBUG
# ---- 服务器端口 ----
server:
port: 8080

View File

@@ -0,0 +1,322 @@
/* ==========================================
* Week 3 学生管理系统 —— 样式表(第 7 天)
* 涵盖盒模型、Flexbox、选择器、伪类、过渡动画
* ========================================== */
/* ---- 基础重置 ---- */
*, *::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-color: #f0f2f5;
color: #333;
line-height: 1.6;
}
/* ---- 容器 ---- */
.container {
max-width: 1100px;
margin: 40px auto;
padding: 0 20px;
}
/* ---- 头部 ---- */
header {
text-align: center;
margin-bottom: 24px;
}
header h1 {
font-size: 28px;
color: #1a1a2e;
}
.subtitle {
color: #888;
font-size: 14px;
margin-top: 4px;
}
/* ---- Tab 切换栏 ---- */
.tab-bar {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.tab {
padding: 8px 24px;
border: 1px solid #d9d9d9;
background: #fff;
border-radius: 6px 6px 0 0;
cursor: pointer;
font-size: 14px;
color: #666;
transition: all 0.2s;
}
.tab:hover {
color: #1890ff;
border-color: #1890ff;
}
.tab.active {
background: #1890ff;
color: #fff;
border-color: #1890ff;
}
/* ---- 工具栏 ---- */
.toolbar {
display: flex;
gap: 12px;
margin-bottom: 16px;
align-items: center;
}
.toolbar input[type="text"] {
flex: 1;
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.toolbar input[type="text"]:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
/* ---- 按钮 ---- */
.btn {
padding: 8px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #1890ff;
color: #fff;
}
.btn-primary:hover {
background: #0070d2;
}
.btn-outline {
background: #fff;
color: #666;
border: 1px solid #d9d9d9;
}
.btn-outline:hover {
color: #1890ff;
border-color: #1890ff;
}
.btn-danger {
background: #ff4d4f;
color: #fff;
padding: 4px 12px;
font-size: 12px;
}
.btn-danger:hover {
background: #cf1322;
}
.btn-edit {
background: #52c41a;
color: #fff;
padding: 4px 12px;
font-size: 12px;
margin-right: 4px;
}
.btn-edit:hover {
background: #389e0d;
}
/* ---- 表格 ---- */
table {
width: 100%;
border-collapse: collapse;
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
thead {
background: #fafafa;
}
th {
padding: 12px 16px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: #888;
border-bottom: 2px solid #f0f0f0;
}
td {
padding: 10px 16px;
font-size: 14px;
border-bottom: 1px solid #f5f5f5;
}
tbody tr:hover {
background: #e6f7ff;
}
/* 成绩徽章 */
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
}
.badge-excellent { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
.badge-good { background: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; }
.badge-normal { background: #fffbe6; color: #faad14; border: 1px solid #ffe58f; }
.badge-poor { background: #fff2f0; color: #ff4d4f; border: 1px solid #ffccc7; }
/* ---- 空状态 ---- */
.empty {
text-align: center;
color: #bbb;
padding: 40px !important;
}
/* ---- 分页 ---- */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 16px;
}
.pagination button {
padding: 6px 14px;
border: 1px solid #d9d9d9;
background: #fff;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.pagination button:hover:not(:disabled) {
color: #1890ff;
border-color: #1890ff;
}
.pagination button:disabled {
color: #ccc;
cursor: not-allowed;
}
.pagination .page-info {
font-size: 13px;
color: #888;
}
/* ---- 统计卡片 ---- */
.stats {
display: flex;
gap: 16px;
margin-top: 24px;
}
.stat-card {
flex: 1;
background: #fff;
padding: 20px;
border-radius: 8px;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.stat-value {
display: block;
font-size: 32px;
font-weight: 700;
color: #1890ff;
}
.stat-label {
font-size: 13px;
color: #888;
margin-top: 4px;
}
/* ---- 模态弹窗 ---- */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.45);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-overlay.show {
display: flex;
}
.modal {
background: #fff;
border-radius: 12px;
padding: 28px;
width: 440px;
max-width: 90vw;
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
}
.modal h2 {
margin-bottom: 16px;
font-size: 18px;
}
.modal label {
display: block;
font-size: 13px;
color: #888;
margin-top: 12px;
margin-bottom: 4px;
}
.modal input {
width: 100%;
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
outline: none;
}
.modal input:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}

View File

@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>学生管理系统 —— Week 3 产出</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="container">
<!-- 头部 -->
<header>
<h1>📚 学生管理系统</h1>
<p class="subtitle">Spring Boot + JPA &amp; MyBatis-Plus 双引擎驱动</p>
</header>
<!-- 切换栏JPA / MP -->
<div class="tab-bar">
<button class="tab active" onclick="switchMode('jpa')">JPA 模式</button>
<button class="tab" onclick="switchMode('mp')">MyBatis-Plus 模式</button>
</div>
<!-- 工具栏 -->
<div class="toolbar">
<input type="text" id="keyword" placeholder="搜索姓名或邮箱..." oninput="search()">
<button class="btn btn-primary" onclick="showAddForm()">+ 新增学生</button>
<button class="btn btn-outline" onclick="loadStudents()">刷新</button>
</div>
<!-- 数据表格 -->
<table>
<thead>
<tr>
<th>ID</th>
<th>姓名</th>
<th>年龄</th>
<th>邮箱</th>
<th>成绩</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="table-body">
<tr><td colspan="7" class="empty">加载中...</td></tr>
</tbody>
</table>
<!-- 分页 -->
<div class="pagination" id="pagination"></div>
<!-- 统计卡片 -->
<div class="stats">
<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>
</div>
<!-- 新增/编辑弹窗 -->
<div class="modal-overlay" id="modal-overlay" onclick="closeModal(event)">
<div class="modal">
<h2 id="modal-title">新增学生</h2>
<form id="student-form" onsubmit="submitForm(event)">
<input type="hidden" id="form-id">
<label>姓名 *</label>
<input type="text" id="form-name" required maxlength="20" placeholder="请输入姓名">
<label>年龄 *</label>
<input type="number" id="form-age" required min="1" max="150" placeholder="1-150">
<label>邮箱 *</label>
<input type="email" id="form-email" required placeholder="example@mail.com">
<label>成绩 *</label>
<input type="number" id="form-score" required min="0" max="100" placeholder="0-100">
<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,253 @@
/**
* 学生管理系统 —— 前端交互逻辑(第 6 天)
*
* 核心知识点:
* fetch() —— 调用后端 API
* DOM 操作 —— 动态渲染页面
* addEventListener —— 事件绑定
* JSON 序列化 —— 前后端数据交换格式
*/
let currentMode = 'jpa'; // 当前模式jpa 或 mp
let currentPage = 1;
const pageSize = 5;
// ==================== 初始化 ====================
document.addEventListener('DOMContentLoaded', () => {
loadStudents();
loadStats();
});
// ==================== 模式切换 ====================
function switchMode(mode) {
currentMode = mode;
currentPage = 1;
// 更新 Tab 样式
document.querySelectorAll('.tab').forEach((tab, i) => {
tab.classList.toggle('active', (i === 0 && mode === 'jpa') || (i === 1 && mode === 'mp'));
});
loadStudents();
loadStats();
}
// ==================== 数据加载 ====================
async function loadStudents(keyword) {
const tbody = document.getElementById('table-body');
tbody.innerHTML = '<tr><td colspan="7" class="empty">加载中...</td></tr>';
try {
const baseUrl = `/api/${currentMode}/students`;
let url;
if (keyword) {
url = `${baseUrl}?keyword=${encodeURIComponent(keyword)}`;
} else {
url = `${baseUrl}/page?pageNum=${currentPage}&pageSize=${pageSize}`;
}
const response = await fetch(url);
const result = await response.json();
if (result.code === 200) {
const students = result.data;
if (students.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty">暂无数据</td></tr>';
} else {
tbody.innerHTML = students.map(s => renderRow(s)).join('');
}
// 分页信息
if (result.total !== undefined) {
renderPagination(result.total, result.pages, result.current);
}
} else {
tbody.innerHTML = '<tr><td colspan="7" class="empty">加载失败</td></tr>';
}
} catch (error) {
tbody.innerHTML = '<tr><td colspan="7" class="empty">请求出错: ' + error.message + '</td></tr>';
}
}
function renderRow(student) {
const score = student.score;
let badgeClass = 'badge-poor';
if (score >= 90) badgeClass = 'badge-excellent';
else if (score >= 80) badgeClass = 'badge-good';
else if (score >= 60) badgeClass = 'badge-normal';
const time = student.createTime ? student.createTime.substring(0, 10) : '-';
return `
<tr>
<td>${student.id}</td>
<td><strong>${escapeHtml(student.name)}</strong></td>
<td>${student.age}</td>
<td>${escapeHtml(student.email)}</td>
<td><span class="badge ${badgeClass}">${score}</span></td>
<td>${time}</td>
<td>
<button class="btn-edit" onclick="showEditForm(${student.id})">编辑</button>
<button class="btn-danger" onclick="deleteStudent(${student.id})">删除</button>
</td>
</tr>`;
}
function renderPagination(total, pages, current) {
const container = document.getElementById('pagination');
if (!pages || pages <= 1) {
container.innerHTML = '';
return;
}
let html = '';
html += `<button onclick="goPage(${current - 1})" ${current <= 1 ? 'disabled' : ''}>上一页</button>`;
html += `<span class="page-info">第 ${current} / ${pages} 页(共 ${total} 条)</span>`;
html += `<button onclick="goPage(${current + 1})" ${current >= pages ? 'disabled' : ''}>下一页</button>`;
container.innerHTML = html;
}
function goPage(page) {
currentPage = page;
loadStudents();
}
function search() {
const keyword = document.getElementById('keyword').value;
currentPage = 1;
loadStudents(keyword || undefined);
}
// ==================== 统计 ====================
async function loadStats() {
try {
const baseUrl = `/api/${currentMode}/students`;
// 总数
const allResp = await fetch(baseUrl);
const allResult = await allResp.json();
document.getElementById('stat-total').textContent =
allResult.code === 200 ? (Array.isArray(allResult.data) ? allResult.data.length : '-') : '-';
// 优秀人数
const statsUrl = currentMode === 'jpa'
? `${baseUrl}/stats?min=85&max=100`
: `${baseUrl}/excellent?threshold=85`;
const statsResp = await fetch(statsUrl);
const statsResult = await statsResp.json();
document.getElementById('stat-excellent').textContent =
statsResult.code === 200 ? (statsResult.count ?? statsResult.data?.length ?? '-') : '-';
} catch (e) {
// 静默处理
}
}
// ==================== 新增 / 编辑 ====================
function showAddForm() {
document.getElementById('modal-title').textContent = '新增学生';
document.getElementById('form-id').value = '';
document.getElementById('form-name').value = '';
document.getElementById('form-age').value = '';
document.getElementById('form-email').value = '';
document.getElementById('form-score').value = '';
document.getElementById('modal-overlay').classList.add('show');
}
async function showEditForm(id) {
const response = await fetch(`/api/${currentMode}/students/${id}`);
const result = await response.json();
if (result.code !== 200) {
alert('获取学生信息失败');
return;
}
const s = result.data;
document.getElementById('modal-title').textContent = '编辑学生';
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;
document.getElementById('modal-overlay').classList.add('show');
}
async function submitForm(event) {
event.preventDefault();
const id = document.getElementById('form-id').value;
const student = {
name: document.getElementById('form-name').value,
age: parseInt(document.getElementById('form-age').value),
email: document.getElementById('form-email').value,
score: parseInt(document.getElementById('form-score').value)
};
const baseUrl = `/api/${currentMode}/students`;
let response;
if (id) {
// 更新
response = await fetch(`${baseUrl}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(student)
});
} else {
// 新增
response = await fetch(baseUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(student)
});
}
const result = await response.json();
if (result.code === 200 || result.code === 201) {
closeModal();
loadStudents();
loadStats();
} else {
alert('操作失败: ' + (result.message || '未知错误'));
}
}
// ==================== 删除 ====================
async function deleteStudent(id) {
if (!confirm('确认删除该学生?')) return;
const response = await fetch(`/api/${currentMode}/students/${id}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.code === 200) {
loadStudents();
loadStats();
} else {
alert('删除失败: ' + (result.message || '未知错误'));
}
}
// ==================== 弹窗控制 ====================
function closeModal(event) {
if (event && event.target !== document.getElementById('modal-overlay')) return;
document.getElementById('modal-overlay').classList.remove('show');
}
// ==================== 工具函数 ====================
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

473
week3/教案.md Normal file
View File

@@ -0,0 +1,473 @@
# 第三阶段教案ORM 双轨 —— JPA + MyBatis-Plus第 3 周)
> **学习周期**7 天
> **每日用时**2-3 小时
> **最终产出**:同时掌握 JPA 和 MyBatis-Plus 两种 ORM 框架,附带 HTML/CSS 前端页面
---
## 前置准备:初始化数据库
在开始之前,先执行 SQL 脚本创建数据库和表:
1. 打开 MySQL 客户端(命令行 / Navicat / DataGrip
2. 执行 `sql/init.sql` 的**全部内容**
3. 验证:`SELECT * FROM week3_student.student;` 应看到 10 条預置数据
4. 修改 `application.yml` 中的数据库密码为你自己的密码
---
## 项目导入
IDEAFile → Open → 选择 `week3/pom.xml` → Open as Project。
本项目同时引入 JPA 和 MyBatis-Plus 两套依赖,共用同一个 `Student` 实体类。
---
## 第 1 天MySQL 基础 + 表设计
### 为什么需要数据库?
第 2 周的通讯录用内存存储应用重启后数据就没了。数据库MySQL是持久化存储数据不会丢失。
### 核心概念
| 术语 | 解释 | 类比 |
|------|------|------|
| 数据库Database | 存放一组相关表的容器 | 一个 Excel 文件 |
| 表Table | 数据以行列存储 | Excel 中的一个 Sheet |
| 列Column/字段) | 表的一个属性 | Sheet 中的一列 |
| 行Row/记录) | 一条完整数据 | Sheet 中的一行 |
| 主键Primary Key | 唯一标识一行数据的列 | 身份证号 |
| 索引Index | 加速查询的数据结构 | 书的目录 |
### 阅读 init.sql
打开 `sql/init.sql`,逐行理解每条语句的作用:
```sql
-- 创建数据库(字符集 utf8mb4 支持中文和 emoji
CREATE DATABASE IF NOT EXISTS week3_student
DEFAULT CHARACTER SET utf8mb4;
-- 创建表
CREATE TABLE student (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
name VARCHAR(20) NOT NULL COMMENT '姓名',
...
PRIMARY KEY (id) -- 主键约束
) ENGINE=InnoDB; -- InnoDB 引擎支持事务
```
关键点:
- `AUTO_INCREMENT` —— 插入时不必指定 ID数据库自动递增
- `VARCHAR(20)` —— 可变长度字符串,最多 20 个字符
- `NOT NULL` —— 该列不能为空
- `DEFAULT` —— 未指定时的默认值
- `COMMENT` —— 列注释(给人看的,不影响数据库行为)
### 动手
1. 执行 `init.sql`,验证数据是否插入成功
2. 用命令行或 GUI 工具执行以下查询:
```sql
SELECT * FROM student WHERE score >= 85 ORDER BY score DESC;
SELECT COUNT(*) FROM student;
SELECT AVG(score) FROM student;
SELECT name, score FROM student WHERE name LIKE '%张%';
```
---
## 第 2 天Spring Data JPA —— 声明式数据访问
### JPA 是什么?
JPAJava Persistence API是 Java 官方的 ORM 标准Hibernate 是它的最流行实现。
**ORMObject-Relational Mapping**:把数据库的表映射为 Java 对象。让你用操作对象的方式操作数据库,不用手写 SQL。
```
数据库表student (id, name, age, email, score)
↕ JPA 映射
Java 类Student { id, name, age, email, score }
```
### JPA 的核心流程
```
StudentJpaRepository ← 继承 JpaRepository<Student, Long>
↓ 自动生成
SQL → 执行 → 结果映射为 Student 对象
```
打开以下文件,对照阅读:
#### 1. 实体:`entity/Student.java`
```java
@Entity // 标记为 JPA 实体
@Table(name = "student") // 映射到 student 表
public class Student {
@Id // 主键
@GeneratedValue(strategy = GenerationType.IDENTITY) // 自增
private Long id;
@Column(name = "name", length = 20) // 映射到 name 列
private String name;
// ...
}
```
**理解**`@Entity` + `@Table` 告诉 JPA"这个类对应哪张表"。`@Column` 告诉 JPA"这个属性对应哪个列"。有了这些注解JPA 就知道如何把查询结果自动填充为 Student 对象。
#### 2. 仓库:`repository/jpa/StudentJpaRepository.java`
```java
@Repository
public interface StudentJpaRepository extends JpaRepository<Student, Long> {
List<Student> findByName(String name); // ← 方法名即查询
List<Student> findByNameContaining(String keyword); // ← 自动模糊搜索
List<Student> findByScoreGreaterThan(int score); // ← 自动 > 条件
@Query("SELECT s FROM Student s WHERE s.name LIKE %:kw%") // ← 自定义 JPQL
List<Student> searchByKeyword(@Param("kw") String kw);
}
```
**理解**你只需要定义接口和方法名JPA 自动生成 SQL。这就是"声明式"的含义——声明你想查什么,框架负责怎么查。
#### 3. 服务:`service/jpa/StudentJpaService.java`
```java
@Transactional // 声明式事务:方法内所有 DB 操作在同一事务中
public Student add(Student student) {
return repository.save(student); // 一句代码完成 INSERT
}
```
### 动手
1. 启动项目,用 Postman 测试 JPA 接口:
```
GET /api/jpa/students —— 查全部
GET /api/jpa/students?keyword=张 —— 模糊搜索
GET /api/jpa/students/1 —— 查单个
POST /api/jpa/students —— 新增
PUT /api/jpa/students/1 —— 更新
DELETE /api/jpa/students/1 —— 删除
GET /api/jpa/students/page?pageNum=1&pageSize=3 —— 分页
GET /api/jpa/students/stats?min=80&max=100 —— 统计
```
2. 观察控制台输出的 JPA SQL`application.yml` 中 `show-sql: true`
---
## 第 3 天MyBatis-Plus —— 灵活的 SQL 利器
### MyBatis-Plus 是什么?
MyBatis 是半自动 ORM你需要自己写 SQL在 XML 或注解中但框架帮你做结果映射。MyBatis-Plus 在 MyBatis 基础上做了增强:常见 CRUD 不用写 SQL。
### JPA vs MyBatis-Plus 设计哲学
| | JPA | MyBatis-Plus |
|------|-----|-------|
| **设计理念** | 面向对象 → 自动生成 SQL | SQL 为核心 → 辅助生成 |
| **你控制什么** | 定义实体和方法名 | 直接写 SQL 或用 Lambda 构建条件 |
| **适用场景** | 常规 CRUD、表关联规范 | 复杂查询、报表、性能优化 |
### MP 的核心流程
```
StudentMapper ← 继承 BaseMapper<Student>
↓ LambdaQueryWrapper 构建条件 / @Select 写 SQL
SQL → 执行 → 结果映射为 Student 对象
```
打开以下文件,对照阅读:
#### 1. Mapper`repository/mp/StudentMapper.java`
```java
@Mapper
public interface StudentMapper extends BaseMapper<Student> {
// 方式一:@Select 直接写 SQL最灵活
@Select("SELECT * FROM student WHERE name LIKE CONCAT('%', #{kw}, '%')")
List<Student> searchByKeyword(@Param("kw") String kw);
// 方式二:自定义分页 + SQL
@Select("SELECT * FROM student WHERE name LIKE CONCAT('%', #{kw}, '%')")
IPage<Student> searchByKeywordPage(Page<Student> page, @Param("kw") String kw);
}
```
#### 2. 服务:`service/mp/StudentMpService.java`
**重点LambdaQueryWrapper** —— MP 的杀手级功能
```java
// 查全部、按成绩降序
LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>();
wrapper.orderByDesc(Student::getScore); // 基于 Lambda列名不会写错
mapper.selectList(wrapper);
// 模糊搜索 + 范围 + 排序(链式调用)
wrapper.like(Student::getName, keyword) // WHERE name LIKE '%keyword%'
.ge(Student::getScore, 60) // AND score >= 60
.orderByDesc(Student::getScore);
mapper.selectList(wrapper);
// 分页
Page<Student> page = new Page<>(1, 5); // 第 1 页,每页 5 条
mapper.selectPage(page, wrapper);
```
**为什么 Lambda 比字符串安全?**
```java
wrapper.eq("name", "张三"); // ❌ "name" 写错了编译期不报错
wrapper.eq(Student::getName, "张三"); // ✅ 写错了编译期直接报红
```
### 动手
1. 启动项目,用 Postman 测试 MP 接口(注意 `/api/mp/` 前缀):
```
GET /api/mp/students —— 查全部
GET /api/mp/students?keyword=张 —— 模糊搜索
GET /api/mp/students/1 —— 查单个
POST /api/mp/students —— 新增
PUT /api/mp/students/1 —— 更新
DELETE /api/mp/students/1 —— 删除
GET /api/mp/students/page?pageNum=1&pageSize=3 —— 分页
GET /api/mp/students/excellent?threshold=85 —— 高分学生
```
2. 观察控制台输出的 MP SQL 日志
3. 对比同一操作在 JPA 和 MP 下生成的 SQL
---
## 第 4 天JPA vs MyBatis-Plus 全面对比
### 同步测试
用 Postman 分别调同一个操作,对比差异:
| 操作 | JPA 接口 | MP 接口 | 观察点 |
|------|---------|---------|--------|
| 查全部 | `GET /api/jpa/students` | `GET /api/mp/students` | 返回格式是否一致? |
| 新增 | `POST /api/jpa/students` | `POST /api/mp/students` | 控制台 SQL 有何不同? |
| 分页 | `GET /api/jpa/students/page` | `GET /api/mp/students/page` | 分页字段名一样吗? |
### 对比总结(动手写)
| 维度 | Spring Data JPA | MyBatis-Plus |
|------|----------------|--------------|
| **风格** | 声明式,方法名即查询 | Lambda 条件构造器 + 原生 SQL |
| **简单 CRUD** | `JpaRepository` 已提供 | `BaseMapper` 已提供 |
| **复杂查询** | `@Query` 写 JPQL / 方法命名 | `@Select` 写原生 SQL / LambdaWrapper |
| **分页** | `PageRequest` + `findAll()` | `Page<T>` + `selectPage()` |
| **SQL 可见性** | 框架生成,不直观 | 控制台清晰输出,更透明 |
| **上手难度** | 需理解 JPA 规范 | 会 SQL 即可上手 |
### 今日任务
1. 完成上面的对比测试表格(填入你的观察)
2. 分别在两个 Controller 中新增一个接口:
- JPA 版:`GET /api/jpa/students/above/{score}` —— 查询大于指定分数的学生
- MP 版:`GET /api/mp/students/above/{score}` —— 同上
3. 观察两者生成的 SQL体会"声明式"和"手动式"的差异
---
## 第 5 天:分页与事务
### 分页对比
**JPA 分页**
```java
PageRequest pr = PageRequest.of(0, 5, Sort.by(Direction.DESC, "score"));
Page<Student> page = repository.findAll(pr);
// page.getContent() → 当前页数据
// page.getTotalElements() → 总记录数
// page.getTotalPages() → 总页数
```
**MP 分页**
```java
Page<Student> page = new Page<>(1, 5); // 页码从 1 开始
IPage<Student> result = mapper.selectPage(page, wrapper);
// result.getRecords() → 当前页数据
// result.getTotal() → 总记录数
// result.getPages() → 总页数
```
**差异**JPA 的 PageRequest 页码从 0 开始MP 从 1 开始。
### @Transactional 详解
```java
@Transactional
public Student add(Student student) {
repository.save(student); // 操作 1
// ... 如果这里抛异常,
// 操作 1 自动回滚,数据不会写入数据库
}
```
关键属性:
- `readOnly = true` —— 只读事务,性能更好(跳过脏检查)
- `rollbackFor` —— 指定哪些异常触发回滚(默认 RuntimeException 和 Error
### 今日任务
1. 故意在新增逻辑后 `throw new RuntimeException("模拟异常")`,验证数据是否回滚
2. 分别用 Postman 测试 JPA 和 MP 的分页接口,对比返回字段名
3. 在 MySQL 中直接插入一条数据,用 API 验证能否查到
---
## 第 6 天HTML 基础 —— 为后端配一个前端
### 核心概念
| 概念 | 解释 |
|------|------|
| **HTML** | 网页的骨架 —— 定义"有什么"(标题、表格、按钮) |
| **标签** | `<tag>` 开始,`</tag>` 结束,如 `<h1>标题</h1>` |
| **属性** | 标签的附加信息,如 `<input type="text" placeholder="搜索...">` |
| **表单** | `<form>` + `<input>`,用于收集用户输入 |
### 阅读 index.html
打开 `src/main/resources/static/index.html`,对照理解:
```
<!DOCTYPE html> → 声明文档类型
<html> → 根元素
<head> → 元数据(标题、样式引用)
<body> → 页面可见内容
<header> → 头部区域
<table> → 表格:<thead> 表头 + <tbody> 数据行
<form> → 表单:<input> 各种输入框
```
### 如何在浏览器中访问
Spring Boot 默认把 `src/main/resources/static/` 下的文件作为静态资源暴露。
启动项目后访问:**`http://localhost:8080/index.html`**
页面中的 JavaScript`js/app.js`)会调用 `fetch()` 访问后端 API 获取数据并渲染表格。
### 今日任务
1. 访问 `http://localhost:8080/index.html`,确认页面正常加载
2. 点击 "JPA 模式" / "MP 模式" Tab观察数据差异如果有的话
3. 尝试新增一个学生,查看是否成功
4. 在 `index.html` 中的表格表头增加一列"成绩排名"(暂时填 "-"
---
## 第 7 天CSS 基础 —— 让页面好看
### 核心概念
| 概念 | 解释 |
|------|------|
| **CSS** | 网页的皮肤 —— 定义"长什么样"(颜色、间距、布局) |
| **选择器** | 指定 CSS 样式作用于哪些元素(如 `.tab` 作用于 class="tab" 的元素) |
| **盒模型** | 每个元素 = content + padding + border + margin |
| **Flexbox** | 弹性布局,轻松实现水平排列和居中对齐 |
| **伪类** | 元素的状态,如 `:hover`(鼠标悬停)、`:focus`(获得焦点) |
### 阅读 style.css
打开 `src/main/resources/static/css/style.css`,找出以下模式:
```css
/* 类选择器 —— 作用于 class="xxx" 的元素 */
.toolbar {
display: flex; /* ← Flexbox 横向排列 */
gap: 12px; /* ← 子元素间距 */
}
/* 标签选择器 —— 作用于所有该标签 */
table {
border-collapse: collapse; /* ← 合并表格边框 */
}
/* 伪类 —— 鼠标悬停效果 */
.btn:hover {
background: #0070d2; /* ← 鼠标悬停时变色 */
}
/* 子元素选择器 —— 只作用于 table 下的 th */
thead th {
font-weight: 600;
}
```
### CSS 盒模型速查
```
┌──────── margin外边距元素与元素之间─────────┐
│ ┌─── border边框───┐ │
│ │ ┌── padding内边距──┐ │
│ │ │ content内容 │ │
│ │ └──────────────────────┘ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
```
### 今日任务
1. 在浏览器中打开 `index.html`F12 打开开发者工具
2. 用"元素选择器"工具点击表格某一行,查看它的 CSS 规则
3. 修改 `style.css`:把侧边栏主色 `#1890ff` 改成你喜欢的颜色
4. 给统计卡片增加一个"鼠标悬停时轻微放大"的动画效果:
```css
.stat-card:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
```
---
## 本周产出清单
```
✅ SQL 数据库表设计与创建student 表 + 10 条测试数据)
✅ JPA 完整 CRUD + 分页 + 模糊搜索 + 统计
✅ MyBatis-Plus 完整 CRUD + 分页 + Lambda 条件查询
✅ 同一实体类兼容两套 ORM 框架
✅ 对比测试笔记JPA vs MP 的 API 风格、SQL 差异、分页差异)
✅ @Transactional 事务验证
✅ HTML 前端页面(表格 + 表单 + 分页 + 统计数据)
✅ CSS 样式Flexbox 布局 + 颜色体系 + 响应式卡片)
✅ JavaScript fetch API 前后端联调
```
---
## 常见问题
| 问题 | 原因 | 解决 |
|------|------|------|
| 启动报 "Access denied for user" | MySQL 密码不对 | 修改 `application.yml` 中的 `password` |
| 启动报 "Unknown database" | 没执行 init.sql | 先在 MySQL 中执行 `sql/init.sql` |
| 启动报 "Table 'student' doesn't exist" | 执行 SQL 时没选对库 | 确保 `USE week3_student;` 后执行建表 |
| JPA 查询中文返回空 | 字符集问题 | 检查 URL 参数:`characterEncoding=utf-8` |
| 前端页面 404 | 静态资源路径不对 | 确认文件在 `src/main/resources/static/` 下 |
| 前端调接口跨域报错 | 前后端同源 | 本项目前后端同端口,不存在跨域问题 |
| MP 分页不生效 | 没配置分页插件 | 确认 `MyBatisPlusConfig` 中注册了 `PaginationInnerInterceptor` |
| JPA 的 show-sql 不打印 | 日志级别太高 | 确认 `logging.level.com.learn: DEBUG` |
---
> **本周核心**JPA 和 MP 不是二选一的对手,而是一个工具箱里的两把刀。简单 CRUD 用 JPA 省力,复杂 SQL 用 MP 掌控。把两个都学透,你就是 ORM 工具箱齐全的开发者。