Week 1-8: Spring Boot 学习计划完整项目
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
63
week4/pom.xml
Normal file
63
week4/pom.xml
Normal file
@@ -0,0 +1,63 @@
|
||||
<?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>week4-student-system</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>Week 4: 学生管理系统 v1</name>
|
||||
<description>全栈学生管理系统 —— Spring Boot + JPA/MP 双轨 + 原生 JS 前端 + 文件上传</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<mybatis-plus.version>3.5.6</mybatis-plus.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>${mybatis-plus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
37
week4/sql/init.sql
Normal file
37
week4/sql/init.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- =============================================
|
||||
-- Week 4:学生管理系统 v1 数据库初始化
|
||||
-- =============================================
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS week4_student
|
||||
DEFAULT CHARACTER SET utf8mb4
|
||||
DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE week4_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',
|
||||
avatar VARCHAR(200) COMMENT '头像文件名',
|
||||
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, avatar) VALUES
|
||||
('张三', 20, 'zhangsan@mail.com', 85, NULL),
|
||||
('李四', 22, 'lisi@mail.com', 92, NULL),
|
||||
('王五', 19, 'wangwu@mail.com', 78, NULL),
|
||||
('赵六', 21, 'zhaoliu@mail.com', 88, NULL),
|
||||
('孙七', 23, 'sunqi@mail.com', 95, NULL),
|
||||
('周八', 20, 'zhouba@mail.com', 73, NULL),
|
||||
('吴九', 22, 'wujiu@mail.com', 81, NULL),
|
||||
('郑十', 21, 'zhengshi@mail.com',90, NULL);
|
||||
|
||||
SELECT COUNT(*) AS total_students FROM student;
|
||||
11
week4/src/main/java/com/learn/Week4Application.java
Normal file
11
week4/src/main/java/com/learn/Week4Application.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.learn;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class Week4Application {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(Week4Application.class, args);
|
||||
}
|
||||
}
|
||||
21
week4/src/main/java/com/learn/config/MyBatisPlusConfig.java
Normal file
21
week4/src/main/java/com/learn/config/MyBatisPlusConfig.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.learn.config;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class MyBatisPlusConfig {
|
||||
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
PaginationInnerInterceptor p = new PaginationInnerInterceptor(DbType.MYSQL);
|
||||
p.setOverflow(true);
|
||||
p.setMaxLimit(100L);
|
||||
interceptor.addInnerInterceptor(p);
|
||||
return interceptor;
|
||||
}
|
||||
}
|
||||
55
week4/src/main/java/com/learn/config/WebConfig.java
Normal file
55
week4/src/main/java/com/learn/config/WebConfig.java
Normal file
@@ -0,0 +1,55 @@
|
||||
package com.learn.config;
|
||||
|
||||
import com.learn.controller.AuthInterceptor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* 第 3、6 天:Web MVC 配置
|
||||
*
|
||||
* 三件事:
|
||||
* 1. 注册登录拦截器(第 3 天)
|
||||
* 2. 配置 CORS 跨域(第 6 天)—— 当前前后端同端口,预留给后续前后端分离
|
||||
* 3. 配置文件上传的静态资源映射(第 6 天)
|
||||
*/
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
private final AuthInterceptor authInterceptor;
|
||||
|
||||
@Value("${upload.path:./uploads}")
|
||||
private String uploadPath;
|
||||
|
||||
public WebConfig(AuthInterceptor authInterceptor) {
|
||||
this.authInterceptor = authInterceptor;
|
||||
}
|
||||
|
||||
/** 注册登录拦截器 */
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(authInterceptor)
|
||||
.addPathPatterns("/api/**") // 拦截所有 /api/ 请求
|
||||
.excludePathPatterns("/api/hello", "/api/login"); // 白名单
|
||||
}
|
||||
|
||||
/** CORS 跨域配置(第 6 天)—— 开放给所有来源(开发环境) */
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOriginPatterns("*")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true);
|
||||
}
|
||||
|
||||
/** 将上传目录映射为 URL 可访问的静态资源 */
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/uploads/**")
|
||||
.addResourceLocations("file:" + uploadPath + "/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.learn.controller;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
/**
|
||||
* 第 3 天:登录拦截器
|
||||
*
|
||||
* Spring MVC 拦截器原理:
|
||||
* 请求 → DispatcherServlet → [拦截器链 preHandle → Controller → postHandle] → 响应
|
||||
*
|
||||
* 这里实现一个简易的 Token 认证:请求头里必须带 Authorization: Bearer <token>
|
||||
* 白名单:/api/hello、/api/login 和静态资源不拦截
|
||||
*
|
||||
* 使用方式(Postman):Headers → Authorization → Bearer week4-secret-token
|
||||
* 使用方式(前端):fetch(url, { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
*/
|
||||
@Component
|
||||
public class AuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AuthInterceptor.class);
|
||||
|
||||
@Value("${auth.token:week4-secret-token}")
|
||||
private String validToken;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Object handler) throws Exception {
|
||||
String path = request.getRequestURI();
|
||||
|
||||
// 白名单:公开接口不拦截
|
||||
if (path.startsWith("/api/hello") || path.startsWith("/api/login")
|
||||
|| path.startsWith("/uploads") || path.contains(".")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// OPTIONS 预检请求放行(CORS 需要)
|
||||
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 校验 Authorization 头
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
log.warn("请求被拦截:缺少 Authorization 头,路径={}", path);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.setStatus(401);
|
||||
response.getWriter().write("{\"code\":401,\"message\":\"未认证,请提供有效的 Authorization 头\"}");
|
||||
return false; // 拦截
|
||||
}
|
||||
|
||||
String token = authHeader.substring(7).trim();
|
||||
if (!validToken.equals(token)) {
|
||||
log.warn("请求被拦截:Token 无效,路径={}", path);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.setStatus(403);
|
||||
response.getWriter().write("{\"code\":403,\"message\":\"Token 无效\"}");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true; // 放行
|
||||
}
|
||||
}
|
||||
159
week4/src/main/java/com/learn/controller/StudentController.java
Normal file
159
week4/src/main/java/com/learn/controller/StudentController.java
Normal file
@@ -0,0 +1,159 @@
|
||||
package com.learn.controller;
|
||||
|
||||
import com.learn.dto.ApiResponse;
|
||||
import com.learn.entity.Student;
|
||||
import com.learn.service.StudentServiceSelector;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 学生管理 REST API —— 统一接口(内部通过 orm.mode 配置切换 JPA/MP)
|
||||
*
|
||||
* RESTful 设计:
|
||||
* GET /api/students —— 查全部/搜索
|
||||
* GET /api/students/{id} —— 查单个
|
||||
* POST /api/students —— 新增
|
||||
* PUT /api/students/{id} —— 更新
|
||||
* DELETE /api/students/{id} —— 删除
|
||||
* POST /api/students/{id}/avatar —— 上传头像
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/students")
|
||||
public class StudentController {
|
||||
|
||||
private final StudentServiceSelector service;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Value("${upload.path:./uploads}")
|
||||
private String uploadPath;
|
||||
|
||||
public StudentController(StudentServiceSelector service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
// ---- 公开接口(白名单)----
|
||||
|
||||
@GetMapping("/hello")
|
||||
public ApiResponse<String> hello() {
|
||||
return ApiResponse.success("学生管理系统 v1 运行中 | 当前 ORM: " + service.getActiveMode());
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<Map<String, String>> login(
|
||||
@org.springframework.beans.factory.annotation.Value("${auth.token}") String token) {
|
||||
return ApiResponse.success(Map.of("token", token));
|
||||
}
|
||||
|
||||
// ---- 查询 ----
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<List<Student>> list(@RequestParam(required = false) String keyword) {
|
||||
List<Student> students;
|
||||
if (keyword != null && !keyword.trim().isEmpty()) {
|
||||
students = service.search(keyword);
|
||||
} else {
|
||||
students = service.list();
|
||||
}
|
||||
return ApiResponse.success(students)
|
||||
.put("total", students.size())
|
||||
.put("orm", service.getActiveMode());
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
public ApiResponse<?> page(
|
||||
@RequestParam(defaultValue = "1") int pageNum,
|
||||
@RequestParam(defaultValue = "5") int pageSize,
|
||||
@RequestParam(required = false) String keyword) {
|
||||
return ApiResponse.success(service.page(pageNum, pageSize, keyword))
|
||||
.put("orm", service.getActiveMode());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Student>> getById(@PathVariable Long id) {
|
||||
return service.getById(id)
|
||||
.map(s -> ResponseEntity.ok(ApiResponse.success(s)))
|
||||
.orElse(ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.notFound("学生不存在,ID: " + id)));
|
||||
}
|
||||
|
||||
@GetMapping("/stats/excellent")
|
||||
public ApiResponse<Long> statsExcellent(@RequestParam(defaultValue = "85") int min) {
|
||||
return ApiResponse.success(service.countExcellent(min));
|
||||
}
|
||||
|
||||
// ---- 新增 ----
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ApiResponse<Student>> add(@Valid @RequestBody Student student) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.created(service.add(student)));
|
||||
}
|
||||
|
||||
// ---- 更新 ----
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<Student>> update(
|
||||
@PathVariable Long id, @Valid @RequestBody Student student) {
|
||||
return service.update(id, student)
|
||||
.map(updated -> ResponseEntity.ok(
|
||||
ApiResponse.success("更新成功", updated)))
|
||||
.orElse(ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.notFound("学生不存在,ID: " + id)));
|
||||
}
|
||||
|
||||
// ---- 删除 ----
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<?>> delete(@PathVariable Long id) {
|
||||
return service.delete(id)
|
||||
? ResponseEntity.ok(ApiResponse.success("删除成功", null))
|
||||
: ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.notFound("学生不存在,ID: " + id));
|
||||
}
|
||||
|
||||
// ---- 头像上传(第 6 天)----
|
||||
|
||||
@PostMapping("/{id}/avatar")
|
||||
public ResponseEntity<ApiResponse<Student>> uploadAvatar(
|
||||
@PathVariable Long id,
|
||||
@RequestParam("file") MultipartFile file) {
|
||||
|
||||
// 校验
|
||||
if (file.isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.badRequest("文件不能为空"));
|
||||
}
|
||||
|
||||
String originalName = file.getOriginalFilename();
|
||||
if (originalName == null || !originalName.matches(".*\\.(jpg|jpeg|png|gif|webp)$")) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.badRequest("仅支持 jpg/png/gif/webp 格式的图片"));
|
||||
}
|
||||
|
||||
// 保存文件
|
||||
try {
|
||||
String ext = originalName.substring(originalName.lastIndexOf("."));
|
||||
String filename = UUID.randomUUID() + ext;
|
||||
|
||||
File dir = new File(uploadPath);
|
||||
if (!dir.exists()) dir.mkdirs();
|
||||
|
||||
file.transferTo(new File(dir, filename));
|
||||
|
||||
Student updated = service.updateAvatar(id, filename);
|
||||
return ResponseEntity.ok(ApiResponse.success("头像上传成功", updated));
|
||||
|
||||
} catch (IOException e) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error(500, "文件上传失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
78
week4/src/main/java/com/learn/dto/ApiResponse.java
Normal file
78
week4/src/main/java/com/learn/dto/ApiResponse.java
Normal file
@@ -0,0 +1,78 @@
|
||||
package com.learn.dto;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 统一 API 响应格式(第 5 天重点)
|
||||
*
|
||||
* 所有接口返回统一结构:
|
||||
* { "code": 200, "message": "ok", "data": {...} }
|
||||
*
|
||||
* 前端只需要关心 code 是否为 200,以及 data 里的内容。
|
||||
*/
|
||||
public class ApiResponse<T> {
|
||||
|
||||
private int code;
|
||||
private String message;
|
||||
private T data;
|
||||
private Map<String, Object> extra;
|
||||
|
||||
private ApiResponse() {}
|
||||
|
||||
// ---- 工厂方法 ----
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
ApiResponse<T> r = new ApiResponse<>();
|
||||
r.code = 200;
|
||||
r.message = "success";
|
||||
r.data = data;
|
||||
return r;
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(String message, T data) {
|
||||
ApiResponse<T> r = new ApiResponse<>();
|
||||
r.code = 200;
|
||||
r.message = message;
|
||||
r.data = data;
|
||||
return r;
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> created(T data) {
|
||||
ApiResponse<T> r = new ApiResponse<>();
|
||||
r.code = 201;
|
||||
r.message = "创建成功";
|
||||
r.data = data;
|
||||
return r;
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> error(int code, String message) {
|
||||
ApiResponse<T> r = new ApiResponse<>();
|
||||
r.code = code;
|
||||
r.message = message;
|
||||
r.data = null;
|
||||
return r;
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> notFound(String message) {
|
||||
return error(404, message);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> badRequest(String message) {
|
||||
return error(400, message);
|
||||
}
|
||||
|
||||
/** 附加额外字段(如分页信息) */
|
||||
public ApiResponse<T> put(String key, Object value) {
|
||||
if (extra == null) extra = new HashMap<>();
|
||||
extra.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ---- getter(序列化需要)----
|
||||
|
||||
public int getCode() { return code; }
|
||||
public String getMessage() { return message; }
|
||||
public T getData() { return data; }
|
||||
public Map<String, Object> getExtra() { return extra; }
|
||||
}
|
||||
82
week4/src/main/java/com/learn/entity/Student.java
Normal file
82
week4/src/main/java/com/learn/entity/Student.java
Normal file
@@ -0,0 +1,82 @@
|
||||
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;
|
||||
|
||||
@Entity
|
||||
@Table(name = "student")
|
||||
@TableName("student")
|
||||
public class Student {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@NotBlank(message = "姓名不能为空")
|
||||
@Size(min = 1, max = 20, message = "姓名长度 1-20")
|
||||
@Column(name = "name", length = 20, nullable = false)
|
||||
@TableField("name")
|
||||
private String name;
|
||||
|
||||
@Min(1) @Max(150)
|
||||
@Column(name = "age", nullable = false)
|
||||
@TableField("age")
|
||||
private int age;
|
||||
|
||||
@NotBlank(message = "邮箱不能为空")
|
||||
@Email(message = "邮箱格式不正确")
|
||||
@Column(name = "email", length = 50, nullable = false)
|
||||
@TableField("email")
|
||||
private String email;
|
||||
|
||||
@Min(0) @Max(100)
|
||||
@Column(name = "score", nullable = false)
|
||||
@TableField("score")
|
||||
private int score;
|
||||
|
||||
@Column(name = "avatar", length = 200)
|
||||
@TableField("avatar")
|
||||
private String avatar;
|
||||
|
||||
@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 String getAvatar() { return avatar; }
|
||||
public void setAvatar(String avatar) { this.avatar = avatar; }
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.learn.exception;
|
||||
|
||||
import com.learn.dto.ApiResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 第 4 天:全局异常处理
|
||||
*
|
||||
* 所有 Controller 抛出的异常都在这里被拦截,统一返回 ApiResponse 格式。
|
||||
* Controller 里不需要写 try-catch,专注业务逻辑。
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
/** 参数校验失败 (@Valid) */
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<?>> handleValidation(MethodArgumentNotValidException ex) {
|
||||
String detail = ex.getBindingResult().getFieldErrors().stream()
|
||||
.map(e -> e.getField() + ": " + e.getDefaultMessage())
|
||||
.collect(Collectors.joining("; "));
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.badRequest("参数校验失败: " + detail));
|
||||
}
|
||||
|
||||
/** 文件大小超限 */
|
||||
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||
public ResponseEntity<ApiResponse<?>> handleFileSize(MaxUploadSizeExceededException ex) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.badRequest("文件大小超过限制(最大 2MB)"));
|
||||
}
|
||||
|
||||
/** 找不到资源 */
|
||||
@ExceptionHandler(jakarta.persistence.EntityNotFoundException.class)
|
||||
public ResponseEntity<ApiResponse<?>> handleNotFound(jakarta.persistence.EntityNotFoundException ex) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(ApiResponse.notFound(ex.getMessage()));
|
||||
}
|
||||
|
||||
/** 兜底 */
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<?>> handleAll(Exception ex) {
|
||||
log.error("未处理异常", ex);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.error(500, "服务器内部错误: " + ex.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.learn.repository.jpa;
|
||||
|
||||
import com.learn.entity.Student;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface StudentJpaRepository extends JpaRepository<Student, Long> {
|
||||
|
||||
List<Student> findByNameContaining(String keyword);
|
||||
|
||||
@Query("SELECT s FROM Student s WHERE s.name LIKE %:kw% OR s.email LIKE %:kw%")
|
||||
List<Student> search(@Param("kw") String keyword);
|
||||
|
||||
@Query("SELECT s FROM Student s WHERE s.score >= :min ORDER BY s.score DESC")
|
||||
List<Student> excellent(@Param("min") int minScore);
|
||||
|
||||
Page<Student> findAll(Pageable pageable);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
|
||||
@Mapper
|
||||
public interface StudentMapper extends BaseMapper<Student> {
|
||||
|
||||
@Select("SELECT * FROM student WHERE name LIKE CONCAT('%', #{kw}, '%') OR email LIKE CONCAT('%', #{kw}, '%')")
|
||||
IPage<Student> search(Page<Student> page, @Param("kw") String keyword);
|
||||
|
||||
@Select("SELECT * FROM student WHERE score >= #{min} ORDER BY score DESC")
|
||||
IPage<Student> excellent(Page<Student> page, @Param("min") int minScore);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.learn.service;
|
||||
|
||||
import com.learn.entity.Student;
|
||||
import com.learn.service.jpa.StudentJpaService;
|
||||
import com.learn.service.mp.StudentMpService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* ORM 选择器 —— 根据配置 orm.mode 的值自动切换 JPA 或 MP
|
||||
*
|
||||
* 使用方式:
|
||||
* application.yml 中设置 orm.mode: jpa → 走 JPA
|
||||
* application.yml 中设置 orm.mode: mp → 走 MP
|
||||
*
|
||||
* Controller 只依赖这个选择器,不需要关心底层用的是 JPA 还是 MP。
|
||||
*/
|
||||
@Service
|
||||
public class StudentServiceSelector {
|
||||
|
||||
private final StudentJpaService jpa;
|
||||
private final StudentMpService mp;
|
||||
|
||||
@Value("${orm.mode:jpa}")
|
||||
private String mode;
|
||||
|
||||
public StudentServiceSelector(StudentJpaService jpa, StudentMpService mp) {
|
||||
this.jpa = jpa;
|
||||
this.mp = mp;
|
||||
}
|
||||
|
||||
private boolean useJpa() {
|
||||
return !"mp".equalsIgnoreCase(mode);
|
||||
}
|
||||
|
||||
public Student add(Student student) {
|
||||
return useJpa() ? jpa.add(student) : mp.add(student);
|
||||
}
|
||||
|
||||
public List<Student> list() {
|
||||
return useJpa() ? jpa.list() : mp.list();
|
||||
}
|
||||
|
||||
public Optional<Student> getById(Long id) {
|
||||
return useJpa() ? jpa.getById(id) : mp.getById(id);
|
||||
}
|
||||
|
||||
public List<Student> search(String keyword) {
|
||||
return useJpa() ? jpa.search(keyword) : mp.search(keyword);
|
||||
}
|
||||
|
||||
public Optional<Student> update(Long id, Student student) {
|
||||
return useJpa() ? jpa.update(id, student) : mp.update(id, student);
|
||||
}
|
||||
|
||||
public boolean delete(Long id) {
|
||||
return useJpa() ? jpa.delete(id) : mp.delete(id);
|
||||
}
|
||||
|
||||
public Object page(int pageNum, int pageSize, String keyword) {
|
||||
return useJpa() ? jpa.page(pageNum, pageSize, keyword)
|
||||
: mp.page(pageNum, pageSize, keyword);
|
||||
}
|
||||
|
||||
public long count() {
|
||||
return useJpa() ? jpa.count() : mp.count();
|
||||
}
|
||||
|
||||
public long countExcellent(int minScore) {
|
||||
return useJpa() ? jpa.countExcellent(minScore) : mp.countExcellent(minScore);
|
||||
}
|
||||
|
||||
public Student updateAvatar(Long id, String filename) {
|
||||
return useJpa() ? jpa.updateAvatar(id, filename) : mp.updateAvatar(id, filename);
|
||||
}
|
||||
|
||||
public String getActiveMode() {
|
||||
return useJpa() ? "JPA" : "MyBatis-Plus";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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;
|
||||
|
||||
@Service
|
||||
public class StudentJpaService {
|
||||
|
||||
private final StudentJpaRepository repo;
|
||||
|
||||
public StudentJpaService(StudentJpaRepository repo) { this.repo = repo; }
|
||||
|
||||
@Transactional
|
||||
public Student add(Student s) { return repo.save(s); }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Student> list() { return repo.findAll(Sort.by(Sort.Direction.DESC, "score")); }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Student> getById(Long id) { return repo.findById(id); }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Student> search(String keyword) {
|
||||
return repo.search(keyword);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Optional<Student> update(Long id, Student updated) {
|
||||
return repo.findById(id).map(existing -> {
|
||||
if (updated.getName() != null) existing.setName(updated.getName());
|
||||
if (updated.getAge() > 0) existing.setAge(updated.getAge());
|
||||
if (updated.getEmail() != null) existing.setEmail(updated.getEmail());
|
||||
if (updated.getScore() >= 0) existing.setScore(updated.getScore());
|
||||
return repo.save(existing);
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean delete(Long id) {
|
||||
if (repo.existsById(id)) { repo.deleteById(id); return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<Student> page(int pageNum, int pageSize, String keyword) {
|
||||
PageRequest pr = PageRequest.of(pageNum - 1, pageSize,
|
||||
Sort.by(Sort.Direction.DESC, "score"));
|
||||
if (keyword != null && !keyword.trim().isEmpty()) {
|
||||
return repo.search(keyword).isEmpty()
|
||||
? Page.empty()
|
||||
: new org.springframework.data.domain.PageImpl<>(
|
||||
repo.search(keyword), pr, repo.search(keyword).size());
|
||||
}
|
||||
return repo.findAll(pr);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long count() { return repo.count(); }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long countExcellent(int minScore) {
|
||||
return repo.excellent(minScore).size();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Student updateAvatar(Long id, String filename) {
|
||||
Student s = repo.findById(id).orElseThrow();
|
||||
s.setAvatar(filename);
|
||||
return repo.save(s);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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;
|
||||
|
||||
@Service
|
||||
public class StudentMpService {
|
||||
|
||||
private final StudentMapper mapper;
|
||||
|
||||
public StudentMpService(StudentMapper mapper) { this.mapper = mapper; }
|
||||
|
||||
@Transactional
|
||||
public Student add(Student s) { mapper.insert(s); return s; }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Student> list() {
|
||||
LambdaQueryWrapper<Student> w = new LambdaQueryWrapper<>();
|
||||
w.orderByDesc(Student::getScore);
|
||||
return mapper.selectList(w);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Student> getById(Long id) {
|
||||
return Optional.ofNullable(mapper.selectById(id));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Student> search(String keyword) {
|
||||
LambdaQueryWrapper<Student> w = new LambdaQueryWrapper<>();
|
||||
w.like(Student::getName, keyword).or().like(Student::getEmail, keyword);
|
||||
w.orderByDesc(Student::getScore);
|
||||
return mapper.selectList(w);
|
||||
}
|
||||
|
||||
@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);
|
||||
return Optional.of(existing);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean delete(Long id) { return mapper.deleteById(id) > 0; }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public IPage<Student> page(int pageNum, int pageSize, String keyword) {
|
||||
Page<Student> page = new Page<>(pageNum, pageSize);
|
||||
LambdaQueryWrapper<Student> w = new LambdaQueryWrapper<>();
|
||||
if (keyword != null && !keyword.trim().isEmpty()) {
|
||||
w.like(Student::getName, keyword).or().like(Student::getEmail, keyword);
|
||||
}
|
||||
w.orderByDesc(Student::getScore);
|
||||
return mapper.selectPage(page, w);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long count() { return mapper.selectCount(null); }
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long countExcellent(int minScore) {
|
||||
LambdaQueryWrapper<Student> w = new LambdaQueryWrapper<>();
|
||||
w.ge(Student::getScore, minScore);
|
||||
return mapper.selectCount(w);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Student updateAvatar(Long id, String filename) {
|
||||
Student s = mapper.selectById(id);
|
||||
if (s == null) throw new RuntimeException("学生不存在");
|
||||
s.setAvatar(filename);
|
||||
mapper.updateById(s);
|
||||
return s;
|
||||
}
|
||||
}
|
||||
50
week4/src/main/resources/application.yml
Normal file
50
week4/src/main/resources/application.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
spring:
|
||||
application:
|
||||
name: week4-student-system
|
||||
|
||||
datasource:
|
||||
url: jdbc:mysql://localhost:3306/week4_student?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
|
||||
username: root
|
||||
password: 1365957941@Wfj
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
show-sql: true
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.MySQLDialect
|
||||
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 2MB
|
||||
max-request-size: 5MB
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
map-underscore-to-camel-case: true
|
||||
global-config:
|
||||
db-config:
|
||||
id-type: auto
|
||||
|
||||
# ORM 选型开关:jpa 或 mp(第 5 天配置切换练习)
|
||||
orm:
|
||||
mode: jpa
|
||||
|
||||
# 自定义文件上传路径(头像)
|
||||
upload:
|
||||
path: ./uploads
|
||||
|
||||
# 简易认证 Token(拦截器验证用)
|
||||
auth:
|
||||
token: week4-secret-token
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.learn: DEBUG
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
150
week4/src/main/resources/static/css/style.css
Normal file
150
week4/src/main/resources/static/css/style.css
Normal file
@@ -0,0 +1,150 @@
|
||||
/* ===== 基础重置 ===== */
|
||||
*, *::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: #f0f2f5; color: #333; line-height: 1.6; min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ===== 容器 ===== */
|
||||
.container { max-width: 960px; margin: 0 auto; padding: 20px; }
|
||||
|
||||
/* ===== 头部 ===== */
|
||||
header { text-align: center; margin-bottom: 24px; }
|
||||
header h1 { font-size: 26px; color: #1a1a2e; }
|
||||
.subtitle { color: #999; font-size: 13px; margin-top: 2px; }
|
||||
|
||||
.main-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
text-align: left; margin-bottom: 16px;
|
||||
}
|
||||
.main-header h1 { font-size: 20px; }
|
||||
.orm-badge {
|
||||
display: inline-block; padding: 2px 12px; border-radius: 10px;
|
||||
font-size: 12px; font-weight: 600; background: #e6f7ff; color: #1890ff;
|
||||
margin-left: 8px; vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ===== 登录卡片 ===== */
|
||||
.login-card {
|
||||
max-width: 400px; margin: 30px auto; padding: 32px;
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
text-align: center;
|
||||
}
|
||||
.login-card h2 { margin-bottom: 12px; font-size: 20px; }
|
||||
.login-card .hint { color: #999; font-size: 13px; margin-bottom: 16px; }
|
||||
.login-card .hint.small { font-size: 12px; margin-top: 12px; margin-bottom: 0; }
|
||||
.login-card input[type="password"] {
|
||||
width: 100%; padding: 10px 14px; border: 1px solid #d9d9d9; border-radius: 8px;
|
||||
font-size: 14px; outline: none; transition: border-color 0.2s;
|
||||
}
|
||||
.login-card input:focus { border-color: #1890ff; box-shadow: 0 0 0 2px rgba(24,144,255,0.2); }
|
||||
#login-error { color: #ff4d4f; font-size: 13px; margin: 8px 0; min-height: 20px; }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
padding: 8px 20px; border: none; border-radius: 6px; font-size: 14px;
|
||||
cursor: pointer; transition: all 0.2s; white-space: nowrap;
|
||||
}
|
||||
.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: 6px; }
|
||||
.btn-edit:hover { background: #389e0d; }
|
||||
.btn-sm { padding: 4px 14px; font-size: 13px; }
|
||||
.btn-block { display: block; width: 100%; margin-top: 8px; }
|
||||
|
||||
/* ===== 工具栏 ===== */
|
||||
.toolbar { display: flex; gap: 12px; margin-bottom: 12px; }
|
||||
.toolbar input[type="text"] {
|
||||
flex: 1; padding: 8px 12px; border: 1px solid #d9d9d9; border-radius: 6px;
|
||||
font-size: 14px; outline: none;
|
||||
}
|
||||
.toolbar input:focus { border-color: #1890ff; box-shadow: 0 0 0 2px rgba(24,144,255,0.2); }
|
||||
|
||||
/* ===== 错误条 ===== */
|
||||
.error-bar {
|
||||
background: #fff2f0; color: #ff4d4f; padding: 10px 16px;
|
||||
border: 1px solid #ffccc7; border-radius: 6px; margin-bottom: 12px; font-size: 13px;
|
||||
}
|
||||
|
||||
/* ===== 表格 ===== */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table {
|
||||
width: 100%; border-collapse: collapse; background: #fff;
|
||||
border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
thead { background: #fafafa; }
|
||||
th {
|
||||
padding: 10px 12px; text-align: left; font-weight: 600;
|
||||
font-size: 12px; color: #999; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
border-bottom: 2px solid #f0f0f0; white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
padding: 10px 12px; font-size: 13px; border-bottom: 1px solid #f5f5f5;
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody tr:hover { background: #e6f7ff; }
|
||||
.empty { text-align: center; color: #bbb; padding: 40px !important; }
|
||||
|
||||
/* 头像缩略图 */
|
||||
.avatar-thumb {
|
||||
width: 32px; height: 32px; border-radius: 50%; object-fit: cover;
|
||||
background: #f0f0f0; border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
/* 成绩徽章 */
|
||||
.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; }
|
||||
|
||||
/* ===== 统计 ===== */
|
||||
.stats { display: flex; gap: 16px; margin-top: 20px; }
|
||||
.stat-card {
|
||||
flex: 1; background: #fff; padding: 20px; border-radius: 8px;
|
||||
text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
.stat-value { display: block; font-size: 32px; font-weight: 700; color: #1890ff; }
|
||||
.stat-label { font-size: 13px; color: #999; 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: 480px;
|
||||
max-width: 92vw; box-shadow: 0 8px 32px rgba(0,0,0,0.12); max-height: 90vh; overflow-y: auto;
|
||||
}
|
||||
.modal h2 { margin-bottom: 16px; font-size: 18px; }
|
||||
|
||||
.form-group { margin-bottom: 14px; }
|
||||
.form-group label { display: block; font-size: 13px; color: #888; margin-bottom: 4px; }
|
||||
.form-group input {
|
||||
width: 100%; padding: 8px 12px; border: 1px solid #d9d9d9; border-radius: 6px;
|
||||
font-size: 14px; outline: none;
|
||||
}
|
||||
.form-group input:focus { border-color: #1890ff; box-shadow: 0 0 0 2px rgba(24,144,255,0.2); }
|
||||
|
||||
.form-row { display: flex; gap: 14px; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
|
||||
.avatar-row { display: flex; align-items: center; gap: 12px; }
|
||||
.avatar-preview { width: 60px; height: 60px; border-radius: 50%; object-fit: cover; border: 2px solid #e0e0e0; }
|
||||
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
||||
|
||||
/* ===== 加载动画 ===== */
|
||||
.loading { text-align: center; padding: 20px; color: #999; }
|
||||
126
week4/src/main/resources/static/index.html
Normal file
126
week4/src/main/resources/static/index.html
Normal file
@@ -0,0 +1,126 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>学生管理系统 v1</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 登录面板 -->
|
||||
<div class="container" id="login-panel">
|
||||
<header>
|
||||
<h1>📚 学生管理系统 v1</h1>
|
||||
<p class="subtitle">Spring Boot + JPA / MyBatis-Plus 双引擎</p>
|
||||
</header>
|
||||
<div class="login-card">
|
||||
<h2>登录</h2>
|
||||
<p class="hint">请输入 Access Token(查看 application.yml 中的 auth.token)</p>
|
||||
<input type="password" id="token-input" placeholder="请输入 Token" autofocus>
|
||||
<div class="error-msg" id="login-error"></div>
|
||||
<button class="btn btn-primary btn-block" onclick="doLogin()">进入系统</button>
|
||||
<p class="hint small">也可通过 Postman 调用 POST /api/login 获取 Token</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主面板(初始隐藏) -->
|
||||
<div class="container" id="main-panel" style="display:none">
|
||||
|
||||
<header class="main-header">
|
||||
<div>
|
||||
<h1>📚 学生管理系统 v1</h1>
|
||||
<span class="orm-badge" id="orm-badge">ORM: --</span>
|
||||
</div>
|
||||
<button class="btn btn-outline btn-sm" onclick="logout()">退出登录</button>
|
||||
</header>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar">
|
||||
<input type="text" id="keyword" placeholder="搜索姓名或邮箱..." onkeyup="if(event.key==='Enter')search()">
|
||||
<button class="btn btn-primary" onclick="showForm(null)">+ 新增学生</button>
|
||||
<button class="btn btn-outline" onclick="loadStudents()">刷新</button>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div class="error-bar" id="error-bar" style="display:none"></div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:50px">头像</th>
|
||||
<th style="width:50px">ID</th>
|
||||
<th>姓名</th>
|
||||
<th>年龄</th>
|
||||
<th>邮箱</th>
|
||||
<th>成绩</th>
|
||||
<th style="width:100px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 统计 -->
|
||||
<div class="stats" id="stats-bar">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value" id="stat-total">-</span>
|
||||
<span class="stat-label">学生总数</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value" id="stat-excellent">-</span>
|
||||
<span class="stat-label">优秀人数 (≥85)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<div class="modal-overlay" id="modal-overlay">
|
||||
<div class="modal">
|
||||
<h2 id="modal-title">新增学生</h2>
|
||||
<form id="student-form" onsubmit="submitForm(event)">
|
||||
<input type="hidden" id="form-id">
|
||||
|
||||
<div class="form-group">
|
||||
<label>姓名 *</label>
|
||||
<input type="text" id="form-name" required maxlength="20" placeholder="请输入姓名">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>年龄 *</label>
|
||||
<input type="number" id="form-age" required min="1" max="150" placeholder="1-150">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>成绩 *</label>
|
||||
<input type="number" id="form-score" required min="0" max="100" placeholder="0-100">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>邮箱 *</label>
|
||||
<input type="email" id="form-email" required placeholder="example@mail.com">
|
||||
</div>
|
||||
|
||||
<!-- 头像上传(仅编辑模式显示)-->
|
||||
<div class="form-group" id="avatar-group" style="display:none">
|
||||
<label>头像</label>
|
||||
<div class="avatar-row">
|
||||
<img id="avatar-preview" class="avatar-preview" src="" alt="头像" style="display:none">
|
||||
<input type="file" id="avatar-file" accept="image/*" onchange="previewAvatar(this)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal()">取消</button>
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
311
week4/src/main/resources/static/js/app.js
Normal file
311
week4/src/main/resources/static/js/app.js
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* 学生管理系统 v1 —— 前端交互逻辑(原生 JS)
|
||||
*
|
||||
* 核心:
|
||||
* 1. 登录拦截 —— 页面加载时先登录,获取 Token
|
||||
* 2. fetch API —— 所有请求带 Authorization 头
|
||||
* 3. CRUD 操作 —— 对应 POST/GET/PUT/DELETE
|
||||
* 4. 文件上传 —— FormData + fetch
|
||||
*
|
||||
* 日期:Week 4 第 1-7 天
|
||||
*/
|
||||
|
||||
// ==================== 全局状态 ====================
|
||||
|
||||
const BASE = '/api/students';
|
||||
let token = localStorage.getItem('wk4_token') || '';
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (token) {
|
||||
// 已有 Token,直接进入主界面
|
||||
showMain();
|
||||
loadStudents();
|
||||
}
|
||||
// 否则显示登录面板
|
||||
});
|
||||
|
||||
// ==================== 登录 ====================
|
||||
|
||||
async function doLogin() {
|
||||
const input = document.getElementById('token-input').value.trim();
|
||||
if (!input) {
|
||||
document.getElementById('login-error').textContent = '请输入 Token';
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 Token:发一个简单请求
|
||||
try {
|
||||
const resp = await fetch(BASE + '/hello', {
|
||||
headers: { 'Authorization': 'Bearer ' + input }
|
||||
});
|
||||
const result = await resp.json();
|
||||
|
||||
if (result.code === 200 && resp.ok) {
|
||||
token = input;
|
||||
localStorage.setItem('wk4_token', token);
|
||||
showMain();
|
||||
loadStudents();
|
||||
} else {
|
||||
document.getElementById('login-error').textContent =
|
||||
'Token 验证失败: ' + (result.message || '服务器拒绝');
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('login-error').textContent = '连接失败: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function showMain() {
|
||||
document.getElementById('login-panel').style.display = 'none';
|
||||
document.getElementById('main-panel').style.display = 'block';
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('wk4_token');
|
||||
token = '';
|
||||
document.getElementById('login-panel').style.display = 'block';
|
||||
document.getElementById('main-panel').style.display = 'none';
|
||||
document.getElementById('token-input').value = '';
|
||||
}
|
||||
|
||||
// ==================== 请求封装 ====================
|
||||
|
||||
async function api(url, options) {
|
||||
options = options || {};
|
||||
options.headers = options.headers || {};
|
||||
options.headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, options);
|
||||
|
||||
// 401/403 说明 Token 失效
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
logout();
|
||||
throw new Error('认证失败,请重新登录');
|
||||
}
|
||||
|
||||
const result = await resp.json();
|
||||
|
||||
if (!resp.ok && result.code !== 200 && result.code !== 201) {
|
||||
throw new Error(result.message || '请求失败');
|
||||
}
|
||||
|
||||
// 首次加载时显示 ORM 模式
|
||||
if (result.extra && result.extra.orm && !document.getElementById('orm-badge').textContent.includes(result.extra.orm)) {
|
||||
document.getElementById('orm-badge').textContent = 'ORM: ' + result.extra.orm;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
showError(e.message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const bar = document.getElementById('error-bar');
|
||||
bar.textContent = '⚠ ' + msg;
|
||||
bar.style.display = 'block';
|
||||
setTimeout(() => { bar.style.display = 'none'; }, 5000);
|
||||
}
|
||||
|
||||
// ==================== 数据加载 ====================
|
||||
|
||||
async function loadStudents(keyword) {
|
||||
const tbody = document.getElementById('table-body');
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="loading">加载中...</td></tr>';
|
||||
|
||||
try {
|
||||
let url = BASE;
|
||||
if (keyword) {
|
||||
url += '?keyword=' + encodeURIComponent(keyword);
|
||||
}
|
||||
const result = await api(url);
|
||||
|
||||
const students = result.data;
|
||||
if (!students || students.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty">暂无数据</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = students.map(renderRow).join('');
|
||||
}
|
||||
|
||||
// 更新统计
|
||||
document.getElementById('stat-total').textContent = result.extra?.total ?? students.length;
|
||||
|
||||
// 统计优秀人数
|
||||
const statResp = await api(BASE + '/stats/excellent?min=85');
|
||||
document.getElementById('stat-excellent').textContent = statResp.data;
|
||||
|
||||
} catch (e) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty">加载失败: ' + e.message + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderRow(s) {
|
||||
const score = s.score;
|
||||
let cls = 'badge-poor';
|
||||
if (score >= 90) cls = 'badge-excellent';
|
||||
else if (score >= 80) cls = 'badge-good';
|
||||
else if (score >= 60) cls = 'badge-normal';
|
||||
|
||||
const avatarHtml = s.avatar
|
||||
? '<img class="avatar-thumb" src="/uploads/' + s.avatar + '" alt="' + escapeHtml(s.name) + '">'
|
||||
: '<div class="avatar-thumb" style="display:inline-block"></div>';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${avatarHtml}</td>
|
||||
<td>${s.id}</td>
|
||||
<td><strong>${escapeHtml(s.name)}</strong></td>
|
||||
<td>${s.age}</td>
|
||||
<td>${escapeHtml(s.email)}</td>
|
||||
<td><span class="badge ${cls}">${score}</span></td>
|
||||
<td>
|
||||
<button class="btn-edit" onclick="showForm(${s.id})">编辑</button>
|
||||
<button class="btn-danger" onclick="deleteStudent(${s.id})">删除</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function search() {
|
||||
const kw = document.getElementById('keyword').value.trim();
|
||||
loadStudents(kw || undefined);
|
||||
}
|
||||
|
||||
// ==================== 新增 / 编辑 ====================
|
||||
|
||||
async function showForm(id) {
|
||||
const modal = document.getElementById('modal-overlay');
|
||||
const avatarGroup = document.getElementById('avatar-group');
|
||||
document.getElementById('form-id').value = '';
|
||||
document.getElementById('student-form').reset();
|
||||
document.getElementById('form-age').value = '';
|
||||
document.getElementById('form-score').value = '';
|
||||
document.getElementById('avatar-preview').style.display = 'none';
|
||||
avatarGroup.style.display = 'none';
|
||||
|
||||
if (id) {
|
||||
// 编辑:回填数据
|
||||
document.getElementById('modal-title').textContent = '编辑学生';
|
||||
try {
|
||||
const result = await api(BASE + '/' + id);
|
||||
const s = result.data;
|
||||
document.getElementById('form-id').value = s.id;
|
||||
document.getElementById('form-name').value = s.name;
|
||||
document.getElementById('form-age').value = s.age;
|
||||
document.getElementById('form-email').value = s.email;
|
||||
document.getElementById('form-score').value = s.score;
|
||||
|
||||
// 显示头像区域
|
||||
avatarGroup.style.display = 'block';
|
||||
if (s.avatar) {
|
||||
document.getElementById('avatar-preview').src = '/uploads/' + s.avatar;
|
||||
document.getElementById('avatar-preview').style.display = 'block';
|
||||
}
|
||||
} catch (e) {
|
||||
return; // api() 已显示错误
|
||||
}
|
||||
} else {
|
||||
document.getElementById('modal-title').textContent = '新增学生';
|
||||
}
|
||||
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
async function submitForm(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const id = document.getElementById('form-id').value;
|
||||
const student = {
|
||||
name: document.getElementById('form-name').value.trim(),
|
||||
age: parseInt(document.getElementById('form-age').value),
|
||||
email: document.getElementById('form-email').value.trim(),
|
||||
score: parseInt(document.getElementById('form-score').value)
|
||||
};
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (id) {
|
||||
// 更新
|
||||
result = await api(BASE + '/' + id, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(student)
|
||||
});
|
||||
} else {
|
||||
// 新增
|
||||
result = await api(BASE, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(student)
|
||||
});
|
||||
}
|
||||
|
||||
// 如果是新增且有头像文件,立即上传
|
||||
const savedId = result.data.id;
|
||||
const fileInput = document.getElementById('avatar-file');
|
||||
if (fileInput.files.length > 0 && savedId) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
await api(BASE + '/' + savedId + '/avatar', {
|
||||
method: 'POST',
|
||||
body: formData // 不设 Content-Type,浏览器自动带 boundary
|
||||
});
|
||||
}
|
||||
|
||||
closeModal();
|
||||
loadStudents();
|
||||
} catch (e) {
|
||||
// api() 已显示错误
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 头像 ====================
|
||||
|
||||
function previewAvatar(input) {
|
||||
const preview = document.getElementById('avatar-preview');
|
||||
if (input.files[0]) {
|
||||
preview.src = URL.createObjectURL(input.files[0]);
|
||||
preview.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 删除 ====================
|
||||
|
||||
async function deleteStudent(id) {
|
||||
if (!confirm('确认删除该学生?此操作不可恢复。')) return;
|
||||
|
||||
try {
|
||||
await api(BASE + '/' + id, { method: 'DELETE' });
|
||||
loadStudents();
|
||||
} catch (e) { /* 已显示错误 */ }
|
||||
}
|
||||
|
||||
// ==================== 弹窗 ====================
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modal-overlay').classList.remove('show');
|
||||
document.getElementById('avatar-file').value = '';
|
||||
document.getElementById('avatar-preview').style.display = 'none';
|
||||
}
|
||||
|
||||
// 点击遮罩关闭
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.id === 'modal-overlay') closeModal();
|
||||
});
|
||||
|
||||
// Enter 键登录
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && document.getElementById('login-panel').style.display !== 'none') {
|
||||
doLogin();
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
444
week4/教案.md
Normal file
444
week4/教案.md
Normal file
@@ -0,0 +1,444 @@
|
||||
# 第四阶段教案:前端基础 + 项目整合(第 4 周)
|
||||
|
||||
> **学习周期**:7 天
|
||||
> **每日用时**:2-3 小时
|
||||
> **里程碑**:学生管理系统 v1 — Spring Boot + JPA/MP 双轨 + 原生 JS 全栈应用
|
||||
|
||||
---
|
||||
|
||||
## 前置准备
|
||||
|
||||
1. 执行 `sql/init.sql` 初始化数据库(在你的 MySQL 中)
|
||||
2. IDEA 打开 `week4/pom.xml`
|
||||
3. 确认 `application.yml` 中数据库密码正确
|
||||
|
||||
---
|
||||
|
||||
## 第 1 天:JavaScript 基础 —— 让页面"动"起来
|
||||
|
||||
### 核心概念
|
||||
|
||||
| 概念 | 解释 | 类比 |
|
||||
|------|------|------|
|
||||
| **变量** | `let name = "张三"` — 存放数据的容器 | 贴了标签的盒子 |
|
||||
| **函数** | `function add(a, b) { return a + b; }` — 可复用的代码块 | 微波炉:输入食材,输出热菜 |
|
||||
| **DOM** | Document Object Model — 把 HTML 文档变成 JS 可操作的对象树 | 遥控器:通过 JS 操控页面元素 |
|
||||
| **事件** | 用户的操作(点击、输入、提交) | 门铃:按一下触发响应 |
|
||||
|
||||
### 阅读 app.js(重点部分)
|
||||
|
||||
打开 `static/js/app.js`,找到以下关键模式:
|
||||
|
||||
**1. 选择 DOM 元素并操作它**
|
||||
```javascript
|
||||
// 获取元素
|
||||
const tbody = document.getElementById('table-body');
|
||||
// 修改内容
|
||||
tbody.innerHTML = '<tr><td>...</td></tr>';
|
||||
```
|
||||
|
||||
**2. 事件绑定**
|
||||
```javascript
|
||||
// HTML 中直接绑定(在按钮上写 onclick="doSomething()")
|
||||
<button onclick="logout()">退出</button>
|
||||
|
||||
// JS 中绑定(更灵活,但在这里我们用上面那种方式入门)
|
||||
document.addEventListener('DOMContentLoaded', () => { ... });
|
||||
```
|
||||
|
||||
**3. 字符串模板(ES6)**
|
||||
```javascript
|
||||
// 反引号 + ${} 插值,比 + 拼接直观很多
|
||||
return `
|
||||
<tr>
|
||||
<td>${s.id}</td>
|
||||
<td>${s.name}</td>
|
||||
</tr>`;
|
||||
```
|
||||
|
||||
### 动手
|
||||
|
||||
1. 打开浏览器开发者工具(F12 → Console),输入:
|
||||
```javascript
|
||||
document.querySelector('h1').textContent = 'Hello JS!'
|
||||
```
|
||||
观察页面标题是否变化
|
||||
2. 在 `app.js` 的 `renderRow` 函数中,把"年龄"列的显示改成 `${s.age} 岁`(加上"岁"字)
|
||||
3. 在 Console 中手动创建一个 `<div>`,设置颜色,插入到页面中
|
||||
|
||||
---
|
||||
|
||||
## 第 2 天:fetch API —— 前后端握手
|
||||
|
||||
### fetch 是什么?
|
||||
|
||||
fetch 是浏览器内置的 HTTP 客户端,让 JS 可以调用后端 API。
|
||||
|
||||
```javascript
|
||||
// GET 请求
|
||||
const response = await fetch('/api/students');
|
||||
const result = await response.json(); // 解析 JSON
|
||||
console.log(result.data); // 使用数据
|
||||
|
||||
// POST 请求
|
||||
const response = await fetch('/api/students', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: '新同学', age: 20, email: 'x@mail.com', score: 85 })
|
||||
});
|
||||
```
|
||||
|
||||
### async / await 是什么?
|
||||
|
||||
fetch 是异步操作(需要时间等待服务器响应),`await` 让代码看起来像同步的一样,更好理解。
|
||||
|
||||
```javascript
|
||||
// ❌ 不用 async/await(回调地狱)
|
||||
fetch(url).then(resp => resp.json()).then(data => console.log(data));
|
||||
|
||||
// ✅ 用 async/await(像写同步代码一样)
|
||||
async function load() {
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
console.log(data);
|
||||
}
|
||||
```
|
||||
|
||||
### 本项目中的请求封装
|
||||
|
||||
打开 `app.js`,看 `api()` 函数:
|
||||
|
||||
```javascript
|
||||
async function api(url, options) {
|
||||
options.headers['Authorization'] = 'Bearer ' + token; // 自动带 Token
|
||||
const resp = await fetch(url, options);
|
||||
const result = await resp.json();
|
||||
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
logout(); // Token 失效,自动退回登录页
|
||||
}
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
**为什么封装?** 每个请求都需要带 Authorization 头,封装后调用方只需要关心业务数据。
|
||||
|
||||
### 动手
|
||||
|
||||
1. 在 Console 中手动调用 fetch 测试 API:
|
||||
```javascript
|
||||
fetch('/api/students/hello', { headers: { 'Authorization': 'Bearer week4-secret-token' } })
|
||||
.then(r => r.json())
|
||||
.then(console.log)
|
||||
```
|
||||
2. 观察 Network 面板(F12 → Network),看请求和响应的完整内容
|
||||
3. 故意发一个错误的 Token,观察 401 返回和处理逻辑
|
||||
|
||||
---
|
||||
|
||||
## 第 3 天:Spring MVC 拦截器 —— 请求的"门卫"
|
||||
|
||||
### 拦截器原理
|
||||
|
||||
```
|
||||
HTTP 请求
|
||||
│
|
||||
▼
|
||||
DispatcherServlet(前端控制器)
|
||||
│
|
||||
▼
|
||||
[拦截器 preHandle] ← 在这里决定"放行"还是"拦截"
|
||||
│ 通过
|
||||
▼
|
||||
Controller(处理业务)
|
||||
│
|
||||
▼
|
||||
[拦截器 postHandle/afterCompletion]
|
||||
│
|
||||
▼
|
||||
HTTP 响应
|
||||
```
|
||||
|
||||
### 本项目拦截器做了什么?
|
||||
|
||||
打开 `controller/AuthInterceptor.java`:
|
||||
|
||||
```java
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, ...) {
|
||||
// 1. 白名单直接放行
|
||||
if (path.startsWith("/api/hello")) return true;
|
||||
|
||||
// 2. 校验 Authorization 头
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||
response.setStatus(401); // 返回 401 Unauthorized
|
||||
return false; // 拦截!
|
||||
}
|
||||
|
||||
// 3. 验证 Token 是否匹配
|
||||
String token = authHeader.substring(7);
|
||||
if (!validToken.equals(token)) {
|
||||
response.setStatus(403); // 返回 403 Forbidden
|
||||
return false;
|
||||
}
|
||||
return true; // 放行
|
||||
}
|
||||
```
|
||||
|
||||
### 注册拦截器(WebConfig.java)
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(authInterceptor)
|
||||
.addPathPatterns("/api/**") // 拦截所有 /api/ 路径
|
||||
.excludePathPatterns("/api/hello", "/api/login"); // 白名单
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 动手
|
||||
|
||||
1. 用 Postman 不带 Authorization 头发一个 `GET /api/students`,观察 401 响应
|
||||
2. 带正确的 Token `Bearer week4-secret-token`,观察正常返回
|
||||
3. 把 `auth.token` 改成另一个值,重启后观察旧 Token 变为 403
|
||||
4. 思考:真正的用户系统会怎么存储和验证 Token?(预告:JWT,第 5 周)
|
||||
|
||||
---
|
||||
|
||||
## 第 4 天:统一异常处理 + 参数校验
|
||||
|
||||
### GlobalExceptionHandler 做了什么?
|
||||
|
||||
```java
|
||||
@RestControllerAdvice // 拦截所有 Controller
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class) // 参数校验失败
|
||||
public ResponseEntity<?> handleValidation(...) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.badRequest("参数校验失败: ..."));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class) // 兜底:所有未捕获异常
|
||||
public ResponseEntity<?> handleAll(...) {
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error(500, "服务器内部错误"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 为什么 Controller 不需要 try-catch?
|
||||
|
||||
因为 `@RestControllerAdvice` 在所有 Controller 方法的外层织入了一层异常处理网。任何异常抛出后,都会落入对应的 `@ExceptionHandler` 方法。
|
||||
|
||||
```java
|
||||
// Controller 中
|
||||
@PostMapping
|
||||
public ApiResponse<Student> add(@Valid @RequestBody Student s) {
|
||||
return ApiResponse.created(service.add(s));
|
||||
// 如果 s 的 name 为空 → MethodArgumentNotValidException
|
||||
// 如果 service.add 中抛异常 → Exception
|
||||
// 都会被 GlobalExceptionHandler 捕获,Controller 不需要处理
|
||||
}
|
||||
```
|
||||
|
||||
### 统一响应格式(ApiResponse)
|
||||
|
||||
所有接口返回统一结构:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": { ... },
|
||||
"extra": { "total": 10, "orm": "JPA" }
|
||||
}
|
||||
```
|
||||
|
||||
这样前端只需要判断 `result.code === 200` 来确定请求是否成功。
|
||||
|
||||
### 动手
|
||||
|
||||
1. 故意提交空的姓名,观察返回的 400 错误详情
|
||||
2. 在 Student 实体类中新增一个校验规则:`@Min(0) @Max(100) private int score`
|
||||
3. 测试传入 score=999,观察校验是否生效
|
||||
|
||||
---
|
||||
|
||||
## 第 5 天:RESTful 设计规范 + ORM 切换
|
||||
|
||||
### RESTful API 最佳实践
|
||||
|
||||
| 原则 | 规则 |
|
||||
|------|------|
|
||||
| **用名词,不用动词** | `/api/students` ✅ `/api/getStudents` ❌ |
|
||||
| **HTTP 方法表达操作** | GET=查 / POST=增 / PUT=改 / DELETE=删 |
|
||||
| **资源用复数** | `/api/students` 而不是 `/api/student` |
|
||||
| **层级表示关系** | `/api/students/5/avatar` = 学生 5 的头像 |
|
||||
| **正确使用状态码** | 200=成功 / 201=创建成功 / 400=参数错误 / 404=未找到 / 500=服务器错误 |
|
||||
|
||||
### ORM 模式切换
|
||||
|
||||
本项目用 `orm.mode` 配置实现了 JPA 和 MP 的无缝切换:
|
||||
|
||||
```yaml
|
||||
# application.yml
|
||||
orm:
|
||||
mode: jpa # 改成 mp 即可切换到 MyBatis-Plus
|
||||
```
|
||||
|
||||
原理:`StudentServiceSelector` 读取 `@Value("${orm.mode}")`,根据值选择调用 `StudentJpaService` 还是 `StudentMpService`。Controller 层完全不感知底层用的是哪个 ORM。
|
||||
|
||||
### 动手
|
||||
|
||||
1. 修改 `orm.mode` 为 `mp`,重启,观察前端页面显示的 ORM 标签
|
||||
2. 测试同一个接口的返回数据是否完全一致
|
||||
3. 对比控制台 SQL 输出,JPA 和 MP 生成的 SQL 有何不同
|
||||
4. 思考:如果要在运行时动态切换(不重启),需要怎么改?
|
||||
|
||||
---
|
||||
|
||||
## 第 6 天:文件上传 + CORS 跨域
|
||||
|
||||
### Spring Boot 文件上传
|
||||
|
||||
Controller 中处理文件只需 `@RequestParam("file") MultipartFile file`:
|
||||
|
||||
```java
|
||||
@PostMapping("/{id}/avatar")
|
||||
public ResponseEntity<?> uploadAvatar(
|
||||
@PathVariable Long id,
|
||||
@RequestParam("file") MultipartFile file) {
|
||||
|
||||
// 1. 文件校验
|
||||
if (file.isEmpty()) return badRequest("文件为空");
|
||||
String ext = originalName.substring(originalName.lastIndexOf("."));
|
||||
|
||||
// 2. 保存到磁盘
|
||||
String filename = UUID.randomUUID() + ext; // 随机名防冲突
|
||||
file.transferTo(new File("./uploads", filename));
|
||||
|
||||
// 3. 更新数据库
|
||||
service.updateAvatar(id, filename);
|
||||
}
|
||||
```
|
||||
|
||||
### 前端如何上传文件
|
||||
|
||||
```javascript
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]); // 从 <input type="file"> 取文件
|
||||
|
||||
fetch(`/api/students/${id}/avatar`, {
|
||||
method: 'POST',
|
||||
body: formData // 不设 Content-Type!浏览器自动设置 multipart/form-data + boundary
|
||||
});
|
||||
```
|
||||
|
||||
### CORS 跨域
|
||||
|
||||
当前前后端同端口,不存在跨域。但项目已经预置了 CORS 配置(`WebConfig.addCorsMappings`),为后续前后端分离做准备。
|
||||
|
||||
### 静态资源映射
|
||||
|
||||
```java
|
||||
// 将本地 uploads/ 目录映射到 URL /uploads/**
|
||||
registry.addResourceHandler("/uploads/**")
|
||||
.addResourceLocations("file:./uploads/");
|
||||
```
|
||||
|
||||
这样通过 `http://localhost:8080/uploads/xxx.jpg` 就能访问上传的头像。
|
||||
|
||||
### 动手
|
||||
|
||||
1. 新增一个学生,编辑该学生 → 上传一张头像 → 观察表格中头像显示
|
||||
2. 打开 `./uploads/` 目录,确认文件已保存
|
||||
3. 故意上传一个超过 2MB 的文件,观察错误提示
|
||||
4. 修改 `application.yml` 中 `spring.servlet.multipart.max-file-size` 为 5MB
|
||||
|
||||
---
|
||||
|
||||
## 第 7 天:项目整合与总结
|
||||
|
||||
### 本周完整请求流程
|
||||
|
||||
```
|
||||
浏览器
|
||||
│
|
||||
├── 1. GET / → 加载 index.html + style.css + app.js
|
||||
│
|
||||
├── 2. 用户输入 Token → JS 调用 /api/students/hello 验证
|
||||
│
|
||||
├── 3. Token 验证通过 → 进入主面板 → JS 调用 /api/students 加载数据
|
||||
│ │
|
||||
│ ▼
|
||||
│ [AuthInterceptor] 校验 Authorization 头
|
||||
│ │ 通过
|
||||
│ ▼
|
||||
│ [StudentController] 接收请求 → 参数校验(@Valid)
|
||||
│ │
|
||||
│ ▼
|
||||
│ [StudentServiceSelector] 根据 orm.mode 选择 JPA 或 MP Service
|
||||
│ │
|
||||
│ ▼
|
||||
│ [StudentJpaService / StudentMpService] 执行业务逻辑
|
||||
│ │
|
||||
│ ▼
|
||||
│ [Repository / Mapper] 操作数据库
|
||||
│ │
|
||||
│ ▼
|
||||
│ 返回 ApiResponse 格式 JSON → JS 渲染到页面
|
||||
│
|
||||
└── 异常 → [GlobalExceptionHandler] 拦截 → 返回统一错误 JSON
|
||||
```
|
||||
|
||||
### 完整验收清单
|
||||
|
||||
**后端(Postman 逐项测试)**:
|
||||
|
||||
- [ ] `GET /api/students/hello` 无需 Token,返回运行状态
|
||||
- [ ] `GET /api/students` 需 Token,返回全部学生
|
||||
- [ ] `GET /api/students?keyword=张` 模糊搜索
|
||||
- [ ] `POST /api/students` 新增一个学生
|
||||
- [ ] `GET /api/students/1` 查询 ID=1
|
||||
- [ ] `PUT /api/students/1` 更新学生信息
|
||||
- [ ] `DELETE /api/students/1` 删除学生
|
||||
- [ ] `POST /api/students/1/avatar` 上传头像
|
||||
- [ ] 不带 Token 发请求 → 401
|
||||
- [ ] 带错误 Token 发请求 → 403
|
||||
- [ ] 提交空 name → 400 校验错误
|
||||
- [ ] 修改 `orm.mode: mp` 后功能不变
|
||||
|
||||
**前端(浏览器)**:
|
||||
|
||||
- [ ] 输入正确 Token → 进入主面板
|
||||
- [ ] 输入错误 Token → 显示错误
|
||||
- [ ] 表格正确展示全部学生(头像、成绩徽章颜色正确)
|
||||
- [ ] 新增学生 → 表单提交 → 表格刷新
|
||||
- [ ] 编辑学生 → 回填数据 → 更新成功
|
||||
- [ ] 上传头像 → 头像显示在表格中
|
||||
- [ ] 搜索 → 过滤数据
|
||||
- [ ] 删除 → 确认弹窗 → 删除成功
|
||||
- [ ] 退出登录 → 回到登录页
|
||||
|
||||
### 本周产出
|
||||
|
||||
```
|
||||
✅ 原生 JS 前后端交互(fetch + DOM 操作)
|
||||
✅ Token 认证与 401/403 处理
|
||||
✅ Spring MVC 拦截器(登录权限校验)
|
||||
✅ 全局异常处理(统一 JSON 格式错误返回)
|
||||
✅ 参数校验(@Valid + Bean Validation + 自定义错误消息)
|
||||
✅ RESTful API 设计规范
|
||||
✅ ORM 配置切换(JPA ↔ MyBatis-Plus,一行配置切换)
|
||||
✅ 文件上传(头像,前端 FormData + 后端 MultipartFile)
|
||||
✅ CORS 跨域预配置
|
||||
✅ 统一 API 响应格式(ApiResponse<T>)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **本周核心**:你已从 Java 基础走到了能独立交付一个**全栈应用**的水平。后端有三层架构和双 ORM,前端有原生 JS 交互和文件上传,系统有拦截器保护、统一异常处理、参数校验。这是你第一个真正的里程碑项目。
|
||||
Reference in New Issue
Block a user