commit f95aa1872432e6f86a506d640183b0880c2941a5 Author: akaxedx <1365957941@qq.com> Date: Wed Apr 29 23:45:17 2026 +0800 Week 1-8: Spring Boot 学习计划完整项目 Co-Authored-By: Claude Opus 4.7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd866ba --- /dev/null +++ b/.gitignore @@ -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 diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..144bca7 --- /dev/null +++ b/plan.md @@ -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 是学习的最好机会。 diff --git a/week1/addressbook/AddressBookApp.java b/week1/addressbook/AddressBookApp.java new file mode 100644 index 0000000..1f1040e --- /dev/null +++ b/week1/addressbook/AddressBookApp.java @@ -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 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 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); + } +} diff --git a/week1/day01/HelloWorld.java b/week1/day01/HelloWorld.java new file mode 100644 index 0000000..083dcca --- /dev/null +++ b/week1/day01/HelloWorld.java @@ -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("他说:\"你好\""); // \" 双引号 + } +} diff --git a/week1/day02/Calculator.java b/week1/day02/Calculator.java new file mode 100644 index 0000000..45ee699 --- /dev/null +++ b/week1/day02/Calculator.java @@ -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(); + } +} diff --git a/week1/day03/GuessNumber.java b/week1/day03/GuessNumber.java new file mode 100644 index 0000000..f2fecc5 --- /dev/null +++ b/week1/day03/GuessNumber.java @@ -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(); + } +} diff --git a/week1/day03/MultiplicationTable.java b/week1/day03/MultiplicationTable.java new file mode 100644 index 0000000..9ba6f6a --- /dev/null +++ b/week1/day03/MultiplicationTable.java @@ -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(); + } + } +} diff --git a/week1/day04/StudentScoreManager.java b/week1/day04/StudentScoreManager.java new file mode 100644 index 0000000..8c40230 --- /dev/null +++ b/week1/day04/StudentScoreManager.java @@ -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 动态存储成绩 + static ArrayList 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); + } +} diff --git a/week1/day05/Student.java b/week1/day05/Student.java new file mode 100644 index 0000000..cafaf9e --- /dev/null +++ b/week1/day05/Student.java @@ -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(""); // 触发校验 + } +} diff --git a/week1/day06/AnimalHierarchy.java b/week1/day06/AnimalHierarchy.java new file mode 100644 index 0000000..a8eae68 --- /dev/null +++ b/week1/day06/AnimalHierarchy.java @@ -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(); + } + } +} diff --git a/week1/教案.md b/week1/教案.md new file mode 100644 index 0000000..51a9499 --- /dev/null +++ b/week1/教案.md @@ -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 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. 泛型 ``:编译期类型检查 + +**综合项目**:`addressbook/` —— 命令行通讯录 +- 封装 Contact 类 +- 使用 HashMap 存储联系人(以姓名为 key) +- 菜单驱动的 CRUD 操作 +- 异常处理防止崩溃 + +--- + +## 教学方法建议 + +| 环节 | 时间 | 内容 | +|------|------|------| +| 概念导入 | 15min | 用生活类比引入概念(类=图纸,对象=房子) | +| 代码演示 | 30min | 老师在 IDE 中边写边讲,每个语句解释为什么 | +| 跟练 | 30min | 学员照着敲一遍,感受语法 | +| 独立练习 | 1h | 不看参考代码,独立完成当日练习项目 | +| 复盘 | 15min | 对比参考代码,找出差异和理解盲区 | + +## 注意事项 + +1. **不要跳步**:每天的内容建立在前一天的基础上,确保当天掌握再前进 +2. **多犯错**:看到报错不要慌,仔细读错误信息是最快的进步方式 +3. **先思考后动手**:在写代码前,先用中文把思路写下来 +4. **代码量 > 看书量**:每天至少手敲 100 行代码,肌肉记忆很重要 diff --git a/week2/pom.xml b/week2/pom.xml new file mode 100644 index 0000000..d9796dc --- /dev/null +++ b/week2/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.learn + week2-spring-boot + 1.0.0 + Week 2: Spring Boot 入门 + 从 IoC 手写到 RESTful API 的 Spring Boot 入门项目 + + + 17 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/week2/src/main/java/com/learn/Week2Application.java b/week2/src/main/java/com/learn/Week2Application.java new file mode 100644 index 0000000..ea62a24 --- /dev/null +++ b/week2/src/main/java/com/learn/Week2Application.java @@ -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); + } +} diff --git a/week2/src/main/java/com/learn/config/AppConfig.java b/week2/src/main/java/com/learn/config/AppConfig.java new file mode 100644 index 0000000..299b9a5 --- /dev/null +++ b/week2/src/main/java/com/learn/config/AppConfig.java @@ -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()); + }; + } +} diff --git a/week2/src/main/java/com/learn/controller/HelloController.java b/week2/src/main/java/com/learn/controller/HelloController.java new file mode 100644 index 0000000..56ced62 --- /dev/null +++ b/week2/src/main/java/com/learn/controller/HelloController.java @@ -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) {} +} diff --git a/week2/src/main/java/com/learn/controller/StudentController.java b/week2/src/main/java/com/learn/controller/StudentController.java new file mode 100644 index 0000000..93a0589 --- /dev/null +++ b/week2/src/main/java/com/learn/controller/StudentController.java @@ -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> list( + @RequestParam(required = false) String keyword) { + + List students; + if (keyword != null && !keyword.trim().isEmpty()) { + students = service.search(keyword); + } else { + students = service.list(); + } + + Map 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> getById(@PathVariable Long id) { + return service.getById(id) + .map(student -> { + Map result = new HashMap<>(); + result.put("code", 200); + result.put("data", student); + return ResponseEntity.ok(result); + }) + .orElseGet(() -> { + Map 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> add(@Valid @RequestBody Student student) { + Student saved = service.add(student); + + Map 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> update( + @PathVariable Long id, + @Valid @RequestBody Student student) { + + return service.update(id, student) + .map(updated -> { + Map result = new HashMap<>(); + result.put("code", 200); + result.put("message", "更新成功"); + result.put("data", updated); + return ResponseEntity.ok(result); + }) + .orElseGet(() -> { + Map 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> delete(@PathVariable Long id) { + if (service.delete(id)) { + Map result = new HashMap<>(); + result.put("code", 200); + result.put("message", "删除成功"); + return ResponseEntity.ok(result); + } else { + Map error = new HashMap<>(); + error.put("code", 404); + error.put("message", "学生不存在,ID: " + id); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); + } + } + + /** DELETE /api/students —— 清空全部(调试用) */ + // 注:这里简化处理,实际项目中应放在测试或管理接口中 +} diff --git a/week2/src/main/java/com/learn/exception/GlobalExceptionHandler.java b/week2/src/main/java/com/learn/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..d1e4c41 --- /dev/null +++ b/week2/src/main/java/com/learn/exception/GlobalExceptionHandler.java @@ -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> handleValidation(MethodArgumentNotValidException ex) { + // 收集所有字段的校验错误 + String errors = ex.getBindingResult().getFieldErrors().stream() + .map(e -> e.getField() + ": " + e.getDefaultMessage()) + .collect(Collectors.joining("; ")); + + Map body = new HashMap<>(); + body.put("code", 400); + body.put("message", "参数校验失败: " + errors); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + + /** + * 兜底:处理所有未捕获的异常 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAll(Exception ex) { + Map body = new HashMap<>(); + body.put("code", 500); + body.put("message", "服务器内部错误: " + ex.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body); + } +} diff --git a/week2/src/main/java/com/learn/ioc/SimpleIocDemo.java b/week2/src/main/java/com/learn/ioc/SimpleIocDemo.java new file mode 100644 index 0000000..9111eb0 --- /dev/null +++ b/week2/src/main/java/com/learn/ioc/SimpleIocDemo.java @@ -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 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 的核心理念——控制反转!"); + } +} diff --git a/week2/src/main/java/com/learn/model/Student.java b/week2/src/main/java/com/learn/model/Student.java new file mode 100644 index 0000000..7be7611 --- /dev/null +++ b/week2/src/main/java/com/learn/model/Student.java @@ -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); + } +} diff --git a/week2/src/main/java/com/learn/repository/StudentRepository.java b/week2/src/main/java/com/learn/repository/StudentRepository.java new file mode 100644 index 0000000..62df8e9 --- /dev/null +++ b/week2/src/main/java/com/learn/repository/StudentRepository.java @@ -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 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 findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + /** 查找全部 */ + public List findAll() { + return new ArrayList<>(store.values()); + } + + /** 根据姓名模糊搜索 */ + public List 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); + } +} diff --git a/week2/src/main/java/com/learn/service/StudentService.java b/week2/src/main/java/com/learn/service/StudentService.java new file mode 100644 index 0000000..e200375 --- /dev/null +++ b/week2/src/main/java/com/learn/service/StudentService.java @@ -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 list() { + return repository.findAll(); + } + + /** 根据 ID 查询 */ + public Optional getById(Long id) { + return repository.findById(id); + } + + /** 模糊搜索 */ + public List search(String keyword) { + if (keyword == null || keyword.trim().isEmpty()) { + return List.of(); + } + return repository.findByName(keyword); + } + + /** 更新学生 */ + public Optional 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(); + } +} diff --git a/week2/src/main/resources/application-dev.yml b/week2/src/main/resources/application-dev.yml new file mode 100644 index 0000000..566c161 --- /dev/null +++ b/week2/src/main/resources/application-dev.yml @@ -0,0 +1,15 @@ +# ========================================== +# 开发环境配置 +# ========================================== +server: + port: 8080 + +logging: + level: + com.learn: DEBUG # 开发时打印详细日志 + +# 自定义配置(演示 @Value 注解的使用) +app: + name: Week2 学生管理系统 + version: 1.0.0-DEV + description: 开发环境,启用 DEBUG 日志和热重载 diff --git a/week2/src/main/resources/application-prod.yml b/week2/src/main/resources/application-prod.yml new file mode 100644 index 0000000..1f97132 --- /dev/null +++ b/week2/src/main/resources/application-prod.yml @@ -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 级别日志 diff --git a/week2/src/main/resources/application.yml b/week2/src/main/resources/application.yml new file mode 100644 index 0000000..7dd764f --- /dev/null +++ b/week2/src/main/resources/application.yml @@ -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 diff --git a/week2/src/test/java/com/learn/StudentControllerTest.java b/week2/src/test/java/com/learn/StudentControllerTest.java new file mode 100644 index 0000000..8ef7722 --- /dev/null +++ b/week2/src/test/java/com/learn/StudentControllerTest.java @@ -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 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 request = new HttpEntity<>(s, headers); + + ResponseEntity response = restTemplate.postForEntity(baseUrl(), request, Map.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } +} diff --git a/week2/教案.md b/week2/教案.md new file mode 100644 index 0000000..bdc9b56 --- /dev/null +++ b/week2/教案.md @@ -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`,理解每个 `` 的作用 +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 集成都是在这个地基上添砖加瓦。 diff --git a/week3/pom.xml b/week3/pom.xml new file mode 100644 index 0000000..f860cda --- /dev/null +++ b/week3/pom.xml @@ -0,0 +1,73 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.learn + week3-orm + 1.0.0 + Week 3: ORM 双轨 —— JPA + MyBatis-Plus + + + 17 + 3.5.6 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + + + com.mysql + mysql-connector-j + runtime + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/week3/sql/init.sql b/week3/sql/init.sql new file mode 100644 index 0000000..ce09a37 --- /dev/null +++ b/week3/sql/init.sql @@ -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; diff --git a/week3/src/main/java/com/learn/Week3Application.java b/week3/src/main/java/com/learn/Week3Application.java new file mode 100644 index 0000000..566aeb0 --- /dev/null +++ b/week3/src/main/java/com/learn/Week3Application.java @@ -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); + } +} diff --git a/week3/src/main/java/com/learn/config/MyBatisPlusConfig.java b/week3/src/main/java/com/learn/config/MyBatisPlusConfig.java new file mode 100644 index 0000000..f978c53 --- /dev/null +++ b/week3/src/main/java/com/learn/config/MyBatisPlusConfig.java @@ -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; + } +} diff --git a/week3/src/main/java/com/learn/controller/jpa/StudentJpaController.java b/week3/src/main/java/com/learn/controller/jpa/StudentJpaController.java new file mode 100644 index 0000000..22109eb --- /dev/null +++ b/week3/src/main/java/com/learn/controller/jpa/StudentJpaController.java @@ -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> list( + @RequestParam(required = false) String keyword) { + + java.util.List students; + if (keyword != null && !keyword.trim().isEmpty()) { + students = service.searchByKeyword(keyword); + } else { + students = service.list(); + } + + return ok(students); + } + + /** 分页查询(第 5 天) */ + @GetMapping("/page") + public ResponseEntity> page( + @RequestParam(defaultValue = "1") int pageNum, + @RequestParam(defaultValue = "5") int pageSize) { + + Page page = service.page(pageNum, pageSize); + + Map 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> getById(@PathVariable Long id) { + return service.getById(id) + .map(s -> ok(s)) + .orElse(notFound(id)); + } + + /** 新增 */ + @PostMapping + public ResponseEntity> add(@Valid @RequestBody Student student) { + Student saved = service.add(student); + Map 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> update( + @PathVariable Long id, @Valid @RequestBody Student student) { + return service.update(id, student) + .map(updated -> { + Map 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> delete(@PathVariable Long id) { + if (service.delete(id)) { + return ResponseEntity.ok(Map.of("code", 200, "message", "删除成功")); + } + return notFound(id); + } + + /** 分数统计 */ + @GetMapping("/stats") + public ResponseEntity> stats( + @RequestParam(defaultValue = "80") int min, + @RequestParam(defaultValue = "100") int max) { + long count = service.countByScoreRange(min, max); + Map result = new HashMap<>(); + result.put("code", 200); + result.put("range", min + "-" + max); + result.put("count", count); + return ResponseEntity.ok(result); + } + + // ==================== 工具方法 ==================== + + private ResponseEntity> ok(Object data) { + Map result = new HashMap<>(); + result.put("code", 200); + result.put("data", data); + return ResponseEntity.ok(result); + } + + private ResponseEntity> notFound(Long id) { + Map error = new HashMap<>(); + error.put("code", 404); + error.put("message", "学生不存在,ID: " + id); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); + } +} diff --git a/week3/src/main/java/com/learn/controller/mp/StudentMpController.java b/week3/src/main/java/com/learn/controller/mp/StudentMpController.java new file mode 100644 index 0000000..fa30ea6 --- /dev/null +++ b/week3/src/main/java/com/learn/controller/mp/StudentMpController.java @@ -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> list( + @RequestParam(required = false) String keyword) { + + java.util.List students; + if (keyword != null && !keyword.trim().isEmpty()) { + students = service.searchByKeyword(keyword); + } else { + students = service.list(); + } + + return ok(students); + } + + /** 分页查询(第 5 天) */ + @GetMapping("/page") + public ResponseEntity> page( + @RequestParam(defaultValue = "1") int pageNum, + @RequestParam(defaultValue = "5") int pageSize) { + + IPage page = service.page(pageNum, pageSize); + + Map 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> getById(@PathVariable Long id) { + return service.getById(id) + .map(s -> ok(s)) + .orElse(notFound(id)); + } + + /** 新增 */ + @PostMapping + public ResponseEntity> add(@Valid @RequestBody Student student) { + Student saved = service.add(student); + Map 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> update( + @PathVariable Long id, @Valid @RequestBody Student student) { + return service.update(id, student) + .map(updated -> { + Map 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> 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> stats( + @RequestParam(defaultValue = "80") int min, + @RequestParam(defaultValue = "100") int max) { + long count = service.countByScoreRange(min, max); + Map 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> excellent( + @RequestParam(defaultValue = "85") int threshold) { + java.util.List students = service.findExcellentStudents(threshold); + return ok(students); + } + + // ==================== 工具方法 ==================== + + private ResponseEntity> ok(Object data) { + Map result = new HashMap<>(); + result.put("code", 200); + result.put("data", data); + return ResponseEntity.ok(result); + } + + private ResponseEntity> notFound(Long id) { + Map error = new HashMap<>(); + error.put("code", 404); + error.put("message", "学生不存在,ID: " + id); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); + } +} diff --git a/week3/src/main/java/com/learn/entity/Student.java b/week3/src/main/java/com/learn/entity/Student.java new file mode 100644 index 0000000..dd4dffb --- /dev/null +++ b/week3/src/main/java/com/learn/entity/Student.java @@ -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); + } +} diff --git a/week3/src/main/java/com/learn/exception/GlobalExceptionHandler.java b/week3/src/main/java/com/learn/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..4615876 --- /dev/null +++ b/week3/src/main/java/com/learn/exception/GlobalExceptionHandler.java @@ -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> handleValidation(MethodArgumentNotValidException ex) { + String errors = ex.getBindingResult().getFieldErrors().stream() + .map(e -> e.getField() + ": " + e.getDefaultMessage()) + .collect(Collectors.joining("; ")); + + Map body = new HashMap<>(); + body.put("code", 400); + body.put("message", "参数校验失败: " + errors); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAll(Exception ex) { + Map body = new HashMap<>(); + body.put("code", 500); + body.put("message", "服务器内部错误: " + ex.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body); + } +} diff --git a/week3/src/main/java/com/learn/repository/jpa/StudentJpaRepository.java b/week3/src/main/java/com/learn/repository/jpa/StudentJpaRepository.java new file mode 100644 index 0000000..6fffa29 --- /dev/null +++ b/week3/src/main/java/com/learn/repository/jpa/StudentJpaRepository.java @@ -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 { + + // ---- 方法命名查询(JPA 自动生成 SQL)---- + + /** 根据姓名精确查找 */ + List findByName(String name); + + /** 根据姓名模糊查找(需要自己拼接 %)*/ + List findByNameContaining(String keyword); + + /** 成绩大于指定值 */ + List findByScoreGreaterThan(int score); + + /** 成绩降序排列 */ + List findAllByOrderByScoreDesc(); + + // ---- JPQL 自定义查询 ---- + + /** 模糊搜索:姓名或邮箱包含关键词 */ + @Query("SELECT s FROM Student s WHERE s.name LIKE %:keyword% OR s.email LIKE %:keyword%") + List 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); +} diff --git a/week3/src/main/java/com/learn/repository/mp/StudentMapper.java b/week3/src/main/java/com/learn/repository/mp/StudentMapper.java new file mode 100644 index 0000000..3f4187c --- /dev/null +++ b/week3/src/main/java/com/learn/repository/mp/StudentMapper.java @@ -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 { + + /** + * 模糊搜索:姓名或邮箱包含关键词 + * + * 这是 MP 的原生 SQL 方式,类似于 MyBatis 的传统用法。 + * 参数较多时可以用 @Param 指定名称。 + */ + @Select("SELECT * FROM student WHERE name LIKE CONCAT('%', #{keyword}, '%') OR email LIKE CONCAT('%', #{keyword}, '%')") + List searchByKeyword(@Param("keyword") String keyword); + + /** + * 成绩大于指定值,按成绩降序 + */ + @Select("SELECT * FROM student WHERE score > #{minScore} ORDER BY score DESC") + List 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 searchByKeywordPage(Page page, @Param("keyword") String keyword); +} diff --git a/week3/src/main/java/com/learn/service/jpa/StudentJpaService.java b/week3/src/main/java/com/learn/service/jpa/StudentJpaService.java new file mode 100644 index 0000000..05965f3 --- /dev/null +++ b/week3/src/main/java/com/learn/service/jpa/StudentJpaService.java @@ -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 list() { + return repository.findAll(Sort.by(Sort.Direction.DESC, "score")); + } + + @Transactional(readOnly = true) + public Optional getById(Long id) { + return repository.findById(id); + } + + @Transactional + public Optional 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 searchByKeyword(String keyword) { + return repository.searchByKeyword(keyword); + } + + @Transactional(readOnly = true) + public List findByName(String name) { + return repository.findByNameContaining(name); + } + + // ==================== 分页(第 5 天) ==================== + + @Transactional(readOnly = true) + public Page 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); + } +} diff --git a/week3/src/main/java/com/learn/service/mp/StudentMpService.java b/week3/src/main/java/com/learn/service/mp/StudentMpService.java new file mode 100644 index 0000000..7451fbc --- /dev/null +++ b/week3/src/main/java/com/learn/service/mp/StudentMpService.java @@ -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 list() { + // MP 条件构造器:空的 wrapper → 查全部 + // orderByDesc 让结果按成绩降序 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.orderByDesc(Student::getScore); + return mapper.selectList(wrapper); + } + + @Transactional(readOnly = true) + public Optional getById(Long id) { + return Optional.ofNullable(mapper.selectById(id)); + } + + @Transactional + public Optional 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 searchByKeyword(String keyword) { + return mapper.searchByKeyword(keyword); + } + + @Transactional(readOnly = true) + public List findByName(String name) { + // MP 的 Lambda 方式构建条件 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.like(Student::getName, name); // WHERE name LIKE '%name%' + return mapper.selectList(wrapper); + } + + // ==================== 分页(第 5 天) ==================== + + @Transactional(readOnly = true) + public IPage page(int pageNum, int pageSize) { + // MP 分页:Page(页码, 每页条数) + Page page = new Page<>(pageNum, pageSize); + + // 按成绩降序 + LambdaQueryWrapper 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 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 findExcellentStudents(int threshold) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.ge(Student::getScore, threshold) // WHERE score >= ? + .orderByDesc(Student::getScore); + return mapper.selectList(wrapper); + } +} diff --git a/week3/src/main/resources/application.yml b/week3/src/main/resources/application.yml new file mode 100644 index 0000000..1e09aef --- /dev/null +++ b/week3/src/main/resources/application.yml @@ -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 diff --git a/week3/src/main/resources/static/css/style.css b/week3/src/main/resources/static/css/style.css new file mode 100644 index 0000000..2a0de45 --- /dev/null +++ b/week3/src/main/resources/static/css/style.css @@ -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; +} diff --git a/week3/src/main/resources/static/index.html b/week3/src/main/resources/static/index.html new file mode 100644 index 0000000..0e64003 --- /dev/null +++ b/week3/src/main/resources/static/index.html @@ -0,0 +1,94 @@ + + + + + + 学生管理系统 —— Week 3 产出 + + + + +
+ +
+

