From 3214c54abdd91c542169ba8de2b42400280587a8 Mon Sep 17 00:00:00 2001 From: lovelywrj <31509009+lovelywrj@users.noreply.github.com> Date: Thu, 31 Mar 2022 09:37:21 +0800 Subject: [PATCH] =?UTF-8?q?=E3=80=90=E9=A1=B9=E7=9B=AE=E8=AE=AD=E7=BB=83?= =?UTF-8?q?=E8=90=A5=E3=80=91Spring=20Boot=E4=BB=8E=E9=9B=B6=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E7=BD=91=E7=9B=98=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【项目训练营】Spring Boot从零实现网盘系统的所有资源文件 --- .DS_Store | Bin 0 -> 6148 bytes ...347\233\256\346\274\224\347\244\272.sy.md" | 35 + ...346\261\202\346\226\207\346\241\243.wd.md" | 152 ++ ...345\217\243\346\226\207\346\241\243.wd.md" | 902 +++++++++++ ...347\233\256\346\220\255\345\273\272.sy.md" | 344 +++++ ...347\232\204\344\275\277\347\224\250.sy.md" | 350 +++++ ...\210MyBatis\345\222\214MyBatis-Plus.sy.md" | 405 +++++ ...345\272\224\347\273\223\346\236\234.sy.md" | 378 +++++ ...345\275\225\350\256\244\350\257\201.sy.md" | 699 +++++++++ ...345\217\243\346\226\207\346\241\243.sy.md" | 356 +++++ ...345\217\243\345\274\200\345\217\221.sy.md" | 680 +++++++++ ...345\217\243\345\274\200\345\217\221.sy.md" | 1204 +++++++++++++++ ...345\217\243\345\274\200\345\217\221.sy.md" | 1036 +++++++++++++ ...347\253\257\345\267\245\347\250\213.sy.md" | 335 +++++ ...46\240\217\345\256\236\347\216\260..sy.md" | 349 +++++ ...351\235\242\345\256\236\347\216\260.sy.md" | 1037 +++++++++++++ ...344\270\273\351\241\265\351\235\242.sy.md" | 1256 ++++++++++++++++ ...344\274\240\345\256\236\347\216\260.sy.md" | 1319 +++++++++++++++++ ...351\207\215\345\221\275\345\220\215.sy.md" | 815 ++++++++++ ...347\234\213\345\256\236\347\216\260.sy.md" | 1081 ++++++++++++++ 20 files changed, 12733 insertions(+) create mode 100644 .DS_Store create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/01-420083-\351\241\271\347\233\256\346\274\224\347\244\272.sy.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/02-466260-\351\234\200\346\261\202\346\226\207\346\241\243.wd.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/03-466261-\346\216\245\345\217\243\346\226\207\346\241\243.wd.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/04-466262-Spring Boot\351\241\271\347\233\256\346\220\255\345\273\272.sy.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/05-466263-\346\225\260\346\215\256\345\272\223\350\256\276\350\256\241\346\265\201\347\250\213\345\217\212JPA\347\232\204\344\275\277\347\224\250.sy.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/06-466264-Spring Boot\346\225\264\345\220\210MyBatis\345\222\214MyBatis-Plus.sy.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/07-466265-Restful\346\216\245\345\217\243\350\256\276\350\256\241\350\247\204\350\214\203\344\273\245\345\217\212\347\273\237\344\270\200\345\223\215\345\272\224\347\273\223\346\236\234.sy.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/08-466266-Spring Boot\346\225\264\345\220\210Java Web Token\357\274\214\345\256\236\347\216\260\347\224\250\346\210\267\347\231\273\345\275\225\350\256\244\350\257\201.sy.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/09-466267-Spring Boot\351\233\206\346\210\220Swagger 3\357\274\214\345\271\266\347\224\237\346\210\220API\346\216\245\345\217\243\346\226\207\346\241\243.sy.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/10-466268-\345\220\216\345\217\260\346\216\245\345\217\243\345\274\200\345\217\221\345\256\236\346\210\2301\342\200\224\346\226\207\344\273\266\345\244\271\347\232\204\345\210\233\345\273\272\345\217\212\346\226\207\344\273\266\345\210\227\350\241\250\346\237\245\350\257\242\346\216\245\345\217\243\345\274\200\345\217\221.sy.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/11-466269-\345\220\216\345\217\260\346\216\245\345\217\243\345\274\200\345\217\221\345\256\236\346\210\2302\342\200\224\346\226\207\344\273\266\345\210\207\347\211\207\344\270\212\344\274\240\345\222\214\346\236\201\351\200\237\344\270\212\344\274\240\346\216\245\345\217\243\345\274\200\345\217\221.sy.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/12-466270-\345\220\216\345\217\260\346\216\245\345\217\243\345\274\200\345\217\221\345\256\236\346\210\2303\342\200\224\346\226\207\344\273\266\347\247\273\345\212\250\343\200\201\345\210\240\351\231\244\343\200\201\351\207\215\345\221\275\345\220\215\346\216\245\345\217\243\345\274\200\345\217\221.sy.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/13-466271-\351\207\207\347\224\250Vue CLI@4 + Element UI\346\220\255\345\273\272\345\211\215\347\253\257\345\267\245\347\250\213.sy.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/14-466272-\344\275\277\347\224\250 Vue Router \346\267\273\345\212\240\350\267\257\347\224\261+\351\241\266\351\203\250\345\257\274\350\210\252\346\240\217\345\256\236\347\216\260..sy.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/15-466273-Axios \346\216\245\345\217\243\345\260\201\350\243\205+\345\211\215\347\253\257\346\263\250\345\206\214\343\200\201\347\231\273\345\275\225\351\241\265\351\235\242\345\256\236\347\216\260.sy.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/16-466274-Vuex + Element UI \347\233\270\345\205\263\347\273\204\344\273\266\345\256\236\347\216\260\347\275\221\347\233\230\344\270\273\351\241\265\351\235\242.sy.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/17-466275-\346\226\207\344\273\266\345\244\271\346\267\273\345\212\240\343\200\201\346\226\207\344\273\266\344\270\212\344\274\240\345\256\236\347\216\260.sy.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/18-466276-\346\226\207\344\273\266\347\232\204\345\237\272\346\234\254\346\223\215\344\275\234\342\200\224\345\210\240\351\231\244\343\200\201\347\247\273\345\212\250\343\200\201\344\270\213\350\275\275\345\222\214\351\207\215\345\221\275\345\220\215.sy.md" create mode 100755 "8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/19-466277-\345\233\276\347\211\207\344\270\211\347\247\215\345\261\225\347\244\272\346\226\271\345\274\217\345\222\214\345\234\250\347\272\277\345\233\276\347\211\207\346\237\245\347\234\213\345\256\236\347\216\260.sy.md" diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..3d8ca5c793adf91eeea642f5aa0c46b74cf8838a GIT binary patch literal 6148 zcmeHKu}Z{15Phpx9N1iGxs6y|abjT^!r2OPrHy(gA{<6M4G40k5G?G)%0CeN2YYKf z|G?75#y2yo7;?52IumAIb}}zZUa^w_5c1Pe59k1B(*SHmYYQgu9G2UaMHC@%Istqu_OdxbbxJwskjoxY#V8&)=sn=-!@{uh;j}$IB(M zY|j!qtlb+;hU49$7|-6&v#x+E;0m|`uE0N6Kzc8&ytSyfiDH*e28?xEMaA+ zM+bwB0K^v4YP{E3f*2)1%o0|H%+P{SiAD`+#0W-bKBl@XVP$A^gfx7Fv~x%kO3>Z; z{;1^$nW0Blz!j(~FmKC_^#67G5A%PWCabdTpd{(?5;1 oTF#)Yn5eCo3u(oR-|C{=^LduAGE_SAN+ + +#### 技术架构图 + +![图片描述](https://doc.shiyanlou.com/courses/8842/1557563/be34a28af734ea3b6da8afa72ba9987a-0/wm) + +### 一般约束 + +1. 开发环境约束: + - 开发工具:VSCode + - 开发语言:HTML5、CSS3、JavaScript、Java、SQL +2. 时间约束:项目开发周期为 14 个工作日,需要开发者合理规划时间。 +3. 技术约束:Vue.js、Element UI、Axios、Stylus、Spring Boot、MySQL、MyBatis。 +4. 其他约束:开发者需在完成项目需求的前提下,考虑编码规范、页面优化等因素。 + +## 详细功能说明 + +### 注册页 + +顶部导航栏所有页面共享,使用 Element UI 的 [NavMenu 导航菜单](https://element.eleme.cn/#/zh-CN/component/menu) 实现;内容区使用 [Form 表单](https://element.eleme.cn/#/zh-CN/component/form) 实现,表单校验规则使用 Element UI 内置的 [async-validator](https://github.com/yiminghe/async-validator) 来实现,表单项中的组件使用 [Input 输入框](https://element.eleme.cn/#/zh-CN/component/input)、[Button 按钮](https://element.eleme.cn/#/zh-CN/component/button) 实现。 + +1. 用户须注册账号后才能登录系统 +2. 注册页面表单项包含 **用户名**, **手机号**, **密码**,页面设计如下: + +![图片描述](https://doc.shiyanlou.com/courses/8842/1557563/4b793f11d75ff9591498006f6b83564d-0/wm) + +### 登录页 + +页面使用 Element UI 的 [Form 表单](https://element.eleme.cn/#/zh-CN/component/form) 实现,表单校验规则使用 Element UI 内置的 [async-validator](https://github.com/yiminghe/async-validator) 来实现,表单项中的组件使用 [Input 输入框](https://element.eleme.cn/#/zh-CN/component/input)、[Button 按钮](https://element.eleme.cn/#/zh-CN/component/button) 实现。 + +用户使用注册的**手机号**和**密码**在登录页面进行账户验证后,即可进入系统,登录页面设计如下: + +非法输入校验:用户输入错误的手机号或密码,页面提示手机号或密码错误 + +![图片描述](https://doc.shiyanlou.com/courses/8842/1557563/24dd663fd25459b89cc32dc276a0725a-0/wm) + +### 网盘主页 + +#### 静态页面布局 + +主页使用了 Element UI 用于布局的容器组件 el-aside 侧边栏容器和 el-main 主要区域容器布局,组成了主页面布局。 + +左侧分类:**全部**、**图片**、**文档**、**视频**、**音乐**、**文档** + +右侧主显示区域:**文件展示及操作**,使用了 [Table 表格](https://element.eleme.cn/#/zh-CN/component/table) 来实现 + +效果图如下: + +![图片描述](https://doc.shiyanlou.com/courses/8842/1557563/05bab2d5d197f755a3c97543b908d2a6-0/wm) + +#### 文件类型分类查看 + +这部分使用了 Element UI 的 [NavMenu 导航菜单](https://element.eleme.cn/#/zh-CN/component/menu) 实现。 + +点击左侧手风琴按钮,即可按照文件类型分类查看文件 + +#### 新建文件夹 + +使用 Element UI 的 [Dialog 对话框](https://element.eleme.cn/#/zh-CN/component/dialog) 来实现。 + +在全部分类下,点击新建文件夹,可以进行文件夹的创建,如下 + +![图片描述](https://doc.shiyanlou.com/courses/8842/1557563/f0041b27d5b5c04621746b7736391cb8-0/wm) + +#### 上传文件 + +上传组件使用开源项目 [vue-simple-uploader](https://github.com/simple-uploader/vue-uploader/blob/master/README_zh-CN.md) ,支持文件分片上传 + +在全部分类下,点击顶部上传文件按钮,可以进行文件上传,支持单个文件上传及批量文件上传,上传的文件会保存到当前停留的文件夹目录下,如下: + +![图片描述](https://doc.shiyanlou.com/courses/8842/1557563/de36864e8aaef99b87b1f63e18c97378-0/wm) + +#### 批量删除文件 + +批量选中文件使用 Table 组件的多选属性来实现。 + +在全部分类下,勾选文件左侧复选框,即可对文件批量进行删除操作,点击取消可撤回操作,如下: + +![图片描述](https://doc.shiyanlou.com/courses/8842/1557563/faf31ee9be2c5ab215b21a2c2ea5e9d5-0/wm) + +#### 批量移动文件 + +对话框内的目录树使用了 Element UI 的 [Tree 树形控件](https://element.eleme.cn/#/zh-CN/component/tree) 来展示。 + +在全部分类下,勾选文件左侧复选框,即可对文件进行批量移动操作,点击取消可撤回操作,如下: + +![图片描述](https://doc.shiyanlou.com/courses/8842/1557563/9566523e9399414ddac6f7992da5e943-0/wm) + +#### 批量下载文件 + +下载功能使用 ``标签来实现。 + +在全部分类下,勾选文件左侧复选框,即可对文件进行批量下载,如下: + +![图片描述](https://doc.shiyanlou.com/courses/8842/1557563/ee02d1a4a50ac165202a139aae0fda45-0/wm) + +#### 文件表格列显隐设置 + +对话框内使用 Element UI 的 [CheckBox 多选框](https://element.eleme.cn/#/zh-CN/component/checkbox) 来实现。 + +点击右上角设置显示列,可以对当前页面表格列显隐进行设置 + +![图片描述](https://doc.shiyanlou.com/courses/8842/1557563/8c68b83da61e370197703ee21b1243fd-0/wm) + +#### 单个文件操作 + +在全部分类下,点击表格操作列右侧 + - 按钮,可以切换操作样式,可以平铺或者下拉,每个文件右侧操作可单独对文件进行删除、移动、重命名、下载操作 + +![图片描述](https://doc.shiyanlou.com/courses/8842/1557563/6ae47541ec080b1842259d9af2e4ca41-0/wm) + +#### 图片分类下的功能介绍 + +分类切换使用 Element UI 的 [CheckBox 多选框](https://element.eleme.cn/#/zh-CN/component/checkbox) 来实现。 + +点击图片分类,右上角有三种可选的展示类型,分别为列表、网格、时间线 + +![图片描述](https://doc.shiyanlou.com/courses/8842/1557563/011b5f08f5e3dae2d585b6a6ef0eb562-0/wm) + +时间线展示,使用原生的 `
    ` 来实现。 + +![图片描述](https://doc.shiyanlou.com/courses/8842/1557563/472a5ce63aeba5614273dc6b9a3623f7-0/wm) + +点击图片即可预览,可切换上一张、下一张、旋转、缩放。缩放功能使用 Element UI 的 [Progress 进度条 ](https://element.eleme.cn/#/zh-CN/component/progress)来实现。 + +![图片描述](https://doc.shiyanlou.com/courses/8842/1557563/aff963f3f9ca1339892f414e9eeaf8c3-0/wm) + +### 项目版本规划 + +**版本迭代** + +| 版本号 | 修订时间 | 修订说明 | 修订人 | +| ------ | ---------- | --------------------------------------------------- | ------ | +| V1.0 | 2022-02-24 | 网盘系统全部功能实现 | MAC | +| V2.0 | 待定 | | | diff --git "a/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/03-466261-\346\216\245\345\217\243\346\226\207\346\241\243.wd.md" "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/03-466261-\346\216\245\345\217\243\346\226\207\346\241\243.wd.md" new file mode 100755 index 0000000..f4c431d --- /dev/null +++ "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/03-466261-\346\216\245\345\217\243\346\226\207\346\241\243.wd.md" @@ -0,0 +1,902 @@ +# 网盘项目 API + +**简介**:网盘项目 API + +**Version**:1.0 + +## 文件接口 + +### 批量删除文件 + +- 接口描述:批量删除文件 +- 数据格式:JSON +- 请求方式:GET +- 接口 URL :`http://localhost:8080/file/batchdeletefile` + +
    + 请求示例 + +```json +{ + "files": "[{\"userFileId\":1}, {\"userFileId\":2]" +} +``` + +
    + +请求数据说明: + +| 参数名称 | 参数说明 | 是否必须 | 数据类型 | +| ----------------- | -------- | -------- | -------- | +| files | 文件集合 | true | String | + +响应数据说明: + +| 参数名称 | 参数说明 | 类型 | +| -------- | -------------------------- | ------- | +| code | 返回码 | int | +| data | 响应数据 | String | +| message | 处理结果信息 | String | +| success | 是否成功标识(true/false) | boolean | + +
    + 响应示例 + +```json +{ + "code": 20000, + "data": "", + "message": "批量删除文件成功", + "success": true +} +``` + +
    + +### 批量移动文件 + +- 接口描述:可以同时选择移动多个文件或者目录 +- 数据格式:JSON +- 请求方式:POST +- 接口 URL :`http://localhost:8080/file/batchdeletefile` + +
    + 请求示例 + +```json +{ + "filePath": "测试1", + "files": "[{\"userFileId\":1}, {\"userFileId\":2]" +} +``` + +
    + +请求数据说明: + +| 参数名称 | 参数说明 | 是否必须 | 数据类型 | +| -------------------- | -------- | -------- | -------- | +| filePath | 文件路径 | true | String | +| files | 文件集合 | true | String | + + +响应数据说明: + +| 参数名称 | 参数说明 | 类型 | +| -------- | -------------------------- | ------- | +| code | 返回码 | int | +| data | 响应数据 | String | +| message | 处理结果信息 | String | +| success | 是否成功标识(true/false) | boolean | + +
    + 响应示例 + +```json +{ + "code": 20000, + "data": "", + "message": "批量移动文件成功", + "success": true +} +``` + +
    + +### 创建文件 + +- 接口描述:目录(文件夹)的创建 +- 数据格式:JSON +- 请求方式:POST +- 接口 URL :`http://localhost:8080/file/createfile` + +
    + 请求示例 + +```json +{ + "fileName": "测试创建", + "filePath": "/" +} +``` + +
    + +请求数据说明: + +| 参数名称 | 参数说明 | 是否必须 | 数据类型 | +| -------------------- | -------- | -------- | -------- | +| fileName | 文件名 | true | String | +| filePath | 文件路径 | true | String | + +响应数据说明: + +| 参数名称 | 参数说明 | 类型 | +| -------- | -------------------------- | ------- | +| code | 返回码 | int | +| data | 响应数据 | String | +| message | 处理结果信息 | String | +| success | 是否成功标识(true/false) | boolean | + +
    + 响应示例 + +```json +{ + "code": 20000, + "data": "", + "message": "创建文件成功", + "success": true +} +``` + +
    + +### 删除文件 + +- 接口描述:可以删除文件或者目录 +- 数据格式:JSON +- 请求方式:POST +- 接口 URL :`http://localhost:8080/file/deletefile` + +
    + 请求示例 + +```json +{ + "fileName": "测试3", + "filePath": "/", + "isDir": 1, + "userFileId": 3 +} +``` + +
    + +请求数据说明: + +| 参数名称 | 参数说明 | 是否必须 | 数据类型 | +| ---------------------- | ----------- | -------- | -------- | +| fileName | 文件名 | true | String | +| filePath | 文件路径 | true | String | +| isDir | 是否是目录 | true | int | +| userFileId | 用户文件 id | true | int | + +响应数据说明: + +| 参数名称 | 参数说明 | 类型 | +| -------- | -------------------------- | ------- | +| code | 返回码 | int | +| data | 响应数据 | object | +| message | 处理结果信息 | String | +| success | 是否成功标识(true/false) | boolean | + +
    + 响应示例 + +```json +{ + "code": 20000, + "data": "", + "message": "删除文件成功", + "success": true +} +``` + +
    + +### 获取文件列表 + +- 接口描述:用来做前台文件列表展示 +- 数据格式:JSON +- 请求方式:GET +- 接口 URL :`http://localhost:8080/file/getfilelist` + +请求数据说明: + +| 参数名称 | 参数说明 | 是否必须 | 数据类型 | +| ----------- | -------- | -------- | -------- | +| currentPage | 当前页 | true | int | +| filePath | 文件路径 | true | String | +| pageCount | 每页数量 | true | int | + +响应数据说明: + +| 参数名称 | 参数说明 | 类型 | +| ------------------------- | -------------------------- | -------------- | +| code | 返回码 | int | +| data | 响应数据 | UserfileListVO | +| extendName | 扩展名 | String | +| fileId | 文件 id | int | +| fileName | 文件名 | String | +| filePath | 文件路径 | String | +| fileSize | 文件大小 | int | +| fileUrl | 文件 url | String | +| identifier | md5 | String | +| isDir | 是否是目录 | int | +| isOSS | 是否是 oss 存储 | int | +| pointCount | 引用数量 | int | +| timeStampName | 时间戳名称 | String | +| uploadTime | 上传时间 | String | +| userFileId | 用户文件 id | int | +| userId | 用户 id | int | +| message | 处理结果信息 | String | +| success | 是否成功标识(true/false) | boolean | + +
    + 响应示例 + +```json +{ + "success": true, + "code": 20000, + "message": "成功", + "data": { + "total": 4, + "list": [ + { + "fileId": null, + "timeStampName": null, + "fileUrl": null, + "fileSize": null, + "isOSS": null, + "pointCount": null, + "identifier": null, + "userFileId": 1, + "userId": 1, + "fileName": "测试1", + "filePath": "/", + "extendName": null, + "isDir": 1, + "uploadTime": "2022-02-28 11:22:29" + }, + { + "fileId": null, + "timeStampName": null, + "fileUrl": null, + "fileSize": null, + "isOSS": null, + "pointCount": null, + "identifier": null, + "userFileId": 2, + "userId": 1, + "fileName": "测试2", + "filePath": "/", + "extendName": null, + "isDir": 1, + "uploadTime": "2022-02-28 11:22:38" + }, + { + "fileId": null, + "timeStampName": null, + "fileUrl": null, + "fileSize": null, + "isOSS": null, + "pointCount": null, + "identifier": null, + "userFileId": 4, + "userId": 1, + "fileName": "测试上传", + "filePath": "/", + "extendName": null, + "isDir": 1, + "uploadTime": "2022-02-28 11:37:45" + }, + { + "fileId": null, + "timeStampName": null, + "fileUrl": null, + "fileSize": null, + "isOSS": null, + "pointCount": null, + "identifier": null, + "userFileId": 8, + "userId": 1, + "fileName": "测试创建", + "filePath": "/", + "extendName": null, + "isDir": 1, + "uploadTime": "2022-02-28 11:43:10" + } + ] + } +} +``` + +
    + +### 获取文件树 + +- 接口描述:文件移动的时候需要用到该接口,用来展示目录树 +- 数据格式:JSON +- 请求方式:GET +- 接口 URL :`http://localhost:8080/file/getfiletree` + +请求数据说明: + +无 + +响应数据说明: + +| 参数名称 | 参数说明 | 类型 | +| ---------------------- | -------------------------- | ---------- | +| code | 返回码 | int | +| data | 响应数据 | TreeNodeVO | +| attributes | 属性集合 | object | +| children | 子节点列表 | array | +| depth | 深度 | int | +| id | 节点 id | int | +| label | 节点名 | String | +| state | 是否被关闭 | String | +| message | 处理结果信息 | String | +| success | 是否成功标识(true/false) | boolean | + +
    + 响应示例 + +```json +{ + "success": true, + "code": null, + "message": null, + "data": { + "id": null, + "label": "/", + "depth": null, + "state": "closed", + "attributes": { }, + "children": [ + { + "id": null, + "label": "测试1", + "depth": null, + "state": "closed", + "attributes": { + "filePath": "/测试1/" + }, + "children": [ + { + "id": null, + "label": "测试2", + "depth": null, + "state": "closed", + "attributes": { + "filePath": "/测试1/测试2/" + }, + "children": [ ] + } + ] + }, + { + "id": null, + "label": "测试3", + "depth": null, + "state": "closed", + "attributes": { + "filePath": "/测试3/" + }, + "children": [ ] + }, + { + "id": null, + "label": "测试上传", + "depth": null, + "state": "closed", + "attributes": { + "filePath": "/测试上传/" + }, + "children": [ ] + }, + { + "id": null, + "label": "测试创建", + "depth": null, + "state": "closed", + "attributes": { + "filePath": "/测试创建/" + }, + "children": [ ] + } + ] + } +} +``` + +
    + +### 文件移动 + +- 接口描述:可以移动文件或者目录 +- 数据格式:JSON +- 请求方式:POST +- 接口 URL :`http://localhost:8080/file/movefile` + +
    + 请求示例 + +```json +{ + "extendName": null, + "fileName": "测试2", + "filePath": "/", + "oldFilePath": "/测试1/" +} +``` + +
    + +请求数据说明: + +| 参数名称 | 参数说明 | 是否必须 | 数据类型 | +| ----------------------- | -------- | -------- | -------- | +| extendName | 扩展名 | true | String | +| fileName | 文件名 | true | String | +| filePath | 文件路径 | true | String | +| oldFilePath | 旧文件名 | true | String | + +响应数据说明: + +| 参数名称 | 参数说明 | 类型 | +| -------- | -------------------------- | ------- | +| code | 返回码 | int | +| data | 响应数据 | String | +| message | 处理结果信息 | String | +| success | 是否成功标识(true/false) | boolean | + +
    + 响应示例 + +```json +{ + "code": 20000, + "data": "", + "message": "文件移动成功", + "success": true +} +``` + +
    + +### 文件重命名 + +- 接口描述:文件重命名 +- 数据格式:JSON +- 请求方式:POST +- 接口 URL :`http://localhost:8080/file/renamefile` + +
    + 请求示例 + +```json +{ + "fileName": "222333", + "userFileId": 7 +} +``` + +
    + +请求数据说明: + +| 参数名称 | 参数说明 | 是否必须 | 数据类型 | +| ---------------------- | ----------- | -------- | -------- | +| fileName | 文件名 | true | String | +| userFileId | 用户文件 id | true | int | + +响应数据说明: + +| 参数名称 | 参数说明 | 类型 | +| -------- | -------------------------- | ------- | +| code | 返回码 | int | +| data | 响应数据 | String | +| message | 处理结果信息 | String | +| success | 是否成功标识(true/false) | boolean | + +
    + 响应示例 + +```json +{ + "code": 20000, + "data": "", + "message": "文件重命名成功", + "success": true +} +``` + +
    + +### 通过文件类型选择文件 + +- 接口描述:该接口可以实现文件格式分类查看 +- 数据格式:JSON +- 请求方式:GET +- 接口 URL :`http://localhost:8080/file/selectfilebyfiletype?fileType=1¤tPage=1&pageCount=20` + +请求数据说明: + +| 参数名称 | 参数说明 | 是否必须 | 数据类型 | +| ----------- | -------- | -------- | -------- | +| fileType | 文件类型 | true | int | +| currentPage | 当前页 | true | int | +| pageCount | 每页数量 | true | int | + +响应数据说明: + +| 参数名称 | 参数说明 | 类型 | +| -------- | -------------------------- | ------- | +| code | 返回码 | int | +| data | 响应数据 | array | +| message | 处理结果信息 | String | +| success | 是否成功标识(true/false) | boolean | + +
    + 响应示例 + +```json +{ + "success": true, + "code": 20000, + "message": "成功", + "data": { + "total": 2, + "list": [ + { + "fileId": 2, + "timeStampName": "4bcb0c0cae9725f377e92e28b281d31f", + "fileUrl": "/upload/20220228/4bcb0c0cae9725f377e92e28b281d31f.jpg", + "fileSize": 298888, + "isOSS": null, + "pointCount": 1, + "identifier": "4bcb0c0cae9725f377e92e28b281d31f", + "userFileId": 6, + "userId": 1, + "fileName": "11111111", + "filePath": "/测试1/", + "extendName": "jpg", + "isDir": 0, + "uploadTime": "2022-02-28 11:38:33" + }, + { + "fileId": 3, + "timeStampName": "aea05c6b808f0501d3aca7dca6ea77c7", + "fileUrl": "/upload/20220228/aea05c6b808f0501d3aca7dca6ea77c7.jpg", + "fileSize": 292414, + "isOSS": null, + "pointCount": 1, + "identifier": "aea05c6b808f0501d3aca7dca6ea77c7", + "userFileId": 7, + "userId": 1, + "fileName": "222333", + "filePath": "/测试1/", + "extendName": "jpg", + "isDir": 0, + "uploadTime": "2022-02-28 11:55:19" + } + ] + } +} +``` + +
    + +## 文件传输接口 + +### 下载文件 + +- 接口描述:下载文件接口 +- 数据格式:JSON +- 请求方式:GET +- 接口 URL :`http://localhost:8080/filetransfer/downloadfile?userFileId=2` + +请求数据说明: + +| 参数名称 | 参数说明 | 请求类型 | 是否必须 | 数据类型 | +| ---------- | ----------- | -------- | -------- | -------- | +| userFileId | 用户文件 id | query | true | int | + +响应数据说明: + +无 + +### 获取存储信息 + +- 接口描述:获取存储信息 +- 数据格式:JSON +- 请求方式:GET +- 接口 URL :`http://localhost:8080/filetransfer/getstorage?token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOi` + +请求数据说明: + +无 + +响应数据说明: + +| 参数名称 | 参数说明 | 类型 | +| -------- | -------------------------- | ------- | +| code | 返回码 | int | +| data | 响应数据 | int | +| message | 处理结果信息 | String | +| success | 是否成功标识(true/false) | boolean | + +
    + 响应示例 + +```json +{ + "code": 20000, + "data": 591419, + "message": "成功", + "success": true +} +``` + +
    + +### 极速上传 + +- 接口描述:校验文件 MD5 判断文件是否存在,如果存在直接上传成功并返回 skipUpload=true,如果不存在返回 skipUpload=false 需要再次调用该接口的 POST 方法 +- 数据格式:JSON +- 请求方式:GET +- 接口 URL :`http://localhost:8080/filetransfer/uploadfile?chunkNumber=1&chunkSize=1048576¤tChunkSize=1048576&totalSize=15152866&identifier=254bddb058fe39f6a5bebc6ffb5591e9&filename=Redis-x64-5.0.9.zip&relativePath=Redis-x64-5.0.9.zip&totalChunks=14&filePath=%2F&isDir=0` + +请求数据说明: + +| 参数名称 | 参数说明 | 是否必须 | 数据类型 | +| ---------------- | ------------ | -------- | -------- | +| chunkNumber | 分片数 | true | int | +| chunkSize | 分片大小 | true | int | +| currentChunkSize | 当前分片大小 | true | int | +| extendName | 扩展名 | true | String | +| filePath | 文件路径 | true | String | +| fileSize | 文件大小 | true | int | +| filename | 文件名 | true | String | +| identifier | md5 | true | String | +| totalChunks | 总分片数 | true | int | +| totalSize | 总大小 | true | int | +| uploadTime | 上传时间 | true | String | + +响应数据说明: + +| 参数名称 | 参数说明 | 类型 | +| ------------------------- | -------------------------- | ------------ | +| code | 返回码 | int | +| data | 响应数据 | UploadFileVo | +| needMerge | 是否需要合并分片 | boolean | +| skipUpload | 跳过上传 | boolean | +| timeStampName | 时间戳 | String | +| uploaded | 已经上传的分片 | array | +| message | 处理结果信息 | String | +| success | 是否成功标识(true/false) | boolean | + +
    + 响应示例 + +```json +{ + "code": 0, + "data": { + "needMerge": true, + "skipUpload": true, + "timeStampName": "123123123123", + "uploaded": "[1,2,3]" + }, + "message": "", + "success": true +} +``` + +
    + +### 上传文件 + +- 接口描述:真正的上传文件接口 +- 数据格式:JSON +- 请求方式:POST +- 接口 URL :`http://localhost:8080/filetransfer/uploadfile` + +请求数据说明: + +| 参数名称 | 参数说明 | 是否必须 | 数据类型 | +| ---------------- | ------------ | -------- | -------- | +| chunkNumber | 分片数量 | true | int | +| chunkSize | 分片大小 | true | int | +| currentChunkSize | 当前分片大小 | true | int | +| extendName | 扩展名 | true | String | +| filePath | 文件路径 | true | String | +| fileSize | 文件大小 | true | int | +| filename | 文件名 | true | String | +| identifier | md5 码 | true | String | +| totalChunks | 总分片数 | true | int | +| totalSize | 总大小 | true | int | +| uploadTime | 上传时间 | true | String | + +响应数据说明: + +| 参数名称 | 参数说明 | 类型 | schema | +| ------------------------- | -------------------------- | ------------ | ------------ | +| code | 返回码 | int | int | +| data | 响应数据 | UploadFileVo | UploadFileVo | +| needMerge | 是否需要合并分片 | boolean | | +| skipUpload | 跳过上传 | boolean | | +| timeStampName | 时间戳 | String | | +| uploaded | 已经上传的分片 | array | integer | +| message | 处理结果信息 | String | | +| success | 是否成功标识(true/false) | boolean | | + +
    + 响应示例 + +```json +{ + "success": true, + "code": 20000, + "message": "成功", + "data": { + "timeStampName": null, + "skipUpload": false, + "needMerge": false, + "uploaded": null + } +} +``` + +
    + +## 用户接口 + +### 检查用户登录信息 + +- 接口描述:验证 token 的有效性 +- 数据格式:JSON +- 请求方式:GET +- 接口 URL :`http://localhost:8080/user/checkuserlogininfo` + +请求数据说明: + +无 + +响应数据说明: + +| 参数名称 | 参数说明 | 类型 | +| -------------------- | -------------------------- | ------- | +| code | 返回码 | int | +| data | 响应数据 | User | +| userId | 用户 id | int | +| username | 用户名 | String | +| message | 处理结果信息 | String | +| success | 是否成功标识(true/false) | boolean | + +
    + 响应示例 + +```json +{ + "success": true, + "code": 20000, + "message": "成功", + "data": { + "userId": 1, + "username": "test", + "telephone": "test123", + "registerTime": "2022-02-28 11:22:20" + } +} +``` + +
    + +### 用户登录 + +- 接口描述:用户登录认证后才能进入系统 +- 数据格式:JSON +- 请求方式:GET +- 接口 URL :`http://localhost:8080/user/login` + +请求数据说明: + +| 参数名称 | 参数说明 | 是否必须 | 数据类型 | +| --------- | -------- | -------- | -------- | +| telephone | 手机号 | true | String | +| password | 密码 | true | String | + +响应数据说明: + +| 参数名称 | 参数说明 | 类型 | +| -------------------- | -------------------------- | ------- | +| code | 返回码 | int | +| data | 响应数据 | LoginVO | +| token | token | String | +| username | 用户名 | String | +| message | 处理结果信息 | String | +| success | 是否成功标识(true/false) | boolean | + +
    + 响应示例 + +```json +{ + "code": 0, + "data": { + "userId": 1, + "token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ7XCJ1c2VySWRcIjoxLFwidXNlcm5hbWVcIjpcInRlc3RcIixcInBhc3N3b3JkXCI6XCJcIixcInRlbGVwaG9uZVwiOlwidGVzdDEyM1wiLFwic2FsdFwiOlwiXCIsXCJyZWdpc3Rl", + "username": "test123" + }, + "message": "", + "success": true +} +``` + +
    + +### 用户注册 + +- 接口描述:注册账号 +- 数据格式:JSON +- 请求方式:POST +- 接口 URL :`http://localhost:8080/user/register` + +
    + 响应示例 + +```json +{ + "password": "test123", + "telephone": "11122223333", + "username": "test" +} +``` + +
    + +请求数据说明: + +| 参数名称 | 参数说明 | 是否必须 | 数据类型 | +| --------------------- | -------- | -------- | -------- | +| password | 密码 | true | String | +| telephone | 手机号 | true | String | +| username | 用户名 | true | String | + +响应数据说明: + +| 参数名称 | 参数说明 | 类型 | +| -------- | -------------------------- | ------- | +| code | 返回码 | int | +| data | 响应数据 | String | +| message | 处理结果信息 | String | +| success | 是否成功标识(true/false) | boolean | + +
    + 响应示例 + +```json +{ + "code": 20000, + "data": "", + "message": "注册成功", + "success": true +} +``` + +
    + diff --git "a/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/04-466262-Spring Boot\351\241\271\347\233\256\346\220\255\345\273\272.sy.md" "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/04-466262-Spring Boot\351\241\271\347\233\256\346\220\255\345\273\272.sy.md" new file mode 100755 index 0000000..30f1d41 --- /dev/null +++ "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/04-466262-Spring Boot\351\241\271\347\233\256\346\220\255\345\273\272.sy.md" @@ -0,0 +1,344 @@ +--- +show: step +version: 1.0 +enable_checker: true +--- + +# Spring Boot 项目的搭建 + +## 实验介绍 + +本实验主要是介绍 Spring Boot 的一些基础知识和搭建流程,并集成常用的 MySQL 和日志,在项目搭建的过程中,我会尽可能的给大家讲清楚每个步骤、每行代码的基本含义。如果你对 Spring Boot 项目已经有一定的了解,可以在学习的过程中适当跳过一些简单的步骤。 + +#### 知识点 + +- 项目介绍 +- Spring Boot 基本概念介绍 +- 开发环境搭建 +- 项目搭建流程 +- 项目的启动和停止 +- 集成日志和数据库 + +#### 开发计划 + +- 开发内容:本次实验带领大家从零开始,完成 Spring Boot 项目的搭建,并完成 MySQL 和日志的集成。 +- 开发耗时:实验预计完成时间为 1~2 小时 +- 开发难点:无 + +## 项目介绍 + +网盘项目一直以来都是一个比较热门的话题,可能每个人都希望能够拥有自己的网盘,那么本实验课程,就手把手教大家如何搭建一个网盘项目,另外如果你想要快速提高自己的开发水平,那么这个项目就非常适合你。 + +本课程我们将使用 Spring Boot 和 Vue 这两个热门开源框架来进行开发。在学习完本次课程之后,你不仅能够达到熟练使用 Spring Boot 和 Vue ,而且将会具备独立开发一个完整项目的能力,包括前端、后台、运维、数据库建模、系统设计等。因此,项目讲解最终目的并不是简单的教大家完成如何开发网盘,而是要教会大家学会如何思考问题,如何设计才能提高系统的可扩展性,面对一个新需求如何进行分析、建模。往往一个好的底层设计,不管需求如何改变,都是能满足系统要求,而一旦设计出了问题,那么随着后面需求复杂度的提升,其结果无外乎是代码冗余越来越多,最终推翻重来。 + +项目的开发模式为前后端分离模式,前后台分离的好处就是我们可以对前后台独立开发和部署,这种模式是以后发展的趋势,在前面实验课程主要以 Spring Boot 为重点展开讲解,后面实验课程教大家用 Vue-Cli3 来制作前台页面并请求后台接口。接下来我们就会从最基本的概念讲起,教大家一步一步的去完成项目的开发。 + +## Spring Boot 基本概念介绍 + +Spring Boot 是由 Pivotal 团队提供的全新框架,其设计目的是用来简化新 Spring 应用的初始搭建以及开发过程。该框架使用了特定的方式来进行配置,从而使开发人员不再需要定义样板化的配置。也就是说 Spring Boot 集成了很多第三方库,并且都给了一套默认的配置,这样我们在使用 Spring Boot 时仅仅只需要很少的配置即可。Spring Boot 的迭代升级是很快的,到目前为止最新的发布版本是 2.4.1,本课程也将会基于 2.4.1 版本展开讲解。 + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/c4ed98dcf8e2b87e878d0c6752f46e9a-0/wm) + +#### SpringBoot 所具备的特征有: + +1. 可以创建独立的 Spring 应用程序,并且基于其 Maven 或 Gradle 插件,可以创建可执行的 JAR 和 WAR。 +2. 内嵌 Tomcat 或 Jetty 等 Servlet 容器。 +3. 提供自动配置的“starter”项目对象模型(POMS)以简化 Maven 配置。 +4. 尽可能自动配置 Spring 容器。 +5. 提供准备好的特性,如指标、健康检查和外部化配置。 +6. 不需要 XML 配置。 + +## Spring Boot 项目搭建 + +为了快速开始,我们直接使用课程开发环境,所需要的软件在环境里面已经默认为我们安装好了,可以通过以下命令来检测环境: + +#### JDK 环境检测 + +使用 `java -version` 命令可以查看当前环境是否安装 JDK,如果已经安装 JDK,那么使用该命令可以显示 JDK 版本,如下图所示: + +![图片描述](https://doc.shiyanlou.com/courses/3472/600404/22d5838d13914bc778319d76667fe78d-0/wm) + +从上图可以看出,环境所使用的 JDK 版本是 11.0.9.1。 + +#### maven 环境检测 + +maven 是一个项目构建和管理的工具,使用 `mvn -version` 命令可以检查当前环境是否安装,如下图所示: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/1f14c7f0d5194c383e584a89deb91deb-0/wm) + +从上图可以看出,当前 maven 工具版本是 3.6.3,因为 maven 工具在编译的过程中是需要用到 JDK 的,所以这里也给出了 Java 版本相关信息。 + +#### MySQL 环境检测 + +使用 `sudo service mysql start` 命令启动 MySQL,启动成功之后可以使用 `mysql -u root` 命令进行连接,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/600404/4d6723a10d25f709fec50d9b0ba05e6f-0/wm) + +从上图连接信息可以看出,环境使用的是 MariaDB 数据库,MariaDB 是 MySQL 的一个分支,语法、API 和命令行完全兼容 MySQL,它们的区别就是 MariaDB 是由开源社区在维护。熟悉完环境之后,下面开始演示具体项目的创建。 + +### 创建项目 + +一般来说,我们创建 Spring Boot 项目有好几种可选的方式,如下: + +1. 通过 [官网](http://start.spring.io/) 提供的 Spring initializr 去创建。 +2. 借助 IDEA 开发工具提供的 Spring initializr 进行创建,创建流程与第一种类似。 +3. 创建一个普通的 maven 工程,然后自己手动引入 Spring Boot 的依赖包。 + +在本实验中,为了便于讲解,我们选择第三种,通过手动方式一步一步搭建一个网盘项目。 + +打开环境,会看到一个编辑器界面,左侧 `PROJECT` 是环境所在的目录,我们可以直接在该目录下开始搭建网盘项目,接下来我将通过两种方式创建项目,大家可以根据喜好来选择其中一种来开始网盘项目搭建。 + +#### 第一种方式 + +在 `PROJECT` 目录下创建一个新的目录 `qiwen-file`,创建完目录,敲击回车,目录就创建好了,可以将该目录作为我们项目的根目录,开始创建 Spring Boot 项目。 + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/7b2b6143bec0c3876cd6adb66e0f3160-0/wm) + +因为我们所使用的 Spring Boot 项目是基于 maven 构建的,因此第一步需要创建一个 maven 工程,maven 工程标准目录结构如下: + +```txt +qiwen-file + |---src + | |---main + | | |---java + | | |---resources + | | + | |---test + | |---java + | |---resources + | + |---target + |---pom.xml +``` + +手动创建上面的 maven 目录结构,创建完成的效果图如下: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/b0be587145ad5912765ab6f12dc8ed5c-0/wm) + +#### 第二种方式 + +如果觉得第一种方式手动创建这么多目录过于麻烦,可以在命令行输入 maven 命令自动生成,执行命令后,maven 就会帮我们创建好一个 maven 工程,代码如下: + +```bash +mvn archetype:generate -DgroupId=com.shiyanlou.file -DartifactId=qiwen-file -DarchetypeArtifactId=maven-archetype-quickstart -DarchetypeCatalog=local -DinteractiveMode=false +``` + +执行完上述命令后,一个普通的 maven 工程就创建好了,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/9a8643a96e9bdbed8f0047f16ba7676f-0/wm) + +从上图可以看出,maven 为我们默认生成了 `App.java` 类和 `AppTest.java` 类,其中 `App.java` 是一个普通的 Java 类,里面仅包含一个 main 方法,`AppTest.java` 是一个 Java 测试类,它的运行依赖于 junit 包,而这个 junit 包的依赖 maven 已经为我们生成在 pom.xml 中,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/02734685f694959b5b3e847644245c21-0/wm) + +由于我们现在要搭建的是一个 Spring Boot 项目,而非一个普通的 maven 工程,这种直接引入 junit 包的方式并不适合,Spring Boot 已经为我们管理了这些包的依赖,所以这里将 `App.java` 和 `AppTest.java` 这两个文件删除。 + +另外,我们发现目录结构中缺失了 `resources` 目录,按照上面的目录结构规划我们需要在 `/src/main/` 和 `/src/test/` 目录下创建 `resources` 目录,`resources` 目录在 Spring Boot 中是用来存放配置文件和静态资源的,然后一个 Spring Boot 的目录结构的创建就完成了。 + +> 注意 Spring Boot 目录下存放静态资源的目录是 `resources` 而不是 `resource`,检查一下是否拼写正确,避免报错。 + +### 修改 pom.xml + +在项目的根路径下有一个名为 `pom.xml` 的文件,pom 文件是 maven 构建的根本,它约定了项目构建所需要的相关信息和依赖,打开 `pom.xml` 文件,初始化如下内容: + +```xml + + + 4.0.0 + + com.shiyanlou.file + qiwen-file + 1.0.0-SNAPSHOT + jar + + +``` + +上面的 xml 文件定义了项目的最基本的信息,此时一个最简单 maven 工程就已经搭建完毕。我们可以试着执行一下 maven 构建,看是否能够成功。在命令行界面,通过 cd 命令切换目录到项目根路径,命令如下: + +```bash +cd qiwen-file +``` + +在命令行目录下,输入 `mvn install` 尝试构建,如果构建成功,会出现 `BUILD SUCCESS` 字样,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/600404/4cafbdd3d7b0eedcb4838c9e058830c4-0/wm) + +## 添加 Spring Boot 依赖 + +#### 增加父 pom 依赖 + +在 `pom.xml` 中的 `project` 节点下,增加如下依赖。 + +```xml + + org.springframework.boot + spring-boot-starter-parent + 2.4.1 + +``` + +`spring-boot-starter-parent` 是 Spring Boot 项目的父 pom,它里面定义了 Spring Boot 所使用的依赖库版本等信息,但它本身并没有任何依赖。 + +#### 项目依赖 + +在 `pom.xml` 中的 `project` 节点下,继续增加如下依赖。 + +```xml + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-test + test + + +``` + +上面这段 xml 脚本增加了两个依赖,其中: + +`spring-boot-starter-web` 依赖项是 Spring Boot 的核心,引入该依赖之后 Spring Boot 将会使用 SpringMVC 构建一个 Web 应用程序,且使用 Tomcat 作为默认的嵌入式容器。 + +`spring-boot-starter-test` 包含了很多测试依赖项的类库,比如 JUnit Jupiter,Hamcrest 和 Mockito,引入这个依赖之后就可以直接进行单元测试类的编写。 + +上面这两个依赖项是 Spring Boot 最基本的依赖,可以看到在引用的时候并没有版本号定义,其实 Spring Boot 的父 pom 的三方件管理库已经为我们定义好了,这里我们只需要直接使用即可。一般来说,我们在构建项目的时候,也应该这样做,将所有引用的第三方 jar 版本统一定义到最上层的父 pom,做到统一管理和配置,比如 Spring Boot 这种,我们在后期升级 Spring Boot 版本时,只需要更改 parent 这一处引入版本,整个项目的所有引用依赖都会被升级 。 + +## 创建 Spring Boot 启动类 + +#### 创建包 + +在 `src/main/java` 下,创建一个包,一般是根据项目命名,这里我创建一个名为 `com.shiyanlou.file` 的包,以后所有的代码就都放到这个包下面。 + +#### 创建启动类 + +在 `com.shiyanlou.file` 包下创建一个名为 Application 的 Java 类,创建完成的示例图如下: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/503ae3588db55a9db0ab0d01de8572ef-0/wm) + +#### 编写启动类内容 + +在 `Application.java` 的类中添加内容如下: + +```java +package com.shiyanlou.file; + +import org.springframework.boot.*; +import org.springframework.boot.autoconfigure.*; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + +} + +``` + +- `@SpringBootApplication` 是 Spring Boot 的核心注解,顾名思义,它用来声明该项目是一个 Spring Boot 应用。 +- `main` 方法,Java 应用程序一般使用 main 方法来作为整个程序的入口,Spring Boot 也遵循了这一标准,我们的主方法通过调用 run 来委托 Spring Boot 的 SpringApplication 类。SpringApplication 引导我们的应用程序,启动 Spring,而 Spring 又启动自动配置的 Tomcat web 服务器。我们需要传递 `Application.class` 作为 run 方法的参数来告诉 SpringApplication 哪个是主要的 Spring 组件。args 数组也可以接收任何命令行参数。 + +## 项目启动和停止 + +#### 启动项目 + +在命令行执行如下命令就可以启动项目: + +```bash +mvn spring-boot:run +``` + +> 也可以直接运行 `Application.java` 类来启动 Spring Boot 项目。 + +#### 访问项目 + +点击右侧 `Web 服务` 按钮,在浏览器出现如下内容,就说明项目启动成功: + +![图片描述](https://doc.shiyanlou.com/courses/3472/600404/c9f6ce461201925f1bc1ba8b8bf78bd4-0/wm) + +> 这里需要说明的是,项目在启动的时候是没有指定端口号的,所以 Spring Boot 会默认使用 `8080` 端口启动服务,而 Web 服务也指向的是 `8080` 端口,因此这里没有配置就可以直接进行访问了。 + +#### 停止项目 + +在命令行按下 `Ctrl + c` 停止项目,到此为止,一个最简单的 Spring Boot 项目就已经开发完成了。 + +## 配置数据库连接 + +#### 添加依赖 + +打开 `pom.xml` 文件,在 `dependencies` 节点下添加如下依赖: + +```xml + + org.springframework.boot + spring-boot-starter-data-jdbc + + + mysql + mysql-connector-java + runtime + +``` + +### 添加配置 + +在 `src/main/resources` 目录下创建 `application.properties` 文件,该文件是 Spring Boot 默认读取的配置文件,创建完成之后打开 `application.properties` 文件,添加 MySQL 连接的相关配置,具体代码如下: + +```properties +spring.datasource.url=jdbc:mysql://localhost:3306/file +spring.datasource.username=root +spring.datasource.password= +``` + +其中: + +- `spring.datasource.url` 配置了数据库连接的 url,Spring Boot 会根据它的属性值来判断数据源并进行连接; +- `spring.datasource.username` 指定数据库的用户名; +- `spring.datasource.password` 配置数据库的密码。 + +### 创建数据库 + +使用 `sudo mysql -u root` 连接数据库环境,会进入到数据库命令行模式,执行创建数据库脚本,命令如下: + +```sql +create database file default charset utf8 collate utf8_general_ci; +``` + +这样一个数据库就创建好了,可以使用 `show databases` 命令就可以看到创建的数据库了,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/d4d94bd4f4d3b6fdfbbdcabe538cdcda-0/wm) + +## 配置日志 + +因为 Spring Boot 内部已经集成了 Logback 日志模块,所以,我们只在 `application.properties` 配置文件中添加 log 日志的相关信息即可。 + +#### 添加配置 + +```properties +# 配置日志保存路径 +logging.file.name=/home/project/log/web.log +# 配置日志级别 +logging.level.root=info +``` + +为了便于查看日志,我们将日志路径设置到环境工程路径下:`/home/project`,在实际开发过程中,可以根据需要调整日志级别,配置完成之后,重新启动项目,就可以在对应目录下看到生成日志文件了,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/6f6651321979bfa684a038bd08797570-0/wm) + +在大型项目中,如果需要更加精细的控制每个包的日志级别,并且将不同级别的日志打印到不同的日志文件,可以使用 `logback.xml` 进行配置,这里就不过多介绍,感兴趣的可以自行学习。 + +## 实验总结 + +本实验向大家演示了 Spring Boot 搭建流程,希望大家能够在搭建的过程中,了解到它每一步的原理。这样在以后的开发过程中才能够得心应手。 + +本次实验完整代码可以通过如下命令进行下载: + +```bash +wget https://labfile.oss.aliyuncs.com/courses/8842/code1.zip +``` diff --git "a/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/05-466263-\346\225\260\346\215\256\345\272\223\350\256\276\350\256\241\346\265\201\347\250\213\345\217\212JPA\347\232\204\344\275\277\347\224\250.sy.md" "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/05-466263-\346\225\260\346\215\256\345\272\223\350\256\276\350\256\241\346\265\201\347\250\213\345\217\212JPA\347\232\204\344\275\277\347\224\250.sy.md" new file mode 100755 index 0000000..df27f23 --- /dev/null +++ "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/05-466263-\346\225\260\346\215\256\345\272\223\350\256\276\350\256\241\346\265\201\347\250\213\345\217\212JPA\347\232\204\344\275\277\347\224\250.sy.md" @@ -0,0 +1,350 @@ +--- +show: step +version: 1.0 +enable_checker: true +--- + +# 数据库设计流程及 JPA 的使用 + +## 实验介绍 + +这节实验主要讲解网盘项目需求分析过程,然后教大家使用 E-R 图来进行数据库的建模,并使用 JPA 技术创建数据库表。 + +#### 知识点 + +- 需求分析 +- 数据库建模 +- JPA 建表 + +#### 开发计划 + +- 开发内容:本次实验带领大家完成项目需求分析,数据建模及数据库表的创建。 +- 开发耗时:实验预计完成时间为 1~2 小时 +- 开发难点: +1. 通过需求分析独立完成模型数据从抽象到具体的转换过程 +2. 使用 JPA 技术完成数据库的建表。 + +## 需求分析 + +在数据库建模之前,首先要做的就是需求分析,目的是对将要设计的系统有一个足够的认识,接下来我将带领大家来分析一个网盘系统,并一步步去实现它。 + +#### 文件的概念 + +网盘系统主要是对文件进行管理,因此我们需要对文件有一个清晰的认识,我这里列出几个比较重要的点: + +1. 文件分为普通文件和目录文件。 +2. 普通文件是真实存在的,保存在磁盘空间的一个二进制文件,因此它具有真实的文件路径和大小。 +3. 目录文件是虚拟的,它存在的目的是对普通文件进行分类归档。 + +#### 文件的物理存储和逻辑存储 + +在计算机内部,由于文件都是以二进制的形式进行存储的,因此一个文件实际上就是一个二进制文件,占用一定的磁盘空间,这就是文件的**物理存储**。而作为一个网盘项目,我们在界面上展示的文件信息实际上只是在数据库存储的数据信息,包括文件路径,文件大小,文件名等,但是它会通过一个 url 字段指向服务器的一个具体文件,这就是**逻辑存储**。如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/d899bd3dd73c22d90495f2c19f84c67a-0/wm) + +## 数据库建模 + +数据库建模,就是对现实世界进行分析、抽象、并从中找出内在联系,进而确定数据库的结构,这一过程就称为数据库建模。它主要包括两部分内容: + +1. 确定最基本的数据结构 +2. 对约束建模。 + +这里我先给出一个简单的需求说明,然后根据这个需求,我们来一步步实现一个完整的数据库建模过程。 + +#### 需求说明 + +实现一个网盘项目,然后实现用户登录,登录用户可以对文件进行管理,其中包含以下功能: + +1. 文件的上传,删除,列表展示,修改名称 +2. 文件的移动,文件的复制 + +#### 根据需求说明提取实体和属性 + +从上面需求描述,我们需要从中提取出实体和属性,如下表: + +|实体|属性| +|-|-| +|文件|文件名、扩展名、大小、路径、...| +|用户|用户名、手机号、密码、年龄、...| + +上表只是给出了一个示例,你可以根据自己的理解和实际需要对属性进行扩展,当实体和属性提取出来之后,就可以对实体和属性,实体和实体之间的关系进行分析,这个分析过程需要用 E-R 图,下面借助 E-R 图来说明整个分析过程。 + +### E-R 图 + +E-R 图也称为实体-联系图(Entity Relationship Diagram),它提供了表示实体类型、属性和联系的方法,用来描述现实世界的概念模型。 + +在 E-R 图中,分别用矩形、菱形、椭圆形来表示不同的含义,如下表: + +|形状|含义| +|-|-| +|矩形|实体| +|菱形|实体之间的联系| +|椭圆形|实体或联系的属性| + +了解了 E-R 图的规则之后,现在我们将上面的文件和用户这两个实体 E-R 图表示出来,如下图: + +- 文件 + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/a254eb024df03523608ac5da27ceaa1e-0/wm) + +- 用户 + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/03ac92c81e68e78ce68f9a0ce09a2f42-0/wm) + +### 文件与用户的关系 + +由于文件是需要用户去进行管理的,因此这里要清楚文件和用户之间的关系,是一对一、一对多、还是多对多,然后在 ER 图中将他们关联起来。 + +要搞清楚他们之间的关系,首先需要明确下面两个问题: + +1. 一个用户能否拥有多个文件 +2. 一个文件是否可以被多个用户所拥有 + +作为一个网盘系统,一个用户肯定是能够拥有多个文件,主要关键在于一个文件是否可以被多个用户所拥有,由于后面我们要实现极速秒传的功能,那么这里就会涉及到,一个文件被多个用户所拥有。 + +从以上两个问题的分析结果,可以得出用户和文件之间是多对多的关系,因此在 ER 图中,我们可以将文件和用户关联起来,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/e96a636ce60ee5c8b06cc7131985a754-0/wm) + +从上图可以看出,两个实体之间进行关联需要用到菱形,在菱形的两边用 M 进行标识,表示两个实体类之间是多对多的关系。 + +### 将多对多联系转为一对多联系模型 + +在数据库设计中,如果两个实体之间是多对多的关系,那么就需要一张中间表进行关联,从而将多对多联系转为一对多联系模型。这个操作是关键点,也是难点,因为之前的两个实体都是直观的,现在就需要抽象出来一个新的实体。 + +我们将这种中间表起名为用户文件表,它存在的意义就是将文件表和用户表关联起来,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/87d4eed2c6e530a118aaa9de9deb2f49-0/wm) + +到此为止,整个数据库底层的关系模型就已经出来了,在此之前,我们所说的文件还是一个模糊的概念,而到了这一步,整个关系模型跟之前讲物理存储和逻辑存储的图正好能够对应,其中文件就是物理存储,它跟磁盘存储的文件是一一对应的,用户文件属于逻辑存储,用户在前台对文件进行移动复制等操作,其实只是做一些数据库的操作,但是指向文件的 url 没有变动,这就恰恰反向论证了整个设计思路是没有问题的。 + +根据上图,这里还需要做进一步的解释,我们发现,在整个 E-R 图的演进过程中,本来属于文件的属性,我却把它放到了用户文件这一层,比如文件名,扩展名,是否是目录,其原因是修改文件名和扩展名,是不会影响文件本身的二进制内容,你可以不妨一试,因此我将它放到逻辑存储的用户文件属性中了。另外我们在文件磁盘存储的角度是不存在目录这个概念的,它只是我们在管理层面抽象出来的,因此它也需要提升到用户文件这个实体类中。 + +## JPA 建表 + +JPA(Java Persistence API),中文名 Java 持久层 API,是 Java 持久化规范,简单的理解就是它为 Java 开发者提供了一个对象映射工具,可以使用这个工具,建立一个 Java 数据模型与数据库表结构之间的一种关系,并将 Java 的数据模型映射到数据库并建立数据库表结构,当然它还有众多的优点,在这里我们重点介绍它的使用方法。 + +#### 引入依赖 + +向 `pom.xml` 文件中补充如下依赖: + +```xml + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.projectlombok + lombok + true + +``` + +上面这段代码引入了两个依赖: + +第一个依赖就是 jpa 的依赖包,第二个依赖是 Lombok 依赖,Lombok 提供了一组有用的注解,用来消除 Java 类中的大量样板代码。仅五个字符(@Data)就可以替换数百行代码从而产生干净,简洁且易于维护的 Java 类。 + +#### 添加配置文件 + +打开 `application.proterties` 文件,添加如下内容: + +```properties +#jpa 配置 +spring.jpa.properties.hibernate.hbm2ddl.auto=update +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect +spring.jpa.show-sql=true +spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl +``` + +需要注意的是最后一行配置,用来定义命名策略,如果不设置,会使用默认的命名策略,比如建表时,会将有驼峰命名的转换为全小写,并用横线分割如:实体类名:userCommon,映射到表名就是 user-common。 + +### 创建实体类 + +打开项目,在 `com.shiyanlou.file` 包下创建一个 `model` 的包,这个包就用来存放我们的实体类对象。 + +在这个包下,我们需要创建三个 Java 类,用来存放之前设计的实体。接下来我们首先创建一张用户的实体类,并添加 jpa 相关的注解。 + +#### 用户实体类 + +新建 `User.java` 文件,并向其中写入如下代码: + +```java +package com.shiyanlou.file.model; + +import lombok.Data; + +import javax.persistence.*; + +@Data +@Table(name = "user") +@Entity +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(columnDefinition = "bigint(20) comment '用户id'") + private Long userId; + + @Column(columnDefinition = "varchar(30) comment '用户名'") + private String username; + + @Column(columnDefinition = "varchar(35) comment '密码'") + private String password; + + @Column(columnDefinition = "varchar(15) comment '手机号码'") + private String telephone; + + @Column(columnDefinition = "varchar(20) comment '盐值'") + private String salt; + + @Column(columnDefinition = "varchar(30) comment '注册时间'") + private String registerTime; + +} +``` + +上面是一个用户实体类,里面的用户属性我只列出了登录所需的关键属性,你可以根据自己的需要补充其他用户属性,比如年龄,性别,省市区等。上面涉及到的 jpa 注解,它的含义如下表: + +|注解名称|说明| +|-|-| +|@Entity|表明该类是一个实体类,添加了该注解后,才能被 jpa 扫描到| +|@Table|可以自定义表名| +|@Id|用来声明主键| +|@GeneratedValue|设置主键生成方式,主要有四种类型,这里我们将 strategy 属性设置为 GenerationType.IDENTITY,表明主键由数据库生成,为自动增长型| +|@Column|可以自定义列名或者定义其他的数据类型| + +#### 文件实体类 + +新建 `File.java` 文件,并向其中写入如下代码: + +```java +package com.shiyanlou.file.model; + +import lombok.Data; + +import javax.persistence.*; + +@Data +@Table(name = "file") +@Entity +public class File { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(columnDefinition="bigint(20) comment '文件id'") + private Long fileId; + + @Column(columnDefinition="varchar(500) comment '时间戳名称'") + private String timeStampName; + + @Column(columnDefinition="varchar(500) comment '文件url'") + private String fileUrl; + + @Column(columnDefinition="bigint(10) comment '文件大小'") + private Long fileSize; + +} + +``` + +#### 用户文件实体类 + +新建 `UserFile.java` 文件,并向其中写入如下代码: + +```java +package com.shiyanlou.file.model; + +import lombok.Data; + +import javax.persistence.*; + +@Data +@Table(name = "userfile", uniqueConstraints = { + @UniqueConstraint(name = "fileindex", columnNames = {"fileName", "filePath", "extendName"})}) +@Entity +public class UserFile { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(columnDefinition = "bigint(20) comment '用户文件id'") + private Long userFileId; + + @Column(columnDefinition = "bigint(20) comment '用户id'") + private Long userId; + + @Column(columnDefinition="bigint(20) comment '文件id'") + private Long fileId; + + @Column(columnDefinition="varchar(100) comment '文件名'") + private String fileName; + + @Column(columnDefinition="varchar(500) comment '文件路径'") + private String filePath; + + @Column(columnDefinition="varchar(100) comment '扩展名'") + private String extendName; + + @Column(columnDefinition="int(1) comment '是否是目录 0-否, 1-是'") + private Integer isDir; + + @Column(columnDefinition="varchar(25) comment '上传时间'") + private String uploadTime; + +} + +``` + +### 启动项目 + +这里需要注意的是,在启动项目的时候,数据库的创建工作必须提前完成,否则在启动的时候会报数据库连接失败,由于实验一我们已经完成了数据库的创建,这里我就不再重复演示了。 + +启动的具体命令如下所示: + +```bash +cd /home/project/qiwen-file +mvn spring-boot:run +``` + +启动之后,可以观察控制台的打印,启动成功之后,控制台会打印出相关的建表语句,此时我们可以进入数据库来查看创建的表,命令如下。 + +使用命令 `mysql -uroot` 连接数据库,并切换到 file 数据库: + +```sql +use file; +``` + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/f7a42f8d450e91112593a19d789640e3-0/wm) + +因为我们在项目中创建了三个实体类,那么项目启动之后就会通过 JPA 生成三张表,查看当前数据库中的表: + +```sql +show tables; +``` + +如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/327b324eff026c2c6637eb5a852ca0a0-0/wm) + +使用 `desc 表名` 命令查看表结构,如下图: + +```sql +desc file; +desc user; +desc userfile; +``` + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/ded03310e494c60cd990887555c7d674-0/wm) + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/a40bcdedf4c69e2a18c21f9f8135080d-0/wm) + +![图片描述](https://doc.shiyanlou.com/courses/3472/600404/8775a8cfdeb3726463060383e0221d8d-0/wm) + +## 实验总结 + +本节实验课程向大家讲述了网盘需求的分析和设计过程,在学习的过程中,重点要掌握整个操作流程,这样在遇到一个新的项目自己也可以去分析和建模。当然,需求的变化和修改是很常见的,作为本项目也是一样的,但是我们能做到的架构是完善的,底层是可靠的,这样遇到新需求才能保证它的可扩展性。 + +本次实验完整代码可以通过如下命令进行下载: + +```bash +wget https://labfile.oss.aliyuncs.com/courses/8842/code2.zip +``` diff --git "a/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/06-466264-Spring Boot\346\225\264\345\220\210MyBatis\345\222\214MyBatis-Plus.sy.md" "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/06-466264-Spring Boot\346\225\264\345\220\210MyBatis\345\222\214MyBatis-Plus.sy.md" new file mode 100755 index 0000000..db1e360 --- /dev/null +++ "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/06-466264-Spring Boot\346\225\264\345\220\210MyBatis\345\222\214MyBatis-Plus.sy.md" @@ -0,0 +1,405 @@ +--- +show: step +version: 1.0 +enable_checker: true +--- + +# Spring Boot 整合 MyBatis 和 MyBatis-Plus + +## 实验介绍 + +在此之前已经完成了 Spring Boot 项目搭建,并且实现了数据库、日志的集成,这节实验将带领大家在此基础之上进行 MyBatis 和 MyBatis-Plus 的集成。 + +#### 知识点 + +- MyBatis +- MyBatis-Plus +- JUnit 单元测试 + +#### 开发计划 + +- 开发内容:MyBatis 及 MyBatis-Plus的介绍、集成及简单使用。 +- 开发耗时:实验预计完成时间为 1~2 小时 +- 开发难点: +1. MyBatis 各种标签的熟练使用 +2. 集成 MyBatis-Plus 基本配置 +3. 熟练掌握 MyBatis 及 MyBatis-Plus 的使用,并提升开发效率。 + +## 基本概念 + +#### MyBatis 介绍 + +MyBatis 是一个 Dao 层框架,它支持普通 SQL 查询,并且支持存储过程和高级映射。MyBatis 消除了几乎所有的 JDBC 代码和参数的手工设置以及对结果集的检索封装。MyBatis 可以使用简单的 XML 或注解用于配置和原始映射,将接口和 Java 的 POJO(Plain Old Java Objects,普通的 Java 对象)映射成数据库中的记录。 + +#### MyBatis-Plus 介绍 + +MyBatis-Plus(简称 MP)是一个 MyBatis 的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。 + +#### JUnit 单元测试 + +JUnit 是一个 Java 编程语言的单元测试框架。它的存在使得我们能够针对性的对项目的关键代码进行测试,每个测试用例称为一个单元,通常我们用它来测试接口或者方法。 + +## Spring Boot 集成 MyBatis + +### 添加依赖 + +打开 `pom.xml` 文件,添加如下依赖: + +```xml + + org.mybatis.spring.boot + mybatis-spring-boot-starter + 2.1.1 + +``` + +### 添加配置 + +打开 `application.properties` 配置文件,添加下面配置,这两行用来指定 mybatis 的配置文件。 + +```properties +mybatis.config-location=classpath:mybatis/mybatis-config.xml +mybatis.mapper-locations=classpath:mybatis/mapper/*.xml +``` + +### 启动类添加 Mapper 扫描包路径 + +在 Spring Boot 启动类 `Application.java` 中添加 `@MapperScan` 注解,用来指定要扫描的 Mapper 文件夹: + +```java +package com.shiyanlou.file; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.*; +import org.springframework.boot.autoconfigure.*; +import org.springframework.web.bind.annotation.*; + +@MapperScan("com.shiyanlou.file.mapper") +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + +} +``` + +### 开发 Mapper 接口类 + +在上面 `@MapperScan` 注解扫描路径下,创建 Mapper 接口类,Mapper 接口主要是用来生产 Sql 调用接口,供上层调用,一般来说每个实体类都应该对应一个 Mapper 接口,因此下面我将会创建三个 Mapper 接口,并以 UserMapper 接口为例,给出一个使用案例。 + +在 `com.shiyanlou.file.mapper` 包下创建三个接口,分别为 `FileMapper`,`UserfileMapper` 和 `UserMapper`,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/00f96d0e9dbfab27060f996c4e251ce8-0/wm) + +#### FileMapper 接口 + +```java +package com.shiyanlou.file.mapper; + +public interface FileMapper { + +} +``` + +#### UserfileMapper 接口 + +```java +package com.shiyanlou.file.mapper; + +public interface UserfileMapper { + +} +``` + +#### UserMapper 接口 + +```java +package com.shiyanlou.file.mapper; + +import com.shiyanlou.file.model.User; +import java.util.List; + +public interface UserMapper { + void insertUser(User user); + List selectUser(); +} +``` + +上面创建的 UserMapper 接口中,新建了两个操作,分别是新增用户和查询,下面来创建它们对应的映射文件。 + +### 添加映射文件 + +在 `resources` 目录下新增 `mybatis` 目录,并在该目录下创建配置文件 `mybatis-config.xml` ,该配置文件主要用来指定 MyBatis 基础配置文件和实体类映射文件的地址,你也可以根据自己的习惯进行修改,如下: + +```xml + + + + + + + + + + + + + +``` + +继续在 `mybatis` 目录下创建 `mapper` 目录,并创建 xml 格式的文件,其名称与上面创建的 mapper 接口相对应,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/3dc7f358f6c63d136d227b527c1b64bb-0/wm) + +上面 `UserMapper.java` 接口中创建了两个操作,因此这里需要在它对应的 `UserMapper.xml` 中创建其 SQL 实现,初始化内容如下: + +```xml + + + + + + + insert into user (username, password, telephone) + value (#{username}, #{password}, #{telephone}) + + + + +``` + +上面这段 xml 脚本中: + +- `namespace` 指定了它的 Mapper 接口,因此它的内容就是 Mapper 接口的包名; +- `insert` 标签和 `select` 标签分别对应 Mapper 接口中的插入和查询操作,因此这里的标签 id 与 Mapper 接口的方法名是一致的。 + +到此为止整个 MyBatis 的代码调用流程就已经实现,接下来我们对代码进行测试。 + +### 测试 + +#### 创建测试类 + +我们采用单元测试代码来进行验证,在工程 `src` 目录下有两个文件目录,分别是 `main` 和 `test`: + +- `main` 是主工程存放目录; +- `test` 是测试工程存放路径。 + +在 `test` 的 `java` 目录下创建包,包名为: `com.shiyanlou.file`,这里需要注意:这个包名必须和主工程的包名相同,否则在执行测试代码的时候会报错,然后在包名下面创建一个测试类,名为 `ApplicationTest.java`,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/32fd2bce4306057fb04d99f0accf7c5d-0/wm) + +然后输入下面的代码进行测试: + +```java +package com.shiyanlou.file; + +import com.shiyanlou.file.mapper.UserMapper; +import com.shiyanlou.file.model.User; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +@SpringBootTest +public class ApplicationTest { + + @Autowired + private UserMapper userMapper; + + @Test + public void test1() { + User user = new User(); + user.setUsername("用户名1"); + user.setPassword("密码1"); + user.setTelephone("手机号1"); + userMapper.insertUser(user); + System.out.println("数据库字段查询结果显示"); + List list = userMapper.selectUser(); + list.forEach(System.out::println); + } + +} +``` + +以上代码通过调用 MyBatis 接口,给用户数据库插入了一条数据,然后查询并打印结果。 + +> 如果以上代码存在属性爆红的情况,应该是你的 IDE 未安装 Lombok 插件,VSCode 可通过如下方式安装 Lombok 插件。 +![图片描述](https://doc.shiyanlou.com/courses/8842/1557563/7d332a3e73f72118ff2cec888b895300-0/wm) +安装完成之后点击右下角重启按钮即可生效。 + +#### 验证测试类 + +记得启动环境中的 MySQL 数据库: + +```bash +sudo service mysql start +``` + +上面的代码首先在 `user` 表中插入一条用户信息,然后查询,如果能查询到插入的信息,说明插入成功。 + +点击测试类方法旁边的运行按钮,就可以开始执行测试方法,执行完成之后便可查看结果,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/8842/1557563/0d243928912b4cc89ccdb8d8ca5edbb8-0/wm) + +## Spring Boot 集成 MyBatis-Plus + +### 添加依赖 + +向 `pom.xml` 文件中继续添加如下依赖: + +```xml + + com.baomidou + mybatis-plus-boot-starter + 3.4.1 + +``` + +上面这段 xml 脚本引入了 1 个依赖,其中 `mybatis-plus-boot-starter` 是 `MyBatis-Plus` 的依赖包,引入之后就可以使用 MyBatis-Plus 的所有功能。 + +### 添加配置 + +如果我们不设置 MyBatis-Plus 默认的驼峰式编码,在 MyBatis-Plus 则会默认把驼峰式编码映射为下划线格式,比如实体类种属性 userId 映射到数据库字段 user_id, 这种下划线格式的字段会导致代码报错,所以我们需要关闭驼峰命名规则映射,在 `application.properties` 配置文件中添加如下配置: + +```properties +mybatis-plus.mapper-locations=classpath:mybatis/mapper/*.xml + +mybatis-plus.configuration.map-underscore-to-camel-case=false +``` + +### 添加注解 + +在之前已经创建的三个实体类中,需要添加 MyBatis-Plus 相关注解,这里需要用到的注解有两个,分别是 `@TableName` 和 `@TableId`。 + +#### 注解说明 +|注解|描述| +|-|-| +|@TableName(“表名”)|实体类添加,如果不添加,会按照默认规则进行表明的映射,比如UserTable->user_table| +|@TableId(type = IdType.AUTO)|用来标注实体类主键| + +#### 实体类添加注解 + +向 `UserFile.java` 文件中添加如下代码: + +```java +... +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.TableId; + +... +@TableName("userfile") +public class UserFile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @TableId(type = IdType.AUTO) + @Column(columnDefinition = "bigint(20) comment '用户文件id'") + private Long userFileId; + + ... +``` + +向 `User.java` 文件中添加如下代码: + +```java +... +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.TableId; + +... +@TableName("user") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @TableId(type = IdType.AUTO) + @Column(columnDefinition = "bigint(20) comment '用户id'") + private Long userId; + + ... +``` + +向 `File.java` 文件中添加如下代码: + +```java +... +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.TableId; + +... +@TableName("file") +public class File { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @TableId(type = IdType.AUTO) + @Column(columnDefinition="bigint(20) comment '文件id'") + private Long fileId; + + ... +``` + + + +### Mapper 接口继承 BaseMapper + +这里我们以 `UserMapper.java` 为例,进行讲解。打开 `UserMapper.java` 并继承 `BaseMapper`,代码如下: + +```java +package com.shiyanlou.file.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.shiyanlou.file.model.User; +import java.util.List; + +public interface UserMapper extends BaseMapper{ + void insertUser(User user); + List selectUser(); +} +``` + +### 测试 + +下面来验证集成结果,首先在 `user` 表中插入一条用户信息,然后查询,查看查询结果是否插入成功。 + +#### 新增测试方法 + +向 `ApplicationTest.java` 文件中补充如下代码: + +```java +@Test +public void test2() { + User user = new User(); + user.setUsername("用户名2"); + user.setPassword("密码2"); + user.setTelephone("手机号2"); + userMapper.insert(user); + List list = userMapper.selectList(null); + System.out.println("数据库字段查询结果显示"); + list.forEach(System.out::println); +} +``` + +#### 查看测试结果 + +如下图我们使用 MyBatis-Plus 提供的方法可以很容易的进行插入和查询,而且不用写一行 SQL 及其各种映射文件,点击执行,查看结果如下图: + +![图片描述](https://doc.shiyanlou.com/courses/8842/1557563/26a7241a1132a36fc8341ecc010f356c-0/wm) + +## 实验总结 + +本节实验带领大家完成了 MyBatis 和 MyBatis-Plus 的集成,并且通过一个简单的例子完成了 Java 代码对数据库的操作,我们可以发现使用 MyBatis-Plus 能够大大提高工作效率,这便是它存在的意义。 + +本次实验完整代码可以通过如下命令进行下载: + +```bash +wget https://labfile.oss.aliyuncs.com/courses/8842/code3.zip +``` diff --git "a/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/07-466265-Restful\346\216\245\345\217\243\350\256\276\350\256\241\350\247\204\350\214\203\344\273\245\345\217\212\347\273\237\344\270\200\345\223\215\345\272\224\347\273\223\346\236\234.sy.md" "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/07-466265-Restful\346\216\245\345\217\243\350\256\276\350\256\241\350\247\204\350\214\203\344\273\245\345\217\212\347\273\237\344\270\200\345\223\215\345\272\224\347\273\223\346\236\234.sy.md" new file mode 100755 index 0000000..b054af8 --- /dev/null +++ "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/07-466265-Restful\346\216\245\345\217\243\350\256\276\350\256\241\350\247\204\350\214\203\344\273\245\345\217\212\347\273\237\344\270\200\345\223\215\345\272\224\347\273\223\346\236\234.sy.md" @@ -0,0 +1,378 @@ +--- +show: step +version: 1.0 +enable_checker: true +--- + +# RESTful 接口设计规范及统一响应结果 + +## 实验介绍 + +本实验主要讲解 RESTful 基本概念,包括请求方法、URL 设计、服务器响应等,使得大家能够学到一个真正的 RESTful 接口应该如何去设计,然后带领大家在代码中对后台异常进行统一处理,保证程序能够完整的执行下去。 + +#### 知识点 + +- RESTful 基本概念 +- 后台统一返回结果 +- 统一异常处理 + +#### 开发计划 + +- 开发内容: +1. 介绍 RESTful 接口设计规范,并能够使用该规范进行接口开发 +2. 通过统一异常处理来进行异常场景下的全局返回处理。 +- 开发耗时:实验预计完成时间为 1~2 小时 +- 开发难点: +1. 在开发一个新的接口命名需要符合 RESTful 规范,包括 HTTP 请求方法,url 路径和格式,后台统一返回格式等。 +2. 返回码的格式和含义需要按照业务场景规划。 + +## RESTful 基本概念 + +RESTful 是一种网络应用程序的设计风格和开发方式,给出一种约定的标准,包含 API 接口规范、命名规则、URL、返回值、授权验证等,下面就介绍几种主要的设计规范,当我们在项目编写当中需要尽可能的去遵守。 + +#### 正确使用 HTTP 请求方法 + +HTTP 请求有 5 种请求方法,对应 CRUD 操作,通常我们使用 GET 来做查询,POST 做提交。 + +- GET:读取(Read) +- POST:新建(Create) +- PUT:更新(Update) +- PATCH:更新(Update),通常是部分更新 +- DELETE:删除(Delete) + +#### url 路径 + +既然上面 HTTP 请求方法已经为我们指定了请求的动作,那么请求的 url 路径只需要指定好需要请求的资源就可以了,它是名词,而非动词,或者动宾,比如 `/articles` 就是正确的,而下面的 URL 不是名词,所以是错误的: + +- `/getArticles` +- `/createNewCar` +- `/deleteAllRedCars` + +#### URL 格式 + +URL 是不区分单数和复数的,通常的做法是当读取一个集合,比如 `GET /articles`,则表示读取所有文章,`GET /articles/3` 则表示读取某一篇文章。 + +#### 避免 URL 嵌套过深 + +常见的情况是,资源需要多级分类,因此很容易写出多级的 URL,比如获取某个作者的某一类文章。 + +```text +GET /authors/12/categories/2 +``` + +这种 URL 不利于扩展,语义也不明确,往往要想一会,才能明白含义。更好的做法是,除了第一级,其他级别都用查询字符串表达。 + +```text +GET /authors/12?categories=2 +``` + +下面是另一个例子,查询已发布的文章。你可能会设计成下面的 URL。 + +```text +GET /articles/published +``` + +查询字符串的写法明显更好。 + +```text +GET /articles?published=true +``` + +#### 服务器响应 + +接口的 API 返回的数据格式,不应该是纯文本,而应该是一个 JSON 对象,因为这样才能返回标准的结构化数据。所以,服务器回应的 HTTP 头的 Content-Type 属性要设为 `application/json`。 +目前的前后端开发大部分数据的传输格式都是 JSON,因此定义一个统一规范的数据格式有利于前后端的交互与 UI 的展示。 + +## 后台统一返回结果 + +在了解 RESTful 基本概念之后,接下来就带领大家进行代码实现,当设计后台接口时,我们需要统一返回格式,这里我们首先使用枚举类来定义各种返回状态。 + +### 定义结果枚举类 + +一般后台返回给前台的状态可以大致分为两类:成功和失败,但是失败的情况却有很多种,为了能够让前台调用者更加清楚的知道后台报了什么错,这里我们可以尽可能的将错误细化,比如下面枚举类中参数错误,空指针异常等。 + +创建一个 `com.shiyanlou.file.common` 包用来存放一些公共类,创建返回码的枚举类 `ResultCodeEnum.java` 放到这个包下面,如下: + +```java +package com.shiyanlou.file.common; + +import lombok.Getter; + +/** + * 结果类枚举 + */ +@Getter +public enum ResultCodeEnum { + SUCCESS(true,20000,"成功"), + UNKNOWN_ERROR(false,20001,"未知错误"), + PARAM_ERROR(false,20002,"参数错误"), + NULL_POINT(false, 20003, "空指针异常"), + INDEX_OUT_OF_BOUNDS(false, 20004, "下标越界异常"), + ; + + // 响应是否成功 + private Boolean success; + // 响应状态码 + private Integer code; + // 响应信息 + private String message; + + ResultCodeEnum(boolean success, Integer code, String message) { + this.success = success; + this.code = code; + this.message = message; + } +} +``` + +### 定义统一结果返回类 + +在 `com.shiyanlou.file.common` 包下继续定义 `RestResult.java` 类,该类用于定义后台统一返回格式。 + +```java +package com.shiyanlou.file.common; + +import lombok.Data; + +/** + * 统一结果返回 + * + * @param + */ +@Data +public class RestResult { + private Boolean success = true; + private Integer code; + private String message; + private T data; + + // 通用返回成功 + public static RestResult success() { + RestResult r = new RestResult(); + r.setSuccess(ResultCodeEnum.SUCCESS.getSuccess()); + r.setCode(ResultCodeEnum.SUCCESS.getCode()); + r.setMessage(ResultCodeEnum.SUCCESS.getMessage()); + return r; + } + + // 通用返回失败,未知错误 + public static RestResult fail() { + RestResult r = new RestResult(); + r.setSuccess(ResultCodeEnum.UNKNOWN_ERROR.getSuccess()); + r.setCode(ResultCodeEnum.UNKNOWN_ERROR.getCode()); + r.setMessage(ResultCodeEnum.UNKNOWN_ERROR.getMessage()); + return r; + } + +} +``` + +上面这段代码就是统一结果返回类,到目前为止,我们创建了两个类,分别是 `ResultCodeEnum.java` 和 `RestResult.java`, 创建完成结果如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/33c4d75b3bafd4d83bbd72ba0b814977-0/wm) + +### 返回结果测试 + +接下来我们对上面的代码进行测试,创建一个 `com.shiyanlou.file.controller` 包用来存放接口,在包下创建文件:`UserController.java`,打开该文件,初始化内容如下: + +```java +package com.shiyanlou.file.controller; + +import com.shiyanlou.file.common.RestResult; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +import com.shiyanlou.file.common.RestResult; + +@RestController +@RequestMapping("/user") +public class UserController { + + /** + * 成功响应测试 + */ + @GetMapping(value="/test1") + @ResponseBody + public RestResult test1(){ + return RestResult.success(); + } + + /** + * 失败响应测试 + */ + @GetMapping(value="/test2") + @ResponseBody + public RestResult test2(){ + return RestResult.fail(); + } +} +``` + +启动项目进行测试(`Application.java` 文件),启动成功之后,点击右侧 Web 服务进行测试,在浏览器上面的地址栏追加内容:`/user/test1`,查看后台返回,如下图是模拟成功响应: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/72cf062441559f832ab255b2cce61072-0/wm) + +在浏览器上面的地址栏追加内容: `/user/test2`,查看后台返回,如下图是模拟失败响应: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/8e60c726fb45d7486a36493fd37ea29f-0/wm) + +上面这段代码我们可以称它们为通用的成功或失败响应码,但是有些情况,当系统返回失败的时候我们需要自定义返回码或者返回信息,因此需要在 RestResult 结果类里面添加方法,来满足各种错误场景。 + +打开 `RestResult.java` 类,添加如下代码: + +```java +// 自定义返回数据 +public RestResult data(T param) { + this.setData(param); + return this; +} + +// 自定义状态信息 +public RestResult message(String message) { + this.setMessage(message); + return this; +} + +// 自定义状态码 +public RestResult code(Integer code) { + this.setCode(code); + return this; +} + +// 设置结果,形参为结果枚举 +public static RestResult setResult(ResultCodeEnum result) { + RestResult r = new RestResult(); + r.setSuccess(result.getSuccess()); + r.setCode(result.getCode()); + r.setMessage(result.getMessage()); + return r; +} +``` + +上面这三个方法分别用来自定义返回数据,状态码和状态信息,具体的使用方法如下: + +```java + +//查询文件列表 +public RestResult list() { + List filelist = new ArrayList(); + //获取文件列表(略) + ... + return RestResult.fail().data(filelist); +} + +//模拟用户登录失败响应场景 +public RestResult loginFailResult() { + return RestResult.fail().message("手机号不存在!"); +} +``` + +## 统一异常处理 + +在我们编程的过程中,并不是百分之百的接口都能通过正常的方式调用及返回,往往还会有一些异常场景,比如代码中经常出现数据下标越界,空指针异常等异常场景,而这些场景都是不可控的,一旦发生,程序流程就会终止,此时如果不对这种场景进行处理,那么就会造成前台无响应的局面。 + +### 异常处理类 + +异常处理类的原理就是通过 AOP 进行拦截,AOP 就是面向切面编程,而我们这里使用的切面实在控制层进行切面拦截,当发生异常时就会执行异常处理类。 + +创建 `com.shiyanlou.file.advice` 包,在这个包下新建 `GlobalExceptionHandlerAdvice.java`, 代码如下: + +```java +package com.shiyanlou.file.advice; + +import com.shiyanlou.file.common.RestResult; + +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@ControllerAdvice +public class GlobalExceptionHandlerAdvice { + /**-------- 通用异常处理方法 --------**/ + @ExceptionHandler(Exception.class) + @ResponseBody + public RestResult error(Exception e) { + e.printStackTrace(); + log.error("全局异常捕获:" + e); + + return RestResult.fail(); // 通用异常结果 + } +} +``` + +上面代码涉及到几个重要的注解,说明如下表: + +|注解|说明| +|-|-| +|@ControllerAdvice|这是一个增强的 Controller。使用这个 Controller ,可以实现三个方面的功能:1、全局异常处理, 2、全局数据绑定, 3、全局数据预处理| +|@ExceptionHandler|该注解用来指明异常的处理类型| +|@ResponseBody|该注解为 Spring Boot 响应体注解,用在这里的目的就是当出现异常时,直接将错误返回给前台,可以避免前台页面阻塞| + +上面我们使用了大异常 `Exception` 来进行处理,不管发生任何异常都会执行该方法,当然在实际当中,我们也可以将异常进行细化,使得前台返回的错误更加具体,继续在该 `GlobalExceptionHandlerAdvice.java` 文件中添加下面代码: + +```java +import com.shiyanlou.file.common.ResultCodeEnum; + +@Slf4j +@ControllerAdvice +public class GlobalExceptionHandlerAdvice { + ... + @ExceptionHandler(NullPointerException.class) + @ResponseBody + //空指针处理方法 + public RestResult error(NullPointerException e) { + e.printStackTrace(); + log.error("全局异常捕获:" + e); + return RestResult.setResult(ResultCodeEnum.NULL_POINT); + } + + //下标越界处理方法 + @ExceptionHandler(IndexOutOfBoundsException.class) + @ResponseBody + public RestResult error(IndexOutOfBoundsException e) { + e.printStackTrace(); + log.error("全局异常捕获:" + e); + return RestResult.setResult(ResultCodeEnum.INDEX_OUT_OF_BOUNDS); + } +} +``` + +上面两个示例方法就是对不同的异常进行返回,你也可以添加其他的异常,具体做法是一样的,当然我们在代码里面还会有一些自定义异常,其做法也是一样的。 + +### 测试 + +在 `UserController.java` 类中添加如下代码进行测试: + +```java +/** +* 空指针异常响应测试 +*/ +@GetMapping(value="/test3") +@ResponseBody +public RestResult test3(){ + String s = null; + int i = s.length(); + return RestResult.success(); +} +``` + +上面这段代码我构造了一个空指针异常,一旦程序报错,拦截器就会进行处理,并将错误信息返回给前台。 + +启动项目进行测试,启动成功之后,点击右侧 web 服务进行测试,在浏览器上面的地址栏追加内容:`/user/test3`,查看后台返回,如下图是模拟成功响应: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/3d63f0189d10d99f624dc0982335035c-0/wm) + +## 实验总结 + +本实验主要讲解了 RESTful 接口规范,统一响应结果,统一异常处理在代码开发中的应用,在实际项目开发中,我们一定要严格按照约定来开发接口,才能使得整个项目能够符合行业规范。 + +本次实验完整代码可以通过如下命令进行下载: + +```bash +wget https://labfile.oss.aliyuncs.com/courses/8842/code4.zip +``` diff --git "a/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/08-466266-Spring Boot\346\225\264\345\220\210Java Web Token\357\274\214\345\256\236\347\216\260\347\224\250\346\210\267\347\231\273\345\275\225\350\256\244\350\257\201.sy.md" "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/08-466266-Spring Boot\346\225\264\345\220\210Java Web Token\357\274\214\345\256\236\347\216\260\347\224\250\346\210\267\347\231\273\345\275\225\350\256\244\350\257\201.sy.md" new file mode 100755 index 0000000..2404825 --- /dev/null +++ "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/08-466266-Spring Boot\346\225\264\345\220\210Java Web Token\357\274\214\345\256\236\347\216\260\347\224\250\346\210\267\347\231\273\345\275\225\350\256\244\350\257\201.sy.md" @@ -0,0 +1,699 @@ +--- +show: step +version: 1.0 +enable_checker: true +--- + +# Spring Boot 整合 Java Web Token 实现用户登录认证 + +## 实验介绍 + +登录认证是一个系统最基本的功能,本节实现带领大家使用 Java Web Token 来进行登录验证,再此之前我会首先对 session 和 token 做一个详细的讲解,因为这个是一个比较容易混淆的知识点,然后通过代码来实现 Java Web Token 认证。 + +#### 知识点 + +- session 与 token 发展历程 +- Java Web Token 介绍 +- 登录认证流程 + +#### 开发计划 + +- 开发内容: +使用 JWT 来完成用户的登录校验 + +- 开发耗时:实验预计完成时间为 2~4 小时 +- 开发难点: +1. 理解 JWT 生成原理。 +2. 用户登录校验。 + +## session 与 token 发展历程 + +我们都知道,一个普通 Web 应用前后台进行交互使用的是 HTTP 协议,由于 HTTP 协议是无状态的,因此每个请求对于后台服务端来说,都是全新的,但是随着互联网的 Web 应用兴起,像很多购物网站,需要登录的网站就面临一个问题,那就是会话管理,后台需要记住哪些人登录系统,哪些人进行了购物操作,也就是说必须把每个人区分开,但是 HTTP 请求时无状态的,所以就想到一个办法,给每个请求者发一个会话标识,也就是 session,说白了就是一个随机的字符串,保证每个人不重复,这样当大家再次向服务端发送请求的时候,就能区分开来谁是谁了。 + +随着用户数量的增加,服务器要保存所有用户的 session,这对服务器来说是一个巨大的开销,严重的限制了服务器的扩展能力,比如负载均衡+服务器集群部署方式,往往用户在 A 服务器登录了系统,session 会保存在机器 A 上,但是如果下一次请求被转发到了机器 B 怎么办?通常有两种做法,一种是通过 Sticky 技术将相同用户请求分发到同一个机器上,但是这样就违背了做负载均衡的初衷了,而且万一这个机器挂了,那么还是会请求到另外的机器上。另一种做法就 session 的复制了,将 session 在多个应用之间进行复制,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/f2fa9ecf92b1f56bced1a10bc651ead7-0/wm) + +但是集群的数量如果过大,将 session 拷来拷去,却是一件很麻烦的事情,因此,有人支招,将 session 集中存储到一个地方,所有机器都来访问这个地方的数据,这样一来,就不用复制了,如下: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/fa008106d06141c062e48b8cb6506978-0/wm) + +但是如果这个 session 存储的机器挂了,所有人都得受到影响,因此也尝试将这个机器也搞一个集群,增加可靠性,但是不管如何,这个小小的 session 对后台来说都是一个沉重的负担。 + +于是又有人思考,为什么后台要保存这些 session 呢?如果让每个客户端自己去保存该有多好,出于这个起点,人们开始不断的尝试。关键点在于如果服务端不保存 session,如何知道 session 是我生成的。关键点就在于验证,比如客户端 M 登录了系统,我给他发了一个令牌(token),里面包含了该用户的 user id,用来标识客户端身份,下次客户端 M 再次访问我的时候,再把这个 token 通过 Http header 带过来就行了,不过这个本质和 session 没区别,任何人都可以伪造,所以得想点办法,让别人无法伪造。 + +那就对数据做个签名,将数据和密钥一起作为 token,由于密钥别人不知道,就无法伪造 token 了。当客户端再次进行请求,服务端只需要用密钥再次签名进行验证,如果签名一致,则为有效 token,这时就可以直接取到 user id,如果签名不一致,则说明数据被篡改了,则告诉请求者,认证失败。 + +## Java Web Token 介绍 + +上面介绍了 session 与 token 的发展历程,相信大家对 token 已经有了一个初步的认识,我们能够发现,传统的 token 认证有一个缺陷,就是用户的关键信息,比如 id 等信息是通过明文来进行传输的,因此基于安全考虑,Java Web Token 应运而生了。 + +### Java Web Token 概念 + +JSON Web Token (JWT) 是一个开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于将信息作为 JSON 对象安全地在各方之间传输信息。此信息可以验证和信任,因为它是数字签名。JWT 可以使用密钥(使用 HMAC 算法)或使用 RSA 或 ECDSA 进行公钥/私钥对进行签名。 + +#### JWT 结构 + +JSON Web 令牌以紧凑的形式由三个部分组成,由点(.)分隔,它们包括: + +- Header +- Payload +- Signature + +将上面三部分用(.)拼接起来就形成了JWT,因此,JWT 的格式通常如下所示。 + +`xxxxx.yyyyy.zzzzz` + +#### Header + +标头通常由两部分组成:token 的类型(typ)和正在使用的签名算法(alg),如 HMAC SHA256 或 RSA。 + +例如: + +```json +{ + "alg": "HS256", + "typ": "JWT" +} +``` + +然后,此 JSON 编码为 Base64Url,以形成 JWT 的第一部分。 + +#### Payload + +token 的第二部分是有效负载,其中包含 claims。claims 是关于实体(通常为用户)和其他数据的语句。 + +示例有效负载可能是: + +```json +{ + "sub": "1234567890", + "name": "John Doe", + "admin": true +} +``` + +然后对有效负载进行 Base64Url 编码,以形成 JSON Web 令牌的第二部分。 + +请注意,对于已签名的 token,此信息虽然可防止篡改,但任何人都可以阅读。除非对 JWT 进行加密,否则不要将机密信息放在 JWT 的有效负载或标头元素中。 + +#### Signature + +要创建签名部分,您必须使用编码标头、编码有效负载、机密、标头中指定的算法,并签名。 + +例如,如果要使用 HMAC SHA256 算法,将采用以下方式创建签名: + +``` +HMACSHA256( + base64UrlEncode(header) + "." + + base64UrlEncode(payload), + secret) +``` + +签名用于验证信息在传输过程中不被篡改,对于使用私钥签名的 token,它还可以验证 JWT 的发件人是否为它所说的发件人。 + +### 放在一起 + +输出是三个 Base64-URL 字符串,由点分隔,这些点可以在 HTML 和 HTTP 环境中轻松传递,但与基于 XML 的标准(如 SAML)相比,更紧凑。 + +## 集成 Java Web Token + +### 添加依赖 + +向 `pom.xml` 添加如下依赖: + +```xml + + io.jsonwebtoken + jjwt + 0.9.1 + +``` + +### 添加配置文件 + +上面已经介绍了 JWT 的基本概念了,它由三部分构成,分别是 header, payload 和 signature, 其中前两部分中的参数设计到的一些参数需要用户自定义,因此我们将这些参数放到配置文件中,方便后续修改。 + +打开 `application.properties` 文件,添加如下配置: + +```properties +# 密钥 +jwt.secret = 6L6T5LqG5L2g77yM6LWi5LqG5LiW55WM5Y+I6IO95aaC5L2V44CC +# 签名算法:HS256,HS384,HS512,RS256,RS384,RS512,ES256,ES384,ES512,PS256,PS384,PS512 +jwt.header.alg = HS256 +#jwt签发者 +jwt.payload.registerd-claims.iss = qiwen-cms +#jwt过期时间(单位:毫秒) +jwt.payload.registerd-claims.exp = 60 * 60 * 1000 * 24 * 7 +#jwt接收者 +jwt.payload.registerd-claims.aud = qiwenshare +``` + +### 添加 JWT 配置属性类 + +创建包 `com.shiyanlou.file.config.jwt`, 然后新增四个配置类如下: + +JwtHeader 属性类用来定义 JWT 第一部分,代码如下: + +```java +package com.shiyanlou.file.config.jwt; + +import lombok.Data; + +@Data +public class JwtHeader { + private String alg; + private String typ; +} +``` + +JwtPayload 属性类用来定义 JWT 第二部分,代码如下: + +```java +package com.shiyanlou.file.config.jwt; + +import lombok.Data; + +@Data +public class JwtPayload { + private RegisterdClaims registerdClaims; +} +``` + +```java +package com.shiyanlou.file.config.jwt; + +import lombok.Data; + +@Data +public class RegisterdClaims { + private String iss; + private String exp; + private String sub; + private String aud; +} +``` + +接下来我们将上面的属性类组织起来,形成一个完整的 JWT 配置类 JwtProperties, 该类使用 ConfigurationProperties 注解,其中的 prefix 参数是 jwt,可以读取 `application.properties` 配置文件中以 jwt 开头的配置,代码如下: + +```java +package com.shiyanlou.file.config.jwt; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "jwt") +public class JwtProperties { + private String secret; + private JwtHeader header; + private JwtPayload payload; +} +``` + +### 添加 JWT 工具类 + +创建包 `com.shiyanlou.file.util` 工具类,然后新增类 `JWTUtil.java`,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/bbec62096d82134ec60a63af9fe7079c-0/wm) + +### 初始化 + +向 `JWTUtil.java` 文件中添加如下代码进行初始化: + +```java +package com.shiyanlou.file.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.impl.DefaultClaims; + +import org.apache.tomcat.util.codec.binary.Base64; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import com.shiyanlou.file.config.jwt.JwtProperties; + +import java.util.Date; + +@Component +public class JwtUtil { + + @Resource + JwtProperties jwtProperties; + +} + +``` + +### 添加生成密钥方法 + +在 `JWTUtil.java` 中创建 generalSecretKey 方法来生成密钥,生成的过程需要使用 JWT 的方法 SecretKeySpec 来生成密钥,该密钥在创建 JWT 和验证 JWT 的时候都会用到且相同,代码如下: + +```java +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.tomcat.util.codec.binary.Base64; + +public class JWTUtil { + ... + /** + * 由字符串生成加密key + * @return + */ + private SecretKey generalKey() { + // 本地的密码解码 + byte[] encodedKey = Base64.decodeBase64(jwtProperties.getSecret()); + // 根据给定的字节数组使用AES加密算法构造一个密钥 + SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES"); + return key; + } +} +``` + +### 创建 JWT 方法 + +继续向 `JWTUtil.java` 文件中补充如下代码: + +```java +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.Jwts; + +public class JWTUtil { + .... + + /** + * 创建jwt + * @param subject + * @return + * @throws Exception + */ + public String createJWT(String subject) throws Exception { + + // 生成JWT的时间 + long nowTime = System.currentTimeMillis(); + Date nowDate = new Date(nowTime); + // 生成签名的时候使用的秘钥secret,切记这个秘钥不能外露,是你服务端的私钥,在任何场景都不应该流露出去,一旦客户端得知这个secret,那就意味着客户端是可以自我签发jwt的 + SecretKey key = generalKey(); + + ScriptEngineManager manager = new ScriptEngineManager(); + ScriptEngine se = manager.getEngineByName("js"); + int expireTime = 0; + try { + expireTime =(int) se.eval(jwtProperties.getPayload().getRegisterdClaims().getExp()); + } catch (ScriptException e) { + e.printStackTrace(); + } + + // 为payload添加各种标准声明和私有声明 + DefaultClaims defaultClaims = new DefaultClaims(); + defaultClaims.setIssuer(jwtProperties.getPayload().getRegisterdClaims().getIss()); + defaultClaims.setExpiration(new Date(System.currentTimeMillis() + expireTime)); + defaultClaims.setSubject(subject); + defaultClaims.setAudience(jwtProperties.getPayload().getRegisterdClaims().getAud()); + + JwtBuilder builder = Jwts.builder() // 表示new一个JwtBuilder,设置jwt的body + .setClaims(defaultClaims) + .setIssuedAt(nowDate) // iat(issuedAt):jwt的签发时间 + .signWith(SignatureAlgorithm.forName(jwtProperties.getHeader().getAlg()), key); // 设置签名,使用的是签名算法和签名使用的秘钥 + + return builder.compact(); + } +} +``` + +### 解析 JWT 方法 + +继续向 `JWTUtil.java` 文件中补充如下代码: + +```java +import io.jsonwebtoken.Claims; + +public class JWTUtil { + ... + /** + * 解密jwt + * @param jwt + * @return + * @throws Exception + */ + public Claims parseJWT(String jwt) throws Exception { + SecretKey key = generalKey(); // 签名秘钥,和生成的签名的秘钥一模一样 + Claims claims = Jwts.parser() // 得到DefaultJwtParser + .setSigningKey(key) // 设置签名的秘钥 + .parseClaimsJws(jwt).getBody(); // 设置需要解析的jwt + return claims; + } +} +``` + +## 注册逻辑实现 + +创建包 `com.shiyanlou.file.service` ,在该包下面新增 `UserService.java` 类,该类为 Service 层的接口,然后继续创建包 `com.shiyanlou.service.impl`,在该包下新增 `UserServiceImpl.java` 类,用来作为 `UserService.java` 的实现类,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/99a3cc825fd4c2ec35f982471ec5f2a3-0/wm) + +### 初始化 UserService.java + +```java +package com.shiyanlou.file.service; + +import com.shiyanlou.file.common.RestResult; +import com.shiyanlou.file.model.User; + +public interface UserService { + RestResult registerUser(User user); +} +``` + +### 初始化 UserServiceImpl.java + +```java +package com.shiyanlou.file.service.impl; + +import java.util.List; +import java.util.UUID; + +import javax.annotation.Resource; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.shiyanlou.file.common.RestResult; +import com.shiyanlou.file.mapper.UserMapper; +import com.shiyanlou.file.model.User; +import com.shiyanlou.file.service.UserService; +import com.shiyanlou.file.util.DateUtil; + +import org.springframework.stereotype.Service; +import org.springframework.util.DigestUtils; +import org.springframework.util.StringUtils; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class UserServiceImpl extends ServiceImpl implements UserService{ + + @Resource + UserMapper userMapper; + + @Override + public RestResult registerUser(User user) { + //判断验证码 + String telephone = user.getTelephone(); + String password = user.getPassword(); + + if (!StringUtils.hasLength(telephone) || !StringUtils.hasLength(password)){ + return RestResult.fail().message("手机号或密码不能为空!"); + } + if (isTelePhoneExit(telephone)){ + return RestResult.fail().message("手机号已存在!"); + } + + + String salt = UUID.randomUUID().toString().replace("-", "").substring(15); + String passwordAndSalt = password + salt; + String newPassword = DigestUtils.md5DigestAsHex(passwordAndSalt.getBytes()); + + user.setSalt(salt); + + user.setPassword(newPassword); + user.setRegisterTime(DateUtil.getCurrentTime()); + int result = userMapper.insert(user); + + if (result == 1) { + return RestResult.success(); + } else { + return RestResult.fail().message("注册用户失败,请检查输入信息!"); + } + } + + private boolean isTelePhoneExit(String telePhone) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.eq(User::getTelephone, telePhone); + List list = userMapper.selectList(lambdaQueryWrapper); + if (list != null && !list.isEmpty()) { + return true; + } else { + return false; + } + + } + +} +``` + +向 `com.shiyanlou.file.util` 模块中添加 `DateUtil.java` 文件,并向其中写入如下代码: + +```java +package com.shiyanlou.file.util; + +import java.util.Date; + +public class DateUtil { + /** + * 获取系统当前时间 + * + * @return 系统当前时间 + */ + public static String getCurrentTime() { + Date date = new Date(); + String stringDate = String.format("%tF % login(User user); +``` + +### 登录接口实现 + +打开 `UserServiceImpl.java` 类,新增 `login` 接口方法实现: + +```java +@Override +public RestResult login(User user) { + String telephone = user.getTelephone(); + String password = user.getPassword(); + + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.eq(User::getTelephone, telephone); + User saveUser = userMapper.selectOne(lambdaQueryWrapper); + String salt = saveUser.getSalt(); + String passwordAndSalt = password + salt; + String newPassword = DigestUtils.md5DigestAsHex(passwordAndSalt.getBytes()); + if (newPassword.equals(saveUser.getPassword())) { + saveUser.setPassword(""); + saveUser.setSalt(""); + return RestResult.success().data(saveUser); + } else { + return RestResult.fail().message("手机号或密码错误!"); + } + +} +``` + +## 使用 Java Web Token 实现用户登录认证 + +首先我们需要在 `UserController.java` 中创建两个接口,分别是登录和注册,前面我们已经将 `UserService.java` 接口和实现类完成了,这里我们只需要注入就可以使用了。 + +```java +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import javax.annotation.Resource; +import com.shiyanlou.file.service.UserService; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequestMapping("/user") +public class UserController { + @Resource + UserService userService; + ... +} +``` + +### 前台注册接口 + +创建包 `com.shiyanlou.file.dto` 用来存放接口请求参数,在该包下面创建 `RegisterDTO.java`,并初始化内容如下: + +```java +package com.shiyanlou.file.dto; + +import lombok.Data; + +@Data +public class RegisterDTO { + private String username; + private String telephone; + private String password; +} +``` + +接下来在 `UserController.java` 中,添加注册方法,代码如下: + +```java +import com.shiyanlou.file.dto.RegisterDTO; +import com.shiyanlou.file.model.User; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@Slf4j +@RestController +@RequestMapping("/user") +public class UserController { + ... + @PostMapping(value = "/register") + @ResponseBody + public RestResult register(@RequestBody RegisterDTO registerDTO) { + RestResult restResult = null; + User user = new User(); + user.setUsername(registerDTO.getUsername()); + user.setTelephone(registerDTO.getTelephone()); + user.setPassword(registerDTO.getPassword()); + + restResult = userService.registerUser(user); + + return restResult; + } +} +``` + +### 前台登录接口 + +创建包 `com.shiyanlou.file.vo` 用来存放接口响应参数,在该包下面创建 `LoginVO.java`,并初始化内容如下: + +```java +package com.shiyanlou.file.vo; + +import lombok.Data; + +@Data +public class LoginVO { + private String username; + private String token; +} +``` + +接下来在 `UserController.java` 中,添加登录方法,代码如下: + +```java +import com.fasterxml.jackson.databind.ObjectMapper; +import com.shiyanlou.file.util.JWTUtil; +import com.shiyanlou.file.vo.LoginVO; + +@Slf4j +@RestController +@RequestMapping("/user") +public class UserController { + + @Resource + JWTUtil jwtUtil; + + ... + @GetMapping(value = "/login") + @ResponseBody + public RestResult userLogin(String telephone, String password) { + RestResult restResult = new RestResult(); + LoginVO loginVO = new LoginVO(); + User user = new User(); + user.setTelephone(telephone); + user.setPassword(password); + RestResult loginResult = userService.login(user); + + if (!loginResult.getSuccess()) { + return RestResult.fail().message("登录失败!"); + } + + loginVO.setUsername(loginResult.getData().getUsername()); + String jwt = ""; + try { + ObjectMapper objectMapper = new ObjectMapper(); + jwt = jwtUtil.createJWT(objectMapper.writeValueAsString(loginResult.getData())); + } catch (Exception e) { + return RestResult.fail().message("登录失败!"); + } + loginVO.setToken(jwt); + + return RestResult.success().data(loginVO); + } +} +``` + +### token 校验接口 + +用户登录成功后,可以调用该接口来获取登录状态,判断 token 是否失效,保证前后台登录状态一致。 + +继续向 `UserController.java` 文件中添加如下代码: + +```java +import org.springframework.web.bind.annotation.RequestHeader; +import io.jsonwebtoken.Claims; + +@Slf4j +@RestController +@RequestMapping("/user") +public class UserController { + ... + @GetMapping("/checkuserlogininfo") + @ResponseBody + public RestResult checkToken(@RequestHeader("token") String token) { + RestResult restResult = new RestResult(); + User tokenUserInfo = null; + try { + + Claims c = jwtUtil.parseJWT(token); + String subject = c.getSubject(); + ObjectMapper objectMapper = new ObjectMapper(); + tokenUserInfo = objectMapper.readValue(subject, User.class); + + } catch (Exception e) { + log.error("解码异常"); + return RestResult.fail().message("认证失败"); + + } + + if (tokenUserInfo != null) { + + return RestResult.success().data(tokenUserInfo); + + } else { + return RestResult.fail().message("用户暂未登录"); + } + } +} +``` + +上面这段代码,如果 token 不正确,或者 token 过期,就会导致解码失败,返回认证失败,如果能够正确解析,那么就会返回成功。 + +## 实验总结 + +通过本节实验,我们需要掌握登录方式的基本思路和原理,并能够熟练运用 JWT 来进行登录认证,目前已经完成了用户登录和注册接口的实现,而在下一节实验中我将带领大家完成 Swagger 3 的搭建,生成接口文档,并对本节实验接口做一个验证。 + +本次实验完整代码可以通过如下命令进行下载: + +```bash +wget https://labfile.oss.aliyuncs.com/courses/8842/code5.zip +``` diff --git "a/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/09-466267-Spring Boot\351\233\206\346\210\220Swagger 3\357\274\214\345\271\266\347\224\237\346\210\220API\346\216\245\345\217\243\346\226\207\346\241\243.sy.md" "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/09-466267-Spring Boot\351\233\206\346\210\220Swagger 3\357\274\214\345\271\266\347\224\237\346\210\220API\346\216\245\345\217\243\346\226\207\346\241\243.sy.md" new file mode 100755 index 0000000..497a48e --- /dev/null +++ "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/09-466267-Spring Boot\351\233\206\346\210\220Swagger 3\357\274\214\345\271\266\347\224\237\346\210\220API\346\216\245\345\217\243\346\226\207\346\241\243.sy.md" @@ -0,0 +1,356 @@ +--- +show: step +version: 1.0 +enable_checker: true +--- + +# Spring Boot 集成 Swagger 3,并生成 API 接口文档 + +## 实验介绍 + +前后端分离的项目,接口文档的存在十分重要。与手动编写接口文档不同,swagger 是一个自动生成接口文档的工具,在需求不断变更的环境下,手动编写文档的效率实在太低。与 swagger2 相比新版的 swagger3 配置更少,使用更加方便,本实验通过介绍 swagger 3,可以很方便的生成 api 文档。 + +#### 知识点 + +- springdoc-openapi 集成 +- knife4j 集成 +- 登录接口调试 + +#### 开发计划 + +- 开发内容: +集成 springdoc-openapi 实现接口文档的生成,并使用 knife4j 对接口文档进行页面优化及功能增强,使用 knife4j 完成接口测试。 +- 开发耗时:实验预计完成时间为 2~4 小时 +- 开发难点: +1. 运用 springdoc-openapi 注解,生成 api 接口文档。 +2. 使用 knife4j 进行接口调试。 + +## Swagger 基本概念 + +Swagger 是一个 API 文档维护组织,后来成为了 Open API 标准的主要定义者,现在最新的版本为 17 年发布的 Swagger3。 + +Swagger 是一个规范和完整的框架,用于生成可视化 RESTful 风格的 Web 服务。 + +如果想要将 Swagger 文档集成到 Spring 中,目前有两个开源项目可供开发者选择,一个是 SpringFox,另一个是 SpringDoc,这两个项目都是由 Spring 社区来维护的,在 Swagger 2.0 时代,SpringFox 是主流,但是随着 Swagger 3.0 版本发布之后,SpringDoc 对最新版本的兼容性更好,而且 SpringDoc 支持 Swagger 页面 Oauth2 登录,因此使用 SpringDoc 是更好的选择,下面我将主要介绍 SpringDoc 集成。 + +## Spring Boot 集成 springdoc-openapi + +### 添加依赖 + +向 `pom.xml` 文件中添加如下依赖: + +```xml + + org.springdoc + springdoc-openapi-ui + 1.5.4 + +``` + +### 添加配置类 + +在 `com.shiyanlou.file.config` 包下创建 `OpenApiConfig.java` 文件,写入如下代码: + +```java +package com.shiyanlou.file.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.ExternalDocumentation; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; + +@Configuration +public class OpenApiConfig { + @Bean + public OpenAPI qiwenFileOpenAPI() { + return new OpenAPI() + .info(new Info().title("网盘项目 API") + .description("基于springboot + vue 框架开发的Web文件系统,旨在为用户提供一个简单、方便的文件存储方案,能够以完善的目录结构体系,对文件进行管理 。") + .version("v1.0.0") + .license(new License().name("MIT").url("http://springdoc.org"))) + .externalDocs(new ExternalDocumentation() + .description("网盘gitee地址") + .url("https://www.gitee.com/qiwen-cloud/qiwen-file")); + } +} +``` + +### 注解介绍 + +#### @Tag 注解 + +该注解可以用在类或方法上,当作用在方法是用来定义单个操作,当作用在类上代表所有操作。 + +| 属性 | 描述 | +| ------------ | ------------------------ | +| name | 标签名 | +| description | 这里可以做一个简短的描述 | +| externalDocs | 添加一个扩展文档 | +| extensions | 可选的扩展列表 | + +#### @Operation 注解 + +该注解可用于将资源方法定义为 OpenAPI 操作,在该注解中也可以定义该操作的其他属性。 + +| 属性 | 描述 | +| ----------- | ------------------------------ | +| method | HTTP 请求方法 | +| tags | 按照资源对操作进行逻辑分组 | +| summary | 提供此操作的简要说明。 | +| description | 对操作的详细描述 | +| requestBody | 与操作关联的请求报文 | +| parameters | 一个可选的参数数组 | +| responses | 执行此操作返回的可能响应的列表 | +| deprecated | 允许将操作标记为已弃用 | +| security | 可用于此操作的安全机制的声明。 | + +#### @Schema 注解 + +该注解用来定义模型,主要用来定义模型类及模型的属性,请求和响应的内容、报文头等。 + +| 属性 | 描述 | +| ----------- | -------------------------------- | +| not | 提供用于禁止匹配属性的 java 类。 | +| name | 用于描述模型类或属性的名称 | +| title | 用于描述模型类的标题 | +| maximum | 设置属性的最大数值。 | +| minimum | 设置属性的最小数值。 | +| maxLength | 设置字符串值的最大长度。 | +| minLength | 设置字符串值的最大小度。 | +| pattern | 值必须满足的模式。 | +| required | 是否必输 | +| description | 描述 | +| nullable | 如果为 true 则可能为 null。 | +| example | 使用示例 | + +### 代码实现 + +#### 添加 Controller 接口信息 + +打开 `UserController.java` 类,在类上添加 `@Tag` 标签用来描述一组操作的信息,在方法上面添加 `@Operation` 注解,用来描述接口信息,代码如下: + +```java +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "user", description = "该接口为用户接口,主要做用户登录,注册和校验token") +@Slf4j +@RestController +@RequestMapping("/user") +public class UserController { + + ... + @Operation(summary = "用户注册", description = "注册账号", tags = {"user"}) + @PostMapping(value = "/register") + @ResponseBody + public RestResult addUser(@RequestBody RegisterDTO registerDTO) { + ... + } + + @Operation(summary = "用户登录", description = "用户登录认证后才能进入系统", tags = {"user"}) + @GetMapping("/login") + @ResponseBody + public RestResult userLogin(String telephone, String password) { + ... + } + + @Operation(summary = "检查用户登录信息", description = "验证token的有效性", tags = {"user"}) + @GetMapping("/checkuserlogininfo") + @ResponseBody + public RestResult checkToken(@RequestHeader("token") String token) { + + } + +} + +``` + +#### 添加实体类信息 + +在此之前我们在接口层使用了两个实体类,分别是 `RegisterDTO.java` 和 `LoginVO.java`,然后现在我们添加该实体类注解: + +```java +package com.shiyanlou.file.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description="注册DTO") +@Data +public class RegisterDTO { + @Schema(description="用户名") + private String username; + @Schema(description="手机号") + private String telephone; + @Schema(description="密码") + private String password; +} +``` + +```java +package com.shiyanlou.file.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description="登录VO") +@Data +public class LoginVO { + @Schema(description="用户名") + private String username; + @Schema(description="token") + private String token; +} +``` + +### 启动项目 + +启动 MySQL 数据库: + +```bash +sudo service mysql start +``` + +点击 run 启动项目,在控制台查看项目启动成功之后,点击右侧 Web 服务进行访问,在浏览器页面地址后面追加 `/swagger-ui.html` 查看效果,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/87c813893f75f5c22d4cfea7762e07e0-0/wm) + +点击其中的一个接口,查看信息: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/58e71d633523360752240a62ff58067d-0/wm) + +在 Request body 项点击 Schema 可以查看该模型类信息: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/edd0d0d4e530582ca6d1d1e69487ebe2-0/wm) + +从上图可以看出该模型的具体信息及其字段含义,整个文档在开发过程已经满足日常使用的要求,但是整体文档风格及排版不太符合大多数人的视觉体验,甚至有人觉得它丑,这个因人而异,因此下面我将介绍另一款工具 knife4j,来美化该文档。 + +### 项目集成 knife4j + +knife4j 在最新版本已经基本完全兼容了 springdoc-openapi-ui,因此在替换的时候将依赖替换掉就可以了,打开 `pom.xml` 文件,删除掉之前的 `springdoc-openapi-ui` 依赖,添加如下依赖: + +```xml + + com.github.xiaoymin + knife4j-spring-boot-starter + 3.0.2 + +``` + +替换完成之后,重新启动项目,在地址栏之后添加 `/doc.html` 即可访问文档,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/dde375aa04e3741fbe814c3c64f24422-0/wm) + +上面这种文档接口比较符合我们平时使用的 API 文档,而且各种信息也显示的比较清晰,然后在 `OpenApiConfig.java` 配置文件中添加如下配置: + +```java +package com.shiyanlou.file.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.ExternalDocumentation; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import springfox.documentation.builders.ApiInfoBuilder; +import springfox.documentation.builders.PathSelectors; +import springfox.documentation.builders.RequestHandlerSelectors; +import springfox.documentation.service.ApiInfo; +import springfox.documentation.spi.DocumentationType; +import springfox.documentation.spring.web.plugins.Docket; + +@Configuration +public class OpenApiConfig { +// @Bean +// public OpenAPI qiwenFileOpenAPI() { +// return new OpenAPI() +// .info(new Info().title("网盘项目 API") +// .description("基于springboot + vue 框架开发的Web文件系统,旨在为用户提供一个简单、方便的文件存储方案,能够以完善的目录结构体系,对文件进行管理 。") +// .version("v1.0.0") +// .license(new License().name("MIT").url("http://springdoc.org"))) +// .externalDocs(new ExternalDocumentation() +// .description("网盘gitee地址") +// .url("https://www.gitee.com/qiwen-cloud/qiwen-file")); +// } + + @Bean(value = "indexApi") + public Docket indexApi() { + return new Docket(DocumentationType.OAS_30) + .groupName("网站前端接口分组").apiInfo(apiInfo()) + .select() + .apis(RequestHandlerSelectors.basePackage("com.shiyanlou.file.controller")) + .paths(PathSelectors.any()) + .build(); + } + + private ApiInfo apiInfo() { + return new ApiInfoBuilder() + .title("网盘项目 API") + .description("基于springboot + vue 框架开发的Web文件系统,旨在为用户提供一个简单、方便的文件存储方案,能够以完善的目录结构体系,对文件进行管理 。") + .version("1.0") + .build(); + } +} +``` + +重新启动后,查看效果,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/89c90526e48209f3f5fc87090f01c37f-0/wm) + +## 接口测试 + +目前已经实现了三个接口,分别是用户登录,用户注册,检查用户登录信息。现在我们借助 knife4j 对接口进行测试。 + +### 用户注册接口测试 + +点击左侧用户注册接口入口,点击调试页签,此时进入到了接口调试界面,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/f1ee2bd180722b531cc2199c781a6749-0/wm) + +可以看到,POST 接口请求参数是使用 JSON 报文作为请求参数,输入如下请求报文点击发送: + +```json +{ + "password": "test", + "telephone": "12345678900", + "username": "test" +} +``` + +请求结果如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/34944db9649a93154696979ffa14f49d-0/wm) + +从上图返回成功可以看出,注册已经成功,接着我们使用该信息进行登录。 + +### 用户登录接口测试 + +点击左侧用户登录接口入口,点击调试页签,此时进入到了接口调试界面,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/11ac7d3f2a65fda1d07d3a7cc4fd2697-0/wm) + +从上图可以看出,GET 接口请求是使用类似表单作为请求参数,输入注册用户信息,进行登录,结果如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/012a67c28d773ece24c6e39aee1b6353-0/wm) + +### token 校验 + +登录成功之后,接口会返回给我们 token,现在我们调用校验接口对该 token 进行校验,来判断我们是否处于登录状态,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/fe84424124ccee28f2ccb3168abb2aa5-0/wm) + +从上图可以看出该接口返回成功,说明目前处于登录状态。 + +## 实验总结 + +本节实验需要掌握 springdoc-openapi 的集成,并熟练掌握各种注解的含义及其用法,这样才能给前台显示一份完美的 api 文档,同时也可以借助该工具进行接口测试,减少后续沟通成本。 + +本次实验完整代码可以通过如下命令进行下载: + +```bash +wget https://labfile.oss.aliyuncs.com/courses/8842/code6.zip +``` diff --git "a/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/10-466268-\345\220\216\345\217\260\346\216\245\345\217\243\345\274\200\345\217\221\345\256\236\346\210\2301\342\200\224\346\226\207\344\273\266\345\244\271\347\232\204\345\210\233\345\273\272\345\217\212\346\226\207\344\273\266\345\210\227\350\241\250\346\237\245\350\257\242\346\216\245\345\217\243\345\274\200\345\217\221.sy.md" "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/10-466268-\345\220\216\345\217\260\346\216\245\345\217\243\345\274\200\345\217\221\345\256\236\346\210\2301\342\200\224\346\226\207\344\273\266\345\244\271\347\232\204\345\210\233\345\273\272\345\217\212\346\226\207\344\273\266\345\210\227\350\241\250\346\237\245\350\257\242\346\216\245\345\217\243\345\274\200\345\217\221.sy.md" new file mode 100755 index 0000000..cbfc890 --- /dev/null +++ "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/10-466268-\345\220\216\345\217\260\346\216\245\345\217\243\345\274\200\345\217\221\345\256\236\346\210\2301\342\200\224\346\226\207\344\273\266\345\244\271\347\232\204\345\210\233\345\273\272\345\217\212\346\226\207\344\273\266\345\210\227\350\241\250\346\237\245\350\257\242\346\216\245\345\217\243\345\274\200\345\217\221.sy.md" @@ -0,0 +1,680 @@ +--- +show: step +version: 1.0 +enable_checker: true +--- + +# 后台接口开发实战 1—文件夹的创建及文件列表查询接口开发 + +## 实验介绍 + +在之前的课程已经完成了项目开发的基本工作,这节实验开始真正的文件接口开发,将分为三个实验来完成,由浅入深,本节实验主要完成最简单的文件操作,包括文件夹的创建和查询接口,作为后面实验课程的基础。 + +#### 知识点 + +- 文件夹创建接口实现 +- 文件列表查询接口实现 +- 文件分类查看接口实现 + +#### 开发计划 + +- 开发内容:开发文件夹创建及文件列表查询接口。 + +- 开发耗时:实验预计完成时间为 2~4 小时 +- 开发难点:熟练掌握接口开发流程及测试流程。 + +## 文件代码类创建 + +在之前数据库设计的时候,关于文件操作主要有两张表,分别是 `file` 和 `userfile` 表,下面我们完善这两张表基础代码。 + +### Mapper 层接口 + +在 `com.shiyanlou.file.mapper` 下之前已经创建好了 `FileMapper.java` 和 `UserfileMapper.java` 接口类,现在我们将这两个类都继承 BaseMapper,以便我们之后使用 MyBatis-Plus 来操作数据库,代码如下: + +#### FileMapper 接口 + +```java +package com.shiyanlou.file.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.shiyanlou.file.model.File; + +public interface FileMapper extends BaseMapper { + +} +``` + +#### UserfileMapper 接口 + +```java +package com.shiyanlou.file.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.shiyanlou.file.model.UserFile; + +public interface UserfileMapper extends BaseMapper { + +} +``` + +### Service 接口 + +在 `com.shiyanlou.file.service` 包下创建文件 Service 层接口 `FileService.java`,并初始化如下内容: + +```java +package com.shiyanlou.file.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.shiyanlou.file.model.File; + +public interface FileService extends IService { + +} +``` + +在 `com.shiyanlou.file.service` 包下创建用户文件 Service 层接口 `UserfileService.java`,并初始化如下内容: + +```java +package com.shiyanlou.file.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.shiyanlou.file.model.UserFile; + +public interface UserfileService extends IService { + +} +``` + +### Service 实现 + +在 `com.shiyanlou.file.service.impl` 包下创建 `FileService.java` 接口的实现类 `FileServiceImpl.java`,代码如下: + +```java +package com.shiyanlou.file.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.shiyanlou.file.mapper.FileMapper; +import com.shiyanlou.file.model.File; +import com.shiyanlou.file.service.FileService; + +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class FileServiceImpl extends ServiceImpl implements FileService { + +} +``` + +在 `com.shiyanlou.file.service.impl` 包下创建 `UserfileService.java` 接口的实现类 `UserfileServiceImpl.java`,代码如下: + +```java +package com.shiyanlou.file.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.shiyanlou.file.mapper.UserfileMapper; +import com.shiyanlou.file.model.UserFile; +import com.shiyanlou.file.service.UserfileService; + +import org.springframework.stereotype.Service; + +@Service +public class UserfileServiceImpl extends ServiceImpl implements UserfileService { + +} +``` + +在 `UserServiceImpl.java` 中添加 `getUserByToken` 方法,该方法通过 token 获取到用户信息,后面会频繁用到,可以用来校验 token,此段代码依赖 JWTUtil,需要在顶部注入,代码如下: + +```java +import com.fasterxml.jackson.databind.ObjectMapper; +import com.shiyanlou.file.util.JWTUtil; +import io.jsonwebtoken.Claims; + +@Slf4j +@Service +public class UserServiceImpl extends ServiceImpl implements UserService{ + + @Resource + JWTUtil jwtUtil; + ... + @Override + public User getUserByToken(String token) { + User tokenUserInfo = null; + try { + + Claims c = jwtUtil.parseJWT(token); + String subject = c.getSubject(); + ObjectMapper objectMapper = new ObjectMapper(); + tokenUserInfo = objectMapper.readValue(subject, User.class); + + } catch (Exception e) { + log.error("解码异常"); + return null; + + } + return tokenUserInfo; + } +} +``` + +当调用该方法,如果返回 null,则认为 token 是无效的。 + +向 `UserService.java` 文件中补充如下代码: + +```java + User getUserByToken(String token); +``` + +## 文件夹创建接口开发 + +#### DTO 实体类 + +在 `com.shiyanlou.file.dto` 包下创建 `CreateFileDTO.java` 实体类,代码如下: + +```java +package com.shiyanlou.file.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(name = "创建文件DTO",required = true) +public class CreateFileDTO { + @Schema(description="文件名") + private String fileName; + @Schema(description="文件路径") + private String filePath; +} +``` + +#### Controller 控制器层 + +在 `com.shiyanlou.file.controller` 包下创建 `FileController.java` 实体类,并增加文件夹创建接口,接口代码如下: + +```java +package com.shiyanlou.file.controller; + +import java.util.List; + +import javax.annotation.Resource; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.shiyanlou.file.common.RestResult; +import com.shiyanlou.file.dto.CreateFileDTO; +import com.shiyanlou.file.model.User; +import com.shiyanlou.file.model.UserFile; +import com.shiyanlou.file.service.FileService; +import com.shiyanlou.file.service.UserService; +import com.shiyanlou.file.service.UserfileService; +import com.shiyanlou.file.util.DateUtil; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; + + +@Tag(name = "file", description = "该接口为文件接口,主要用来做一些文件的基本操作,如创建目录,删除,移动,复制等。") +@RestController +@Slf4j +@RequestMapping("/file") +public class FileController { + + @Resource + FileService fileService; + @Resource + UserService userService; + @Resource + UserfileService userfileService; + + @Operation(summary = "创建文件", description = "目录(文件夹)的创建", tags = {"file"}) + @PostMapping(value = "/createfile") + @ResponseBody + public RestResult createFile(@RequestBody CreateFileDTO createFileDto, @RequestHeader("token") String token) { + + + User sessionUser = userService.getUserByToken(token); + if (sessionUser == null) { + RestResult.fail().message("token认证失败"); + } + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.eq(UserFile::getFileName, "").eq(UserFile::getFilePath, "").eq(UserFile::getUserId, 0); + List userfiles = userfileService.list(lambdaQueryWrapper); + if (!userfiles.isEmpty()) { + RestResult.fail().message("同目录下文件名重复"); + } + + UserFile userFile = new UserFile(); + userFile.setUserId(sessionUser.getUserId()); + userFile.setFileName(createFileDto.getFileName()); + userFile.setFilePath(createFileDto.getFilePath()); + userFile.setIsDir(1); + userFile.setUploadTime(DateUtil.getCurrentTime()); + + userfileService.save(userFile); + return RestResult.success(); + } +} +``` + +## 文件列表查询接口开发 + +#### DTO 实体类 + +创建 DTO 实体类 `UserfileListDTO.java`,用来作为文件列表查询接口接收前台请求信息的载体,代码如下: + +```java +package com.shiyanlou.file.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(name = "文件列表DTO",required = true) +public class UserfileListDTO { + @Schema(description = "文件路径") + private String filePath; + @Schema(description = "当前页码") + private Long currentPage; + @Schema(description = "一页显示数量") + private Long pageCount; +} +``` + +#### VO 实体类 + +创建 VO 实体类 `UserfileListVO.java`,用来作为文件列表查询接口给前台返回的信息载体,代码如下: + +```java +package com.shiyanlou.file.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description="用户文件列表VO") +public class UserfileListVO { + @Schema(description="文件id") + private Long fileId; + @Schema(description="时间戳名称") + private String timeStampName; + @Schema(description="文件url") + private String fileUrl; + @Schema(description="文件大小") + private Long fileSize; + @Schema(description="是否是oss存储") + private Integer isOSS; + @Schema(description="引用数量") + private Integer pointCount; + @Schema(description="md5") + private String identifier; + @Schema(description="用户文件id") + private Long userFileId; + @Schema(description="用户id") + private Long userId; + + @Schema(description="文件名") + private String fileName; + @Schema(description="文件路径") + private String filePath; + @Schema(description="扩展名") + private String extendName; + @Schema(description="是否是目录") + private Integer isDir; + @Schema(description="上传时间") + private String uploadTime; +} +``` + +#### DAO 层代码编写 + +在 `resources/mybatis/mapper` 路径下创建 `UserfileMapper.xml`,并初始化内容如下: + +```xml + + + + + + + +``` + +在 `UserfileMapper.xml` 文件中创建用户文件分页查询 sql,代码如下: + +```xml +... + + + + + +``` + +在 `UserfileMapper.java` 接口类中,新增文件查询接口,代码如下: + +```java +... +import java.util.List; +import com.shiyanlou.file.vo.UserfileListVO; + +public interface UserfileMapper extends BaseMapper { + List userfileList(UserFile userfile, Long beginCount, Long pageCount); + +} +``` + +#### Service 层代码编写 + +在 `UserfileService.java` 接口类中,新增获取文件列表接口,代码如下: + +```java +import java.util.List; +import com.shiyanlou.file.vo.UserfileListVO; + +public interface UserfileService extends IService { + List getUserFileByFilePath(String filePath, Long userId, Long currentPage, Long pageCount); +} +``` + +在 `UserfileServiceImpl.java` 中新增获取文件列表方法实现,代码如下: + +```java +package com.shiyanlou.file.service.impl; + +import java.util.List; + +import javax.annotation.Resource; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.shiyanlou.file.mapper.UserfileMapper; +import com.shiyanlou.file.model.UserFile; +import com.shiyanlou.file.service.UserfileService; +import com.shiyanlou.file.vo.UserfileListVO; +import org.springframework.stereotype.Service; + +@Service +public class UserfileServiceImpl extends ServiceImpl implements UserfileService { + + @Resource + UserfileMapper userfileMapper; + + @Override + public List getUserFileByFilePath(String filePath, Long userId, Long currentPage, Long pageCount) { + Long beginCount = (currentPage - 1) * pageCount; + UserFile userfile = new UserFile(); + userfile.setUserId(userId); + userfile.setFilePath(filePath); + List fileList = userfileMapper.userfileList(userfile, beginCount, pageCount); + return fileList; + } + +} +``` + +#### Controller 层代码编写 + +在 `FileController.java` 接口中新增获取文件接口 `getUserfileList`,代码如下: + +```java +import java.util.HashMap; +import java.util.Map; +import com.shiyanlou.file.vo.UserfileListVO; +import com.shiyanlou.file.dto.UserfileListDTO; + +@Tag(name = "file", description = "该接口为文件接口,主要用来做一些文件的基本操作,如创建目录,删除,移动,复制等。") +@RestController +@Slf4j +@RequestMapping("/file") +public class FileController { + ... + @Operation(summary = "获取文件列表", description = "用来做前台文件列表展示", tags = { "file" }) + @GetMapping(value = "/getfilelist") + @ResponseBody + public RestResult getUserfileList(UserfileListDTO userfileListDto, + @RequestHeader("token") String token) { + + + User sessionUser = userService.getUserByToken(token); + if (sessionUser == null) { + return RestResult.fail().message("token验证失败"); + + } + + List fileList = userfileService.getUserFileByFilePath(userfileListDto.getFilePath(), + sessionUser.getUserId(), userfileListDto.getCurrentPage(), userfileListDto.getPageCount()); + + LambdaQueryWrapper userFileLambdaQueryWrapper = new LambdaQueryWrapper<>(); + userFileLambdaQueryWrapper.eq(UserFile::getUserId, sessionUser.getUserId()).eq(UserFile::getFilePath, userfileListDto.getFilePath()); + int total = userfileService.count(userFileLambdaQueryWrapper); + + Map map = new HashMap<>(); + map.put("total", total); + map.put("list", fileList); + + return RestResult.success().data(map); + + } +} +``` + +## 文件分类查询接口开发 + +创建 `com.shiyanlou.file.constant` 包,并在该包下新建 `FileConstant.java` 类,用来存放常量类,首先在该类中先初始化好文件分类的常量,代码如下: + +```java +package com.shiyanlou.file.constant; + +public class FileConstant { + public static final String[] IMG_FILE = {"bmp", "jpg", "png", "tif", "gif", "jpeg"}; + public static final String[] DOC_FILE = {"doc", "docx", "ppt", "pptx", "xls", "xlsx", "txt", "hlp", "wps", "rtf", "html", "pdf"}; + public static final String[] VIDEO_FILE = {"avi", "mp4", "mpg", "mov", "swf"}; + public static final String[] MUSIC_FILE = {"wav", "aif", "au", "mp3", "ram", "wma", "mmf", "amr", "aac", "flac"}; + public static final int IMAGE_TYPE = 1; + public static final int DOC_TYPE = 2; + public static final int VIDEO_TYPE = 3; + public static final int MUSIC_TYPE = 4; + public static final int OTHER_TYPE = 5; +} +``` + +#### DAO 层代码编写 + +上面我们将常用的文件格式按照后缀分为 4 类,分别是图像类,文档类,视频类,音乐类,除了这 4 类的格式,其他格式我们统一放到其他类中,这样当做查询的时侯,查询具体某一类的文件,只需要传入对应的文件格式数组即可,查询其他类,则需要排除这些类,接下来我们首先来实现 sql,向 `UserfileMapper.xml` 文件中添加如下代码: + +```xml + + left join file on file.fileId = userfile.fileId + where extendName in + + #{fileName} + + and userId = #{userId} + + + left join file on file.fileId = userfile.fileId + where extendName not in + + #{fileName} + + and userId = #{userId} + + + + + + + + +``` + +上面写了 4 个 sql,接下来在 `UserfileMapper.java` 中新增对应的 Mapper 接口,代码如下: + +```java +List selectFileByExtendName(List fileNameList, Long beginCount, Long pageCount, long userId); +Long selectCountByExtendName(List fileNameList, Long beginCount, Long pageCount, long userId); +List selectFileNotInExtendNames(List fileNameList, Long beginCount, Long pageCount, long userId); +Long selectCountNotInExtendNames(List fileNameList, Long beginCount, Long pageCount, long userId); +``` + +#### Service 层代码编写 + +在 `UserfileService.java` 接口中,创建通过类型获取用户文件列表接口,代码如下: + +```java +... +import java.util.Map; +public interface UserfileService extends IService { + ... + + Map getUserFileByType(int fileType, Long currentPage, Long pageCount, Long userId); +} +``` + +向 `UserfileServiceImpl.java` 文件中补充如下代码: + +```java +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import com.shiyanlou.file.constant.FileConstant; + +@Service +public class UserfileServiceImpl extends ServiceImpl implements UserfileService { + ... + @Override + public Map getUserFileByType(int fileType, Long currentPage, Long pageCount, Long userId) { + Long beginCount = (currentPage - 1) * pageCount; + List fileList; + Long total; + if (fileType == FileConstant.OTHER_TYPE) { + + List arrList = new ArrayList<>(); + arrList.addAll(Arrays.asList(FileConstant.DOC_FILE)); + arrList.addAll(Arrays.asList(FileConstant.IMG_FILE)); + arrList.addAll(Arrays.asList(FileConstant.VIDEO_FILE)); + arrList.addAll(Arrays.asList(FileConstant.MUSIC_FILE)); + + fileList = userfileMapper.selectFileNotInExtendNames(arrList,beginCount, pageCount, userId); + total = userfileMapper.selectCountNotInExtendNames(arrList,beginCount, pageCount, userId); + } else { + List fileExtends = null; + if (fileType == FileConstant.IMAGE_TYPE) { + fileExtends = Arrays.asList(FileConstant.IMG_FILE); + } else if (fileType == FileConstant.DOC_TYPE) { + fileExtends = Arrays.asList(FileConstant.DOC_FILE); + } else if (fileType == FileConstant.VIDEO_TYPE) { + fileExtends = Arrays.asList(FileConstant.VIDEO_FILE); + } else if (fileType == FileConstant.MUSIC_TYPE) { + fileExtends = Arrays.asList(FileConstant.MUSIC_FILE); + } + fileList = userfileMapper.selectFileByExtendName(fileExtends, beginCount, pageCount,userId); + total = userfileMapper.selectCountByExtendName(fileExtends, beginCount, pageCount,userId); + } + Map map = new HashMap<>(); + map.put("list",fileList); + map.put("total", total); + return map; + } +} +``` + +#### Controller 层代码编写 + +向 `FileController.java` 文件中补充如下代码: + +```java +@Operation(summary = "通过文件类型选择文件", description = "该接口可以实现文件格式分类查看", tags = {"file"}) +@GetMapping(value = "/selectfilebyfiletype") +@ResponseBody +public RestResult>> selectFileByFileType(int fileType, Long currentPage, Long pageCount, @RequestHeader("token") String token) { + + User sessionUser = userService.getUserByToken(token); + if (sessionUser == null) { + return RestResult.fail().message("token验证失败"); + } + long userId = sessionUser.getUserId(); + + Map map = userfileService.getUserFileByType(fileType, currentPage, pageCount, userId); + return RestResult.success().data(map); + +} +``` + +## 测试 + +启动 MySQL 数据库: + +```bash +sudo service mysql start +``` + +点击 run 启动项目,在控制台查看项目启动成功之后,点击右侧 Web 服务进行访问。在地址栏之后添加 `/doc.html` 即可访问文档。 + +先使用登录接口获取 token,然后点击创建文件按钮,进行接口测试,在请求头部填入 token 串,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/b847abdc7dbde9af54a6027858640fbb-0/wm) + +#### 创建文件接口测试 + +在根目录下创建文件夹,名称为 `测试1`,点击发送,如果显示成功,则说明文件夹创建成功,如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/3d560e1cb85f220b44302d2706e1e909-0/wm) + +#### 获取文件接口测试 + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/2c50e3cd131814e60758525ddbb9507d-0/wm) + +#### 通过文件类型选择文件接口测试 + +由于目前上传文件接口还没有开发完成,因此通过文件类型选择文件接口只需要返回成功即可,测试结果如下图: + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/bb29794159545597aa666704c87ae800-0/wm) + +## 实验总结 + +本实验主要是对网盘项目最基本的接口开发和测试,为后面开发文件上传功能打下基础。 + +本次实验完整代码可以通过如下命令进行下载: + +```bash +wget https://labfile.oss.aliyuncs.com/courses/8842/code7.zip +``` diff --git "a/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/11-466269-\345\220\216\345\217\260\346\216\245\345\217\243\345\274\200\345\217\221\345\256\236\346\210\2302\342\200\224\346\226\207\344\273\266\345\210\207\347\211\207\344\270\212\344\274\240\345\222\214\346\236\201\351\200\237\344\270\212\344\274\240\346\216\245\345\217\243\345\274\200\345\217\221.sy.md" "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/11-466269-\345\220\216\345\217\260\346\216\245\345\217\243\345\274\200\345\217\221\345\256\236\346\210\2302\342\200\224\346\226\207\344\273\266\345\210\207\347\211\207\344\270\212\344\274\240\345\222\214\346\236\201\351\200\237\344\270\212\344\274\240\346\216\245\345\217\243\345\274\200\345\217\221.sy.md" new file mode 100755 index 0000000..fe0fef6 --- /dev/null +++ "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/11-466269-\345\220\216\345\217\260\346\216\245\345\217\243\345\274\200\345\217\221\345\256\236\346\210\2302\342\200\224\346\226\207\344\273\266\345\210\207\347\211\207\344\270\212\344\274\240\345\222\214\346\236\201\351\200\237\344\270\212\344\274\240\346\216\245\345\217\243\345\274\200\345\217\221.sy.md" @@ -0,0 +1,1204 @@ +--- +show: step +version: 1.0 +enable_checker: true +--- + +# 后台接口开发实战 2—文件切片上传和极速上传接口开发 + +## 实验介绍 + +本节实验带领大家来完成文件的切片上传和极速上传接口开发,并了解其原理。 + +#### 知识点 + +- 抽象工厂方法 +- 上传文件接口开发 +- 下载文件接口开发 + +#### 开发计划 + +- 开发内容:文件切片上传和极速上传后台接口开发。 + +- 开发耗时:实验预计完成时间为 4~6 小时 +- 开发难点: +1. 熟练使用抽象工厂模式,能够借助类图进行代码结构设计。 +2. 理解文件切片上传原理。 + +## 引入依赖 + +下面引入两个依赖,分别是 `commons-io` 和 `thumbnailator` 在 commons-io 中封装了很多对输入输出流的操作,这在后面的开发过程中需要用到,`thumbnailator` 是一个用来生成图像缩略图的 Java 类库,通过很简单的代码即可生成图片缩略图,也可直接对一整个目录的图片生成缩略图,向 `pom.xml` 文件中添加如下代码: + +```xml + + commons-io + commons-io + 2.8.0 + + + net.coobird + thumbnailator + 0.4.13 + + + com.alibaba + fastjson + 1.2.75 + +``` + +## 配置文件 + +Spring Boot 默认会限制文件上传文件大小 2M,超过该大小的文件都会上传失败,因此需要在配置文件中修改该限制,向 `application.properties` 文件中补充如下代码: + +```java +#上传下载 +spring.servlet.multipart.max-file-size=100MB +spring.servlet.multipart.max-request-size=100MB +spring.servlet.multipart.enabled=true + +#文件存储类型 +file.storage-type=0 +``` + +## 创建统一异常类 + +#### 文件 md5 校验失败异常 + +在 `com.shiyanlou.file` 包下创建 `exception` 文件夹,并在该文件夹下创建 `NotSameFileExpection.java` 文件,写入如下代码: + +```java +package com.shiyanlou.file.exception; + +public class NotSameFileExpection extends Exception { + public NotSameFileExpection() { + super("File MD5 Different"); + } +} +``` + +#### 上传异常 + +在 `com.shiyanlou.file.exception` 包下创建 `UploadException.java` 文件,向其中写入如下代码: + +```java +package com.shiyanlou.file.exception; + +public class UploadException extends RuntimeException { + public UploadException(Throwable cause) { + super("上传出现了异常", cause); + } + + public UploadException(String message) { + super(message); + } + + public UploadException(String message, Throwable cause) { + super(message, cause); + } +} +``` + +## 创建常用工具类 + +#### 获取配置文件类 + +在 Spring Boot 中,有多种方式可以轻松获取到 application.properties 配置文件中的配置参数,但提供的默认都是基于 Spring 体系,当我们在静态工具类中想要获取配置参数,则需要使用如下方式进行获取: + +在 `com.shiyanlou.file.config` 包下新建 `PropertiesConfig.java` 类,用来读取环境变量: + +```java +package com.shiyanlou.file.config; + +import javax.annotation.PostConstruct; +import javax.annotation.Resource; + +import com.shiyanlou.file.util.PropertiesUtil; + +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +@Configuration +public class PropertiesConfig { + + @Resource + private Environment env; + + @PostConstruct + public void setProperties() { + PropertiesUtil.setEnvironment(env); + } + +} +``` + +在 `com.shiyanlou.file.util` 包下,创建 `PropertiesUtil.java`,通过该类的 `getProperty` 方法,可以获取 `application.properties` 中的配置,代码如下: + +```java +package com.shiyanlou.file.util; + +import org.springframework.core.env.Environment; + +public class PropertiesUtil { + private static Environment env = null; + + public static void setEnvironment(Environment env) { + PropertiesUtil.env = env; + } + + public static String getProperty(String key) { + return PropertiesUtil.env.getProperty(key); + } + +} +``` + +#### 文件工具类 + +在 `com.shiyanlou.file.util` 包下创建 `FileUtil.java` 类,该类用来定义文件的一些常用操作方法,代码如下: + +```java +package com.shiyanlou.file.util; + +public class FileUtil { + public static final String[] IMG_FILE = {"bmp", "jpg", "png", "tif", "gif", "jpeg"}; + public static final String[] DOC_FILE = {"doc", "docx", "ppt", "pptx", "xls", "xlsx", "txt", "hlp", "wps", "rtf", "html", "pdf"}; + public static final String[] VIDEO_FILE = {"avi", "mp4", "mpg", "mov", "swf"}; + public static final String[] MUSIC_FILE = {"wav", "aif", "au", "mp3", "ram", "wma", "mmf", "amr", "aac", "flac"}; + public static final int IMAGE_TYPE = 1; + public static final int DOC_TYPE = 2; + public static final int VIDEO_TYPE = 3; + public static final int MUSIC_TYPE = 4; + public static final int OTHER_TYPE = 5; + public static final int SHARE_FILE = 6; + public static final int RECYCLE_FILE = 7; + /** + * 判断是否为图片文件 + * + * @param extendName 文件扩展名 + * @return 是否为图片文件 + */ + public static boolean isImageFile(String extendName) { + for (int i = 0; i < IMG_FILE.length; i++) { + if (extendName.equalsIgnoreCase(IMG_FILE[i])) { + return true; + } + } + return false; + } + /** + * 获取文件扩展名,如果没有扩展名,则返回空串 + * @param fileName 文件名 + * @return 文件扩展名 + */ + public static String getFileExtendName(String fileName) { + if (fileName.lastIndexOf(".") == -1) { + return ""; + } + return fileName.substring(fileName.lastIndexOf(".") + 1); + } + + /** + * 获取不包含扩展名的文件名 + * + * @param fileName 文件名 + * @return 文件名(不带扩展名) + */ + public static String getFileNameNotExtend(String fileName) { + String fileType = getFileExtendName(fileName); + return fileName.replace("." + fileType, ""); + } +} +``` + +#### 路径工具类 + +在 `com.shiyanlou.file.util` 包下创建 `PathUtil.java` 类,该类用来获取文件路径,代码如下: + +```java +package com.shiyanlou.file.util; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.text.SimpleDateFormat; +import java.util.Date; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.util.ResourceUtils; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class PathUtil { + + public static String getFilePath() { + + String path = "upload"; + SimpleDateFormat formater = new SimpleDateFormat("yyyyMMdd"); + path = File.separator + path + File.separator + formater.format(new Date()); + + String staticPath = PathUtil.getStaticPath(); + + File dir = new File(staticPath + path); + if (!dir.exists()) { + try { + boolean isSuccessMakeDir = dir.mkdirs(); + if (!isSuccessMakeDir) { + log.error("目录创建失败:" + PathUtil.getStaticPath() + path); + } + } catch (Exception e) { + log.error("目录创建失败" + PathUtil.getStaticPath() + path); + return ""; + } + } + return path; + } + + public static String getStaticPath() { + String localStoragePath = PropertiesUtil.getProperty("file.local-storage-path"); + if (StringUtils.isNotEmpty(localStoragePath)) { + return localStoragePath; + }else { + String projectRootAbsolutePath = getProjectRootPath(); + + int index = projectRootAbsolutePath.indexOf("file:"); + if (index != -1) { + projectRootAbsolutePath = projectRootAbsolutePath.substring(0, index); + } + + return projectRootAbsolutePath + "static" + File.separator; + } + + + } + + /** + * 路径解码 + * @param url + * @return + */ + public static String urlDecode(String url){ + String decodeUrl = null; + try { + decodeUrl = URLDecoder.decode(url, "utf-8"); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + } + return decodeUrl; + } + + /** + * 获取项目所在的根目录路径 resources路径 + * @return + */ + public static String getProjectRootPath() { + String absolutePath = null; + try { + String url = ResourceUtils.getURL("classpath:").getPath(); + absolutePath = urlDecode(new File(url).getAbsolutePath()) + File.separator; + } catch (FileNotFoundException e) { + e.printStackTrace(); + } + + return absolutePath; + } + +} +``` + +## 文件操作 + +下面通过代码来讲解如何对文件进行上传和下载,文件的存储形式有多种,我们可以选择将文件存储在本地,阿里云 OSS,七牛云或者 FastDFS 等等,可以说存储的方式有很多种,这里我们只实现其中的一种,但是为了以后便于扩展,我们需要通过抽象工厂的设计模式来对代码结构进行设计,这样做的好处就是能保证我们写的代码易于扩展,且对之前的代码没有影响。 + +### 抽象工厂模式类图 + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/a61ef206557119979d85aec3f993c904-0/wm) + +如上图,是我对整个文件操作的一个类图设计,最上层封装了三个具体的操作,分别是上传,下载和删除,然后第二层是对抽象接口的一个实现,因为目前我们只实现本地文件的操作,因此这里的实现类只有一个,如果后续有其他方式存储方式,可以继续横向扩展其实现类,再下面分别是抽象工厂和具体的工厂,主要是为了提供给外部来使用,整体的规划就是这样,接下来是对代码的实现。 + +### 文件上传 + +前端上传文件时如果文件很大,上传时会出现各种问题,比如连接超时了,网断了,都会导致上传失败。为了避免上传大文件时上传超时,就需要用到切片上传,工作原理是:我们将大文件切割为小文件,然后将切割的若干小文件上传到服务器端,服务器端接收到被切割的小文件,然后按照一定的顺序将小文件拼接合并成一个大文件。 + +#### 抽象类接口 + +创建 `com.shiyanlou.file.operation.upload` 包,并在该包下创建 `Uploader.java` 类,代码如下: + +```java +package com.shiyanlou.file.operation.upload; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; + +import javax.servlet.http.HttpServletRequest; + +import com.shiyanlou.file.operation.upload.domain.UploadFile; +import com.shiyanlou.file.util.PathUtil; + +import org.apache.commons.io.FileUtils; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public abstract class Uploader { + public static final String ROOT_PATH = "upload"; + public static final String FILE_SEPARATOR = "/"; + // 文件大小限制,单位KB + public final int maxSize = 10000000; + + public abstract List upload(HttpServletRequest request, UploadFile uploadFile); + + /** + * 根据字符串创建本地目录 并按照日期建立子目录返回 + * + * @return + */ + protected String getSaveFilePath() { + + String path = ROOT_PATH; + SimpleDateFormat formater = new SimpleDateFormat("yyyyMMdd"); + path = FILE_SEPARATOR + path + FILE_SEPARATOR + formater.format(new Date()); + + String staticPath = PathUtil.getStaticPath(); + + File dir = new File(staticPath + path); + //LOG.error(PathUtil.getStaticPath() + path); + if (!dir.exists()) { + try { + boolean isSuccessMakeDir = dir.mkdirs(); + if (!isSuccessMakeDir) { + log.error("目录创建失败:" + PathUtil.getStaticPath() + path); + } + } catch (Exception e) { + log.error("目录创建失败" + PathUtil.getStaticPath() + path); + return ""; + } + } + return path; + } + + /** + * 依据原始文件名生成新文件名 + * + * @return + */ + protected String getTimeStampName() { + try { + SecureRandom number = SecureRandom.getInstance("SHA1PRNG"); + return "" + number.nextInt(10000) + + System.currentTimeMillis(); + } catch (NoSuchAlgorithmException e) { + log.error("生成安全随机数失败"); + } + return "" + + System.currentTimeMillis(); + + } + + public synchronized boolean checkUploadStatus(UploadFile param, File confFile) throws IOException { + RandomAccessFile confAccessFile = new RandomAccessFile(confFile, "rw"); + //设置文件长度 + confAccessFile.setLength(param.getTotalChunks()); + //设置起始偏移量 + confAccessFile.seek(param.getChunkNumber() - 1); + //将指定的一个字节写入文件中 127, + confAccessFile.write(Byte.MAX_VALUE); + byte[] completeStatusList = FileUtils.readFileToByteArray(confFile); + confAccessFile.close();//不关闭会造成无法占用 + //创建conf文件文件长度为总分片数,每上传一个分块即向conf文件中写入一个127,那么没上传的位置就是默认的0,已上传的就是127 + for (int i = 0; i < completeStatusList.length; i++) { + if (completeStatusList[i] != Byte.MAX_VALUE) { + return false; + } + } + confFile.delete(); + return true; + } + + protected String getFileName(String fileName){ + if (!fileName.contains(".")) { + return fileName; + } + return fileName.substring(0, fileName.lastIndexOf(".")); + } +} +``` + +#### 本地上传实现类 + +创建 `com.shiyanlou.file.operation.upload.product` 包,该包用于存放各种方式上传实现,目前我们暂时只实现本地文件上传方式,在该包下创建 `LocalStorageUploader.java` 文件,并向其中写入如下代码: + +```java +package com.shiyanlou.file.operation.upload.product; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.UUID; + +import javax.servlet.http.HttpServletRequest; + +import com.shiyanlou.file.exception.NotSameFileExpection; +import com.shiyanlou.file.exception.UploadException; +import com.shiyanlou.file.operation.upload.Uploader; +import com.shiyanlou.file.operation.upload.domain.UploadFile; +import com.shiyanlou.file.util.FileUtil; +import com.shiyanlou.file.util.PathUtil; + +import org.apache.commons.lang3.StringUtils; +import org.apache.tomcat.util.http.fileupload.servlet.ServletFileUpload; +import org.springframework.stereotype.Component; +import org.springframework.util.DigestUtils; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.support.StandardMultipartHttpServletRequest; + +import lombok.extern.slf4j.Slf4j; +import net.coobird.thumbnailator.Thumbnails; + +@Component +public class LocalStorageUploader extends Uploader{ + + public LocalStorageUploader() { + + } + + @Override + public List upload(HttpServletRequest httpServletRequest,UploadFile uploadFile) { + List saveUploadFileList = new ArrayList(); + StandardMultipartHttpServletRequest standardMultipartHttpServletRequest = (StandardMultipartHttpServletRequest) httpServletRequest; + boolean isMultipart = ServletFileUpload.isMultipartContent(standardMultipartHttpServletRequest); + if (!isMultipart) { + throw new UploadException("未包含文件上传域"); + } + + String savePath = getSaveFilePath(); + + try { + Iterator iter = standardMultipartHttpServletRequest.getFileNames(); + while (iter.hasNext()) { + saveUploadFileList = doUpload(standardMultipartHttpServletRequest, savePath, iter, uploadFile); + } + } catch (IOException e) { + throw new UploadException("未包含文件上传域"); + } catch (NotSameFileExpection notSameFileExpection) { + notSameFileExpection.printStackTrace(); + } + return saveUploadFileList; + } + + private List doUpload(StandardMultipartHttpServletRequest standardMultipartHttpServletRequest, String savePath, Iterator iter, UploadFile uploadFile) throws IOException, NotSameFileExpection { + List saveUploadFileList = new ArrayList(); + MultipartFile multipartfile = standardMultipartHttpServletRequest.getFile(iter.next()); + + String timeStampName = uploadFile.getIdentifier(); + + String originalName = multipartfile.getOriginalFilename(); + + String fileName = getFileName(originalName); + String fileType = FileUtil.getFileExtendName(originalName); + uploadFile.setFileName(fileName); + uploadFile.setFileType(fileType); + uploadFile.setTimeStampName(timeStampName); + + String saveFilePath = savePath + FILE_SEPARATOR + timeStampName + "." + fileType; + String tempFilePath = savePath + FILE_SEPARATOR + timeStampName + "." + fileType + "_tmp"; + String minFilePath = savePath + FILE_SEPARATOR + timeStampName + "_min" + "." + fileType; + String confFilePath = savePath + FILE_SEPARATOR + timeStampName + "." + "conf"; + File file = new File(PathUtil.getStaticPath() + FILE_SEPARATOR + saveFilePath); + File tempFile = new File(PathUtil.getStaticPath() + FILE_SEPARATOR + tempFilePath); + File minFile = new File(PathUtil.getStaticPath() + FILE_SEPARATOR + minFilePath); + File confFile = new File(PathUtil.getStaticPath() + FILE_SEPARATOR + confFilePath); + // uploadFile.setIsOSS(0); + // uploadFile.setStorageType(0); + uploadFile.setUrl(saveFilePath); + + if (StringUtils.isEmpty(uploadFile.getTaskId())) { + uploadFile.setTaskId(UUID.randomUUID().toString()); + } + + //第一步 打开将要写入的文件 + RandomAccessFile raf = new RandomAccessFile(tempFile, "rw"); + //第二步 打开通道 + FileChannel fileChannel = raf.getChannel(); + //第三步 计算偏移量 + long position = (uploadFile.getChunkNumber() - 1) * uploadFile.getChunkSize(); + //第四步 获取分片数据 + byte[] fileData = multipartfile.getBytes(); + //第五步 写入数据 + fileChannel.position(position); + fileChannel.write(ByteBuffer.wrap(fileData)); + fileChannel.force(true); + fileChannel.close(); + raf.close(); + //判断是否完成文件的传输并进行校验与重命名 + boolean isComplete = checkUploadStatus(uploadFile, confFile); + if (isComplete) { + FileInputStream fileInputStream = new FileInputStream(tempFile.getPath()); + String md5 = DigestUtils.md5DigestAsHex(fileInputStream); + fileInputStream.close(); + if (StringUtils.isNotBlank(md5) && !md5.equals(uploadFile.getIdentifier())) { + throw new NotSameFileExpection(); + } + tempFile.renameTo(file); + if (FileUtil.isImageFile(uploadFile.getFileType())){ + Thumbnails.of(file).size(300, 300).toFile(minFile); + } + + uploadFile.setSuccess(1); + uploadFile.setMessage("上传成功"); + } else { + uploadFile.setSuccess(0); + uploadFile.setMessage("未完成"); + } + uploadFile.setFileSize(uploadFile.getTotalSize()); + saveUploadFileList.add(uploadFile); + + return saveUploadFileList; + } +} +``` + +#### 本地上传文件实体类 + +创建 `com.shiyanlou.file.operation.upload.domain` 包,在该包下创建 `UploadFile.java` 文件,并向其中写入如下代码: + +```java +package com.shiyanlou.file.operation.upload.domain; + +import lombok.Data; + +@Data +public class UploadFile { + private String fileName; + private String fileType; + private long fileSize; + private String timeStampName; + private int success; + private String message; + private String url; + //切片上传相关参数 + private String taskId; + private int chunkNumber; + private long chunkSize; + private int totalChunks; + private String identifier; + private long totalSize; + private long currentChunkSize; + +} +``` + +### 文件下载 + +#### 抽象类接口 + +创建 `com.shiyanlou.file.operation.download` 包,并创建 `Downloader.java` 类,代码如下: + +```java +package com.shiyanlou.file.operation.download; + +import javax.servlet.http.HttpServletResponse; + +import com.shiyanlou.file.operation.download.domain.DownloadFile; + +public abstract class Downloader { + public abstract void download(HttpServletResponse httpServletResponse, DownloadFile uploadFile); +} +``` + +#### 本地下载实现类 + +创建 `com.shiyanlou.file.operation.download.product` 包,该包用于存放各种方式下载实现,目前我们暂时只实现本地文件下载方式,并创建 `LocalStorageDownloader.java` 类,代码如下: + +```java +package com.shiyanlou.file.operation.download.product; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.OutputStream; + +import javax.servlet.http.HttpServletResponse; + +import com.shiyanlou.file.operation.download.Downloader; +import com.shiyanlou.file.operation.download.domain.DownloadFile; +import com.shiyanlou.file.util.PathUtil; + +import org.springframework.stereotype.Component; + +@Component +public class LocalStorageDownloader extends Downloader { + @Override + public void download(HttpServletResponse httpServletResponse, DownloadFile downloadFile) { + BufferedInputStream bis = null; + byte[] buffer = new byte[1024]; + //设置文件路径 + File file = new File(PathUtil.getStaticPath() + downloadFile.getFileUrl()); + if (file.exists()) { + + FileInputStream fis = null; + + try { + fis = new FileInputStream(file); + bis = new BufferedInputStream(fis); + OutputStream os = httpServletResponse.getOutputStream(); + int i = bis.read(buffer); + while (i != -1) { + os.write(buffer, 0, i); + i = bis.read(buffer); + } + + } catch (Exception e) { + e.printStackTrace(); + } finally { + if (bis != null) { + try { + bis.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } + } +} +``` + +#### 本地下载文件实体类 + +创建 `com.shiyanlou.file.operation.download.domain` 包,并创建本地下载文件实体类 `DownloadFile.java`,代码如下: + +```java +package com.shiyanlou.file.operation.download.domain; + +import lombok.Data; + +@Data +public class DownloadFile { + private String fileUrl; + private String timeStampName; +} +``` + +### 文件删除 + +#### 抽象类接口 + +创建 `com.shiyanlou.file.operation.delete` 包,并在该包下创建 `Deleter.java` 类,代码如下: + +```java +package com.shiyanlou.file.operation.delete; + +public abstract class Deleter { + public abstract void delete(DeleteFile deleteFile); +} +``` + +#### 本地删除实现类 + +创建 `com.shiyanlou.file.operation.delete.product` 包,该包用于存放各种方式删除实现,目前我们暂时只实现本地文件删除方式,并在该包下创建 `LocalStorageDeleter.java` 类,代码如下: + +```java +package com.shiyanlou.file.operation.delete.product; + +import java.io.File; + +import com.shiyanlou.file.operation.delete.Deleter; +import com.shiyanlou.file.operation.delete.domain.DeleteFile; +import com.shiyanlou.file.util.FileUtil; +import com.shiyanlou.file.util.PathUtil; + +import org.springframework.stereotype.Component; + +@Component +public class LocalStorageDeleter extends Deleter { + @Override + public void delete(DeleteFile deleteFile) { + File file = new File(PathUtil.getStaticPath() + deleteFile.getFileUrl()); + if (file.exists()) { + file.delete(); + } + + if (FileUtil.isImageFile(FileUtil.getFileExtendName(deleteFile.getFileUrl()))) { + File minFile = new File(PathUtil.getStaticPath() + deleteFile.getFileUrl().replace(deleteFile.getTimeStampName(), deleteFile.getTimeStampName() + "_min")); + if (minFile.exists()) { + minFile.delete(); + } + } + } +} +``` + +#### 删除文件实体类 + +创建 `com.shiyanlou.file.operation.delete.domain` 包,并创建删除文件实体类 `DeleteFile.java`,代码如下: + +```java +package com.shiyanlou.file.operation.delete.domain; + +import lombok.Data; + +@Data +public class DeleteFile { + private String fileUrl; + private String timeStampName; +} +``` + +## 创建文件操作工厂 + +#### 抽象工厂 + +在 `com.shiyanlou.file.operation` 包下创建类 `FileOperationFactory.java`,代码如下: + +```java +package com.shiyanlou.file.operation; + +import com.shiyanlou.file.operation.delete.Deleter; +import com.shiyanlou.file.operation.download.Downloader; +import com.shiyanlou.file.operation.upload.Uploader; + +public interface FileOperationFactory { + Uploader getUploader(); + Downloader getDownloader(); + Deleter getDeleter(); +} +``` + +#### 具体工厂 + +在 `com.shiyanlou.file.operation` 包下创建类 `LocalStorageOperationFactory.java`,代码如下: + +```java +package com.shiyanlou.file.operation; + +import javax.annotation.Resource; + +import com.shiyanlou.file.operation.delete.Deleter; +import com.shiyanlou.file.operation.delete.product.LocalStorageDeleter; +import com.shiyanlou.file.operation.download.Downloader; +import com.shiyanlou.file.operation.download.product.LocalStorageDownloader; +import com.shiyanlou.file.operation.upload.Uploader; +import com.shiyanlou.file.operation.upload.product.LocalStorageUploader; + +import org.springframework.stereotype.Component; + +@Component +public class LocalStorageOperationFactory implements FileOperationFactory{ + + @Resource + LocalStorageUploader localStorageUploader; + @Resource + LocalStorageDownloader localStorageDownloader; + @Resource + LocalStorageDeleter localStorageDeleter; + @Override + public Uploader getUploader() { + return localStorageUploader; + } + + @Override + public Downloader getDownloader() { + return localStorageDownloader; + } + + @Override + public Deleter getDeleter() { + return localStorageDeleter; + } + + +} +``` + +## 上传下载接口实现 + +#### 上传下载文件 DTO + +在 `com.shiyanlou.file.dto` 包下创建上传文件 DTO `UploadFileDTO.java`,代码如下: + +```java +package com.shiyanlou.file.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(name = "上传文件DTO",required = true) +public class UploadFileDTO { + + @Schema(description = "文件路径") + private String filePath; + + @Schema(description = "上传时间") + private String uploadTime; + + @Schema(description = "扩展名") + private String extendName; + + + @Schema(description = "文件名") + private String filename; + + @Schema(description = "文件大小") + private Long fileSize; + + @Schema(description = "切片数量") + private int chunkNumber; + + @Schema(description = "切片大小") + private long chunkSize; + + @Schema(description = "所有切片") + private int totalChunks; + @Schema(description = "总大小") + private long totalSize; + @Schema(description = "当前切片大小") + private long currentChunkSize; + @Schema(description = "md5码") + private String identifier; + +} +``` + +在 `com.shiyanlou.file.dto` 包下创建下载文件 DTO `DownloadFileDTO.java`,代码如下: + +```java +package com.shiyanlou.file.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(name = "下载文件DTO",required = true) +public class DownloadFileDTO { + private Long userFileId; +} +``` + +#### 上传文件 VO + +在 `com.shiyanlou.file.vo` 包下创建上传文件 VO `UploadFileVo.java`,代码如下: + +```java +package com.shiyanlou.file.vo; + +import java.util.List; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(name = "文件上传Vo",required = true) +public class UploadFileVo { + + @Schema(description = "时间戳", example = "123123123123") + private String timeStampName; + @Schema(description = "跳过上传", example = "true") + private boolean skipUpload; + @Schema(description = "是否需要合并分片", example = "true") + private boolean needMerge; + @Schema(description = "已经上传的分片", example = "[1,2,3]") + private List uploaded; + + +} +``` + +### 文件上传下载 Service 层代码 + +#### Service 接口层代码实现 + +在 `com.shiyanlou.file.service` 包下创建 `FiletransferService.java` 类,代码如下: + +```java +package com.shiyanlou.file.service; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.shiyanlou.file.dto.DownloadFileDTO; +import com.shiyanlou.file.dto.UploadFileDTO; + +public interface FiletransferService { + void uploadFile(HttpServletRequest request, UploadFileDTO uploadFileDto, Long userId); + void downloadFile(HttpServletResponse httpServletResponse, DownloadFileDTO downloadFileDTO); +} +``` + +#### Service 实现层代码实现 + +在 `com.shiyanlou.file.service.impl` 包下创建 `FiletransferServiceImpl.java` 类,代码如下: + +```java +package com.shiyanlou.file.service.impl; + +import java.io.UnsupportedEncodingException; +import java.util.List; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.shiyanlou.file.dto.DownloadFileDTO; +import com.shiyanlou.file.dto.UploadFileDTO; +import com.shiyanlou.file.mapper.FileMapper; +import com.shiyanlou.file.mapper.UserfileMapper; +import com.shiyanlou.file.model.File; +import com.shiyanlou.file.model.UserFile; +import com.shiyanlou.file.operation.FileOperationFactory; +import com.shiyanlou.file.operation.download.Downloader; +import com.shiyanlou.file.operation.download.domain.DownloadFile; +import com.shiyanlou.file.operation.upload.Uploader; +import com.shiyanlou.file.operation.upload.domain.UploadFile; +import com.shiyanlou.file.service.FiletransferService; +import com.shiyanlou.file.util.DateUtil; +import com.shiyanlou.file.util.PropertiesUtil; + +import org.springframework.stereotype.Service; + +@Service +public class FiletransferServiceImpl implements FiletransferService{ + + @Resource + FileMapper fileMapper; + @Resource + UserfileMapper userfileMapper; + + @Resource + FileOperationFactory localStorageOperationFactory; + + @Override + public void uploadFile(HttpServletRequest request, UploadFileDTO uploadFileDto, Long userId) { + + Uploader uploader = null; + UploadFile uploadFile = new UploadFile(); + uploadFile.setChunkNumber(uploadFileDto.getChunkNumber()); + uploadFile.setChunkSize(uploadFileDto.getChunkSize()); + uploadFile.setTotalChunks(uploadFileDto.getTotalChunks()); + uploadFile.setIdentifier(uploadFileDto.getIdentifier()); + uploadFile.setTotalSize(uploadFileDto.getTotalSize()); + uploadFile.setCurrentChunkSize(uploadFileDto.getCurrentChunkSize()); + String storageType = PropertiesUtil.getProperty("file.storage-type"); + synchronized (FiletransferService.class) { + if ("0".equals(storageType)) { + uploader = localStorageOperationFactory.getUploader(); + } + } + + List uploadFileList = uploader.upload(request, uploadFile); + for (int i = 0; i < uploadFileList.size(); i++){ + uploadFile = uploadFileList.get(i); + File file = new File(); + + file.setIdentifier(uploadFileDto.getIdentifier()); + file.setStorageType(Integer.parseInt(storageType)); + file.setTimeStampName(uploadFile.getTimeStampName()); + if (uploadFile.getSuccess() == 1){ + file.setFileUrl(uploadFile.getUrl()); + file.setFileSize(uploadFile.getFileSize()); + file.setPointCount(1); + fileMapper.insert(file); + UserFile userFile = new UserFile(); + userFile.setFileId(file.getFileId()); + userFile.setExtendName(uploadFile.getFileType()); + userFile.setFileName(uploadFile.getFileName()); + userFile.setFilePath(uploadFileDto.getFilePath()); + //userFile.setDeleteFlag(0); + userFile.setUserId(userId); + userFile.setIsDir(0); + userFile.setUploadTime(DateUtil.getCurrentTime()); + userfileMapper.insert(userFile); + } + + } + } + + @Override + public void downloadFile(HttpServletResponse httpServletResponse, DownloadFileDTO downloadFileDTO) { + UserFile userFile = userfileMapper.selectById(downloadFileDTO.getUserFileId()); + + String fileName = userFile.getFileName() + "." + userFile.getExtendName(); + try { + fileName = new String(fileName.getBytes("utf-8"), "ISO-8859-1"); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + } + httpServletResponse.setContentType("application/force-download");// 设置强制下载不打开 + httpServletResponse.addHeader("Content-Disposition", "attachment;fileName=" + fileName);// 设置文件名 + + + File file = fileMapper.selectById(userFile.getFileId()); + Downloader downloader = null; + if (file.getStorageType() == 0) { + downloader = localStorageOperationFactory.getDownloader(); + } + DownloadFile uploadFile = new DownloadFile(); + uploadFile.setFileUrl(file.getFileUrl()); + uploadFile.setTimeStampName(file.getTimeStampName()); + downloader.download(httpServletResponse, uploadFile); + } +} +``` + +向 `com.shiyanlou.file.model` 包下的 `File.java` 文件中添加下面三个属性,其中: + +**storageType** 用来保存文件的存储类型。 + +**identifier** 保存文件的md5唯一标识,这个唯一标识是文件极速秒传的关键,当检测上传文件的 md5 已存在,则文件已存在于服务器,文件直接返回上传成功。 + +**pointCount** 用来保存文件的引用数量,当上传文件在服务器已存在,则 pointCount 加1,文件删除的时候减1,此时如果引用数量大于0,则文件逻辑删除,等于0时文件需要彻底物理删除,代码如下: + +```java + @Column(columnDefinition="int(1) comment '存储类型 0-本地存储, 1-阿里云存储, 2-FastDFS存储'") + private Integer storageType; + + @Column(columnDefinition="varchar(32) comment 'md5唯一标识'") + private String identifier; + + @Column(columnDefinition="int(1) comment '引用数量'") + private Integer pointCount; +``` + +## 文件传输类接口创建 + +在 `com.shiyanlou.file.controller` 包下创建 `FiletransferController.java` 类,这个类专门用来作为文件传输接口,主要职责为文件上传,下载及删除,代码如下: + +```java +package com.shiyanlou.file.controller; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import com.shiyanlou.file.dto.DownloadFileDTO; +import com.shiyanlou.file.common.RestResult; +import com.shiyanlou.file.dto.UploadFileDTO; +import com.shiyanlou.file.model.File; +import com.shiyanlou.file.model.User; +import com.shiyanlou.file.model.UserFile; +import com.shiyanlou.file.service.FileService; +import com.shiyanlou.file.service.FiletransferService; +import com.shiyanlou.file.service.UserService; +import com.shiyanlou.file.service.UserfileService; +import com.shiyanlou.file.util.DateUtil; +import com.shiyanlou.file.util.FileUtil; +import com.shiyanlou.file.vo.UploadFileVo; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "filetransfer", description = "该接口为文件传输接口,主要用来做文件的上传和下载") +@RestController +@RequestMapping("/filetransfer") +public class FiletransferController { + + @Resource + UserService userService; + @Resource + FileService fileService; + @Resource + UserfileService userfileService; + @Resource + FiletransferService filetransferService; + + @Operation(summary = "极速上传", description = "校验文件MD5判断文件是否存在,如果存在直接上传成功并返回skipUpload=true,如果不存在返回skipUpload=false需要再次调用该接口的POST方法", tags = {"filetransfer"}) + @GetMapping(value = "/uploadfile") + @ResponseBody + public RestResult uploadFileSpeed(UploadFileDTO uploadFileDto, @RequestHeader("token") String token) { + + User sessionUser = userService.getUserByToken(token); + if (sessionUser == null){ + + return RestResult.fail().message("未登录"); + } + + UploadFileVo uploadFileVo = new UploadFileVo(); + Map param = new HashMap(); + param.put("identifier", uploadFileDto.getIdentifier()); + synchronized (FiletransferController.class) { + List list = fileService.listByMap(param); + if (list != null && !list.isEmpty()) { + File file = list.get(0); + + UserFile userfile = new UserFile(); + userfile.setFileId(file.getFileId()); + userfile.setUserId(sessionUser.getUserId()); + userfile.setFilePath(uploadFileDto.getFilePath()); + String fileName = uploadFileDto.getFilename(); + userfile.setFileName(fileName.substring(0, fileName.lastIndexOf("."))); + userfile.setExtendName(FileUtil.getFileExtendName(fileName)); + userfile.setIsDir(0); + userfile.setUploadTime(DateUtil.getCurrentTime()); + userfileService.save(userfile); + // fileService.increaseFilePointCount(file.getFileId()); + uploadFileVo.setSkipUpload(true); + + } else { + uploadFileVo.setSkipUpload(false); + + } + } + return RestResult.success().data(uploadFileVo); + + } + + @Operation(summary = "上传文件", description = "真正的上传文件接口", tags = {"filetransfer"}) + @RequestMapping(value = "/uploadfile", method = RequestMethod.POST) + @ResponseBody + public RestResult uploadFile(HttpServletRequest request, UploadFileDTO uploadFileDto, @RequestHeader("token") String token) { + + User sessionUser = userService.getUserByToken(token); + if (sessionUser == null){ + return RestResult.fail().message("未登录"); + } + + + filetransferService.uploadFile(request, uploadFileDto, sessionUser.getUserId()); + UploadFileVo uploadFileVo = new UploadFileVo(); + return RestResult.success().data(uploadFileVo); + + } + + @Operation(summary = "下载文件", description = "下载文件接口", tags = {"filetransfer"}) + @RequestMapping(value = "/downloadfile", method = RequestMethod.GET) + public void downloadFile(HttpServletResponse response, DownloadFileDTO downloadFileDTO) { + filetransferService.downloadFile(response, downloadFileDTO); + } +} +``` + +## 实验总结 + +由于上传下载文件接口的特殊性,本节实验暂时不对接口进行测试,等到后面进行前后端联调的时候去对文件上传下载功能做一个系统性的测试,另外在编写代码的时候,不能只以实现功能为目标,更要考虑以后的扩展性。 + +本次实验完整代码可以通过如下命令进行下载: + +```bash +wget https://labfile.oss.aliyuncs.com/courses/8842/code8.zip +``` \ No newline at end of file diff --git "a/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/12-466270-\345\220\216\345\217\260\346\216\245\345\217\243\345\274\200\345\217\221\345\256\236\346\210\2303\342\200\224\346\226\207\344\273\266\347\247\273\345\212\250\343\200\201\345\210\240\351\231\244\343\200\201\351\207\215\345\221\275\345\220\215\346\216\245\345\217\243\345\274\200\345\217\221.sy.md" "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/12-466270-\345\220\216\345\217\260\346\216\245\345\217\243\345\274\200\345\217\221\345\256\236\346\210\2303\342\200\224\346\226\207\344\273\266\347\247\273\345\212\250\343\200\201\345\210\240\351\231\244\343\200\201\351\207\215\345\221\275\345\220\215\346\216\245\345\217\243\345\274\200\345\217\221.sy.md" new file mode 100755 index 0000000..c78230b --- /dev/null +++ "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/12-466270-\345\220\216\345\217\260\346\216\245\345\217\243\345\274\200\345\217\221\345\256\236\346\210\2303\342\200\224\346\226\207\344\273\266\347\247\273\345\212\250\343\200\201\345\210\240\351\231\244\343\200\201\351\207\215\345\221\275\345\220\215\346\216\245\345\217\243\345\274\200\345\217\221.sy.md" @@ -0,0 +1,1036 @@ +--- +show: step +version: 1.0 +enable_checker: true +--- + +# 后台接口开发实战 3—文件移动、删除、重命名接口开发 + +## 实验介绍 + +本节实验带领大家来完成文件的移动、删除、重命名接口开发。 + +#### 知识点 + +- 数据结构 +- 多线程 +- 文件移动,删除,重命名接口开发 + +#### 开发计划 + +- 开发内容:文件移动、删除、重命名接口开发。 + +- 开发耗时:实验预计完成时间为 4~6 小时 +- 开发难点: +1. 理解删除文件的原理,逻辑删除和物理删除的区别。 +2. 递归算法的实际应用。 + +## 删除文件接口开发 + +下图为删除文件流程图,整个删除的过程其实只是做标记,并非真正的删除数据和删除磁盘文件,这样做的目的是为了后续回收站功能扩展。 + +![图片描述](https://doc.shiyanlou.com/courses/3472/1557563/ffe14fda1acf0aa7063f58676dbd4d51-0/wm) + +### Model 层实体类添加 + +打开 `com.shiyanlou.file.model` 包,并在该包下添加 `RecoveryFile.java` 类,每次删除文件操作都会记录到该表,用于后续文件恢复使用,代码如下: + +```java +package com.shiyanlou.file.model; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import lombok.Data; + +@Data +@Table(name = "recoveryfile") +@Entity +@TableName("recoveryfile") +public class RecoveryFile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @TableId(type = IdType.AUTO) + @Column(columnDefinition="bigint(20)") + private Long recoveryFileId; + @Column(columnDefinition = "bigint(20)") + private Long userFileId; + @Column(columnDefinition="varchar(25)") + private String deleteTime; + @Column(columnDefinition = "varchar(50)") + private String deleteBatchNum; +} +``` + +打开 `com.shiyanlou.file.model` 包,在 `UserFile.java` 实体类中添加三个属性,代码如下: + +```java + +@Column(columnDefinition="int(11) comment '删除标志 0-未删除 1-已删除'") +private Integer deleteFlag; + +@Column(columnDefinition="varchar(25) comment '删除时间'") +private String deleteTime; + +@Column(columnDefinition = "varchar(50) comment '删除批次号'") +private String deleteBatchNum; +``` + +上面增加了删除文件标识 `deleteFlag`,当我们新增文件或者上传文件的时候,应该将该标志置为 0,删除之后置为 1,因此在之前已经实现了的新增文件夹及上传文件的时候,需要给该字段设置为 0,打开 `FileController.java` 类,修改 `createFile` 接口如下: + +```java +... +public RestResult createFile(@RequestBody CreateFileDTO createFileDto, + @RequestHeader("token") String token) { + ... + userFile.setIsDir(1); + userFile.setUploadTime(DateUtil.getCurrentTime()); + //***修改点***,添加下面这一行代码 + userFile.setDeleteFlag(0); + userfileService.save(userFile); + return RestResult.success(); +} +``` + +打开 `FiletransferServiceImpl.java` 类,修改 `uploadFile` 接口如下: + +```java +... +@Override +public void uploadFile(HttpServletRequest request, UploadFileDTO uploadFileDto, Long userId) { + ... + userFile.setFileName(uploadFile.getFileName()); + userFile.setFilePath(uploadFileDto.getFilePath()); + //***修改点***,添加下面这一行代码 + userFile.setDeleteFlag(0); + userFile.setUserId(userId); + ... +} +``` + +打开 `FiletransferController.java` 类,修改 `uploadFileSpeed` 接口如下: + +```java +... +public RestResult uploadFileSpeed(UploadFileDTO uploadFileDto, @RequestHeader("token") String token) { + ... + userfile.setIsDir(0); + userfile.setUploadTime(DateUtil.getCurrentTime()); + //***修改点***,添加下面这一行代码 + userfile.setDeleteFlag(0); + userfileService.save(userfile); + // fileService.increaseFilePointCount(file.getFileId()); + uploadFileVo.setSkipUpload(true); + ... +} +``` + +### DTO 和 VO 实体类 + +在 `com.shiyanlou.file.dto` 包下创建 `DeleteFileDTO.java`实体类,作为删除文件接口参数,代码如下: + +```java +package com.shiyanlou.file.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(name = "删除文件DTO",required = true) +public class DeleteFileDTO { + @Schema(description = "用户文件id") + private Long userFileId; + @Schema(description = "文件路径") + @Deprecated + private String filePath; + @Schema(description = "文件名") + @Deprecated + private String fileName; + @Schema(description = "是否是目录") + @Deprecated + private Integer isDir; +} +``` + +继续在该包下创建 `BatchDeleteFileDTO.java` 实体类,作为批量删除文件接口参数,代码如下: + +```java +package com.shiyanlou.file.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(name = "批量删除文件DTO",required = true) +public class BatchDeleteFileDTO { + @Schema(description="文件集合") + private String files; +} +``` + +### Service 层代码开发 + +打开 `com.shiyanlou.file.service` 包下的 `UserfileService.java` 类,并在该类中添加如下代码: + +```java +void deleteUserFile(Long userFileId, Long sessionUserId); +List selectFileTreeListLikeFilePath(String filePath, long userId); +``` + +打开 `com.shiyanlou.file.service.impl` 包下的 `UserfileServiceImpl` 类,并在该类中添加如下代码实现: + +```java +... +import java.util.UUID; +import com.shiyanlou.file.util.DateUtil; +import com.shiyanlou.file.model.File; +import com.shiyanlou.file.model.RecoveryFile; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.shiyanlou.file.mapper.RecoveryFileMapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.shiyanlou.file.mapper.FileMapper; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class UserfileServiceImpl extends ServiceImpl implements UserfileService { + public static Executor executor = Executors.newFixedThreadPool(20); + @Resource + UserfileMapper userfileMapper; + @Resource + FileMapper fileMapper; + @Resource + RecoveryFileMapper recoveryFileMapper; + ... + + @Override + public void deleteUserFile(Long userFileId, Long sessionUserId) { + + UserFile userFile = userfileMapper.selectById(userFileId); + String uuid = UUID.randomUUID().toString(); + if (userFile.getIsDir() == 1) { + LambdaUpdateWrapper userFileLambdaUpdateWrapper = new LambdaUpdateWrapper(); + userFileLambdaUpdateWrapper.set(UserFile::getDeleteFlag, 1) + .set(UserFile::getDeleteBatchNum, uuid) + .set(UserFile::getDeleteTime, DateUtil.getCurrentTime()) + .eq(UserFile::getUserFileId, userFileId); + userfileMapper.update(null, userFileLambdaUpdateWrapper); + + String filePath = userFile.getFilePath() + userFile.getFileName() + "/"; + updateFileDeleteStateByFilePath(filePath, userFile.getDeleteBatchNum(), sessionUserId); + + }else{ + + UserFile userFileTemp = userfileMapper.selectById(userFileId); + File file = fileMapper.selectById(userFileTemp.getFileId()); + + LambdaUpdateWrapper userFileLambdaUpdateWrapper = new LambdaUpdateWrapper<>(); + userFileLambdaUpdateWrapper.set(UserFile::getDeleteFlag, 1) + .set(UserFile::getDeleteTime, DateUtil.getCurrentTime()) + .set(UserFile::getDeleteBatchNum, uuid) + .eq(UserFile::getUserFileId, userFileTemp.getUserFileId()); + userfileMapper.update(null, userFileLambdaUpdateWrapper); + + } + + RecoveryFile recoveryFile = new RecoveryFile(); + recoveryFile.setUserFileId(userFileId); + recoveryFile.setDeleteTime(DateUtil.getCurrentTime()); + recoveryFile.setDeleteBatchNum(uuid); + recoveryFileMapper.insert(recoveryFile); + + + } + + + @Override + public List selectFileTreeListLikeFilePath(String filePath, long userId) { + //UserFile userFile = new UserFile(); + filePath = filePath.replace("\\", "\\\\\\\\"); + filePath = filePath.replace("'", "\\'"); + filePath = filePath.replace("%", "\\%"); + filePath = filePath.replace("_", "\\_"); + + //userFile.setFilePath(filePath); + + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + + log.info("查询文件路径:" + filePath); + + lambdaQueryWrapper.eq(UserFile::getUserId, userId).likeRight(UserFile::getFilePath, filePath); + return userfileMapper.selectList(lambdaQueryWrapper); + } + +} +``` + +在 `com.shiyanlou.file.mapper` 包下新建 `RecoveryFileMapper.java` 类,向其中写入如下代码: + +```java +package com.shiyanlou.file.mapper; + +import java.util.List; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.shiyanlou.file.model.RecoveryFile; +import com.shiyanlou.file.vo.RecoveryFileListVO; + +public interface RecoveryFileMapper extends BaseMapper { + List selectRecoveryFileList(); +} +``` + +在 `com.shiyanlou.file.vo` 包下新建 `RecoveryFileListVO.java` 类,向其中写入如下代码: + +```java +package com.shiyanlou.file.vo; + +public class RecoveryFileListVO { + +} +``` + +在 `com.shiyanlou.file.service.impl` 包下新建 `RecoveryFileServiceImpl.java` 类,向其中写入如下代码: + +```java +package com.shiyanlou.file.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.shiyanlou.file.mapper.RecoveryFileMapper; +import com.shiyanlou.file.model.RecoveryFile; +import com.shiyanlou.file.service.RecoveryFileService; + +public class RecoveryFileServiceImpl extends ServiceImpl + implements RecoveryFileService { + +} +``` + +在 `com.shiyanlou.file.service` 包下新建 `RecoveryFileService.java` 类,向其中写入如下代码: + +```java +package com.shiyanlou.file.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.shiyanlou.file.model.RecoveryFile; + +public interface RecoveryFileService extends IService { + +} +``` + +删除目录时需要将该文件目录下的所有文件都放入回收站,而代码实现则是通过一个删除标志来实现,为了防止文件目录下文件特别多,因此这里需要创建一个新的线程去执行,防止出现阻塞,继续向 `UserfileServiceImpl.java` 类中添加如下代码: + +```java +... +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +public class UserfileServiceImpl extends ServiceImpl implements UserfileService { + ... + private void updateFileDeleteStateByFilePath(String filePath, String deleteBatchNum, Long userId) { + new Thread(()->{ + List fileList = selectFileTreeListLikeFilePath(filePath, userId); + for (int i = 0; i < fileList.size(); i++){ + UserFile userFileTemp = fileList.get(i); + executor.execute(new Runnable() { + @Override + public void run() { + //标记删除标志 + LambdaUpdateWrapper userFileLambdaUpdateWrapper1 = new LambdaUpdateWrapper<>(); + userFileLambdaUpdateWrapper1.set(UserFile::getDeleteFlag, 1) + .set(UserFile::getDeleteTime, DateUtil.getCurrentTime()) + .set(UserFile::getDeleteBatchNum, deleteBatchNum) + .eq(UserFile::getUserFileId, userFileTemp.getUserFileId()) + .eq(UserFile::getDeleteFlag, 0); + userfileMapper.update(null, userFileLambdaUpdateWrapper1); + } + }); + + } + }).start(); + } + ... +} +``` + +### Controller 层代码开发 + +打开 `com.shiyanlou.file.controller` 包,在该包下的 `FileController.java` 文件中,添加单个文件删除和批量文件删除操作接口,代码如下: + +```java +... +import com.alibaba.fastjson.JSON; +import com.shiyanlou.file.dto.DeleteFileDTO; +import com.shiyanlou.file.dto.BatchDeleteFileDTO; + +public class FileController { + ... + @Operation(summary = "删除文件", description = "可以删除文件或者目录", tags = { "file" }) + @RequestMapping(value = "/deletefile", method = RequestMethod.POST) + @ResponseBody + public RestResult deleteFile(@RequestBody DeleteFileDTO deleteFileDto, @RequestHeader("token") String token) { + + User sessionUser = userService.getUserByToken(token); + + userfileService.deleteUserFile(deleteFileDto.getUserFileId(), sessionUser.getUserId()); + + return RestResult.success(); + + } + + @Operation(summary = "批量删除文件", description = "批量删除文件", tags = { "file" }) + @RequestMapping(value = "/batchdeletefile", method = RequestMethod.POST) + @ResponseBody + public RestResult deleteImageByIds(@RequestBody BatchDeleteFileDTO batchDeleteFileDto, + @RequestHeader("token") String token) { + + User sessionUser = userService.getUserByToken(token); + + List userFiles = JSON.parseArray(batchDeleteFileDto.getFiles(), UserFile.class); + for (UserFile userFile : userFiles) { + userfileService.deleteUserFile(userFile.getUserFileId(),sessionUser.getUserId()); + } + + return RestResult.success().message("批量删除文件成功"); + } +} +``` + +到此为止删除文件接口就开发完成了,我们会发现删除文件其实就是将删除标识从 0 修改为 1,接下来我们只需要在查询文件列表的时候把标记为 1 的文件过滤掉,只查询标记为 0 的文件,就实现了删除。 + +打开 `resource/mybatis/mapper` 路径,在 `UserfileMapper.xml` 文件中 **修改**查询文件列表接口,(注意下面代码实在之前的基础上修改),代码如下: + +```xml + + + + left join file on file.fileId = userfile.fileId + where extendName in + + #{fileName} + + and userId = #{userId} + and deleteFlag = 0 + + + left join file on file.fileId = userfile.fileId + where extendName not in + + #{fileName} + + and userId = #{userId} + and deleteFlag = 0 + +``` + +打开 `FileController.java` 类,修改获取文件查询列表接口,查询文件数量时增加删除标记条件,只查询未删除的文件,代码如下: + +```java +... +public RestResult getUserfileList(UserfileListDTO userfileListDto, + @RequestHeader("token") String token) { + ... + //***修改点***,修改下面这一行代码,在末尾进行补充 + userFileLambdaQueryWrapper.eq(UserFile::getUserId, sessionUser.getUserId()) + .eq(UserFile::getFilePath, userfileListDto.getFilePath()).eq(UserFile::getDeleteFlag, 0); + int total = userfileService.count(userFileLambdaQueryWrapper); + + Map map = new HashMap<>(); + map.put("total", total); + map.put("list", fileList); + + return RestResult.success().data(map); + +} +``` + +## 移动文件接口开发 + +在移动文件之前,需要对当前文件系统的目录结构进行显示,因此首先要做的就是展示当前目录树,代码如下: + +### DTO 和 VO 实体类 + +在包 `com.shiyanlou.file.vo` 下创建 `TreeNodeVO.java` 类,用来展示目录结构,代码如下: + +```java +package com.shiyanlou.file.vo; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +@Schema(name = "树节点VO",required = true) +@Data +public class TreeNodeVO { + + @Schema(description = "节点id") + private Long id; + + @Schema(description = "节点名") + private String label; + + @Schema(description = "深度") + private Long depth; + + @Schema(description = "是否被关闭") + private String state = "closed"; + + @Schema(description = "属性集合") + private Map attributes = new HashMap<>(); + + @Schema(description = "子节点列表") + private List children = new ArrayList<>(); + +} +``` + +在 `com.shiyanlou.file.dto` 包下创建 `MoveFileDTO.java` 类,并初始化内容如下: + +```java +@Data +@Schema(name = "移动文件DTO",required = true) +public class MoveFileDTO { + + @Schema(description = "文件路径") + private String filePath; + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "旧文件名") + private String oldFilePath; + @Schema(description = "扩展名") + private String extendName; + +} +``` + +继续在该包下创建 `BatchMoveFileDTO.java` 类,并初始化内容如下: + +```java +@Data +@Schema(name = "批量移动文件DTO",required = true) +public class BatchMoveFileDTO { + @Schema(description="文件集合") + private String files; + @Schema(description="文件路径") + private String filePath; +} +``` + +### DAO 层代码开发 + +打开 `com.shiyanlou.file.mapper` 包,在该包下 `UserfileMapper.java` 接口中,添加如下接口,代码如下: + +```java +void updateFilepathByFilepath(String oldfilePath, String newfilePath, Long userId); +``` + +打开 `resource/mybatis/mapper` 目录,在该目录下 `UserfileMapper.xml` 文件中,添加 MyBatis 脚本,代码如下: + +```xml + + UPDATE userfile SET filePath=REPLACE(filePath, #{param1}, #{param2}) + WHERE filePath like N'${param1}%' and userId = #{param3} + +``` + +### Service 层代码开发 + +打开 `com.shiyanlou.file.service` 包下的 `UserfileService.java` 类,并在该类中添加如下代码: + +```java +List selectFilePathTreeByUserId(Long userId); +void updateFilepathByFilepath(String oldfilePath, String newfilePath, String fileName, String extendName, Long userId); +``` + +打开 `com.shiyanlou.file.service.impl` 包下的 `UserfileServiceImpl.java` 类,并在该类中添加如下代码: + +```java +... +import org.apache.commons.lang3.StringUtils; + +public class UserfileServiceImpl extends ServiceImpl implements UserfileService { + ... + @Override + public void updateFilepathByFilepath(String oldfilePath, String newfilePath, String fileName, String extendName, Long userId) { + if ("null".equals(extendName)){ + extendName = null; + } + + LambdaUpdateWrapper lambdaUpdateWrapper = new LambdaUpdateWrapper(); + lambdaUpdateWrapper.set(UserFile::getFilePath, newfilePath) + .eq(UserFile::getFilePath, oldfilePath) + .eq(UserFile::getFileName, fileName) + .eq(UserFile::getUserId, userId); + if (StringUtils.isNotEmpty(extendName)) { + lambdaUpdateWrapper.eq(UserFile::getExtendName, extendName); + } else { + lambdaUpdateWrapper.isNull(UserFile::getExtendName); + } + userfileMapper.update(null, lambdaUpdateWrapper); + //移动子目录 + oldfilePath = oldfilePath + fileName + "/"; + newfilePath = newfilePath + fileName + "/"; + + oldfilePath = oldfilePath.replace("\\", "\\\\\\\\"); + oldfilePath = oldfilePath.replace("'", "\\'"); + oldfilePath = oldfilePath.replace("%", "\\%"); + oldfilePath = oldfilePath.replace("_", "\\_"); + + if (extendName == null) { //为null说明是目录,则需要移动子目录 + userfileMapper.updateFilepathByFilepath(oldfilePath, newfilePath, userId); + } + } + + ... + + @Override + public List selectFilePathTreeByUserId(Long userId) { LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.eq(UserFile::getUserId, userId) + .eq(UserFile::getIsDir, 1); + return userfileMapper.selectList(lambdaQueryWrapper); + } + + ... +} +``` + +### Controller 层代码开发 + +打开 `com.shiyanlou.file.controller` 包,在该包下的 `FileController.java` 文件中,添加获取文件树接口,代码如下: + +```java +import com.shiyanlou.file.vo.TreeNodeVO; +import java.util.Queue; +import java.util.LinkedList; + +public class FileController { + + ... + @Operation(summary = "获取文件树", description = "文件移动的时候需要用到该接口,用来展示目录树", tags = {"file"}) + @RequestMapping(value = "/getfiletree", method = RequestMethod.GET) + @ResponseBody + public RestResult getFileTree(@RequestHeader("token") String token){ + RestResult result = new RestResult(); + UserFile userFile = new UserFile(); + User sessionUser = userService.getUserByToken(token); + userFile.setUserId(sessionUser.getUserId()); + + List filePathList = userfileService.selectFilePathTreeByUserId(sessionUser.getUserId()); + TreeNodeVO resultTreeNode = new TreeNodeVO(); + resultTreeNode.setLabel("/"); + + for (int i = 0; i < filePathList.size(); i++){ + String filePath = filePathList.get(i).getFilePath() + filePathList.get(i).getFileName() + "/"; + + Queue queue = new LinkedList<>(); + + String[] strArr = filePath.split("/"); + for (int j = 0; j < strArr.length; j++){ + if (!"".equals(strArr[j]) && strArr[j] != null){ + queue.add(strArr[j]); + } + + } + if (queue.size() == 0){ + continue; + } + resultTreeNode = insertTreeNode(resultTreeNode,"/", queue); + + + } + result.setSuccess(true); + result.setData(resultTreeNode); + return result; + + } + ... +} +``` + +上面这段代码需要另外调用一段递归代码,这段代码逻辑主要是将查询出来的文件路径组装成树形结构,代码如下: + +```java +public TreeNodeVO insertTreeNode(TreeNodeVO treeNode, String filePath, Queue nodeNameQueue){ + + List childrenTreeNodes = treeNode.getChildren(); + String currentNodeName = nodeNameQueue.peek(); + if (currentNodeName == null){ + return treeNode; + } + + Map map = new HashMap<>(); + filePath = filePath + currentNodeName + "/"; + map.put("filePath", filePath); + + if (!isExistPath(childrenTreeNodes, currentNodeName)){ //1、判断有没有该子节点,如果没有则插入 + //插入 + TreeNodeVO resultTreeNode = new TreeNodeVO(); + + + resultTreeNode.setAttributes(map); + resultTreeNode.setLabel(nodeNameQueue.poll()); + // resultTreeNode.setId(treeid++); + + childrenTreeNodes.add(resultTreeNode); + + }else{ //2、如果有,则跳过 + nodeNameQueue.poll(); + } + + if (nodeNameQueue.size() != 0) { + for (int i = 0; i < childrenTreeNodes.size(); i++) { + + TreeNodeVO childrenTreeNode = childrenTreeNodes.get(i); + if (currentNodeName.equals(childrenTreeNode.getLabel())){ + childrenTreeNode = insertTreeNode(childrenTreeNode, filePath, nodeNameQueue); + childrenTreeNodes.remove(i); + childrenTreeNodes.add(childrenTreeNode); + treeNode.setChildren(childrenTreeNodes); + } + + } + }else{ + treeNode.setChildren(childrenTreeNodes); + } + + return treeNode; + +} + +public boolean isExistPath(List childrenTreeNodes, String path){ + boolean isExistPath = false; + + try { + for (int i = 0; i < childrenTreeNodes.size(); i++){ + if (path.equals(childrenTreeNodes.get(i).getLabel())){ + isExistPath = true; + } + } + }catch (Exception e){ + e.printStackTrace(); + } + + + return isExistPath; +} +``` + +上面已经可以将目录树展示出来了,接下来完成最后真正的移动文件接口,移动文件接口的本质其实就是将保存到数据库中的虚拟路径做一个修改即可,真实的保存在磁盘上的文件是不需要做任何变动的,代码如下: + +```java +... +import com.shiyanlou.file.dto.MoveFileDTO; +import com.shiyanlou.file.dto.BatchMoveFileDTO; + +public class FileController { + ... + @Operation(summary = "文件移动", description = "可以移动文件或者目录", tags = { "file" }) + @RequestMapping(value = "/movefile", method = RequestMethod.POST) + @ResponseBody + public RestResult moveFile(@RequestBody MoveFileDTO moveFileDto, @RequestHeader("token") String token) { + User sessionUser = userService.getUserByToken(token); + String oldfilePath = moveFileDto.getOldFilePath(); + String newfilePath = moveFileDto.getFilePath(); + String fileName = moveFileDto.getFileName(); + String extendName = moveFileDto.getExtendName(); + + userfileService.updateFilepathByFilepath(oldfilePath, newfilePath, fileName, extendName, sessionUser.getUserId()); + return RestResult.success(); + + } + + @Operation(summary = "批量移动文件", description = "可以同时选择移动多个文件或者目录", tags = { "file" }) + @RequestMapping(value = "/batchmovefile", method = RequestMethod.POST) + @ResponseBody + public RestResult batchMoveFile(@RequestBody BatchMoveFileDTO batchMoveFileDto, + @RequestHeader("token") String token) { + + User sessionUser = userService.getUserByToken(token); + String files = batchMoveFileDto.getFiles(); + String newfilePath = batchMoveFileDto.getFilePath(); + List userFiles = JSON.parseArray(files, UserFile.class); + + for (UserFile userFile : userFiles) { + userfileService.updateFilepathByFilepath(userFile.getFilePath(), newfilePath, userFile.getFileName(), + userFile.getExtendName(), sessionUser.getUserId()); + } + + return RestResult.success().data("批量移动文件成功"); + + } + ... +} +``` + +## 文件重命名接口开发 + +### DTO 和 VO 实体类开发 + +在包 `com.shiyanlou.file.dto` 下创建 `RenameFileDTO.java` 类,用来接受重命名接口传参,代码如下: + +```java +package com.shiyanlou.file.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(name = "重命名文件DTO",required = true) +public class RenameFileDTO { + private Long userFileId; + + @Schema(description = "文件名") + private String fileName; + +} +``` + +### DAO 层代码开发 + +打开 `com.shiyanlou.file.mapper` 包,在该包下 `UserfileMapper.java` 接口中,添加如下接口,代码如下: + +```java +import org.apache.ibatis.annotations.Param; +public interface UserfileMapper extends BaseMapper { + ... + void replaceFilePath(@Param("filePath") String filePath, @Param("oldFilePath") String oldFilePath, @Param("userId") Long userId); + Long selectStorageSizeByUserId(Long userId); +} +``` + +打开 `resource/mybatis/mapper` 目录,在该目录下 `UserfileMapper.xml` 文件中,添加 MyBatis 脚本,代码如下: + +```xml + + UPDATE userfile SET filepath=REPLACE(filepath, #{oldFilePath}, #{filePath}) + WHERE filepath LIKE N'${oldFilePath}%' and userId = #{userId}; + +``` + +### Service 层代码开发 + +打开 `com.shiyanlou.file.service` 包下的 `UserfileService.java` 类,并在该类中添加如下代码: + +```java +List selectUserFileByNameAndPath(String fileName, String filePath, Long userId); +void replaceUserFilePath(String filePath, String oldFilePath, Long userId); +``` + +打开 `com.shiyanlou.file.service.impl` 包下的 `UserfileServiceImpl` 类,并在该类中添加如下代码实现: + +```java + @Override + public List selectUserFileByNameAndPath(String fileName, String filePath, Long userId) { + LambdaQueryWrapper lambdaQueryWrapper = new LambdaQueryWrapper<>(); + lambdaQueryWrapper.eq(UserFile::getFileName, fileName) + .eq(UserFile::getFilePath, filePath) + .eq(UserFile::getUserId, userId) + .eq(UserFile::getDeleteFlag, "0"); + return userfileMapper.selectList(lambdaQueryWrapper); + } + + @Override + public void replaceUserFilePath(String filePath, String oldFilePath, Long userId) { + userfileMapper.replaceFilePath(filePath, oldFilePath, userId); + } +``` + +### Controller 层代码开发 + +打开 `com.shiyanlou.file.controller` 包,在该包下的 `FileController.java` 文件中,添加文件重命名接口,代码如下: + +```java +import com.shiyanlou.file.dto.RenameFileDTO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.shiyanlou.file.model.File; + +public class FileController { + ... + @Operation(summary = "文件重命名", description = "文件重命名", tags = {"file"}) + @RequestMapping(value = "/renamefile", method = RequestMethod.POST) + @ResponseBody + public RestResult renameFile(@RequestBody RenameFileDTO renameFileDto, @RequestHeader("token") String token) { + User sessionUser = userService.getUserByToken(token); + UserFile userFile = userfileService.getById(renameFileDto.getUserFileId()); + + List userFiles = userfileService.selectUserFileByNameAndPath(renameFileDto.getFileName(), userFile.getFilePath(), sessionUser.getUserId()); + if (userFiles != null && !userFiles.isEmpty()) { + return RestResult.fail().message("同名文件已存在"); + + } + if (1 == userFile.getIsDir()) { + LambdaUpdateWrapper lambdaUpdateWrapper = new LambdaUpdateWrapper<>(); + lambdaUpdateWrapper.set(UserFile::getFileName, renameFileDto.getFileName()) + .set(UserFile::getUploadTime, DateUtil.getCurrentTime()) + .eq(UserFile::getUserFileId, renameFileDto.getUserFileId()); + userfileService.update(lambdaUpdateWrapper); + userfileService.replaceUserFilePath(userFile.getFilePath() + renameFileDto.getFileName() + "/", + userFile.getFilePath() + userFile.getFileName() + "/", sessionUser.getUserId()); + } else { + File file = fileService.getById(userFile.getFileId()); + + LambdaUpdateWrapper lambdaUpdateWrapper = new LambdaUpdateWrapper<>(); + lambdaUpdateWrapper.set(UserFile::getFileName, renameFileDto.getFileName()) + .set(UserFile::getUploadTime, DateUtil.getCurrentTime()) + .eq(UserFile::getUserFileId, renameFileDto.getUserFileId()); + userfileService.update(lambdaUpdateWrapper); + + + } + + return RestResult.success(); + } + ... +} +``` + +## 获取存储信息接口 + +### Dao 层代码开发 + +打开 `com.shiyanlou.file.mapper` 包,在该包下 `UserfileMapper.java` 接口中,添加如下接口,代码如下: + +```java +Long selectStorageSizeByUserId(@Param("userId") Long userId); +``` + +打开 `resource/mybatis/mapper` 目录,在该目录下 `UserfileMapper.xml` 文件中,添加 MyBatis 脚本,代码如下: + +```xml + +``` + +### Service 层代码开发 + +打开 `com.shiyanlou.file.service` 包下的 `FiletransferService.java` 类,并在该类中添加如下代码: + +```java +Long selectStorageSizeByUserId(Long userId); +``` + +打开 `com.shiyanlou.file.service.impl` 包下的 `FiletransferServiceImpl.java` 类,并在该类中添加如下代码: + +```java +@Override +public Long selectStorageSizeByUserId(Long userId) { + return userfileMapper.selectStorageSizeByUserId(userId); +} +``` + +### Controller 层代码开发 + +打开 `com.shiyanlou.file.controller` 包,并在 `FiletransferController.java` 接口中添加如下代码: + +```java +... +import com.shiyanlou.file.model.Storage; + +public class FiletransferController { + ... + @Operation(summary = "获取存储信息", description = "获取存储信息", tags = {"filetransfer"}) + @RequestMapping(value = "/getstorage", method = RequestMethod.GET) + @ResponseBody + public RestResult getStorage(@RequestHeader("token") String token) { + + User sessionUserBean = userService.getUserByToken(token); + Storage storageBean = new Storage(); + + + Long storageSize = filetransferService.selectStorageSizeByUserId(sessionUserBean.getUserId()); + return RestResult.success().data(storageSize); + + } +} +``` + +打开 `com.shiyanlou.file.model` 包,新建 `Storage.java` 文件,并向其中写入如下代码: + +```java +package com.shiyanlou.file.model; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; + +import lombok.Data; + +@Data +@Table(name = "storage") +@Entity +@TableName("storage") +public class Storage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @TableId(type = IdType.AUTO) + @Column(columnDefinition="bigint(20)") + private Long storageId; + + @Column(columnDefinition="bigint(20)") + private Long userId; + + @Column(columnDefinition="bigint(20)") + private Long storageSize; + +} +``` + + +## 实验总结 + +本节实验带领大家完成了文件移动,删除,重命名接口的开发,到此为止整个文件管理的最基本的功能就已经开发完成了,但是这仅仅只是基础,后面可以根据自己的需要,自行设计及开发。 + +需要注意的是,目前的删除文件只是实现了文件的逻辑删除,并没有真正的从服务器删除掉,真正删除文件的操作可以在回收站中去删除,关于回收站的功能大家可以自行进行完善。 + +后台的课程到这里就结束了,相信大家已经掌握了一定的开发和设计能力,如果你有更多的想法,可以对该网盘继续进行开发。 + +本次实验完整代码可以通过如下命令进行下载: + +```bash +wget https://labfile.oss.aliyuncs.com/courses/8842/code9.zip +``` diff --git "a/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/13-466271-\351\207\207\347\224\250Vue CLI@4 + Element UI\346\220\255\345\273\272\345\211\215\347\253\257\345\267\245\347\250\213.sy.md" "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/13-466271-\351\207\207\347\224\250Vue CLI@4 + Element UI\346\220\255\345\273\272\345\211\215\347\253\257\345\267\245\347\250\213.sy.md" new file mode 100755 index 0000000..abfc88b --- /dev/null +++ "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/13-466271-\351\207\207\347\224\250Vue CLI@4 + Element UI\346\220\255\345\273\272\345\211\215\347\253\257\345\267\245\347\250\213.sy.md" @@ -0,0 +1,335 @@ +--- +show: step +version: 1.0 +enable_checker: true +--- + +# 采用 Vue CLI@4 + Element UI 搭建前端工程 + +## 实验介绍 + +本实验及后续章节将介绍网盘项目的前端工程,本次实验主要是介绍采用 Vue CLI@4 脚手架搭建前端项目的流程和组件库 Element UI 的使用,我会尽可能的给大家讲清楚每个安装步骤。如果你对 Vue CLI@4 和 Element UI 已经有一定的了解,可以在学习的过程中适当跳过一些简单的步骤。 + +#### 知识点 + +- Vue CLI@4 环境要求和安装步骤 +- 采用 Vue CLI@4 搭建前端工程的两种方式及依赖安装 +- Element UI 的安装和引入 + +#### 开发计划 + +- 开发内容:本次实验带领大家从零开始,采用 Vue CLI@4 脚手架搭建前端工程,采用组件库 Element UI 快速构建页面,并介绍项目文件结构。 +- 开发耗时:实验预计完成时间为 0.5 ~ 1 小时 +- 开发难点:无 + +## 检测环境要求 + +打开 Vue CLI 官方文档([安装 | Vue CLI](https://cli.vuejs.org/zh/guide/installation.html)),看到版本 4.x 对 Node.js 的要求和旧版本的 Vue CLI 升级到 4.x 的方法。 + +![10-1](https://doc.shiyanlou.com/courses/3472/1557563/8d27651920d694ef6e22cf591bc18994-0/wm) + +执行以下命令,检测环境的 Node.js 版本: + +```bash +node --version +``` + +可以看到符合要求 + +![10-2](https://doc.shiyanlou.com/courses/3472/1557563/35c410a841db01b984ec5363b00be507-0/wm) + +执行以下命令,检测 Vue CLl 的安装情况: + +```bash +vue --version +``` + +可以看到环境安装的是 Vue CLI@4 + +![10-3](https://doc.shiyanlou.com/courses/3472/1557563/5ca14596cdadccfc5762110cb0d8767b-0/wm) + +## 创建并启动项目 + +创建项目有两种方式:终端方式和图形化界面。 + +下面将介绍创建项目的具体步骤,过程中会安装 Vue Router、Vuex、Axios、Stylus 等项目必须的依赖,之后启动项目。 + +### 终端方式 + +执行以下命令来创建项目,`vue create` 加上项目名称,此次实验项目命名为 `file-web`: + +```bash +vue create file-web +``` + +可以看到如下结果: + +![10-4](https://doc.shiyanlou.com/courses/3472/1557563/f930607f5589536479a5e0c915fd306c-0/wm) + +`?Please pick a presset(Use arrow keys)` 这一行是提示,之后的每一个依赖安装都会以这样的提示开头,紧接着的是选项列表。用键盘上下键可切换选中项(高亮蓝色),回车键确定选中。 + +第一个问题:请选择预设,这里我们采用 `Manually select features` 来手动安装一些依赖。 + +选中之后会自动将你选中的项拼接在提示之后,并出现下一个依赖的安装提问语句和选项列表。 + +![10-5](https://doc.shiyanlou.com/courses/3472/1557563/d8b14183715e0bc0bb6cb6dda48beff1-0/wm) + +第二个提示:为你的项目选择你需要的功能。仍然是键盘上下键控制选中项,这里采用空格键选中或取消选中当前项。这里我们选中下图中的几个功能:选择 Vue 版本,安装 Babel、Router、Vuex、CSS 预编译器和格式化检查,回车键。 + +![10-6](https://doc.shiyanlou.com/courses/3472/1557563/c0cb464b9fee428c4a4e3fb0072b2777-0/wm) + +第三个提示:选择 Vue.js 版本,此实验采用 `2.x`。 + +![10-7](https://doc.shiyanlou.com/courses/3472/1557563/30be76ea1f0dfb7e5d75e19f87a40901-0/wm) + +第四个提示:路由是否采用 history 模式([HTML5 History 模式 | Vue Router](https://router.vuejs.org/zh/guide/essentials/history-mode.html#html5-history-%E6%A8%A1%E5%BC%8F)),选择是。输入 Y,回车键。 + +![10-8](https://doc.shiyanlou.com/courses/3472/1557563/4b9331bc4c61a15d8900ed2c0f5f71de-0/wm) + +第五个提示:选择一个 CSS 预编译器,可以选择任意一个,此实验采用 `Stylus`。 + +![10-9](https://doc.shiyanlou.com/courses/3472/1557563/6223c40ac5e1891d9cb4fc555e481769-0/wm) + +第六个提示:选择一个插件化的 JavaScript 代码检测和格式化工具 ,结合 Visual Studio Code 提供的扩展程序 ESLint、Prettier 等,你可以选择自己习惯用的代码检测工具和格式化工具,此实验采用 `ESLint width error prevention only`。 + +![10-10](https://doc.shiyanlou.com/courses/3472/1557563/a0bbc89d542ba8bd3ab4185d8a195c18-0/wm) + +第七个提示:何时检测代码,此实验采用 `Lint on save` 保存文件时测试。 + +![10-11](https://doc.shiyanlou.com/courses/3472/1557563/dee7f4d7cbf7256ae455479f8a095336-0/wm) + +第八个提示:如何存放配置,选择保存到专用的配置文件中。 + +![10-12](https://doc.shiyanlou.com/courses/3472/1557563/8fa8588f74dad694b19e6c16c5631bce-0/wm) + +第九个提示:是否保存本次配置(y:记录本次配置,然后需要给配置命名,N:不记录本次配置),这里选择 N。 + +![10-13](https://doc.shiyanlou.com/courses/3472/1557563/abc2fd97de31630a28f0200d655e6048-0/wm) + +至此配置完成,所有的配置如下图所示: + +![10-14](https://doc.shiyanlou.com/courses/3472/1557563/048360176e9b779e82e53c875a42b3fe-0/wm) + +自动开始安装刚才所选的依赖: + +![10-15](https://doc.shiyanlou.com/courses/3472/1557563/da04c364d8396903fd158c5dfca0691e-0/wm) + +等待进度条 100%,项目创建成功: + +![10-16](https://doc.shiyanlou.com/courses/3472/1557563/c4950a2790652ce08147ed0506a9c605-0/wm) + +可以看到启动项目的提示: + +```bash +cd file-web +npm run serve +``` + +执行以上命令,启动成功之后可以看到 `App running at: - Local: http://localhost:8080/` + +![10-17](https://doc.shiyanlou.com/courses/3472/1557563/2f8a2795502e8c67893f25680966c171-0/wm) + +本地环境运行时,在浏览器中打开此地址即可;在本课程环境中,需要在 `file-web` 根目录下新建文件 `vue.config.js`,写入以下内容: + +```javascript +module.exports = { + publicPath: '/', + devServer: { + host: '0.0.0.0', + open: true, + disableHostCheck: true + } +} +``` + +重新启动项目,点击实验环境右侧的 Web 服务,即可在本地浏览器打开页面,至此,我们的项目就创建并启动成功了。 + +10-18 + +### 图形化界面方式 + +执行以下命令,会启动 8000 端口,并自动在浏览器中打开新的页面,你可以在页面中创建、启动、停止、管理多个项目。 + +```bash +vue ui +``` + +![10-19](https://doc.shiyanlou.com/courses/3472/1557563/aca7a6e4fc1e37ef47f636b44bbea226-0/wm) + +由于环境的特殊性,无法打开图形化界面,大家可以在本地进行尝试,依赖安装步骤和终端方式类似。 + +### 项目目录结构介绍 + +如图所示,可以看到生成的项目默认包含一些目录,下面来介绍几个重点目录和文件: + +10-20 + +1. `node_modules`:存放项目的各种依赖。 +2. `public`:存放静态资源,其中的 index.html 是项目的入口文件,浏览器访问项目的时候默认打开的是生成后的 index.html。 +3. `src`:存放项目主体文件,具体介绍如下: + - `assets`:存放各种静态文件,包括图片、CSS 文件、JavaScript 文件、各类数据文件等。 + - `components`:存放公共组件,比如此课程后续将会用到的顶部导航栏 Header.vue。 + - `router/index.js`:vue-router 安装时自动生成的路由相关文件,主要用来为每个路由设置路径、名称和对应页面的 .vue 文件等。 + - `store/index.js`:vuex 安装时自动生成的状态相关文件,后续章节会详细介绍,用来让多个页面或组件共享数据。 + - `views`:存放页面文件,比如默认生成的 Home.vue 首页、About.vue 关于页面。 + - `App.vue`:是主 vue 模块,主要是使用 router-link 引入其他模块,所有的页面都是在 App.vue 下切换的。 + - `main.js`:是入口文件,主要作用是初始化 vue 示例、引用某些组件库或挂载一些变量。 +4. `.eslintrc`:配置代码校验 ESLint 规则。 +5. `.gitignore`:配置 git 上传时想要忽略的文件。 +6. `babel.config.js`:一个工具链,主要用于兼容低版本的浏览器。 +7. `package.json`:配置项目名称、版本号,记录项目开发所安装的依赖的名称、版本号等。 +8. `package-lock.json`:记录项目安装依赖时,各个依赖的具体来源和版本号。 + +### 格式化代码配置 + +为了保持代码整洁,我们需要对编辑器做一定的设置。在编辑器根目录下创建文件夹 .vscode,创建 settings.json 文件,写入以下内容: + +```json +{ + // 控制是否在打开文件时,基于文件内容自动检测 #editor.tabSize# 和 #editor.insertSpaces#。 + "editor.detectIndentation": false, + // 一个制表符等于的空格数 + "editor.tabSize": 2, + // 每次保存的时候是否自动格式化 + "editor.formatOnSave": true, + // 函数(名)和后面的括号之间是否加空格 + "javascript.format.insertSpaceBeforeFunctionParenthesis": false, + "vetur.format.defaultFormatterOptions": { + "prettier": { + "semi": false, // 代码结尾不加分号 + "singleQuote": true, // 使用单引号 + "trailingComma": "none" // 不自动添加逗号 + } + } +} +``` + +![10-21](https://doc.shiyanlou.com/courses/3472/1557563/a60b83afc76dbea4ac0f2d1eff7f62e4-0/wm) + +配置项的各个说明都在代码注释中,大家可以根据自己的习惯更改配置项。 + +## Element UI 的安装和使用 + +Element UI 是一套基于 Vue 2.0 的桌面端组件库,其中的很多组件可以帮助我们快速搭建页面,例如后续实验将要用到的 NavMenu 导航菜单、Table 表格、Breadcrumb 面包屑等。来跟随官方文档([组件 | Element](https://element.eleme.cn/#/zh-CN/component/installation))学习 Element UI 的安装和使用。 + +### 安装 + +先通过 `Ctrl + C` 组合快捷键停止项目。或者新建终端,通过 `cd file-web` 命令进入项目根目录。 + +使用 `npm` 的方式安装 Element UI,目前最新版本是 v2.15.0,执行以下命令,会自动安装最新版本: + +```bash +npm i element-ui -S +``` + +等待安装,成功之后如下图所示。 + +![10-22](https://doc.shiyanlou.com/courses/3472/1557563/01e5a8faf35cb9da40706c649e7c8d58-0/wm) + +### 引入 + +此次实验采用完整引入 Element UI 的方式,在 `src/main.js` 中补充以下内容: + +```javascript +import ElementUI from 'element-ui' +import 'element-ui/lib/theme-chalk/index.css' + +Vue.use(ElementUI) +``` + +10-23 + +需要注意的是,样式文件需要单独引入。 + +### 组件使用介绍 + +先来通过 `npm run serve` 重新启动项目。使用最常见的按钮组件 Button 来验证我们的组件库是否正确引入了(此次课程所有组件均来自 Element UI,可以参照官方示例文档使用),后续实验用到的组件,使用方法均和此组件相同。 + +我们来使用官方示例代码中的主要按钮: + +![10-24](https://doc.shiyanlou.com/courses/3472/1557563/6c9fda222905fbcf01a57caabda66229-0/wm) + +在 `src/views/Home.vue` 中的写入以下内容: + +```html +主要按钮 +``` + +![10-25](https://doc.shiyanlou.com/courses/3472/1557563/2e234becace146f120987f6559917584-0/wm) + +仍然是点击右侧的 Web 服务器,打开页面,看到按钮已经渲染出来了,证明 Element UI 组件库引入成功。 + +10-26 + +### 组件属性介绍 + +Element UI 的每个组件介绍最下方,都有相关的文档说明,包括此组件的属性、事件等。 + +![10-27](https://doc.shiyanlou.com/courses/3472/1557563/ab0ac8d8d025299be82e80413dae95e9-0/wm) + +例如 Button 按钮组件的属性介绍部分:参数名对应 HTML 元素中的属性名,参数值是可选值中的一个,对应 HTML 元素中每一个属性的属性值,部分参数有默认值。 + +例如刚才使用的 `主要按钮`,参数 type,参数值 primary。可以加上参数是否圆角按钮 round,看下效果: + +```html +主要按钮 +``` + +看到按钮变成了圆角: + +10-28 + +### 组件事件介绍 + +我们再来看下 Input 输入框的事件介绍: + +![10-29](https://doc.shiyanlou.com/courses/3472/1557563/bfd6227e3c3c6de6198b48d0f40f2378-0/wm) + +可以看到 Element UI 提供了组件最常用的一些事件,包括事件名称、说明和回调参数的格式,比如输入框的失焦事件,继续向 `src/views/Home.vue` 文件中写入如下代码: + +```html + +``` + +```vue + +``` + +![10-30](https://doc.shiyanlou.com/courses/3472/1557563/509332865627f502e657bbb9d81ada71-0/wm) + +在页面上的输入框中输入 `this is test value`,点击输入框之外的区域,触发输入框的失焦事件,可以看到控制台(F12 打开)打印出了输入框的值: + +![10-31](https://doc.shiyanlou.com/courses/3472/1557563/2224767101d17d4968284bd5f2466ee6-0/wm) + +其他组件的使用,大家有兴趣可以自行学习和研究。 + +## 实验总结 + +本实验向大家演示了 Vue CLI@4 搭建项目的流程,并介绍了 Element UI 的安装和使用方法,有兴趣的同学可以尝试使用 Element UI 中的一些组件搭建页面。 + +本次实验完整代码可以通过如下命令进行下载: + +```bash +wget https://labfile.oss.aliyuncs.com/courses/3472/code10.zip +``` diff --git "a/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/14-466272-\344\275\277\347\224\250 Vue Router \346\267\273\345\212\240\350\267\257\347\224\261+\351\241\266\351\203\250\345\257\274\350\210\252\346\240\217\345\256\236\347\216\260..sy.md" "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/14-466272-\344\275\277\347\224\250 Vue Router \346\267\273\345\212\240\350\267\257\347\224\261+\351\241\266\351\203\250\345\257\274\350\210\252\346\240\217\345\256\236\347\216\260..sy.md" new file mode 100755 index 0000000..316e1ee --- /dev/null +++ "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/14-466272-\344\275\277\347\224\250 Vue Router \346\267\273\345\212\240\350\267\257\347\224\261+\351\241\266\351\203\250\345\257\274\350\210\252\346\240\217\345\256\236\347\216\260..sy.md" @@ -0,0 +1,349 @@ +--- +show: step +version: 1.0 +enable_checker: true +--- + +# 使用 Vue Router 添加路由+顶部导航栏实现 + +## 实验介绍 + +本次实验将讲解页面及路由的添加,并实现网盘顶部导航栏。 + +#### 知识点 + +- Vue Router +- Element UI 的 NavMenu 导航菜单 + +#### 开发计划 + +- 开发内容:路由插件 Vue Router 的使用,登录、注册、网盘页面的添加,采用 Element UI 的 NavMenu 组件构建顶部导航栏,并实现点击导航栏跳转到相应页面的效果。 +- 开发耗时:实验预计完成时间为 1 ~ 1.5 小时 +- 开发难点: + 1. Vue Router 的使用 + 2. NavMenu 组件的使用 + +## 使用 Vue Router 添加路由 + +Vue Router 是 Vue.js 官方的路由管理器([Vue Router](https://router.vuejs.org/zh/)),和 Vue.js 的核心深度集成,可以帮我们快速创建、配置路由,例如路由和页面的对应、路由参数获取和修改、HTML5 历史模式或 hash 模式等。 + +### 路由相关文件解析 + +先来看下自动生成的路由文件 `src/router/index.js`,首先需要引入 `vue-router` 模块(在上个实验中创建项目时,已经安装了 Vue Router 依赖)、挂载在 Vue 上,接着需要创建路由列表,包含路径、路由名称、页面文件等配置,之后创建路由实例,并导出,然后在 `src/main.js` 中引入并使用即可。 + +![11-1](https://doc.shiyanlou.com/courses/3472/1557563/86571992803593a744e16c9f8d0f08bc-0/wm) + +基于上一个实验,对此文件中的部分代码做了注释说明: + +```javascript +import Vue from 'vue' +import VueRouter from 'vue-router' // 引入vue-router模块 +import Home from '../views/Home.vue' // 引入Home页面对应的文件 + +Vue.use(VueRouter) // 将VueRouter挂载在Vue上 + +// 创建路由列表 +const routes = [ + { + path: '/', // 路由路径,即浏览器地址栏中显示的URL + name: 'Home', // 路由名称 + component: Home // 路由所使用的页面 + }, + { + path: '/about', + name: 'About', + /** + * 1. route level code-splitting + * 2. this generates a separate chunk (about.[hash].js) for this route : + * 这会为该路由生成一个单独的块(about.[hash].js),打包时对应的css文件、js文件将会以 webpackChunkName 的值拼接hash值命名。 + * 3. which is lazy-loaded when the route is visited. + * 当此路由被加载时,才加载对应的页面文件。这样做可以加快页面访问速度。 + */ + component: () => import(/* webpackChunkName: "about" */ '../views/About.vue') + } +] + +// 创建路由实例 +const router = new VueRouter({ + mode: 'history', // HTML5 History 模式 + base: process.env.BASE_URL, + routes +}) + +export default router +``` + +这里特别要说明的是 HTML5 History 模式。如果不使用 history 模式,例如现有的关于页面,路由路径将会是 `http://oursite.com/#/about` 这种形式。如果不想要就很丑的 hash, 可以用路由的 **history 模式**,那么路由路径就会是 `http://oursite.com/about`。这种模式充分利用 `history.pushState` API 来完成 URL 跳转而无须重新加载页面。如果使用了这种模式,在部署时需要后台配置支持,因为我们的应用是个单页客户端应用,如果后台没有正确的配置,当用户在浏览器直接访问 `http://oursite.com/about` 就会返回 404。所以需要在服务端增加一个覆盖所有情况的候选资源:如果 URL 匹配不到任何静态资源,则应该返回同一个 `index.html` 页面,这个页面就是你 app 依赖的页面。 + +例如此次课程部署用到的 nginx,应当添加如下配置: + +```nginx +location / { + try_files $uri $uri/ /index.html; +} +``` + +这里先暂做了解,后续实验将会在部署相关章节中,详细说明配置项。 + +之后,我们可以在 `*.vue` 文件中通过 `$router` 访问路由实例,调用 `this.$router` 来操作路由([编程式的导航 | Vue Router](https://router.vuejs.org/zh/guide/essentials/navigation.html)),例如后续实验将会用到的 `this.$router.push`,用于向 history 栈添加新的记录;使用 `this.$route` 获取路由信息,例如接下来将会用到的获取当前页面路由路径 `this.$route.path`。 + +### 首页、登录、注册页面及路由添加 + +首先来做一点清洁工作:删除 `src/views/about.vue` 文件,删除 `src/router/index.js` 中的路由 About,在 `src/App.vue` 的\中,删除 `text-align center`、`color #2c3e50`、`margin-top 60px`。 + +在 `src/assets` 下新建 `style` 文件夹,然后在该文件夹下新建 `base.styl` 文件,键入以下内容,设置所有元素的外边距和内边距均为 `0px`: + +```stylus +* { + margin: 0; + padding: 0; +} +``` + +然后在 `src/main.js` 中引入此样式文件: + +```javascript +import './assets/style/base.styl' +``` + +我们将沿用 `src/router/index.js` 中的路由 Home 来作为首页,可以看到此路由的 component 中引入的页面文件为 `src/views/Home.vue`,先来清空下 `src/views/Home.vue` 中的代码,以便进行后续的代码编写,并删除 `src/components/HelloWorld.vue` 文件。 + +清空后,键入以下内容: + +```vue + + + +``` + +在 `src/views` 中新建登录页面文件 `Login.vue`,键入以下内容: + +```vue + + + +``` + +在 `src/router/index.js` 创建登录页面路由: + +```javascript +const routes = [ + ... + { + path: '/login', // 登录页面 + name: 'Login', + component: () => import(/* webpackChunkName: "login" */ '../views/Login.vue') + } +] +``` + +同理,在 `views` 目录下新建注册页面文件 `Register.vue` 并添加路由: + +```vue + + + +``` + +在 `router/index.js` 文件下添加路由: + +```javascript +const routes = [ + ... + { + path: '/register', // 注册页面 + name: 'Register', + component: () => import(/* webpackChunkName: "register" */ '../views/Register.vue') + } +] +``` + +页面和路由添加好了,我们需要在 `src/App.vue` 中添加上述三个页面的跳转链接。 + +### 跳转链接添加 + +在 `src/App.vue` 中的 \ 中添加首页、登录、注册页面的跳转链接: + +```vue + +``` + +现在启动项目:(之后将不再提示) + +```bash +cd file-web +npm run serve +``` + +启动成功之后,仍然是点击右侧的 Web 服务(之后将不再提示)打开页面,检验下上述三个页面是否可以显示并成功跳转: + +首页页面及路径: + +11-2 + +登录页面及路径: + +11-3 + +注册页面及路径: + +11-4 + +可以看到三个页面均可以成功跳转。 + +### 404 页面添加 + +为了防止用户在地址栏输入错误的路径而导致页面加载出错,我们需要用一个 404 页面来拦截,页面添加方式与登录、注册页面相同,在 `src/views` 目录下新建 `Error_404.vue` 文件: + +```vue + + + +``` + +重点是 404 页面路由的添加位置,需要添加在所有路由之后([404 Not found 路由](https://router.vuejs.org/zh/guide/essentials/dynamic-matching.html#%E6%8D%95%E8%8E%B7%E6%89%80%E6%9C%89%E8%B7%AF%E7%94%B1%E6%88%96-404-not-found-%E8%B7%AF%E7%94%B1)): + +```javascript +const routes = [ + ... + { + path: '/register', // 注册页面 + name: 'Register', + component: () => import(/* webpackChunkName: "register" */ '../views/Register.vue') + }, + { + path: '*', // 404页面 + name: 'Error_404', + component: () => import(/* webpackChunkName: "error_404" */ '../views/Error_404.vue') + } +] +``` + +可以看到在浏览器地址栏里输入不存在的路径,就会自动打开 404 页面,有 CSS 基础的同学,可以给 404 页面添加一些样式,或者去 Iconfont([阿里巴巴矢量图标库](https://www.iconfont.cn/home/index))上寻找一些图标或插画,来使页面内容更加丰富。 + +11-5 + +## 使用 Element UI 中的 NavMenu 导航菜单 + +前面我们已经成功的添加了页面,接下来我们将使用 [NavMenu 导航菜单](https://element.eleme.cn/#/zh-CN/component/menu) 添加顶部导航栏,帮助我们快速构建页面,免去不必要的样式修改、事件添加等工作。 + +在 `src/components` 下创建文件 `Header.vue`,键入以下内容: + +```vue + + + +``` + +其中的 el-menu 即为 Element UI 中的 NavMenu 导航菜单,`:router="true"` 表示使用 vue-router 的模式, index 是每个导航菜单的唯一标志,这里配置为各个页面对应的路由名称 name,default-active 为当前激活菜单的 index,为了刷新页面时也可以保证停留在当前页面,这里采用计算属性的方式给 activeIndex 赋值。\ 中的属性 route 为 Vue Router 路径对象,即要跳转到的页面的路由对象,这里依次配置为首页、登录、注册页面的路由对象。 + +在 `src/App.vue` 中引入、注册并使用此组件: + +```vue + + + +``` + +来看下页面效果: + +11-6 + +11-7 + +11-8 + +至此我们的顶部导航栏就实现了,最终的文件目录结构如下: + +11-9 + +有兴趣的同学可以自己参考官方文档给的组件属性,结合 stylus 调整下导航栏的样式,例如将登录、注册靠右放置、当前激活菜单底部高亮颜色修改等。 + +## 实验总结 + +此次实验带领大家了解了 Vue Router 的相关知识,实践了路由的使用,页面的引入,结合 Element UI 的 NavMenu 导航菜单,快速构建出了页面的主体效果,有兴趣的同学可以尝试给页面添加上底部栏,优化顶部导航栏样式、在顶部导航栏添加所需要展示的 logo 等。 + +本次实验完整代码可以通过如下命令进行下载: + +```bash +wget https://labfile.oss.aliyuncs.com/courses/3472/code11.zip +``` diff --git "a/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/15-466273-Axios \346\216\245\345\217\243\345\260\201\350\243\205+\345\211\215\347\253\257\346\263\250\345\206\214\343\200\201\347\231\273\345\275\225\351\241\265\351\235\242\345\256\236\347\216\260.sy.md" "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/15-466273-Axios \346\216\245\345\217\243\345\260\201\350\243\205+\345\211\215\347\253\257\346\263\250\345\206\214\343\200\201\347\231\273\345\275\225\351\241\265\351\235\242\345\256\236\347\216\260.sy.md" new file mode 100755 index 0000000..109d2c8 --- /dev/null +++ "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/15-466273-Axios \346\216\245\345\217\243\345\260\201\350\243\205+\345\211\215\347\253\257\346\263\250\345\206\214\343\200\201\347\231\273\345\275\225\351\241\265\351\235\242\345\256\236\347\216\260.sy.md" @@ -0,0 +1,1037 @@ +--- +show: step +version: 1.0 +enable_checker: true +--- + +# Axios 接口封装+前端注册、登录页面实现 + +## 实验介绍 + +本实验将介绍采用 Axios 封装接口,并添加注册、登录页面及相关接口。 + +#### 知识点 + +- Axios 封装 GET、POST、PUT、DELETE 类型接口 +- Element UI 的 Form 表单组件使用 + +#### 开发计划 + +- 开发内容:完成注册、登录页面的开发,添加页面元素;采用 Axios 封装 GET、POST、PUT、DELETE 类型的接口,添加登录、注册接口,介绍其引入和使用方法,并简单介绍采用 Vuex 在各个组件间共享登录状态。 +- 开发耗时:实验预计完成时间为 1~2 小时 +- 开发难点: + 1. Axios 封装各个类型的接口,如何添加、引入和使用接口 + 2. 使用 Form 表单组件完成注册、登录页面 + +## Axios 安装和接口封装 + +Axios([axios 中文网|axios API 中文文档 | axios](http://www.axios-js.com/))是易用、简洁且高效的 http 库, 使用 Promise 管理异步,支持请求和响应拦截器,自动转换 JSON 数据等高级配置,与 Vue.js 有很好的融合。 + +终端中键入以下命令,以安装 Axios: + +```bash +npm install axios +``` + +为了便于后续接口管理,一般都将所有的接口单独放在同一目录下统一管理。在 `src` 下新建文件夹`request`,并创建文件 `src/request/http.js`,后续对接口的 baseURL、超时时间、请求和响应拦截、接口类型封装等都将在此文件中。 + +### Axios 基础设置 + +在 `http.js` 中先来引入 Axios,设置请求超时时间,基础 URL,并自定义 POST 请求头: + +```javascript +import axios from 'axios' + +// 请求超时时间 +axios.defaults.timeout = 10000 * 5 +// 请求基础URL,对应后台服务接口地址 +axios.defaults.baseURL = 'http://localhost:8081' +// 自定义post请求头 +axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded' +``` + +设置请求拦截器和响应拦截器,对接口的请求头、响应结果做统一处理,例如自定义请求头,对接口响应的 HTTP 状态码非 200 的情况做处理等: + +```javascript +import { Message } from 'element-ui' + +// 请求拦截器 +axios.interceptors.request.use( + (config) => { + // 自定义请求头 + return config + }, + (error) => { + // 请求错误时 + console.log(error) // 打印错误信息 + return Promise.reject(error) + } +) + +// 响应拦截器 +axios.interceptors.response.use( + (response) => { + if (response.status === 200) { + // 接口HTTP状态码为200时 + return Promise.resolve(response) + } + }, + // HTTP状态码非200的情况 + (error) => { + if (error.response.status) { + switch (error.response.status) { + case 500: // HTTP状态码500 + Message.error('后台服务发生错误') + break + case 401: // HTTP状态码401 + Message.error('无权限') + break + case 404: // HTTP状态码404 + Message.error('当前接口不存在') + break + default: + this.$message.error(error.response.message) // 页面显示接口返回的错误信息 + return Promise.reject(error.response) + } + } + } +) +``` + +### GET、POST、PUT、DELETE 接口类型封装 + +get 请求中的参数分为 params 和 info,其中 params 是查询参数,接口中的表现形式为 & 符号连接的 key=value 形式的字符串,统一用?符号拼接在接口后,例如常用的分页查询接口 `getFileList?page=1&pageSize=10`;info 参数直接拼接在 url 中,例如某些查询接口 get 请求,需要把 id 拼接在 url 中。 + +```javascript +/** + * get方法,对应get请求 + */ +export function get(url, params, info = '') { + return new Promise((resolve, reject) => { + axios + .get(url + info, { + params: params + }) + .then((res) => { + resolve(res.data) // 返回接口响应结果 + }) + .catch((err) => { + reject(err.data) + }) + }) +} +``` + +post 请求中的参数分为 formData 格式和 json 格式,需要根据后台接口采用不同的传参格式: + +```javascript +/** + * post方法,对应post请求 + * info为 true,formData格式; + * info为 undefined或false,是json格式 + */ +export function post(url, data = {}, info) { + return new Promise((resolve, reject) => { + let newData = data + if (info) { + // 转formData格式 + newData = new FormData() + for (let i in data) { + newData.append(i, data[i]) + } + } + axios + .post(url, newData) + .then((res) => { + resolve(res.data) + }) + .catch((err) => { + reject(err.data) + }) + }) +} +``` + +put 请求和 delete 请求封装同理: + +```javascript +/** + * 封装put请求 + */ + +export function put(url, params = {}, info = '') { + return new Promise((resolve, reject) => { + axios.put(url + info, params).then( + (res) => { + resolve(res.data) + }, + (err) => { + reject(err.data) + } + ) + }) +} + +/** + * 封装delete请求 + */ +export function axiosDelete(url, params = {}, info = '') { + return new Promise((resolve, reject) => { + axios + .delete(url + info, { + params: params + }) + .then((res) => { + resolve(res.data) + }) + .catch((err) => { + reject(err.data) + }) + }) +} +``` + +### 注册、登录接口封装 + +在 `src/request` 下创建新文件 `user.js`,所有与用户相关的接口均维护在这个文件中。 + +首先引入封装好的 get、post 类型的请求: + +```javascript +import { get, post } from './http' +``` + +接下来封装登录接口: + +- export 表示导出此接口,以便后续在 vue 文件中引入此接口去调用; +- const login 表示定义当前接口名称为 login,p 表示接口传参,对应刚才封装 get 类型请求时的 params,如果还需要传参 info,传参应当为(p, info); +- get 表示接口类型为 get,`'/user/login'`为后台提供的接口 path。 + +```javascript +// 登录接口 +export const login = (p) => get('/user/login', p) +``` + +封装注册接口: + +```javascript +// 注册接口 +export const addUser = (p) => post('/user/register', p) +``` + +## 后台项目启动 + +后台项目的编写可以参考本次课程的后台项目相关的实验,这里仅仅介绍后台项目启动,和如何连接后台接口。 + +在 `qiwen-file/src/main/resources/application.properties` 中第 1 行添加 `server.port=8081` 来修改后台服务启动端口: + +```properties +server.port=8081 +spring.datasource.url=jdbc:mysql://localhost:3306/file?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true +... +``` + +新建终端启动项目: + +```bash +sudo service mysql start +cd /home/project/qiwen-file +mvn spring-boot:run +``` + +当出现以下结果,说明后台项目启动成功: + +![12-1](https://doc.shiyanlou.com/courses/3472/1557563/b7fd02ff5506f52596327dcfe5da77a6-0/wm) + +### 接口连接 + +我们需要对 Axios 中的设置和封装做些改动,以便在本地开发环境中也可以调用接口。 + +在 `public` 中新建 `config.json` 文件,存放后台接口,这里必须配置完整的接口 baseURL,包括协议、IP、端口,有时候后台会有后缀 `/backend` 等: + +```json +{ + "baseUrl": "http://localhost:8081" +} +``` + +在 `vue.config.js` 中配置代理: + +```javascript +const productConfig = require('./public/config.json') // 引入config.json文件 +module.exports = { + publicPath: '/', + devServer: { + host: '0.0.0.0', + open: true, + disableHostCheck: true, + proxy: { + //配置代理,解决跨域请求后台数据的问题 + '/api': { + target: productConfig.baseUrl, //后台接口,连接本地服务 + ws: true, //是否跨域 + changeOrigin: true, + pathRewrite: { + '^/api': '/' + } + } + } + } +} +``` + +对 `src/request/http.js` 中的 Axios 的 baseURL 做修改: + +```javascript +// 请求基础URL +axios.defaults.baseURL = '/api' +``` + +现在我们重新启动项目。 + +## 注册页面编写 + +先来采用 Element UI 的表单 Form 组件等编写页面,在 `src/views/Register.vue` 中键入以下内容: + +```vue + + + + + +``` + +看下页面效果: + +图片描述 + +### 注册接口使用 + +继续编辑 `Register.vue` 文件,引入封装好的注册接口: + +```javascript +import { addUser } from '@/request/user.js' +``` + +在注册按钮的点击事件中调用注册接口: + +```vue + +``` + +来看下接口调用是否符合代码中的处理: + +![12-3](https://doc.shiyanlou.com/courses/3472/1557563/8b90bda3fec2cf8fd95f99a494e50580-0/wm) + +注册接口返回值中的 success 为 true 时: + +12-4 + +注册接口的返回值中 success 为 false 时: + +![12-5](https://doc.shiyanlou.com/courses/3472/1557563/ba62fe303251968011c72a15effb9888-0/wm) + +## 登录页面编写 + +在 `src/views/Login.vue` 中编写登录页面: + +```vue + + + + + +``` + +### 登录接口使用 + +在登录按钮的点击事件中调用登录接口,这里我们需要在登录之后在接口的自定义请求头中添加 token。 + +键入以下命令安装 `js-cookie`: + +```bash +npm install js-cookie +``` + +在 `src/request/http.js` 和 `src/views/Login.vue` 中引入 `js-cookie`,并自定义请求头: + +`http.js` 中使用 `js-cookie`: + +```javascript +import Cookies from 'js-cookie' + +// 请求拦截器 +axios.interceptors.request.use( + (config) => { + // 自定义请求头 + config.headers['token'] = Cookies.get('token') + return config + }, + (error) => { + console.log(error) + return Promise.reject(error) + } +) +``` + +登录页面使用 `js-cookie`,引入封装好的登录接口,编辑 `src/views/Login.vue` 文件: + +```vue +... + +``` + +使用刚才注册的用户,来测试下登录接口: + +![12-6](https://doc.shiyanlou.com/courses/3472/1557563/006e7e0c3cee644eada6bbabb5198014-0/wm) + +![12-7](https://doc.shiyanlou.com/courses/3472/1557563/8b524482dcbedf8cb1dee61d2d2f75e2-0/wm) + +## 登录状态共享和页面跳转 + +在登录之后,需要保存登录状态,之后自动跳转到首页。若用户直接进入了首页,就需要自动跳转到登录页面,若用户已登录,进入登录和注册页面时,就需要自动跳转到首页。那么需要把登录状态共享给各个页面,就需要用到 Vue Router 的全局前置守卫([导航守卫 | Vue Router](https://router.vuejs.org/zh/guide/advanced/navigation-guards.html#%E5%85%A8%E5%B1%80%E5%89%8D%E7%BD%AE%E5%AE%88%E5%8D%AB))和 Vuex,关于 Vuex 的知识将会在下个实验介绍,这次实验先来实现状态共享和界面自动跳转。 + +### 登录状态保存 + +在 `src/request/user.js` 中添加获取用户登录信息接口: + +```javascript +// 获取登录状态及用户信息 +export const checkUserLoginInfo = (p) => get('/user/checkuserlogininfo', p) +``` + +先来使用 Vuex 把状态保存实现,在 `src/store` 下新建文件夹 `module`,并新建文件 `src/store/module/user.js`,键入以下内容(下节实验会介绍 Vuex 的使用): + +```javascript +import { checkUserLoginInfo } from '@/request/user.js' // 引入获取用户登录信息接口 + +export default { + state: { + isLogin: false, // 初始时候给一个 isLogin = false 表示用户未登录 + username: '', + userId: 0, + userImgUrl: '', + userInfoObj: {} + }, + mutations: { + changeLogin(state, data) { + state.isLogin = data + }, + changeUsername(state, data) { + state.username = data + }, + changeUserId(state, data) { + state.userId = data + }, + changeUserInfoObj(state, data) { + state.userInfoObj = Object.assign({}, state.userInfoObj, data) + } + }, + actions: { + getUserInfo(context) { + return checkUserLoginInfo().then((res) => { + if (res.success) { + context.commit('changeLogin', res.success) + context.commit('changeUsername', res.data.username) + context.commit('changeUserId', res.data.userId) + context.commit('changeUserInfoObj', res.data) + } else { + context.commit('changeLogin', res.success) + } + }) + } + } +} +``` + +在 `src/store/index.js` 中引入刚才创建好的 `user.js`,并将相关数据导出: + +```javascript +import Vue from 'vue' +import Vuex from 'vuex' + +import user from './module/user' // 引入user.js + +Vue.use(Vuex) + +export default new Vuex.Store({ + state: { + // + }, + getters: { + isLogin: (state) => state.user.isLogin, + username: (state) => state.user.username, + userId: (state) => state.user.userId, + userInfoObj: (state) => state.user.userInfoObj + }, + mutations: { + // + }, + actions: { + // + }, + modules: { + user + } +}) +``` + +之后就可以在 `*.vue` 文件中使用 `this.$store.getters.isLogin` 来获取用户的登录状态了。 + +### 全局前置守卫 + +为了判断哪些路由需要登录之后才可进入,需要在路由上添加一些信息。在 `src/router/index.js` 中给首页路由添加 meta 属性,并添加参数 `requireAuth`,值为 true: + +```javascript +{ + path: '/', // 路由路径,即浏览器地址栏中显示的URL + name: 'Home', // 路由名称 + component: Home, // 路由所使用的页面 + meta: { + requireAuth: true + } +} +``` + +在 `src/router` 下新建文件 `before.js`,引入 Vue Router 和状态保存文件 `src/store/index.js`: + +```javascript +import router from './index.js' +import store from '@/store/index.js' + +// 路由全局前置守卫 +router.beforeEach((to, from, next) => { + // 调用接口,判断当前登录状态 + store.dispatch("getUserInfo").then(() => { + if (to.matched.some(m => m.meta.requireAuth)) { + if (!store.getters.isLogin) { // 没有登录 + next({ + path: '/login', + query: { Rurl: to.fullPath } + }) + } else { + next() // 正常跳转到你设置好的页面 + } + } else { + next() // 正常跳转到你设置好的页面 + } + }) +}) +``` + +添加全局前置守卫,可以在触发导航之前进行一些处理,当处理完成后才会执行导航: + +1. 先调用接口,判断当前登录状态。 +2. 判断将要去的路由是否需要登录,即刚才我们给路由添加的参数 `meta.requireAuth` 是否为 true,若为 true,表示需要登录后才可进入;若没有设置当前参数,或参数值为 false,表示无需登录也可进入。 +3. 当 `meta.requireAuth` 为 true 时,判断在 Vuex 中保存的 isLogin 为 true 还是 false,为 true 表示已登录,那么执行 `next()` 即可正常导航;为 false 表示未登录,按照之前的说明,将跳转到登录页面。 + +全局前置守卫有三个参数 to、from、next: + +1. `to: Route`:即将要进入的路由对象,包含路由名称、路径、参数等。 +2. `from: Route`:当前导航正要离开的路由对象。 +3. `next: Function`:在全局前置守卫中, 一定要调用该方法来 **resolve** 这个钩子。执行效果依赖 `next` 方法的调用参数:`next()`无参数时, 进行管道中的下一个钩子;`next(false)` 参数为 false 时,中断当前的导航;`next({ path: '/' })` 跳转到一个不同的地址,当前的导航被中断,然后进行一个新的导航。 `next` 支持传递任意位置对象,且允许设置诸如 `replace: true`、`name: 'home'` 之类的选项以及任何用 [`router-link` 的 `to` prop](https://router.vuejs.org/zh/api/#to) 或 [`router.push`](https://router.vuejs.org/zh/api/#router-push) 中的选项。 + +在 `src/main.js` 中引入刚才创建好的 `before.js`: + +```javascript +import '@/router/before.js' +``` + +现在我们来直接进入首页,发现接口请求返回的 false,页面直接跳转到了登录页面,并且带了查询参数 `Rurl`: + +![12-8](https://doc.shiyanlou.com/courses/3472/1557563/5e30c3525c906735cd097327dfd9821c-0/wm) + +然后来给登录、注册页面在登录状态下添加自动跳转到首页的效果,在 `src/views/Login.vue` 中的生命周期 `created()` 中添加登录状态判断: + +```javascript + + + +``` + +来看下登录、跳转到首页、退出登录流程的效果: + +12-11 + +## 实验总结 + +此次实验介绍了 Axios 的安装,接口的封装和使用,在页面编写过程中使用了 Element UI 的 Form 表单组件 ,并介绍了路由全局前置守卫,完成了登录到退出登录的状态保存和页面跳转。有兴趣的同学可以对登录、注册界面的表单校验和接口做优化,例如表单校验对手机号添加号码长度限制,密码添加格式校验和密码强度校验,添加短信验证码、图形验证码或行为验证码等。 + +本次实验完整代码可以通过如下命令进行下载: + +```bash +wget https://labfile.oss.aliyuncs.com/courses/3472/code12.zip +``` diff --git "a/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/16-466274-Vuex + Element UI \347\233\270\345\205\263\347\273\204\344\273\266\345\256\236\347\216\260\347\275\221\347\233\230\344\270\273\351\241\265\351\235\242.sy.md" "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/16-466274-Vuex + Element UI \347\233\270\345\205\263\347\273\204\344\273\266\345\256\236\347\216\260\347\275\221\347\233\230\344\270\273\351\241\265\351\235\242.sy.md" new file mode 100755 index 0000000..7c17935 --- /dev/null +++ "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/16-466274-Vuex + Element UI \347\233\270\345\205\263\347\273\204\344\273\266\345\256\236\347\216\260\347\275\221\347\233\230\344\270\273\351\241\265\351\235\242.sy.md" @@ -0,0 +1,1256 @@ +--- +show: step +version: 1.0 +enable_checker: true +--- + +# Vuex + Element UI 相关组件实现网盘首页 + +## 实验介绍 + +本实验将使用 Element UI 的 Table 表格、NavMenu 导航菜单、BreadCrumb 面包屑导航、Checkbox 多选框等组件实现网盘的主页面,并使用 Vuex 来保存表格列筛选的结果,实现页面组件间的数据联动。 + +#### 知识点 + +- Vuex +- Element UI 的 NavMenu 导航菜单、BreadCrumb 面包屑导航栏、Table 表格、Pagination 分页和 Checkbox 多选框组件的使用 + +#### 开发计划 + +- 开发内容:网盘主页面的实现,主要包括以下几点 + 1. 使用 NavMenu 导航组件实现左侧文件分类栏,并添加收缩、展开的效果 + 2. 使用 BreadCrumb 面包屑导航组件展示当前查看的文件路径 + 3. 使用 Table 表格组件实现文件列表 + 4. 使用 Pagination 分页组件实现文件列表的分页 + 5. 使用 Checkbox 组件实现文件表格某些列的显隐控制 + 6. 使用 Vuex 来保存表格列筛选的结果 + 7. 添加获取文件列表的接口 + 8. 左侧文件分类栏和右侧表格、右侧面包屑导航栏的数据联动 +- 开发耗时:实验预计完成时间为 2~2.5 小时 +- 开发难点: + 1. Vuex 的使用 + 2. 组件间的数据联动 + +## 实现网盘首页 + +### 使用 NavMenu 实现左侧菜单 + +根据前面的实验,我们知道了项目启动的完整命令,如下所示: + +```bash +# 启动后端 +sudo service mysql start +cd /home/project/qiwen-file +mvn spring-boot:run + +# 新开一个终端,启动前端 +cd /home/project/file-web +npm run serve +``` + +在之前的实验中,实现顶部导航栏时已经用到过 NavMenu 导航菜单,这次我们主要来实现菜单的收缩展开功能。先来对项目的文件目录做点调整: + +1. 在 `src/views` 下新建文件夹 Home,将 `src/views/Home.vue` 重命名为 `src/views/index.vue`,并移动到刚才创建好的文件夹 `src/views/Home` 中。 +2. 修改 `src/router/index.js` 中对原有的 `Home.vue` 的引入,保证路由和页面可以正常匹配:将 `import Home from '../views/Home.vue'` 改为 `import Home from '../views/Home/index.vue'`。 + +刷新首页,可以正常显示。 + +在 `src/views/Home` 下创建新的文件夹 `components`,存放独属于首页的组件,后续的面包屑导航栏、表格列筛选组件都会放置于此文件夹下。 + +在 `src/views/Home/components` 下创建新文件 `SideMenu.vue`,在 `src/views/Home/index.vue` 中添加以下内容来引入、注册并使用此组件: + +```vue + + + +``` + +参考 Element UI 的 NavMenu([NavMenu 垂直菜单](https://element.eleme.cn/#/zh-CN/component/menu#ce-lan)),先来实现一个区分文件类型的垂直菜单,在 `SideMenu.vue` 文件中添加以下内容: + +```vue + + + +``` + +接着我们来使用 NavMenu 的 collapse 属性([NavMenu 折叠](https://element.eleme.cn/#/zh-CN/component/menu#zhe-die))把左侧菜单的收缩展开功能添加上,并调整样式: + +```vue + + + + + +``` + +` +``` + +在 `index.vue` 中引入 `BreadCrumb.vue`,调整布局,使菜单居左,右侧内容区域自适应宽度: + +```vue + + + + + +``` + +刷新首页,看下页面布局,收缩展开左侧菜单时,右侧内容区域可以自适应宽度: + +13-2 + +### 使用 Table 表格组件实现文件展示 + +参考官方示例([Table 表格](https://element.eleme.cn/#/zh-CN/component/table))来实现文件展示区域。仍然在 `views/Home/components` 下创建文件 `FileTable.vue`,添加以下内容: + +```vue + + + +``` + +在 `index.vue` 中引入: + +```vue + + + +... +``` + +刷新首页,看下文件展示区域和页面布局: + +![13-3](https://doc.shiyanlou.com/courses/3472/1557563/c2077568eb9aa346721939d0d47aa3ce-0/wm) + +表格的操作列需要添加对文件操作的按钮:删除、移动、重命名、下载,同时需要支持操作列的展开和收缩两种形态,先来添加下操作列展开状态下的按钮,并为每个按钮先创建好点击事件,写法可以参考官方示例([Table 自定义列模板](https://element.eleme.cn/#/zh-CN/component/table#zi-ding-yi-lie-mo-ban)),继续编辑 `FileTable.vue` 文件: + +```vue + + + +``` + +刷新页面,点击操作列的按钮,可以看到控制台打印出相应的信息: + +![13-4](https://doc.shiyanlou.com/courses/3472/1557563/19eff73b5fa17880bb19c1a36860b922-0/wm) + +再来添加操作列收缩状态下的按钮,用 Element UI 的下拉菜单([Dropdown 下拉菜单](https://element.eleme.cn/#/zh-CN/component/dropdown))来实现: + +```vue + +``` + +刷新页面,看下效果: + +![13-5](https://doc.shiyanlou.com/courses/3472/1557563/0d5bf2843217e1847b6cbc214d05aedb-0/wm) + +来添加下表格操作列收缩和展开状态的切换,将控制切换的入口添加在表格头上,这里需要用到 Element UI 中 Table 组件的自定义表头([Table 自定义表头](https://element.eleme.cn/#/zh-CN/component/table#zi-ding-yi-biao-tou)),同时将表格列的宽度设置为动态变化,随收缩状态而改变: + +```vue + + + + + +``` + +刷新首页,点击表格操作列表头的图标,切换收缩状态: + +13-6 + +### 使用 Pagination 组件实现分页 + +由于网盘中存储的文件会很多,一次性加载所有的文件会降低文件加载速度,且在表格内拖动滚动条的方式在交互上也不够友好,所以需要常见的分页组件来提升查看文件的效率。参考官方示例([Pagination 分页组件的附加功能](https://element.eleme.cn/#/zh-CN/component/pagination#fu-jia-gong-neng))中的完整功能,来实现分页组件。仍然在 `views/Home/components` 下创建新文件 `FilePagination.vue`,添加以下内容: + +```vue + + + + + +``` + +然后在 `views/Home/index.vue` 文件中添加如下代码: + +```vue + + + +``` + +然后在 `views/Home/index.vue` 文件中添加如下代码: + +```vue + + + +``` + +现在需要在 `SelectColumn.vue` 中来控制表格列的显隐,点击按钮时,获取 store 中存储的表格显示列,对应的多选框处于勾选状态,点击对话框确定按钮时,提交 mutation 更新表格显示列: + +```vue +... + +``` + +刷新首页,表格显示列的显隐已可以控制: + +13-9 + +## 页面接口添加并实现数据联动 + +在 `src/request` 下新建文件 `file.js`,后续与文件有关的接口均可放在此文件中,添加接口: + +```javascript +import { get } from './http' + +// 左侧菜单选择的为 全部 时,根据路径,获取文件列表 +export const getFileListByPath = (p) => get('/file/getfilelist', p) +// 左侧菜单选择的为 除全部以外 的类型时,根据类型,获取文件列表 +export const getFileListByType = (p) => get('/file/selectfilebyfiletype', p) +``` + +当左侧菜单选择**全部**时,右侧的面包屑导航栏将会显示当前所处的路径,需调用接口——根据路径获取文件列表;当左侧菜单选择**除全部以外**的菜单时,右侧的面包屑导航栏会显示当前所展示的文件类型,右侧表格区域显示相应类型的文件,需调用接口——根据类型获取文件列表。 + +### 左侧菜单和右侧表格数据联动 + +表格数据获取要考虑三点:文件类型、文件路径、分页。在前面实现左侧菜单组件时,已经把文件类型添加到了路由参数上,文件类型可以通过 `this.$route.query.fileType` 来获取;文件路径在面包屑导航栏组件中;分页数据也在子组件中;查询结果需要在表格中显示。 + +综合考量,将调用接口函数写在 `src/views/Home/index.vue` 中比较合适,父子组件通讯使用 `props/$emit`,在 `index.vue` 中添加以下内容: + +```vue + + + +``` + +在 `FilePagination.vue` 中添加以下内容: + +```vue +... + +... +``` + +在 `FileTable.vue` 中添加以下内容: + +```vue + + + +``` + +刷新首页,左侧菜单选中**全部**、**图片**时看下运行结果: + +![13-10](https://doc.shiyanlou.com/courses/3472/1557563/28947b8c2ded3fd3ad5628fc87d96f05-0/wm) + +![13-11](https://doc.shiyanlou.com/courses/3472/1557563/4f142a8bd1bae4c585cacf29b957ef2b-0/wm) + +下一节介绍完上传文件后,上述两个接口返回值就会有数据了。 + +### 左侧菜单和右侧面包屑导航栏数据联动 + +在 `src/views/Home/index.vue` 中给面包屑导航栏组件传值 `fileType`: + +```vue + + +``` + +由于要区分当前查看的文件是按类型还是按路径,面包屑导航栏要显示的信息不同,在 `BreadCrumb.vue` 中添加以下内容: + +```vue + + + + + +``` + +在下个实验讲解完上传文件和创建文件夹后,将会回到这个文件,完善面包屑导航栏的功能:当点击面包屑导航栏中的某一级时,改变路由,在 `index.vue` 中监听 filePath 变化重新获取表格数据。 + +刷新首页,看到左侧菜单切换时,路由变化,右侧面包屑导航栏也会随之变化: + +![13-12](https://doc.shiyanlou.com/courses/3472/1557563/f80864eb4b454f8a17e830a6c21ced96-0/wm) + +## 实验总结 + +本次实验介绍了使用 Element UI 的部分组件来实现首页页面,使用 Vuex 保存数据、共享状态,并实现了组件间的数据联动。 + +本次实验完整代码可以通过如下命令进行下载: + +```bash +wget https://labfile.oss.aliyuncs.com/courses/3472/code13.zip +``` diff --git "a/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/17-466275-\346\226\207\344\273\266\345\244\271\346\267\273\345\212\240\343\200\201\346\226\207\344\273\266\344\270\212\344\274\240\345\256\236\347\216\260.sy.md" "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/17-466275-\346\226\207\344\273\266\345\244\271\346\267\273\345\212\240\343\200\201\346\226\207\344\273\266\344\270\212\344\274\240\345\256\236\347\216\260.sy.md" new file mode 100755 index 0000000..8641da4 --- /dev/null +++ "b/8842-Spring Boot \344\273\216\351\233\266\345\256\236\347\216\260\347\275\221\347\233\230\347\263\273\347\273\237-\351\241\271\347\233\256\350\256\255\347\273\203\350\220\245-private/17-466275-\346\226\207\344\273\266\345\244\271\346\267\273\345\212\240\343\200\201\346\226\207\344\273\266\344\270\212\344\274\240\345\256\236\347\216\260.sy.md" @@ -0,0 +1,1319 @@ +--- +show: step +version: 1.0 +enable_checker: true +--- + +# 文件夹添加、文件上传实现 + +## 实验介绍 + +本实验将介绍文件夹的添加和文件上传,包括文件秒传、对大文件的文件切片和断点上传功能的实现,之后将会完善文件列表数据展示,并添加存储空间统计。 + +#### 知识点 + +- 文件切片 +- 断点上传 +- 文件秒传 +- 全局函数 +- Element UI 的 Dialog 对话框和 Progress 进度条组件的使用 + +#### 开发计划 + +- 开发内容: + 1. 使用 Dialog 组件实现新建文件夹功能 + 2. 使用开源插件 [vue-simple-uploader](https://github.com/simple-uploader/vue-uploader) 实现文件上传:包括文件秒传、大文件切片和断点上传 + 3. 完善文件列表数据展示:文件图标、文件扩展名 + 4. 在左侧文件分类栏底部,添加存储空间大小展示。 +- 开发耗时:实验预计完成时间为 2~3 小时 +- 开发难点:vue-simple-uploader 开源插件的使用 + +## 添加文件上传组件 + +在 `src/views/Home/components` 下创建文件 `OperationMenu.vue`,将文件夹的添加、文件上传均放在此组件中,文件内容稍后讲解。在 `src/views/Home/index.vue` 中引入此文件,将 fileType 传递给子组件,以便在不同类型文件页面,判断是否对新建文件夹按钮做禁用: + +```vue + + + + + +``` + +对所有跳转到首页中**全部**类型文件页面的路由做修改: + +`src/components/Header.vue` 中跳转到首页的路由修改为: + +```vue +首页 +``` + +`src/views/Home/components/SideMenu.vue` 中跳转到首页的路由修改为: + +```vue + + + 全部 + +``` + +`Login.vue` 和 `Register.vue` 中跳转到首页的路由修改为: + +```javascript +this.$router.replace({ + name: 'Home', + query: { fileType: 0, filePath: '/' } +}) +``` + +同时将 `SideMenu.vue` 中 `created()` 中的路由跳转代码删除: + +![14-1](https://doc.shiyanlou.com/courses/3472/1557563/c618202b0c4b29585d579f3cd8142961-0/wm) + +现在退出登录,重新登录,跳转到首页的路由参数会带上 fileType 和 filePath。 + +## 新建文件夹功能 + +在 `src/views/Home/index.vue` 中将 filePath 传递给子组件,同时接收子组件向外触发的获取文件列表事件: + +```vue + +``` + +filePath 的值通过路由参数获取: + +```javascript +computed: { + ... + // 当前所在路径 + filePath() { + return this.$route.query.filePath + } +}, +``` + +在 `src/request/file.js` 中添加新建文件夹接口: + +```javascript +import { get, post } from './http' + +// 创建文件夹 或 文件 +export const createFile = (p) => post('/file/createfile', p) +``` + +在 `OperationMenu.vue` 中使用按钮组来包裹新建文件按钮,点击按钮弹出对话框,用户输入文件夹名称,点击对话框提交按钮,表单校验通过后,调用新建文件夹接口,创建成功后,关闭对话框,并重新获取文件列表: + +```vue + + + +``` + +刷新首页,新建文件夹“实验楼”: + +14-2 + +面包屑导航栏文件 `BreadCrumb.vue` 也需要一些改造: + +```vue + +... +``` + +根据路径获取文件列表接口 getFileListByPath 中的请求参数 filePath 也需要改为路由参数中的 filePath,监听路由参数中的 filePath 变化,值改变时,重新获取文件列表。这样点击面包屑导航栏中的某一级就可以获取该路径下的文件列表了。在 `src/views/Home/index.vue` 中修改: + +```javascript +... +watch: { + filePath() { + // 当左侧菜单选择全部,文件路径发生变化时,再重新获取文件列表 + if (this.fileType === 0) { + this.getFileData() // 获取文件列表 + } + } +}, +methods: { + ... + // 根据路径获取文件列表 + getFileDataByPath() { + getFileListByPath({ + filePath: this.filePath, // 传递当前路径 + currentPage: this.pageData.currentPage, + pageCount: this.pageData.pageCount + }).then( + // 已有代码不再赘述 + ... + ) + } +} +``` + +## 文件列表数据处理 + +现在获取文件列表接口的返回值有值了,来处理下这些返回值,以便能更好的在表格中展示: + +1. 类型:显示当前行的文件类型,若为文件夹就显示“文件夹”; +2. 大小:显示当前行的文件的大小,单位转化为 KB、MB、GB; +3. 文件名:当前行若为文件夹,点击文件名,进入文件夹内部,获取文件夹内部的文件列表,路由参数中的 filePath 和面包屑导航栏随之改变,表格数据重新渲染。 + +在 `FileTable.vue` 中加入以下内容,处理文件类型: + +```vue +... + + + +... +``` + +处理文件大小: + +```vue +... + + + +... +``` + +处理函数如下: + +```javascript +... + methods: { + // 计算文件大小 + calculateFileSize(size) { + const B = 1024 + const KB = Math.pow(1024, 2) + const MB = Math.pow(1024, 3) + const GB = Math.pow(1024, 4) + if (!size) { + return '_' + } else if (size < KB) { + return (size / B).toFixed(0) + 'KB' + } else if (size < MB) { + return (size / KB).toFixed(1) + 'MB' + } else if (size < GB) { + return (size / MB).toFixed(2) + 'GB' + } else { + return (size / GB).toFixed(3) + 'TB' + } + }, + // 删除按钮 - 点击事件 + handleClickDelete(row) { + console.log('删除', row.fileName) + }, + ... +``` + +处理文件名点击事件: + +```vue + + + +``` + +添加函数: + +```javascript +methods: { + // 文件名点击事件 + handleFileNameClick(row) { + // 若是目录则进入目录 + if (row.isDir) { + this.$router.push({ + query: { + filePath: `${row.filePath}${row.fileName}/`, + fileType: 0 + } + }) + } + }, + ... +} +``` + +刷新首页,点击文件夹名称路由参数改变,可以进入到文件夹内部查看文件列表,点击面包屑导航栏也可以切换文件查看路径: + +14-3 + +再来调整下表格高度和滚动条样式,使表格高度自适应窗口高度,表格组件添加 height 属性,继续编辑 `FileTable.vue` 文件: + +```vue +