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

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

63
week4/pom.xml Normal file
View 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
View 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;

View 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);
}
}

View File

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

View File

@@ -0,0 +1,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 + "/");
}
}

View File

@@ -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 和静态资源不拦截
*
* 使用方式PostmanHeaders → 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; // 放行
}
}

View 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()));
}
}
}

View 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; }
}

View 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; }
}

View File

@@ -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()));
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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";
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View 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

View 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; }

View 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>

View 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
View 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 交互和文件上传,系统有拦截器保护、统一异常处理、参数校验。这是你第一个真正的里程碑项目。