📚 学生管理系统

+

Spring Boot + JPA & MyBatis-Plus 双引擎驱动

+
+ + +
+ + +
+ + +
+ + + +
+ + + + + + + + + + + + + + + + + +
ID姓名年龄邮箱成绩创建时间操作
加载中...
+ + + + + +
+
+ - + 学生总数 +
+
+ - + 优秀人数 (≥85) +
+
+
+ + + + + + + diff --git a/week3/src/main/resources/static/js/app.js b/week3/src/main/resources/static/js/app.js new file mode 100644 index 0000000..e24624e --- /dev/null +++ b/week3/src/main/resources/static/js/app.js @@ -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 = '加载中...'; + + 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 = '暂无数据'; + } else { + tbody.innerHTML = students.map(s => renderRow(s)).join(''); + } + + // 分页信息 + if (result.total !== undefined) { + renderPagination(result.total, result.pages, result.current); + } + } else { + tbody.innerHTML = '加载失败'; + } + } catch (error) { + tbody.innerHTML = '请求出错: ' + error.message + ''; + } +} + +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 ` + + ${student.id} + ${escapeHtml(student.name)} + ${student.age} + ${escapeHtml(student.email)} + ${score} + ${time} + + + + + `; +} + +function renderPagination(total, pages, current) { + const container = document.getElementById('pagination'); + if (!pages || pages <= 1) { + container.innerHTML = ''; + return; + } + + let html = ''; + html += ``; + html += `第 ${current} / ${pages} 页(共 ${total} 条)`; + html += ``; + 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; +} diff --git a/week3/教案.md b/week3/教案.md new file mode 100644 index 0000000..7ca9948 --- /dev/null +++ b/week3/教案.md @@ -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 + ↓ 自动生成 +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 { + + List findByName(String name); // ← 方法名即查询 + List findByNameContaining(String keyword); // ← 自动模糊搜索 + List findByScoreGreaterThan(int score); // ← 自动 > 条件 + + @Query("SELECT s FROM Student s WHERE s.name LIKE %:kw%") // ← 自定义 JPQL + List 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 + ↓ LambdaQueryWrapper 构建条件 / @Select 写 SQL +SQL → 执行 → 结果映射为 Student 对象 +``` + +打开以下文件,对照阅读: + +#### 1. Mapper:`repository/mp/StudentMapper.java` + +```java +@Mapper +public interface StudentMapper extends BaseMapper { + + // 方式一:@Select 直接写 SQL(最灵活) + @Select("SELECT * FROM student WHERE name LIKE CONCAT('%', #{kw}, '%')") + List searchByKeyword(@Param("kw") String kw); + + // 方式二:自定义分页 + SQL + @Select("SELECT * FROM student WHERE name LIKE CONCAT('%', #{kw}, '%')") + IPage searchByKeywordPage(Page page, @Param("kw") String kw); +} +``` + +#### 2. 服务:`service/mp/StudentMpService.java` + +**重点:LambdaQueryWrapper** —— MP 的杀手级功能 + +```java +// 查全部、按成绩降序 +LambdaQueryWrapper 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 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` + `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 page = repository.findAll(pr); +// page.getContent() → 当前页数据 +// page.getTotalElements() → 总记录数 +// page.getTotalPages() → 总页数 +``` + +**MP 分页**: +```java +Page page = new Page<>(1, 5); // 页码从 1 开始 +IPage 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** | 网页的骨架 —— 定义"有什么"(标题、表格、按钮) | +| **标签** | `` 开始,`` 结束,如 `

