Week 1-8: Spring Boot 学习计划完整项目
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# === IDE ===
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# === Java / Maven ===
|
||||||
|
target/
|
||||||
|
*.class
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
|
||||||
|
# === Node / Vue ===
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# === OS ===
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# === Claude Code ===
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# === Build output ===
|
||||||
|
out/
|
||||||
|
|
||||||
|
# === Logs ===
|
||||||
|
*.log
|
||||||
332
plan.md
Normal file
332
plan.md
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
# 三月学习计划:从 Spring 全家桶到 AI Agent 网站
|
||||||
|
|
||||||
|
> 目标:以 Spring 生态为基础,Web 能力为骨架,AI Agent 开发与算法能力为扩展,最终交付一个具备前端交互能力的 AI Agent 网站。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 学习路线总览
|
||||||
|
|
||||||
|
```
|
||||||
|
Month 1 Month 2 Month 3
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Java + Spring │───▶│ Spring 进阶 │───▶│ AI Agent 开发 │
|
||||||
|
│ ORM(JPA+MP) │ │ Vue 3 前端 │ │ 算法入门 │
|
||||||
|
│ HTML/CSS/JS │ │ 工程化部署 │ │ 最终项目 │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前置准备(开始前 1-2 天完成)
|
||||||
|
|
||||||
|
| 事项 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 安装 JDK 17+ | 推荐 Eclipse Temurin / Amazon Corretto |
|
||||||
|
| 安装 IDE | IntelliJ IDEA Community Edition |
|
||||||
|
| 安装 Maven | Spring Boot 项目构建工具 |
|
||||||
|
| 安装 Git | 版本管理 |
|
||||||
|
| 安装 Postman | API 调试工具 |
|
||||||
|
| 安装 VS Code | 前端开发用 |
|
||||||
|
| 安装 Node.js 18+ | 前端环境 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一阶段:Java 基础 + Spring Boot 入门 + Web 基础(第 1-4 周)
|
||||||
|
|
||||||
|
### 第 1 周:Java 核心基础速通
|
||||||
|
|
||||||
|
**目标**:掌握 Java 基本语法,能写出简单程序。
|
||||||
|
|
||||||
|
| 天 | 学习内容 | 实践 |
|
||||||
|
|----|---------|------|
|
||||||
|
| 1 | JDK 安装、环境变量、Hello World、IDE 使用 | 跑通第一个程序 |
|
||||||
|
| 2 | 变量、数据类型、运算符、字符串 | 写一个计算器 |
|
||||||
|
| 3 | if/else、switch、for/while 循环 | 打印乘法表、猜数字游戏 |
|
||||||
|
| 4 | 数组、ArrayList、方法定义与调用 | 写一个学生成绩管理器 |
|
||||||
|
| 5 | 类与对象、构造方法、封装 | 定义一个 Student 类并实例化 |
|
||||||
|
| 6 | 继承、多态、接口、抽象类 | 设计简单的动物继承体系 |
|
||||||
|
| 7 | 异常处理、集合框架(List/Map/Set) | 用 Map 实现一个简易通讯录 |
|
||||||
|
|
||||||
|
**本周产出**:一个命令行通讯录程序(增删改查)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 第 2 周:Spring Boot 入门
|
||||||
|
|
||||||
|
**目标**:理解 Spring Boot 是什么,创建第一个 Web 项目。
|
||||||
|
|
||||||
|
| 天 | 学习内容 | 实践 |
|
||||||
|
|----|---------|------|
|
||||||
|
| 1 | Spring 框架是什么、IoC 和 DI 概念 | 手写一个简单的 IoC 容器理解原理 |
|
||||||
|
| 2 | Spring Boot 项目结构、启动流程 | 用 Spring Initializr 创建项目 |
|
||||||
|
| 3 | @RestController、@RequestMapping | 写第一个 "Hello World" API |
|
||||||
|
| 4 | @GetMapping/@PostMapping、参数接收 | 实现 RESTful 风格的学生 CRUD |
|
||||||
|
| 5 | @Service、@Repository 分层架构 | 重构代码为 Controller-Service-Repository |
|
||||||
|
| 6 | application.yml 配置、多环境配置 | 配置开发/生产环境 |
|
||||||
|
| 7 | 复习与总结 | 完成一个简单的 REST API 服务 |
|
||||||
|
|
||||||
|
**本周产出**:一个基于内存的 RESTful 学生管理 API。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 第 3 周:ORM 双轨 —— JPA + MyBatis-Plus
|
||||||
|
|
||||||
|
**目标**:同时掌握两种主流 ORM 框架,理解各自适用场景。
|
||||||
|
|
||||||
|
| 天 | 学习内容 | 实践 |
|
||||||
|
|----|---------|------|
|
||||||
|
| 1 | MySQL 安装、数据库/表的基本操作 | 创建 students 数据库和表 |
|
||||||
|
| 2 | Spring Data JPA、Entity、Repository | 用 JPA 实现学生数据持久化 |
|
||||||
|
| 3 | MyBatis-Plus 基础、Mapper、BaseMapper | 用 MyBatis-Plus 重写同一套 CRUD |
|
||||||
|
| 4 | JPA vs MyBatis-Plus 对比总结 | 写一份对比笔记:API 风格、适用场景、性能差异 |
|
||||||
|
| 5 | @Transactional、分页查询(两种框架写法) | 分别用 JPA 和 MP 实现分页和搜索 |
|
||||||
|
| 6 | HTML 基础(标签、表单、布局) | 写一个静态学生列表页 |
|
||||||
|
| 7 | CSS 基础(选择器、盒模型、Flexbox) | 美化学生列表页 |
|
||||||
|
|
||||||
|
**本周产出**:分别用 JPA 和 MyBatis-Plus 实现的学生数据访问层,附带对比笔记。
|
||||||
|
|
||||||
|
> **快速参考:JPA vs MyBatis-Plus 选型**
|
||||||
|
>
|
||||||
|
> | 维度 | Spring Data JPA | MyBatis-Plus |
|
||||||
|
> |------|----------------|--------------|
|
||||||
|
> | 风格 | 自动生成 SQL(约定大于配置) | 半自动,SQL 可控性高 |
|
||||||
|
> | 简单 CRUD | `JpaRepository` 零代码 | 继承 `BaseMapper` 零代码 |
|
||||||
|
> | 复杂查询 | JPQL / Specification / QueryDSL | LambdaQueryWrapper / 手写 SQL |
|
||||||
|
> | 灵活度 | 低 — SQL 是框架生成的 | 高 — 可以精确控制 SQL |
|
||||||
|
> | 学习曲线 | 较陡(JPA 规范本身很重) | 较平缓(对 SQL 友好) |
|
||||||
|
> | 适用场景 | 表关系复杂、面向对象建模 | 复杂 SQL、报表、多表联查 |
|
||||||
|
> | 国内流行度 | 外企/传统企业多 | 互联网/中小团队首选 |
|
||||||
|
>
|
||||||
|
> **新手建议**:两个都学。简单 CRUD 用 JPA 体验"无 SQL"的快感;复杂查询用 MyBatis-Plus 体会 SQL 的掌控力。实际工作中两把刀都磨利,视场景出鞘。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 第 4 周:前端基础 + 项目整合
|
||||||
|
|
||||||
|
**目标**:补齐前端基础,理解 Spring MVC 核心,交付第一个完整项目。
|
||||||
|
|
||||||
|
| 天 | 学习内容 | 实践 |
|
||||||
|
|----|---------|------|
|
||||||
|
| 1 | JavaScript 基础(变量、函数、DOM 操作) | 用 JS 实现页面的增删按钮 |
|
||||||
|
| 2 | fetch API 调用后端接口 | 前端页面调通后端 API |
|
||||||
|
| 3 | Spring MVC 请求处理流程、拦截器 | 实现登录拦截器 |
|
||||||
|
| 4 | 统一异常处理、参数校验(Bean Validation) | 添加全局异常处理和表单校验 |
|
||||||
|
| 5 | RESTful API 设计规范、HTTP 状态码 | 规范化所有 API 接口 |
|
||||||
|
| 6 | 跨域 CORS 配置、文件上传 | 实现头像上传功能 |
|
||||||
|
| 7 | 阶段总结 & 第一个里程碑项目 | 前后端联调、代码整理、写 README |
|
||||||
|
|
||||||
|
**里程碑项目**:学生管理系统 v1 —— Spring Boot + JPA/MyBatis-Plus + MySQL + 原生 JS 全栈应用。设计上同一套业务逻辑提供两个数据访问实现(JPA 版 + MP 版),通过配置切换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二阶段:Spring 进阶 + 现代前端框架(第 5-8 周)
|
||||||
|
|
||||||
|
### 第 5 周:Spring 全家桶核心组件
|
||||||
|
|
||||||
|
**目标**:掌握 Spring Security 和常用中间件。
|
||||||
|
|
||||||
|
| 天 | 学习内容 | 实践 |
|
||||||
|
|----|---------|------|
|
||||||
|
| 1 | Spring Security 架构、过滤器链 | 引入 Security 依赖,配置基础认证 |
|
||||||
|
| 2 | JWT 认证、登录/注册接口 | 实现 JWT 登录注册流程 |
|
||||||
|
| 3 | 权限控制(RBAC)、方法级安全 | 实现角色权限管理 |
|
||||||
|
| 4 | Redis 基础、Spring Cache | 用 Redis 缓存热点数据 |
|
||||||
|
| 5 | Spring Boot Actuator、健康检查 | 配置应用监控端点 |
|
||||||
|
| 6 | 参数校验进阶、自定义注解 | 写一个自定义校验注解 |
|
||||||
|
| 7 | MyBatis-Plus 进阶(LambdaQueryWrapper、条件构造器、逻辑删除) | 用 MP 实现复杂查询和软删除 |
|
||||||
|
|
||||||
|
**本周产出**:带安全认证和缓存的学生管理系统 v2。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 第 6 周:前端框架入门(Vue.js)
|
||||||
|
|
||||||
|
**目标**:掌握 Vue 3 基础,能开发 SPA 应用。
|
||||||
|
|
||||||
|
| 天 | 学习内容 | 实践 |
|
||||||
|
|----|---------|------|
|
||||||
|
| 1 | Vue 3 介绍、创建项目(Vite)、组件基础 | 搭建 Vue 项目 |
|
||||||
|
| 2 | 响应式数据(ref/reactive)、计算属性 | 实现一个计数器和一个 Todo List |
|
||||||
|
| 3 | 指令(v-if/v-for/v-model)、事件处理 | 写一个动态表单 |
|
||||||
|
| 4 | 组件通信(props/emits)、插槽 | 拆分 Todo List 为多个组件 |
|
||||||
|
| 5 | Vue Router 路由、导航守卫 | 实现多页面导航 |
|
||||||
|
| 6 | Pinia 状态管理 | 全局状态管理用户信息和 Token |
|
||||||
|
| 7 | Axios 封装、请求拦截 | 封装 HTTP 客户端统一处理 JWT |
|
||||||
|
|
||||||
|
**本周产出**:一个独立的前端 SPA 应用(Todo + 路由 + 状态管理)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 第 7 周:前后端分离实战
|
||||||
|
|
||||||
|
**目标**:将 Vue 前端与 Spring Boot 后端完整对接。
|
||||||
|
|
||||||
|
| 天 | 学习内容 | 实践 |
|
||||||
|
|----|---------|------|
|
||||||
|
| 1 | 前后端分离架构设计 | 规划 API 接口文档 |
|
||||||
|
| 2 | 登录注册页面 + JWT 对接 | 实现完整登录流程 |
|
||||||
|
| 3 | CRUD 页面实现(列表、表单、删除) | 学生管理的完整前端 |
|
||||||
|
| 4 | 分页组件、搜索、排序 | 列表页增加分页和搜索 |
|
||||||
|
| 5 | 文件上传前端实现 | 头像上传和预览 |
|
||||||
|
| 6 | 错误处理、Loading 状态、空状态 | 完善交互体验 |
|
||||||
|
| 7 | Nginx 部署、前后端联调 | 本地部署完整应用 |
|
||||||
|
|
||||||
|
**本周产出**:前后端分离的学生管理系统 v3(Vue 3 + Spring Boot)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 第 8 周:工程化能力
|
||||||
|
|
||||||
|
**目标**:掌握测试和 API 文档等工程化技能。
|
||||||
|
|
||||||
|
| 天 | 学习内容 | 实践 |
|
||||||
|
|----|---------|------|
|
||||||
|
| 1 | 单元测试(JUnit 5 + Mockito) | 为 Service 层写单元测试 |
|
||||||
|
| 2 | 集成测试(@SpringBootTest + H2) | 用 H2 内存数据库测试 |
|
||||||
|
| 3 | Swagger/Knife4j API 文档 | 为所有接口生成文档 |
|
||||||
|
| 4 | 阶段总结 & 第二个里程碑项目 | 代码审查、性能优化 |
|
||||||
|
|
||||||
|
**里程碑项目**:学生管理系统 v4 —— 有测试、有文档的完整前后端分离项目,DAO 层同时提供 JPA 和 MyBatis-Plus 两套实现,可配置切换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第三阶段:AI Agent 开发 + 算法基础(第 9-12 周)
|
||||||
|
|
||||||
|
### 第 9 周:AI & LLM 基础
|
||||||
|
|
||||||
|
**目标**:理解大语言模型的基本概念和使用方式。
|
||||||
|
|
||||||
|
| 天 | 学习内容 | 实践 |
|
||||||
|
|----|---------|------|
|
||||||
|
| 1 | AI/ML 基本概念、LLM 是什么 | 了解 GPT/Claude/文心一言等模型 |
|
||||||
|
| 2 | Prompt Engineering 基础 | 动手写各种 Prompt,体验不同效果 |
|
||||||
|
| 3 | OpenAI API / 国内大模型 API 调用方式 | 用 Postman 调通一个大模型 API |
|
||||||
|
| 4 | Spring AI 框架入门 | 集成 Spring AI 到 Spring Boot 项目 |
|
||||||
|
| 5 | 对话接口实现(Chat Completion) | 实现一个简单的聊天 API |
|
||||||
|
| 6 | Streaming 流式响应(SSE) | 实现打字机效果的流式对话 |
|
||||||
|
| 7 | 上下文管理、多轮对话 | 实现带记忆的多轮对话 |
|
||||||
|
|
||||||
|
**本周产出**:一个可以多轮对话的聊天机器人 API。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 第 10 周:AI Agent 核心概念
|
||||||
|
|
||||||
|
**目标**:理解 Agent 架构,实现带工具调用的 Agent。
|
||||||
|
|
||||||
|
| 天 | 学习内容 | 实践 |
|
||||||
|
|----|---------|------|
|
||||||
|
| 1 | Agent 是什么(感知-思考-行动) | 了解 ReAct / Plan-and-Execute 模式 |
|
||||||
|
| 2 | Function Calling / Tool Use 原理 | 手动解析一次 Function Call 流程 |
|
||||||
|
| 3 | Spring AI Function Calling 实现 | 定义并注册一个 Function 给模型调用 |
|
||||||
|
| 4 | 多工具协作(天气、搜索、计算器) | 注册多个 Tool,让模型自动选择调用 |
|
||||||
|
| 5 | Agent 记忆系统(短期 + 长期记忆) | 用 Redis 实现对话历史持久化 |
|
||||||
|
| 6 | RAG(检索增强生成)基础 | 用 Spring AI + 向量数据库做知识库问答 |
|
||||||
|
| 7 | Prompt 模板管理与优化 | 设计并管理多个 Prompt 模板 |
|
||||||
|
|
||||||
|
**本周产出**:一个支持 Function Calling 和多轮对话的 Agent 后端。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 第 11 周:算法入门 + Agent 前端
|
||||||
|
|
||||||
|
**目标**:学习基础算法,构建 Agent 前端交互界面。
|
||||||
|
|
||||||
|
| 天 | 学习内容 | 实践 |
|
||||||
|
|----|---------|------|
|
||||||
|
| 1 | 时间复杂度、空间复杂度、大 O 表示法 | 分析常见代码的时间复杂度 |
|
||||||
|
| 2 | 排序算法(冒泡/快排/归并) | 手写三种排序并比较性能 |
|
||||||
|
| 3 | 搜索算法(二分、BFS、DFS) | 用 BFS 解决迷宫问题 |
|
||||||
|
| 4 | 哈希表、常用技巧(双指针、滑动窗口) | LeetCode 简单题 3-5 道 |
|
||||||
|
| 5 | AI Agent 前端页面设计 | 用 Vue 3 设计聊天界面 |
|
||||||
|
| 6 | 流式对话前端实现(SSE 对接) | 前端实现打字机效果 |
|
||||||
|
| 7 | 对话历史管理、Markdown 渲染 | 对话列表、消息气泡、代码高亮 |
|
||||||
|
|
||||||
|
**本周产出**:Agent 聊天前端页面,支持流式输出和历史记录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 第 12 周:最终项目 —— AI Agent 网站
|
||||||
|
|
||||||
|
**目标**:整合所有技能,交付完整 AI Agent 网站。
|
||||||
|
|
||||||
|
| 天 | 学习内容 | 实践 |
|
||||||
|
|----|---------|------|
|
||||||
|
| 1 | 项目架构设计 | 设计 Agent 网站的完整架构图 |
|
||||||
|
| 2 | Agent 后端开发(工具集成) | 集成搜索、计算、天气等工具 |
|
||||||
|
| 3 | Agent 后端开发(记忆 + RAG) | 完善记忆系统和知识库 |
|
||||||
|
| 4 | 前端页面完善(聊天 + 设置 + 工具面板) | 完成所有前端页面 |
|
||||||
|
| 5 | 前后端联调、错误处理 | 全流程测试和 bug 修复 |
|
||||||
|
| 6 | 部署上线(Docker + Nginx) | 打包部署到服务器 |
|
||||||
|
| 7 | 项目总结与复盘 | 写项目 README、整理技术文档 |
|
||||||
|
|
||||||
|
**最终项目**:AI Agent 网站 ——
|
||||||
|
|
||||||
|
```
|
||||||
|
功能清单:
|
||||||
|
├── 🤖 智能对话 —— 多轮对话,流式输出
|
||||||
|
├── 🔧 工具调用 —— 联网搜索、计算器、天气查询
|
||||||
|
├── 📚 知识库 —— RAG 文档问答
|
||||||
|
├── 🧠 记忆系统 —— 跨会话记忆
|
||||||
|
├── 👤 用户系统 —— 注册登录、JWT 认证
|
||||||
|
├── 💬 聊天界面 —— Markdown 渲染、代码高亮
|
||||||
|
└── ⚙️ 系统设置 —— 模型选择、参数配置
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 学习节奏建议
|
||||||
|
|
||||||
|
| 事项 | 建议 |
|
||||||
|
|------|------|
|
||||||
|
| 每天学习时间 | 2-3 小时(周末可加量到 4-6 小时) |
|
||||||
|
| 代码量 | 每天至少手写 50-100 行代码 |
|
||||||
|
| 复习频率 | 每周日复习本周内容,每月底做一次大复习 |
|
||||||
|
| 遇到问题 | 先自己 Debug 15 分钟 → Google/StackOverflow → 问 AI |
|
||||||
|
| 笔记 | 用 Markdown 记录核心概念和自己的理解 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 推荐学习资源
|
||||||
|
|
||||||
|
### 书籍
|
||||||
|
- 《Head First Java》—— Java 入门首选
|
||||||
|
- 《Spring Boot 实战派》—— Spring Boot 快速上手
|
||||||
|
- 《Vue.js 快速上手》—— 前端框架入门
|
||||||
|
|
||||||
|
### 视频
|
||||||
|
- B 站尚硅谷/黑马程序员 Spring Boot 教程
|
||||||
|
- B 站 Vue 3 入门教程
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- [Spring Boot 官方文档](https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/)
|
||||||
|
- [Spring Data JPA 官方文档](https://docs.spring.io/spring-data/jpa/reference/)
|
||||||
|
- [MyBatis-Plus 官方文档](https://baomidou.com/)
|
||||||
|
- [Spring AI 官方文档](https://docs.spring.io/spring-ai/reference/)
|
||||||
|
- [Vue 3 官方文档](https://cn.vuejs.org/)
|
||||||
|
|
||||||
|
### 刷题
|
||||||
|
- LeetCode 简单难度(从第 2 个月开始每天 1 题)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 每周检查点
|
||||||
|
|
||||||
|
| 周次 | 核心检查项 |
|
||||||
|
|------|-----------|
|
||||||
|
| 第 1 周 | 能用 Java 写出面向对象的命令行程序 |
|
||||||
|
| 第 2 周 | 能用 Spring Boot 写 RESTful API |
|
||||||
|
| 第 3 周 | 能用 JPA 和 MyBatis-Plus 分别操作数据库 |
|
||||||
|
| 第 4 周 | 第一个全栈 CRUD 应用(双 ORM 实现可切换) |
|
||||||
|
| 第 5 周 | 理解并实现 JWT 认证 |
|
||||||
|
| 第 6 周 | 能用 Vue 3 写 SPA 应用 |
|
||||||
|
| 第 7 周 | 前后端分离完整对接 |
|
||||||
|
| 第 8 周 | 有测试、有文档 |
|
||||||
|
| 第 9 周 | 能调用大模型 API 实现对话 |
|
||||||
|
| 第 10 周 | 理解 Agent 架构 + Function Calling |
|
||||||
|
| 第 11 周 | 算法入门 + Agent 聊天前端 |
|
||||||
|
| 第 12 周 | AI Agent 网站上线 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **最重要的原则**:多写代码,少看视频。看完概念立刻动手,遇到 bug 是学习的最好机会。
|
||||||
258
week1/addressbook/AddressBookApp.java
Normal file
258
week1/addressbook/AddressBookApp.java
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package addressbook;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 7 天综合项目 —— 命令行通讯录
|
||||||
|
*
|
||||||
|
* 综合运用本周所学:
|
||||||
|
* 1. 类与对象(Contact 内部类)
|
||||||
|
* 2. 封装(private 属性 + getter/setter)
|
||||||
|
* 3. 集合框架(HashMap 存储,ArrayList 排序)
|
||||||
|
* 4. 流程控制(while 菜单循环 + switch 分支)
|
||||||
|
* 5. 异常处理(try-catch)
|
||||||
|
* 6. 方法封装(每个功能一个方法)
|
||||||
|
*/
|
||||||
|
public class AddressBookApp {
|
||||||
|
|
||||||
|
private static final Scanner scanner = new Scanner(System.in);
|
||||||
|
// 核心数据结构:以姓名为 key,Contact 对象为 value
|
||||||
|
private static final Map<String, Contact> contacts = new LinkedHashMap<>();
|
||||||
|
// LinkedHashMap 保持插入顺序,方便"查看全部"时有序展示
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println("============================================");
|
||||||
|
System.out.println(" 📒 命令行通讯录 v1.0");
|
||||||
|
System.out.println(" 你的第一个 Java 项目!");
|
||||||
|
System.out.println("============================================");
|
||||||
|
|
||||||
|
// 预置几条测试数据,方便快速体验
|
||||||
|
initSampleData();
|
||||||
|
|
||||||
|
// 主循环
|
||||||
|
while (true) {
|
||||||
|
printMenu();
|
||||||
|
int choice = readChoice();
|
||||||
|
if (!executeChoice(choice)) {
|
||||||
|
break; // 用户选择退出
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 菜单 ====================
|
||||||
|
|
||||||
|
static void printMenu() {
|
||||||
|
System.out.println("\n-----------------------------------------");
|
||||||
|
System.out.println(" 1. 添加联系人");
|
||||||
|
System.out.println(" 2. 查看全部联系人");
|
||||||
|
System.out.println(" 3. 搜索联系人");
|
||||||
|
System.out.println(" 4. 修改联系人");
|
||||||
|
System.out.println(" 5. 删除联系人");
|
||||||
|
System.out.println(" 6. 退出");
|
||||||
|
System.out.println("-----------------------------------------");
|
||||||
|
System.out.print("请输入你的选择 (1-6): ");
|
||||||
|
}
|
||||||
|
|
||||||
|
static int readChoice() {
|
||||||
|
try {
|
||||||
|
return scanner.nextInt();
|
||||||
|
} catch (InputMismatchException e) {
|
||||||
|
scanner.nextLine(); // 清掉无效输入
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return false 表示用户选择了退出 */
|
||||||
|
static boolean executeChoice(int choice) {
|
||||||
|
scanner.nextLine(); // 清除换行符(nextInt 不会消耗换行符)
|
||||||
|
|
||||||
|
switch (choice) {
|
||||||
|
case 1 -> addContact();
|
||||||
|
case 2 -> listContacts();
|
||||||
|
case 3 -> searchContact();
|
||||||
|
case 4 -> updateContact();
|
||||||
|
case 5 -> deleteContact();
|
||||||
|
case 6 -> {
|
||||||
|
System.out.println("\n再见!👋");
|
||||||
|
scanner.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
default -> System.out.println("⚠ 无效选项,请输入 1-6。");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CRUD 操作 ====================
|
||||||
|
|
||||||
|
/** 1. 添加联系人 */
|
||||||
|
static void addContact() {
|
||||||
|
System.out.println("\n--- 添加联系人 ---");
|
||||||
|
|
||||||
|
String name = readInput("姓名: ");
|
||||||
|
// 校验:姓名不能为空
|
||||||
|
if (name.trim().isEmpty()) {
|
||||||
|
System.out.println("⚠ 姓名不能为空!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 校验:不允许重名(HashMap 的 key 唯一)
|
||||||
|
if (contacts.containsKey(name)) {
|
||||||
|
System.out.println("⚠ 联系人 [" + name + "] 已存在!如需修改请选择功能 4。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String phone = readInput("电话: ");
|
||||||
|
String email = readInput("邮箱: ");
|
||||||
|
String address = readInput("地址: ");
|
||||||
|
|
||||||
|
Contact c = new Contact(name, phone, email, address);
|
||||||
|
contacts.put(name, c);
|
||||||
|
System.out.println("✅ 联系人 [" + name + "] 已添加!");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2. 查看全部联系人 */
|
||||||
|
static void listContacts() {
|
||||||
|
System.out.println("\n--- 全部联系人 ---");
|
||||||
|
|
||||||
|
if (contacts.isEmpty()) {
|
||||||
|
System.out.println("通讯录为空,快去添加第一个联系人吧!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印表头
|
||||||
|
System.out.printf("%-4s %-12s %-16s %-20s %-20s\n", "序号", "姓名", "电话", "邮箱", "地址");
|
||||||
|
System.out.println("---- ------------ ---------------- -------------------- --------------------");
|
||||||
|
|
||||||
|
int index = 1;
|
||||||
|
for (Contact c : contacts.values()) {
|
||||||
|
System.out.printf("%-4d %-12s %-16s %-20s %-20s\n",
|
||||||
|
index++, c.name, c.phone, c.email, c.address);
|
||||||
|
}
|
||||||
|
System.out.println("\n共 " + contacts.size() + " 个联系人。");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 3. 搜索联系人(支持姓名精确搜索,以及姓名/电话的模糊搜索) */
|
||||||
|
static void searchContact() {
|
||||||
|
System.out.println("\n--- 搜索联系人 ---");
|
||||||
|
String keyword = readInput("请输入搜索关键词(支持姓名或电话的模糊匹配): ").trim();
|
||||||
|
|
||||||
|
if (keyword.isEmpty()) {
|
||||||
|
System.out.println("⚠ 搜索关键词不能为空!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Contact> results = new ArrayList<>();
|
||||||
|
for (Contact c : contacts.values()) {
|
||||||
|
if (c.name.contains(keyword) || c.phone.contains(keyword)) {
|
||||||
|
results.add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.isEmpty()) {
|
||||||
|
System.out.println("未找到匹配的联系人。");
|
||||||
|
} else {
|
||||||
|
System.out.println("\n找到 " + results.size() + " 条记录:");
|
||||||
|
for (Contact c : results) {
|
||||||
|
System.out.println(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4. 修改联系人 */
|
||||||
|
static void updateContact() {
|
||||||
|
System.out.println("\n--- 修改联系人 ---");
|
||||||
|
String name = readInput("请输入要修改的联系人姓名: ").trim();
|
||||||
|
|
||||||
|
Contact target = contacts.get(name);
|
||||||
|
if (target == null) {
|
||||||
|
System.out.println("⚠ 未找到联系人 [" + name + "]。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("当前信息:" + target);
|
||||||
|
System.out.println("(直接回车保留原值,输入新值则更新)");
|
||||||
|
|
||||||
|
String newPhone = readInput("新电话 [" + target.phone + "]: ");
|
||||||
|
if (!newPhone.trim().isEmpty()) {
|
||||||
|
target.setPhone(newPhone);
|
||||||
|
}
|
||||||
|
|
||||||
|
String newEmail = readInput("新邮箱 [" + target.email + "]: ");
|
||||||
|
if (!newEmail.trim().isEmpty()) {
|
||||||
|
target.setEmail(newEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
String newAddress = readInput("新地址 [" + target.address + "]: ");
|
||||||
|
if (!newAddress.trim().isEmpty()) {
|
||||||
|
target.setAddress(newAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("✅ 联系人 [" + name + "] 已更新!");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 5. 删除联系人 */
|
||||||
|
static void deleteContact() {
|
||||||
|
System.out.println("\n--- 删除联系人 ---");
|
||||||
|
String name = readInput("请输入要删除的联系人姓名: ").trim();
|
||||||
|
|
||||||
|
if (!contacts.containsKey(name)) {
|
||||||
|
System.out.println("⚠ 未找到联系人 [" + name + "]。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二次确认
|
||||||
|
System.out.print("确认删除 [" + name + "]?(y/n): ");
|
||||||
|
String confirm = scanner.nextLine().trim().toLowerCase();
|
||||||
|
|
||||||
|
if ("y".equals(confirm) || "yes".equals(confirm)) {
|
||||||
|
contacts.remove(name);
|
||||||
|
System.out.println("✅ 联系人 [" + name + "] 已删除!");
|
||||||
|
} else {
|
||||||
|
System.out.println("已取消删除。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 工具方法 ====================
|
||||||
|
|
||||||
|
/** 打印提示并读取一行输入 */
|
||||||
|
static String readInput(String prompt) {
|
||||||
|
System.out.print(prompt);
|
||||||
|
return scanner.nextLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预置示例数据 */
|
||||||
|
static void initSampleData() {
|
||||||
|
contacts.put("张三", new Contact("张三", "13800138001", "zhangsan@mail.com", "北京市朝阳区"));
|
||||||
|
contacts.put("李四", new Contact("李四", "13900139002", "lisi@mail.com", "上海市浦东新区"));
|
||||||
|
contacts.put("王五", new Contact("王五", "13700137003", "wangwu@mail.com", "广州市天河区"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Contact 实体类 ====================
|
||||||
|
|
||||||
|
class Contact {
|
||||||
|
String name;
|
||||||
|
String phone;
|
||||||
|
String email;
|
||||||
|
String address;
|
||||||
|
|
||||||
|
public Contact(String name, String phone, String email, String address) {
|
||||||
|
this.name = name;
|
||||||
|
this.phone = phone;
|
||||||
|
this.email = email;
|
||||||
|
this.address = address;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getter / Setter(体现封装)
|
||||||
|
public String getName() { return name; }
|
||||||
|
public String getPhone() { return phone; }
|
||||||
|
public void setPhone(String phone) { this.phone = phone; }
|
||||||
|
public String getEmail() { return email; }
|
||||||
|
public void setEmail(String email) { this.email = email; }
|
||||||
|
public String getAddress() { return address; }
|
||||||
|
public void setAddress(String address) { this.address = address; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return String.format("[%s] 电话:%s 邮箱:%s 地址:%s", name, phone, email, address);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
week1/day01/HelloWorld.java
Normal file
36
week1/day01/HelloWorld.java
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package day01;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 1 天:Hello World
|
||||||
|
* 目标:理解 Java 程序的基本结构和运行方式
|
||||||
|
*
|
||||||
|
* 概念速查:
|
||||||
|
* public class —— 声明一个公共类,类名必须和文件名一致
|
||||||
|
* public static void main(String[] args) —— 程序的入口方法
|
||||||
|
* System.out.println() —— 向控制台输出一行文字
|
||||||
|
*
|
||||||
|
* 运行方式:
|
||||||
|
* javac HelloWorld.java (编译生成 HelloWorld.class)
|
||||||
|
* java HelloWorld (运行)
|
||||||
|
*/
|
||||||
|
public class HelloWorld {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
// 1. 最简单的输出
|
||||||
|
System.out.println("Hello, Java!");
|
||||||
|
|
||||||
|
// 2. 加点花样:用转义字符
|
||||||
|
System.out.println("-------------------");
|
||||||
|
System.out.println("欢迎来到 Java 世界!");
|
||||||
|
System.out.println("-------------------");
|
||||||
|
|
||||||
|
// 3. print vs println(print 不换行)
|
||||||
|
System.out.print("Java ");
|
||||||
|
System.out.print("一次编译,");
|
||||||
|
System.out.println("到处运行。");
|
||||||
|
|
||||||
|
// 4. 转义字符
|
||||||
|
System.out.println("第一行\n第二行"); // \n 换行
|
||||||
|
System.out.println("姓名\t年龄\t成绩"); // \t 制表符
|
||||||
|
System.out.println("他说:\"你好\""); // \" 双引号
|
||||||
|
}
|
||||||
|
}
|
||||||
78
week1/day02/Calculator.java
Normal file
78
week1/day02/Calculator.java
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package day02;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 2 天:计算器 —— 变量、数据类型、运算符
|
||||||
|
* 目标:掌握 Java 基本数据类型和运算符
|
||||||
|
*
|
||||||
|
* 概念速查:
|
||||||
|
* int —— 整数(4 字节,约 ±21 亿)
|
||||||
|
* double —— 浮点数(8 字节,双精度)
|
||||||
|
* Scanner —— 从控制台读取用户输入
|
||||||
|
* + - * / % —— 加减乘除取余
|
||||||
|
*/
|
||||||
|
import java.util.Scanner;
|
||||||
|
|
||||||
|
public class Calculator {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
Scanner scanner = new Scanner(System.in);
|
||||||
|
|
||||||
|
System.out.println("========== 简易计算器 ==========");
|
||||||
|
|
||||||
|
// 读取第一个数
|
||||||
|
System.out.print("请输入第一个数字: ");
|
||||||
|
double num1 = scanner.nextDouble();
|
||||||
|
|
||||||
|
// 读取运算符
|
||||||
|
System.out.print("请输入运算符 (+, -, *, /): ");
|
||||||
|
// 注意:next() 读取字符串,charAt(0) 取第一个字符
|
||||||
|
char operator = scanner.next().charAt(0);
|
||||||
|
|
||||||
|
// 读取第二个数
|
||||||
|
System.out.print("请输入第二个数字: ");
|
||||||
|
double num2 = scanner.nextDouble();
|
||||||
|
|
||||||
|
double result = 0;
|
||||||
|
boolean valid = true; // 标记运算是否合法
|
||||||
|
|
||||||
|
// 根据运算符执行不同运算
|
||||||
|
switch (operator) {
|
||||||
|
case '+':
|
||||||
|
result = num1 + num2;
|
||||||
|
break;
|
||||||
|
case '-':
|
||||||
|
result = num1 - num2;
|
||||||
|
break;
|
||||||
|
case '*':
|
||||||
|
result = num1 * num2;
|
||||||
|
break;
|
||||||
|
case '/':
|
||||||
|
// 除法需要特殊处理:除数不能为 0
|
||||||
|
if (num2 != 0) {
|
||||||
|
result = num1 / num2;
|
||||||
|
} else {
|
||||||
|
System.out.println("错误:除数不能为零!");
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
System.out.println("错误:不支持的运算符!");
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输出结果
|
||||||
|
if (valid) {
|
||||||
|
System.out.println("----------------------------");
|
||||||
|
System.out.printf("%.2f %c %.2f = %.2f\n", num1, operator, num2, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 额外练习:类型转换演示
|
||||||
|
System.out.println("\n--- 类型转换小实验 ---");
|
||||||
|
int a = 10;
|
||||||
|
int b = 3;
|
||||||
|
System.out.println("整数除法 10 / 3 = " + (a / b)); // 输出 3(截断)
|
||||||
|
System.out.println("浮点除法 10.0 / 3 = " + (10.0 / b)); // 输出 3.333...
|
||||||
|
System.out.println("取余 10 % 3 = " + (a % b)); // 输出 1
|
||||||
|
|
||||||
|
scanner.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
63
week1/day03/GuessNumber.java
Normal file
63
week1/day03/GuessNumber.java
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package day03;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 3 天:猜数字游戏 —— while 循环 + if 判断
|
||||||
|
* 目标:掌握 while 循环和条件判断的配合使用
|
||||||
|
*
|
||||||
|
* 游戏规则:程序随机生成 1-100 的数字,玩家最多猜 7 次,
|
||||||
|
* 每次猜测后提示"大了"或"小了",猜中或次数用完则结束。
|
||||||
|
*/
|
||||||
|
import java.util.Scanner;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
public class GuessNumber {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
Scanner scanner = new Scanner(System.in);
|
||||||
|
Random random = new Random();
|
||||||
|
|
||||||
|
// 生成 1~100 的随机整数
|
||||||
|
int target = random.nextInt(100) + 1;
|
||||||
|
|
||||||
|
int maxAttempts = 7; // 最多猜 7 次
|
||||||
|
int attempts = 0; // 已猜次数
|
||||||
|
boolean guessed = false;
|
||||||
|
|
||||||
|
System.out.println("========== 猜数字游戏 ==========");
|
||||||
|
System.out.println("我已想好一个 1~100 之间的数字,你有 " + maxAttempts + " 次机会!");
|
||||||
|
|
||||||
|
// while 循环:当还有剩余次数且未猜中时继续
|
||||||
|
while (attempts < maxAttempts && !guessed) {
|
||||||
|
attempts++;
|
||||||
|
System.out.print("\n第 " + attempts + " 次猜测,请输入数字: ");
|
||||||
|
int guess = scanner.nextInt();
|
||||||
|
|
||||||
|
if (guess < 1 || guess > 100) {
|
||||||
|
System.out.println("请输入 1~100 之间的数字!");
|
||||||
|
attempts--; // 不浪费这次机会
|
||||||
|
continue; // 跳过本次循环剩余代码
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guess == target) {
|
||||||
|
System.out.println("\n🎉 恭喜你猜对了!答案就是 " + target + "!");
|
||||||
|
System.out.println("你一共猜了 " + attempts + " 次。");
|
||||||
|
guessed = true;
|
||||||
|
} else if (guess > target) {
|
||||||
|
System.out.println("太大了!");
|
||||||
|
} else {
|
||||||
|
System.out.println("太小了!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提示剩余次数
|
||||||
|
if (!guessed && attempts < maxAttempts) {
|
||||||
|
System.out.println("还有 " + (maxAttempts - attempts) + " 次机会。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没猜中的情况
|
||||||
|
if (!guessed) {
|
||||||
|
System.out.println("\n😢 很遗憾,次数用完了!正确答案是 " + target + "。");
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
47
week1/day03/MultiplicationTable.java
Normal file
47
week1/day03/MultiplicationTable.java
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package day03;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 3 天:九九乘法表 —— for 循环嵌套
|
||||||
|
* 目标:理解 for 循环的执行顺序和嵌套逻辑
|
||||||
|
*
|
||||||
|
* 输出效果:
|
||||||
|
* 1×1=1
|
||||||
|
* 1×2=2 2×2=4
|
||||||
|
* 1×3=3 2×3=6 3×3=9
|
||||||
|
* ...
|
||||||
|
* 1×9=9 2×9=18 3×9=27 ... 9×9=81
|
||||||
|
*/
|
||||||
|
public class MultiplicationTable {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println("========== 九九乘法表 ==========\n");
|
||||||
|
|
||||||
|
// 外层循环控制行(被乘数 i 从 1 到 9)
|
||||||
|
for (int i = 1; i <= 9; i++) {
|
||||||
|
// 内层循环控制列(乘数 j 从 1 到 i,三角形输出)
|
||||||
|
for (int j = 1; j <= i; j++) {
|
||||||
|
// \t 制表符对齐 \t 制表符对齐
|
||||||
|
System.out.print(j + "×" + i + "=" + (i * j) + "\t");
|
||||||
|
}
|
||||||
|
// 每行结束后换行
|
||||||
|
System.out.println();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 附加练习:完整矩形乘法表(1-9 × 1-9)
|
||||||
|
System.out.println("\n========== 完整矩形乘法表 ==========\n");
|
||||||
|
|
||||||
|
// 先打表头
|
||||||
|
System.out.print(" |");
|
||||||
|
for (int i = 1; i <= 9; i++) {
|
||||||
|
System.out.printf("%4d", i);
|
||||||
|
}
|
||||||
|
System.out.println("\n---+------------------------------------");
|
||||||
|
|
||||||
|
for (int i = 1; i <= 9; i++) {
|
||||||
|
System.out.printf(" %d |", i);
|
||||||
|
for (int j = 1; j <= 9; j++) {
|
||||||
|
System.out.printf("%4d", i * j);
|
||||||
|
}
|
||||||
|
System.out.println();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
week1/day04/StudentScoreManager.java
Normal file
118
week1/day04/StudentScoreManager.java
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package day04;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 4 天:学生成绩管理器 —— 数组、ArrayList、方法
|
||||||
|
* 目标:掌握数据组织(数组/动态数组)和代码组织(方法封装)
|
||||||
|
*
|
||||||
|
* 功能:添加成绩、查看全部、求平均分、找最高/最低分
|
||||||
|
*/
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Scanner;
|
||||||
|
|
||||||
|
public class StudentScoreManager {
|
||||||
|
|
||||||
|
// Scanner 作为静态成员,全局复用(记得最后 close)
|
||||||
|
static Scanner scanner = new Scanner(System.in);
|
||||||
|
// ArrayList<Integer> 动态存储成绩
|
||||||
|
static ArrayList<Integer> scores = new ArrayList<>();
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
while (true) {
|
||||||
|
printMenu();
|
||||||
|
System.out.print("请选择操作 (1-5): ");
|
||||||
|
int choice = scanner.nextInt();
|
||||||
|
|
||||||
|
switch (choice) {
|
||||||
|
case 1 -> addScore();
|
||||||
|
case 2 -> showAllScores();
|
||||||
|
case 3 -> showAverage();
|
||||||
|
case 4 -> showMaxMin();
|
||||||
|
case 5 -> {
|
||||||
|
System.out.println("再见!");
|
||||||
|
scanner.close();
|
||||||
|
return; // 退出程序
|
||||||
|
}
|
||||||
|
default -> System.out.println("无效选项,请重试。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 方法定义 ====================
|
||||||
|
|
||||||
|
/** 打印菜单 */
|
||||||
|
static void printMenu() {
|
||||||
|
System.out.println("\n========== 学生成绩管理器 ==========");
|
||||||
|
System.out.println("1. 添加成绩");
|
||||||
|
System.out.println("2. 查看全部成绩");
|
||||||
|
System.out.println("3. 查看平均分");
|
||||||
|
System.out.println("4. 查看最高分 / 最低分");
|
||||||
|
System.out.println("5. 退出");
|
||||||
|
System.out.println("====================================");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 添加成绩 */
|
||||||
|
static void addScore() {
|
||||||
|
System.out.print("请输入成绩 (0-100): ");
|
||||||
|
int score = scanner.nextInt();
|
||||||
|
|
||||||
|
// 基本输入校验
|
||||||
|
if (score < 0 || score > 100) {
|
||||||
|
System.out.println("成绩必须在 0-100 之间!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scores.add(score);
|
||||||
|
System.out.println("✅ 第 " + scores.size() + " 个学生的成绩已添加: " + score);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查看全部成绩 */
|
||||||
|
static void showAllScores() {
|
||||||
|
if (scores.isEmpty()) {
|
||||||
|
System.out.println("暂无成绩记录。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("\n--- 全部成绩 ---");
|
||||||
|
// 传统 for 循环(需要索引)
|
||||||
|
for (int i = 0; i < scores.size(); i++) {
|
||||||
|
System.out.printf("学生 %d: %d 分\n", i + 1, scores.get(i));
|
||||||
|
}
|
||||||
|
System.out.println("共 " + scores.size() + " 名学生的成绩。");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 求平均分 */
|
||||||
|
static void showAverage() {
|
||||||
|
if (scores.isEmpty()) {
|
||||||
|
System.out.println("暂无成绩记录,无法计算平均分。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遍历求和
|
||||||
|
int sum = 0;
|
||||||
|
for (int s : scores) { // 增强 for 循环(foreach)
|
||||||
|
sum += s;
|
||||||
|
}
|
||||||
|
double average = (double) sum / scores.size(); // 强制转换防截断
|
||||||
|
|
||||||
|
System.out.printf("平均分: %.2f(共 %d 名学生)\n", average, scores.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 找最高分和最低分 */
|
||||||
|
static void showMaxMin() {
|
||||||
|
if (scores.isEmpty()) {
|
||||||
|
System.out.println("暂无成绩记录。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int max = scores.get(0);
|
||||||
|
int min = scores.get(0);
|
||||||
|
|
||||||
|
for (int s : scores) {
|
||||||
|
if (s > max) max = s;
|
||||||
|
if (s < min) min = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("最高分: " + max);
|
||||||
|
System.out.println("最低分: " + min);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
week1/day05/Student.java
Normal file
117
week1/day05/Student.java
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package day05;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 5 天:面向对象(上)—— 类与对象、封装
|
||||||
|
* 目标:掌握如何定义一个类,如何创建和使用对象
|
||||||
|
*
|
||||||
|
* 重点:
|
||||||
|
* private 成员变量 + public getter/setter = 封装
|
||||||
|
* 构造方法 = 初始化对象的快捷方式
|
||||||
|
* this = 区分成员变量和局部变量
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== Student 类定义 ====================
|
||||||
|
class Student {
|
||||||
|
|
||||||
|
// 1. 私有成员变量(封装:外部不能直接访问)
|
||||||
|
private String name;
|
||||||
|
private int age;
|
||||||
|
private String studentId; // 学号
|
||||||
|
|
||||||
|
// 2. 构造方法(与类同名,无返回值)
|
||||||
|
// 无参构造
|
||||||
|
public Student() {
|
||||||
|
this.name = "未知";
|
||||||
|
this.age = 0;
|
||||||
|
this.studentId = "未分配";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有参构造(支持创建对象时直接赋值)
|
||||||
|
public Student(String name, int age, String studentId) {
|
||||||
|
this.name = name;
|
||||||
|
this.age = age;
|
||||||
|
this.studentId = studentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Getter / Setter(公开的访问入口)
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
// 可以在 setter 里加校验逻辑
|
||||||
|
if (name == null || name.trim().isEmpty()) {
|
||||||
|
System.out.println("⚠ 姓名不能为空!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getAge() {
|
||||||
|
return age;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAge(int age) {
|
||||||
|
if (age < 0 || age > 150) {
|
||||||
|
System.out.println("⚠ 年龄不合理!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.age = age;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStudentId() {
|
||||||
|
return studentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStudentId(String studentId) {
|
||||||
|
this.studentId = studentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 行为方法
|
||||||
|
public void study(String subject) {
|
||||||
|
System.out.println(name + " 正在学习 " + subject + "。");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void takeExam(String subject) {
|
||||||
|
System.out.println(name + "(学号:" + studentId + ")正在参加 " + subject + " 考试。");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void introduce() {
|
||||||
|
System.out.println("-----------------------------------");
|
||||||
|
System.out.println("自我介绍:");
|
||||||
|
System.out.println(" 姓名:" + name);
|
||||||
|
System.out.println(" 年龄:" + age + " 岁");
|
||||||
|
System.out.println(" 学号:" + studentId);
|
||||||
|
System.out.println("-----------------------------------");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 测试类 ====================
|
||||||
|
class StudentTest {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println("========== 面向对象练习 ==========\n");
|
||||||
|
|
||||||
|
// 1. 使用无参构造 + setter 创建对象
|
||||||
|
Student s1 = new Student();
|
||||||
|
s1.setName("张三");
|
||||||
|
s1.setAge(20);
|
||||||
|
s1.setStudentId("2024001");
|
||||||
|
s1.introduce();
|
||||||
|
s1.study("Java");
|
||||||
|
s1.takeExam("面向对象程序设计");
|
||||||
|
|
||||||
|
// 2. 使用有参构造直接创建
|
||||||
|
Student s2 = new Student("李四", 22, "2024002");
|
||||||
|
s2.introduce();
|
||||||
|
|
||||||
|
// 3. 创建第三个学生
|
||||||
|
Student s3 = new Student("王五", 19, "2024003");
|
||||||
|
s3.introduce();
|
||||||
|
s3.study("数据结构");
|
||||||
|
|
||||||
|
// 4. 演示数据校验
|
||||||
|
System.out.println("\n--- 测试 data 校验 ---");
|
||||||
|
s3.setAge(-5); // 触发校验
|
||||||
|
s3.setName(""); // 触发校验
|
||||||
|
}
|
||||||
|
}
|
||||||
145
week1/day06/AnimalHierarchy.java
Normal file
145
week1/day06/AnimalHierarchy.java
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package day06;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 6 天:面向对象(下)—— 继承、多态、接口、抽象类
|
||||||
|
* 目标:理解 OOP 三大特性中的"继承"和"多态"
|
||||||
|
*
|
||||||
|
* 重点:
|
||||||
|
* extends —— 单继承:子类拥有父类的属性和方法
|
||||||
|
* @Override —— 方法重写:子类用自己的实现覆盖父类方法
|
||||||
|
* 抽象类 —— 不能被 new,可以有抽象方法(留给子类实现)
|
||||||
|
* 接口 —— 一组行为规范(方法签名),一个类可以实现多个接口
|
||||||
|
* 多态 —— 父类引用指向子类对象:Animal a = new Dog();
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== 1. 抽象父类 ====================
|
||||||
|
abstract class Animal {
|
||||||
|
protected String name;
|
||||||
|
|
||||||
|
public Animal(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 抽象方法:没有方法体,子类必须实现
|
||||||
|
public abstract String makeSound();
|
||||||
|
|
||||||
|
// 普通方法:有方法体,子类可以继承也可以重写
|
||||||
|
public void eat() {
|
||||||
|
System.out.println(name + " 正在吃东西。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 2. 接口 ====================
|
||||||
|
interface Pet {
|
||||||
|
void play(); // 接口中的方法默认是 public abstract
|
||||||
|
String getBreed(); // 品种
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 3. 子类 Dog ====================
|
||||||
|
class Dog extends Animal implements Pet {
|
||||||
|
private String breed; // 品种
|
||||||
|
|
||||||
|
public Dog(String name, String breed) {
|
||||||
|
super(name); // 调用父类构造方法(必须放在第一行)
|
||||||
|
this.breed = breed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String makeSound() {
|
||||||
|
return "汪汪汪!";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void play() {
|
||||||
|
System.out.println(name + "(" + breed + ")兴奋地摇着尾巴,接住了飞盘!");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getBreed() {
|
||||||
|
return breed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void eat() {
|
||||||
|
System.out.println(name + " 狼吞虎咽地吃着狗粮。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 4. 子类 Cat ====================
|
||||||
|
class Cat extends Animal implements Pet {
|
||||||
|
private String breed;
|
||||||
|
|
||||||
|
public Cat(String name, String breed) {
|
||||||
|
super(name);
|
||||||
|
this.breed = breed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String makeSound() {
|
||||||
|
return "喵喵喵~";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void play() {
|
||||||
|
System.out.println(name + "(" + breed + ")懒洋洋地拨弄着逗猫棒。");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getBreed() {
|
||||||
|
return breed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void eat() {
|
||||||
|
System.out.println(name + " 优雅地小口吃着猫粮。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 测试类 ====================
|
||||||
|
class AnimalTest {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println("========== 动物继承体系演示 ==========\n");
|
||||||
|
|
||||||
|
// 1. 基本使用
|
||||||
|
Dog wangcai = new Dog("旺财", "金毛");
|
||||||
|
Cat miaomiao = new Cat("咪咪", "英短");
|
||||||
|
|
||||||
|
System.out.println("--- 各自叫 ---");
|
||||||
|
System.out.println(wangcai.getName() + ":" + wangcai.makeSound());
|
||||||
|
System.out.println(miaomiao.getName() + ":" + miaomiao.makeSound());
|
||||||
|
|
||||||
|
System.out.println("\n--- 各自吃 ---");
|
||||||
|
wangcai.eat();
|
||||||
|
miaomiao.eat();
|
||||||
|
|
||||||
|
System.out.println("\n--- 各自玩 ---");
|
||||||
|
wangcai.play();
|
||||||
|
miaomiao.play();
|
||||||
|
|
||||||
|
// 2. ★ 多态:父类引用指向子类对象
|
||||||
|
System.out.println("\n--- 多态演示 ---");
|
||||||
|
Animal[] zoo = { // Animal 类型的数组
|
||||||
|
new Dog("阿黄", "中华田园犬"),
|
||||||
|
new Cat("小花", "橘猫"),
|
||||||
|
new Dog("小黑", "拉布拉多")
|
||||||
|
};
|
||||||
|
|
||||||
|
for (Animal a : zoo) {
|
||||||
|
// 运行时决定调用哪个子类的 makeSound(动态绑定)
|
||||||
|
System.out.println(a.getName() + ":" + a.makeSound());
|
||||||
|
a.eat();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 接口引用
|
||||||
|
System.out.println("\n--- 接口多态 ---");
|
||||||
|
Pet[] pets = { wangcai, miaomiao };
|
||||||
|
for (Pet p : pets) {
|
||||||
|
System.out.print(p.getBreed() + " -> ");
|
||||||
|
p.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
157
week1/教案.md
Normal file
157
week1/教案.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# 第一阶段教案:Java 核心基础速通(第 1 周)
|
||||||
|
|
||||||
|
> **学习周期**:7 天
|
||||||
|
> **每日用时**:2-3 小时
|
||||||
|
> **教学方法**:概念讲解(30min)→ 代码演示(30min)→ 动手练习(1-2h)
|
||||||
|
> **最终产出**:命令行通讯录程序
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 每日教学大纲
|
||||||
|
|
||||||
|
### 第 1 天:Hello World —— 迈出第一步
|
||||||
|
|
||||||
|
**核心概念**:JDK/JRE/JVM 的关系、Java 跨平台原理、编译与运行
|
||||||
|
|
||||||
|
**教学重点**:
|
||||||
|
1. JDK(开发工具包)包含 JRE(运行环境),JRE 包含 JVM(虚拟机)
|
||||||
|
2. Java 程序先编译成 .class 字节码,再由 JVM 执行 —— "一次编译,到处运行"
|
||||||
|
3. `javac` 是编译器,`java` 是启动器
|
||||||
|
4. `public static void main(String[] args)` —— 程序的唯一入口
|
||||||
|
|
||||||
|
**代码示例** `day01/HelloWorld.java`:
|
||||||
|
```java
|
||||||
|
public class HelloWorld {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println("Hello, Java!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**常见踩坑**:
|
||||||
|
- IDEA 中右键 → Run 即可运行,无需手动配置环境变量
|
||||||
|
- 文件名必须和 public 类名完全一致(包括大小写)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 第 2 天:数据与运算 —— 让程序"算"起来
|
||||||
|
|
||||||
|
**核心概念**:基本数据类型、变量声明、运算符、字符串拼接
|
||||||
|
|
||||||
|
**教学重点**:
|
||||||
|
1. 8 种基本类型:byte/short/int/long/float/double/char/boolean
|
||||||
|
2. 变量声明三要素:类型 + 名称 + 值
|
||||||
|
3. 算术运算符:`+ - * / %`,注意整数除法截断
|
||||||
|
4. 字符串用 `String`(不是基本类型,是引用类型),`+` 可拼接
|
||||||
|
|
||||||
|
**练习项目**:`day02/Calculator.java` —— 一个简单的四则运算计算器
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 第 3 天:流程控制 —— 让程序"思考"起来
|
||||||
|
|
||||||
|
**核心概念**:条件判断、循环、分支选择
|
||||||
|
|
||||||
|
**教学重点**:
|
||||||
|
1. `if / else if / else` —— 分支逻辑
|
||||||
|
2. `switch` —— 多分支(适合固定值匹配)
|
||||||
|
3. `for` 循环 —— 已知次数时使用
|
||||||
|
4. `while` 循环 —— 未知次数时使用(先判断后执行)
|
||||||
|
5. `do-while` 循环 —— 至少执行一次(先执行后判断)
|
||||||
|
6. `break` 跳出循环,`continue` 跳过本次
|
||||||
|
|
||||||
|
**练习项目**:
|
||||||
|
- `day03/MultiplicationTable.java` —— 打印 9×9 乘法表(for 嵌套)
|
||||||
|
- `day03/GuessNumber.java` —— 猜数字游戏(while + if)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 第 4 天:数组与方法 —— 组织数据与代码
|
||||||
|
|
||||||
|
**核心概念**:数组、动态数组、方法定义与调用
|
||||||
|
|
||||||
|
**教学重点**:
|
||||||
|
1. 数组声明:`int[] arr = new int[10];`
|
||||||
|
2. 数组下标从 0 开始,越界会抛 `ArrayIndexOutOfBoundsException`
|
||||||
|
3. `ArrayList` 动态数组:`ArrayList<String> list = new ArrayList<>();`
|
||||||
|
4. 方法的定义:`public static 返回类型 方法名(参数列表) { 方法体 }`
|
||||||
|
5. 方法的调用:`方法名(实参);`
|
||||||
|
6. 形参 vs 实参,值传递
|
||||||
|
|
||||||
|
**练习项目**:`day04/StudentScoreManager.java` —— 学生成绩管理器
|
||||||
|
- 添加成绩、查看全部、计算平均分、查找最高/最低分
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 第 5 天:面向对象(上)—— 用类描述世界
|
||||||
|
|
||||||
|
**核心概念**:类与对象、构造方法、封装、this 关键字
|
||||||
|
|
||||||
|
**教学重点**:
|
||||||
|
1. 类是模板,对象是实例 —— `new 类名()` 创建对象
|
||||||
|
2. 成员变量 vs 局部变量
|
||||||
|
3. 构造方法:与类同名,无返回值,用于初始化对象
|
||||||
|
4. 封装:`private` 修饰成员变量 + `public` getter/setter
|
||||||
|
5. `this` 关键字:代表当前对象,区分成员变量和局部变量
|
||||||
|
|
||||||
|
**练习项目**:`day05/Student.java` —— 定义 Student 类
|
||||||
|
- 属性:姓名、年龄、学号(private)
|
||||||
|
- 方法:学习、考试、自我介绍
|
||||||
|
- 写测试类创建 3 个学生对象并调用方法
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 第 6 天:面向对象(下)—— 继承与多态
|
||||||
|
|
||||||
|
**核心概念**:继承、多态、接口、抽象类
|
||||||
|
|
||||||
|
**教学重点**:
|
||||||
|
1. `extends` 继承:子类拥有父类的属性和方法(单继承)
|
||||||
|
2. `super` 调用父类构造方法或成员
|
||||||
|
3. 方法重写 `@Override`:子类重新定义父类方法
|
||||||
|
4. 多态:父类引用指向子类对象 `Animal a = new Dog();`
|
||||||
|
5. `abstract` 抽象类:不能实例化,可以有抽象方法和普通方法
|
||||||
|
6. `interface` 接口:完全抽象,一个类可以实现多个接口
|
||||||
|
|
||||||
|
**练习项目**:`day06/AnimalHierarchy.java` —— 动物继承体系
|
||||||
|
- 抽象父类 Animal(有 name 和抽象方法 makeSound)
|
||||||
|
- 子类 Dog、Cat 重写 makeSound
|
||||||
|
- 接口 Pet(有 play 方法),Dog 和 Cat 实现它
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 第 7 天:异常处理 + 集合框架 + 综合项目
|
||||||
|
|
||||||
|
**核心概念**:异常处理、集合框架(List/Map/Set)
|
||||||
|
|
||||||
|
**教学重点**:
|
||||||
|
1. try-catch-finally 处理异常,防止程序崩溃
|
||||||
|
2. `ArrayList` 有序列表(List)—— 可重复,有索引
|
||||||
|
3. `HashMap` 键值对(Map)—— key 唯一,快速查找
|
||||||
|
4. `HashSet` 无序集合(Set)—— 不可重复
|
||||||
|
5. 泛型 `<T>`:编译期类型检查
|
||||||
|
|
||||||
|
**综合项目**:`addressbook/` —— 命令行通讯录
|
||||||
|
- 封装 Contact 类
|
||||||
|
- 使用 HashMap 存储联系人(以姓名为 key)
|
||||||
|
- 菜单驱动的 CRUD 操作
|
||||||
|
- 异常处理防止崩溃
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 教学方法建议
|
||||||
|
|
||||||
|
| 环节 | 时间 | 内容 |
|
||||||
|
|------|------|------|
|
||||||
|
| 概念导入 | 15min | 用生活类比引入概念(类=图纸,对象=房子) |
|
||||||
|
| 代码演示 | 30min | 老师在 IDE 中边写边讲,每个语句解释为什么 |
|
||||||
|
| 跟练 | 30min | 学员照着敲一遍,感受语法 |
|
||||||
|
| 独立练习 | 1h | 不看参考代码,独立完成当日练习项目 |
|
||||||
|
| 复盘 | 15min | 对比参考代码,找出差异和理解盲区 |
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **不要跳步**:每天的内容建立在前一天的基础上,确保当天掌握再前进
|
||||||
|
2. **多犯错**:看到报错不要慌,仔细读错误信息是最快的进步方式
|
||||||
|
3. **先思考后动手**:在写代码前,先用中文把思路写下来
|
||||||
|
4. **代码量 > 看书量**:每天至少手敲 100 行代码,肌肉记忆很重要
|
||||||
53
week2/pom.xml
Normal file
53
week2/pom.xml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?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>week2-spring-boot</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>Week 2: Spring Boot 入门</name>
|
||||||
|
<description>从 IoC 手写到 RESTful API 的 Spring Boot 入门项目</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot Web 启动器(内置 Tomcat + Spring MVC) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Bean Validation:参数校验 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Test -->
|
||||||
|
<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>
|
||||||
21
week2/src/main/java/com/learn/Week2Application.java
Normal file
21
week2/src/main/java/com/learn/Week2Application.java
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package com.learn;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Boot 应用主入口
|
||||||
|
*
|
||||||
|
* @SpringBootApplication 是三个注解的组合:
|
||||||
|
* @SpringBootConfiguration —— 标记为配置类
|
||||||
|
* @EnableAutoConfiguration —— 自动装配(Spring Boot 的核心能力)
|
||||||
|
* @ComponentScan —— 扫描当前包及子包的组件
|
||||||
|
*
|
||||||
|
* 运行方式:IDEA 中右键此类 → Run 'Week2Application'
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
public class Week2Application {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(Week2Application.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
week2/src/main/java/com/learn/config/AppConfig.java
Normal file
44
week2/src/main/java/com/learn/config/AppConfig.java
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package com.learn.config;
|
||||||
|
|
||||||
|
import com.learn.model.Student;
|
||||||
|
import com.learn.repository.StudentRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.CommandLineRunner;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 6 天:Spring 配置类
|
||||||
|
*
|
||||||
|
* @Configuration 标记这是一个配置类,相当于一个"工厂",用于创建和管理 Bean。
|
||||||
|
* 替代了传统 Spring 中繁琐的 XML 配置文件。
|
||||||
|
*
|
||||||
|
* @Bean 方法返回的对象会被自动注册到 Spring 容器中。
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class AppConfig {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AppConfig.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用启动后执行的初始化逻辑
|
||||||
|
*
|
||||||
|
* CommandLineRunner:Spring Boot 启动完成后自动回调 run 方法。
|
||||||
|
* 常用于预置测试数据。
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
CommandLineRunner initSampleData(StudentRepository repository) {
|
||||||
|
return args -> {
|
||||||
|
log.info("正在初始化示例数据...");
|
||||||
|
|
||||||
|
repository.save(new Student(null, "张三", 20, "zhangsan@mail.com", 85));
|
||||||
|
repository.save(new Student(null, "李四", 22, "lisi@mail.com", 92));
|
||||||
|
repository.save(new Student(null, "王五", 19, "wangwu@mail.com", 78));
|
||||||
|
repository.save(new Student(null, "赵六", 21, "zhaoliu@mail.com", 88));
|
||||||
|
repository.save(new Student(null, "孙七", 23, "sunqi@mail.com", 95));
|
||||||
|
|
||||||
|
log.info("预置 {} 条学生数据完成", repository.count());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.learn.controller;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 3 天:第一个 Spring MVC 控制器
|
||||||
|
*
|
||||||
|
* @RestController = @Controller + @ResponseBody
|
||||||
|
* 表示这个类中所有方法的返回值都直接写入 HTTP 响应体(JSON 格式)
|
||||||
|
*
|
||||||
|
* 知识点:
|
||||||
|
* @GetMapping —— 处理 HTTP GET 请求
|
||||||
|
* @PathVariable —— 从 URL 路径中提取变量(如 /hello/123)
|
||||||
|
* @RequestParam —— 从 URL 查询参数中提取变量(如 /hello?name=xxx)
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class HelloController {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最简接口 —— 访问 http://localhost:8080/hello
|
||||||
|
*/
|
||||||
|
@GetMapping("/hello")
|
||||||
|
public String hello() {
|
||||||
|
return "Hello, Spring Boot!";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路径参数 —— 访问 http://localhost:8080/hello/你的名字
|
||||||
|
*/
|
||||||
|
@GetMapping("/hello/{name}")
|
||||||
|
public String helloName(@PathVariable String name) {
|
||||||
|
return "你好," + name + "!欢迎来到 Spring Boot 的世界。";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询参数 —— 访问 http://localhost:8080/greet?name=张三&age=20
|
||||||
|
*
|
||||||
|
* @RequestParam 默认要求参数必传,可以设置 required=false 表示可选
|
||||||
|
* defaultValue 设置默认值,当参数未传时使用
|
||||||
|
*/
|
||||||
|
@GetMapping("/greet")
|
||||||
|
public String greet(
|
||||||
|
@RequestParam(defaultValue = "同学") String name,
|
||||||
|
@RequestParam(defaultValue = "0") int age) {
|
||||||
|
|
||||||
|
if (age > 0) {
|
||||||
|
return String.format("你好,%s!你今年 %d 岁。", name, age);
|
||||||
|
}
|
||||||
|
return "你好," + name + "!";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回 JSON 对象 —— Spring Boot 自动将对象序列化为 JSON
|
||||||
|
* 访问 http://localhost:8080/info
|
||||||
|
*/
|
||||||
|
@GetMapping("/info")
|
||||||
|
public Object info() {
|
||||||
|
return new Status("UP", "Spring Boot 学习项目运行中", System.currentTimeMillis());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一个简单的内部类,用于返回 JSON
|
||||||
|
record Status(String status, String message, long timestamp) {}
|
||||||
|
}
|
||||||
135
week2/src/main/java/com/learn/controller/StudentController.java
Normal file
135
week2/src/main/java/com/learn/controller/StudentController.java
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package com.learn.controller;
|
||||||
|
|
||||||
|
import com.learn.model.Student;
|
||||||
|
import com.learn.service.StudentService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 4-7 天:学生管理 RESTful API
|
||||||
|
*
|
||||||
|
* RESTful 设计原则:
|
||||||
|
* GET /api/students → 查询全部
|
||||||
|
* GET /api/students/{id} → 查询单个
|
||||||
|
* POST /api/students → 新增
|
||||||
|
* PUT /api/students/{id} → 更新
|
||||||
|
* DELETE /api/students/{id} → 删除
|
||||||
|
*
|
||||||
|
* @RestController —— REST 控制器,返回 JSON
|
||||||
|
* @RequestMapping —— 统一的前缀路径
|
||||||
|
* @Valid —— 触发 Bean Validation 校验
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/students")
|
||||||
|
public class StudentController {
|
||||||
|
|
||||||
|
private final StudentService service;
|
||||||
|
|
||||||
|
public StudentController(StudentService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 查询 ====================
|
||||||
|
|
||||||
|
/** GET /api/students —— 查询全部(支持按姓名搜索) */
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<Map<String, Object>> list(
|
||||||
|
@RequestParam(required = false) String keyword) {
|
||||||
|
|
||||||
|
List<Student> students;
|
||||||
|
if (keyword != null && !keyword.trim().isEmpty()) {
|
||||||
|
students = service.search(keyword);
|
||||||
|
} else {
|
||||||
|
students = service.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 200);
|
||||||
|
result.put("total", students.size());
|
||||||
|
result.put("data", students);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/students/{id} —— 根据 ID 查询单个 */
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<Map<String, Object>> getById(@PathVariable Long id) {
|
||||||
|
return service.getById(id)
|
||||||
|
.map(student -> {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 200);
|
||||||
|
result.put("data", student);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
})
|
||||||
|
.orElseGet(() -> {
|
||||||
|
Map<String, Object> error = new HashMap<>();
|
||||||
|
error.put("code", 404);
|
||||||
|
error.put("message", "学生不存在,ID: " + id);
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 新增 ====================
|
||||||
|
|
||||||
|
/** POST /api/students —— 新增学生 */
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<Map<String, Object>> add(@Valid @RequestBody Student student) {
|
||||||
|
Student saved = service.add(student);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 201);
|
||||||
|
result.put("message", "添加成功");
|
||||||
|
result.put("data", saved);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 更新 ====================
|
||||||
|
|
||||||
|
/** PUT /api/students/{id} —— 更新学生 */
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<Map<String, Object>> update(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@Valid @RequestBody Student student) {
|
||||||
|
|
||||||
|
return service.update(id, student)
|
||||||
|
.map(updated -> {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 200);
|
||||||
|
result.put("message", "更新成功");
|
||||||
|
result.put("data", updated);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
})
|
||||||
|
.orElseGet(() -> {
|
||||||
|
Map<String, Object> error = new HashMap<>();
|
||||||
|
error.put("code", 404);
|
||||||
|
error.put("message", "学生不存在,ID: " + id);
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 删除 ====================
|
||||||
|
|
||||||
|
/** DELETE /api/students/{id} —— 删除学生 */
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
|
||||||
|
if (service.delete(id)) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 200);
|
||||||
|
result.put("message", "删除成功");
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
} else {
|
||||||
|
Map<String, Object> error = new HashMap<>();
|
||||||
|
error.put("code", 404);
|
||||||
|
error.put("message", "学生不存在,ID: " + id);
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /api/students —— 清空全部(调试用) */
|
||||||
|
// 注:这里简化处理,实际项目中应放在测试或管理接口中
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.learn.exception;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.validation.FieldError;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 7 天:全局异常处理
|
||||||
|
*
|
||||||
|
* @RestControllerAdvice 拦截所有 @RestController 中抛出的异常,
|
||||||
|
* 统一返回友好的 JSON 格式错误信息,而不是默认的 500 错误页面。
|
||||||
|
*
|
||||||
|
* 好处:Controller 中不需要到处写 try-catch,代码更简洁。
|
||||||
|
*/
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理参数校验失败异常(@Valid 触发)
|
||||||
|
* 返回友好的字段错误信息
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
|
||||||
|
// 收集所有字段的校验错误
|
||||||
|
String errors = ex.getBindingResult().getFieldErrors().stream()
|
||||||
|
.map(e -> e.getField() + ": " + e.getDefaultMessage())
|
||||||
|
.collect(Collectors.joining("; "));
|
||||||
|
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("code", 400);
|
||||||
|
body.put("message", "参数校验失败: " + errors);
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兜底:处理所有未捕获的异常
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleAll(Exception ex) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("code", 500);
|
||||||
|
body.put("message", "服务器内部错误: " + ex.getMessage());
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
164
week2/src/main/java/com/learn/ioc/SimpleIocDemo.java
Normal file
164
week2/src/main/java/com/learn/ioc/SimpleIocDemo.java
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package com.learn.ioc;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 1 天:手写简易 IoC 容器 —— 理解控制反转的本质
|
||||||
|
*
|
||||||
|
* 核心概念:
|
||||||
|
* IoC(Inversion of Control):把"创建对象"的控制权从程序员手里
|
||||||
|
* 交给容器。以前是 new 一个对象,现在是容器帮我们创建并注入。
|
||||||
|
*
|
||||||
|
* DI(Dependency Injection):依赖注入,IoC 最常见的实现方式。
|
||||||
|
* 当 A 类需要 B 类时,不需要 A 自己 new B,而是由容器把 B 注入到 A 中。
|
||||||
|
*
|
||||||
|
* 这个类是一个极简 IoC 容器实现,只依赖 JDK,用于理解 Spring 的核心思想。
|
||||||
|
*
|
||||||
|
* 运行方式:IDEA 中右键此类 → Run 'SimpleIocDemo.main()'
|
||||||
|
*/
|
||||||
|
public class SimpleIocDemo {
|
||||||
|
|
||||||
|
// ==================== 1. 定义两个简单的"业务类" ====================
|
||||||
|
|
||||||
|
/** 模拟一个"数据访问层"组件 */
|
||||||
|
static class UserRepository {
|
||||||
|
public String findUser() {
|
||||||
|
return "张三(来自数据库)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模拟一个"业务逻辑层"组件 —— 它依赖 UserRepository */
|
||||||
|
static class UserService {
|
||||||
|
// UserService 需要 UserRepository,但自己不 new
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
// 通过 setter 方法接收依赖(这就是"注入")
|
||||||
|
public void setUserRepository(UserRepository userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserInfo() {
|
||||||
|
return userRepository.findUser();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 2. 极简 IoC 容器 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个玩具级 IoC 容器,实现两个能力:
|
||||||
|
* 1. 注册 Bean(把对象放进容器)
|
||||||
|
* 2. 注入依赖(帮对象建立关联关系)
|
||||||
|
*/
|
||||||
|
static class SimpleContainer {
|
||||||
|
// 存储所有 Bean:名字 → 对象
|
||||||
|
private final Map<String, Object> beans = new HashMap<>();
|
||||||
|
|
||||||
|
/** 注册一个 Bean */
|
||||||
|
public void register(String name, Object bean) {
|
||||||
|
beans.put(name, bean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取一个 Bean */
|
||||||
|
public Object getBean(String name) {
|
||||||
|
return beans.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动注入:扫描每个 Bean 中带 @Autowired 注解的字段,自动赋值
|
||||||
|
* (这里用我们自定义的注解来模拟 Spring 的 @Autowired)
|
||||||
|
*/
|
||||||
|
public void autowire() {
|
||||||
|
for (Object bean : beans.values()) {
|
||||||
|
for (Field field : bean.getClass().getDeclaredFields()) {
|
||||||
|
if (field.isAnnotationPresent(MyAutowired.class)) {
|
||||||
|
field.setAccessible(true);
|
||||||
|
try {
|
||||||
|
// 根据字段类型找到匹配的 Bean
|
||||||
|
Object dependency = findBeanByType(field.getType());
|
||||||
|
if (dependency != null) {
|
||||||
|
field.set(bean, dependency);
|
||||||
|
}
|
||||||
|
} catch (IllegalAccessException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object findBeanByType(Class<?> type) {
|
||||||
|
for (Object bean : beans.values()) {
|
||||||
|
if (type.isAssignableFrom(bean.getClass())) {
|
||||||
|
return bean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 3. 自定义注解(模拟 Spring 的 @Autowired) ====================
|
||||||
|
|
||||||
|
@java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.RUNTIME)
|
||||||
|
@interface MyAutowired {
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 4. 使用注解版本测试自动注入 ====================
|
||||||
|
|
||||||
|
static class OrderRepository {
|
||||||
|
public String getOrder() {
|
||||||
|
return "订单 #12345";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static class OrderService {
|
||||||
|
@MyAutowired // 告诉容器:帮我把 OrderRepository 注入进来
|
||||||
|
private OrderRepository orderRepository;
|
||||||
|
|
||||||
|
public String getOrderInfo() {
|
||||||
|
return orderRepository.getOrder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 5. 主方法——跑起来看效果 ====================
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println("========== IoC 容器原理演示 ==========\n");
|
||||||
|
|
||||||
|
// --- 方式一:手动注入(模拟 XML 配置时代的做法) ---
|
||||||
|
System.out.println("--- 方式一:手动 setter 注入 ---");
|
||||||
|
SimpleContainer container1 = new SimpleContainer();
|
||||||
|
|
||||||
|
UserRepository userRepo = new UserRepository();
|
||||||
|
UserService userService = new UserService();
|
||||||
|
|
||||||
|
container1.register("userRepository", userRepo);
|
||||||
|
container1.register("userService", userService);
|
||||||
|
|
||||||
|
// 手动注入依赖
|
||||||
|
userService.setUserRepository(userRepo);
|
||||||
|
|
||||||
|
UserService service1 = (UserService) container1.getBean("userService");
|
||||||
|
System.out.println("调用结果: " + service1.getUserInfo());
|
||||||
|
|
||||||
|
// --- 方式二:自动注入(模拟注解时代的做法) ---
|
||||||
|
System.out.println("\n--- 方式二:注解自动注入 ---");
|
||||||
|
SimpleContainer container2 = new SimpleContainer();
|
||||||
|
|
||||||
|
container2.register("orderRepository", new OrderRepository());
|
||||||
|
container2.register("orderService", new OrderService());
|
||||||
|
|
||||||
|
// 一行代码,自动完成所有依赖注入
|
||||||
|
container2.autowire();
|
||||||
|
|
||||||
|
OrderService service2 = (OrderService) container2.getBean("orderService");
|
||||||
|
System.out.println("调用结果: " + service2.getOrderInfo());
|
||||||
|
|
||||||
|
// --- 对比总结 ---
|
||||||
|
System.out.println("\n========== 总结 ==========");
|
||||||
|
System.out.println("没有 IoC 容器时:A a = new A(); a.setB(new B()); // 自己控制一切");
|
||||||
|
System.out.println("有了 IoC 容器后:@Autowired B b; // 容器自动注入,你只管用");
|
||||||
|
System.out.println("\n这就是 Spring 的核心理念——控制反转!");
|
||||||
|
}
|
||||||
|
}
|
||||||
65
week2/src/main/java/com/learn/model/Student.java
Normal file
65
week2/src/main/java/com/learn/model/Student.java
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package com.learn.model;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生实体类 —— 第 4 天引入
|
||||||
|
*
|
||||||
|
* 用于接收请求参数和返回响应数据。
|
||||||
|
* 添加了 Bean Validation 注解做参数校验(第 6 天深化)。
|
||||||
|
*/
|
||||||
|
public class Student {
|
||||||
|
|
||||||
|
private Long id; // 唯一标识
|
||||||
|
|
||||||
|
@NotBlank(message = "姓名不能为空")
|
||||||
|
@Size(min = 1, max = 20, message = "姓名长度 1-20")
|
||||||
|
private String name; // 姓名
|
||||||
|
|
||||||
|
@Min(value = 1, message = "年龄必须大于 0")
|
||||||
|
@Max(value = 150, message = "年龄必须小于 150")
|
||||||
|
private int age; // 年龄
|
||||||
|
|
||||||
|
@NotBlank(message = "邮箱不能为空")
|
||||||
|
@Email(message = "邮箱格式不正确")
|
||||||
|
private String email; // 邮箱
|
||||||
|
|
||||||
|
@Min(value = 0, message = "成绩不能为负数")
|
||||||
|
@Max(value = 100, message = "成绩不能超过 100")
|
||||||
|
private int score; // 成绩(0-100)
|
||||||
|
|
||||||
|
// ==================== 构造方法 ====================
|
||||||
|
|
||||||
|
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; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return String.format("Student{id=%d, name='%s', age=%d, email='%s', score=%d}",
|
||||||
|
id, name, age, email, score);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.learn.repository;
|
||||||
|
|
||||||
|
import com.learn.model.Student;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 5 天:数据访问层 —— 学生仓库
|
||||||
|
*
|
||||||
|
* 负责数据的存储和查询。这里使用内存存储(ConcurrentHashMap),
|
||||||
|
* 后续学习 JPA/MyBatis-Plus 时可以替换为真实数据库。
|
||||||
|
*
|
||||||
|
* @Repository 注解:标记这是数据访问层组件,被 Spring 扫描并管理
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class StudentRepository {
|
||||||
|
|
||||||
|
// 线程安全的内存存储
|
||||||
|
private final Map<Long, Student> store = new ConcurrentHashMap<>();
|
||||||
|
// 自增 ID 生成器
|
||||||
|
private final AtomicLong idGenerator = new AtomicLong(1);
|
||||||
|
|
||||||
|
/** 保存(新增或更新) */
|
||||||
|
public Student save(Student student) {
|
||||||
|
if (student.getId() == null) {
|
||||||
|
// 新增:分配 ID
|
||||||
|
student.setId(idGenerator.getAndIncrement());
|
||||||
|
}
|
||||||
|
store.put(student.getId(), student);
|
||||||
|
return student;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据 ID 查找 */
|
||||||
|
public Optional<Student> findById(Long id) {
|
||||||
|
return Optional.ofNullable(store.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查找全部 */
|
||||||
|
public List<Student> findAll() {
|
||||||
|
return new ArrayList<>(store.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据姓名模糊搜索 */
|
||||||
|
public List<Student> findByName(String keyword) {
|
||||||
|
return store.values().stream()
|
||||||
|
.filter(s -> s.getName().contains(keyword))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除 */
|
||||||
|
public boolean deleteById(Long id) {
|
||||||
|
return store.remove(id) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 总数 */
|
||||||
|
public long count() {
|
||||||
|
return store.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空(用于测试) */
|
||||||
|
public void clear() {
|
||||||
|
store.clear();
|
||||||
|
idGenerator.set(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
week2/src/main/java/com/learn/service/StudentService.java
Normal file
76
week2/src/main/java/com/learn/service/StudentService.java
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package com.learn.service;
|
||||||
|
|
||||||
|
import com.learn.model.Student;
|
||||||
|
import com.learn.repository.StudentRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 5 天:业务逻辑层 —— 学生服务
|
||||||
|
*
|
||||||
|
* 负责业务规则的校验和处理,介于 Controller 和 Repository 之间。
|
||||||
|
* Controller 只负责接收请求和返回响应,业务逻辑全部在 Service 中。
|
||||||
|
*
|
||||||
|
* @Service 注解:标记这是业务逻辑层组件,被 Spring 扫描并管理
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class StudentService {
|
||||||
|
|
||||||
|
private final StudentRepository repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造方法注入(推荐方式,比 @Autowired 字段注入更安全,也更容易测试)
|
||||||
|
* Spring 会自动从容器中找到 StudentRepository 并传入
|
||||||
|
*/
|
||||||
|
public StudentService(StudentRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增学生 */
|
||||||
|
public Student add(Student student) {
|
||||||
|
// 业务校验可以放在这里,例如检查邮箱是否已注册等
|
||||||
|
return repository.save(student);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询全部 */
|
||||||
|
public List<Student> list() {
|
||||||
|
return repository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据 ID 查询 */
|
||||||
|
public Optional<Student> getById(Long id) {
|
||||||
|
return repository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模糊搜索 */
|
||||||
|
public List<Student> search(String keyword) {
|
||||||
|
if (keyword == null || keyword.trim().isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return repository.findByName(keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新学生 */
|
||||||
|
public Optional<Student> update(Long id, Student updated) {
|
||||||
|
return repository.findById(id).map(existing -> {
|
||||||
|
// 只更新非 null 的字段
|
||||||
|
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 repository.save(existing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除学生 */
|
||||||
|
public boolean delete(Long id) {
|
||||||
|
return repository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 总数 */
|
||||||
|
public long count() {
|
||||||
|
return repository.count();
|
||||||
|
}
|
||||||
|
}
|
||||||
15
week2/src/main/resources/application-dev.yml
Normal file
15
week2/src/main/resources/application-dev.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# ==========================================
|
||||||
|
# 开发环境配置
|
||||||
|
# ==========================================
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.learn: DEBUG # 开发时打印详细日志
|
||||||
|
|
||||||
|
# 自定义配置(演示 @Value 注解的使用)
|
||||||
|
app:
|
||||||
|
name: Week2 学生管理系统
|
||||||
|
version: 1.0.0-DEV
|
||||||
|
description: 开发环境,启用 DEBUG 日志和热重载
|
||||||
15
week2/src/main/resources/application-prod.yml
Normal file
15
week2/src/main/resources/application-prod.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# ==========================================
|
||||||
|
# 生产环境配置
|
||||||
|
# ==========================================
|
||||||
|
server:
|
||||||
|
port: 80
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.learn: WARN # 生产环境只打印警告和错误
|
||||||
|
org.springframework: WARN
|
||||||
|
|
||||||
|
app:
|
||||||
|
name: Week2 学生管理系统
|
||||||
|
version: 1.0.0-RELEASE
|
||||||
|
description: 生产环境,仅记录 WARN 级别日志
|
||||||
24
week2/src/main/resources/application.yml
Normal file
24
week2/src/main/resources/application.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# ==========================================
|
||||||
|
# Spring Boot 核心配置文件(第 6 天重点)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# 应用基本信息
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: week2-spring-boot
|
||||||
|
|
||||||
|
# 激活 dev 环境配置
|
||||||
|
profiles:
|
||||||
|
active: dev
|
||||||
|
|
||||||
|
# 内嵌 Tomcat 端口和上下文路径
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
servlet:
|
||||||
|
context-path: /
|
||||||
|
|
||||||
|
# 日志级别(开发环境建议 DEBUG)
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.learn: DEBUG
|
||||||
|
org.springframework.web: INFO
|
||||||
55
week2/src/test/java/com/learn/StudentControllerTest.java
Normal file
55
week2/src/test/java/com/learn/StudentControllerTest.java
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package com.learn;
|
||||||
|
|
||||||
|
import com.learn.model.Student;
|
||||||
|
import org.junit.jupiter.api.*;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
import org.springframework.http.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第 8 周才正式学习测试,这里先体验一下集成测试的基本写法。
|
||||||
|
*
|
||||||
|
* @SpringBootTest(webEnvironment = ...RANDOM_PORT) 启动一个真实的 Web 容器
|
||||||
|
* TestRestTemplate 是 Spring Boot 提供的 HTTP 测试客户端
|
||||||
|
*/
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||||
|
class StudentControllerTest {
|
||||||
|
|
||||||
|
@LocalServerPort
|
||||||
|
private int port;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TestRestTemplate restTemplate;
|
||||||
|
|
||||||
|
private String baseUrl() {
|
||||||
|
return "http://localhost:" + port + "/api/students";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(1)
|
||||||
|
void shouldListStudents() {
|
||||||
|
ResponseEntity<Map> response = restTemplate.getForEntity(baseUrl(), Map.class);
|
||||||
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
assertThat((Integer) response.getBody().get("code")).isEqualTo(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Order(2)
|
||||||
|
void shouldAddStudent() {
|
||||||
|
Student s = new Student(null, "测试学生", 18, "test@mail.com", 90);
|
||||||
|
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
HttpEntity<Student> request = new HttpEntity<>(s, headers);
|
||||||
|
|
||||||
|
ResponseEntity<Map> response = restTemplate.postForEntity(baseUrl(), request, Map.class);
|
||||||
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||||
|
}
|
||||||
|
}
|
||||||
467
week2/教案.md
Normal file
467
week2/教案.md
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
# 第二阶段教案:Spring Boot 入门(第 2 周)
|
||||||
|
|
||||||
|
> **学习周期**:7 天
|
||||||
|
> **每日用时**:2-3 小时
|
||||||
|
> **最终产出**:RESTful 学生管理 API(分层架构 + 参数校验 + 多环境配置)
|
||||||
|
> **项目结构**:Maven + Spring Boot 3.2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Maven 项目导入
|
||||||
|
|
||||||
|
在 IDEA 中:File → Open → 选择 `week2/pom.xml` → Open as Project。
|
||||||
|
IDEA 会自动下载依赖,等待右下角进度条完成即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 1 天:Spring 是什么?IoC 和 DI
|
||||||
|
|
||||||
|
### 核心概念
|
||||||
|
|
||||||
|
**没有 Spring 时**:你写代码手动 `new` 对象,所有对象的创建和关联都由你自己管理。
|
||||||
|
|
||||||
|
```java
|
||||||
|
UserRepository repo = new UserRepository(); // 自己创建
|
||||||
|
UserService service = new UserService(); // 自己创建
|
||||||
|
service.setUserRepository(repo); // 自己关联
|
||||||
|
```
|
||||||
|
|
||||||
|
**有了 Spring 后**:对象的创建和关联由容器负责,你只需要声明"我需要什么"。
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
public class UserService {
|
||||||
|
private final UserRepository repo; // Spring 会自动注入
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 三个关键术语
|
||||||
|
|
||||||
|
| 术语 | 白话解释 |
|
||||||
|
|------|---------|
|
||||||
|
| **IoC**(控制反转) | 创建对象的控制权从你手里"反转"给了 Spring 容器 |
|
||||||
|
| **DI**(依赖注入) | 当 A 需要 B 时,Spring 自动把 B "注入"到 A 里,你不需要 `new` |
|
||||||
|
| **容器** | 一个巨大的 Map,存着所有被 Spring 管理的对象(称为 Bean) |
|
||||||
|
|
||||||
|
### 动手:运行 IoC 演示
|
||||||
|
|
||||||
|
打开 `src/main/java/com/learn/ioc/SimpleIocDemo.java`,右键 Run。
|
||||||
|
|
||||||
|
这个文件包含了一个用纯 Java 手写的极简 IoC 容器(约 100 行),没有依赖任何框架。它会输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
========== IoC 容器原理演示 ==========
|
||||||
|
|
||||||
|
--- 方式一:手动 setter 注入 ---
|
||||||
|
调用结果: 张三(来自数据库)
|
||||||
|
|
||||||
|
--- 方式二:注解自动注入 ---
|
||||||
|
调用结果: 订单 #12345
|
||||||
|
|
||||||
|
========== 总结 ==========
|
||||||
|
没有 IoC 容器时:A a = new A(); a.setB(new B()); // 自己控制一切
|
||||||
|
有了 IoC 容器后:@Autowired B b; // 容器自动注入,你只管用
|
||||||
|
```
|
||||||
|
|
||||||
|
**今日任务**:
|
||||||
|
1. 逐行读懂 `SimpleIocDemo.java`,理解容器/注册/注入三步
|
||||||
|
2. 尝试在 `SimpleIocDemo` 里新增一个类(如 `ProductService`),并把它注册到容器中
|
||||||
|
3. 思考:为什么反射(Reflection)是实现 IoC 的关键技术?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 2 天:Spring Boot 项目结构
|
||||||
|
|
||||||
|
### 核心概念
|
||||||
|
|
||||||
|
Spring Boot 不是新框架,它是对 Spring 的**封装和简化**。传统 Spring 需要大量 XML 配置,Spring Boot 通过"约定大于配置"消除了这些繁琐步骤。
|
||||||
|
|
||||||
|
### 项目结构详解
|
||||||
|
|
||||||
|
```
|
||||||
|
week2/
|
||||||
|
├── pom.xml # Maven 配置(依赖管理)
|
||||||
|
└── src/
|
||||||
|
├── main/
|
||||||
|
│ ├── java/com/learn/
|
||||||
|
│ │ ├── Week2Application.java # 启动入口
|
||||||
|
│ │ ├── controller/ # 控制器层(接收 HTTP 请求)
|
||||||
|
│ │ ├── service/ # 业务逻辑层
|
||||||
|
│ │ ├── repository/ # 数据访问层
|
||||||
|
│ │ ├── model/ # 数据模型
|
||||||
|
│ │ ├── config/ # 配置类
|
||||||
|
│ │ └── exception/ # 全局异常处理
|
||||||
|
│ └── resources/
|
||||||
|
│ ├── application.yml # 主配置文件
|
||||||
|
│ ├── application-dev.yml # 开发环境
|
||||||
|
│ └── application-prod.yml # 生产环境
|
||||||
|
└── test/ # 测试代码
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spring Boot 启动流程
|
||||||
|
|
||||||
|
1. `main()` 调用 `SpringApplication.run()`
|
||||||
|
2. 创建 `ApplicationContext`(即 IoC 容器)
|
||||||
|
3. 扫描 `@SpringBootApplication` 所在包及子包
|
||||||
|
4. 找到所有带 `@Component` / `@Service` / `@Repository` / `@Controller` 的类
|
||||||
|
5. 实例化它们并处理依赖注入
|
||||||
|
6. 启动内嵌 Tomcat,监听 8080 端口
|
||||||
|
|
||||||
|
### 动手:观察启动日志
|
||||||
|
|
||||||
|
在 IDEA 中运行 `Week2Application.main()`,观察控制台输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
Started Week2Application in 1.234 seconds
|
||||||
|
Tomcat started on port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
你会看到 Spring Boot 的 Banner、自动配置报告、Tomcat 启动信息。
|
||||||
|
|
||||||
|
**今日任务**:
|
||||||
|
1. 对着上面的目录结构,在你的项目中找到每个文件的位置
|
||||||
|
2. 打开 `pom.xml`,理解每个 `<dependency>` 的作用
|
||||||
|
3. 尝试改 `application.yml` 中的 `server.port` 为 9090,重启验证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 3 天:第一个 REST API
|
||||||
|
|
||||||
|
### 核心概念
|
||||||
|
|
||||||
|
| 注解 | 作用 | 举例 |
|
||||||
|
|------|------|------|
|
||||||
|
| `@RestController` | 标记 REST 控制器,方法返回值自动转 JSON | 写在类上 |
|
||||||
|
| `@GetMapping("/xxx")` | 映射 HTTP GET 请求 | `@GetMapping("/hello")` |
|
||||||
|
| `@PathVariable` | 从 URL 路径中取值 | `/hello/{id}` → `@PathVariable Long id` |
|
||||||
|
| `@RequestParam` | 从 URL 查询参数中取值 | `/greet?name=张三` → `@RequestParam String name` |
|
||||||
|
|
||||||
|
### 动手:测试接口
|
||||||
|
|
||||||
|
运行 `Week2Application`,打开浏览器或 Postman 访问:
|
||||||
|
|
||||||
|
| URL | 预期结果 |
|
||||||
|
|-----|---------|
|
||||||
|
| `http://localhost:8080/hello` | `Hello, Spring Boot!` |
|
||||||
|
| `http://localhost:8080/hello/小明` | `你好,小明!欢迎来到 Spring Boot 的世界。` |
|
||||||
|
| `http://localhost:8080/greet?name=张三&age=20` | `你好,张三!你今年 20 岁。` |
|
||||||
|
| `http://localhost:8080/greet` | `你好,同学!`(默认值生效) |
|
||||||
|
| `http://localhost:8080/info` | JSON 对象 `{"status":"UP","message":"...","timestamp":...}` |
|
||||||
|
|
||||||
|
### 关键理解
|
||||||
|
|
||||||
|
`@RestController` 的请求处理流程:
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP 请求 → DispatcherServlet(前端控制器)
|
||||||
|
→ 找到匹配的 Controller 方法
|
||||||
|
→ 执行方法,得到返回值
|
||||||
|
→ Jackson 将返回值序列化为 JSON
|
||||||
|
→ HTTP 响应返回给客户端
|
||||||
|
```
|
||||||
|
|
||||||
|
**今日任务**:
|
||||||
|
1. 在 `HelloController` 中新增一个接口:`GET /echo?msg=xxx`,返回 `{"echo": "xxx"}`
|
||||||
|
2. 用 Postman 测试所有接口,观察请求头和响应头
|
||||||
|
3. 思考:`@RestController` 和普通 `@Controller` 有什么区别?(提示:`@ResponseBody`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 4 天:RESTful CRUD(上)—— 请求映射
|
||||||
|
|
||||||
|
### 核心概念
|
||||||
|
|
||||||
|
RESTful API 用 HTTP 方法表达操作意图:
|
||||||
|
|
||||||
|
| HTTP 方法 | 对应注解 | 语义 |
|
||||||
|
|-----------|---------|------|
|
||||||
|
| GET | `@GetMapping` | 查询(安全、幂等) |
|
||||||
|
| POST | `@PostMapping` | 新增 |
|
||||||
|
| PUT | `@PutMapping` | 更新(幂等) |
|
||||||
|
| DELETE | `@DeleteMapping` | 删除(幂等) |
|
||||||
|
|
||||||
|
### @RequestBody —— 接收 JSON 请求体
|
||||||
|
|
||||||
|
```java
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> add(@RequestBody Student student) {
|
||||||
|
// Spring 自动将请求体的 JSON → Student 对象
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
客户端发送:
|
||||||
|
```json
|
||||||
|
POST /api/students
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "张三",
|
||||||
|
"age": 20,
|
||||||
|
"email": "zhangsan@mail.com",
|
||||||
|
"score": 85
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 动手:测试 CRUD 接口
|
||||||
|
|
||||||
|
> 第 4 天的代码在 `StudentController` 中,此时还没有 Service/Repository 分层。
|
||||||
|
> 阅读 `StudentController` 的代码,理解每个方法的注解和 URL 设计。
|
||||||
|
|
||||||
|
| 操作 | 方法 | URL | 请求体 |
|
||||||
|
|------|------|-----|--------|
|
||||||
|
| 查全部 | GET | `/api/students` | - |
|
||||||
|
| 查单个 | GET | `/api/students/1` | - |
|
||||||
|
| 搜索 | GET | `/api/students?keyword=张` | - |
|
||||||
|
| 新增 | POST | `/api/students` | JSON |
|
||||||
|
| 更新 | PUT | `/api/students/1` | JSON |
|
||||||
|
| 删除 | DELETE | `/api/students/1` | - |
|
||||||
|
|
||||||
|
用 Postman 逐个测试(启动后已有 5 条预置数据)。
|
||||||
|
|
||||||
|
**今日任务**:
|
||||||
|
1. 用 Postman 完成"新增→查询→更新→删除"的完整流程
|
||||||
|
2. 观察 PUT 更新时只传部分字段的效果
|
||||||
|
3. 尝试故意传一个错误的 JSON(少字段、错类型),观察返回什么
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 5 天:分层架构 —— Controller → Service → Repository
|
||||||
|
|
||||||
|
### 为什么需要分层?
|
||||||
|
|
||||||
|
第 4 天所有逻辑挤在 Controller 里。当项目变复杂时:
|
||||||
|
|
||||||
|
- Controller 既处理请求又写业务逻辑,又操作数据 → **难以维护**
|
||||||
|
- 一个地方改了,可能影响不相关的地方 → **难以测试**
|
||||||
|
|
||||||
|
### 三层架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ Controller 层(@RestController) │ ← 接收请求、参数校验、返回响应
|
||||||
|
│ "我只负责接待客人,不干活" │
|
||||||
|
├──────────────────────────────────────┤
|
||||||
|
│ Service 层(@Service) │ ← 业务逻辑、事务管理
|
||||||
|
│ "我负责干活,不知道客人从哪来的" │
|
||||||
|
├──────────────────────────────────────┤
|
||||||
|
│ Repository 层(@Repository) │ ← 数据存取
|
||||||
|
│ "我只负责存取数据,不知道业务逻辑" │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 依赖方向(重要!)
|
||||||
|
|
||||||
|
```
|
||||||
|
Controller → Service → Repository
|
||||||
|
↑ ↑
|
||||||
|
只能从上往下依赖,不能反着来
|
||||||
|
```
|
||||||
|
|
||||||
|
每个层只依赖下一层,不跨层调用。Repository 不能依赖 Service,Service 不能依赖 Controller。
|
||||||
|
|
||||||
|
### 本项目的分层实现
|
||||||
|
|
||||||
|
打开对应文件,理解各层职责:
|
||||||
|
|
||||||
|
| 文件 | 层 | 关键点 |
|
||||||
|
|------|-----|--------|
|
||||||
|
| `controller/StudentController.java` | Controller | 只做参数提取和响应封装,调用 Service |
|
||||||
|
| `service/StudentService.java` | Service | 业务逻辑(校验、组合),调用 Repository |
|
||||||
|
| `repository/StudentRepository.java` | Repository | 数据存取,使用 ConcurrentHashMap 模拟数据库 |
|
||||||
|
| `model/Student.java` | Model | 贯穿三层的数据载体 |
|
||||||
|
|
||||||
|
### 注入方式对比
|
||||||
|
|
||||||
|
```java
|
||||||
|
// ❌ 字段注入(不推荐——测试困难,掩盖依赖关系)
|
||||||
|
@Autowired
|
||||||
|
private StudentService service;
|
||||||
|
|
||||||
|
// ✅ 构造方法注入(推荐——依赖明确,易于测试)
|
||||||
|
private final StudentService service;
|
||||||
|
public StudentController(StudentService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**今日任务**:
|
||||||
|
1. 画出三层架构的依赖关系图
|
||||||
|
2. 在 `StudentService` 中新增一个方法:`getTopStudents(int n)`,返回成绩最高的 n 个学生
|
||||||
|
3. 为这个新方法在 Controller 中新增接口 `GET /api/students/top?n=3`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 6 天:配置管理
|
||||||
|
|
||||||
|
### application.yml vs application.properties
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# YAML 格式(推荐,层次清晰)
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
```properties
|
||||||
|
# properties 格式(传统,扁平)
|
||||||
|
server.port=8080
|
||||||
|
spring.application.name=my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
两者等效,YAML 更直观。
|
||||||
|
|
||||||
|
### 多环境配置
|
||||||
|
|
||||||
|
```
|
||||||
|
application.yml # 公共配置
|
||||||
|
application-dev.yml # 开发环境
|
||||||
|
application-prod.yml # 生产环境
|
||||||
|
```
|
||||||
|
|
||||||
|
`application.yml` 中通过 `spring.profiles.active: dev` 指定激活哪个环境。
|
||||||
|
|
||||||
|
激活方式(优先级从高到低):
|
||||||
|
1. 命令行:`java -jar app.jar --spring.profiles.active=prod`
|
||||||
|
2. 环境变量:`SPRING_PROFILES_ACTIVE=prod`
|
||||||
|
3. application.yml 中的 `spring.profiles.active`
|
||||||
|
|
||||||
|
### @Value 读取配置
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Value("${app.name}")
|
||||||
|
private String appName;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义配置
|
||||||
|
|
||||||
|
在 YAML 中写:
|
||||||
|
```yaml
|
||||||
|
app:
|
||||||
|
name: 学生管理系统
|
||||||
|
version: 1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
在代码中读:
|
||||||
|
```java
|
||||||
|
@Value("${app.name}")
|
||||||
|
private String appName;
|
||||||
|
```
|
||||||
|
|
||||||
|
**今日任务**:
|
||||||
|
1. 修改 `spring.profiles.active` 为 `prod`,观察端口和日志变化
|
||||||
|
2. 在 `HelloController` 中用 `@Value` 注入 `app.name`,新增接口返回应用名称
|
||||||
|
3. 想一想:为什么数据库密码、API Key 要放在配置文件中而不是代码里?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 7 天:整合与总结
|
||||||
|
|
||||||
|
### 全局异常处理
|
||||||
|
|
||||||
|
复习 `exception/GlobalExceptionHandler.java`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@RestControllerAdvice // 拦截所有 Controller 的异常
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class) // 参数校验失败
|
||||||
|
public ResponseEntity<?> handleValidation(...) { ... }
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class) // 兜底:所有未捕获的异常
|
||||||
|
public ResponseEntity<?> handleAll(...) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
有了这个,Controller 的任何异常都会被拦截并返回友好的 JSON 错误信息。
|
||||||
|
|
||||||
|
### 参数校验(Bean Validation)
|
||||||
|
|
||||||
|
看 `Student` 类中的注解:
|
||||||
|
```java
|
||||||
|
@NotBlank(message = "姓名不能为空")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Min(value = 1, message = "年龄必须大于 0")
|
||||||
|
private int age;
|
||||||
|
|
||||||
|
@Email(message = "邮箱格式不正确")
|
||||||
|
private String email;
|
||||||
|
```
|
||||||
|
|
||||||
|
Controller 中用 `@Valid` 触发校验:
|
||||||
|
```java
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> add(@Valid @RequestBody Student student) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 本周完整的请求处理流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. HTTP 请求到达 → DispatcherServlet 接收
|
||||||
|
2. 根据 URL + HTTP Method 匹配 Controller 方法
|
||||||
|
3. @Valid 触发参数校验(失败 → GlobalExceptionHandler 捕获 → 返回 400)
|
||||||
|
4. Controller 调用 Service 处理业务逻辑
|
||||||
|
5. Service 调用 Repository 存取数据
|
||||||
|
6. 结果返回 Controller,封装为 ResponseEntity
|
||||||
|
7. Jackson 序列化为 JSON,写入 HTTP 响应
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试(预习第 8 周)
|
||||||
|
|
||||||
|
打开 `StudentControllerTest.java`,这是一个集成测试示例,用 `TestRestTemplate` 模拟 HTTP 请求来测试真实接口。
|
||||||
|
|
||||||
|
运行方式:右键类名 → Run。观察所有测试通过。
|
||||||
|
|
||||||
|
### 最终验收清单
|
||||||
|
|
||||||
|
用 Postman 完成以下测试:
|
||||||
|
|
||||||
|
- [ ] `GET /api/students` 返回 5 条预置数据
|
||||||
|
- [ ] `GET /api/students/1` 返回张三的信息
|
||||||
|
- [ ] `GET /api/students?keyword=李` 只返回李四
|
||||||
|
- [ ] `POST /api/students` 新增一个学生,返回 201
|
||||||
|
- [ ] `POST /api/students` 传空 name,返回 400 校验错误
|
||||||
|
- [ ] `PUT /api/students/1` 修改张三的年龄,返回更新后数据
|
||||||
|
- [ ] `DELETE /api/students/1` 删除张三,返回成功
|
||||||
|
- [ ] `DELETE /api/students/999` 删除不存在的,返回 404
|
||||||
|
- [ ] 重启应用,之前新增/修改的数据被重置(内存存储)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本周产出
|
||||||
|
|
||||||
|
**一个完整的 RESTful 学生管理 API 服务**,具备:
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ 三层分层架构(Controller → Service → Repository)
|
||||||
|
✅ 完整的 CRUD 接口(GET/POST/PUT/DELETE)
|
||||||
|
✅ 模糊搜索功能
|
||||||
|
✅ 统一 JSON 响应格式(code + message + data)
|
||||||
|
✅ 参数校验(@Valid + Bean Validation)
|
||||||
|
✅ 全局异常处理(@RestControllerAdvice)
|
||||||
|
✅ 多环境配置(dev/prod)
|
||||||
|
✅ 启动时预置测试数据(CommandLineRunner)
|
||||||
|
✅ 基础集成测试
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题排查
|
||||||
|
|
||||||
|
| 问题 | 原因 | 解决 |
|
||||||
|
|------|------|------|
|
||||||
|
| 启动报端口占用 | 8080 端口已被占用 | 改 `application.yml` 中的 `server.port` |
|
||||||
|
| `@Autowired` 报红 | 类上没有 `@Service` 等注解 | 检查是否漏了 `@Service` / `@Repository` / `@Component` |
|
||||||
|
| 找不到 Bean | 类不在启动类所在包的子包下 | Spring 默认扫描启动类同级及子包 |
|
||||||
|
| POST 请求 415 | Content-Type 没设对 | Postman 中 Body 选 raw → JSON |
|
||||||
|
| 中文乱码 | 未设编码 | Spring Boot 默认 UTF-8,如果乱码检查 Postman 设置 |
|
||||||
|
| JSON 返回 406 | 可能缺少 Jackson 依赖 | `spring-boot-starter-web` 已包含,检查 pom.xml |
|
||||||
|
| 修改代码不生效 | 需要重启 | 后续第 8 周会学 DevTools 热重载 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **本周核心**:理解 Spring 的 IoC/DI 思想 → 掌握分层架构 → 能写标准的 RESTful CRUD API。
|
||||||
|
> 这 7 天你建立的是整个 Spring 技术栈的地基,后续 JPA、Security、AI 集成都是在这个地基上添砖加瓦。
|
||||||
73
week3/pom.xml
Normal file
73
week3/pom.xml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?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>week3-orm</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>Week 3: ORM 双轨 —— JPA + MyBatis-Plus</name>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<mybatis-plus.version>3.5.6</mybatis-plus.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot Web -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Data JPA -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MyBatis-Plus -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.baomidou</groupId>
|
||||||
|
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||||
|
<version>${mybatis-plus.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MySQL 驱动 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mysql</groupId>
|
||||||
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Bean Validation -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Test -->
|
||||||
|
<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>
|
||||||
48
week3/sql/init.sql
Normal file
48
week3/sql/init.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
-- =============================================
|
||||||
|
-- Week 3:学生管理系统数据库初始化脚本
|
||||||
|
-- 使用方式:
|
||||||
|
-- 1. 打开 MySQL 客户端(命令行或 Navicat/DataGrip)
|
||||||
|
-- 2. 复制本文件全部内容并执行
|
||||||
|
-- 3. 或:mysql -u root -p < init.sql
|
||||||
|
-- =============================================
|
||||||
|
|
||||||
|
-- 创建数据库
|
||||||
|
CREATE DATABASE IF NOT EXISTS week3_student
|
||||||
|
DEFAULT CHARACTER SET utf8mb4
|
||||||
|
DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE week3_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',
|
||||||
|
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) VALUES
|
||||||
|
('张三', 20, 'zhangsan@mail.com', 85),
|
||||||
|
('李四', 22, 'lisi@mail.com', 92),
|
||||||
|
('王五', 19, 'wangwu@mail.com', 78),
|
||||||
|
('赵六', 21, 'zhaoliu@mail.com', 88),
|
||||||
|
('孙七', 23, 'sunqi@mail.com', 95),
|
||||||
|
('周八', 20, 'zhouba@mail.com', 73),
|
||||||
|
('吴九', 22, 'wujiu@mail.com', 81),
|
||||||
|
('郑十', 21, 'zhengshi@mail.com',90),
|
||||||
|
('钱十一', 19, 'qianshiyi@mail.com', 67),
|
||||||
|
('陈十二', 23, 'chenshier@mail.com', 88);
|
||||||
|
|
||||||
|
-- 验证数据
|
||||||
|
SELECT COUNT(*) AS total_students FROM student;
|
||||||
|
|
||||||
|
SELECT * FROM student ORDER BY id;
|
||||||
11
week3/src/main/java/com/learn/Week3Application.java
Normal file
11
week3/src/main/java/com/learn/Week3Application.java
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package com.learn;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class Week3Application {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(Week3Application.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
week3/src/main/java/com/learn/config/MyBatisPlusConfig.java
Normal file
39
week3/src/main/java/com/learn/config/MyBatisPlusConfig.java
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MyBatis-Plus 配置类
|
||||||
|
*
|
||||||
|
* 主要配置两件事:
|
||||||
|
* 1. 分页插件 —— MP 的分页功能需要显式注册才能生效
|
||||||
|
* 2. 其他可选插件(乐观锁、防全表更新等,后续学习)
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class MyBatisPlusConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MP 拦截器链
|
||||||
|
*
|
||||||
|
* PaginationInnerInterceptor:分页插件,拦截 SQL 并自动追加 LIMIT 子句。
|
||||||
|
* 不需要你手动写 LIMIT,MP 会根据 Page 对象自动处理。
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||||
|
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||||
|
|
||||||
|
// 分页插件,指定数据库类型为 MySQL
|
||||||
|
PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL);
|
||||||
|
// 溢出处理:页码超过最大页时回到首页(默认 false,会返回空)
|
||||||
|
pagination.setOverflow(true);
|
||||||
|
// 单页最大限制(防止恶意请求一次性查百万条)
|
||||||
|
pagination.setMaxLimit(100L);
|
||||||
|
|
||||||
|
interceptor.addInnerInterceptor(pagination);
|
||||||
|
return interceptor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package com.learn.controller.jpa;
|
||||||
|
|
||||||
|
import com.learn.entity.Student;
|
||||||
|
import com.learn.service.jpa.StudentJpaService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA 版本的学生管理 REST API
|
||||||
|
*
|
||||||
|
* 访问路径全部以 /api/jpa/students 开头,
|
||||||
|
* 与 MyBatis-Plus 版本(/api/mp/students)区分,方便对比测试。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/jpa/students")
|
||||||
|
public class StudentJpaController {
|
||||||
|
|
||||||
|
private final StudentJpaService service;
|
||||||
|
|
||||||
|
public StudentJpaController(StudentJpaService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询全部 / 搜索 */
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<Map<String, Object>> list(
|
||||||
|
@RequestParam(required = false) String keyword) {
|
||||||
|
|
||||||
|
java.util.List<Student> students;
|
||||||
|
if (keyword != null && !keyword.trim().isEmpty()) {
|
||||||
|
students = service.searchByKeyword(keyword);
|
||||||
|
} else {
|
||||||
|
students = service.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(students);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页查询(第 5 天) */
|
||||||
|
@GetMapping("/page")
|
||||||
|
public ResponseEntity<Map<String, Object>> page(
|
||||||
|
@RequestParam(defaultValue = "1") int pageNum,
|
||||||
|
@RequestParam(defaultValue = "5") int pageSize) {
|
||||||
|
|
||||||
|
Page<Student> page = service.page(pageNum, pageSize);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 200);
|
||||||
|
result.put("total", page.getTotalElements());
|
||||||
|
result.put("pages", page.getTotalPages());
|
||||||
|
result.put("current", page.getNumber() + 1);
|
||||||
|
result.put("data", page.getContent());
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据 ID 查询 */
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<Map<String, Object>> getById(@PathVariable Long id) {
|
||||||
|
return service.getById(id)
|
||||||
|
.map(s -> ok(s))
|
||||||
|
.orElse(notFound(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增 */
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<Map<String, Object>> add(@Valid @RequestBody Student student) {
|
||||||
|
Student saved = service.add(student);
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 201);
|
||||||
|
result.put("message", "添加成功");
|
||||||
|
result.put("data", saved);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新 */
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<Map<String, Object>> update(
|
||||||
|
@PathVariable Long id, @Valid @RequestBody Student student) {
|
||||||
|
return service.update(id, student)
|
||||||
|
.map(updated -> {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 200);
|
||||||
|
result.put("message", "更新成功");
|
||||||
|
result.put("data", updated);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
})
|
||||||
|
.orElse(notFound(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除 */
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
|
||||||
|
if (service.delete(id)) {
|
||||||
|
return ResponseEntity.ok(Map.of("code", 200, "message", "删除成功"));
|
||||||
|
}
|
||||||
|
return notFound(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分数统计 */
|
||||||
|
@GetMapping("/stats")
|
||||||
|
public ResponseEntity<Map<String, Object>> stats(
|
||||||
|
@RequestParam(defaultValue = "80") int min,
|
||||||
|
@RequestParam(defaultValue = "100") int max) {
|
||||||
|
long count = service.countByScoreRange(min, max);
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 200);
|
||||||
|
result.put("range", min + "-" + max);
|
||||||
|
result.put("count", count);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 工具方法 ====================
|
||||||
|
|
||||||
|
private ResponseEntity<Map<String, Object>> ok(Object data) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 200);
|
||||||
|
result.put("data", data);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Map<String, Object>> notFound(Long id) {
|
||||||
|
Map<String, Object> error = new HashMap<>();
|
||||||
|
error.put("code", 404);
|
||||||
|
error.put("message", "学生不存在,ID: " + id);
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package com.learn.controller.mp;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.learn.entity.Student;
|
||||||
|
import com.learn.service.mp.StudentMpService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MyBatis-Plus 版本的学生管理 REST API
|
||||||
|
*
|
||||||
|
* 访问路径以 /api/mp/students 开头。
|
||||||
|
* 你可以用 Postman 分别测试 /api/jpa/students 和 /api/mp/students
|
||||||
|
* 的同一操作,对比两者的响应和 SQL 输出。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mp/students")
|
||||||
|
public class StudentMpController {
|
||||||
|
|
||||||
|
private final StudentMpService service;
|
||||||
|
|
||||||
|
public StudentMpController(StudentMpService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询全部 / 搜索 */
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<Map<String, Object>> list(
|
||||||
|
@RequestParam(required = false) String keyword) {
|
||||||
|
|
||||||
|
java.util.List<Student> students;
|
||||||
|
if (keyword != null && !keyword.trim().isEmpty()) {
|
||||||
|
students = service.searchByKeyword(keyword);
|
||||||
|
} else {
|
||||||
|
students = service.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok(students);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页查询(第 5 天) */
|
||||||
|
@GetMapping("/page")
|
||||||
|
public ResponseEntity<Map<String, Object>> page(
|
||||||
|
@RequestParam(defaultValue = "1") int pageNum,
|
||||||
|
@RequestParam(defaultValue = "5") int pageSize) {
|
||||||
|
|
||||||
|
IPage<Student> page = service.page(pageNum, pageSize);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 200);
|
||||||
|
result.put("total", page.getTotal());
|
||||||
|
result.put("pages", page.getPages());
|
||||||
|
result.put("current", page.getCurrent());
|
||||||
|
result.put("data", page.getRecords());
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据 ID 查询 */
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<Map<String, Object>> getById(@PathVariable Long id) {
|
||||||
|
return service.getById(id)
|
||||||
|
.map(s -> ok(s))
|
||||||
|
.orElse(notFound(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增 */
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<Map<String, Object>> add(@Valid @RequestBody Student student) {
|
||||||
|
Student saved = service.add(student);
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 201);
|
||||||
|
result.put("message", "添加成功");
|
||||||
|
result.put("data", saved);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新 */
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<Map<String, Object>> update(
|
||||||
|
@PathVariable Long id, @Valid @RequestBody Student student) {
|
||||||
|
return service.update(id, student)
|
||||||
|
.map(updated -> {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 200);
|
||||||
|
result.put("message", "更新成功");
|
||||||
|
result.put("data", updated);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
})
|
||||||
|
.orElse(notFound(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除 */
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
|
||||||
|
if (service.delete(id)) {
|
||||||
|
return ResponseEntity.ok(Map.of("code", 200, "message", "删除成功"));
|
||||||
|
}
|
||||||
|
return notFound(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分数统计(使用 LambdaQueryWrapper 的 between) */
|
||||||
|
@GetMapping("/stats")
|
||||||
|
public ResponseEntity<Map<String, Object>> stats(
|
||||||
|
@RequestParam(defaultValue = "80") int min,
|
||||||
|
@RequestParam(defaultValue = "100") int max) {
|
||||||
|
long count = service.countByScoreRange(min, max);
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 200);
|
||||||
|
result.put("range", min + "-" + max);
|
||||||
|
result.put("count", count);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 高分学生(演示 LambdaQueryWrapper 的 ge + orderByDesc) */
|
||||||
|
@GetMapping("/excellent")
|
||||||
|
public ResponseEntity<Map<String, Object>> excellent(
|
||||||
|
@RequestParam(defaultValue = "85") int threshold) {
|
||||||
|
java.util.List<Student> students = service.findExcellentStudents(threshold);
|
||||||
|
return ok(students);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 工具方法 ====================
|
||||||
|
|
||||||
|
private ResponseEntity<Map<String, Object>> ok(Object data) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("code", 200);
|
||||||
|
result.put("data", data);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Map<String, Object>> notFound(Long id) {
|
||||||
|
Map<String, Object> error = new HashMap<>();
|
||||||
|
error.put("code", 404);
|
||||||
|
error.put("message", "学生不存在,ID: " + id);
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
107
week3/src/main/java/com/learn/entity/Student.java
Normal file
107
week3/src/main/java/com/learn/entity/Student.java
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生实体 —— 同时兼容 JPA 和 MyBatis-Plus
|
||||||
|
*
|
||||||
|
* 对比观察:
|
||||||
|
* @Entity (JPA) ↔ @TableName (MP) —— 声明这是一个表映射类
|
||||||
|
* @Table (JPA) ↔ @TableName (MP) —— 指定对应的表名
|
||||||
|
* @Id (JPA) ↔ @TableId (MP) —— 主键
|
||||||
|
* @GeneratedValue (JPA) ↔ @TableId(type=...) (MP) —— 主键生成策略
|
||||||
|
* @Column (JPA) ↔ @TableField (MP) —— 字段映射
|
||||||
|
*
|
||||||
|
* 注意:JPA 和 MP 的注解可以共存于同一个实体类上,互不冲突。
|
||||||
|
* 因为它们分属不同的框架,各自读取各自的注解。
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "student")
|
||||||
|
@TableName("student") // MP 注解:指定表名
|
||||||
|
public class Student {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY) // JPA:数据库自增
|
||||||
|
@TableId(type = IdType.AUTO) // MP:数据库自增
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@NotBlank(message = "姓名不能为空")
|
||||||
|
@Size(min = 1, max = 20, message = "姓名长度 1-20")
|
||||||
|
@Column(name = "name", length = 20, nullable = false)
|
||||||
|
@TableField("name") // MP 注解(当字段名=列名时可省略,此处显式写出便于学习)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Min(value = 1, message = "年龄必须大于 0")
|
||||||
|
@Max(value = 150, message = "年龄必须小于 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(value = 0, message = "成绩不能为负数")
|
||||||
|
@Max(value = 100, message = "成绩不能超过 100")
|
||||||
|
@Column(name = "score", nullable = false)
|
||||||
|
@TableField("score")
|
||||||
|
private int score;
|
||||||
|
|
||||||
|
// ---- 审计字段(由数据库自动管理)----
|
||||||
|
@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 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; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return String.format("Student{id=%d, name='%s', age=%d, email='%s', score=%d}",
|
||||||
|
id, name, age, email, score);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.learn.exception;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.validation.FieldError;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
|
||||||
|
String errors = ex.getBindingResult().getFieldErrors().stream()
|
||||||
|
.map(e -> e.getField() + ": " + e.getDefaultMessage())
|
||||||
|
.collect(Collectors.joining("; "));
|
||||||
|
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("code", 400);
|
||||||
|
body.put("message", "参数校验失败: " + errors);
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleAll(Exception ex) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("code", 500);
|
||||||
|
body.put("message", "服务器内部错误: " + ex.getMessage());
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.learn.repository.jpa;
|
||||||
|
|
||||||
|
import com.learn.entity.Student;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA 数据访问层
|
||||||
|
*
|
||||||
|
* 继承 JpaRepository<实体类, 主键类型> 后,自动获得以下方法(零代码):
|
||||||
|
* save() —— 新增/更新
|
||||||
|
* findById() —— 按主键查询
|
||||||
|
* findAll() —— 查询全部
|
||||||
|
* deleteById() —— 按主键删除
|
||||||
|
* count() —— 计数
|
||||||
|
* existsById() —— 判断是否存在
|
||||||
|
*
|
||||||
|
* 还可以通过"方法命名规则"定义查询,JPA 会自动生成 SQL:
|
||||||
|
* findByName() → SELECT ... WHERE name = ?
|
||||||
|
* findByNameLike() → SELECT ... WHERE name LIKE ?
|
||||||
|
* findByScoreGreaterThan() → SELECT ... WHERE score > ?
|
||||||
|
*
|
||||||
|
* 复杂查询用 @Query 写 JPQL(面向对象的 SQL)
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface StudentJpaRepository extends JpaRepository<Student, Long> {
|
||||||
|
|
||||||
|
// ---- 方法命名查询(JPA 自动生成 SQL)----
|
||||||
|
|
||||||
|
/** 根据姓名精确查找 */
|
||||||
|
List<Student> findByName(String name);
|
||||||
|
|
||||||
|
/** 根据姓名模糊查找(需要自己拼接 %)*/
|
||||||
|
List<Student> findByNameContaining(String keyword);
|
||||||
|
|
||||||
|
/** 成绩大于指定值 */
|
||||||
|
List<Student> findByScoreGreaterThan(int score);
|
||||||
|
|
||||||
|
/** 成绩降序排列 */
|
||||||
|
List<Student> findAllByOrderByScoreDesc();
|
||||||
|
|
||||||
|
// ---- JPQL 自定义查询 ----
|
||||||
|
|
||||||
|
/** 模糊搜索:姓名或邮箱包含关键词 */
|
||||||
|
@Query("SELECT s FROM Student s WHERE s.name LIKE %:keyword% OR s.email LIKE %:keyword%")
|
||||||
|
List<Student> searchByKeyword(@Param("keyword") String keyword);
|
||||||
|
|
||||||
|
/** 统计某个分数段的人数 */
|
||||||
|
@Query("SELECT COUNT(s) FROM Student s WHERE s.score BETWEEN :min AND :max")
|
||||||
|
long countByScoreRange(@Param("min") int min, @Param("max") int max);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MyBatis-Plus 数据访问层
|
||||||
|
*
|
||||||
|
* 继承 BaseMapper<实体类> 后,自动获得以下方法(零代码):
|
||||||
|
* insert() —— 新增
|
||||||
|
* updateById() —— 按主键更新
|
||||||
|
* selectById() —— 按主键查询
|
||||||
|
* selectList() —— 条件查询
|
||||||
|
* deleteById() —— 按主键删除
|
||||||
|
* selectPage() —— 分页查询
|
||||||
|
*
|
||||||
|
* 对比 JPA:
|
||||||
|
* JPA 用"方法命名"定义查询 → findByScoreGreaterThan()
|
||||||
|
* MP 用"Lambda 条件构造器"定义查询 → selectList(wrapper) 在 Service 层使用
|
||||||
|
* MP 也可以用 @Select 直接写 SQL(原生 SQL,灵活性最高)
|
||||||
|
*
|
||||||
|
* @Mapper 注解:MyBatis 识别这个接口并生成实现类
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface StudentMapper extends BaseMapper<Student> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模糊搜索:姓名或邮箱包含关键词
|
||||||
|
*
|
||||||
|
* 这是 MP 的原生 SQL 方式,类似于 MyBatis 的传统用法。
|
||||||
|
* 参数较多时可以用 @Param 指定名称。
|
||||||
|
*/
|
||||||
|
@Select("SELECT * FROM student WHERE name LIKE CONCAT('%', #{keyword}, '%') OR email LIKE CONCAT('%', #{keyword}, '%')")
|
||||||
|
List<Student> searchByKeyword(@Param("keyword") String keyword);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成绩大于指定值,按成绩降序
|
||||||
|
*/
|
||||||
|
@Select("SELECT * FROM student WHERE score > #{minScore} ORDER BY score DESC")
|
||||||
|
List<Student> findByScoreGreaterThan(@Param("minScore") int minScore);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计分数段人数
|
||||||
|
*/
|
||||||
|
@Select("SELECT COUNT(*) FROM student WHERE score BETWEEN #{min} AND #{max}")
|
||||||
|
long countByScoreRange(@Param("min") int min, @Param("max") int max);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询(使用 MP 的分页插件 + 自定义 SQL)
|
||||||
|
*/
|
||||||
|
@Select("SELECT * FROM student WHERE name LIKE CONCAT('%', #{keyword}, '%')")
|
||||||
|
IPage<Student> searchByKeywordPage(Page<Student> page, @Param("keyword") String keyword);
|
||||||
|
}
|
||||||
101
week3/src/main/java/com/learn/service/jpa/StudentJpaService.java
Normal file
101
week3/src/main/java/com/learn/service/jpa/StudentJpaService.java
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA 版本的学生服务
|
||||||
|
*
|
||||||
|
* @Transactional:声明式事务。方法执行过程中如果抛出 RuntimeException,
|
||||||
|
* 所有数据库操作会自动回滚。保证数据一致性。
|
||||||
|
* readOnly=true 表示只读,性能更好(跳过脏检查)。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class StudentJpaService {
|
||||||
|
|
||||||
|
private final StudentJpaRepository repository;
|
||||||
|
|
||||||
|
public StudentJpaService(StudentJpaRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 增删改查 ====================
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Student add(Student student) {
|
||||||
|
return repository.save(student); // JPA: save() 同时用于新增和更新
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Student> list() {
|
||||||
|
return repository.findAll(Sort.by(Sort.Direction.DESC, "score"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<Student> getById(Long id) {
|
||||||
|
return repository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Optional<Student> update(Long id, Student updated) {
|
||||||
|
return repository.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());
|
||||||
|
// JPA 的 save: 有 ID 且存在则更新,无 ID 则新增
|
||||||
|
return repository.save(existing);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public boolean delete(Long id) {
|
||||||
|
if (repository.existsById(id)) {
|
||||||
|
repository.deleteById(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 搜索 ====================
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Student> searchByKeyword(String keyword) {
|
||||||
|
return repository.searchByKeyword(keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Student> findByName(String name) {
|
||||||
|
return repository.findByNameContaining(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 分页(第 5 天) ====================
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<Student> page(int pageNum, int pageSize) {
|
||||||
|
// JPA 分页:PageRequest.of(页码从0开始, 每页条数, 排序)
|
||||||
|
PageRequest pageRequest = PageRequest.of(pageNum - 1, pageSize,
|
||||||
|
Sort.by(Sort.Direction.DESC, "score"));
|
||||||
|
return repository.findAll(pageRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 统计 ====================
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long count() {
|
||||||
|
return repository.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long countByScoreRange(int min, int max) {
|
||||||
|
return repository.countByScoreRange(min, max);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
week3/src/main/java/com/learn/service/mp/StudentMpService.java
Normal file
131
week3/src/main/java/com/learn/service/mp/StudentMpService.java
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MyBatis-Plus 版本的学生服务
|
||||||
|
*
|
||||||
|
* 重点对比与 JPA 版本的差异:
|
||||||
|
* 1. 新增:MP 用 insert(),JPA 用 save()
|
||||||
|
* 2. 查询:MP 用 LambdaQueryWrapper 构建条件,JPA 用方法命名或 JPQL
|
||||||
|
* 3. 分页:MP 用 Page + IPage,JPA 用 PageRequest + Page
|
||||||
|
* 4. 更新:MP 用 updateById(),JPA 用 save()
|
||||||
|
*
|
||||||
|
* 关键武器:LambdaQueryWrapper
|
||||||
|
* 用它构建条件,不用担心列名写错(基于 Lambda 表达式,编译期安全)
|
||||||
|
* 例如:wrapper.eq(Student::getName, "张三") → WHERE name = '张三'
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class StudentMpService {
|
||||||
|
|
||||||
|
private final StudentMapper mapper;
|
||||||
|
|
||||||
|
public StudentMpService(StudentMapper mapper) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 增删改查 ====================
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Student add(Student student) {
|
||||||
|
mapper.insert(student); // MP: insert() 只用于新增
|
||||||
|
return student; // insert 后 student.id 已被自动回填
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Student> list() {
|
||||||
|
// MP 条件构造器:空的 wrapper → 查全部
|
||||||
|
// orderByDesc 让结果按成绩降序
|
||||||
|
LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.orderByDesc(Student::getScore);
|
||||||
|
return mapper.selectList(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<Student> getById(Long id) {
|
||||||
|
return Optional.ofNullable(mapper.selectById(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@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); // MP 用 updateById
|
||||||
|
return Optional.of(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public boolean delete(Long id) {
|
||||||
|
return mapper.deleteById(id) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 搜索 ====================
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Student> searchByKeyword(String keyword) {
|
||||||
|
return mapper.searchByKeyword(keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Student> findByName(String name) {
|
||||||
|
// MP 的 Lambda 方式构建条件
|
||||||
|
LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.like(Student::getName, name); // WHERE name LIKE '%name%'
|
||||||
|
return mapper.selectList(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 分页(第 5 天) ====================
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public IPage<Student> page(int pageNum, int pageSize) {
|
||||||
|
// MP 分页:Page<Student>(页码, 每页条数)
|
||||||
|
Page<Student> page = new Page<>(pageNum, pageSize);
|
||||||
|
|
||||||
|
// 按成绩降序
|
||||||
|
LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.orderByDesc(Student::getScore);
|
||||||
|
|
||||||
|
return mapper.selectPage(page, wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 统计 ====================
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long count() {
|
||||||
|
return mapper.selectCount(null); // null = 无筛选条件
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long countByScoreRange(int min, int max) {
|
||||||
|
// MP 的 Lambda 方式构建范围条件
|
||||||
|
LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.between(Student::getScore, min, max); // WHERE score BETWEEN min AND max
|
||||||
|
return mapper.selectCount(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 高级查询(演示 LambdaQueryWrapper 的威力) ====================
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Student> findExcellentStudents(int threshold) {
|
||||||
|
LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.ge(Student::getScore, threshold) // WHERE score >= ?
|
||||||
|
.orderByDesc(Student::getScore);
|
||||||
|
return mapper.selectList(wrapper);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
week3/src/main/resources/application.yml
Normal file
43
week3/src/main/resources/application.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# ==========================================
|
||||||
|
# Week 3 配置文件 —— JPA + MyBatis-Plus 双轨
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: week3-orm
|
||||||
|
|
||||||
|
# ---- 数据源配置(连接你本地的 MySQL)----
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://localhost:3306/week3_student?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
|
||||||
|
username: root
|
||||||
|
password: 1365957941@Wfj
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
|
||||||
|
# ---- JPA 配置 ----
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: none # 手动管理表结构(SQL 脚本执行)
|
||||||
|
show-sql: true # 打印 JPA 生成的 SQL
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
format_sql: true # 格式化输出的 SQL
|
||||||
|
dialect: org.hibernate.dialect.MySQLDialect
|
||||||
|
|
||||||
|
# ---- MyBatis-Plus 配置 ----
|
||||||
|
mybatis-plus:
|
||||||
|
configuration:
|
||||||
|
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印 MP 生成的 SQL
|
||||||
|
map-underscore-to-camel-case: true # 下划线转驼峰(create_time → createTime)
|
||||||
|
global-config:
|
||||||
|
db-config:
|
||||||
|
id-type: auto # 主键自增
|
||||||
|
logic-delete-field: deleted # 逻辑删除字段(预习)
|
||||||
|
|
||||||
|
# ---- 日志 ----
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.learn: DEBUG
|
||||||
|
|
||||||
|
# ---- 服务器端口 ----
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
322
week3/src/main/resources/static/css/style.css
Normal file
322
week3/src/main/resources/static/css/style.css
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
/* ==========================================
|
||||||
|
* Week 3 学生管理系统 —— 样式表(第 7 天)
|
||||||
|
* 涵盖:盒模型、Flexbox、选择器、伪类、过渡动画
|
||||||
|
* ========================================== */
|
||||||
|
|
||||||
|
/* ---- 基础重置 ---- */
|
||||||
|
*, *::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-color: #f0f2f5;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 容器 ---- */
|
||||||
|
.container {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 头部 ---- */
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tab 切换栏 ---- */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 8px 24px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 工具栏 ---- */
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar input[type="text"]:focus {
|
||||||
|
border-color: #1890ff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 按钮 ---- */
|
||||||
|
.btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit:hover {
|
||||||
|
background: #389e0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 表格 ---- */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
border-bottom: 2px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: #e6f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 成绩徽章 */
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* ---- 空状态 ---- */
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #bbb;
|
||||||
|
padding: 40px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 分页 ---- */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:hover:not(:disabled) {
|
||||||
|
color: #1890ff;
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination button:disabled {
|
||||||
|
color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .page-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 统计卡片 ---- */
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
flex: 1;
|
||||||
|
background: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
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: 440px;
|
||||||
|
max-width: 90vw;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal input:focus {
|
||||||
|
border-color: #1890ff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
94
week3/src/main/resources/static/index.html
Normal file
94
week3/src/main/resources/static/index.html
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>学生管理系统 —— Week 3 产出</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- 头部 -->
|
||||||
|
<header>
|
||||||
|
<h1>📚 学生管理系统</h1>
|
||||||
|
<p class="subtitle">Spring Boot + JPA & MyBatis-Plus 双引擎驱动</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 切换栏:JPA / MP -->
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button class="tab active" onclick="switchMode('jpa')">JPA 模式</button>
|
||||||
|
<button class="tab" onclick="switchMode('mp')">MyBatis-Plus 模式</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<input type="text" id="keyword" placeholder="搜索姓名或邮箱..." oninput="search()">
|
||||||
|
<button class="btn btn-primary" onclick="showAddForm()">+ 新增学生</button>
|
||||||
|
<button class="btn btn-outline" onclick="loadStudents()">刷新</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据表格 -->
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>姓名</th>
|
||||||
|
<th>年龄</th>
|
||||||
|
<th>邮箱</th>
|
||||||
|
<th>成绩</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="table-body">
|
||||||
|
<tr><td colspan="7" class="empty">加载中...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="pagination" id="pagination"></div>
|
||||||
|
|
||||||
|
<!-- 统计卡片 -->
|
||||||
|
<div class="stats">
|
||||||
|
<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" onclick="closeModal(event)">
|
||||||
|
<div class="modal">
|
||||||
|
<h2 id="modal-title">新增学生</h2>
|
||||||
|
<form id="student-form" onsubmit="submitForm(event)">
|
||||||
|
<input type="hidden" id="form-id">
|
||||||
|
|
||||||
|
<label>姓名 *</label>
|
||||||
|
<input type="text" id="form-name" required maxlength="20" placeholder="请输入姓名">
|
||||||
|
|
||||||
|
<label>年龄 *</label>
|
||||||
|
<input type="number" id="form-age" required min="1" max="150" placeholder="1-150">
|
||||||
|
|
||||||
|
<label>邮箱 *</label>
|
||||||
|
<input type="email" id="form-email" required placeholder="example@mail.com">
|
||||||
|
|
||||||
|
<label>成绩 *</label>
|
||||||
|
<input type="number" id="form-score" required min="0" max="100" placeholder="0-100">
|
||||||
|
|
||||||
|
<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>
|
||||||
253
week3/src/main/resources/static/js/app.js
Normal file
253
week3/src/main/resources/static/js/app.js
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* 学生管理系统 —— 前端交互逻辑(第 6 天)
|
||||||
|
*
|
||||||
|
* 核心知识点:
|
||||||
|
* fetch() —— 调用后端 API
|
||||||
|
* DOM 操作 —— 动态渲染页面
|
||||||
|
* addEventListener —— 事件绑定
|
||||||
|
* JSON 序列化 —— 前后端数据交换格式
|
||||||
|
*/
|
||||||
|
|
||||||
|
let currentMode = 'jpa'; // 当前模式:jpa 或 mp
|
||||||
|
let currentPage = 1;
|
||||||
|
const pageSize = 5;
|
||||||
|
|
||||||
|
// ==================== 初始化 ====================
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadStudents();
|
||||||
|
loadStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 模式切换 ====================
|
||||||
|
|
||||||
|
function switchMode(mode) {
|
||||||
|
currentMode = mode;
|
||||||
|
currentPage = 1;
|
||||||
|
|
||||||
|
// 更新 Tab 样式
|
||||||
|
document.querySelectorAll('.tab').forEach((tab, i) => {
|
||||||
|
tab.classList.toggle('active', (i === 0 && mode === 'jpa') || (i === 1 && mode === 'mp'));
|
||||||
|
});
|
||||||
|
|
||||||
|
loadStudents();
|
||||||
|
loadStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 数据加载 ====================
|
||||||
|
|
||||||
|
async function loadStudents(keyword) {
|
||||||
|
const tbody = document.getElementById('table-body');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="empty">加载中...</td></tr>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = `/api/${currentMode}/students`;
|
||||||
|
let url;
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
url = `${baseUrl}?keyword=${encodeURIComponent(keyword)}`;
|
||||||
|
} else {
|
||||||
|
url = `${baseUrl}/page?pageNum=${currentPage}&pageSize=${pageSize}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
const students = result.data;
|
||||||
|
if (students.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="empty">暂无数据</td></tr>';
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = students.map(s => renderRow(s)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页信息
|
||||||
|
if (result.total !== undefined) {
|
||||||
|
renderPagination(result.total, result.pages, result.current);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="empty">加载失败</td></tr>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="empty">请求出错: ' + error.message + '</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRow(student) {
|
||||||
|
const score = student.score;
|
||||||
|
let badgeClass = 'badge-poor';
|
||||||
|
if (score >= 90) badgeClass = 'badge-excellent';
|
||||||
|
else if (score >= 80) badgeClass = 'badge-good';
|
||||||
|
else if (score >= 60) badgeClass = 'badge-normal';
|
||||||
|
|
||||||
|
const time = student.createTime ? student.createTime.substring(0, 10) : '-';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${student.id}</td>
|
||||||
|
<td><strong>${escapeHtml(student.name)}</strong></td>
|
||||||
|
<td>${student.age}</td>
|
||||||
|
<td>${escapeHtml(student.email)}</td>
|
||||||
|
<td><span class="badge ${badgeClass}">${score}</span></td>
|
||||||
|
<td>${time}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-edit" onclick="showEditForm(${student.id})">编辑</button>
|
||||||
|
<button class="btn-danger" onclick="deleteStudent(${student.id})">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination(total, pages, current) {
|
||||||
|
const container = document.getElementById('pagination');
|
||||||
|
if (!pages || pages <= 1) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
html += `<button onclick="goPage(${current - 1})" ${current <= 1 ? 'disabled' : ''}>上一页</button>`;
|
||||||
|
html += `<span class="page-info">第 ${current} / ${pages} 页(共 ${total} 条)</span>`;
|
||||||
|
html += `<button onclick="goPage(${current + 1})" ${current >= pages ? 'disabled' : ''}>下一页</button>`;
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPage(page) {
|
||||||
|
currentPage = page;
|
||||||
|
loadStudents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
const keyword = document.getElementById('keyword').value;
|
||||||
|
currentPage = 1;
|
||||||
|
loadStudents(keyword || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 统计 ====================
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const baseUrl = `/api/${currentMode}/students`;
|
||||||
|
|
||||||
|
// 总数
|
||||||
|
const allResp = await fetch(baseUrl);
|
||||||
|
const allResult = await allResp.json();
|
||||||
|
document.getElementById('stat-total').textContent =
|
||||||
|
allResult.code === 200 ? (Array.isArray(allResult.data) ? allResult.data.length : '-') : '-';
|
||||||
|
|
||||||
|
// 优秀人数
|
||||||
|
const statsUrl = currentMode === 'jpa'
|
||||||
|
? `${baseUrl}/stats?min=85&max=100`
|
||||||
|
: `${baseUrl}/excellent?threshold=85`;
|
||||||
|
const statsResp = await fetch(statsUrl);
|
||||||
|
const statsResult = await statsResp.json();
|
||||||
|
document.getElementById('stat-excellent').textContent =
|
||||||
|
statsResult.code === 200 ? (statsResult.count ?? statsResult.data?.length ?? '-') : '-';
|
||||||
|
} catch (e) {
|
||||||
|
// 静默处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 新增 / 编辑 ====================
|
||||||
|
|
||||||
|
function showAddForm() {
|
||||||
|
document.getElementById('modal-title').textContent = '新增学生';
|
||||||
|
document.getElementById('form-id').value = '';
|
||||||
|
document.getElementById('form-name').value = '';
|
||||||
|
document.getElementById('form-age').value = '';
|
||||||
|
document.getElementById('form-email').value = '';
|
||||||
|
document.getElementById('form-score').value = '';
|
||||||
|
document.getElementById('modal-overlay').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showEditForm(id) {
|
||||||
|
const response = await fetch(`/api/${currentMode}/students/${id}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.code !== 200) {
|
||||||
|
alert('获取学生信息失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = result.data;
|
||||||
|
document.getElementById('modal-title').textContent = '编辑学生';
|
||||||
|
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;
|
||||||
|
document.getElementById('modal-overlay').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const id = document.getElementById('form-id').value;
|
||||||
|
const student = {
|
||||||
|
name: document.getElementById('form-name').value,
|
||||||
|
age: parseInt(document.getElementById('form-age').value),
|
||||||
|
email: document.getElementById('form-email').value,
|
||||||
|
score: parseInt(document.getElementById('form-score').value)
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseUrl = `/api/${currentMode}/students`;
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
// 更新
|
||||||
|
response = await fetch(`${baseUrl}/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(student)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 新增
|
||||||
|
response = await fetch(baseUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(student)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.code === 200 || result.code === 201) {
|
||||||
|
closeModal();
|
||||||
|
loadStudents();
|
||||||
|
loadStats();
|
||||||
|
} else {
|
||||||
|
alert('操作失败: ' + (result.message || '未知错误'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 删除 ====================
|
||||||
|
|
||||||
|
async function deleteStudent(id) {
|
||||||
|
if (!confirm('确认删除该学生?')) return;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/${currentMode}/students/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
loadStudents();
|
||||||
|
loadStats();
|
||||||
|
} else {
|
||||||
|
alert('删除失败: ' + (result.message || '未知错误'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 弹窗控制 ====================
|
||||||
|
|
||||||
|
function closeModal(event) {
|
||||||
|
if (event && event.target !== document.getElementById('modal-overlay')) return;
|
||||||
|
document.getElementById('modal-overlay').classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 工具函数 ====================
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
473
week3/教案.md
Normal file
473
week3/教案.md
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
# 第三阶段教案:ORM 双轨 —— JPA + MyBatis-Plus(第 3 周)
|
||||||
|
|
||||||
|
> **学习周期**:7 天
|
||||||
|
> **每日用时**:2-3 小时
|
||||||
|
> **最终产出**:同时掌握 JPA 和 MyBatis-Plus 两种 ORM 框架,附带 HTML/CSS 前端页面
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前置准备:初始化数据库
|
||||||
|
|
||||||
|
在开始之前,先执行 SQL 脚本创建数据库和表:
|
||||||
|
|
||||||
|
1. 打开 MySQL 客户端(命令行 / Navicat / DataGrip)
|
||||||
|
2. 执行 `sql/init.sql` 的**全部内容**
|
||||||
|
3. 验证:`SELECT * FROM week3_student.student;` 应看到 10 条預置数据
|
||||||
|
4. 修改 `application.yml` 中的数据库密码为你自己的密码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目导入
|
||||||
|
|
||||||
|
IDEA:File → Open → 选择 `week3/pom.xml` → Open as Project。
|
||||||
|
|
||||||
|
本项目同时引入 JPA 和 MyBatis-Plus 两套依赖,共用同一个 `Student` 实体类。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 1 天:MySQL 基础 + 表设计
|
||||||
|
|
||||||
|
### 为什么需要数据库?
|
||||||
|
|
||||||
|
第 2 周的通讯录用内存存储,应用重启后数据就没了。数据库(MySQL)是持久化存储,数据不会丢失。
|
||||||
|
|
||||||
|
### 核心概念
|
||||||
|
|
||||||
|
| 术语 | 解释 | 类比 |
|
||||||
|
|------|------|------|
|
||||||
|
| 数据库(Database) | 存放一组相关表的容器 | 一个 Excel 文件 |
|
||||||
|
| 表(Table) | 数据以行列存储 | Excel 中的一个 Sheet |
|
||||||
|
| 列(Column/字段) | 表的一个属性 | Sheet 中的一列 |
|
||||||
|
| 行(Row/记录) | 一条完整数据 | Sheet 中的一行 |
|
||||||
|
| 主键(Primary Key) | 唯一标识一行数据的列 | 身份证号 |
|
||||||
|
| 索引(Index) | 加速查询的数据结构 | 书的目录 |
|
||||||
|
|
||||||
|
### 阅读 init.sql
|
||||||
|
|
||||||
|
打开 `sql/init.sql`,逐行理解每条语句的作用:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 创建数据库(字符集 utf8mb4 支持中文和 emoji)
|
||||||
|
CREATE DATABASE IF NOT EXISTS week3_student
|
||||||
|
DEFAULT CHARACTER SET utf8mb4;
|
||||||
|
|
||||||
|
-- 创建表
|
||||||
|
CREATE TABLE student (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
name VARCHAR(20) NOT NULL COMMENT '姓名',
|
||||||
|
...
|
||||||
|
PRIMARY KEY (id) -- 主键约束
|
||||||
|
) ENGINE=InnoDB; -- InnoDB 引擎支持事务
|
||||||
|
```
|
||||||
|
|
||||||
|
关键点:
|
||||||
|
- `AUTO_INCREMENT` —— 插入时不必指定 ID,数据库自动递增
|
||||||
|
- `VARCHAR(20)` —— 可变长度字符串,最多 20 个字符
|
||||||
|
- `NOT NULL` —— 该列不能为空
|
||||||
|
- `DEFAULT` —— 未指定时的默认值
|
||||||
|
- `COMMENT` —— 列注释(给人看的,不影响数据库行为)
|
||||||
|
|
||||||
|
### 动手
|
||||||
|
|
||||||
|
1. 执行 `init.sql`,验证数据是否插入成功
|
||||||
|
2. 用命令行或 GUI 工具执行以下查询:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM student WHERE score >= 85 ORDER BY score DESC;
|
||||||
|
SELECT COUNT(*) FROM student;
|
||||||
|
SELECT AVG(score) FROM student;
|
||||||
|
SELECT name, score FROM student WHERE name LIKE '%张%';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 2 天:Spring Data JPA —— 声明式数据访问
|
||||||
|
|
||||||
|
### JPA 是什么?
|
||||||
|
|
||||||
|
JPA(Java Persistence API)是 Java 官方的 ORM 标准,Hibernate 是它的最流行实现。
|
||||||
|
|
||||||
|
**ORM(Object-Relational Mapping)**:把数据库的表映射为 Java 对象。让你用操作对象的方式操作数据库,不用手写 SQL。
|
||||||
|
|
||||||
|
```
|
||||||
|
数据库表:student (id, name, age, email, score)
|
||||||
|
↕ JPA 映射
|
||||||
|
Java 类:Student { id, name, age, email, score }
|
||||||
|
```
|
||||||
|
|
||||||
|
### JPA 的核心流程
|
||||||
|
|
||||||
|
```
|
||||||
|
StudentJpaRepository ← 继承 JpaRepository<Student, Long>
|
||||||
|
↓ 自动生成
|
||||||
|
SQL → 执行 → 结果映射为 Student 对象
|
||||||
|
```
|
||||||
|
|
||||||
|
打开以下文件,对照阅读:
|
||||||
|
|
||||||
|
#### 1. 实体:`entity/Student.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Entity // 标记为 JPA 实体
|
||||||
|
@Table(name = "student") // 映射到 student 表
|
||||||
|
public class Student {
|
||||||
|
|
||||||
|
@Id // 主键
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY) // 自增
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "name", length = 20) // 映射到 name 列
|
||||||
|
private String name;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**理解**:`@Entity` + `@Table` 告诉 JPA:"这个类对应哪张表"。`@Column` 告诉 JPA:"这个属性对应哪个列"。有了这些注解,JPA 就知道如何把查询结果自动填充为 Student 对象。
|
||||||
|
|
||||||
|
#### 2. 仓库:`repository/jpa/StudentJpaRepository.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Repository
|
||||||
|
public interface StudentJpaRepository extends JpaRepository<Student, Long> {
|
||||||
|
|
||||||
|
List<Student> findByName(String name); // ← 方法名即查询
|
||||||
|
List<Student> findByNameContaining(String keyword); // ← 自动模糊搜索
|
||||||
|
List<Student> findByScoreGreaterThan(int score); // ← 自动 > 条件
|
||||||
|
|
||||||
|
@Query("SELECT s FROM Student s WHERE s.name LIKE %:kw%") // ← 自定义 JPQL
|
||||||
|
List<Student> searchByKeyword(@Param("kw") String kw);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**理解**:你只需要定义接口和方法名,JPA 自动生成 SQL。这就是"声明式"的含义——声明你想查什么,框架负责怎么查。
|
||||||
|
|
||||||
|
#### 3. 服务:`service/jpa/StudentJpaService.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Transactional // 声明式事务:方法内所有 DB 操作在同一事务中
|
||||||
|
public Student add(Student student) {
|
||||||
|
return repository.save(student); // 一句代码完成 INSERT
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 动手
|
||||||
|
|
||||||
|
1. 启动项目,用 Postman 测试 JPA 接口:
|
||||||
|
```
|
||||||
|
GET /api/jpa/students —— 查全部
|
||||||
|
GET /api/jpa/students?keyword=张 —— 模糊搜索
|
||||||
|
GET /api/jpa/students/1 —— 查单个
|
||||||
|
POST /api/jpa/students —— 新增
|
||||||
|
PUT /api/jpa/students/1 —— 更新
|
||||||
|
DELETE /api/jpa/students/1 —— 删除
|
||||||
|
GET /api/jpa/students/page?pageNum=1&pageSize=3 —— 分页
|
||||||
|
GET /api/jpa/students/stats?min=80&max=100 —— 统计
|
||||||
|
```
|
||||||
|
2. 观察控制台输出的 JPA SQL(`application.yml` 中 `show-sql: true`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 3 天:MyBatis-Plus —— 灵活的 SQL 利器
|
||||||
|
|
||||||
|
### MyBatis-Plus 是什么?
|
||||||
|
|
||||||
|
MyBatis 是半自动 ORM,你需要自己写 SQL(在 XML 或注解中),但框架帮你做结果映射。MyBatis-Plus 在 MyBatis 基础上做了增强:常见 CRUD 不用写 SQL。
|
||||||
|
|
||||||
|
### JPA vs MyBatis-Plus 设计哲学
|
||||||
|
|
||||||
|
| | JPA | MyBatis-Plus |
|
||||||
|
|------|-----|-------|
|
||||||
|
| **设计理念** | 面向对象 → 自动生成 SQL | SQL 为核心 → 辅助生成 |
|
||||||
|
| **你控制什么** | 定义实体和方法名 | 直接写 SQL 或用 Lambda 构建条件 |
|
||||||
|
| **适用场景** | 常规 CRUD、表关联规范 | 复杂查询、报表、性能优化 |
|
||||||
|
|
||||||
|
### MP 的核心流程
|
||||||
|
|
||||||
|
```
|
||||||
|
StudentMapper ← 继承 BaseMapper<Student>
|
||||||
|
↓ LambdaQueryWrapper 构建条件 / @Select 写 SQL
|
||||||
|
SQL → 执行 → 结果映射为 Student 对象
|
||||||
|
```
|
||||||
|
|
||||||
|
打开以下文件,对照阅读:
|
||||||
|
|
||||||
|
#### 1. Mapper:`repository/mp/StudentMapper.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Mapper
|
||||||
|
public interface StudentMapper extends BaseMapper<Student> {
|
||||||
|
|
||||||
|
// 方式一:@Select 直接写 SQL(最灵活)
|
||||||
|
@Select("SELECT * FROM student WHERE name LIKE CONCAT('%', #{kw}, '%')")
|
||||||
|
List<Student> searchByKeyword(@Param("kw") String kw);
|
||||||
|
|
||||||
|
// 方式二:自定义分页 + SQL
|
||||||
|
@Select("SELECT * FROM student WHERE name LIKE CONCAT('%', #{kw}, '%')")
|
||||||
|
IPage<Student> searchByKeywordPage(Page<Student> page, @Param("kw") String kw);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 服务:`service/mp/StudentMpService.java`
|
||||||
|
|
||||||
|
**重点:LambdaQueryWrapper** —— MP 的杀手级功能
|
||||||
|
|
||||||
|
```java
|
||||||
|
// 查全部、按成绩降序
|
||||||
|
LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.orderByDesc(Student::getScore); // 基于 Lambda,列名不会写错
|
||||||
|
mapper.selectList(wrapper);
|
||||||
|
|
||||||
|
// 模糊搜索 + 范围 + 排序(链式调用)
|
||||||
|
wrapper.like(Student::getName, keyword) // WHERE name LIKE '%keyword%'
|
||||||
|
.ge(Student::getScore, 60) // AND score >= 60
|
||||||
|
.orderByDesc(Student::getScore);
|
||||||
|
mapper.selectList(wrapper);
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
Page<Student> page = new Page<>(1, 5); // 第 1 页,每页 5 条
|
||||||
|
mapper.selectPage(page, wrapper);
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么 Lambda 比字符串安全?**
|
||||||
|
```java
|
||||||
|
wrapper.eq("name", "张三"); // ❌ "name" 写错了编译期不报错
|
||||||
|
wrapper.eq(Student::getName, "张三"); // ✅ 写错了编译期直接报红
|
||||||
|
```
|
||||||
|
|
||||||
|
### 动手
|
||||||
|
|
||||||
|
1. 启动项目,用 Postman 测试 MP 接口(注意 `/api/mp/` 前缀):
|
||||||
|
```
|
||||||
|
GET /api/mp/students —— 查全部
|
||||||
|
GET /api/mp/students?keyword=张 —— 模糊搜索
|
||||||
|
GET /api/mp/students/1 —— 查单个
|
||||||
|
POST /api/mp/students —— 新增
|
||||||
|
PUT /api/mp/students/1 —— 更新
|
||||||
|
DELETE /api/mp/students/1 —— 删除
|
||||||
|
GET /api/mp/students/page?pageNum=1&pageSize=3 —— 分页
|
||||||
|
GET /api/mp/students/excellent?threshold=85 —— 高分学生
|
||||||
|
```
|
||||||
|
2. 观察控制台输出的 MP SQL 日志
|
||||||
|
3. 对比同一操作在 JPA 和 MP 下生成的 SQL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 4 天:JPA vs MyBatis-Plus 全面对比
|
||||||
|
|
||||||
|
### 同步测试
|
||||||
|
|
||||||
|
用 Postman 分别调同一个操作,对比差异:
|
||||||
|
|
||||||
|
| 操作 | JPA 接口 | MP 接口 | 观察点 |
|
||||||
|
|------|---------|---------|--------|
|
||||||
|
| 查全部 | `GET /api/jpa/students` | `GET /api/mp/students` | 返回格式是否一致? |
|
||||||
|
| 新增 | `POST /api/jpa/students` | `POST /api/mp/students` | 控制台 SQL 有何不同? |
|
||||||
|
| 分页 | `GET /api/jpa/students/page` | `GET /api/mp/students/page` | 分页字段名一样吗? |
|
||||||
|
|
||||||
|
### 对比总结(动手写)
|
||||||
|
|
||||||
|
| 维度 | Spring Data JPA | MyBatis-Plus |
|
||||||
|
|------|----------------|--------------|
|
||||||
|
| **风格** | 声明式,方法名即查询 | Lambda 条件构造器 + 原生 SQL |
|
||||||
|
| **简单 CRUD** | `JpaRepository` 已提供 | `BaseMapper` 已提供 |
|
||||||
|
| **复杂查询** | `@Query` 写 JPQL / 方法命名 | `@Select` 写原生 SQL / LambdaWrapper |
|
||||||
|
| **分页** | `PageRequest` + `findAll()` | `Page<T>` + `selectPage()` |
|
||||||
|
| **SQL 可见性** | 框架生成,不直观 | 控制台清晰输出,更透明 |
|
||||||
|
| **上手难度** | 需理解 JPA 规范 | 会 SQL 即可上手 |
|
||||||
|
|
||||||
|
### 今日任务
|
||||||
|
|
||||||
|
1. 完成上面的对比测试表格(填入你的观察)
|
||||||
|
2. 分别在两个 Controller 中新增一个接口:
|
||||||
|
- JPA 版:`GET /api/jpa/students/above/{score}` —— 查询大于指定分数的学生
|
||||||
|
- MP 版:`GET /api/mp/students/above/{score}` —— 同上
|
||||||
|
3. 观察两者生成的 SQL,体会"声明式"和"手动式"的差异
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 5 天:分页与事务
|
||||||
|
|
||||||
|
### 分页对比
|
||||||
|
|
||||||
|
**JPA 分页**:
|
||||||
|
```java
|
||||||
|
PageRequest pr = PageRequest.of(0, 5, Sort.by(Direction.DESC, "score"));
|
||||||
|
Page<Student> page = repository.findAll(pr);
|
||||||
|
// page.getContent() → 当前页数据
|
||||||
|
// page.getTotalElements() → 总记录数
|
||||||
|
// page.getTotalPages() → 总页数
|
||||||
|
```
|
||||||
|
|
||||||
|
**MP 分页**:
|
||||||
|
```java
|
||||||
|
Page<Student> page = new Page<>(1, 5); // 页码从 1 开始
|
||||||
|
IPage<Student> result = mapper.selectPage(page, wrapper);
|
||||||
|
// result.getRecords() → 当前页数据
|
||||||
|
// result.getTotal() → 总记录数
|
||||||
|
// result.getPages() → 总页数
|
||||||
|
```
|
||||||
|
|
||||||
|
**差异**:JPA 的 PageRequest 页码从 0 开始,MP 从 1 开始。
|
||||||
|
|
||||||
|
### @Transactional 详解
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Transactional
|
||||||
|
public Student add(Student student) {
|
||||||
|
repository.save(student); // 操作 1
|
||||||
|
// ... 如果这里抛异常,
|
||||||
|
// 操作 1 自动回滚,数据不会写入数据库
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
关键属性:
|
||||||
|
- `readOnly = true` —— 只读事务,性能更好(跳过脏检查)
|
||||||
|
- `rollbackFor` —— 指定哪些异常触发回滚(默认 RuntimeException 和 Error)
|
||||||
|
|
||||||
|
### 今日任务
|
||||||
|
|
||||||
|
1. 故意在新增逻辑后 `throw new RuntimeException("模拟异常")`,验证数据是否回滚
|
||||||
|
2. 分别用 Postman 测试 JPA 和 MP 的分页接口,对比返回字段名
|
||||||
|
3. 在 MySQL 中直接插入一条数据,用 API 验证能否查到
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 6 天:HTML 基础 —— 为后端配一个前端
|
||||||
|
|
||||||
|
### 核心概念
|
||||||
|
|
||||||
|
| 概念 | 解释 |
|
||||||
|
|------|------|
|
||||||
|
| **HTML** | 网页的骨架 —— 定义"有什么"(标题、表格、按钮) |
|
||||||
|
| **标签** | `<tag>` 开始,`</tag>` 结束,如 `<h1>标题</h1>` |
|
||||||
|
| **属性** | 标签的附加信息,如 `<input type="text" placeholder="搜索...">` |
|
||||||
|
| **表单** | `<form>` + `<input>`,用于收集用户输入 |
|
||||||
|
|
||||||
|
### 阅读 index.html
|
||||||
|
|
||||||
|
打开 `src/main/resources/static/index.html`,对照理解:
|
||||||
|
|
||||||
|
```
|
||||||
|
<!DOCTYPE html> → 声明文档类型
|
||||||
|
<html> → 根元素
|
||||||
|
<head> → 元数据(标题、样式引用)
|
||||||
|
<body> → 页面可见内容
|
||||||
|
<header> → 头部区域
|
||||||
|
<table> → 表格:<thead> 表头 + <tbody> 数据行
|
||||||
|
<form> → 表单:<input> 各种输入框
|
||||||
|
```
|
||||||
|
|
||||||
|
### 如何在浏览器中访问
|
||||||
|
|
||||||
|
Spring Boot 默认把 `src/main/resources/static/` 下的文件作为静态资源暴露。
|
||||||
|
|
||||||
|
启动项目后访问:**`http://localhost:8080/index.html`**
|
||||||
|
|
||||||
|
页面中的 JavaScript(`js/app.js`)会调用 `fetch()` 访问后端 API 获取数据并渲染表格。
|
||||||
|
|
||||||
|
### 今日任务
|
||||||
|
|
||||||
|
1. 访问 `http://localhost:8080/index.html`,确认页面正常加载
|
||||||
|
2. 点击 "JPA 模式" / "MP 模式" Tab,观察数据差异(如果有的话)
|
||||||
|
3. 尝试新增一个学生,查看是否成功
|
||||||
|
4. 在 `index.html` 中的表格表头增加一列"成绩排名"(暂时填 "-")
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第 7 天:CSS 基础 —— 让页面好看
|
||||||
|
|
||||||
|
### 核心概念
|
||||||
|
|
||||||
|
| 概念 | 解释 |
|
||||||
|
|------|------|
|
||||||
|
| **CSS** | 网页的皮肤 —— 定义"长什么样"(颜色、间距、布局) |
|
||||||
|
| **选择器** | 指定 CSS 样式作用于哪些元素(如 `.tab` 作用于 class="tab" 的元素) |
|
||||||
|
| **盒模型** | 每个元素 = content + padding + border + margin |
|
||||||
|
| **Flexbox** | 弹性布局,轻松实现水平排列和居中对齐 |
|
||||||
|
| **伪类** | 元素的状态,如 `:hover`(鼠标悬停)、`:focus`(获得焦点) |
|
||||||
|
|
||||||
|
### 阅读 style.css
|
||||||
|
|
||||||
|
打开 `src/main/resources/static/css/style.css`,找出以下模式:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 类选择器 —— 作用于 class="xxx" 的元素 */
|
||||||
|
.toolbar {
|
||||||
|
display: flex; /* ← Flexbox 横向排列 */
|
||||||
|
gap: 12px; /* ← 子元素间距 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签选择器 —— 作用于所有该标签 */
|
||||||
|
table {
|
||||||
|
border-collapse: collapse; /* ← 合并表格边框 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 伪类 —— 鼠标悬停效果 */
|
||||||
|
.btn:hover {
|
||||||
|
background: #0070d2; /* ← 鼠标悬停时变色 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 子元素选择器 —— 只作用于 table 下的 th */
|
||||||
|
thead th {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS 盒模型速查
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────── margin(外边距,元素与元素之间)─────────┐
|
||||||
|
│ ┌─── border(边框)───┐ │
|
||||||
|
│ │ ┌── padding(内边距)──┐ │
|
||||||
|
│ │ │ content(内容) │ │
|
||||||
|
│ │ └──────────────────────┘ │
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 今日任务
|
||||||
|
|
||||||
|
1. 在浏览器中打开 `index.html`,F12 打开开发者工具
|
||||||
|
2. 用"元素选择器"工具点击表格某一行,查看它的 CSS 规则
|
||||||
|
3. 修改 `style.css`:把侧边栏主色 `#1890ff` 改成你喜欢的颜色
|
||||||
|
4. 给统计卡片增加一个"鼠标悬停时轻微放大"的动画效果:
|
||||||
|
```css
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本周产出清单
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ SQL 数据库表设计与创建(student 表 + 10 条测试数据)
|
||||||
|
✅ JPA 完整 CRUD + 分页 + 模糊搜索 + 统计
|
||||||
|
✅ MyBatis-Plus 完整 CRUD + 分页 + Lambda 条件查询
|
||||||
|
✅ 同一实体类兼容两套 ORM 框架
|
||||||
|
✅ 对比测试笔记(JPA vs MP 的 API 风格、SQL 差异、分页差异)
|
||||||
|
✅ @Transactional 事务验证
|
||||||
|
✅ HTML 前端页面(表格 + 表单 + 分页 + 统计数据)
|
||||||
|
✅ CSS 样式(Flexbox 布局 + 颜色体系 + 响应式卡片)
|
||||||
|
✅ JavaScript fetch API 前后端联调
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
| 问题 | 原因 | 解决 |
|
||||||
|
|------|------|------|
|
||||||
|
| 启动报 "Access denied for user" | MySQL 密码不对 | 修改 `application.yml` 中的 `password` |
|
||||||
|
| 启动报 "Unknown database" | 没执行 init.sql | 先在 MySQL 中执行 `sql/init.sql` |
|
||||||
|
| 启动报 "Table 'student' doesn't exist" | 执行 SQL 时没选对库 | 确保 `USE week3_student;` 后执行建表 |
|
||||||
|
| JPA 查询中文返回空 | 字符集问题 | 检查 URL 参数:`characterEncoding=utf-8` |
|
||||||
|
| 前端页面 404 | 静态资源路径不对 | 确认文件在 `src/main/resources/static/` 下 |
|
||||||
|
| 前端调接口跨域报错 | 前后端同源 | 本项目前后端同端口,不存在跨域问题 |
|
||||||
|
| MP 分页不生效 | 没配置分页插件 | 确认 `MyBatisPlusConfig` 中注册了 `PaginationInnerInterceptor` |
|
||||||
|
| JPA 的 show-sql 不打印 | 日志级别太高 | 确认 `logging.level.com.learn: DEBUG` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **本周核心**:JPA 和 MP 不是二选一的对手,而是一个工具箱里的两把刀。简单 CRUD 用 JPA 省力,复杂 SQL 用 MP 掌控。把两个都学透,你就是 ORM 工具箱齐全的开发者。
|
||||||
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 交互和文件上传,系统有拦截器保护、统一异常处理、参数校验。这是你第一个真正的里程碑项目。
|
||||||
106
week5/pom.xml
Normal file
106
week5/pom.xml
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?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>week5-security</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>Week 5: Spring 全家桶核心组件</name>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<mybatis-plus.version>3.5.6</mybatis-plus.version>
|
||||||
|
<jjwt.version>0.12.5</jjwt.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>
|
||||||
|
|
||||||
|
<!-- Spring Security -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JWT -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>${jjwt.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>${jjwt.version}</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>${jjwt.version}</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Cache + Redis -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-cache</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Actuator 监控 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</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>
|
||||||
56
week5/sql/init.sql
Normal file
56
week5/sql/init.sql
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
-- =============================================
|
||||||
|
-- Week 5:学生管理系统 v2 数据库初始化
|
||||||
|
-- 新增:users 表(认证)+ 逻辑删除 + RBAC
|
||||||
|
-- =============================================
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS week5_student
|
||||||
|
DEFAULT CHARACTER SET utf8mb4
|
||||||
|
DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
USE week5_student;
|
||||||
|
|
||||||
|
-- 学生表(增加 deleted 字段用于逻辑删除,第 7 天)
|
||||||
|
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',
|
||||||
|
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除 0-未删 1-已删',
|
||||||
|
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
INDEX idx_name (name),
|
||||||
|
INDEX idx_score (score)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生表';
|
||||||
|
|
||||||
|
-- 用户表(Spring Security 认证用,第 2-3 天)
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',
|
||||||
|
password VARCHAR(200) NOT NULL COMMENT 'BCrypt 加密后的密码',
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'USER' COMMENT '角色:ADMIN / USER',
|
||||||
|
enabled TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||||
|
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
|
||||||
|
|
||||||
|
-- 预置学生数据
|
||||||
|
INSERT INTO student (name, age, email, score) VALUES
|
||||||
|
('张三', 20, 'zhangsan@mail.com', 85),
|
||||||
|
('李四', 22, 'lisi@mail.com', 92),
|
||||||
|
('王五', 19, 'wangwu@mail.com', 78),
|
||||||
|
('赵六', 21, 'zhaoliu@mail.com', 88),
|
||||||
|
('孙七', 23, 'sunqi@mail.com', 95),
|
||||||
|
('周八', 20, 'zhouba@mail.com', 73),
|
||||||
|
('吴九', 22, 'wujiu@mail.com', 81),
|
||||||
|
('郑十', 21, 'zhengshi@mail.com',90);
|
||||||
|
|
||||||
|
-- 用户数据由应用启动时通过 Java 代码自动初始化(保证 BCrypt 哈希正确)
|
||||||
|
-- 默认账号:admin / 123456(ADMIN)、user / 123456(USER)
|
||||||
|
|
||||||
|
SELECT 'student' AS tbl, COUNT(*) AS cnt FROM student
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'users', COUNT(*) FROM users;
|
||||||
13
week5/src/main/java/com/learn/Week5Application.java
Normal file
13
week5/src/main/java/com/learn/Week5Application.java
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package com.learn;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
@EnableCaching // 开启 Spring Cache(第 4 天)
|
||||||
|
public class Week5Application {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(Week5Application.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
week5/src/main/java/com/learn/annotation/ScoreValidator.java
Normal file
19
week5/src/main/java/com/learn/annotation/ScoreValidator.java
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package com.learn.annotation;
|
||||||
|
|
||||||
|
import jakarta.validation.ConstraintValidator;
|
||||||
|
import jakarta.validation.ConstraintValidatorContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @ValidScore 的校验逻辑实现
|
||||||
|
*
|
||||||
|
* isValid 方法返回 true 表示校验通过,false 表示校验失败。
|
||||||
|
* ConstraintValidatorContext 用于自定义错误消息。
|
||||||
|
*/
|
||||||
|
public class ScoreValidator implements ConstraintValidator<ValidScore, Integer> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isValid(Integer value, ConstraintValidatorContext context) {
|
||||||
|
if (value == null) return true; // null 由 @NotNull 处理
|
||||||
|
return value >= 0 && value <= 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
week5/src/main/java/com/learn/annotation/ValidScore.java
Normal file
31
week5/src/main/java/com/learn/annotation/ValidScore.java
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package com.learn.annotation;
|
||||||
|
|
||||||
|
import jakarta.validation.Constraint;
|
||||||
|
import jakarta.validation.Payload;
|
||||||
|
import java.lang.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义校验注解(第 6 天)
|
||||||
|
*
|
||||||
|
* 功能等价于 @Min(0) @Max(100),但演示了自定义注解的完整流程:
|
||||||
|
* 1. 定义注解 @ValidScore
|
||||||
|
* 2. 编写校验器 ScoreValidator(实现 ConstraintValidator)
|
||||||
|
* 3. 在实体类字段上使用 @ValidScore
|
||||||
|
*
|
||||||
|
* @Constraint(validatedBy = ...) 将注解和校验器绑定
|
||||||
|
*/
|
||||||
|
@Documented
|
||||||
|
@Constraint(validatedBy = ScoreValidator.class)
|
||||||
|
@Target({ElementType.FIELD, ElementType.PARAMETER})
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
public @interface ValidScore {
|
||||||
|
|
||||||
|
// 校验失败时的默认错误消息
|
||||||
|
String message() default "成绩必须在 0-100 之间";
|
||||||
|
|
||||||
|
// 分组校验(后续学习,此处用默认)
|
||||||
|
Class<?>[] groups() default {};
|
||||||
|
|
||||||
|
// Payload(扩展用,此处用默认)
|
||||||
|
Class<? extends Payload>[] payload() default {};
|
||||||
|
}
|
||||||
41
week5/src/main/java/com/learn/config/DataInitializer.java
Normal file
41
week5/src/main/java/com/learn/config/DataInitializer.java
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package com.learn.config;
|
||||||
|
|
||||||
|
import com.learn.entity.User;
|
||||||
|
import com.learn.repository.jpa.UserRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.CommandLineRunner;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class DataInitializer implements CommandLineRunner {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
|
||||||
|
private final UserRepository userRepo;
|
||||||
|
private final PasswordEncoder encoder;
|
||||||
|
|
||||||
|
public DataInitializer(UserRepository userRepo, PasswordEncoder encoder) {
|
||||||
|
this.userRepo = userRepo;
|
||||||
|
this.encoder = encoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(String... args) {
|
||||||
|
if (userRepo.count() > 0) return; // 已有数据则跳过
|
||||||
|
|
||||||
|
User admin = new User();
|
||||||
|
admin.setUsername("admin");
|
||||||
|
admin.setPassword(encoder.encode("123456"));
|
||||||
|
admin.setRole("ADMIN");
|
||||||
|
userRepo.save(admin);
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
user.setUsername("user");
|
||||||
|
user.setPassword(encoder.encode("123456"));
|
||||||
|
user.setRole("USER");
|
||||||
|
userRepo.save(user);
|
||||||
|
|
||||||
|
log.info("默认用户已创建: admin/123456 (ADMIN), user/123456 (USER)");
|
||||||
|
}
|
||||||
|
}
|
||||||
21
week5/src/main/java/com/learn/config/MyBatisPlusConfig.java
Normal file
21
week5/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
week5/src/main/java/com/learn/config/SecurityConfig.java
Normal file
66
week5/src/main/java/com/learn/config/SecurityConfig.java
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package com.learn.config;
|
||||||
|
|
||||||
|
import com.learn.security.JwtAuthFilter;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Security 配置(第 1-3 天)
|
||||||
|
*
|
||||||
|
* 核心设计:
|
||||||
|
* 1. 无状态会话(JWT,不依赖 Cookie/Session)
|
||||||
|
* 2. 自定义 JWT 过滤器在 UsernamePasswordAuthenticationFilter 之前执行
|
||||||
|
* 3. RBAC:ADMIN 可增删改,USER 只能查
|
||||||
|
* 4. 公开 /api/auth/** 和 Actuator 端点
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
@EnableMethodSecurity // 开启方法级安全注解(第 3 天)
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private final JwtAuthFilter jwtAuthFilter;
|
||||||
|
|
||||||
|
public SecurityConfig(JwtAuthFilter jwtAuthFilter) {
|
||||||
|
this.jwtAuthFilter = jwtAuthFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.csrf(csrf -> csrf.disable()) // 前后端分离,禁用 CSRF
|
||||||
|
.sessionManagement(sm -> sm
|
||||||
|
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 无状态
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
// 公开接口
|
||||||
|
.requestMatchers("/api/auth/**").permitAll()
|
||||||
|
.requestMatchers("/actuator/**").permitAll()
|
||||||
|
.requestMatchers("/", "/index.html", "/css/**", "/js/**").permitAll()
|
||||||
|
// 查询:USER 及以上
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/students/**").hasAnyRole("ADMIN", "USER")
|
||||||
|
// 增删改:仅 ADMIN(第 3 天 RBAC)
|
||||||
|
.requestMatchers(HttpMethod.POST, "/api/students/**").hasRole("ADMIN")
|
||||||
|
.requestMatchers(HttpMethod.PUT, "/api/students/**").hasRole("ADMIN")
|
||||||
|
.requestMatchers(HttpMethod.DELETE, "/api/students/**").hasRole("ADMIN")
|
||||||
|
// 其他请求需认证
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
)
|
||||||
|
// 在用户名密码过滤器之前插入 JWT 过滤器
|
||||||
|
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder(); // BCrypt 加密,不可逆
|
||||||
|
}
|
||||||
|
}
|
||||||
50
week5/src/main/java/com/learn/controller/AuthController.java
Normal file
50
week5/src/main/java/com/learn/controller/AuthController.java
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package com.learn.controller;
|
||||||
|
|
||||||
|
import com.learn.dto.ApiResponse;
|
||||||
|
import com.learn.dto.LoginRequest;
|
||||||
|
import com.learn.service.AuthService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证接口(第 2 天)
|
||||||
|
*
|
||||||
|
* POST /api/auth/register —— 注册
|
||||||
|
* POST /api/auth/login —— 登录,返回 JWT
|
||||||
|
*
|
||||||
|
* 这些接口在 SecurityConfig 中设置了 permitAll(),无需认证即可访问。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
|
public AuthController(AuthService authService) {
|
||||||
|
this.authService = authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> register(@RequestBody Map<String, String> body) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> result = authService.register(
|
||||||
|
body.get("username"), body.get("password"));
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("注册成功", result));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return ResponseEntity.badRequest().body(ApiResponse.badRequest(e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> login(@Valid @RequestBody LoginRequest req) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> result = authService.login(req);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success("登录成功", result));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return ResponseEntity.status(401).body(ApiResponse.error(401, e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.security.Principal;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/students")
|
||||||
|
public class StudentController {
|
||||||
|
|
||||||
|
private final StudentServiceSelector service;
|
||||||
|
|
||||||
|
public StudentController(StudentServiceSelector service) { this.service = service; }
|
||||||
|
|
||||||
|
// ---- 查询(USER + ADMIN 均可)----
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<java.util.List<Student>> list(
|
||||||
|
@RequestParam(required = false) String keyword) {
|
||||||
|
java.util.List<Student> students = (keyword != null && !keyword.trim().isEmpty())
|
||||||
|
? service.search(keyword) : service.list();
|
||||||
|
return ApiResponse.success(students)
|
||||||
|
.put("total", students.size())
|
||||||
|
.put("orm", service.getActiveMode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Student>> getById(@PathVariable Long id) {
|
||||||
|
Student s = service.getById(id);
|
||||||
|
if (s != null) return ResponseEntity.ok(ApiResponse.success(s));
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(ApiResponse.notFound("学生不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/stats/excellent")
|
||||||
|
public ApiResponse<Long> statsExcellent(@RequestParam(defaultValue = "85") int min) {
|
||||||
|
return ApiResponse.success(service.countExcellent(min));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 新增(仅 ADMIN)----
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<Student>> add(@Valid @RequestBody Student student) {
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED)
|
||||||
|
.body(ApiResponse.created(service.add(student)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 更新(仅 ADMIN)----
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Student>> update(
|
||||||
|
@PathVariable Long id, @Valid @RequestBody Student student) {
|
||||||
|
return service.update(id, student)
|
||||||
|
.map(u -> ResponseEntity.ok(ApiResponse.success("更新成功", u)))
|
||||||
|
.orElse(ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(ApiResponse.notFound("学生不存在")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 删除(仅 ADMIN,MP 模式下为逻辑删除)----
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')") // 方法级权限控制(第 3 天)
|
||||||
|
public ResponseEntity<ApiResponse<?>> delete(@PathVariable Long id) {
|
||||||
|
return service.delete(id)
|
||||||
|
? ResponseEntity.ok(ApiResponse.success("删除成功", null))
|
||||||
|
: ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(ApiResponse.notFound("学生不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 当前用户信息 ----
|
||||||
|
|
||||||
|
@GetMapping("/me")
|
||||||
|
public ApiResponse<String> me(Principal principal) {
|
||||||
|
return ApiResponse.success("当前用户: " + principal.getName()
|
||||||
|
+ " | ORM: " + service.getActiveMode());
|
||||||
|
}
|
||||||
|
}
|
||||||
33
week5/src/main/java/com/learn/dto/ApiResponse.java
Normal file
33
week5/src/main/java/com/learn/dto/ApiResponse.java
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package com.learn.dto;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class ApiResponse<T> {
|
||||||
|
private int code;
|
||||||
|
private String message;
|
||||||
|
private T data;
|
||||||
|
private Map<String, Object> extra;
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> success(T data) { return build(200, "success", data); }
|
||||||
|
public static <T> ApiResponse<T> success(String msg, T data) { return build(200, msg, data); }
|
||||||
|
public static <T> ApiResponse<T> created(T data) { return build(201, "创建成功", data); }
|
||||||
|
public static <T> ApiResponse<T> error(int code, String msg) { return build(code, msg, null); }
|
||||||
|
public static <T> ApiResponse<T> notFound(String msg) { return error(404, msg); }
|
||||||
|
public static <T> ApiResponse<T> badRequest(String msg) { return error(400, msg); }
|
||||||
|
|
||||||
|
public ApiResponse<T> put(String key, Object value) {
|
||||||
|
if (extra == null) extra = new HashMap<>();
|
||||||
|
extra.put(key, value); return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> ApiResponse<T> build(int code, String msg, T data) {
|
||||||
|
ApiResponse<T> r = new ApiResponse<>();
|
||||||
|
r.code = code; r.message = msg; r.data = data; return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getCode() { return code; }
|
||||||
|
public String getMessage() { return message; }
|
||||||
|
public T getData() { return data; }
|
||||||
|
public Map<String, Object> getExtra() { return extra; }
|
||||||
|
}
|
||||||
15
week5/src/main/java/com/learn/dto/LoginRequest.java
Normal file
15
week5/src/main/java/com/learn/dto/LoginRequest.java
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package com.learn.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public class LoginRequest {
|
||||||
|
@NotBlank(message = "用户名不能为空")
|
||||||
|
private String username;
|
||||||
|
@NotBlank(message = "密码不能为空")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
public String getUsername() { return username; }
|
||||||
|
public void setUsername(String username) { this.username = username; }
|
||||||
|
public String getPassword() { return password; }
|
||||||
|
public void setPassword(String password) { this.password = password; }
|
||||||
|
}
|
||||||
77
week5/src/main/java/com/learn/entity/Student.java
Normal file
77
week5/src/main/java/com/learn/entity/Student.java
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package com.learn.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "student")
|
||||||
|
@TableName("student")
|
||||||
|
public class Student implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@NotBlank(message = "姓名不能为空")
|
||||||
|
@Size(min = 1, max = 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 @Email
|
||||||
|
@Column(name = "email", length = 50, nullable = false)
|
||||||
|
@TableField("email")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@com.learn.annotation.ValidScore // 第 6 天:自定义校验注解
|
||||||
|
@Column(name = "score", nullable = false)
|
||||||
|
@TableField("score")
|
||||||
|
private int score;
|
||||||
|
|
||||||
|
/** 逻辑删除标记(第 7 天): 0=未删, 1=已删 */
|
||||||
|
@TableLogic // MyBatis-Plus 逻辑删除
|
||||||
|
@Column(name = "deleted")
|
||||||
|
@TableField("deleted")
|
||||||
|
private Integer deleted = 0;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Integer getDeleted() { return deleted; }
|
||||||
|
public void setDeleted(Integer deleted) { this.deleted = deleted; }
|
||||||
|
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; }
|
||||||
|
}
|
||||||
44
week5/src/main/java/com/learn/entity/User.java
Normal file
44
week5/src/main/java/com/learn/entity/User.java
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package com.learn.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "users")
|
||||||
|
public class User implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 50)
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 200)
|
||||||
|
private String password; // BCrypt 加密后的密文
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 20)
|
||||||
|
private String role; // ADMIN / USER(第 3 天 RBAC)
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Integer enabled = 1;
|
||||||
|
|
||||||
|
@Column(name = "create_time", insertable = false, updatable = false)
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
public User() {}
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
public String getUsername() { return username; }
|
||||||
|
public void setUsername(String username) { this.username = username; }
|
||||||
|
public String getPassword() { return password; }
|
||||||
|
public void setPassword(String password) { this.password = password; }
|
||||||
|
public String getRole() { return role; }
|
||||||
|
public void setRole(String role) { this.role = role; }
|
||||||
|
public Integer getEnabled() { return enabled; }
|
||||||
|
public void setEnabled(Integer enabled) { this.enabled = enabled; }
|
||||||
|
public LocalDateTime getCreateTime() { return createTime; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
|
||||||
|
/** 参数校验失败 */
|
||||||
|
@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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 权限不足(第 3 天) */
|
||||||
|
@ExceptionHandler(AccessDeniedException.class)
|
||||||
|
public ResponseEntity<ApiResponse<?>> handleAccessDenied(AccessDeniedException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(ApiResponse.error(403, "权限不足: 只有 ADMIN 可以执行此操作"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兜底 */
|
||||||
|
@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,21 @@
|
|||||||
|
package com.learn.repository.jpa;
|
||||||
|
|
||||||
|
import com.learn.entity.Student;
|
||||||
|
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%) AND s.deleted = 0")
|
||||||
|
List<Student> search(@Param("kw") String keyword);
|
||||||
|
|
||||||
|
@Query("SELECT s FROM Student s WHERE s.score >= :min AND s.deleted = 0 ORDER BY s.score DESC")
|
||||||
|
List<Student> excellent(@Param("min") int minScore);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.learn.repository.jpa;
|
||||||
|
|
||||||
|
import com.learn.entity.User;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface UserRepository extends JpaRepository<User, Long> {
|
||||||
|
Optional<User> findByUsername(String username);
|
||||||
|
boolean existsByUsername(String username);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.learn.repository.mp;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.learn.entity.Student;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface StudentMapper extends BaseMapper<Student> {
|
||||||
|
}
|
||||||
79
week5/src/main/java/com/learn/security/JwtAuthFilter.java
Normal file
79
week5/src/main/java/com/learn/security/JwtAuthFilter.java
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package com.learn.security;
|
||||||
|
|
||||||
|
import com.learn.entity.User;
|
||||||
|
import com.learn.repository.jpa.UserRepository;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 认证过滤器(第 2 天)
|
||||||
|
*
|
||||||
|
* 每个请求到达时,从 Authorization 头提取 JWT Token,
|
||||||
|
* 解析出用户名和角色,设置到 Spring Security 上下文中。
|
||||||
|
*
|
||||||
|
* OncePerRequestFilter:保证每个请求只过滤一次。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final JwtUtil jwtUtil;
|
||||||
|
private final UserRepository userRepo;
|
||||||
|
|
||||||
|
public JwtAuthFilter(JwtUtil jwtUtil, UserRepository userRepo) {
|
||||||
|
this.jwtUtil = jwtUtil;
|
||||||
|
this.userRepo = userRepo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain chain) throws ServletException, IOException {
|
||||||
|
// 1. 提取 Token
|
||||||
|
String token = extractToken(request);
|
||||||
|
if (token == null || !jwtUtil.validateToken(token)) {
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 解析用户名
|
||||||
|
String username = jwtUtil.getUsername(token);
|
||||||
|
|
||||||
|
// 3. 查数据库确认用户仍存在且启用
|
||||||
|
Optional<User> userOpt = userRepo.findByUsername(username);
|
||||||
|
if (userOpt.isEmpty() || userOpt.get().getEnabled() == 0) {
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 将认证信息设置到 Spring Security 上下文
|
||||||
|
String role = jwtUtil.getRole(token);
|
||||||
|
List<SimpleGrantedAuthority> authorities = List.of(
|
||||||
|
new SimpleGrantedAuthority("ROLE_" + role)
|
||||||
|
);
|
||||||
|
|
||||||
|
UsernamePasswordAuthenticationToken auth =
|
||||||
|
new UsernamePasswordAuthenticationToken(username, null, authorities);
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||||
|
|
||||||
|
chain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractToken(HttpServletRequest request) {
|
||||||
|
String header = request.getHeader("Authorization");
|
||||||
|
if (header != null && header.startsWith("Bearer ")) {
|
||||||
|
return header.substring(7).trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
week5/src/main/java/com/learn/security/JwtUtil.java
Normal file
81
week5/src/main/java/com/learn/security/JwtUtil.java
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package com.learn.security;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.*;
|
||||||
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 工具类(第 2 天)
|
||||||
|
*
|
||||||
|
* 三个核心方法:
|
||||||
|
* generateToken(username, role) → 生成 Token
|
||||||
|
* getUsername(token) → 从 Token 解析用户名
|
||||||
|
* validateToken(token) → 验证 Token 是否有效
|
||||||
|
*
|
||||||
|
* JWT 结构:header.payload.signature
|
||||||
|
* header: {"alg": "HS256"}
|
||||||
|
* payload: {"sub": "admin", "role": "ADMIN", "exp": 1234567890}
|
||||||
|
* signature: HMAC-SHA256(header + "." + payload, secret)
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class JwtUtil {
|
||||||
|
|
||||||
|
private final SecretKey key;
|
||||||
|
private final long expiration;
|
||||||
|
|
||||||
|
public JwtUtil(@Value("${jwt.secret}") String secret,
|
||||||
|
@Value("${jwt.expiration}") long expiration) {
|
||||||
|
// 确保密钥至少 256 位(32 字节)
|
||||||
|
String paddedSecret = secret;
|
||||||
|
if (paddedSecret.length() < 32) {
|
||||||
|
paddedSecret = String.format("%-32s", secret).replace(' ', '0');
|
||||||
|
}
|
||||||
|
this.key = Keys.hmacShaKeyFor(paddedSecret.getBytes(StandardCharsets.UTF_8));
|
||||||
|
this.expiration = expiration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成 Token */
|
||||||
|
public String generateToken(String username, String role) {
|
||||||
|
Date now = new Date();
|
||||||
|
return Jwts.builder()
|
||||||
|
.subject(username)
|
||||||
|
.claim("role", role)
|
||||||
|
.issuedAt(now)
|
||||||
|
.expiration(new Date(now.getTime() + expiration))
|
||||||
|
.signWith(key)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 Token 中提取用户名 */
|
||||||
|
public String getUsername(String token) {
|
||||||
|
return parse(token).getSubject();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 Token 中提取角色 */
|
||||||
|
public String getRole(String token) {
|
||||||
|
return parse(token).get("role", String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 验证 Token 是否有效 */
|
||||||
|
public boolean validateToken(String token) {
|
||||||
|
try {
|
||||||
|
parse(token);
|
||||||
|
return true;
|
||||||
|
} catch (JwtException | IllegalArgumentException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Claims parse(String token) {
|
||||||
|
return Jwts.parser()
|
||||||
|
.verifyWith(key)
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload();
|
||||||
|
}
|
||||||
|
}
|
||||||
70
week5/src/main/java/com/learn/service/AuthService.java
Normal file
70
week5/src/main/java/com/learn/service/AuthService.java
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package com.learn.service;
|
||||||
|
|
||||||
|
import com.learn.dto.LoginRequest;
|
||||||
|
import com.learn.entity.User;
|
||||||
|
import com.learn.repository.jpa.UserRepository;
|
||||||
|
import com.learn.security.JwtUtil;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证服务(第 2-3 天)
|
||||||
|
*
|
||||||
|
* 注册:创建用户(BCrypt 加密密码)
|
||||||
|
* 登录:验证密码 → 签发 JWT
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class AuthService {
|
||||||
|
|
||||||
|
private final UserRepository userRepo;
|
||||||
|
private final PasswordEncoder encoder;
|
||||||
|
private final JwtUtil jwtUtil;
|
||||||
|
|
||||||
|
public AuthService(UserRepository userRepo, PasswordEncoder encoder, JwtUtil jwtUtil) {
|
||||||
|
this.userRepo = userRepo;
|
||||||
|
this.encoder = encoder;
|
||||||
|
this.jwtUtil = jwtUtil;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册(默认 USER 角色) */
|
||||||
|
public Map<String, Object> register(String username, String password) {
|
||||||
|
if (userRepo.existsByUsername(username)) {
|
||||||
|
throw new RuntimeException("用户名已存在");
|
||||||
|
}
|
||||||
|
User user = new User();
|
||||||
|
user.setUsername(username);
|
||||||
|
user.setPassword(encoder.encode(password)); // BCrypt 加密
|
||||||
|
user.setRole("USER");
|
||||||
|
userRepo.save(user);
|
||||||
|
|
||||||
|
String token = jwtUtil.generateToken(username, "USER");
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("token", token);
|
||||||
|
result.put("role", "USER");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录 */
|
||||||
|
public Map<String, Object> login(LoginRequest req) {
|
||||||
|
User user = userRepo.findByUsername(req.getUsername())
|
||||||
|
.orElseThrow(() -> new RuntimeException("用户名或密码错误"));
|
||||||
|
|
||||||
|
if (!encoder.matches(req.getPassword(), user.getPassword())) {
|
||||||
|
throw new RuntimeException("用户名或密码错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.getEnabled() == 0) {
|
||||||
|
throw new RuntimeException("账号已被禁用");
|
||||||
|
}
|
||||||
|
|
||||||
|
String token = jwtUtil.generateToken(user.getUsername(), user.getRole());
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("token", token);
|
||||||
|
result.put("role", user.getRole());
|
||||||
|
result.put("username", user.getUsername());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@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 s) { return useJpa() ? jpa.add(s) : mp.add(s); }
|
||||||
|
|
||||||
|
public List<Student> list() { return useJpa() ? jpa.list() : mp.list(); }
|
||||||
|
|
||||||
|
public Student getById(Long id) { return useJpa() ? jpa.getById(id) : mp.getById(id); }
|
||||||
|
|
||||||
|
public List<Student> search(String kw) { return useJpa() ? jpa.search(kw) : mp.search(kw); }
|
||||||
|
|
||||||
|
public Optional<Student> update(Long id, Student s) { return useJpa() ? jpa.update(id, s) : mp.update(id, s); }
|
||||||
|
|
||||||
|
public boolean delete(Long id) { return useJpa() ? jpa.delete(id) : mp.delete(id); }
|
||||||
|
|
||||||
|
public long count() { return useJpa() ? jpa.count() : mp.count(); }
|
||||||
|
|
||||||
|
public long countExcellent(int min) { return useJpa() ? jpa.countExcellent(min) : mp.countExcellent(min); }
|
||||||
|
|
||||||
|
public String getActiveMode() { return useJpa() ? "JPA" : "MyBatis-Plus"; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.learn.service.jpa;
|
||||||
|
|
||||||
|
import com.learn.entity.Student;
|
||||||
|
import com.learn.repository.jpa.StudentJpaRepository;
|
||||||
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
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
|
||||||
|
@CacheEvict(value = "students", allEntries = true) // 新增时清空缓存
|
||||||
|
public Student add(Student s) { return repo.save(s); }
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
@Cacheable(value = "students", key = "'list'") // 缓存查询结果
|
||||||
|
public List<Student> list() { return repo.findAll(Sort.by(Sort.Direction.DESC, "score")); }
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
@Cacheable(value = "students", key = "#id")
|
||||||
|
public Student getById(Long id) { return repo.findById(id).orElse(null); }
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Student> search(String kw) { return repo.search(kw); }
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@CacheEvict(value = "students", allEntries = true)
|
||||||
|
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
|
||||||
|
@CacheEvict(value = "students", allEntries = true)
|
||||||
|
public boolean delete(Long id) {
|
||||||
|
if (repo.existsById(id)) { repo.deleteById(id); return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long count() { return repo.count(); }
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long countExcellent(int min) { return repo.excellent(min).size(); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.learn.service.mp;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.learn.entity.Student;
|
||||||
|
import com.learn.repository.mp.StudentMapper;
|
||||||
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
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
|
||||||
|
@CacheEvict(value = "students", allEntries = true)
|
||||||
|
public Student add(Student s) { mapper.insert(s); return s; }
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
@Cacheable(value = "students", key = "'list'")
|
||||||
|
public List<Student> list() {
|
||||||
|
LambdaQueryWrapper<Student> w = new LambdaQueryWrapper<>();
|
||||||
|
w.orderByDesc(Student::getScore);
|
||||||
|
return mapper.selectList(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
@Cacheable(value = "students", key = "#id")
|
||||||
|
public Student getById(Long id) {
|
||||||
|
return mapper.selectById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Student> search(String kw) {
|
||||||
|
LambdaQueryWrapper<Student> w = new LambdaQueryWrapper<>();
|
||||||
|
w.like(Student::getName, kw).or().like(Student::getEmail, kw);
|
||||||
|
w.orderByDesc(Student::getScore);
|
||||||
|
return mapper.selectList(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@CacheEvict(value = "students", allEntries = true)
|
||||||
|
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
|
||||||
|
@CacheEvict(value = "students", allEntries = true)
|
||||||
|
public boolean delete(Long id) {
|
||||||
|
return mapper.deleteById(id) > 0; // 逻辑删除:MP 自动将 deleted 设为 1
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long count() {
|
||||||
|
return mapper.selectCount(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public long countExcellent(int min) {
|
||||||
|
LambdaQueryWrapper<Student> w = new LambdaQueryWrapper<>();
|
||||||
|
w.ge(Student::getScore, min);
|
||||||
|
return mapper.selectCount(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
week5/src/main/resources/application.yml
Normal file
69
week5/src/main/resources/application.yml
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: week5-security
|
||||||
|
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://localhost:3306/week5_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
|
||||||
|
|
||||||
|
# Redis(第 4 天,本地未安装 Redis 时可注释掉)
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
password: 1365957941@Wfj
|
||||||
|
timeout: 3000ms
|
||||||
|
|
||||||
|
cache:
|
||||||
|
type: redis
|
||||||
|
redis:
|
||||||
|
time-to-live: 60000 # 缓存有效期 60 秒
|
||||||
|
cache-null-values: false
|
||||||
|
|
||||||
|
mybatis-plus:
|
||||||
|
configuration:
|
||||||
|
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||||
|
map-underscore-to-camel-case: true
|
||||||
|
global-config:
|
||||||
|
db-config:
|
||||||
|
id-type: auto
|
||||||
|
logic-delete-field: deleted # 逻辑删除字段(第 7 天)
|
||||||
|
logic-delete-value: 1 # 已删除 = 1
|
||||||
|
logic-not-delete-value: 0 # 未删除 = 0
|
||||||
|
|
||||||
|
# JWT 配置
|
||||||
|
jwt:
|
||||||
|
secret: week5-jwt-secret-key-for-student-management-system
|
||||||
|
expiration: 86400000 # 24 小时(毫秒)
|
||||||
|
|
||||||
|
# Actuator(第 5 天)
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info,beans,env,mappings
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
show-details: always
|
||||||
|
|
||||||
|
# ORM 模式
|
||||||
|
orm:
|
||||||
|
mode: jpa
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.learn: DEBUG
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
149
week5/src/main/resources/static/css/style.css
Normal file
149
week5/src/main/resources/static/css/style.css
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/* ========== 基础重置 ========== */
|
||||||
|
*, *::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: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh; padding: 20px; color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1100px; margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 头部 ========== */
|
||||||
|
header {
|
||||||
|
text-align: center; padding: 30px 20px 20px;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 2em; color: #fff; margin-bottom: 6px; }
|
||||||
|
.subtitle { color: rgba(255,255,255,0.75); font-size: 0.95em; }
|
||||||
|
|
||||||
|
.main-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 20px; background: #fff; border-radius: 12px 12px 0 0;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.main-header h1 { font-size: 1.4em; color: #333; margin-bottom: 0; }
|
||||||
|
.main-header .subtitle { color: #999; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ========== 登录/注册卡片 ========== */
|
||||||
|
.login-card {
|
||||||
|
background: #fff; border-radius: 12px; padding: 36px 30px;
|
||||||
|
max-width: 420px; margin: 0 auto;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
.login-card h2 { font-size: 1.3em; margin-bottom: 18px; color: #333; }
|
||||||
|
.hint { color: #999; font-size: 0.85em; margin-bottom: 14px; }
|
||||||
|
.hint.small { font-size: 0.8em; text-align: center; }
|
||||||
|
.hint a { color: #667eea; text-decoration: none; }
|
||||||
|
|
||||||
|
/* ========== 表单 ========== */
|
||||||
|
.form-group { margin-bottom: 14px; }
|
||||||
|
.form-group label { display: block; font-size: 0.85em; color: #666; margin-bottom: 4px; font-weight: 500; }
|
||||||
|
.form-group input, .form-group select {
|
||||||
|
width: 100%; padding: 10px 12px; border: 1.5px solid #e0e0e0;
|
||||||
|
border-radius: 8px; font-size: 0.95em; transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.15);
|
||||||
|
}
|
||||||
|
.form-row { display: flex; gap: 12px; }
|
||||||
|
.form-row .form-group { flex: 1; }
|
||||||
|
|
||||||
|
/* ========== 按钮 ========== */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
padding: 10px 20px; border: none; border-radius: 8px; font-size: 0.93em;
|
||||||
|
cursor: pointer; transition: all 0.2s; text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn-primary { background: #667eea; color: #fff; }
|
||||||
|
.btn-primary:hover { background: #5a6fd6; }
|
||||||
|
.btn-outline { background: #fff; color: #667eea; border: 1.5px solid #667eea; }
|
||||||
|
.btn-outline:hover { background: #f0f2ff; }
|
||||||
|
.btn-danger { background: #e74c3c; color: #fff; padding: 5px 12px; font-size: 0.82em; }
|
||||||
|
.btn-danger:hover { background: #c0392b; }
|
||||||
|
.btn-edit { background: #2ecc71; color: #fff; padding: 5px 12px; font-size: 0.82em; margin-right: 6px; }
|
||||||
|
.btn-edit:hover { background: #27ae60; }
|
||||||
|
.btn-sm { padding: 6px 14px; font-size: 0.82em; }
|
||||||
|
.btn-block { display: block; width: 100%; }
|
||||||
|
|
||||||
|
/* ========== 工具栏 ========== */
|
||||||
|
.toolbar {
|
||||||
|
background: #fff; padding: 14px 20px; display: flex; gap: 10px;
|
||||||
|
border-top: 1px solid #f0f0f0; align-items: center;
|
||||||
|
}
|
||||||
|
.toolbar input {
|
||||||
|
flex: 1; padding: 9px 12px; border: 1.5px solid #e0e0e0; border-radius: 8px;
|
||||||
|
font-size: 0.93em;
|
||||||
|
}
|
||||||
|
.toolbar input:focus { outline: none; border-color: #667eea; }
|
||||||
|
|
||||||
|
/* ========== 表格 ========== */
|
||||||
|
.table-wrap {
|
||||||
|
background: #fff; padding: 0 20px 20px; overflow-x: auto;
|
||||||
|
}
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 0.93em; }
|
||||||
|
thead th {
|
||||||
|
text-align: left; padding: 12px 10px; border-bottom: 2px solid #667eea;
|
||||||
|
color: #667eea; font-weight: 600; white-space: nowrap;
|
||||||
|
}
|
||||||
|
tbody td {
|
||||||
|
padding: 12px 10px; border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
tbody tr:hover { background: #f8f9ff; }
|
||||||
|
.loading, .empty { text-align: center; color: #999; padding: 40px 0 !important; }
|
||||||
|
|
||||||
|
/* ========== 成绩徽章 ========== */
|
||||||
|
.badge {
|
||||||
|
display: inline-block; padding: 3px 10px; border-radius: 12px;
|
||||||
|
font-size: 0.82em; font-weight: 600; color: #fff;
|
||||||
|
}
|
||||||
|
.badge-excellent { background: #2ecc71; }
|
||||||
|
.badge-good { background: #3498db; }
|
||||||
|
.badge-normal { background: #f39c12; }
|
||||||
|
.badge-poor { background: #e74c3c; }
|
||||||
|
|
||||||
|
/* ========== 统计卡片 ========== */
|
||||||
|
.stats {
|
||||||
|
background: #fff; padding: 16px 20px; border-radius: 0 0 12px 12px;
|
||||||
|
display: flex; gap: 14px; border-top: 1px solid #f0f0f0;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
flex: 1; text-align: center; padding: 14px 10px; border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
.stat-value { display: block; font-size: 1.6em; font-weight: 700; color: #fff; }
|
||||||
|
.stat-label { display: block; font-size: 0.78em; color: rgba(255,255,255,0.8); margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ========== 错误消息 ========== */
|
||||||
|
.error-msg { color: #e74c3c; font-size: 0.85em; margin-bottom: 10px; min-height: 1.2em; }
|
||||||
|
.error-bar {
|
||||||
|
background: #fff3cd; color: #856404; padding: 10px 20px; border-left: 4px solid #ffc107;
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 弹窗 ========== */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||||
|
background: rgba(0,0,0,0.45); display: flex; justify-content: center;
|
||||||
|
align-items: center; z-index: 1000; opacity: 0; pointer-events: none;
|
||||||
|
transition: opacity 0.25s;
|
||||||
|
}
|
||||||
|
.modal-overlay.show { opacity: 1; pointer-events: auto; }
|
||||||
|
.modal {
|
||||||
|
background: #fff; border-radius: 12px; padding: 28px 30px; width: 90%; max-width: 480px;
|
||||||
|
box-shadow: 0 16px 48px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.modal h2 { margin-bottom: 18px; font-size: 1.2em; }
|
||||||
|
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
|
||||||
|
|
||||||
|
/* ========== 响应式 ========== */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
body { padding: 10px; }
|
||||||
|
.login-card { padding: 24px 18px; }
|
||||||
|
.form-row { flex-direction: column; gap: 0; }
|
||||||
|
.main-header { flex-direction: column; gap: 10px; }
|
||||||
|
.toolbar { flex-wrap: wrap; }
|
||||||
|
.stats { flex-direction: column; }
|
||||||
|
}
|
||||||
105
week5/src/main/resources/static/index.html
Normal file
105
week5/src/main/resources/static/index.html
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>学生管理系统 v2</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- 登录面板 -->
|
||||||
|
<div class="container" id="login-panel">
|
||||||
|
<header>
|
||||||
|
<h1>📚 学生管理系统 v2</h1>
|
||||||
|
<p class="subtitle">Spring Security + JWT + Redis</p>
|
||||||
|
</header>
|
||||||
|
<div class="login-card">
|
||||||
|
<h2>登录</h2>
|
||||||
|
<p class="hint">默认账号:admin/123456 (管理员) 或 user/123456 (普通用户)</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" id="login-username" placeholder="用户名" autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="password" id="login-password" placeholder="密码">
|
||||||
|
</div>
|
||||||
|
<div class="error-msg" id="login-error"></div>
|
||||||
|
<button class="btn btn-primary btn-block" onclick="doLogin()">登录</button>
|
||||||
|
<p class="hint small" style="margin-top:12px">
|
||||||
|
没有账号?<a href="#" onclick="showRegister()">注册</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 注册面板 -->
|
||||||
|
<div class="container" id="register-panel" style="display:none">
|
||||||
|
<header><h1>📚 注册</h1></header>
|
||||||
|
<div class="login-card">
|
||||||
|
<h2>注册新账号</h2>
|
||||||
|
<div class="form-group"><input type="text" id="reg-username" placeholder="用户名"></div>
|
||||||
|
<div class="form-group"><input type="password" id="reg-password" placeholder="密码"></div>
|
||||||
|
<div class="error-msg" id="reg-error"></div>
|
||||||
|
<button class="btn btn-primary btn-block" onclick="doRegister()">注册</button>
|
||||||
|
<p class="hint small" style="margin-top:12px"><a href="#" onclick="showLogin()">返回登录</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主面板 -->
|
||||||
|
<div class="container" id="main-panel" style="display:none">
|
||||||
|
<header class="main-header">
|
||||||
|
<div>
|
||||||
|
<h1>📚 学生管理系统 v2</h1>
|
||||||
|
<span id="user-info"></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" id="btn-add" 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>ID</th><th>姓名</th><th>年龄</th><th>邮箱</th><th>成绩</th><th>操作</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 class="stat-card"><span class="stat-value" id="stat-orm">-</span><span class="stat-label">ORM 引擎</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"></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>年龄 *</label><input type="number" id="form-age" required min="1" max="150"></div>
|
||||||
|
<div class="form-group"><label>成绩 *</label><input type="number" id="form-score" required min="0" max="100"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>邮箱 *</label><input type="email" id="form-email" required></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>
|
||||||
210
week5/src/main/resources/static/js/app.js
Normal file
210
week5/src/main/resources/static/js/app.js
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
const BASE = '/api';
|
||||||
|
let token = localStorage.getItem('wk5_token') || '';
|
||||||
|
let role = localStorage.getItem('wk5_role') || '';
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (token) { showMain(); loadStudents(); checkRoleUI(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 认证 ====================
|
||||||
|
|
||||||
|
async function doLogin() {
|
||||||
|
const username = document.getElementById('login-username').value.trim();
|
||||||
|
const password = document.getElementById('login-password').value.trim();
|
||||||
|
if (!username || !password) { showLoginError('请输入用户名和密码'); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(BASE + '/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
if (resp.ok && result.code === 200) {
|
||||||
|
token = result.data.token;
|
||||||
|
role = result.data.role;
|
||||||
|
localStorage.setItem('wk5_token', token);
|
||||||
|
localStorage.setItem('wk5_role', role);
|
||||||
|
showMain(); loadStudents(); checkRoleUI();
|
||||||
|
} else {
|
||||||
|
showLoginError(result.message || '登录失败');
|
||||||
|
}
|
||||||
|
} catch (e) { showLoginError('连接失败: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRegister() {
|
||||||
|
const username = document.getElementById('reg-username').value.trim();
|
||||||
|
const password = document.getElementById('reg-password').value.trim();
|
||||||
|
if (!username || !password) { document.getElementById('reg-error').textContent = '请输入用户名和密码'; return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(BASE + '/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
if (resp.ok && result.code === 200) {
|
||||||
|
token = result.data.token;
|
||||||
|
role = result.data.role;
|
||||||
|
localStorage.setItem('wk5_token', token);
|
||||||
|
localStorage.setItem('wk5_role', role);
|
||||||
|
showMain(); loadStudents(); checkRoleUI();
|
||||||
|
} else {
|
||||||
|
document.getElementById('reg-error').textContent = result.message || '注册失败';
|
||||||
|
}
|
||||||
|
} catch (e) { document.getElementById('reg-error').textContent = '连接失败'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoginError(msg) { document.getElementById('login-error').textContent = msg; }
|
||||||
|
|
||||||
|
function showLogin() {
|
||||||
|
document.getElementById('login-panel').style.display = 'block';
|
||||||
|
document.getElementById('register-panel').style.display = 'none';
|
||||||
|
document.getElementById('main-panel').style.display = 'none';
|
||||||
|
}
|
||||||
|
function showRegister() {
|
||||||
|
document.getElementById('login-panel').style.display = 'none';
|
||||||
|
document.getElementById('register-panel').style.display = 'block';
|
||||||
|
}
|
||||||
|
function showMain() {
|
||||||
|
document.getElementById('login-panel').style.display = 'none';
|
||||||
|
document.getElementById('register-panel').style.display = 'none';
|
||||||
|
document.getElementById('main-panel').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('wk5_token'); localStorage.removeItem('wk5_role');
|
||||||
|
token = ''; role = ''; showLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据角色显示/隐藏操作按钮 */
|
||||||
|
function checkRoleUI() {
|
||||||
|
document.getElementById('user-info').textContent = '当前: ' + (role || '-') + ' | ';
|
||||||
|
document.getElementById('btn-add').style.display = (role === 'ADMIN') ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 请求封装 ====================
|
||||||
|
|
||||||
|
async function api(url, options) {
|
||||||
|
options = options || {};
|
||||||
|
options.headers = options.headers || {};
|
||||||
|
options.headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
|
||||||
|
const resp = await fetch(url, options);
|
||||||
|
const result = await resp.json();
|
||||||
|
|
||||||
|
if (resp.status === 403) {
|
||||||
|
showError('权限不足:需要 ADMIN 角色'); return null;
|
||||||
|
}
|
||||||
|
if ((resp.status === 401 || resp.status === 403) && result.code !== 200) {
|
||||||
|
logout(); showError('认证失败,请重新登录'); return null;
|
||||||
|
}
|
||||||
|
if (result.extra && result.extra.orm) {
|
||||||
|
document.getElementById('stat-orm').textContent = result.extra.orm;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
const bar = document.getElementById('error-bar');
|
||||||
|
bar.textContent = '⚠ ' + msg; bar.style.display = 'block';
|
||||||
|
setTimeout(() => bar.style.display = 'none', 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 数据 ====================
|
||||||
|
|
||||||
|
async function loadStudents(keyword) {
|
||||||
|
const tbody = document.getElementById('table-body');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="loading">加载中...</td></tr>';
|
||||||
|
|
||||||
|
let url = BASE + '/students';
|
||||||
|
if (keyword) url += '?keyword=' + encodeURIComponent(keyword);
|
||||||
|
|
||||||
|
const result = await api(url);
|
||||||
|
if (!result) { tbody.innerHTML = '<tr><td colspan="6" class="empty">加载失败</td></tr>'; return; }
|
||||||
|
|
||||||
|
const students = result.data;
|
||||||
|
if (!students || students.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="empty">暂无数据</td></tr>';
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = students.map(s => {
|
||||||
|
let cls = 'badge-poor';
|
||||||
|
if (s.score >= 90) cls = 'badge-excellent';
|
||||||
|
else if (s.score >= 80) cls = 'badge-good';
|
||||||
|
else if (s.score >= 60) cls = 'badge-normal';
|
||||||
|
const actions = role === 'ADMIN'
|
||||||
|
? `<button class="btn-edit" onclick="showForm(${s.id})">编辑</button>
|
||||||
|
<button class="btn-danger" onclick="deleteStudent(${s.id})">删除</button>`
|
||||||
|
: '<span style="color:#ccc">--</span>';
|
||||||
|
return `<tr>
|
||||||
|
<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}">${s.score}</span></td>
|
||||||
|
<td>${actions}</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('stat-total').textContent = result.extra?.total ?? students.length;
|
||||||
|
|
||||||
|
const statResp = await api(BASE + '/students/stats/excellent?min=85');
|
||||||
|
if (statResp) document.getElementById('stat-excellent').textContent = statResp.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() { loadStudents(document.getElementById('keyword').value.trim() || undefined); }
|
||||||
|
|
||||||
|
// ==================== 表单 ====================
|
||||||
|
|
||||||
|
async function showForm(id) {
|
||||||
|
document.getElementById('form-id').value = ''; document.getElementById('student-form').reset();
|
||||||
|
if (id) {
|
||||||
|
document.getElementById('modal-title').textContent = '编辑学生';
|
||||||
|
const result = await api(BASE + '/students/' + id);
|
||||||
|
if (!result) return;
|
||||||
|
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;
|
||||||
|
} else {
|
||||||
|
document.getElementById('modal-title').textContent = '新增学生';
|
||||||
|
}
|
||||||
|
document.getElementById('modal-overlay').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = document.getElementById('form-id').value;
|
||||||
|
const body = {
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (id) {
|
||||||
|
result = await api(BASE + '/students/' + id, {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result = await api(BASE + '/students', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (result) { closeModal(); loadStudents(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteStudent(id) {
|
||||||
|
if (!confirm('确认删除?(MP 模式下为逻辑删除)')) return;
|
||||||
|
const result = await api(BASE + '/students/' + id, { method: 'DELETE' });
|
||||||
|
if (result) loadStudents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() { document.getElementById('modal-overlay').classList.remove('show'); }
|
||||||
|
document.addEventListener('click', e => { if (e.target.id === 'modal-overlay') closeModal(); });
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter' && document.getElementById('login-panel').style.display !== 'none') doLogin();
|
||||||
|
});
|
||||||
|
function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
|
||||||
216
week5/教案.md
Normal file
216
week5/教案.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# Week 5:Spring 全家桶核心组件 —— 安全、缓存、监控与进阶
|
||||||
|
|
||||||
|
**目标**:在 Week 4 的全栈项目基础上,引入 Spring Security + JWT 实现认证授权,集成 Redis 缓存,接入 Actuator 监控,自定义校验注解,掌握 MyBatis-Plus 逻辑删除与分页。
|
||||||
|
|
||||||
|
**前置**:已完成 Week 4 的 Spring Boot + ORM 双轨 + MVC 全栈项目。
|
||||||
|
|
||||||
|
**学习成果**:一个完整的"学生管理系统 v2",具备:
|
||||||
|
- JWT 令牌认证(登录 / 注册)
|
||||||
|
- RBAC 角色控制(ADMIN 可增删改,USER 只读)
|
||||||
|
- Redis 缓存加速查询
|
||||||
|
- Actuator 健康检查与监控
|
||||||
|
- @ValidScore 自定义校验注解
|
||||||
|
- MyBatis-Plus 逻辑删除
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Day 1:Spring Security 架构 & 过滤器链
|
||||||
|
|
||||||
|
**知识点**:
|
||||||
|
- Spring Security 核心概念:Authentication(认证)vs Authorization(授权)
|
||||||
|
- SecurityFilterChain:过滤器链的工作原理
|
||||||
|
- 关键过滤器:UsernamePasswordAuthenticationFilter、SecurityContextHolder
|
||||||
|
- 无状态会话(STATELESS)vs 有状态会话
|
||||||
|
- CSRF 的概念及何时禁用(前后端分离 API)
|
||||||
|
- BCrypt 密码编码器的不可逆特性
|
||||||
|
|
||||||
|
**动手 → 理解**:
|
||||||
|
1. 阅读 `SecurityConfig.java`:逐行理解每条配置的意图
|
||||||
|
2. 用 Postman / 浏览器测试:不登录直接访问 `/api/students`,观察 401 响应
|
||||||
|
3. 去掉 `@Bean` 的 SecurityFilterChain,对比默认行为(表单登录页)
|
||||||
|
4. 尝试把 `sessionCreationPolicy` 改回 `IF_REQUIRED`,观察 JSESSIONID 的出现
|
||||||
|
|
||||||
|
**核心文件**:
|
||||||
|
- `config/SecurityConfig.java` — 安全配置入口
|
||||||
|
- `service/AuthService.java` — 认证逻辑(login / register)
|
||||||
|
|
||||||
|
**思考题**:为什么要用 `requestMatchers(...).permitAll()` 放行 `/api/auth/**`?不放行会怎样?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Day 2:JWT 认证 —— 登录 / 注册
|
||||||
|
|
||||||
|
**知识点**:
|
||||||
|
- JWT(JSON Web Token)结构:Header.Payload.Signature
|
||||||
|
- 对称密钥签名:HMAC-SHA256
|
||||||
|
- JJWT 库的使用:`Jwts.builder()` 生成,`Jwts.parser().verifyWith()` 验证
|
||||||
|
- Claims:subject、issuedAt、expiration、自定义 claim(role)
|
||||||
|
- 前端 Token 存储:localStorage vs sessionStorage vs Cookie 的权衡
|
||||||
|
- OncePerRequestFilter:为什么用它而不是普通 Filter
|
||||||
|
|
||||||
|
**动手 → 理解**:
|
||||||
|
1. 用浏览器 DevTools Application 面板查看 localStorage 中的 `wk5_token`
|
||||||
|
2. 把 token 粘贴到 [jwt.io](https://jwt.io) 在线解析,观察 payload 内容
|
||||||
|
3. 修改 `application.yml` 中 `jwt.expiration` 为 60000(1分钟),等一分钟再刷新页面
|
||||||
|
4. 手动篡改 localStorage 中 token 的某一个字符,观察 401 错误
|
||||||
|
|
||||||
|
**核心文件**:
|
||||||
|
- `security/JwtUtil.java` — Token 生成与解析工具
|
||||||
|
- `security/JwtAuthFilter.java` — 过滤器:从请求头提取并验证 Token
|
||||||
|
- `config/DataInitializer.java` — 启动时创建默认用户
|
||||||
|
|
||||||
|
**思考题**:JWT 的三个部分中,哪个是"不可伪造"的关键?为什么?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Day 3:RBAC 方法级安全 —— 权限控制
|
||||||
|
|
||||||
|
**知识点**:
|
||||||
|
- RBAC(Role-Based Access Control):角色 → 权限 → 资源
|
||||||
|
- `@EnableMethodSecurity` 开启方法级注解
|
||||||
|
- `@PreAuthorize("hasRole('ADMIN')")` 的 SpEL 表达式
|
||||||
|
- SecurityContextHolder:如何在 Controller 中获取当前用户
|
||||||
|
- `Principal` 参数注入
|
||||||
|
- 权限不足时的 AccessDeniedException → 全局异常处理返回 403
|
||||||
|
|
||||||
|
**动手 → 理解**:
|
||||||
|
1. 用 user/123456 登录,尝试新增学生,观察后台日志中的 AccessDeniedException
|
||||||
|
2. 在前端查看:USER 角色的"新增"按钮为何消失(app.js 中的 `checkRoleUI()`)
|
||||||
|
3. 在 `StudentController.java` 的 `deleteStudent` 方法上暂时注释掉 `@PreAuthorize`,用 USER 登录后通过 fetch 发送 DELETE 请求验证 SecurityConfig 的 URL 级别拦截
|
||||||
|
4. 登录 admin 后访问 `/api/students/me`,查看返回的 Principal 信息
|
||||||
|
|
||||||
|
**核心文件**:
|
||||||
|
- `controller/StudentController.java` → `@PreAuthorize` 注解
|
||||||
|
- `exception/GlobalExceptionHandler.java` → AccessDeniedException 处理
|
||||||
|
|
||||||
|
**思考题**:URL 级别拦截(SecurityConfig)和方法级拦截(@PreAuthorize)各有什么适用场景?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Day 4:Redis 缓存集成
|
||||||
|
|
||||||
|
**知识点**:
|
||||||
|
- Spring Cache 抽象:`@EnableCaching`、`@Cacheable`、`@CacheEvict`
|
||||||
|
- Redis 作为缓存后端 vs 默认 ConcurrentHashMap
|
||||||
|
- 缓存 Key 的 SpEL 表达式:`#id`、`'list'`
|
||||||
|
- `@CacheEvict(allEntries = true)` 的使用场景和风险
|
||||||
|
- application.yml 中的 Redis 连接配置
|
||||||
|
- Redis 命令行基本操作:`KEYS *`、`GET key`、`DEL key`
|
||||||
|
|
||||||
|
**动手 → 理解**:
|
||||||
|
1. 启动项目,访问 `/api/students` 两次,观察第二次的响应速度变化
|
||||||
|
2. 用 `redis-cli KEYS *` 查看 Spring Cache 生成的 Key 格式
|
||||||
|
3. 用 `redis-cli GET "students::list"` 查看缓存的序列化数据
|
||||||
|
4. 新增一个学生后,再次 `KEYS *` 观察缓存是否被清空
|
||||||
|
5. 暂时关掉 Redis 服务,观察应用是否还能正常启动(`spring.cache.type: none`)
|
||||||
|
|
||||||
|
**核心文件**:
|
||||||
|
- `service/jpa/StudentJpaService.java` — JPA 服务的缓存注解
|
||||||
|
- `service/mp/StudentMpService.java` — MP 服务的缓存注解
|
||||||
|
- `application.yml` — Redis 与 Cache 配置
|
||||||
|
|
||||||
|
**思考题**:为什么新增/修改/删除时要 `allEntries = true` 清理全部缓存,而不是只清理单条?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Day 5:Actuator 应用监控
|
||||||
|
|
||||||
|
**知识点**:
|
||||||
|
- Spring Boot Actuator 的端点(Endpoints):health、info、beans、env、mappings
|
||||||
|
- `/actuator/health`:应用健康状态(UP / DOWN)
|
||||||
|
- `/actuator/beans`:Spring 容器中所有 Bean 的列表和依赖关系
|
||||||
|
- `/actuator/env`:运行时环境变量和配置属性
|
||||||
|
- `/actuator/mappings`:所有 URL 映射(Controller + Filter)
|
||||||
|
- 生产环境中端点的安全控制
|
||||||
|
|
||||||
|
**动手 → 理解**:
|
||||||
|
1. 浏览器访问 `http://localhost:8080/actuator` 查看所有可用端点
|
||||||
|
2. 访问 `/actuator/health`,观察 JSON 中的 `status: "UP"`
|
||||||
|
3. 访问 `/actuator/beans`,搜索 "jwt" 或 "security" 找到相关 Bean
|
||||||
|
4. 访问 `/actuator/mappings`,找到自己写的 StudentController 的映射信息
|
||||||
|
5. 在 `application.yml` 中把 `include` 改为只保留 `health,info`,重启再访问 `/actuator`
|
||||||
|
|
||||||
|
**核心文件**:
|
||||||
|
- `application.yml` → `management.endpoints.web.exposure.include`
|
||||||
|
- `pom.xml` → `spring-boot-starter-actuator`
|
||||||
|
|
||||||
|
**思考题**:为什么不应该在生产环境暴露 `/actuator/env`?里面会有什么敏感信息?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Day 6:自定义校验注解 —— @ValidScore
|
||||||
|
|
||||||
|
**知识点**:
|
||||||
|
- Bean Validation(JSR 380)的扩展机制
|
||||||
|
- `@Constraint(validatedBy = ...)` 元注解
|
||||||
|
- `ConstraintValidator<A, T>` 接口:`initialize()` + `isValid()`
|
||||||
|
- 自定义注解的三要素:`message`、`groups`、`payload`
|
||||||
|
- `ConstraintValidatorContext` 自定义错误消息
|
||||||
|
- `@ValidScore` + `@NotNull` 的配合使用
|
||||||
|
|
||||||
|
**动手 → 理解**:
|
||||||
|
1. 阅读 `ScoreValidator.java` 的 `isValid` 方法,理解 null 的处理逻辑
|
||||||
|
2. 在前端新增学生时,输入成绩 150 并提交,观察后端返回的校验错误
|
||||||
|
3. 修改 `@ValidScore` 的 `message` 默认值为中文提示,观察返回的 JSON
|
||||||
|
4. 尝试把 `@ValidScore` 应用到 `Student` 实体的 `score` 字段(DTO 校验 vs 实体校验的区别)
|
||||||
|
5. 动手写一个新的自定义注解 `@ValidEmail`,校验邮箱格式
|
||||||
|
|
||||||
|
**核心文件**:
|
||||||
|
- `annotation/ValidScore.java` — 自定义注解定义
|
||||||
|
- `annotation/ScoreValidator.java` — 校验逻辑实现
|
||||||
|
|
||||||
|
**思考题**:`isValid` 中对 null 返回 true,而不做 null 判断。如果调用方忘记加 `@NotNull`,会发生什么?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Day 7:MyBatis-Plus 进阶 —— 逻辑删除 & LambdaQueryWrapper
|
||||||
|
|
||||||
|
**知识点**:
|
||||||
|
- 逻辑删除 vs 物理删除:概念差异与适用场景
|
||||||
|
- `@TableLogic`:标记逻辑删除字段
|
||||||
|
- `mybatis-plus.global-config.db-config.logic-delete-field` 全局配置
|
||||||
|
- 逻辑删除的 SQL 行为:DELETE → UPDATE SET deleted=1;SELECT 自动追加 deleted=0
|
||||||
|
- JPA 如何手动实现逻辑删除(@Query 中添加 `s.deleted = 0`)
|
||||||
|
- LambdaQueryWrapper 条件构造:`.eq()`、`.like()`、`.orderByDesc()`、`.between()`
|
||||||
|
- 分页拦截器 PaginationInnerInterceptor 的 `overflow` 和 `maxLimit` 配置
|
||||||
|
|
||||||
|
**动手 → 理解**:
|
||||||
|
1. 用 XML (MyBatisPlusConfig) 配置逻辑删除 vs 用 `@TableLogic` 注解的效果对比
|
||||||
|
2. 在 MySQL 中执行 `SELECT * FROM student WHERE deleted = 1`,验证逻辑删除后的数据是否存在
|
||||||
|
3. 对比 MP 服务(自动过滤 deleted)和 JPA 服务(手动写 deleted=0 条件)的实现差异
|
||||||
|
4. 尝试修改 `maxLimit` 为 5,观察分页查询被限制的效果
|
||||||
|
5. 在 LambdaQueryWrapper 上尝试组合条件:`like(Student::getName, keyword).between(Student::getAge, 18, 25)`
|
||||||
|
|
||||||
|
**核心文件**:
|
||||||
|
- `config/MyBatisPlusConfig.java` — 分页拦截器 + 逻辑删除全局配置
|
||||||
|
- `service/mp/StudentMpService.java` — MP 服务的 LambdaQueryWrapper 使用
|
||||||
|
- `service/jpa/StudentJpaService.java` — JPA 手动逻辑删除的 @Query 写法
|
||||||
|
- `application.yml` → `mybatis-plus.global-config.db-config`
|
||||||
|
|
||||||
|
**思考题**:逻辑删除和物理删除各适用于什么业务场景?为什么 Week 5 选择了逻辑删除?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Week 5 总结
|
||||||
|
|
||||||
|
| 维度 | Week 4(v1) | Week 5(v2) |
|
||||||
|
|------|-------------|-------------|
|
||||||
|
| 认证 | 固定 Token 字符串 | JWT 动态签发 + 过期控制 |
|
||||||
|
| 授权 | 无角色概念 | RBAC(ADMIN / USER) |
|
||||||
|
| 安全框架 | 手写 Interceptor | Spring Security + FilterChain |
|
||||||
|
| 缓存 | 无 | Redis + Spring Cache 注解 |
|
||||||
|
| 监控 | 无 | Actuator 端点 |
|
||||||
|
| 校验 | @Min/@Max 内置注解 | 自定义 @ValidScore |
|
||||||
|
| 数据库 | MP/JPA 直接操作 | MP 逻辑删除 + JPA 手动兼容 |
|
||||||
|
|
||||||
|
**环境保障**:
|
||||||
|
- MySQL 8.0+,数据库 `gc_plan`,表 `student` + `users`
|
||||||
|
- Redis 需运行在 localhost:6379(默认端口)
|
||||||
|
- `DataInitializer` 在首次启动时自动创建 admin/123456 和 user/123456
|
||||||
|
- 如需重置:删除 users 表中记录,重启应用即可重新初始化
|
||||||
|
|
||||||
|
**附加练习**:
|
||||||
|
1. 给 USER 角色新增一个权限:可以查看但不能修改自己的详细信息
|
||||||
|
2. 用 Redis 缓存 JWT Token,实现"黑名单"注销功能
|
||||||
|
3. 在 Actuator 中增加自定义 HealthIndicator(检查 Redis 连接状态)
|
||||||
|
4. 把 @ValidScore 扩展为支持不同学科的不同分数范围
|
||||||
24
week6/.gitignore
vendored
Normal file
24
week6/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
5
week6/README.md
Normal file
5
week6/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||||
13
week6/index.html
Normal file
13
week6/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Week 6 — Vue 3 学习项目</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1950
week6/package-lock.json
generated
Normal file
1950
week6/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
week6/package.json
Normal file
21
week6/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "week6",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.15.2",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.32",
|
||||||
|
"vue-router": "^5.0.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"vite": "^8.0.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
week6/public/favicon.svg
Normal file
1
week6/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
week6/public/icons.svg
Normal file
24
week6/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
11
week6/src/App.vue
Normal file
11
week6/src/App.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<!-- 根组件:NavBar + 路由视图 -->
|
||||||
|
<template>
|
||||||
|
<NavBar />
|
||||||
|
<main>
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import NavBar from './components/NavBar.vue'
|
||||||
|
</script>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user