Week 1-8: Spring Boot 学习计划完整项目
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
73
week3/pom.xml
Normal file
73
week3/pom.xml
Normal 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
48
week3/sql/init.sql
Normal 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;
|
||||
11
week3/src/main/java/com/learn/Week3Application.java
Normal file
11
week3/src/main/java/com/learn/Week3Application.java
Normal 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);
|
||||
}
|
||||
}
|
||||
39
week3/src/main/java/com/learn/config/MyBatisPlusConfig.java
Normal file
39
week3/src/main/java/com/learn/config/MyBatisPlusConfig.java
Normal 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 子句。
|
||||
* 不需要你手动写 LIMIT,MP 会根据 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
107
week3/src/main/java/com/learn/entity/Student.java
Normal file
107
week3/src/main/java/com/learn/entity/Student.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
101
week3/src/main/java/com/learn/service/jpa/StudentJpaService.java
Normal file
101
week3/src/main/java/com/learn/service/jpa/StudentJpaService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
131
week3/src/main/java/com/learn/service/mp/StudentMpService.java
Normal file
131
week3/src/main/java/com/learn/service/mp/StudentMpService.java
Normal 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 + IPage,JPA 用 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);
|
||||
}
|
||||
}
|
||||
43
week3/src/main/resources/application.yml
Normal file
43
week3/src/main/resources/application.yml
Normal 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
|
||||
322
week3/src/main/resources/static/css/style.css
Normal file
322
week3/src/main/resources/static/css/style.css
Normal 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;
|
||||
}
|
||||
94
week3/src/main/resources/static/index.html
Normal file
94
week3/src/main/resources/static/index.html
Normal 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 & 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>
|
||||
253
week3/src/main/resources/static/js/app.js
Normal file
253
week3/src/main/resources/static/js/app.js
Normal 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
473
week3/教案.md
Normal 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` 中的数据库密码为你自己的密码
|
||||
|
||||
---
|
||||
|
||||
## 项目导入
|
||||
|
||||
IDEA:File → 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 是什么?
|
||||
|
||||
JPA(Java Persistence API)是 Java 官方的 ORM 标准,Hibernate 是它的最流行实现。
|
||||
|
||||
**ORM(Object-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 工具箱齐全的开发者。
|
||||
Reference in New Issue
Block a user