标题

` | +| **属性** | 标签的附加信息,如 `` | +| **表单** | `
` + ``,用于收集用户输入 | + +### 阅读 index.html + +打开 `src/main/resources/static/index.html`,对照理解: + +``` + → 声明文档类型 + → 根元素 + → 元数据(标题、样式引用) + → 页面可见内容 +
→ 头部区域 + → 表格: 表头 + 数据行 + → 表单: 各种输入框 +``` + +### 如何在浏览器中访问 + +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 工具箱齐全的开发者。 diff --git a/week4/pom.xml b/week4/pom.xml new file mode 100644 index 0000000..0d63ba8 --- /dev/null +++ b/week4/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.learn + week4-student-system + 1.0.0 + Week 4: 学生管理系统 v1 + 全栈学生管理系统 —— Spring Boot + JPA/MP 双轨 + 原生 JS 前端 + 文件上传 + + + 17 + 3.5.6 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/week4/sql/init.sql b/week4/sql/init.sql new file mode 100644 index 0000000..94d9da3 --- /dev/null +++ b/week4/sql/init.sql @@ -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; diff --git a/week4/src/main/java/com/learn/Week4Application.java b/week4/src/main/java/com/learn/Week4Application.java new file mode 100644 index 0000000..52f5da6 --- /dev/null +++ b/week4/src/main/java/com/learn/Week4Application.java @@ -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); + } +} diff --git a/week4/src/main/java/com/learn/config/MyBatisPlusConfig.java b/week4/src/main/java/com/learn/config/MyBatisPlusConfig.java new file mode 100644 index 0000000..d9af693 --- /dev/null +++ b/week4/src/main/java/com/learn/config/MyBatisPlusConfig.java @@ -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; + } +} diff --git a/week4/src/main/java/com/learn/config/WebConfig.java b/week4/src/main/java/com/learn/config/WebConfig.java new file mode 100644 index 0000000..2f52f73 --- /dev/null +++ b/week4/src/main/java/com/learn/config/WebConfig.java @@ -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 + "/"); + } +} diff --git a/week4/src/main/java/com/learn/controller/AuthInterceptor.java b/week4/src/main/java/com/learn/controller/AuthInterceptor.java new file mode 100644 index 0000000..bfb5f97 --- /dev/null +++ b/week4/src/main/java/com/learn/controller/AuthInterceptor.java @@ -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 + * 白名单:/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; // 放行 + } +} diff --git a/week4/src/main/java/com/learn/controller/StudentController.java b/week4/src/main/java/com/learn/controller/StudentController.java new file mode 100644 index 0000000..da204bc --- /dev/null +++ b/week4/src/main/java/com/learn/controller/StudentController.java @@ -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 hello() { + return ApiResponse.success("学生管理系统 v1 运行中 | 当前 ORM: " + service.getActiveMode()); + } + + @PostMapping("/login") + public ApiResponse> login( + @org.springframework.beans.factory.annotation.Value("${auth.token}") String token) { + return ApiResponse.success(Map.of("token", token)); + } + + // ---- 查询 ---- + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String keyword) { + List 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> 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 statsExcellent(@RequestParam(defaultValue = "85") int min) { + return ApiResponse.success(service.countExcellent(min)); + } + + // ---- 新增 ---- + + @PostMapping + public ResponseEntity> add(@Valid @RequestBody Student student) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.created(service.add(student))); + } + + // ---- 更新 ---- + + @PutMapping("/{id}") + public ResponseEntity> 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> 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> 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())); + } + } +} diff --git a/week4/src/main/java/com/learn/dto/ApiResponse.java b/week4/src/main/java/com/learn/dto/ApiResponse.java new file mode 100644 index 0000000..abc0ff2 --- /dev/null +++ b/week4/src/main/java/com/learn/dto/ApiResponse.java @@ -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 { + + private int code; + private String message; + private T data; + private Map extra; + + private ApiResponse() {} + + // ---- 工厂方法 ---- + + public static ApiResponse success(T data) { + ApiResponse r = new ApiResponse<>(); + r.code = 200; + r.message = "success"; + r.data = data; + return r; + } + + public static ApiResponse success(String message, T data) { + ApiResponse r = new ApiResponse<>(); + r.code = 200; + r.message = message; + r.data = data; + return r; + } + + public static ApiResponse created(T data) { + ApiResponse r = new ApiResponse<>(); + r.code = 201; + r.message = "创建成功"; + r.data = data; + return r; + } + + public static ApiResponse error(int code, String message) { + ApiResponse r = new ApiResponse<>(); + r.code = code; + r.message = message; + r.data = null; + return r; + } + + public static ApiResponse notFound(String message) { + return error(404, message); + } + + public static ApiResponse badRequest(String message) { + return error(400, message); + } + + /** 附加额外字段(如分页信息) */ + public ApiResponse 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 getExtra() { return extra; } +} diff --git a/week4/src/main/java/com/learn/entity/Student.java b/week4/src/main/java/com/learn/entity/Student.java new file mode 100644 index 0000000..5700b8a --- /dev/null +++ b/week4/src/main/java/com/learn/entity/Student.java @@ -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; } +} diff --git a/week4/src/main/java/com/learn/exception/GlobalExceptionHandler.java b/week4/src/main/java/com/learn/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..2d1694a --- /dev/null +++ b/week4/src/main/java/com/learn/exception/GlobalExceptionHandler.java @@ -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> 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> handleFileSize(MaxUploadSizeExceededException ex) { + return ResponseEntity.badRequest() + .body(ApiResponse.badRequest("文件大小超过限制(最大 2MB)")); + } + + /** 找不到资源 */ + @ExceptionHandler(jakarta.persistence.EntityNotFoundException.class) + public ResponseEntity> handleNotFound(jakarta.persistence.EntityNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.notFound(ex.getMessage())); + } + + /** 兜底 */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAll(Exception ex) { + log.error("未处理异常", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error(500, "服务器内部错误: " + ex.getMessage())); + } +} diff --git a/week4/src/main/java/com/learn/repository/jpa/StudentJpaRepository.java b/week4/src/main/java/com/learn/repository/jpa/StudentJpaRepository.java new file mode 100644 index 0000000..b7bcba2 --- /dev/null +++ b/week4/src/main/java/com/learn/repository/jpa/StudentJpaRepository.java @@ -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 { + + List findByNameContaining(String keyword); + + @Query("SELECT s FROM Student s WHERE s.name LIKE %:kw% OR s.email LIKE %:kw%") + List search(@Param("kw") String keyword); + + @Query("SELECT s FROM Student s WHERE s.score >= :min ORDER BY s.score DESC") + List excellent(@Param("min") int minScore); + + Page findAll(Pageable pageable); +} diff --git a/week4/src/main/java/com/learn/repository/mp/StudentMapper.java b/week4/src/main/java/com/learn/repository/mp/StudentMapper.java new file mode 100644 index 0000000..98ad7e0 --- /dev/null +++ b/week4/src/main/java/com/learn/repository/mp/StudentMapper.java @@ -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 { + + @Select("SELECT * FROM student WHERE name LIKE CONCAT('%', #{kw}, '%') OR email LIKE CONCAT('%', #{kw}, '%')") + IPage search(Page page, @Param("kw") String keyword); + + @Select("SELECT * FROM student WHERE score >= #{min} ORDER BY score DESC") + IPage excellent(Page page, @Param("min") int minScore); +} diff --git a/week4/src/main/java/com/learn/service/StudentServiceSelector.java b/week4/src/main/java/com/learn/service/StudentServiceSelector.java new file mode 100644 index 0000000..5b088ea --- /dev/null +++ b/week4/src/main/java/com/learn/service/StudentServiceSelector.java @@ -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 list() { + return useJpa() ? jpa.list() : mp.list(); + } + + public Optional getById(Long id) { + return useJpa() ? jpa.getById(id) : mp.getById(id); + } + + public List search(String keyword) { + return useJpa() ? jpa.search(keyword) : mp.search(keyword); + } + + public Optional 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"; + } +} diff --git a/week4/src/main/java/com/learn/service/jpa/StudentJpaService.java b/week4/src/main/java/com/learn/service/jpa/StudentJpaService.java new file mode 100644 index 0000000..dc38f3a --- /dev/null +++ b/week4/src/main/java/com/learn/service/jpa/StudentJpaService.java @@ -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 list() { return repo.findAll(Sort.by(Sort.Direction.DESC, "score")); } + + @Transactional(readOnly = true) + public Optional getById(Long id) { return repo.findById(id); } + + @Transactional(readOnly = true) + public List search(String keyword) { + return repo.search(keyword); + } + + @Transactional + public Optional 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 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); + } +} diff --git a/week4/src/main/java/com/learn/service/mp/StudentMpService.java b/week4/src/main/java/com/learn/service/mp/StudentMpService.java new file mode 100644 index 0000000..85fcd64 --- /dev/null +++ b/week4/src/main/java/com/learn/service/mp/StudentMpService.java @@ -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 list() { + LambdaQueryWrapper w = new LambdaQueryWrapper<>(); + w.orderByDesc(Student::getScore); + return mapper.selectList(w); + } + + @Transactional(readOnly = true) + public Optional getById(Long id) { + return Optional.ofNullable(mapper.selectById(id)); + } + + @Transactional(readOnly = true) + public List search(String keyword) { + LambdaQueryWrapper w = new LambdaQueryWrapper<>(); + w.like(Student::getName, keyword).or().like(Student::getEmail, keyword); + w.orderByDesc(Student::getScore); + return mapper.selectList(w); + } + + @Transactional + public Optional 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 page(int pageNum, int pageSize, String keyword) { + Page page = new Page<>(pageNum, pageSize); + LambdaQueryWrapper 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 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; + } +} diff --git a/week4/src/main/resources/application.yml b/week4/src/main/resources/application.yml new file mode 100644 index 0000000..1152185 --- /dev/null +++ b/week4/src/main/resources/application.yml @@ -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 diff --git a/week4/src/main/resources/static/css/style.css b/week4/src/main/resources/static/css/style.css new file mode 100644 index 0000000..6353c68 --- /dev/null +++ b/week4/src/main/resources/static/css/style.css @@ -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; } diff --git a/week4/src/main/resources/static/index.html b/week4/src/main/resources/static/index.html new file mode 100644 index 0000000..7efb4b3 --- /dev/null +++ b/week4/src/main/resources/static/index.html @@ -0,0 +1,126 @@ + + + + + + 学生管理系统 v1 + + + + + +
+
+

📚 学生管理系统 v1

+

Spring Boot + JPA / MyBatis-Plus 双引擎

+
+ +
+ + +
+ + + + + + + + + + + + +
头像ID姓名年龄邮箱成绩操作
+ + + +
+
+ - + 学生总数 +
+
+ - + 优秀人数 (≥85) +
+
+ + + + + + + + diff --git a/week4/src/main/resources/static/js/app.js b/week4/src/main/resources/static/js/app.js new file mode 100644 index 0000000..ee8a553 --- /dev/null +++ b/week4/src/main/resources/static/js/app.js @@ -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 = '加载中...'; + + 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 = '暂无数据'; + } 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 = '加载失败: ' + e.message + ''; + } +} + +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 + ? '' + escapeHtml(s.name) + '' + : '
'; + + return ` + + ${avatarHtml} + ${s.id} + ${escapeHtml(s.name)} + ${s.age} + ${escapeHtml(s.email)} + ${score} + + + + + `; +} + +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; +} diff --git a/week4/教案.md b/week4/教案.md new file mode 100644 index 0000000..e497058 --- /dev/null +++ b/week4/教案.md @@ -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 = '...'; +``` + +**2. 事件绑定** +```javascript +// HTML 中直接绑定(在按钮上写 onclick="doSomething()") + + +// JS 中绑定(更灵活,但在这里我们用上面那种方式入门) +document.addEventListener('DOMContentLoaded', () => { ... }); +``` + +**3. 字符串模板(ES6)** +```javascript +// 反引号 + ${} 插值,比 + 拼接直观很多 +return ` + + ${s.id} + ${s.name} + `; +``` + +### 动手 + +1. 打开浏览器开发者工具(F12 → Console),输入: + ```javascript + document.querySelector('h1').textContent = 'Hello JS!' + ``` + 观察页面标题是否变化 +2. 在 `app.js` 的 `renderRow` 函数中,把"年龄"列的显示改成 `${s.age} 岁`(加上"岁"字) +3. 在 Console 中手动创建一个 `
`,设置颜色,插入到页面中 + +--- + +## 第 2 天:fetch API —— 前后端握手 + +### fetch 是什么? + +fetch 是浏览器内置的 HTTP 客户端,让 JS 可以调用后端 API。 + +```javascript +// GET 请求 +const response = await fetch('/api/students'); +const result = await response.json(); // 解析 JSON +console.log(result.data); // 使用数据 + +// POST 请求 +const response = await fetch('/api/students', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: '新同学', age: 20, email: 'x@mail.com', score: 85 }) +}); +``` + +### async / await 是什么? + +fetch 是异步操作(需要时间等待服务器响应),`await` 让代码看起来像同步的一样,更好理解。 + +```javascript +// ❌ 不用 async/await(回调地狱) +fetch(url).then(resp => resp.json()).then(data => console.log(data)); + +// ✅ 用 async/await(像写同步代码一样) +async function load() { + const resp = await fetch(url); + const data = await resp.json(); + console.log(data); +} +``` + +### 本项目中的请求封装 + +打开 `app.js`,看 `api()` 函数: + +```javascript +async function api(url, options) { + options.headers['Authorization'] = 'Bearer ' + token; // 自动带 Token + const resp = await fetch(url, options); + const result = await resp.json(); + + if (resp.status === 401 || resp.status === 403) { + logout(); // Token 失效,自动退回登录页 + } + return result; +} +``` + +**为什么封装?** 每个请求都需要带 Authorization 头,封装后调用方只需要关心业务数据。 + +### 动手 + +1. 在 Console 中手动调用 fetch 测试 API: + ```javascript + fetch('/api/students/hello', { headers: { 'Authorization': 'Bearer week4-secret-token' } }) + .then(r => r.json()) + .then(console.log) + ``` +2. 观察 Network 面板(F12 → Network),看请求和响应的完整内容 +3. 故意发一个错误的 Token,观察 401 返回和处理逻辑 + +--- + +## 第 3 天:Spring MVC 拦截器 —— 请求的"门卫" + +### 拦截器原理 + +``` +HTTP 请求 + │ + ▼ +DispatcherServlet(前端控制器) + │ + ▼ +[拦截器 preHandle] ← 在这里决定"放行"还是"拦截" + │ 通过 + ▼ +Controller(处理业务) + │ + ▼ +[拦截器 postHandle/afterCompletion] + │ + ▼ +HTTP 响应 +``` + +### 本项目拦截器做了什么? + +打开 `controller/AuthInterceptor.java`: + +```java +@Override +public boolean preHandle(HttpServletRequest request, ...) { + // 1. 白名单直接放行 + if (path.startsWith("/api/hello")) return true; + + // 2. 校验 Authorization 头 + String authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + response.setStatus(401); // 返回 401 Unauthorized + return false; // 拦截! + } + + // 3. 验证 Token 是否匹配 + String token = authHeader.substring(7); + if (!validToken.equals(token)) { + response.setStatus(403); // 返回 403 Forbidden + return false; + } + return true; // 放行 +} +``` + +### 注册拦截器(WebConfig.java) + +```java +@Configuration +public class WebConfig implements WebMvcConfigurer { + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(authInterceptor) + .addPathPatterns("/api/**") // 拦截所有 /api/ 路径 + .excludePathPatterns("/api/hello", "/api/login"); // 白名单 + } +} +``` + +### 动手 + +1. 用 Postman 不带 Authorization 头发一个 `GET /api/students`,观察 401 响应 +2. 带正确的 Token `Bearer week4-secret-token`,观察正常返回 +3. 把 `auth.token` 改成另一个值,重启后观察旧 Token 变为 403 +4. 思考:真正的用户系统会怎么存储和验证 Token?(预告:JWT,第 5 周) + +--- + +## 第 4 天:统一异常处理 + 参数校验 + +### GlobalExceptionHandler 做了什么? + +```java +@RestControllerAdvice // 拦截所有 Controller +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) // 参数校验失败 + public ResponseEntity handleValidation(...) { + return ResponseEntity.badRequest() + .body(ApiResponse.badRequest("参数校验失败: ...")); + } + + @ExceptionHandler(Exception.class) // 兜底:所有未捕获异常 + public ResponseEntity handleAll(...) { + return ResponseEntity.status(500) + .body(ApiResponse.error(500, "服务器内部错误")); + } +} +``` + +### 为什么 Controller 不需要 try-catch? + +因为 `@RestControllerAdvice` 在所有 Controller 方法的外层织入了一层异常处理网。任何异常抛出后,都会落入对应的 `@ExceptionHandler` 方法。 + +```java +// Controller 中 +@PostMapping +public ApiResponse add(@Valid @RequestBody Student s) { + return ApiResponse.created(service.add(s)); + // 如果 s 的 name 为空 → MethodArgumentNotValidException + // 如果 service.add 中抛异常 → Exception + // 都会被 GlobalExceptionHandler 捕获,Controller 不需要处理 +} +``` + +### 统一响应格式(ApiResponse) + +所有接口返回统一结构: +```json +{ + "code": 200, + "message": "success", + "data": { ... }, + "extra": { "total": 10, "orm": "JPA" } +} +``` + +这样前端只需要判断 `result.code === 200` 来确定请求是否成功。 + +### 动手 + +1. 故意提交空的姓名,观察返回的 400 错误详情 +2. 在 Student 实体类中新增一个校验规则:`@Min(0) @Max(100) private int score` +3. 测试传入 score=999,观察校验是否生效 + +--- + +## 第 5 天:RESTful 设计规范 + ORM 切换 + +### RESTful API 最佳实践 + +| 原则 | 规则 | +|------|------| +| **用名词,不用动词** | `/api/students` ✅ `/api/getStudents` ❌ | +| **HTTP 方法表达操作** | GET=查 / POST=增 / PUT=改 / DELETE=删 | +| **资源用复数** | `/api/students` 而不是 `/api/student` | +| **层级表示关系** | `/api/students/5/avatar` = 学生 5 的头像 | +| **正确使用状态码** | 200=成功 / 201=创建成功 / 400=参数错误 / 404=未找到 / 500=服务器错误 | + +### ORM 模式切换 + +本项目用 `orm.mode` 配置实现了 JPA 和 MP 的无缝切换: + +```yaml +# application.yml +orm: + mode: jpa # 改成 mp 即可切换到 MyBatis-Plus +``` + +原理:`StudentServiceSelector` 读取 `@Value("${orm.mode}")`,根据值选择调用 `StudentJpaService` 还是 `StudentMpService`。Controller 层完全不感知底层用的是哪个 ORM。 + +### 动手 + +1. 修改 `orm.mode` 为 `mp`,重启,观察前端页面显示的 ORM 标签 +2. 测试同一个接口的返回数据是否完全一致 +3. 对比控制台 SQL 输出,JPA 和 MP 生成的 SQL 有何不同 +4. 思考:如果要在运行时动态切换(不重启),需要怎么改? + +--- + +## 第 6 天:文件上传 + CORS 跨域 + +### Spring Boot 文件上传 + +Controller 中处理文件只需 `@RequestParam("file") MultipartFile file`: + +```java +@PostMapping("/{id}/avatar") +public ResponseEntity uploadAvatar( + @PathVariable Long id, + @RequestParam("file") MultipartFile file) { + + // 1. 文件校验 + if (file.isEmpty()) return badRequest("文件为空"); + String ext = originalName.substring(originalName.lastIndexOf(".")); + + // 2. 保存到磁盘 + String filename = UUID.randomUUID() + ext; // 随机名防冲突 + file.transferTo(new File("./uploads", filename)); + + // 3. 更新数据库 + service.updateAvatar(id, filename); +} +``` + +### 前端如何上传文件 + +```javascript +const formData = new FormData(); +formData.append('file', fileInput.files[0]); // 从 取文件 + +fetch(`/api/students/${id}/avatar`, { + method: 'POST', + body: formData // 不设 Content-Type!浏览器自动设置 multipart/form-data + boundary +}); +``` + +### CORS 跨域 + +当前前后端同端口,不存在跨域。但项目已经预置了 CORS 配置(`WebConfig.addCorsMappings`),为后续前后端分离做准备。 + +### 静态资源映射 + +```java +// 将本地 uploads/ 目录映射到 URL /uploads/** +registry.addResourceHandler("/uploads/**") + .addResourceLocations("file:./uploads/"); +``` + +这样通过 `http://localhost:8080/uploads/xxx.jpg` 就能访问上传的头像。 + +### 动手 + +1. 新增一个学生,编辑该学生 → 上传一张头像 → 观察表格中头像显示 +2. 打开 `./uploads/` 目录,确认文件已保存 +3. 故意上传一个超过 2MB 的文件,观察错误提示 +4. 修改 `application.yml` 中 `spring.servlet.multipart.max-file-size` 为 5MB + +--- + +## 第 7 天:项目整合与总结 + +### 本周完整请求流程 + +``` +浏览器 + │ + ├── 1. GET / → 加载 index.html + style.css + app.js + │ + ├── 2. 用户输入 Token → JS 调用 /api/students/hello 验证 + │ + ├── 3. Token 验证通过 → 进入主面板 → JS 调用 /api/students 加载数据 + │ │ + │ ▼ + │ [AuthInterceptor] 校验 Authorization 头 + │ │ 通过 + │ ▼ + │ [StudentController] 接收请求 → 参数校验(@Valid) + │ │ + │ ▼ + │ [StudentServiceSelector] 根据 orm.mode 选择 JPA 或 MP Service + │ │ + │ ▼ + │ [StudentJpaService / StudentMpService] 执行业务逻辑 + │ │ + │ ▼ + │ [Repository / Mapper] 操作数据库 + │ │ + │ ▼ + │ 返回 ApiResponse 格式 JSON → JS 渲染到页面 + │ + └── 异常 → [GlobalExceptionHandler] 拦截 → 返回统一错误 JSON +``` + +### 完整验收清单 + +**后端(Postman 逐项测试)**: + +- [ ] `GET /api/students/hello` 无需 Token,返回运行状态 +- [ ] `GET /api/students` 需 Token,返回全部学生 +- [ ] `GET /api/students?keyword=张` 模糊搜索 +- [ ] `POST /api/students` 新增一个学生 +- [ ] `GET /api/students/1` 查询 ID=1 +- [ ] `PUT /api/students/1` 更新学生信息 +- [ ] `DELETE /api/students/1` 删除学生 +- [ ] `POST /api/students/1/avatar` 上传头像 +- [ ] 不带 Token 发请求 → 401 +- [ ] 带错误 Token 发请求 → 403 +- [ ] 提交空 name → 400 校验错误 +- [ ] 修改 `orm.mode: mp` 后功能不变 + +**前端(浏览器)**: + +- [ ] 输入正确 Token → 进入主面板 +- [ ] 输入错误 Token → 显示错误 +- [ ] 表格正确展示全部学生(头像、成绩徽章颜色正确) +- [ ] 新增学生 → 表单提交 → 表格刷新 +- [ ] 编辑学生 → 回填数据 → 更新成功 +- [ ] 上传头像 → 头像显示在表格中 +- [ ] 搜索 → 过滤数据 +- [ ] 删除 → 确认弹窗 → 删除成功 +- [ ] 退出登录 → 回到登录页 + +### 本周产出 + +``` +✅ 原生 JS 前后端交互(fetch + DOM 操作) +✅ Token 认证与 401/403 处理 +✅ Spring MVC 拦截器(登录权限校验) +✅ 全局异常处理(统一 JSON 格式错误返回) +✅ 参数校验(@Valid + Bean Validation + 自定义错误消息) +✅ RESTful API 设计规范 +✅ ORM 配置切换(JPA ↔ MyBatis-Plus,一行配置切换) +✅ 文件上传(头像,前端 FormData + 后端 MultipartFile) +✅ CORS 跨域预配置 +✅ 统一 API 响应格式(ApiResponse) +``` + +--- + +> **本周核心**:你已从 Java 基础走到了能独立交付一个**全栈应用**的水平。后端有三层架构和双 ORM,前端有原生 JS 交互和文件上传,系统有拦截器保护、统一异常处理、参数校验。这是你第一个真正的里程碑项目。 diff --git a/week5/pom.xml b/week5/pom.xml new file mode 100644 index 0000000..b9d789c --- /dev/null +++ b/week5/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.learn + week5-security + 1.0.0 + Week 5: Spring 全家桶核心组件 + + + 17 + 3.5.6 + 0.12.5 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + com.mysql + mysql-connector-j + runtime + + + + + org.springframework.boot + spring-boot-starter-security + + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-cache + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/week5/sql/init.sql b/week5/sql/init.sql new file mode 100644 index 0000000..34ffa35 --- /dev/null +++ b/week5/sql/init.sql @@ -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; diff --git a/week5/src/main/java/com/learn/Week5Application.java b/week5/src/main/java/com/learn/Week5Application.java new file mode 100644 index 0000000..1bb0956 --- /dev/null +++ b/week5/src/main/java/com/learn/Week5Application.java @@ -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); + } +} diff --git a/week5/src/main/java/com/learn/annotation/ScoreValidator.java b/week5/src/main/java/com/learn/annotation/ScoreValidator.java new file mode 100644 index 0000000..3f8e06d --- /dev/null +++ b/week5/src/main/java/com/learn/annotation/ScoreValidator.java @@ -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 { + + @Override + public boolean isValid(Integer value, ConstraintValidatorContext context) { + if (value == null) return true; // null 由 @NotNull 处理 + return value >= 0 && value <= 100; + } +} diff --git a/week5/src/main/java/com/learn/annotation/ValidScore.java b/week5/src/main/java/com/learn/annotation/ValidScore.java new file mode 100644 index 0000000..1e3eef8 --- /dev/null +++ b/week5/src/main/java/com/learn/annotation/ValidScore.java @@ -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[] payload() default {}; +} diff --git a/week5/src/main/java/com/learn/config/DataInitializer.java b/week5/src/main/java/com/learn/config/DataInitializer.java new file mode 100644 index 0000000..dea9b63 --- /dev/null +++ b/week5/src/main/java/com/learn/config/DataInitializer.java @@ -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)"); + } +} diff --git a/week5/src/main/java/com/learn/config/MyBatisPlusConfig.java b/week5/src/main/java/com/learn/config/MyBatisPlusConfig.java new file mode 100644 index 0000000..d9af693 --- /dev/null +++ b/week5/src/main/java/com/learn/config/MyBatisPlusConfig.java @@ -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; + } +} diff --git a/week5/src/main/java/com/learn/config/SecurityConfig.java b/week5/src/main/java/com/learn/config/SecurityConfig.java new file mode 100644 index 0000000..778504a --- /dev/null +++ b/week5/src/main/java/com/learn/config/SecurityConfig.java @@ -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 加密,不可逆 + } +} diff --git a/week5/src/main/java/com/learn/controller/AuthController.java b/week5/src/main/java/com/learn/controller/AuthController.java new file mode 100644 index 0000000..e0d3077 --- /dev/null +++ b/week5/src/main/java/com/learn/controller/AuthController.java @@ -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>> register(@RequestBody Map body) { + try { + Map 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>> login(@Valid @RequestBody LoginRequest req) { + try { + Map result = authService.login(req); + return ResponseEntity.ok(ApiResponse.success("登录成功", result)); + } catch (RuntimeException e) { + return ResponseEntity.status(401).body(ApiResponse.error(401, e.getMessage())); + } + } +} diff --git a/week5/src/main/java/com/learn/controller/StudentController.java b/week5/src/main/java/com/learn/controller/StudentController.java new file mode 100644 index 0000000..c7bbcf3 --- /dev/null +++ b/week5/src/main/java/com/learn/controller/StudentController.java @@ -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> list( + @RequestParam(required = false) String keyword) { + java.util.List 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> 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 statsExcellent(@RequestParam(defaultValue = "85") int min) { + return ApiResponse.success(service.countExcellent(min)); + } + + // ---- 新增(仅 ADMIN)---- + + @PostMapping + public ResponseEntity> add(@Valid @RequestBody Student student) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.created(service.add(student))); + } + + // ---- 更新(仅 ADMIN)---- + + @PutMapping("/{id}") + public ResponseEntity> 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> 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 me(Principal principal) { + return ApiResponse.success("当前用户: " + principal.getName() + + " | ORM: " + service.getActiveMode()); + } +} diff --git a/week5/src/main/java/com/learn/dto/ApiResponse.java b/week5/src/main/java/com/learn/dto/ApiResponse.java new file mode 100644 index 0000000..68d3d26 --- /dev/null +++ b/week5/src/main/java/com/learn/dto/ApiResponse.java @@ -0,0 +1,33 @@ +package com.learn.dto; + +import java.util.HashMap; +import java.util.Map; + +public class ApiResponse { + private int code; + private String message; + private T data; + private Map extra; + + public static ApiResponse success(T data) { return build(200, "success", data); } + public static ApiResponse success(String msg, T data) { return build(200, msg, data); } + public static ApiResponse created(T data) { return build(201, "创建成功", data); } + public static ApiResponse error(int code, String msg) { return build(code, msg, null); } + public static ApiResponse notFound(String msg) { return error(404, msg); } + public static ApiResponse badRequest(String msg) { return error(400, msg); } + + public ApiResponse put(String key, Object value) { + if (extra == null) extra = new HashMap<>(); + extra.put(key, value); return this; + } + + private static ApiResponse build(int code, String msg, T data) { + ApiResponse 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 getExtra() { return extra; } +} diff --git a/week5/src/main/java/com/learn/dto/LoginRequest.java b/week5/src/main/java/com/learn/dto/LoginRequest.java new file mode 100644 index 0000000..64118dd --- /dev/null +++ b/week5/src/main/java/com/learn/dto/LoginRequest.java @@ -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; } +} diff --git a/week5/src/main/java/com/learn/entity/Student.java b/week5/src/main/java/com/learn/entity/Student.java new file mode 100644 index 0000000..7c954d1 --- /dev/null +++ b/week5/src/main/java/com/learn/entity/Student.java @@ -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; } +} diff --git a/week5/src/main/java/com/learn/entity/User.java b/week5/src/main/java/com/learn/entity/User.java new file mode 100644 index 0000000..194143a --- /dev/null +++ b/week5/src/main/java/com/learn/entity/User.java @@ -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; } +} diff --git a/week5/src/main/java/com/learn/exception/GlobalExceptionHandler.java b/week5/src/main/java/com/learn/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..929c2e6 --- /dev/null +++ b/week5/src/main/java/com/learn/exception/GlobalExceptionHandler.java @@ -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> 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> handleAccessDenied(AccessDeniedException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error(403, "权限不足: 只有 ADMIN 可以执行此操作")); + } + + /** 兜底 */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAll(Exception ex) { + log.error("未处理异常", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error(500, "服务器错误: " + ex.getMessage())); + } +} diff --git a/week5/src/main/java/com/learn/repository/jpa/StudentJpaRepository.java b/week5/src/main/java/com/learn/repository/jpa/StudentJpaRepository.java new file mode 100644 index 0000000..191b1d2 --- /dev/null +++ b/week5/src/main/java/com/learn/repository/jpa/StudentJpaRepository.java @@ -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 { + + List findByNameContaining(String keyword); + + @Query("SELECT s FROM Student s WHERE (s.name LIKE %:kw% OR s.email LIKE %:kw%) AND s.deleted = 0") + List 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 excellent(@Param("min") int minScore); +} diff --git a/week5/src/main/java/com/learn/repository/jpa/UserRepository.java b/week5/src/main/java/com/learn/repository/jpa/UserRepository.java new file mode 100644 index 0000000..d51e86b --- /dev/null +++ b/week5/src/main/java/com/learn/repository/jpa/UserRepository.java @@ -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 { + Optional findByUsername(String username); + boolean existsByUsername(String username); +} diff --git a/week5/src/main/java/com/learn/repository/mp/StudentMapper.java b/week5/src/main/java/com/learn/repository/mp/StudentMapper.java new file mode 100644 index 0000000..1857999 --- /dev/null +++ b/week5/src/main/java/com/learn/repository/mp/StudentMapper.java @@ -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 { +} diff --git a/week5/src/main/java/com/learn/security/JwtAuthFilter.java b/week5/src/main/java/com/learn/security/JwtAuthFilter.java new file mode 100644 index 0000000..c5c76ba --- /dev/null +++ b/week5/src/main/java/com/learn/security/JwtAuthFilter.java @@ -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 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 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; + } +} diff --git a/week5/src/main/java/com/learn/security/JwtUtil.java b/week5/src/main/java/com/learn/security/JwtUtil.java new file mode 100644 index 0000000..a601ca8 --- /dev/null +++ b/week5/src/main/java/com/learn/security/JwtUtil.java @@ -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(); + } +} diff --git a/week5/src/main/java/com/learn/service/AuthService.java b/week5/src/main/java/com/learn/service/AuthService.java new file mode 100644 index 0000000..5fb0bfb --- /dev/null +++ b/week5/src/main/java/com/learn/service/AuthService.java @@ -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 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 result = new HashMap<>(); + result.put("token", token); + result.put("role", "USER"); + return result; + } + + /** 登录 */ + public Map 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 result = new HashMap<>(); + result.put("token", token); + result.put("role", user.getRole()); + result.put("username", user.getUsername()); + return result; + } +} diff --git a/week5/src/main/java/com/learn/service/StudentServiceSelector.java b/week5/src/main/java/com/learn/service/StudentServiceSelector.java new file mode 100644 index 0000000..cde619e --- /dev/null +++ b/week5/src/main/java/com/learn/service/StudentServiceSelector.java @@ -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 list() { return useJpa() ? jpa.list() : mp.list(); } + + public Student getById(Long id) { return useJpa() ? jpa.getById(id) : mp.getById(id); } + + public List search(String kw) { return useJpa() ? jpa.search(kw) : mp.search(kw); } + + public Optional 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"; } +} diff --git a/week5/src/main/java/com/learn/service/jpa/StudentJpaService.java b/week5/src/main/java/com/learn/service/jpa/StudentJpaService.java new file mode 100644 index 0000000..696961f --- /dev/null +++ b/week5/src/main/java/com/learn/service/jpa/StudentJpaService.java @@ -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 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 search(String kw) { return repo.search(kw); } + + @Transactional + @CacheEvict(value = "students", allEntries = true) + public Optional 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(); } +} diff --git a/week5/src/main/java/com/learn/service/mp/StudentMpService.java b/week5/src/main/java/com/learn/service/mp/StudentMpService.java new file mode 100644 index 0000000..6ceead5 --- /dev/null +++ b/week5/src/main/java/com/learn/service/mp/StudentMpService.java @@ -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 list() { + LambdaQueryWrapper 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 search(String kw) { + LambdaQueryWrapper 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 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 w = new LambdaQueryWrapper<>(); + w.ge(Student::getScore, min); + return mapper.selectCount(w); + } +} diff --git a/week5/src/main/resources/application.yml b/week5/src/main/resources/application.yml new file mode 100644 index 0000000..4517753 --- /dev/null +++ b/week5/src/main/resources/application.yml @@ -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 diff --git a/week5/src/main/resources/static/css/style.css b/week5/src/main/resources/static/css/style.css new file mode 100644 index 0000000..1427b8b --- /dev/null +++ b/week5/src/main/resources/static/css/style.css @@ -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; } +} diff --git a/week5/src/main/resources/static/index.html b/week5/src/main/resources/static/index.html new file mode 100644 index 0000000..a54012e --- /dev/null +++ b/week5/src/main/resources/static/index.html @@ -0,0 +1,105 @@ + + + + + + 学生管理系统 v2 + + + + + +
+
+

