Files
gc-plan/week4/教案.md
2026-04-29 23:45:17 +08:00

13 KiB
Raw Blame History

第四阶段教案:前端基础 + 项目整合(第 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 元素并操作它

// 获取元素
const tbody = document.getElementById('table-body');
// 修改内容
tbody.innerHTML = '<tr><td>...</td></tr>';

2. 事件绑定

// HTML 中直接绑定(在按钮上写 onclick="doSomething()"
<button onclick="logout()">退出</button>

// JS 中绑定(更灵活,但在这里我们用上面那种方式入门)
document.addEventListener('DOMContentLoaded', () => { ... });

3. 字符串模板ES6

// 反引号 + ${} 插值,比 + 拼接直观很多
return `
    <tr>
        <td>${s.id}</td>
        <td>${s.name}</td>
    </tr>`;

动手

  1. 打开浏览器开发者工具F12 → Console输入
    document.querySelector('h1').textContent = 'Hello JS!'
    
    观察页面标题是否变化
  2. app.jsrenderRow 函数中,把"年龄"列的显示改成 ${s.age} 岁(加上"岁"字)
  3. 在 Console 中手动创建一个 <div>,设置颜色,插入到页面中

第 2 天fetch API —— 前后端握手

fetch 是什么?

fetch 是浏览器内置的 HTTP 客户端,让 JS 可以调用后端 API。

// 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 让代码看起来像同步的一样,更好理解。

// ❌ 不用 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() 函数:

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

@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

@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 做了什么?

@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 方法。

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

所有接口返回统一结构:

{
    "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 的无缝切换:

# application.yml
orm:
  mode: jpa  # 改成 mp 即可切换到 MyBatis-Plus

原理:StudentServiceSelector 读取 @Value("${orm.mode}"),根据值选择调用 StudentJpaService 还是 StudentMpService。Controller 层完全不感知底层用的是哪个 ORM。

动手

  1. 修改 orm.modemp,重启,观察前端页面显示的 ORM 标签
  2. 测试同一个接口的返回数据是否完全一致
  3. 对比控制台 SQL 输出JPA 和 MP 生成的 SQL 有何不同
  4. 思考:如果要在运行时动态切换(不重启),需要怎么改?

第 6 天:文件上传 + CORS 跨域

Spring Boot 文件上传

Controller 中处理文件只需 @RequestParam("file") MultipartFile file

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

前端如何上传文件

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),为后续前后端分离做准备。

静态资源映射

// 将本地 uploads/ 目录映射到 URL /uploads/**
registry.addResourceHandler("/uploads/**")
        .addResourceLocations("file:./uploads/");

这样通过 http://localhost:8080/uploads/xxx.jpg 就能访问上传的头像。

动手

  1. 新增一个学生,编辑该学生 → 上传一张头像 → 观察表格中头像显示
  2. 打开 ./uploads/ 目录,确认文件已保存
  3. 故意上传一个超过 2MB 的文件,观察错误提示
  4. 修改 application.ymlspring.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 交互和文件上传,系统有拦截器保护、统一异常处理、参数校验。这是你第一个真正的里程碑项目。