445 lines
13 KiB
Markdown
445 lines
13 KiB
Markdown
# 第四阶段教案:前端基础 + 项目整合(第 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 交互和文件上传,系统有拦截器保护、统一异常处理、参数校验。这是你第一个真正的里程碑项目。
|