📚 学生管理系统 v2

+

Spring Security + JWT + Redis

+
+ +
+ + + + + + + + + + + + + diff --git a/week5/src/main/resources/static/js/app.js b/week5/src/main/resources/static/js/app.js new file mode 100644 index 0000000..a1c82f7 --- /dev/null +++ b/week5/src/main/resources/static/js/app.js @@ -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 = '加载中...'; + + let url = BASE + '/students'; + if (keyword) url += '?keyword=' + encodeURIComponent(keyword); + + const result = await api(url); + if (!result) { tbody.innerHTML = '加载失败'; return; } + + const students = result.data; + if (!students || students.length === 0) { + tbody.innerHTML = '暂无数据'; + } 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' + ? ` + ` + : '--'; + return ` + ${s.id}${escapeHtml(s.name)} + ${s.age}${escapeHtml(s.email)} + ${s.score} + ${actions}`; + }).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; } diff --git a/week5/教案.md b/week5/教案.md new file mode 100644 index 0000000..005d01a --- /dev/null +++ b/week5/教案.md @@ -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` 接口:`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 扩展为支持不同学科的不同分数范围 diff --git a/week6/.gitignore b/week6/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/week6/.gitignore @@ -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? diff --git a/week6/README.md b/week6/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/week6/README.md @@ -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 ` + + diff --git a/week6/package-lock.json b/week6/package-lock.json new file mode 100644 index 0000000..35b86d8 --- /dev/null +++ b/week6/package-lock.json @@ -0,0 +1,1950 @@ +{ + "name": "week6", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "week6", + "version": "0.0.0", + "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" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", + "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", + "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz", + "integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.33", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz", + "integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz", + "integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.33", + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.10", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz", + "integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz", + "integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz", + "integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz", + "integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/runtime-core": "3.5.33", + "@vue/shared": "3.5.33", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz", + "integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "vue": "3.5.33" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", + "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "ast-kit": "^2.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz", + "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-sfc": "3.5.33", + "@vue/runtime-dom": "3.5.33", + "@vue/server-renderer": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.6.tgz", + "integrity": "sha512-9+kmUTGbKMyW9Asoy98IXXYIzrTMT7JDAdpDDeEkorHvybpUvBI2wsrSM5jFOXrFydpzRFJ9vAh+80DN2PGu9w==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.28.6", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.0.6", + "ast-walker-scope": "^0.8.3", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.8.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.17", + "pinia": "^3.0.4", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.1.tgz", + "integrity": "sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.1.1" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-kit": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.1.tgz", + "integrity": "sha512-gVBaBv++i+adg4JpH71k9ppl4soyR7Y2McEqO5YNgv0BI1kMZ7BDX5gnwkZ5COYgiCyhejZG+yGNrBAjj6Coqg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.1.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-shared": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.1.tgz", + "integrity": "sha512-+h4ttmJYl/txpxHKaoZcaKpC+pvckgLzIDiSQlaQ7kKthKh8KuwoLW2D8hPJEnqKzXOvu15UHEoGyngAXCz0EQ==", + "license": "MIT" + }, + "node_modules/vue-router/node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/week6/package.json b/week6/package.json new file mode 100644 index 0000000..5ac8fb0 --- /dev/null +++ b/week6/package.json @@ -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" + } +} diff --git a/week6/public/favicon.svg b/week6/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/week6/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/week6/public/icons.svg b/week6/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/week6/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/week6/src/App.vue b/week6/src/App.vue new file mode 100644 index 0000000..8ea8e47 --- /dev/null +++ b/week6/src/App.vue @@ -0,0 +1,11 @@ + + + + diff --git a/week6/src/api/http.js b/week6/src/api/http.js new file mode 100644 index 0000000..538946f --- /dev/null +++ b/week6/src/api/http.js @@ -0,0 +1,40 @@ +// 第 7 天:Axios 封装 — 统一处理 JWT 和错误 +import axios from 'axios' + +const http = axios.create({ + baseURL: '/api', + timeout: 10000 +}) + +// 请求拦截器:自动附加 Token +http.interceptors.request.use(config => { + const token = localStorage.getItem('wk6_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}, error => Promise.reject(error)) + +// 响应拦截器:统一错误处理 +http.interceptors.response.use( + response => response, + error => { + if (error.response) { + switch (error.response.status) { + case 401: + localStorage.removeItem('wk6_token') + window.location.href = '/login' + break + case 403: + alert('权限不足') + break + case 500: + alert('服务器错误: ' + (error.response.data?.message || '未知')) + break + } + } + return Promise.reject(error) + } +) + +export default http diff --git a/week6/src/components/NavBar.vue b/week6/src/components/NavBar.vue new file mode 100644 index 0000000..687a1f7 --- /dev/null +++ b/week6/src/components/NavBar.vue @@ -0,0 +1,35 @@ + + + + + + diff --git a/week6/src/components/TodoForm.vue b/week6/src/components/TodoForm.vue new file mode 100644 index 0000000..3d187fe --- /dev/null +++ b/week6/src/components/TodoForm.vue @@ -0,0 +1,43 @@ + + + + + + diff --git a/week6/src/components/TodoItem.vue b/week6/src/components/TodoItem.vue new file mode 100644 index 0000000..60634d5 --- /dev/null +++ b/week6/src/components/TodoItem.vue @@ -0,0 +1,30 @@ + + + + + + diff --git a/week6/src/main.js b/week6/src/main.js new file mode 100644 index 0000000..a14dd8b --- /dev/null +++ b/week6/src/main.js @@ -0,0 +1,10 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import router from './router' +import App from './App.vue' +import './style.css' + +const app = createApp(App) +app.use(createPinia()) // 第 6 天:Pinia 状态管理 +app.use(router) // 第 5 天:Vue Router +app.mount('#app') diff --git a/week6/src/router/index.js b/week6/src/router/index.js new file mode 100644 index 0000000..30dc5b6 --- /dev/null +++ b/week6/src/router/index.js @@ -0,0 +1,47 @@ +// 第 5 天:Vue Router 路由配置 +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { + path: '/', + name: 'Home', + component: () => import('../views/HomeView.vue'), + meta: { title: '首页' } + }, + { + path: '/counter', + name: 'Counter', + component: () => import('../views/CounterView.vue'), + meta: { title: '响应式计数器 — Day 2' } + }, + { + path: '/form', + name: 'FormDemo', + component: () => import('../views/FormDemoView.vue'), + meta: { title: '表单指令 — Day 3' } + }, + { + path: '/todo', + name: 'Todo', + component: () => import('../views/TodoView.vue'), + meta: { title: 'Todo List — Day 2-6' } + }, + { + path: '/about', + name: 'About', + component: () => import('../views/AboutView.vue'), + meta: { title: '关于 Week 6' } + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// 导航守卫:动态修改页面标题 +router.afterEach((to) => { + document.title = to.meta.title || 'Vue 3 学习' +}) + +export default router diff --git a/week6/src/stores/auth.js b/week6/src/stores/auth.js new file mode 100644 index 0000000..53bc123 --- /dev/null +++ b/week6/src/stores/auth.js @@ -0,0 +1,36 @@ +// 第 6-7 天:Pinia — 认证 Store(Token 管理 + 模拟登录) +// Week 7 对接真实后端时会替换为实际 API 调用 +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('wk6_token') || '') + const user = ref(null) + + const isLoggedIn = computed(() => !!token.value) + + function login(username, password) { + // 第 7 天会用 axios 调用真实登录接口替换此模拟 + return new Promise((resolve, reject) => { + // 模拟延迟 + setTimeout(() => { + if (username && password) { + token.value = 'demo-jwt-token-' + Date.now() + user.value = { username, role: username === 'admin' ? 'ADMIN' : 'USER' } + localStorage.setItem('wk6_token', token.value) + resolve(user.value) + } else { + reject(new Error('用户名和密码不能为空')) + } + }, 500) + }) + } + + function logout() { + token.value = '' + user.value = null + localStorage.removeItem('wk6_token') + } + + return { token, user, isLoggedIn, login, logout } +}) diff --git a/week6/src/stores/counter.js b/week6/src/stores/counter.js new file mode 100644 index 0000000..da539b8 --- /dev/null +++ b/week6/src/stores/counter.js @@ -0,0 +1,15 @@ +// 第 6 天:Pinia — 计数器 Store(演示 ref/computed 在 store 中的用法) +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export const useCounterStore = defineStore('counter', () => { + const count = ref(0) + + const double = computed(() => count.value * 2) + + function increment() { count.value++ } + function decrement() { count.value-- } + function reset() { count.value = 0 } + + return { count, double, increment, decrement, reset } +}) diff --git a/week6/src/stores/todo.js b/week6/src/stores/todo.js new file mode 100644 index 0000000..e3bea36 --- /dev/null +++ b/week6/src/stores/todo.js @@ -0,0 +1,36 @@ +// 第 6 天:Pinia — Todo Store(跨组件共享状态) +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export const useTodoStore = defineStore('todo', () => { + const todos = ref([ + { id: 1, text: '学习 Vue 3 响应式数据', done: true }, + { id: 2, text: '理解 computed 计算属性', done: true }, + { id: 3, text: '掌握组件通信 (props/emits)', done: false } + ]) + + let nextId = 4 + + // 计算属性:未完成 / 已完成 + const undone = computed(() => todos.value.filter(t => !t.done)) + const done = computed(() => todos.value.filter(t => t.done)) + + function add(text) { + todos.value.push({ id: nextId++, text, done: false }) + } + + function toggle(id) { + const t = todos.value.find(t => t.id === id) + if (t) t.done = !t.done + } + + function remove(id) { + todos.value = todos.value.filter(t => t.id !== id) + } + + function clearDone() { + todos.value = todos.value.filter(t => !t.done) + } + + return { todos, undone, done, add, toggle, remove, clearDone } +}) diff --git a/week6/src/style.css b/week6/src/style.css new file mode 100644 index 0000000..966b4b5 --- /dev/null +++ b/week6/src/style.css @@ -0,0 +1,11 @@ +/* ========== 全局重置 ========== */ +*, *::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; min-height: 100vh; +} + +a { color: #667eea; text-decoration: none; } +button { font-family: inherit; } diff --git a/week6/src/views/AboutView.vue b/week6/src/views/AboutView.vue new file mode 100644 index 0000000..0389200 --- /dev/null +++ b/week6/src/views/AboutView.vue @@ -0,0 +1,36 @@ + + + + diff --git a/week6/src/views/CounterView.vue b/week6/src/views/CounterView.vue new file mode 100644 index 0000000..2662d22 --- /dev/null +++ b/week6/src/views/CounterView.vue @@ -0,0 +1,65 @@ + + + + + + diff --git a/week6/src/views/FormDemoView.vue b/week6/src/views/FormDemoView.vue new file mode 100644 index 0000000..d239eb3 --- /dev/null +++ b/week6/src/views/FormDemoView.vue @@ -0,0 +1,84 @@ + + + + + + diff --git a/week6/src/views/HomeView.vue b/week6/src/views/HomeView.vue new file mode 100644 index 0000000..f144a71 --- /dev/null +++ b/week6/src/views/HomeView.vue @@ -0,0 +1,74 @@ + + + + + + diff --git a/week6/src/views/TodoView.vue b/week6/src/views/TodoView.vue new file mode 100644 index 0000000..e14bdfb --- /dev/null +++ b/week6/src/views/TodoView.vue @@ -0,0 +1,66 @@ + + + + + + diff --git a/week6/vite.config.js b/week6/vite.config.js new file mode 100644 index 0000000..d362126 --- /dev/null +++ b/week6/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + proxy: { + '/api': { // 第 7 天:代理后端 API + target: 'http://localhost:8080', + changeOrigin: true + } + } + } +}) diff --git a/week6/教案.md b/week6/教案.md new file mode 100644 index 0000000..c3a3ca2 --- /dev/null +++ b/week6/教案.md @@ -0,0 +1,226 @@ +# Week 6:Vue 3 前端框架入门 + +**目标**:掌握 Vue 3 Composition API + Vite + Vue Router + Pinia,能独立开发 SPA 应用。 + +**前置**:已完成 Week 5 Spring Boot 后端,有 HTML/CSS/JS 基础。 + +**本周产出**:一个独立的 Todo SPA 应用,包含计数器、动态表单、组件拆分、路由导航、状态管理和 HTTP 封装。 + +**启动方式**: +```bash +cd week6 +npm install # 首次运行 +npm run dev # 启动开发服务器 → http://localhost:5173 +``` + +--- + +## Day 1:Vue 3 介绍、Vite 脚手架、组件基础 + +**知识点**: +- Vue 3 是什么:渐进式前端框架,核心是"响应式数据 + 声明式渲染" +- SFC(Single File Component):`.vue` 文件 = `