# 第四阶段教案:前端基础 + 项目整合(第 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 = '...'; ``` **2. 事件绑定** ```javascript // HTML 中直接绑定(在按钮上写 onclick="doSomething()") // JS 中绑定(更灵活,但在这里我们用上面那种方式入门) document.addEventListener('DOMContentLoaded', () => { ... }); ``` **3. 字符串模板(ES6)** ```javascript // 反引号 + ${} 插值,比 + 拼接直观很多 return ` ${s.id} ${s.name} `; ``` ### 动手 1. 打开浏览器开发者工具(F12 → Console),输入: ```javascript document.querySelector('h1').textContent = 'Hello JS!' ``` 观察页面标题是否变化 2. 在 `app.js` 的 `renderRow` 函数中,把"年龄"列的显示改成 `${s.age} 岁`(加上"岁"字) 3. 在 Console 中手动创建一个 `
`,设置颜色,插入到页面中 --- ## 第 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 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]); // 从 取文件 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) ``` --- > **本周核心**:你已从 Java 基础走到了能独立交付一个**全栈应用**的水平。后端有三层架构和双 ORM,前端有原生 JS 交互和文件上传,系统有拦截器保护、统一异常处理、参数校验。这是你第一个真正的里程碑项目。