伙伴匹配系统 需求分析 移动端 H5 网页(尽量兼容 PC 端)
需求分析 用户去添加标签 标签的分类:学习方向 java / c++,工作 / 大学 主动搜索:允许用户根据标签去搜索其他用户 Redis 缓存
组队 创建队伍 加入队伍 根据标签查询队伍 邀请其他人 允许用户修改标签
推荐 相似度计算算法 + 本地分布式计算
技术选型 前端 Vue 3 + Vant4 UI 组件库 + Axios 请求库
后端 Java SpringBoot 2.7.x 框架 + MySQL + MyBatis-Plus + MyBatis X 自动生成 + Redis 缓存(Spring Data Redis 等多种实现方式) + Redisson 分布式锁 + Easy Excel 数据导入 + Spring Scheduler 定时任务 + Swagger , Knife4j 接口文档 + Gson:JSON 序列化库 + 相似度匹配算法
暂时目录 15后端分布式登录改造(Session 共享) 16用户登录功能开发 17修改个人信息功能开发 18主页开发(抽象通用列表组件) 19批量导入数据功能 20几种方案介绍及对比 21测试及性能优化(并发编程) 22主页性能优化 23缓存和分布式缓存讲解 24Redis 讲解 25缓存开发和注意事项 26缓存预热设计与实现 27定时任务介绍和实现 28锁 / 分布式锁介绍 29分布式锁注意事项讲解 30Redisson 分布式锁实战 31控制定时任务执行的几种方案介绍及对比 32组队功能 33需求分析 34系统设计 35多个接口开发及测试 36前端多页面开发 37权限控制 38随机匹配功能 39匹配算法介绍及实现 40性能优化及测试 41项目优化及完善 42免备案方式上线前后端
前端初始化 用脚手架初始化项目
创建项目Vite 官网 Vite 脚手架 npm create vue@latest 选择语言——ts
整合组件库 Vant
安装 Vant npm i vant
按需引入组件样式(此项目选择这个) npm i @vant/auto-import-resolver unplugin-vue-components unplugin-auto-import -D
在 vite.config.js 配置插件(从文档获取并修改)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import vueDevTools from 'vite-plugin-vue-devtools' import AutoImport from 'unplugin-auto-import/vite' ;import Components from 'unplugin-vue-components/vite' ;import { VantResolver } from '@vant/auto-import-resolver' ;export default defineConfig ({ plugins : [ vue (), vueDevTools (), AutoImport ({ resolvers : [VantResolver ()], }), Components ({ resolvers : [VantResolver ()], }), ], resolve : { alias : { '@' : fileURLToPath (new URL ('./src' , import .meta .url )) }, }, })
添加 vite.config.ts 端口为3000
1 2 3 4 server: { port: 3000, }
使用 Vant 组件和 API unplugin-vue-components 会解析模板并自动注册对应的组件 @vant/auto-import-resolver 会自动引入对应的组件样式
1 2 3 <template > <van-button type ="primary" /> </template >
unplugin-auto-import 会自动导入对应的 Vant API 以及样式
1 2 3 <script > showToast ('No need to import showToast' ); </script >
挂载到 main.ts 中
1 2 3 4 5 6 7 import { createApp } from 'vue' import App from './App.vue' import {Button } from "vant" ;const app = createApp (App )app.use (Button ) app.mount ('#app' )
前端页面设计及通用布局开发 设计 导航条:展示当前页面名称 主页搜索框 => 搜索页 => 搜索结果页(标签筛选页)
内容 tab 栏:
主页(推荐页 + 广告 )
队伍页
用户页(消息 - 暂时考虑发邮件)
开发 很多页面要复用组件/样式,所以用通用布局(Layout) 创建页面: pages/Index.vue,TeamPage.vue,UserPage.vue,SearchPage.vue
顶部导航栏 引入导航组件-导航栏navbar:app.use(NavBar); 复制模块代码到 layouts/BasicLayouts.vue (新建) 中
1 2 3 4 5 <van-nav-bar title ="泡泡匹配" left-text ="" left-arrow > <template #right > <van-icon name ="search" size ="18" /> </template > </van-nav-bar >
如何使用setup
底部标签栏 引入导航组件-标签栏tabbar:app.use(TabBar);app.use(TabbarItem); 复制模块代码到 layouts/BasicLayouts.vue 中 监听切换事件
1 2 3 4 5 6 <van-tabbar v-model ="active" @change ="onChange" > <van-tabbar-item icon ="home-o" name ="index" > 主页</van-tabbar-item > <van-tabbar-item icon ="search" name ="team" > 队伍</van-tabbar-item > <van-tabbar-item icon ="friends-o" name ="user" > 个人</van-tabbar-item > </van-tabbar >
1 2 3 4 5 6 import {ref} from "vue" ;import {showToast} from "vant" ;import "vant/es/toast/style" ;const active = ref ("index" );const onChange = (index:String ) => showToast (`标签 ${index} ` )
插槽
1 2 3 <slot name ="content" > <div > 这里写内容</div > </slot >
后端数据库表设计 数据库 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 create database if not exists paopao;use paopao; create table user ( username varchar (256 ) null comment '用户昵称' , id bigint auto_increment comment 'id' primary key , userAccount varchar (256 ) null comment '账号' , avatarUrl varchar (1024 ) null comment '用户头像' , gender tinyint null comment '性别' , profile varchar (512 ) null comment'个人简介' , # 暂时添加 userPassword varchar (512 ) not null comment '密码' , phone varchar (128 ) null comment '电话' , email varchar (512 ) null comment '邮箱' , userStatus int default 0 not null comment '状态 0 - 正常' , createTime datetime default CURRENT_TIMESTAMP null comment '创建时间' , updateTime datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP , isDelete tinyint default 0 not null comment '是否删除' , userRole int default 0 not null comment '用户角色 0 - 普通用户 1 - 管理员' , tags varchar (1024 ) null comment '标签 json 列表' ) comment '用户' ; create table team( id bigint auto_increment comment 'id' primary key , name varchar (256 ) not null comment '队伍名称' , description varchar (1024 ) null comment '描述' , maxNum int default 1 not null comment '最大人数' , expireTime datetime null comment '过期时间' , userId bigint comment '用户id(队长 id)' , status int default 0 not null comment '0 - 公开,1 - 私有,2 - 加密' , password varchar (512 ) null comment '密码' , createTime datetime default CURRENT_TIMESTAMP null comment '创建时间' , updateTime datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP , isDelete tinyint default 0 not null comment '是否删除' ) comment '队伍' ; create table user_team( id bigint auto_increment comment 'id' primary key , userId bigint comment '用户id' , teamId bigint comment '队伍id' , joinTime datetime null comment '加入时间' , createTime datetime default CURRENT_TIMESTAMP null comment '创建时间' , updateTime datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP , isDelete tinyint default 0 not null comment '是否删除' ) comment '用户队伍关系' ; create table tag( id bigint auto_increment comment 'id' primary key , tagName varchar (256 ) null comment '标签名称' , userId bigint null comment '用户 id' , parentId bigint null comment '父标签 id' , isParent tinyint null comment '0 - 不是, 1 - 父标签' , createTime datetime default CURRENT_TIMESTAMP null comment '创建时间' , updateTime datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP , isDelete tinyint default 0 not null comment '是否删除' , constraint uniIdx_tagName unique (tagName) ) comment '标签' ; create index idx_userId on tag (userId);
标签接口调试
更改代码 用户中心的searchUsersByTags方法里写了两种查询方式,这次就把它们分开,写成两个方法 整理如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 @Override public List <User > searchUsersByTags (List <String > tagNameList ) { if (CollectionUtils .isEmpty (tagNameList)) { throw new BusinessException (ErrorCode .PARAMS_ERROR ); } QueryWrapper <User > queryWrapper = new QueryWrapper <>(); List <User > userList = userMapper.selectList (queryWrapper); Gson gson = new Gson (); return userList.stream ().filter (user -> { String tagsStr = user.getTags (); if (StringUtils .isBlank (tagsStr)) { return false ; } Set <String > tempTagNameSet = gson.fromJson (tagsStr, new TypeToken <Set <String >>() { }.getType ()); tempTagNameSet = Optional .ofNullable (tempTagNameSet).orElse (new HashSet <>()); for (String tagName : tagNameList) { if (!tempTagNameSet.contains (tagName)) { return false ; } } return true ; }).map (this ::getSafetyUser).collect (Collectors .toList ()); } @Deprecated private List <User > searchUsersByTagsBySQL (List <String > tagNameList ) { if (CollectionUtils .isEmpty (tagNameList)) { throw new BusinessException (ErrorCode .PARAMS_ERROR ); } QueryWrapper <User > queryWrapper = new QueryWrapper <>(); for (String tagName : tagNameList) { queryWrapper = queryWrapper.like ("tags" , tagName); } List <User > userList = userMapper.selectList (queryWrapper); return userList.stream ().map (this ::getSafetyUser).collect (Collectors .toList ()); }
前端功能实现 前端整合路由 Vue-Router 安装:npm install vue-router@4 Vue-Router 根据不同的 url 来跳转页面(组件),不用自己写if 路由配置影响整个项目,所以用 config/router.ts 集中定义和管理
另一种方法
1 2 3 4 5 6 7 8 <div id="content" > <template v-if ="active===`index`" > <Index /> </template > <template v-if ="active===`team`" > <Team /> </template > </div>
config/router.ts 挂载:app.use(router);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import {createRouter, createWebHashHistory} from 'vue-router' import Index from "@/pages/Index.vue" ;import Team from "@/pages/TeamPage.vue" ;import User from "@/pages/UserPage.vue" ;import SearchPage from "@/pages/SearchPage.vue" ;const routes = [ { path : '/' , component : Index }, { path : '/team' , component : Team }, { path : '/user' , component : User }, { path : '/search' , component : SearchPage } ] const router = createRouter ({ history : createWebHashHistory (), routes, }) export default router;
/layouts/BasicLayouts.vue 删除 const active = ref("index");
1 2 3 4 5 6 7 8 <div id ="content" > <router-view /> </div > <van-tabbar route @change ="onChange" > <van-tabbar-item to ="/" icon ="home-o" name ="index" > 主页</van-tabbar-item > <van-tabbar-item to ="/team" icon ="search" name ="team" > 队伍</van-tabbar-item > <van-tabbar-item to ="/user" icon ="friends-o" name ="user" > 个人</van-tabbar-item > </van-tabbar >
搜索功能
搜索页面 Vant4 - 表单组件 - 搜索 - 事件监听 复制到SearchPage.vue页面
1 2 3 4 5 6 7 8 9 10 11 <template > <form action ="/" > <van-search v-model ="searchText" show-action placeholder ="请输入搜索关键词" @search ="onSearch" @cancel ="onCancel" /> </form > </template >
1 2 3 4 5 6 7 8 9 <script setup> import { ref } from 'vue' ;import { showToast } from 'vant' ;const searchText = ref ('' );const onSearch = (val ) => showToast (val);const onCancel = ( ) => showToast ('取消' );</script>
搜索按钮vue router文档-编程式导航
1 2 3 4 router.push ('/users/eduardo' ) router.back ()
复制到BsaicLayout.vue中修改
1 2 3 4 5 <van-nav-bar title ="泡泡匹配" left-text ="" left-arrow @click-left ="onClickLeft" @click-right ="onClickRight" > <template #right > <van-icon name ="search" size ="18" /> </template > </van-nav-bar >
1 2 3 4 5 import {useRouter} from "vue-router" ;const router = useRouter ();const onClickLeft = ( ) => alert (router.back ());const onClickRight = ( ) => alert (router.push ('/search' ));
添加样式:解决底部导航栏被遮住
1 2 3 4 5 <style scoped> #content { padding-bottom : 50px ; } </style>
标签的添加,搜索,删除 vant - 展示组件 - 分割线 - Divider
1 2 3 <van-divider :style ="{ color: '#1989fa', borderColor: '#1989fa', padding: '0 16px' }" > 文字 </van-divider >
vant - 展示组件 - 标签 - Tag
1 2 3 <van-tag :show ="show" closeable size ="medium" type ="primary" @close ="close" > 标签 </van-tag >
vant - 导航组件 - 分类选择 - TreeSelect
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 import { ref } from 'vue' ;export default { setup ( ) { const activeId = ref (1 ); const activeIndex = ref (0 ); const items = [ { text : '浙江' , children : [ { text : '杭州' , id : 1 }, { text : '温州' , id : 2 }, ], }, { text : '江苏' , children : [ { text : '南京' , id : 5 }, { text : '无锡' , id : 6 }, ], }, ]; return { items, activeId, activeIndex, }; }, };
vant - 基础组件 - 间距 - Space
1 2 3 4 5 <van-row gutter ="20" > <van-col span ="8" > span: 8</van-col > <van-col span ="8" > span: 8</van-col > <van-col span ="8" > span: 8</van-col > </van-row >
将上面的组件复制到 SearchPage.vue 并整理修改
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <van-divider content-position ="left" > 已选标签</van-divider > <div v-if ="activeIds.length===0" > 请选择标签</div > <van-row gutter ="16" style ="padding: 0 16px" > <van-col v-for ="tag in activeIds" > <van-tag closeable size ="small" type ="primary" > {{tag}} </van-tag > </van-col > </van-row > <van-tree-select v-model:active-id ="activeIds" v-model:main-active-index ="activeIndex" :items ="tagList" />
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 const activeIds = ref ([]);const activeIndex = ref (0 );const tagList = [ { text : '性别' , children : [ { text : '男' , id : '男' }, { text : '女' , id : '女' }, ], }, { text : '年级' , children : [ { text : '大一' , id : '大一' }, { text : '大二' , id : '大二' }, ], }, ];
移除标签函数
1 2 3 4 5 6 const doClose = (tag ) =>{ activeIds.value =activeIds.value .filter (item => { return item !== tag; }) }
在van-tag里面添加@close="doClose(tag)"
修改搜索函数
1 2 3 4 5 6 const onSearch = (val:any ) => { console .log (tagList.flatMap (parentTag => parentTag.children )) }; const onCancel = ( ) => { searchText.value = "" ; };
数据扁平化,原来是嵌套结构,将数据扁平之后(打平)再进行过滤
1 2 3 4 5 6 7 8 9 10 11 12 13 const onSearch = (val:any ) => { tagList.value = originTagList.map (parentTag => { const tempChildren = [...parentTag.children ]; const tempParentTag = {...parentTag}; tempParentTag.children = tempChildren.filter (item => item.text .includes (searchText.value )); return tempParentTag; }); }; const onCancel = ( ) => { searchText.value = "" ; tagList.value = originTagList; };
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 const originTagList = [ { text : '性别' , children : [ { text : '男' , id : '男' }, { text : '女' , id : '女' }, ], }, { text : '年级' , children : [ { text : '大一' , id : '大一' }, { text : '大二' , id : '大二' }, ], }, ]; let tagList = ref (originTagList);
创建用户信息页 vant - 基础组件 - 单元格 - cell UserPage.vue
1 2 3 <van-cell title ="单元格" is-link /> <van-cell title ="单元格" is-link value ="内容" /> <van-cell title ="单元格" is-link arrow-direction ="down" value ="内容" />
定义一下后台用户数据的类别,在用户中心 中曾写过这个规范 在src目录下建立models/user.d.ts,将规范粘贴进去并适当修改如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 export type UserType = { id : number ; username : string ; userAccount : string ; avatarUrl ?: string ; gender : number ; profile ?: string ; phone : string ; email : string ; userStatus : number ; userRole : number ; tags : string []; createTime : Date ; };
在UserPage.vue中引入,自己写点假数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 <template > <van-cell title ="昵称" is-link to ='/user/edit' :value ="user.username" /> <van-cell title ="账号" is-link to ='/user/edit' :value ="user.userAccount" /> <van-cell title ="头像" is-link to ='/user/edit' > <img style ="height: 48px" :src ="user.avatarUrl" /> </van-cell > <van-cell title ="性别" is-link to ='/user/edit' :value ="user.gender" /> <van-cell title ="电话" is-link to ='/user/edit' :value ="user.phone" /> <van-cell title ="邮箱" is-link to ='/user/edit' :value ="user.email" /> <van-cell title ="注册时间" :value ="user.createTime.toISOString()" /> </template > <script setup > const user = { id : 1 , username : '微光zc' , userAccount : 'wgzc' , avatarUrl : 'https://wzcwzc10.github.io/img/jufufu-ht.gif' , gender : '男' , phone : '19722739665' , email : '3412399241@qq.com' , createTime : new Date (), }; </script >
创建用户信息修改页 新建 pages/UserEditPage.vue 在route.ts添加新路由{ path:'/user/edit', component: UserEditPage }
对UserPage.vue和UserEditPage.vue进行修改 UserPage.vue: 添加@click="toEdit('gender', '性别', user.gender)" 添加@click="toEdit('phone', '电话', user.phone)"
1 2 3 4 5 6 7 8 9 10 11 12 13 14 const router = useRouter ();const toEdit = (editKey :string ,editName :string ,currentValue :string ) =>{ router.push ({ path :'/user/edit' , params :{ a :1 }, query :{ editKey, editName, currentValue, } }) }
UserEditPage.vue:
1 2 3 4 5 6 7 import {useRoute,useRouter}from "vue-router" ;const router = useRouter ();const route = useRoute ();console .log (route)console .log (route.params )console .log (route.query )
vant - 表单组件 - 表单 - Form 复制粘贴到UserEditPage.vue修改整理如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <template> <van-form @submit ="onSubmit" > <van-field v-model ="editUser.currentValue" :name ="editUser.editKey" :label ="editUser.editName" :placeholder ="'请输入${editUser.editName}'" /> <div style ="margin: 16px;" > <van-button round block type ="primary" native-type ="submit" > 提交 </van-button > </div > </van-form > </template>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <script setup lang="ts" > import {useRoute} from "vue-router" ;import {ref} from "vue" ;const route = useRoute ();const editUser = ref ({ editKey : route.query .editKey , currentValue : route.query .currentValue , editName : route.query .editName , }) const onSubmit = (values ) => { console .log ('onSubmit' ,values); } </script>
后端整合 Swagger + Knife4j 接口文档
请求参数
响应参数
接口地址
接口名称
请求类型
请求格式
备注
自动化接口文档生成:自动根据项目代码生成完整的文档或在线调试的网页。 国外: Knife4j, Postman(侧重接口管理) 国产: apifox, apipost, eolink
引入 Knife4j 依赖Knife4j ) Spring Boot 2 + OpenAPI3: 引入依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 <dependency > <groupId > org.springdoc</groupId > <artifactId > springdoc-openapi-ui</artifactId > <version > 1.6.14</version > </dependency > <dependency > <groupId > com.github.xiaoymin</groupId > <artifactId > knife4j-openapi3-spring-boot-starter</artifactId > <version > 4.5.0</version > </dependency >
Knife4j 的配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 springdoc: swagger-ui: path: /swagger-ui-custom tags-sorter: alpha operations-sorter: alpha api-docs: path: /v3/api-docs group-configs: - group: 'default' paths-to-match: '/**' packages-to-scan: com.yupi.yupao.controller knife4j: enable: true setting: language: zh_cn
启动 knife4j http://localhost:8080/api/doc.html swagger-ui http://localhost:8080/api/swagger-ui-custom OpenAPI JSON http://localhost:8080/api/v3/api-docs
注解自定义生成的接口描述信息 接口 作者名字@ApiOperationSupport(author = "微光zc")@ApiOperation(value = "写文档注释我是认真的") Controller模块 作者名字@Api(tags = "2.0.3版本-20200312")@ApiSupport(author = "微光zc",order = 284)
自定义文档 resources/markdown/XXX.md 配置文件添加
1 2 3 4 5 6 7 8 9 10 11 12 13 knife4j: enable: true documents: - group: 1.2 .x name: 测试自定义标题分组 locations: classpath:markdown/* - group: 1.2 .x name: 接口签名 locations: classpath:markdown/sign.md
自定义主页配置
1 2 3 4 5 knife4j: enable: true setting: enable-home-custom: true home-custom-path: classpath:markdown/home.md
读表格程序(FastExcel) 引入依赖 easy excel (已过时)fesod (存疑-暂不能用)fast excel
1 2 3 4 5 <dependency > <groupId > cn.idev.excel</groupId > <artifactId > fastexcel</artifactId > <version > 1.3.0</version > </dependency >
创建实体类和监听器
创建实体类 定义一个实体类,该类中的每个属性对应Excel中的一列。 使用@ExcelProperty*`注解来指定列名
本项目示例: 这是一次性的代码 先创建一个 once 目录创建 XingQiuTableUserInfo.java 这个文件作用就是将表格和对象相关联起来
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @Data public class XingQiuTableUserInfo { @ExcelProperty("成员编号") private String planetCode; @ExcelProperty("成员昵称") private String userName; }
创建事件监听器 FastExcel 依靠事件监听器实现 Excel 逐行读取文件 事件监听器使得数据可以边读取边处理。 例如,可以直接将数据写入数据库或者进行其他业务逻辑处理,避免了大量数据在内存中的堆积
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class BaseExcelListener <T> extends AnalysisEventListener <T> { private List<T> dataList = new ArrayList <>(); @Override public void invoke (T t, AnalysisContext analysisContext) { dataList.add(t); } @Override public void doAfterAllAnalysed (AnalysisContext analysisContext) { System.out.println("读取完成,共读取了 " + dataList.size() + " 条数据" ); } public List<T> getDataList () { return dataList; } }
本项目示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Slf4j public class TableListener implements ReadListener <XingQiuTableUserInfo> { @Override public void invoke (XingQiuTableUserInfo data, AnalysisContext context) { System.out.println(data); } @Override public void doAfterAllAnalysed (AnalysisContext context) { System.out.println("已解析完成" ); } }
实现写入和读取功能
Excel写入功能 首先,创建测试数据,然后通过FastExcel.write方法将数据写入到Excel文件中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @GetMapping("/download") public void download (HttpServletResponse response) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ); response.setCharacterEncoding("utf-8" ); String fileName = URLEncoder.encode("test" , "UTF-8" ); response.setHeader("Content-disposition" , "attachment;filename*=utf-8''" + fileName + ".xlsx" ); FastExcel.write(response.getOutputStream(), User.class) .sheet("模板" ) .doWrite(buildData()); } private List<User> buildData () { User user1 = new User (); user1.setId(1 ); user1.setName("张三" ); user1.setAge(18 ); User user2 = new User (); user2.setId(2 ); user2.setName("李四" ); user2.setAge(19 ); return List.of(user1, user2); }
Excel读取功能 通过FastExcel.read方法读取Excel文件,并使用之前创建的监听器来处理读取到的数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @PostMapping("/upload") public ResponseEntity<String> upload (@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return ResponseEntity.badRequest().body("请选择一个文件上传!" ); } try { BaseExcelListener<User> baseExcelListener = new BaseExcelListener <>(); FastExcel.read(file.getInputStream(), User.class, baseExcelListener).sheet().doRead(); List<User> dataList = baseExcelListener.getDataList(); System.out.println(dataList); return ResponseEntity.ok("文件上传并处理成功!" ); } catch (IOException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("文件处理失败!" ); } }
本项目示例: 导入excel
1 2 3 4 5 6 7 8 9 10 11 12 public class ImportExcel { public static void main (String[] args) { String fileName = "F:\\code\\星球项目\\用户中心\\user-center-backend-master\\src\\main\\resources\\testExcel.xlsx" ; FastExcel.read(fileName,XingQiuTableUserInfo.class,new TableListener ()).sheet().doRead(); } }
试一下第二种方法: 同步读:无需创建监听器,一次性获取完整数据。方便简单,但是数据量大时会有等待时常,也可能内存溢出。 在ImportExcel里创建两个方法,为了以后调用方便,修改代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 public class ImportExcel { public static void main (String[] args) { String fileName = "E:\\Projects\\PartnerSystem\\testExcel.xlsx" ; synchronousRead(fileName); } public static void readByListener (String fileName) { FastExcel.read(fileName, XingQiuTableUserInfo.class, new TableListener ()).sheet().doRead(); } public static void synchronousRead (String fileName) { List<XingQiuTableUserInfo> list = EasyExcel.read(fileName).head(XingQiuTableUserInfo.class).sheet().doReadSync(); for (XingQiuTableUserInfo xingQiuTableUserInfo : list) { System.out.println(xingQiuTableUserInfo); } } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public class ImportXingQiuUser { public static void main (String[] args) { String fileName = "E:\\Projects\\PartnerSystem\\testExcel.xlsx" ; List<XingQiuTableUserInfo> userInfoList = FastExcel.read(fileName).head(XingQiuTableUserInfo.class).sheet().doReadSync(); System.out.println("总数 = " + userInfoList.size()); Map<String, List<XingQiuTableUserInfo>> listMap = userInfoList.stream() .filter(userInfo -> StringUtils.isNotEmpty(userInfo.getUserName())) .collect(Collectors.groupingBy(XingQiuTableUserInfo::getUserName)); for (Map.Entry<String, List<XingQiuTableUserInfo>> stringListEntry : listMap.entrySet()) { if (stringListEntry.getValue().size() > 1 ) { System.out.println("username = " + stringListEntry.getKey()); System.out.println("1" ); } } System.out.println("不重复昵称数 = " + listMap.keySet().size()); } }
Excel转换为PDF 这一功能底层依赖于Apache POI和itext-pdf。 请注意,使用itext-pdf时需要确保符合其许可证要求。FastExcel.convertToPdf(new File("excelFile"),new File("pdfFile"),null,null);
前端优化 前端页面跳转传值 query => url searchParams,url 后附加参数,传递的值长度有限 vuex(全局状态管理),搜索页将关键词塞到状态中,搜索结果页从状态取值
todo 待优化 前端:动态展示页面标题、微调格式
一、页面和功能开发
搜索页面 (1)新建SearchResultPage.vue,创建页面,同时在路由里引入这个页面 { path: '/user/list', component: SearchResultPage }
(2)优化SearchPage页面,添加一个搜索按钮,来实现点击提交选中的标签到UserResultPage页面
1 2 3 <div style ="padding: 16px" > <van-button block type ="primary" @click ="doSearchResult" > 搜索</van-button > </div >
1 2 3 4 5 6 7 8 9 10 11 import {useRouter} from 'vue-router' ;const router = useRouter ();const doSearchResult = ( ) => { router.push ({ path : '/user/list' , query : { tags : activeIds.value } }) }
(3)vant - 业务组件- 商品卡片 - Card 复制到 SearchResultPage.vue 页面并修改如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <template > <van-card v-for ="user in userList" :desc ="`个人简介:${user.profile}`" :title ="`${user.username} (${user.planetCode})`" :thumb ="user.avatarUrl" > <template #tags > <van-tag plain type ="danger" v-for ="tag in tags" style ="margin-right: 8px; margin-top: 8px" > {{tag}} </van-tag > </template > <template #footer > <van-button size ="mini" > 联系我</van-button > </template > </van-card > <van-empty v-if ="!userList || userList.length < 1" description ="搜索结果为空" /> </template >
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 <script setup > import {ref} from "vue" ; import {useRoute} from "vue-router" ; const route = useRoute (); const {tags} = route.query ; const mockUser = { id : 777 , username : '微光zc' , userAccount : 'wgzc' , avatarUrl : 'https://wzcwzc10.github.io/img/jufufu-ht.gif' , gender : 0 , profile : '代码还真多' , phone : '110' , email : '3412399241@qq.com' , planetCode : '777' , userRole : '管理员' , createTime : new Date (), tags : ['java' , '前端' , '实习' , '后端' , '开学' ], }; const userList = ref ({mockUser}); </script>
(4)前端后端对接 对接后端接口,在controller层编写代码
1 2 3 4 5 6 7 8 9 @GetMapping("/search/tags") public BaseResponse<List<User>> searchUsersByTags (@RequestParam(required = false) List<String> tagNameList) { if (CollectionUtils.isEmpty(tagNameList)){ return ResultUtils.error(ErrorCode.PARAMS_ERROR); } List<User> userList = userService.searchUsersByTags(tagNameList); return ResultUtils.success(userList); }
debug启动项目,去knife4j接口操作,确保已经登录,传两个参数,回后端看看是否获取参数
banner.txt 修改项目其中时的信息 选择一些好看的图案
//////////////////////////////////////////////////////////////////// // ooOoo // // o8888888o // // 88” . “88 // // (| - - |) // // O\ = /O // // /---'\____ // // .' \\| |// . // // / \||| : |||// \ // // / ||||| -:- |||||- \ // // | | \\ - /// | | // // | _| ‘’-–/‘’ | | // // \ .-_ - /-. / // // . .' /--.--\ . . ___ // // .”” ‘< .___\_<|>_/___.' >'"". // // | | : - `.;\ _ /;./ - : | | // // \ \ -. \_ __\ /__ _/ .- / / // // ========-.____-. _ / .-____.-'======== // // =—=’ // // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ // // 佛祖保佑 永不宕机 永无BUG // ////////////////////////////////////////////////////////////////////
(5)开发前端接口 首先要在前端引入axios 终端输入npm install axios
新建src/plugins/myAxios.ts 复制axios文档中自定义实例默认值,拦截器并修改整理如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import axios from "axios" ;const myAxios = axios.create ({ baseURL : 'http://localhost:8080/api' , }); myAxios.interceptors .request .use (function (config ) { console .log ("我要发送请求了," ,config) return config; }, function (error ) { return Promise .reject (error); }); myAxios.interceptors .response .use (function (response ) { console .log ("我收到你的响应了," ,response) return response.data ; }, function (error ) { return Promise .reject (error); }); export default myAxios;
在 SearchResultPage.vue 页面新增如下代码用来页面挂载 onMounted 钩子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 onMounted (() => { myAxios.get ('/user/search/tags' ,{ params : { tagNameList : tags } }) .then (function (response ) { console .log ('/user/search/tags succeed' ,response); showSuccessToast ('请求成功' ); }) .catch (function (error ) { console .error ('/user/search/tags error' ,error); showFailToast ('请求失败' ); }) })
解决跨域问题@crossorigin(origins "http://localhost:3000")
将 SearchResultPage.vue 代码进行修改const userList = ref(); 删除假数据 修改 onMounted 钩子函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 const userList = ref ([]); onMounted (async () => { const userListData = await myAxios.get ('/user/search/tags' , { withCredentials : false , params : { tagNameList : tags }, paramsSerializer : { serialize : params => qs.stringify (params, {indices : false }), }, }) .then (function (response ) { console .log ('/user/search/tags succeed' , response); return response.data ?.data ; }) .catch (function (error ) { console .log ('/user/search/tags error' , error); Toast .fail ('请求失败' ); }); if (userListData) { userListData.forEach (user => { if (user.tags ) { user.tags = JSON .parse (user.tags ); } }) userList.value = userListData; } })
改造用户中心,把单机登录改为分布式 session 登录 分布式登录 分布式登录:不能在 Seesion 来做存信息(不能只保存到本地上)。 如果登陆信息放在了服务器A,下一次请求到了服务器B,那此时服务器B没有上一次的登录信息 所以要用中间件,也就是redis(Redission Java客户端)做这个分布式登录,这样不管请求那个服务器都会有登录信息。
Session 共享 种 session 的时候注意范围,cookie.domain 比如两个域名: aaa.wgzc.com bbb.wgzc.com 如果要共享 cookie,可以种一个更高层的公共域名,比如 wgzc.com
下载 redisRedis 5.0.14 下载 提取码:vkoi redis 管理工具quick redis
引入 redis 依赖 在springboot里引入redis,能够操作redis
1 2 3 4 5 6 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-redis</artifactId > <version > 4.0.0</version > </dependency >
引入 spring-session 和 redis 的整合,使得自动将 session 存储到 redis 中
1 2 3 4 5 6 <dependency > <groupId > org.springframework.session</groupId > <artifactId > spring-session-data-redis</artifactId > <version > 4.0.0</version > </dependency >
如果启动失败去掉版本号
修改 spring-session 存储配置 spring.session.store-type 默认是 none,表示存储在单台服务器 store-type: redis,表示从 redis 读写 session
1 2 3 4 5 6 7 8 9 session: timeout: 86400 store-type: redis redis: port: 6379 host: localhost database: 1
为了模拟多服务器,我们需要打包项目,在另一个端口启动,这里是8081 先打包,后在target目录下打开终端运行下面的代码 java -jar .\user-center-backend-0.0.1-SNAPSHOT.jar –server.port=8081
改造用户信息接口
在UserController里面添加更新用户信息接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @PostMapping("/update") public BaseResponse<Integer> updateUser (@RequestBody User user, HttpServletRequest request) { if (user == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } User loginUser = userService.getCurrentUser(request); int result = userService.updateUser(user, loginUser); return ResultUtils.success(result); }
由于项目中多次用户鉴权,所以在 UserService 创建 isAdmin 方法(包括复写)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 boolean isAdmin (HttpServletRequest request) ;boolean isAdmin (User loginUser) ;User getCurrentUser (HttpServletRequest request) ; int updateUser (User user, User loginUser) ;
同时在 UserServiceImpl 里面实现上述方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 @Override public int updateUser (User user, User loginUser) { Long userId = user.getId(); if (userId <= 0 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } if (!isAdmin(loginUser) && userId != loginUser.getId()) { throw new BusinessException (ErrorCode.NO_AUTH); } User oldUser = this .getById(user.getId()); if (oldUser == null ) { throw new BusinessException (ErrorCode.NULL_ERROR); } return this .baseMapper.updateById(user); } public boolean isAdmin (HttpServletRequest request) { User user = (User) request.getSession().getAttribute(UserConstant.USER_LOGIN_STATE); if (user == null || user.getUserRole() != UserConstant.ADMIN_ROLE) { return false ; } return true ; } @Override public boolean isAdmin (User loginUser) { return loginUser.getUserRole() == ADMIN_ROLE; } @Override public User getCurrentUser (HttpServletRequest request) { if (request == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } User user = (User) request.getSession().getAttribute(USER_LOGIN_STATE); if (user == null ) { throw new BusinessException (ErrorCode.NOT_LOGIN); } return user; }
前端用户信息接口 修改 UserEditPage.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 const route = useRoute ();const router = useRouter ();const editUser :any = ref ({ editName : route.query .editName , currentValue : route.query .currentValue , editKey : route.query .editKey , }) const onSubmit = async ( ) => { const res = await MyAxios .post ('user/update' ,{ 'id' : 1 , [editUser.value .editKey ]: editUser.value .currentValue }) console .log ('更新请求' ,res); if (res.data .code === 0 && res.data .data >0 ){ showSuccessToast ('修改成功' ) router.back () }else { showFailToast ('修改失败' ); } };
新建登录页面 UserLoginPage.vue 并在路由里面引入 { path: '/user/login', component: UserLoginPage }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 <template> <van-form @submit ="onSubmit" > <van-cell-group inset > <van-field v-model ="userAccount" name ="userAccount" label ="账号" placeholder ="请输入账号" :rules ="[{ required: true, message: '请填写用户名' }]" /> <van-field v-model ="userPassword" type ="password" name ="userPassword" label ="密码" placeholder ="请输入密码" :rules ="[{ required: true, message: '请填写密码' }]" /> </van-cell-group > <div style ="margin: 16px;" > <van-button round block type ="primary" native-type ="submit" > 提交 </van-button > </div > </van-form > </template> <script setup > import myAxios from "../plugins/myAxios.ts" ;import {Toast } from "vant" ;import {ref} from "vue" ;import {useRouter} from "vue-router" ;const router = useRouter ();const userAccount = ref ('' );const userPassword = ref ('' );const onSubmit = async ( ) => { const res = await myAxios.post ("/user/login" , { userAccount : userAccount.value , userPassword : userPassword.value }); if (res.code == 0 && res.data != null ) { showSuccessToast ("登录成功" ); router.replace ("/" ) } else { showFailToast ("登录失败" ); } }; </script > <style scoped > </style >
http://localhost:3000/#/user/login 运行 ,输入数据,成功登录并跳转的主页
在myAxios.ts中添加以下代码(myAxios之后粘贴)myAxios.defaults.withCredentials = true; // 允许携带 cookie
在后端接口添加允许携带 cookie 的配置
UserController 修改@CrossOrigin(origins = "http://localhost:3000", allowCredentials = "true")
同时后端设置 cookie 的作用域
1 2 3 4 5 6 7 server: port: 8080 servlet: context-path: /api session: cookie: domain: localhost
查看操作台报错,发现是createTime的字符转变导致的,直接删去,后面再处理
前端 UserPage.vue 修改
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 <script setup lang="ts" > import {useRouter} from "vue-router" ;import {onMounted, ref} from "vue" ;import myAxios from "@/plugins/myAxios.ts" ;import {showFailToast, showSuccessToast} from "vant" ;const user = ref ();const router = useRouter ();const toEdit = (editKey :string ,editName :string ,currentValue :string ) =>{ router.push ({ path :'/user/edit' , query :{ editKey, editName, currentValue, } }) } onMounted (async () => { const res = await myAxios.get ('/user/current' ); if (res.data .code === 0 ) { user.value = res.data ; showSuccessToast ('获取用户信息成功' ); } else { showFailToast ('获取用户信息成功' ); } }) </script> <template > <van-cell title ="昵称" is-link to ='/user/edit' :value ="user.username" @click ="toEdit('username','昵称',user.username)" /> <van-cell title ="账号" is-link to ='/user/edit' :value ="user.userAccount" @click ="toEdit('userAccount','账户',user.userAccount)" /> <van-cell title ="头像" is-link to ='/user/edit' @click ="toEdit('avatarUrl','头像',user.avatarUrl)" > <img style ="height: 48px" :src ="user.avatarUrl" /> </van-cell > <van-cell title ="性别" is-link to ='/user/edit' :value ="user.gender" @click ="toEdit('gender','性别', user.gender)" /> <van-cell title ="电话" is-link to ='/user/edit' :value ="user.phone" @click ="toEdit('phone','电话', user.phone)" /> <van-cell title ="邮箱" is-link to ='/user/edit' :value ="user.email" @click ="toEdit('email','邮箱',user.email)" /> <van-cell title ="注册时间" :value ="user.createTime" /> </template > <style scoped > </style >
前端完善修改用户信息 发现每个页面都要获取当前的用户信息,所以我们把这个方法提取出来 建立src/services包,创建user.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import myAxios from "../plugins/myAxios" ;import {getCurrentUserState,setCurrentUserState} from "@/states/user.ts" ;export const getCurrentUser = async ( ) => { const user = getCurrentUserState (); if (user) { return user; } const res = await myAxios.get ("/user/current" ); if (res.data .code == 0 ) { return res.data ; } return null ; }
建立src/states包,创建user.ts(定义两个方法)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import type { UserType } from "@/models/user" ;let currentUser : UserType ;const setCurrentUserState = (user :UserType ) =>{ currentUser =user; } const getCurrentUserState = (): UserType => { return currentUser; } export { setCurrentUserState, getCurrentUserState, }
用户页原来编写的代码需要修改为
1 2 3 4 5 6 7 8 9 10 onMounted (async () => { user.value = await getCurrentUser (); })
用户修改页面,发送修改用户信息请求 UserEditPage.vue完整代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 <script setup lang="ts" > import {useRoute,useRouter}from "vue-router" ;import {ref} from "vue" ;import {showFailToast, showSuccessToast} from "vant" ;import {getCurrentUser} from "@/services/user.ts" ;import myAxios from "@/plugins/myAxios.ts" ;const route = useRoute ();const router = useRouter ();const editUser :any = ref ({ editName : route.query .editName , currentValue : route.query .currentValue , editKey : route.query .editKey , }) const onSubmit = async ( ) => { const currentUser = await getCurrentUser (); console .log ("-------UserEditPage" , currentUser); const res = await myAxios.post ("/user/update" , { "id" : currentUser.id , [editUser.value .editKey ]: editUser.value .currentValue }) console .log ("修改用户信息" , res); if (res.data .code === 0 && res.data > 0 ) { showSuccessToast ('修改成功' ) router.back () }else { showFailToast ('修改失败' ); } }; </script> <template > <van-form @submit ="onSubmit" > <van-field v-model ="editUser.currentValue" :name ="editUser.editKey" :label ="editUser.editName" :placeholder ="'请输入 ${ editUser.editName }'" /> <div style ="margin: 16px;" > <van-button round block type ="primary" native-type ="submit" > 提交 </van-button > </div > </van-form > </template > <style scoped > </style >
注意踩坑处: 语法糖是支持写在外面的,但是这里面运用就不显示页面
关于调用缓存去获取当前用户信息的问题 src/services/user.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 export const getCurrentUser = async ( ) => { const res = await myAxios.get ("/user/current" ); if (res.data .code == 0 ) { return res.data ; } return null ; }
这边就很玄学,鱼皮使用了缓存,结果获取不到最新的用户信息,而我这边可以。。。 建议:在小系统(用户少)中尽量不要使用缓存,可以使用路由守卫,从远程获取 踩坑处:如果前端在用户页的信息中并未设定editKey的话,就无法修改信息(更新内容为空),这需要我们在后端去控制、筛选前端所传入的参数后端会报错
开发主页 编写一次性任务 for 循环插入数据的问题: 1建立和释放数据库链接(批量查询解决) 2for 循环是绝对线性的(并发)
并发要注意执行的先后顺序无所谓,不要用到非并发类的集合private ExecutorService executorService = new ThreadPoolExecutor(16, 1000, 10000, TimeUnit.MINUTES, new ArrayBlockingQueue<>(10000)); // CPU 密集型:分配的核心线程数 = CPU - 1 // IO 密集型:分配的核心线程数可以大于 CPU 核数 数据库慢?预先把数据查出来,放到一个更快读取的地方,不用再查数据库了。(缓存) 预加载缓存,定时更新缓存。(定时任务) 多个机器都要执行任务么?(分布式锁:控制同一时间只有一台机器去执行定时任务,其他机器不用重复执行了)
主页
启动前后端项目 在 SearchResultPage.vue 中 修改如下代码:return response?.data?.data;
编写主页(直接list列表) UserController.java
1 2 3 4 5 6 7 8 9 10 11 12 @GetMapping("/recommend") public BaseResponse<List<User>> recommendUsers (HttpServletRequest request) { QueryWrapper<User> queryWrapper = new QueryWrapper <>(); List<User> userList = userService.list(queryWrapper); List<User> list = userList.stream().map(user -> userService.getSafetyUser(user)).collect(Collectors.toList()); return ResultUtils.success(list); }
pages/Index.vue 复制搜索结果的代码并修改
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 <template> <van-card v-for ="user in userList" :desc ="user.profile" :title ="`${user.username} (${user.planetCode})`" :thumb ="user.avatarUrl" > <template #tags > <van-tag plain type ="danger" v-for ="tag in tags" style ="margin-right: 8px; margin-top: 8px" > {{ tag }} </van-tag > </template > <template #footer > <van-button size ="mini" > 联系我</van-button > </template > </van-card > <van-empty v-if ="!userList || userList.length < 1" image ="search" description ="数据为空" /> </template> <script setup > import {onMounted, ref} from "vue" ; import {useRoute} from "vue-router" ; import {showFailToast, showSuccessToast} from "vant/lib/vant.es" ; import myAxios from "../plugins/myAxios.ts" ; import qs from 'qs' const route = useRoute (); const {tags} = route.query ; const userList = ref ([]); onMounted (async () => { const userListData = await myAxios.get ('/user/recommend' , { withCredentials : false , params : {}, }) .then (function (response ) { console .log ('/user/recommend succeed' , response); showSuccessToast ('请求成功' ); return response?.data ; }) .catch (function (error ) { console .log ('/user/recommend error' , error); showFailToast ('请求失败' ) }); if (userListData) { userListData.forEach (user => { if (user.tags ) { user.tags = JSON .parse (user.tags ); } }) userList.value = userListData; } }) </script > <style scoped > </style >
发现几个页面都用到了列表组件,所以提取出公共组件 添加 components/UserCardList.vue 并复制主页里面的模板修改为如下 然后在Index、SearchResultPage引入UserCardList<user-card-list :user-list="userList"/>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 <template> <van-card v-for ="user in userList" :desc ="user.profile" :title ="`${user.username} (${user.planetCode})`" :thumb ="user.avatarUrl" > <template #tags > <van-tag plain type ="danger" v-for ="tag in user.tags" style ="margin-right: 8px; margin-top: 8px" > {{ tag }} </van-tag > </template > <template #footer > <van-button size ="mini" > 联系我</van-button > </template > </van-card > </template> <script setup lang ="ts" > import {UserType } from "../models/user" ;interface UserCardListProps { userList : UserType []; } const props= withDefaults (defineProps<UserCardListProps >(),{ userList : [] as UserType [] }); </script > <style scoped > .van-tag--danger .van-tag--plain { color : #002fff ; } </style >
模拟1000万用户,再次进行查询 我们需要插入数据:
用可视化界面:适合一次性导入、数据量可控
写程序:for 循环,建议分批,这里演示了两种插入数据的方法 首先创建测试方法文件InsertUsersTest,编写批量查询解决 并发执行,这里的线程可自定义或者用idea默认的,两种方法的区别是, 自定义可以跑满线程,而默认的只能跑CPU核数-1, 代码区别:就是在异步执行处加上自定义的线程名
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 @SpringBootTest public class InsertUsersTest { @Resource private UserService userService; private ExecutorService executorService = new ThreadPoolExecutor (16 , 1000 , 10000 , TimeUnit.MINUTES, new ArrayBlockingQueue <>(10000 )); @Test public void doInsertUser () { StopWatch stopWatch = new StopWatch (); stopWatch.start(); final int INSERT_NUM = 1000 ; List<User> userList = new ArrayList <>(); for (int i = 0 ; i < INSERT_NUM; i++) { User user = new User (); user.setUsername("假数据" ); user.setUserAccount("fakeaccount" ); user.setAvatarUrl("https://img0.baidu.com/it/u=3514514443,3153875602&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500" ); user.setGender(0 ); user.setUserPassword("231313123" ); user.setPhone("1231312" ); user.setEmail("12331234@qq.com" ); user.setUserStatus(0 ); user.setUserRole(0 ); user.setPlanetCode("213123" ); user.setTags("[]" ); userList.add(user); } userService.saveBatch(userList, 100 ); stopWatch.stop(); System.out.println(stopWatch.getLastTaskTimeMillis()); } @Test public void doConcurrencyInsertUser () { StopWatch stopWatch = new StopWatch (); stopWatch.start(); final int INSERT_NUM = 100000 ; int j = 0 ; int batchSize = 5000 ; List<CompletableFuture<Void>> futureList = new ArrayList <>(); for (int i = 0 ; i < INSERT_NUM / batchSize; i++) { List<User> userList = new ArrayList <>(); while (true ) { j++; User user = new User (); user.setUsername("假shier" ); user.setUserAccount("shier" ); user.setAvatarUrl("https://c-ssl.dtstatic.com/uploads/blog/202101/11/20210111220519_7da89.thumb.1000_0.jpeg" ); user.setProfile("fat cat" ); user.setGender(1 ); user.setUserPassword("12345678" ); user.setPhone("123456789108" ); user.setEmail("22288999@qq.com" ); user.setUserStatus(0 ); user.setUserRole(0 ); user.setPlanetCode("33322" ); user.setTags("[]" ); userList.add(user); if (j % batchSize == 0 ) { break ; } } CompletableFuture<Void> future = CompletableFuture.runAsync(() -> { System.out.println("ThreadName:" + Thread.currentThread().getName()); userService.saveBatch(userList, batchSize); }, executorService); futureList.add(future); } CompletableFuture.allOf(futureList.toArray(new CompletableFuture []{})).join(); stopWatch.stop(); System.out.println(stopWatch.getLastTaskTimeMillis()); } }
若使用默认线程池,删去异步执行下的四行executorService
现在启动前后端,查看主页,发现搜查不出 这是因为数据太多需要分页,修改后端接口方法
1 2 3 4 5 6 7 8 9 10 11 @GetMapping("/recommend") public BaseResponse<Page<User>> recommendUsers (long pageSize,long pageNum, HttpServletRequest request) { QueryWrapper<User> queryWrapper = new QueryWrapper <>(); Page<User> userList = userService.page(new Page <>(pageNum, pageSize), queryWrapper); return ResultUtils.success(userList); }
同时还要引入mybatis的分页插件配置,直接复制文档到 config 目录 主要不要忘了把扫包的路径改为自己的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Configuration @MapperScan("com.yupi.usercenter.mapper") public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor () { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor (); interceptor.addInnerInterceptor(new PaginationInnerInterceptor (DbType.H2)); return interceptor; } }
修改前端主页
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 onMounted (async () => { const userListData = await myAxios.get ('/user/recommend' , { withCredentials : false , params : { pageSize :8 , pageNum :1 , }, }) .then (function (response ) { console .log ('/user/recommend succeed' , response); showSuccessToast ('请求成功' ); return response?.data ?.records ; })
java自带的redistemplate只能查询字符串类型,不全面 需要自定义序列化,在config包里创建如下配置类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 package com.shier.usercenter.config;@Configuration public class RedisTemplateConfig { @Bean public RedisTemplate<String, Object> redisTemplate (RedisConnectionFactory connectionFactory) { RedisTemplate<String, Object> redisTemplate = new RedisTemplate <>(); redisTemplate.setConnectionFactory(connectionFactory); redisTemplate.setKeySerializer(RedisSerializer.string()); GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer (); redisTemplate.setValueSerializer(jsonRedisSerializer); return redisTemplate; } }
在测试包里创建测试类RedisTest,测试代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @SpringBootTest public class RedisTest { @Resource private RedisTemplate redisTemplate; @Test void test () { ValueOperations valueOperations = redisTemplate.opsForValue(); valueOperations.set("yupiString" ,"dog" ); valueOperations.set("yupiInt" ,1 ); valueOperations.set("yupiDouble" ,2.0 ); User user = new User (); user.setId(1L ); user.setUsername("yupi" ); valueOperations.set("yupiUser" ,user); Object yupi = valueOperations.get("yupiString" ); Assertions.assertTrue("dog" .equals((String)yupi)); yupi = valueOperations.get("yupiInt" ); Assertions.assertTrue(1 ==((Integer)yupi)); yupi = valueOperations.get("yupiDouble" ); Assertions.assertTrue(2.0 ==((Double)yupi)); System.out.println(valueOperations.get("yupiUser" )); } }
redis中可查询到缓存的数据
根据用户开发个性推荐页 开发
修改推荐页面的接口,整理如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @GetMapping("/recommend") public BaseResponse<Page<User>> recommendUsers (long pageSize,long pageNum, HttpServletRequest request) { User loginUser = userService.getLoginUser(request); String redisKey = String.format("partner:user:recommend:%s" , loginUser.getId()); ValueOperations<String,Object> valueOperations = redisTemplate.opsForValue(); Page<User> userPage= (Page<User>)valueOperations.get(redisKey); if (userPage!=null ){ return ResultUtils.success(userPage); } QueryWrapper<User> queryWrapper = new QueryWrapper <>(); userPage = userService.page(new Page <>(pageNum, pageSize), queryWrapper); try { valueOperations.set(redisKey,userPage,30000 , TimeUnit.MILLISECONDS); }catch (Exception e){ log.error("redis set key error" ,e); } return ResultUtils.success(userPage); }
刷新页面,有了缓存之后查询速度变快,只有20多毫秒 如果页面没有显示用户列表,则修改前端主页 Index.vue 文件onMounted方法withCredentials: true
定时任务
还存在一个问题:第一个用户访问还是很慢,要实现缓存预热这里我们使用了定时的方法 新建job/PreCacheJob.java 实现定时预热缓存
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 @Slf4j @Component public class PreCacheJob { @Resource private UserService userService; @Resource private RedisTemplate<String,Object> redisTemplate; private List<Long> mainUserList = Arrays.asList(1l ); @Scheduled(cron = "0 24 18 * * *") public void doCacheRecommendUser () { for (Long userId: mainUserList){ QueryWrapper<User> queryWrapper = new QueryWrapper <>(); Page<User> userPage = userService.page(new Page <>(1 , 20 ), queryWrapper); String redisKey = String.format("yupao:user:recommend:%s" , userId); ValueOperations<String,Object> valueOperations = redisTemplate.opsForValue(); try { valueOperations.set(redisKey,userPage,30000 , TimeUnit.MILLISECONDS); }catch (Exception e){ log.error("redis set key error" ,e); } } } }
注意:要在UserCenterApplication添加 @EnableScheduling 注解,允许定时任务
控制定时任务的执行
浪费资源,想象 10000 台服务器同时 “打鸣”
脏数据,比如重复插入
要控制定时任务在同一时间只有 1 个服务器能执行。 怎么做?
分离定时任务程序和主程序,只在 1 个服务器运行定时任务。成本太大
写死配置,每个服务器都执行定时任务,但是只有 ip 符合配置的服务器才真实执行业务逻辑,其他的直接返回。 成本最低;但是我们的 IP 可能是不固定的,把 IP 写的太死了
动态配置,配置是可以轻松的、很方便地更新的(代码无需重启 ),但是只有 ip 符合配置的服务器才真实执行业务逻辑。 问题:服务器多了、IP 不可控还是很麻烦,还是要人工修改 - 数据库 - Redis - 配置中心(Nacos、Apollo、Spring Cloud Config)
分布式锁,只有抢到锁的服务器才能执行业务逻辑。坏处:增加成本;好处:不用手动配置,多少个服务器都一样。
单机就会存在单点故障。 锁 有限资源的情况下,控制同一时间(段)只有某些线程(用户 / 服务器)能访问到资源。 Java 实现锁:synchronized 关键字、并发包的类 问题:只对单个 JVM 有效
分布式锁 为啥需要分布式锁?
有限资源的情况下,控制同一时间(段)只有某些线程(用户 / 服务器)能访问到资源。
单个锁只对单个 JVM 有效
分布式锁实现的关键 抢锁机制 怎么保证同一时间只有 1 个服务器能抢到锁?核心思想 就是:先来的人先把数据改成自己的标识(服务器 ip),后来的人发现标识已存在,就抢锁失败,继续等待。 等先来的人执行方法结束,把标识清空,其他的人继续抢锁。 MySQL 数据库:select for update 行级锁(最简单)(乐观锁) ✔ Redis 实现:内存数据库,读写速度快 。支持 setnx 、lua 脚本,比较方便我们实现分布式锁。 setnx:set if not exists 如果不存在,则设置;只有设置成功才会返回 true,否则返回 false
注意事项
用完锁要释放(腾地方)√
锁一定要加过期时间 √
如果方法执行时间过长,锁提前过期了?问题: 1. 连锁效应:释放掉别人的锁 2. 这样还是会存在多个方法同时执行的情况
解决方案:续期
1 2 3 4 5 6 7 8 boolean end = false ;new Thread (() -> { if (!end)}{ 续期 }) end = true ;
释放锁的时候,有可能先判断出是自己的锁,但这时锁过期了,最后还是释放了别人的锁 Redis 如果是集群(而不是只有一个 Redis),如果分布式锁的数据不同步怎么办?
1 2 3 4 5 if (get lock == A) { del lock }
Redis + lua 脚本实现Redisson–红锁(Redlock)–使用/原理
Redisson 实现分布式锁 Java 客户端,数据网格 实现了很多 Java 里支持的接口和数据结构 Redisson 是一个 java 操作 Redis 的客户端,提供了大量的分布式数据集来简化对 Redis 的操作和使用,可以让开发者像使用本地集合一样使用 Redis,完全感知不到 Redis 的存在。
2 种引入方式
spring boot starter 引入 (不推荐,版本迭代太快,容易冲突)
直接引入 示例代码
1 2 3 4 5 6 7 8 9 10 11 12 List<String> list = new ArrayList <>(); list.add("yupi" ); System.out.println("list:" + list.get(0 )); list.remove(0 ); RList<String> rList = redissonClient.getList("test-list" ); rList.add("yupi" ); System.out.println("rlist:" + rList.get(0 )); rList.remove(0 );
定时任务 + 锁 1waitTime 设置为 0,只抢一次,抢不到就放弃 2注意释放锁要写在 finally 中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 void testWatchDog () { RLock lock = redissonClient.getLock("yupao:precachejob:docache:lock" ); try { if (lock.tryLock(0 , -1 , TimeUnit.MILLISECONDS)) { doSomeThings(); System.out.println("getLock: " + Thread.currentThread().getId()); } } catch (InterruptedException e) { System.out.println(e.getMessage()); } finally { if (lock.isHeldByCurrentThread()) { System.out.println("unLock: " + Thread.currentThread().getId()); lock.unlock(); } } }
看门狗机制 redisson 中提供的续期机制 开一个监听线程,如果方法还没执行完,就帮你重置 redis 锁的过期时间。 原理:
监听当前线程,默认过期时间是 30 秒,每 10 秒续期一次(补到 30 秒)
如果线程挂掉(注意 debug 模式也会被它当成服务器宕机),则不会续期Redisson 分布式锁的watch dog自动续期机制 Zookeeper 实现(不推荐)
redission实现分布式锁
引入依赖
1 2 3 4 5 <dependency > <groupId > org.redisson</groupId > <artifactId > redisson</artifactId > <version > 3.18.0</version > </dependency >
注意踩坑处:我这边开了梯子,下载依赖,会有io.netty:netty-codec-dns:jar:4.1.74.Final依赖安装失败, 解决办法:删除maven仓库里redission和netty-codec-dns。然后关闭梯子重新安装依赖
写redisson配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 package com.shier.usercenter.config;@Configuration @ConfigurationProperties(prefix = "spring.redis") @Data public class RedissonConfig { private String host; private String port; @Bean public RedissonClient redissonClient () { Config config = new Config (); String redisAddress = String.format("redis://%s:%s" , host, port); config.useSingleServer().setAddress(redisAddress).setDatabase(3 ); RedissonClient redisson = Redisson.create(config); return redisson; } }
编写测试类来使用 Redisson 实现分布式锁
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 package com.shier.usercenter.service;@SpringBootTest public class RedissonTest { @Resource private RedissonClient redissonClient; @Test void test () { List<String> list = new ArrayList <>(); list.add("shier" ); System.out.println("list:" + list.get(0 )); RList<String> rList = redissonClient.getList("test-list" ); rList.add("shier" ); System.out.println("rlist:" + rList.get(0 )); Map<String, Integer> map = new HashMap <>(); map.put("shier" , 10 ); map.get("shier" ); RMap<Object, Object> map1 = redissonClient.getMap("test-map" ); } }
定时任务 + 锁
修改定时任务 waitTime 设置为 0,只抢一次,抢不到就放弃
注意释放锁要写在 finally 中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 @Slf4j @Component public class PreCacheJob { @Resource private UserService userService; @Resource private RedissonClient redissonClient; @Resource private RedisTemplate<String,Object> redisTemplate; private List<Long> mainUserList = Arrays.asList(1l ); @Scheduled(cron = "0 24 18 * * *") public void doCacheRecommendUser () { RLock lock = redissonClient.getLock("yupao:precachejob:docache:lock" ); try { if (lock.tryLock(0 ,30000L ,TimeUnit.MILLISECONDS)){ System.out.println("getLock: " +Thread.currentThread().getId()); for (Long userId: mainUserList){ QueryWrapper<User> queryWrapper = new QueryWrapper <>(); Page<User> userPage = userService.page(new Page <>(1 , 20 ), queryWrapper); String redisKey = String.format("yupao:user:recommend:%s" , userId); ValueOperations<String,Object> valueOperations = redisTemplate.opsForValue(); try { valueOperations.set(redisKey,userPage,30000 , TimeUnit.MILLISECONDS); }catch (Exception e){ log.error("redis set key error" ,e); } } } }catch (InterruptedException e){ log.error("doCacheRecommendUser error " ,e); }finally { if (lock.isHeldByCurrentThread()){ System.out.println("unlock: " +Thread.currentThread().getId()); lock.unlock(); } } } }
打包项目,在终端打开两个,主程序启动(由于定时任务太过于麻烦,所以我们提取出来写一个测试) java -jar .\yupao-backend-0.0.1-SNAPSHOT.jar –server.port=8081
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Test void testWatchDog () { RLock lock = redissonClient.getLock("shier:precachejob:docache:lock" ); try { if (lock.tryLock(0 ,-1 , TimeUnit.MILLISECONDS)){ Thread.sleep(300000 ); System.out.println("getLock: " +Thread.currentThread().getId()); } }catch (InterruptedException e){ System.out.println(e.getMessage()); }finally { if (lock.isHeldByCurrentThread()){ System.out.println("unlock: " +Thread.currentThread().getId()); lock.unlock(); } } }
注意锁的存在时间要设置为-1(开启开门狗),默认锁的过期时间是30秒,通过sleep实现 运行,通过quickredis观察,可以发现 每 10 秒续期一次(补到 30 秒) 踩坑处:不要用debug启动,会被认为是宕机
组队功能 需求分析
用户可以创建队伍(设置人数、队伍名、描述、超时时间),最多5个
队长、剩余的人数
公开/ private/ 加密
展示队伍列表,根据名称搜索队伍 P0,信息流中不展示已过期的队伍
修改队伍信息 P0 ~ P1
用户可以加入队伍(其他人、未满、未过期),允许加入多个队伍,但是要有个上限 P0
是否需要队长同意?筛选审批?
用户可以退出队伍(队长退出,权限转移元老) P1
队长可以解散队伍 P0
分享队伍–>邀请用户加入队伍 P1
业务流程:
生成分享链接(分享二维码)
用户访问链接,可以点击加入
队伍人满后发送消息通知 P1
系统(接口)设计
创建队伍
用户可以创建队伍(设置人数、队伍名、描述、超时时间),最多5个
队长、剩余的人数
公开/ private/ 加密
信息流中不展示已过期的队伍 请求参数是否为空 是否登录,未登录不允许创建
校验信息 a队伍人数 > 1 且 <= 20 b队伍标题 <= 20 c描述 <= 512 d status 是否公开(int)不传默认为 0(公开) e如果 status 是加密状态,一定要有密码,且密码 <= 32 f超时时间 > 当前时间 g校验用户最多创建 5 个队伍 插入队伍信息到队伍表 插入用户 => 队伍关系到关系表
查询队伍列表 分页展示队伍列表,根据名称、最大人数等搜索队伍 P0,信息流中不展示已过期的队伍 1从请求参数中取出队伍名称等查询条件,如果存在则作为查询条件 2不展示已过期的队伍(根据过期时间筛选) 3可以通过某个关键词同时对名称和描述查询 4只有管理员才能查看加密还有非公开的房间 5关联查询已加入队伍的用户信息 6关联查询已加入队伍的用户信息(可能会很耗费性能,建议大家用自己写 SQL 的方式实现)
修改队伍信息 1判断请求参数是否为空 2查询队伍是否存在 3只有管理员或者队伍的创建者可以修改 4如果用户传入的新值和老值一致,就不用 update 了(可自行实现,降低数据库使用次数) 5如果队伍状态改为加密,必须要有密码 6更新成功
用户可以加入队伍 其他人、未满、未过期,允许加入多个队伍,但是要有个上限 P0 1用户最多加入 5 个队伍 2队伍必须存在,只能加入未满、未过期的队伍 3不能加入自己的队伍,不能重复加入已加入的队伍(幂等性) 4禁止加入私有的队伍 5如果加入的队伍是加密的,必须密码匹配才可以 6新增队伍 - 用户关联信息 注意,一定要加上事务注解!!!!
用户可以退出队伍 请求参数:队伍 id 1校验请求参数 2校验队伍是否存在 3校验我是否已加入队伍 4如果队伍 a只剩一人,队伍解散 b还有其他人 ⅰ如果是队长退出队伍,权限转移给第二早加入的用户 —— 先来后到只用取 id 最小的 2 条数据 ⅱ非队长,自己退出队伍
队长可以解散队伍 请求参数:队伍 id 业务流程: 1校验请求参数 2校验队伍是否存在 3校验你是不是队伍的队长 4移除所有加入队伍的关联信息 5删除队伍
获取当前用户已加入的队伍
获取当前用户创建的队伍 复用 listTeam 方法,只新增查询条件,不做修改(开闭原则) 事务注解 @Transactional(rollbackFor = Exception.class) 要么数据操作都成功,要么都失败
数据库表设计 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 create table team( id bigint auto_increment comment 'id' primary key , name varchar (256 ) not null comment '队伍名称' , description varchar (1024 ) null comment '描述' , maxNum int default 1 not null comment '最大人数' , expireTime datetime null comment '过期时间' , userId bigint comment '用户id(队长 id)' ,, status int default 0 not null comment '0 - 公开,1 - 私有,2 - 加密' , password varchar (512 ) null comment '密码' , createTime datetime default CURRENT_TIMESTAMP null comment '创建时间' , updateTime datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP , isDelete tinyint default 0 not null comment '是否删除' ) comment '队伍' ; create table user_team( id bigint auto_increment comment 'id' primary key , userId bigint comment '用户id' , teamId bigint comment '队伍id' , joinTime datetime null comment '加入时间' , createTime datetime default CURRENT_TIMESTAMP null comment '创建时间' , updateTime datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP , isDelete tinyint default 0 not null comment '是否删除' ) comment '用户队伍关系' ;
使用MybatisX-Generator生成代码 别忘了把mapper.xml里的路径改成自己对应的
如果直接将生成的文件拉到对应的文件,就会自动修改mapper.xml的路径 PS:别忘了在team和user_team类中的is_delete字段添加@TableLogic注解,实现逻辑删除
编写接口(TeamController)
复制UserController并重命名为TeamController,编写具体的接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 @PostMapping("/add") public BaseResponse<Long> addTeam (@RequestBody Team team) { if (team == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } boolean save = teamService.save(team); if (!save) { throw new BusinessException (ErrorCode.SYSTEM_ERROR, "插入失败" ); } return ResultUtils.success(team.getId()); } @PostMapping("/delete") public BaseResponse<Boolean> deleteTeam (@RequestBody long id) { if (id <= 0 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } boolean result = teamService.removeById(id); if (!result) { throw new BusinessException (ErrorCode.SYSTEM_ERROR, "删除失败" ); } return ResultUtils.success(true ); } @PostMapping("/update") public BaseResponse<Boolean> updateTeam (@RequestBody Team team) { if (team == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } boolean result = teamService.updateById(team); if (!result) { throw new BusinessException (ErrorCode.SYSTEM_ERROR, "更新失败" ); } return ResultUtils.success(true ); } @GetMapping("/get") public BaseResponse<Team> getTeamById (@RequestBody long id) { if (id <= 0 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } Team team = teamService.getById(id); if (team == null ) { throw new BusinessException (ErrorCode.NULL_ERROR); } return ResultUtils.success(team); } @GetMapping("/list") public BaseResponse<List<Team>> listTeams (TeamQuery teamQuery) { if (teamQuery == null ){ throw new BusinessException (ErrorCode.PARAMS_ERROR); } Team team = new Team (); BeanUtils.copyProperties(team,teamQuery); QueryWrapper<Team> queryWrapper = new QueryWrapper <>(team); List<Team> teamList = teamService.list(queryWrapper); return ResultUtils.success(teamList); } @GetMapping("/list/page") public BaseResponse<Page<Team>> listTeamsByPage (TeamQuery teamQuery) { if (teamQuery == null ){ throw new BusinessException (ErrorCode.PARAMS_ERROR); } Team team = new Team (); BeanUtils.copyProperties(teamQuery,team); Page<Team> page = new Page <>(teamQuery.getPageNum(),teamQuery.getPageSize()); QueryWrapper<Team> queryWrapper = new QueryWrapper <>(team); Page<Team> resultPage = teamService.page(page, queryWrapper); return ResultUtils.success(resultPage); }
踩坑处:注意Page导入的是mybatisplus,而不是springboot自带的
新建model/dto/TeamQuery
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 @EqualsAndHashCode(callSuper = true) @Data public class TeamQuery extends PageRequest { private Long id; private List<Long> idList; private String searchText; private String name; private String description; private Integer maxNum; private Long userId; private Integer status; }
在common包下新建分页请求参数包装类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Data public class PageRequest implements Serializable { private static final long serialVersionUID = -4162304142710323660L ; protected int pageSize; protected int pageNum; }
细化接口(根据具体的需求) 这边我们会运用到队伍的状态,即公开,私有等 所以提前写一个队伍状态枚举类 model/enums/TeamStatusEnum.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 public enum TeamStatusEnum { PUBLIC(0 ,"公开" ), PRIVATE(1 ,"私有" ), SECRET(2 ,"加密" ); private int value; private String text; public static TeamStatusEnum getEnumByValue (Integer value) { if (value == null ){ return null ; } TeamStatusEnum[] values = TeamStatusEnum.values(); for (TeamStatusEnum teamStatusEnum: values){ if (teamStatusEnum.getValue()==value){ return teamStatusEnum; } } return null ; } TeamStatusEnum(int value, String text) { this .value = value; this .text = text; } public int getValue () { return value; } public void setValue (int value) { this .value = value; } public String getText () { return text; } public void setText (String text) { this .text = text; } }
正式开始细化接口 在TeamService里写入long addTeam(Team team, User loginUser); 并在TeamServiceImpl实现这个方法,代码整理如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 @Service public class TeamServiceImpl extends ServiceImpl <TeamMapper, Team> implements TeamService { @Resource private UserTeamService userTeamService; @Override @Transactional(rollbackFor = Exception.class) public long addTeam (Team team, User loginUser) { if (team == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } if (loginUser == null ) { throw new BusinessException (ErrorCode.NO_AUTH); } final long userId = loginUser.getId(); int maxNum = Optional.ofNullable(team.getMaxNum()).orElse(0 ); if (maxNum < 1 || maxNum > 20 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "队伍人数不满足要求" ); } String name = team.getName(); if (StringUtils.isBlank(name) || name.length() > 20 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "队伍标题不满足要求" ); } String description = team.getDescription(); if (StringUtils.isNotBlank(description) && description.length() > 512 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "队伍描述过长" ); } int status = Optional.ofNullable(team.getStatus()).orElse(0 ); TeamStatusEnum statusEnum = TeamStatusEnum.getEnumByValue(status); if (statusEnum == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "队伍状态不满足要求" ); } String password = team.getPassword(); if (TeamStatusEnum.SECRET.equals(statusEnum)) { if (StringUtils.isBlank(password) || password.length() > 32 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "密码设置不正确" ); } } Date expireTime = team.getExpireTime(); if (new Date ().after(expireTime)) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "超出时间 > 当前时间" ); } QueryWrapper<Team> queryWrapper = new QueryWrapper <>(); queryWrapper.eq("userId" , userId); long hasTeamNum = this .count(queryWrapper); if (hasTeamNum >= 5 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "用户最多创建5个队伍" ); } team.setId(null ); team.setUserId(userId); boolean result = this .save(team); Long teamId = team.getId(); if (!result || team == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "创建队伍失败" ); } UserTeam userTeam = new UserTeam (); userTeam.setTeamId(userId); userTeam.setTeamId(teamId); userTeam.setJoinTime(new Date ()); result = userTeamService.save(userTeam); if (!result) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "创建队伍失败" ); } return teamId; }
优化完成控制层 这里我们因为完善了业务层,所以在controller层可以简便下代码 我们需要新建一个队伍添加请求封装类(便于前端知道该输入哪些参数) 新的请求封装类位于model/request/TeamAddRequest.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 @Data public class TeamAddRequest { private static final long serialVersionUID = -4162304142710323660L ; private String name; private String description; private Integer maxNum; private Date expireTime; private Long userId; private Integer status; private String password; }
修改的addTeam接口如下:
1 2 3 4 5 6 7 8 9 10 11 @PostMapping("/add") public BaseResponse<Long> addTeam (@RequestBody TeamAddRequest teamAddRequest, HttpServletRequest request) { if (teamAddRequest == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } User loginUser = userService.getLoginUser(request); Team team = new Team (); BeanUtils.copyProperties(teamAddRequest,team); long teamId = teamService.addTeam(team, loginUser); return ResultUtils.success(teamId); }
测试 启动项目,好像刚刚的请求参数封装类没起作用(这里跟鱼皮的一样),只能自己删除再输入 成功的插入 ps:这里过期时间的获取可从控制台输入一下代码来实现,单单的输入年月日会导致数据库里的时间增加8小时(应该是时区的问题) 多次发送添加请求,当插入5次之后,再插入会报错 测试事务是否起作用 注意要在方法上添加如下注解@Transactional(rollbackFor = Exception.class) 首先数据库的数据删至4条,别忘了把对应的用户表关系也删除,保持一致 修改TeamServiceImpl,目的是骗过编译器,直接创建队伍失败 稍微修改下参数,发送,确实报用户关系失败 数据库里也没用增加数据,证实了事务起作用,最后别忘了把刚刚增加的代码所删除(骗过编译器)
完善查询,更新,加入队伍接口
为了保护数据不被暴露,所以新建封装类 新建model/vo/TeamUserVO(队伍和用户信息封装类),UserVO类(用户封装类) 这两个类是返回给前端看
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 @Data public class TeamUserVO implements Serializable { private static final long serialVersionUID = 163478861968488713L ; private Long id; private String name; private String description; private Integer maxNum; private Date expireTime; private Long userId; private Integer status; private Date createTime; private Date updateTime; private UserVO createUser; private Integer hasJoinNum; private boolean hasJoin = false ; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 @Data public class UserVO implements Serializable { private long id; private String username; private String userAccount; private String avatarUrl; private Integer gender; private String phone; private String email; private String tags; private Integer userStatus; private Date createTime; private Date updateTime; private Integer userRole; private static final long serialVersionUID = 1L ; }
在TeamService里编写查询队伍方法并在TeamServiceImpl里实现 首先在TeamService里面实现listTeams方法并实现 编写业务层,只有管理员才能查看加密还有非公开的房间,所以需要从请求中获得是否为管理员
1 2 3 4 5 6 7 8 List<TeamUserVO> listTeams (TeamQuery teamQuery, boolean isAdmin) ;
查询队伍imlp整理如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 @Override public List<TeamUserVO> listTeams (TeamQuery teamQuery, boolean isAdmin) { QueryWrapper<Team> queryWrapper = new QueryWrapper <>(); if (teamQuery != null ) { Long id = teamQuery.getId(); if (id != null && id > 0 ) { queryWrapper.eq("id" , id); } String searchText = teamQuery.getSearchText(); if (StringUtils.isNotBlank(searchText)) { queryWrapper.and(qw -> qw.like("name" , searchText).or().like("expireTime" , searchText)); } String name = teamQuery.getName(); if (StringUtils.isNotBlank(name)) { queryWrapper.like("name" , name); } String description = teamQuery.getDescription(); if (StringUtils.isNotBlank(description)) { queryWrapper.like("description" , description); } Integer maxNum = teamQuery.getMaxNum(); if (maxNum != null && maxNum > 0 ) { queryWrapper.eq("maxMum" , maxNum); } Long userId = teamQuery.getUserId(); if (userId != null && userId > 0 ) { queryWrapper.eq("userId" , userId); } Integer status = teamQuery.getStatus(); TeamStatusEnum statusEnum = TeamStatusEnum.getEnumByValue(status); if (statusEnum == null ) { statusEnum = TeamStatusEnum.PUBLIC; } if (!isAdmin && !statusEnum.equals(TeamStatusEnum.PUBLIC)) { throw new BusinessException (ErrorCode.NO_AUTH); } queryWrapper.eq("status" , statusEnum.getValue()); } queryWrapper.and(qw -> qw.gt("expireTime" , new Date ()).or().isNull("expireTime" )); List<Team> teamList = this .list(queryWrapper); if (CollectionUtils.isEmpty(teamList)) { return new ArrayList <>(); } List<TeamUserVO> teamUserVOList = new ArrayList <>(); for (Team team : teamList) { Long userId = team.getUserId(); if (userId == null ) { continue ; } User user = userService.getById(userId); TeamUserVO teamUserVO = new TeamUserVO (); BeanUtils.copyProperties(team, teamUserVO); if (user!=null ){ UserVO userVO = new UserVO (); BeanUtils.copyProperties(user, userVO); teamUserVO.setCreateUser(userVO); } teamUserVOList.add(teamUserVO); } return teamUserVOList; }
请求接口修改整理为:
1 2 3 4 5 6 7 8 9 @GetMapping("/list") public BaseResponse<List<TeamUserVO>> listTeams (TeamQuery teamQuery,HttpServletRequest request) { if (teamQuery == null ){ throw new BusinessException (ErrorCode.PARAMS_ERROR); } boolean isAdmin = userService.isAdmin(request); List<TeamUserVO> teamList = teamService.listTeams(teamQuery,isAdmin); return ResultUtils.success(teamList); }
在TeamService里编写修改队伍信息方法并在TeamServiceImpl里实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 @Data public class TeamUpdateRequest implements Serializable { private static final long serialVersionUID = -6043915331807008592L ; private Long id; private String name; private String description; private Date expireTime; private Integer status; private String password; }
在TeamService里面实现updateTeam方法并实现
1 2 3 4 5 6 7 8 9 boolean updateTeam (TeamUpdateRequest teamUpdateRequest, User loginUser) ;
修改接口更改为
1 2 3 4 5 6 7 8 9 10 11 12 @PostMapping("/update") public BaseResponse<Boolean> updateTeam (@RequestBody TeamUpdateRequest teamUpdateRequest,HttpServletRequest request) { if (teamUpdateRequest == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } User loginUser = userService.getLoginUser(request); boolean result = teamService.updateTeam(teamUpdateRequest,loginUser); if (!result) { throw new BusinessException (ErrorCode.SYSTEM_ERROR, "更新失败" ); } return ResultUtils.success(true ); }
最后编写实现类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Override public boolean updateTeam (TeamUpdateRequest teamUpdateRequest, User loginUser) { if (teamUpdateRequest == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } Long id = teamUpdateRequest.getId(); if (id == null || id <= 0 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } Team oldTeam = this .getById(id); if (oldTeam==null ){ throw new BusinessException (ErrorCode.NULL_ERROR,"队伍不存在" ); } if (oldTeam.getUserId()!=loginUser.getId()&&!userService.isAdmin(loginUser)){ throw new BusinessException (ErrorCode.NO_AUTH); } Team updateTeam = new Team (); BeanUtils.copyProperties(teamUpdateRequest,updateTeam); return this .updateById(updateTeam); }
修复bug(漏了校验) 但是这段代码还有有bug,如果状态为公开,也可以修改信息(需要把密码清除) 所以如果队伍状态改为加密,必须要有密码,修改实现类的代码,增加校验
1 2 3 4 5 6 7 TeamStatusEnum statusEnum = TeamStatusEnum.getEnumByValue(teamUpdateRequest.getStatus());if (statusEnum.equals(TeamStatusEnum.SECRET)) { if (StringUtils.isBlank(teamUpdateRequest.getPassword())) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "加密房间必须要设置密码" ); } }
在TeamService里编写用户加入队伍方法并在TeamServiceImpl里实现 同样在请求包里封装一个用户加入队伍请求体
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Data public class TeamJoinRequest implements Serializable { private static final long serialVersionUID = -24663018187059425L ; private Long teamId; private String password; }
在TeamService里面实现joinTeam方法并实现
1 2 3 4 5 6 7 8 boolean joinTeam (TeamJoinRequest teamJoinRequest, User loginUser) ;
编写用户加入队伍接口
1 2 3 4 5 6 7 8 9 @PostMapping("/join") public BaseResponse<Boolean> joinTeam (@RequestBody TeamJoinRequest teamJoinRequest,HttpServletRequest request) { if (teamJoinRequest==null ){ throw new BusinessException (ErrorCode.PARAMS_ERROR); } User loginUser = userService.getLoginUser(request); boolean result = teamService.joinTeam(teamJoinRequest, loginUser); return ResultUtils.success(result); }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 @Override public boolean joinTeam (TeamJoinRequest teamJoinRequest, User loginUser) { if (teamJoinRequest == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } Long teamId = teamJoinRequest.getTeamId(); if (teamId == null || teamId <= 0 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } Team team = this .getById(teamId); if (team == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "队伍不存在" ); } Date expireTime = team.getExpireTime(); if (expireTime != null && expireTime.before(new Date ())) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "队伍已过期" ); } Integer status = team.getStatus(); TeamStatusEnum teamStatusEnum = TeamStatusEnum.getEnumByValue(status); if (teamStatusEnum.PRIVATE.equals(teamStatusEnum)) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "禁止加入私有队伍" ); } String password = teamJoinRequest.getPassword(); if (teamStatusEnum.SECRET.equals(teamStatusEnum)) { if (StringUtils.isBlank(password) || !password.equals(team.getPassword())) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "密码错误" ); } } Long userId = loginUser.getId(); QueryWrapper<UserTeam> userTeamQueryWrapper = new QueryWrapper <>(); userTeamQueryWrapper.eq("userId" , userId); long hasJoinNum = userTeamService.count(userTeamQueryWrapper); if (hasJoinNum > 5 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "最多创建和加入5个队伍" ); } userTeamQueryWrapper = new QueryWrapper <>(); userTeamQueryWrapper.eq("userId" , userId); userTeamQueryWrapper.eq("teamId" , teamId); long hasUserJoinTeam = userTeamService.count(userTeamQueryWrapper); if (hasUserJoinTeam > 0 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "用户已加入该队伍" ); } userTeamQueryWrapper = new QueryWrapper <>(); userTeamQueryWrapper.eq("teamId" , teamId); long teamHasJoinNum = userTeamService.count(userTeamQueryWrapper); if (teamHasJoinNum >= team.getMaxNum()) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "队伍已满" ); } UserTeam userTeam = new UserTeam (); userTeam.setUserId(userId); userTeam.setTeamId(teamId); userTeam.setJoinTime(new Date ()); return userTeamService.save(userTeam); }
接口设计
用户可以退出队伍 新建退出请求体
1 2 3 4 5 6 7 8 @Data public class TeamQuitRequest implements Serializable { private static final long serialVersionUID = -2038884913144640407L ; private Long teamId; }
新建quit请求接口
1 2 3 4 5 6 7 8 9 @PostMapping("/quit") public BaseResponse<Boolean> quitTeam (@RequestBody TeamQuitRequest teamQuitRequest,HttpServletRequest request) { if (teamQuitRequest == null ){ throw new BusinessException (ErrorCode.PARAMS_ERROR); } User loginUser = userService.getLoginUser(request); boolean result = teamService.quitTeam(teamQuitRequest, loginUser); return ResultUtils.success(result); }
在TeamService是写入quitTeam方法
1 2 3 4 5 6 7 boolean quitTeam (TeamQuitRequest teamQuitRequest, User loginUser) ;
在TeamServiceImpl里实现quitTeam方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 @Override @Transactional(rollbackFor = Exception.class) public boolean quitTeam (TeamQuitRequest teamQuitRequest, User loginUser) { if (teamQuitRequest == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } Long teamId = teamQuitRequest.getTeamId(); if (teamId == null || teamId <= 0 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } Team team = this .getById(teamId); if (team == null ) { throw new BusinessException (ErrorCode.NULL_ERROR, "队伍不存在" ); } long userId = loginUser.getId(); UserTeam queryUserTeam = new UserTeam (); queryUserTeam.setTeamId(teamId); queryUserTeam.setUserId(userId); QueryWrapper<UserTeam> queryWrapper = new QueryWrapper <>(queryUserTeam); long count = userTeamService.count(queryWrapper); if (count == 0 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "未加入队伍" ); } long teamHasJoinNum = this .countTeamUserByTeamId(teamId); if (teamHasJoinNum == 1 ) { this .removeById(teamId); } else { if (team.getUserId() == userId) { QueryWrapper<UserTeam> userTeamQueryWrapper = new QueryWrapper <>(); userTeamQueryWrapper.eq("teamId" , teamId); userTeamQueryWrapper.last("order by id asc limit 2" ); List<UserTeam> userTeamList = userTeamService.list(userTeamQueryWrapper); if (CollectionUtils.isEmpty(userTeamList) || userTeamList.size() <= 1 ) { throw new BusinessException (ErrorCode.SYSTEM_ERROR); } UserTeam nextUserTeam = userTeamList.get(1 ); Long nextTeamLeaderId = nextUserTeam.getUserId(); Team updateTeam = new Team (); updateTeam.setId(teamId); updateTeam.setUserId(nextTeamLeaderId); boolean result = this .updateById(updateTeam); if (!result) { throw new BusinessException (ErrorCode.SYSTEM_ERROR, "更新队伍队长失败" ); } } } return userTeamService.remove(queryWrapper); }
这里我们由于多次需要获得队伍当前人数,所以封装了countTeamUserByTeamId方法
1 2 3 4 5 6 7 8 9 10 11 private long countTeamUserByTeamId (long teamId) { QueryWrapper<UserTeam> userTeamQueryWrapper = new QueryWrapper <>(); userTeamQueryWrapper.eq("teamId" , teamId); return userTeamService.count(userTeamQueryWrapper); }
同时在joinTeam方法里修改代码 // 已加入队伍的人数long teamHasJoinNum = this.countTeamUserByTeamId(teamId);
(5).测试(详细) 我这里是队伍18有了两个用户,其中4是创建者,55007是队员(为了方便可复制得到) 现在4退出队伍 的确,4已经不存在队伍18之中了 房主的确顺位给了55007 现在为了方便测试,我们直接在数据库里修改用户队伍关系表,把55007改为退出,4依旧为房主,队伍表里把房主设置为4,然后再次用4退出队伍 队伍18被成功删除 到此为止退出功能基本实现
在TeamService里编写删除队伍方法并在TeamServiceImpl里实现 (1)修改delete接口
1 2 3 4 5 6 7 8 9 10 11 12 @PostMapping("/delete") public BaseResponse<Boolean> deleteTeam (@RequestBody long id,HttpServletRequest request) { if (id <= 0 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } User loginUser = userService.getLoginUser(request); boolean result = teamService.deleteTeam(id,loginUser); if (!result) { throw new BusinessException (ErrorCode.SYSTEM_ERROR, "删除失败" ); } return ResultUtils.success(true ); }
(2).在TeamService里面写入deleteTeam方法
1 2 3 4 5 6 7 boolean deleteTeam (long id, User loginUser) ;
(3).在TeamServiceImpl里实现deleteTeam方法 跟上面一样,我们需要根据id获取队伍信息,这个代码我们重复的写,所以提取出来
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 private Team getTeamById (Long teamId) { if (teamId == null || teamId <= 0 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } Team team = this .getById(teamId);if (team == null ) { throw new BusinessException (ErrorCode.NULL_ERROR, "队伍不存在" ); } return team;}
在Error_Code里添加一个禁止操作FORBIDDEN(40301, "禁止操作", ""),
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Override @Transactional(rollbackFor = Exception.class) public boolean deleteTeam (long id, User loginUser) { Team team = getTeamById(id); long teamId = team.getId(); if (!team.getUserId().equals(loginUser.getId())){ throw new BusinessException (ErrorCode.NO_AUTH,"无访问权限" ); } QueryWrapper<UserTeam> userTeamQueryWrapper = new QueryWrapper <>(); userTeamQueryWrapper.eq("teamId" , teamId); boolean result = userTeamService.remove(userTeamQueryWrapper); if (!result){ throw new BusinessException (ErrorCode.SYSTEM_ERROR,"删除队伍关联信息失败" ); } return this .removeById(teamId); }
注意在操作多个数据库时 在方法上要加上@Transactional(rollbackFor = Exception.class)注解,表示要么数据操作都成功,要么都失败。
踩坑处:这里踩了大坑在校验是不是队长时,按照鱼皮的写了,运行发现报错无权限,debug发现两者的id也是一样的,最后在球友的帮助下,发现是类型的问题,打印出两者的类型是Long封装类,判断两者需要使用equals,而不是==(好像是不支持==),我这里修改为equals成功实现,建议大家先按鱼皮的写,如果报错,就可以替换下!
前端设计
新建一个TeamAddPage,并添加路由 { path:'/team/add', component:TeamAddPage } 在TeamPage里写一个按钮跳转到TeamAddPage
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <script setup lang="ts" > import {useRouter}from "vue-router" ;const router = useRouter ();const doJoinTeam =( )=>{ router.push ({ path :"/team/add" }) }; </script> <template > <div id ="teamPage" > <van-button type ="primary" @click ="doJoinTeam" > 加入队伍</van-button > </div > </template >
设计TeamAddPage页面,在vant组件库里选择合适的组件粘贴 (1)队伍名和描述名 发现队伍名和描述名类似于用户登录页面的表单组件,所以拿来即用(修改下参数) 这个主要是运用了表单,单元格,输入框这三个组件,其中描述使用了高度自适应 参数可以从后台获得(knife4j接口文档) (2)过期时间 我们选择vant里的DatePicker 日期选择和from种的时间选择器 这里的min-date 我们不能直接new Date(),因为这会导致页面一直渲染,从而页面加载不出来,我能得在建一个常量min-date,同时这个日期默认不显示,我们要在JS里展示日期选择器,确当按钮函数 前后端都要加上一个时间格式化 前端时间格式化:moment格式化工具 npm i moment 后端时间格式化:配置文件添加
1 2 3 4 spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai
格式化后端的接口Team.java和TeamAddRequest.java里过期时间添加@DateTimeFormat(pattern "yyyy-MM-dd HH:mm:ss.SSS") (3)队伍状态(当只有选择加密队伍时,才会跳出密码框) 这里我们选择表单类型里的单选框,和field输入框。 注意一定要在判断状态时,把类型转为Number,因为通过打印可得,状态是字符串类型的。 (4)提交按钮 native-type=”submit”属性,点击自动获取van-field name中的值组成的对象。 关键是提交所传的的状态也要转换成Number,同时创建成功后跳转到队伍页面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 <template > <div id ="teamAddPage" > <van-form @submit ="onSubmit" > <van-cell-group inset > <van-field v-model ="addTeamData.name" name ="name" label ="队伍名称" placeholder ="请输入队伍名称" :rules ="[{ required: true, message: '请输入队伍名称' }]" /> <van-field v-model ="addTeamData.description" rows ="4" autosize label ="队伍描述" type ="textarea" placeholder ="请输入队伍描述" /> <van-field is-link readonly name ="datePicker" label ="时间选择" :placeholder ="addTeamData.expireTime ?? '点击选择关闭队伍加入的时间'" @click ="showPicker = true" /> <van-popup v-model:show ="showPicker" position ="bottom" > <van-date-picker @confirm ="onConfirm" @cancel ="showPicker = false" type ="datetime" title ="请选择关闭队伍加入的时间" :min-date ="minDate" /> </van-popup > <van-field name ="stepper" label ="最大人数" > <template #input > <van-stepper v-model ="addTeamData.maxNum" max ="10" min ="3" /> </template > </van-field > <van-field name ="radio" label ="队伍状态" > <template #input > <van-radio-group v-model ="addTeamData.status" direction ="horizontal" > <van-radio name ="0" > 公开</van-radio > <van-radio name ="1" > 私有</van-radio > <van-radio name ="2" > 加密</van-radio > </van-radio-group > </template > </van-field > <van-field v-if ="Number(addTeamData.status) === 2" v-model ="addTeamData.password" type ="password" name ="password" label ="密码" placeholder ="请输入队伍密码" :rules ="[{ required: true, message: '请填写密码' }]" /> </van-cell-group > <div style ="margin: 16px;" > <van-button round block type ="primary" native-type ="submit" > 提交 </van-button > </div > </van-form > </div > </template > <script setup lang ="ts" > import {useRouter} from "vue-router" ;import {ref} from "vue" ;import myAxios from "../plugins/myAxios" ;import moment from 'moment' ;import {showFailToast, showSuccessToast} from "vant/lib/vant.es" ;const router = useRouter ();const showPicker = ref (false );const minDate = new Date ();const onConfirm = ({selectedValues} ) => { addTeamData.value .expireTime = selectedValues.join ('-' ); showPicker.value = false ; }; const initFormData = { "name" : "" , "description" : "" , "expireTime" : null , "maxNum" : 5 , "password" : "" , "status" : 0 , } const addTeamData = ref ({...initFormData})const onSubmit = async ( ) => { const postData = { ...addTeamData.value , status : Number (addTeamData.value .status ), expireTime : moment (addTeamData.value .expireTime ).format ("YYYY-MM-DD HH:mm:ss" ) } const res = await myAxios.post ("/team/add" , postData); if (res?.data .code === 0 && res.data ) { showSuccessToast ('添加成功' ); router.push ({ path : '/team' , replace : true , }); } else { showFailToast ('添加失败' ); } } </script > <style scoped > #teamPage { } </style >
设计队伍列表 首先要定义队伍类型(team.d.ts)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import {UserType } from "./user" ;export type TeamType = { id : number ; name : string ; description : string ; expireTime ?: Date ; maxNum : number ; password ?: string , status : 0 | 1 | 2 ; createTime : Date ; updateTime : Date ; createUser ?: UserType ; hasJoinNum ?: number ; };
创建一个队伍卡片列表组件(类似于用户卡片列表) (1)复制用户卡片列表,将userlist改为teamlist,UserCardList改为TeamCardList,UserType改为TeamType
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 <template> <van-card v-for ="user in props.teamList" :desc ="user.profile" :title ="`${user.username} (${user.planetCode})`" :thumb ="user.avatarUrl" > <template #tags > <van-tag plain type ="danger" v-for ="tag in user.tags" style ="margin-right: 8px; margin-top: 8px" > {{ tag }} </van-tag > </template > <template #footer > <van-button size ="mini" > 联系我</van-button > </template > </van-card > </template> <script setup lang ="ts" > import {TeamType } from "@/models/team" ;interface TeamCardListProps { teamList : TeamType []; } const props= withDefaults (defineProps<TeamCardListProps >(),{ teamList : [] as TeamType [] }); </script > <style scoped > </style >
(2)然后将此组件挂载在TeamPage页面<team-card-list teamList="teamList"/>
1 2 3 4 5 6 7 8 9 const teamList = ref ([]);onMounted (async () =>{ const res = await myAxios.get ("/team/list" ); if (res?.data .code === 0 ) { teamList.value = res.data ; }else { showFailToast ("队伍加载失败,请刷新重试" ); } })
(3)完善 teamcardlist 组件 添加队伍状态,最大人数等以及实现加入队伍功能 下方要涉及到队伍的状态,先创建队伍状态常量 team.ts
1 2 3 4 5 export const teamStatusEnum = { 0 : '公开' , 1 : '私有' , 2 : '加密' , }
完整代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 <template> <div > <van-card v-for ="team in props.teamList" :thumb ="mouse" :desc ="team.description" :title ="`${team.name}`" > <template #tags > <van-tag plain type ="danger" style ="margin-right: 8px; margin-top: 8px" > {{ teamStatusEnum[team.status] }} </van-tag > </template > <template #bottom > <div > {{ '最大人数: ' + team.maxNum }} </div > <div v-if ="team.expireTime" > {{ '过期时间: ' + team.expireTime }} </div > <div > {{ '创建时间: ' + team.createTime }} </div > </template > <template #footer > <van-button size ="small" type ="primary" plain @click ="doJoinTeam(team.id)" > 加入队伍</van-button > </template > </van-card > </div > </template> <script setup lang ="ts" > import type { TeamType } from "@/models/team" ;import mouse from '../assets/mouse.jpg' ;import myAxios from "@/plugins/myAxios" ;import {useRouter} from "vue-router" ;import {teamStatusEnum} from "@/states/team.ts" ;import {showFailToast, showSuccessToast} from "vant" ;interface TeamCardListProps { teamList : TeamType []; } const props = withDefaults (defineProps<TeamCardListProps >(), { teamList : [] as TeamType [], }); const router = useRouter ();const doJoinTeam = async (id:number ) => { const res = await myAxios.post ('/team/join' , { teamId : id, }); if (res?.data .code === 0 ) { showSuccessToast ('加入成功' ); } else { showFailToast ('加入失败' + (res.data .description ? `,${res.data.description} ` : '' )); } } </script > <style scoped > #teamCardList :deep (.van-image__img) { height : 128px ; object-fit : unset; } </style >
注意:
这里 thumb 引入了图片(显示更美观),可以把自己心仪的图片放入assets里并引入
加入队伍里面失败,写的形式是模板字符串,可自行了解
引入样式,原来的图片过于宽,要指定高度关闭自适应,这里我们使用样式穿透,不然不起作用,如果我们给部分组件引入的样式不起作用,都可以使用样式穿透!
匹配用户 用户匹配方法 前端不同页面怎么传递数据?
url querystring(xxx?id=1) 比较适用于页面跳转
url(/team/:id,xxx/1)
hash (/team#1)
localStorage
context(全局变量,同页面或整个项目要访问公共变量)
随机匹配 找到有相似标签的用户 举例: 用户 A:[Java, 大一, 男] 用户 B:[Java, 大二, 男] 用户 C:[Python, 大二, 女] 用户 D:[Java, 大一, 女]
怎么匹配 找到有共同标签最多的用户(TopN) 共同标签越多,分数越高,越排在前面 如果没有匹配的用户,随机推荐几个(降级方案)
编辑距离算法 最小编辑距离:字符串 1 通过最少多少次增删改字符的操作可以变成字符串 2
余弦相似度算法 (如果需要带权重计算,比如学什么方向最重要,性别相对次要)
怎么对所有用户匹配,取 TOP 直接取出所有用户,依次和当前用户计算分数,取 TOP N(54 秒) 优化方法:
切忌不要在数据量大的时候循环输出日志(取消掉日志后 20 秒)
Map 存了所有的分数信息,占用内存解决:
维护一个固定长度的有序集合(sortedSet),只保留分数最高的几个用户(时间换空间) e.g.【3, 4, 5, 6, 7】取 TOP 5,id 为 1 的用户就不用放进去了 细节:剔除自己 √
尽量只查需要的数据: a过滤掉标签为空的用户 √ b根据部分标签取用户(前提是能区分出来哪个标签比较重要) c只查需要的数据(比如 id 和 tags) √(7.0s) 5提前查?(定时任务) a提前把所有用户给缓存(不适用于经常更新的数据) b提前运算出来结果,缓存(针对一些重点用户,提前缓存)
大数据推荐,比如说有几亿个商品,难道要查出来所有的商品? 难道要对所有的数据计算一遍相似度? 检索 => 召回 => 粗排 => 精排 => 重排序等等 检索:尽可能多地查符合要求的数据(比如按记录查) 召回:查询可能要用到的数据(不做运算) 粗排:粗略排序,简单地运算(运算相对轻量) 精排:精细排序,确定固定排位
分表学习建议 mycat、sharding sphere 框架 一致性 hash
队伍操作权限控制 加入队伍: 仅非队伍创建人、且未加入队伍的人可见 更新队伍:仅创建人可见 解散队伍:仅创建人可见 退出队伍:创建人不可见,仅已加入队伍的人可见
加载骨架屏特效 ✔ 解决:van-skeleton 组件 仅加入队伍和创建队伍的人能看到队伍操作按钮(listTeam 接口要能获取我加入的队伍状态) ✔ 方案 1:前端查询我加入了哪些队伍列表,然后判断每个队伍 id 是否在列表中(前端要多发一次请求) 方案 2:在后端去做上述事情(推荐) 前端导航栏死【标题】问题 ✔ 解决:使用 router.beforeEach,根据要跳转页面的 url 路径 匹配 config/routes 配置的 title 字段。
前端页面开发
搜索框 vant-表单组件-搜索 复制到TeamPage页面,同时还有查询为空时,显示的无结果页面(用户页面以写过)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const listTeam = async (val = '' ) => { const res = await myAxios.get ("/team/list" , { params : { searchText : val, pageNum : 1 , }, }); if (res?.data .code === 0 ) { teamList.value = res.data ; } else { showFailToast ('加载队伍失败,请刷新重试' ); } }
挂载和搜索框修改:(PS:搜索直接回车就可行)
1 2 3 4 5 6 7 onMounted (async () =>{ listTeam (); }) const onSearch = (val ) =>{ listTeam (val); }
测试:搜索一个队伍,和查询一个不存在的队伍
更新页面 复制TeamAddPage,创建TeamUpdatePage页面 (1)完善 TeamCardList 首先在队伍页面,创建一个按钮来跳转到更新页面, 但是只有当前用户是队伍创建者才可以看到按钮,可以直接写在TeamCardList组件里 按钮添加 由于需要判断当前用户是否为队伍创建者,我们要获取当前用户(调用以前写的方法) 写跳转按钮的逻辑
PS:别忘了引入useRouter 完整代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 <template> <div id ="teamCardList" > <van-card v-for ="team in props.teamList" :thumb ="mouse" :desc ="team.description" :title ="`${team.name}`" > <template #tags > <van-tag plain type ="danger" style ="margin-right: 8px; margin-top: 8px" > {{ teamStatusEnum[team.status] }} </van-tag > </template > <template #bottom > <div > {{ '最大人数: ' + team.maxNum }} </div > <div v-if ="team.expireTime" > {{ '过期时间: ' + team.expireTime }} </div > <div > {{ '创建时间: ' + team.createTime }} </div > </template > <template #footer > <van-button size ="small" type ="primary" plain @click ="doJoinTeam(team.id)" > 加入队伍</van-button > <van-button v-if ="team.userId === currentUser?.id" size ="small" plain @click ="doUpdateTeam(team.id)" > 更新队伍 </van-button > </template > </van-card > </div > </template> <script setup lang ="ts" > import {TeamType } from "../models/team" ;import {teamStatusEnum} from "../constants/team" ;import mouse from '../assets/mouse.jpg' ;import myAxios from "../plugins/myAxios" ;import {Toast } from "vant" ;import {useRouter} from "vue-router" ;import {onMounted, ref} from "vue" ;import {getCurrentUser} from "../services/user" ;interface TeamCardListProps { teamList : TeamType []; } const props = withDefaults (defineProps<TeamCardListProps >(), { teamList : [] as TeamType [], }); const router = useRouter ();const doJoinTeam = async (id:number ) => { const res = await myAxios.post ('/team/join' , { teamId : id, }); if (res?.code === 0 ) { Toast .success ('加入成功' ); } else { Toast .fail ('加入失败' + (res.description ? `,${res.description} ` : '' )); } } const doUpdateTeam = (id: number ) => { router.push ({ path : '/team/update' , query : { id, } }) } const currentUser = ref ();onMounted (async () =>{ currentUser.value = await getCurrentUser (); }) </script > <style scoped > #teamCardList :deep (.van-image__img) { height : 128px ; object-fit : unset; } </style >
(2)修改TeamUpdateTeam 删除不能修改的组件(最大人数)和固定显示的参数(initFormData),修改提交逻辑 (由于是复制得来的,千万别忘了,不然就是增加队伍了) 关键是获取之前队伍的信息。引入Route,来获取上个页面传来的参数 定义变量idconst id = route.query.id; 挂载获取之前队伍的信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 onMounted (async () => { if (id <= 0 ) { Toast .fail ("队伍加载失败" ); return ; } const res = await myAxios.get ("/team/get" , { params : { id : id, } }); if (res?.code === 0 ) { addTeamData.value = res.data ; } else { Toast .fail ("队伍加载失败,请刷新重试" ); }
完整代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 <template> <div id ="teamAddPage" > <van-form @submit ="onSubmit" > <van-cell-group inset > <van-field v-model ="addTeamData.name" name ="name" label ="队伍名称" placeholder ="请输入队伍名称" :rules ="[{ required: true, message: '请输入队伍名称' }]" /> <van-field v-model ="addTeamData.description" rows ="4" autosize label ="队伍描述" type ="textarea" placeholder ="请输入队伍描述" /> <van-field is-link readonly name ="datePicker" label ="时间选择" :placeholder ="addTeamData.expireTime ?? '点击选择关闭队伍加入的时间'" @click ="showPicker = true" /> <van-popup v-model:show ="showPicker" position ="bottom" > <van-date-picker @confirm ="onConfirm" @cancel ="showPicker = false" type ="datetime" title ="请选择关闭队伍加入的时间" :min-date ="minDate" /> </van-popup > <van-field name ="stepper" label ="最大人数" > <template #input > <van-stepper v-model ="addTeamData.maxNum" max ="10" min ="3" /> </template > </van-field > <van-field name ="radio" label ="队伍状态" > <template #input > <van-radio-group v-model ="addTeamData.status" direction ="horizontal" > <van-radio name ="0" > 公开</van-radio > <van-radio name ="1" > 私有</van-radio > <van-radio name ="2" > 加密</van-radio > </van-radio-group > </template > </van-field > <van-field v-if ="Number(addTeamData.status) === 2" v-model ="addTeamData.password" type ="password" name ="password" label ="密码" placeholder ="请输入队伍密码" :rules ="[{ required: true, message: '请填写密码' }]" /> </van-cell-group > <div style ="margin: 16px;" > <van-button round block type ="primary" native-type ="submit" > 提交 </van-button > </div > </van-form > </div > </template> <script setup lang ="ts" > import {useRouter} from "vue-router" ;import {onMounted, ref} from "vue" ;import myAxios from "../plugins/myAxios" ;import moment from 'moment' ;import {showFailToast, showSuccessToast} from "vant/lib/vant.es" ;import {route} from "vant/es/composables/use-route" ;const router = useRouter ();const id = route.query .id ;const showPicker = ref (false );const minDate = new Date ();const onConfirm = ({selectedValues} ) => { addTeamData.value .expireTime = selectedValues.join ('-' ); showPicker.value = false ; }; const initFormData = { "name" : "" , "description" : "" , "expireTime" : null , "maxNum" : 5 , "password" : "" , "status" : 0 , } const addTeamData = ref ({...initFormData})onMounted (async () => { if (id <= 0 ) { showFailToast ("队伍加载失败" ); return ; } const res = await myAxios.get ("/team/get" , { params : { id : id, } }); if (res?.data .code === 0 ) { addTeamData.value = res.data ; } else { showFailToast ("队伍加载失败,请刷新重试" ); } }) const onSubmit = async ( ) => { const postData = { ...addTeamData.value , status : Number (addTeamData.value .status ), expireTime : moment (addTeamData.value .expireTime ).format ("YYYY-MM-DD HH:mm:ss" ) } const res = await myAxios.post ("/team/add" , postData); if (res?.data .code === 0 && res.data ) { showSuccessToast ('添加成功' ); router.push ({ path : '/team' , replace : true , }); } else { showFailToast ('添加失败' ); } } </script > <style scoped > #teamPage { } </style >
踩坑处:后端update接口要将@RequestBody删去 否则会报Required request body is missing的错误(我这边是这样的)
查看个人已加入队伍 (1)编写后端接口 复用 listTeam 方法,只新增查询条件,不做修改(开闭原则) 获取当前用户已加入的队伍
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 @GetMapping("/list/my/create") public BaseResponse<List<TeamUserVO>> listMyCreateTeams (TeamQuery teamQuery, HttpServletRequest request) { if (teamQuery == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } User loginUser = userService.getLoginUser(request); teamQuery.setUserId(loginUser.getId()); List<TeamUserVO> teamList = teamService.listTeams(teamQuery, true ); return ResultUtils.success(teamList); }
查询加入的队伍需要用到id的列表,所以在Teamquery里增加idList字段
1 2 3 4 private List<Long> idList;
获取我加入的队伍
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @GetMapping("/list/my/join") public BaseResponse<List<TeamUserVO>> listMyJoinTeams (TeamQuery teamQuery, HttpServletRequest request) { if (teamQuery == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } User loginUser = userService.getLoginUser(request); QueryWrapper<UserTeam> queryWrapper = new QueryWrapper <>(); queryWrapper.eq("userId" , loginUser.getId()); List<UserTeam> userTeamList = userTeamService.list(queryWrapper); Map<Long, List<UserTeam>> listMap = userTeamList.stream() .collect(Collectors.groupingBy(UserTeam::getTeamId)); List<Long> idList = new ArrayList <>(listMap.keySet()); teamQuery.setIdList(idList); List<TeamUserVO> teamList = teamService.listTeams(teamQuery, true ); return ResultUtils.success(teamList); }
(3)创建前端页面 复制UserPage,命名为UserUpdatePage,修改UserPage(我们只需要当前用户,修改信息,我创建的队伍,我加入的队伍) 修改UserPage如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 <template> <template v-if ="user" > <van-cell title ="当前用户" :value ="user?.username" /> <van-cell title ="修改信息" is-link to ="/user/update" /> <van-cell title ="我创建的队伍" is-link to ="/user/team/create" /> <van-cell title ="我加入的队伍" is-link to ="/user/team/join" /> </template > </template> <script setup lang ="ts" > import {useRouter} from "vue-router" ;import {onMounted, ref} from "vue" ;import myAxios from "../plugins/myAxios.js" ;import {Toast } from "vant" ;import {getCurrentUser} from "../services/user" ;const user = ref ();onMounted (async ()=>{ user.value =await getCurrentUser (); }) const router = useRouter ();const toEdit = (editKey: string, editName: string, currentValue: string ) => { router.push ({ path : '/user/edit' , query : { editKey, editName, currentValue, } }) } </script > <style scoped > </style >
创建页面:查询加入队伍页面和查询创建队伍页面(复制TeamPage页面,形式相同) PS:别忘了在路由里面添加这两个页面 因为我们把原来的用户页面改为用户更新页面,路由里也要修改{ path:'/user/update', title:'更新信息', component:UserUpdatePage},{ path:'/user/team/join', title:'加入队伍', component:UserTeamJoinPage},{ path:'/user/team/create', title:'创建队伍', component:UserTeamCreatePage},
加入队伍页面 UserTeamJoinPage
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 <template> <div id ="teamPage" > <van-search v-model ="searchText" placeholder ="搜索队伍" @search ="onSearch" /> <team-card-list :teamList ="teamList" /> <van-empty v-if ="teamList?.length < 1" description ="数据为空" /> </div > </template> <script setup lang ="ts" > import {useRouter} from "vue-router" ;import TeamCardList from "../components/TeamCardList.vue" ;import {onMounted, ref} from "vue" ;import myAxios from "../plugins/myAxios" ;import {Toast } from "vant" ;const router = useRouter ();const searchText = ref ('' );const teamList = ref ([]);const listTeam = async (val = '' ) => { const res = await myAxios.get ("/team/list/my/join" , { params : { searchText : val, pageNum : 1 , }, }); if (res?.data .code === 0 ) { teamList.value = res.data ; } else { Toast .fail ('加载队伍失败,请刷新重试' ); } } onMounted ( () => { listTeam (); }) const onSearch = (val ) => { listTeam (val); }; </script > <style scoped > #teamPage { } </style >
创建队伍页面 UserTeamCreatePage
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 <template> <div id ="teamPage" > <van-search v-model ="searchText" placeholder ="搜索队伍" @search ="onSearch" /> <van-button type ="primary" @click ="doJoinTeam" > 创建队伍</van-button > <team-card-list :teamList ="teamList" /> <van-empty v-if ="teamList?.length < 1" description ="数据为空" /> </div > </template> <script setup lang ="ts" > import {useRouter} from "vue-router" ;import TeamCardList from "../components/TeamCardList.vue" ;import {onMounted, ref} from "vue" ;import myAxios from "../plugins/myAxios" ;import {Toast } from "vant" ;const router = useRouter ();const searchText = ref ('' );const doJoinTeam = ( ) => { router.push ({ path : "/team/add" }) } const teamList = ref ([]);const listTeam = async (val = '' ) => { const res = await myAxios.get ("/team/list/my/create" , { params : { searchText : val, pageNum : 1 , }, }); if (res?.data .code === 0 ) { teamList.value = res.data ; } else { Toast .fail ('加载队伍失败,请刷新重试' ); } } onMounted ( () => { listTeam (); }) const onSearch = (val ) => { listTeam (val); }; </script > <style scoped > #teamPage { } </style >
PS:这里我觉得是有bug的,发送的请求是不带参数的,即status的默认状态是为null,会被定义成公共的,这样的话,如果我们创建的队伍是私人或者默认的就不会展现(我觉得应该是复用teamList的缘故,在teamLIst的逻辑里,我们不带参数请求就直接查询所有公开的),解决办法是带个status的参数再发送请求。但是我在knife4j里测试了一下,只能传一个状态参数,类型为整型,这代表了我们不能同查询多个状态的队伍,回到前端由于知识浅薄无法解决传参问题,只能显示公开状态的队伍。拉了鱼皮完整的代码(好像也没解决),不知道有无大佬能够解决这个问题!
退出和解散队伍 在TeamCardList添加两个按钮来实现这两个功能<van-button size="small" plain@click="doQuitTeam(team.id)">退出队伍</van-button><van-button v-if="team.userId === currentUser?.id" size="small" type="danger" plain @click="doDeleteTeam(team.id)">解散队伍</van-button>
在js里写入按钮的方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 const doQuitTeam = async (id : number ) => { const res = await myAxios.post ('/team/quit' , { teamId : id }); if (res?.data .code === 0 ) { showSuccessToast ('操作成功' ); } else { showFailToast ('操作失败' + (res.description ? `,${res.description} ` : '' )); } } const doDeleteTeam = async (id : number ) => { const res = await myAxios.post ('/team/delete' , { id, }); if (res?.data .code === 0 ) { showSuccessToast ('操作成功' ); } else { showFailToast ('操作失败' + (res.description ? `,${res.description} ` : '' )); } }
报错,这是因为我们后端接口没有封装对象(偷懒),所以我们封装一个删除请求DeleteRequest
1 2 3 4 5 6 7 8 9 10 11 12 @Data public class DeleteRequest implements Serializable { private static final long serialVersionUID = 1787902631969457554L ; private long id; }
并且修改删除接口
1 2 3 4 5 6 7 8 9 10 11 12 13 @PostMapping("/delete") public BaseResponse<Boolean> deleteTeam (@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) { if (deleteRequest == null || deleteRequest.getId() <= 0 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } long id = deleteRequest.getId(); User loginUser = userService.getLoginUser(request); boolean result = teamService.deleteTeam(id, loginUser); if (!result) { throw new BusinessException (ErrorCode.SYSTEM_ERROR, "删除失败" ); } return ResultUtils.success(true ); }
随机匹配 编辑距离算法 最小编辑距离:字符串 str1 通过最少多少次增删改字符的操作可以变成字符串str2
余弦相似度算法 (如果需要带权重计算,比如学什么方向最重要,性别相对次要)
匹配用户后端编写 这里使用了编辑距离算法 把这个方法放在工具类(新建一个utils包)里面,并写一个测试类测试 推荐通过标签类(所以我们传的参数应该是字符型的列表),修改整理为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 public class AlgorithmUtils { public static int minDistance (List<String> tagList1, List<String> tagList2) { int n = tagList1.size(); int m = tagList2.size(); if (n * m == 0 ) { return n + m; } int [][] d = new int [n + 1 ][m + 1 ]; for (int i = 0 ; i < n + 1 ; i++) { d[i][0 ] = i; } for (int j = 0 ; j < m + 1 ; j++) { d[0 ][j] = j; } for (int i = 1 ; i < n + 1 ; i++) { for (int j = 1 ; j < m + 1 ; j++) { int left = d[i - 1 ][j] + 1 ; int down = d[i][j - 1 ] + 1 ; int left_down = d[i - 1 ][j - 1 ]; if (!Objects.equals(tagList1.get(i - 1 ), tagList2.get(j - 1 ))) { left_down += 1 ; } d[i][j] = Math.min(left, Math.min(down, left_down)); } } return d[n][m]; } public static int minDistance (String word1, String word2) { int n = word1.length(); int m = word2.length(); if (n * m == 0 ) { return n + m; } int [][] d = new int [n + 1 ][m + 1 ]; for (int i = 0 ; i < n + 1 ; i++) { d[i][0 ] = i; } for (int j = 0 ; j < m + 1 ; j++) { d[0 ][j] = j; } for (int i = 1 ; i < n + 1 ; i++) { for (int j = 1 ; j < m + 1 ; j++) { int left = d[i - 1 ][j] + 1 ; int down = d[i][j - 1 ] + 1 ; int left_down = d[i - 1 ][j - 1 ]; if (word1.charAt(i - 1 ) != word2.charAt(j - 1 )) { left_down += 1 ; } d[i][j] = Math.min(left, Math.min(down, left_down)); } } return d[n][m]; } }
测试方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public class AlgorithmUtilsTest { @Test void test () { String str1 = "鱼皮是狗" ; String str2 = "鱼皮不是狗" ; String str3 = "鱼皮是鱼不是狗" ; int score1 = AlgorithmUtils.minDistance(str1, str2); int score2 = AlgorithmUtils.minDistance(str1, str3); System.out.println(score1); System.out.println(score2); } @Test void testCompareTags () { List<String> tagList1 = Arrays.asList("Java" , "大一" , "男" ); List<String> tagList2 = Arrays.asList("Java" , "大一" , "女" ); List<String> tagList3 = Arrays.asList("Python" , "大二" , "女" ); int score1 = AlgorithmUtils.minDistance(tagList1, tagList2); int score2 = AlgorithmUtils.minDistance(tagList1, tagList3); System.out.println(score1); System.out.println(score2); } }
在UserCOntroller里写入获取最匹配的用户的接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @GetMapping("/match") public BaseResponse<List<User>> matchUsers (long num, HttpServletRequest request) { if (num <= 0 || num > 20 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } User user = userService.getLoginUser(request); return ResultUtils.success(userService.matchUsers(num, user)); }
在USerService里写入matchUsers方法并实现
1 2 3 4 5 6 7 List<User> matchUsers (long num, User loginUser) ;
具体的实现方法本期直播并未完美的完成(遗留bug),所以结合13期的内容,修复了排序的问题 下面就是具体的代码: (这里由于鱼皮踩坑过多,同时自己也没有完全理解,过程就省略)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 @Override public List<User> matchUsers (long num, User loginUser) { QueryWrapper<User> queryWrapper = new QueryWrapper <>(); queryWrapper.select("id" , "tags" ); queryWrapper.isNotNull("tags" ); List<User> userList = this .list(queryWrapper); String tags = loginUser.getTags(); Gson gson = new Gson (); List<String> tagList = gson.fromJson(tags, new TypeToken <List<String>>() { }.getType()); List<Pair<User, Long>> list = new ArrayList <>(); for (int i = 0 ; i < userList.size(); i++) { User user = userList.get(i); String userTags = user.getTags(); if (StringUtils.isBlank(userTags) || user.getId() == loginUser.getId()) { continue ; } List<String> userTagList = gson.fromJson(userTags, new TypeToken <List<String>>() { }.getType()); long distance = AlgorithmUtils.minDistance(tagList, userTagList); list.add(new Pair <>(user, distance)); } List<Pair<User, Long>> topUserPairList = list.stream() .sorted((a, b) -> (int ) (a.getValue() - b.getValue())) .limit(num) .collect(Collectors.toList()); List<Long> userIdList = topUserPairList.stream().map(pair -> pair.getKey().getId()).collect(Collectors.toList()); QueryWrapper<User> userQueryWrapper = new QueryWrapper <>(); userQueryWrapper.in("id" , userIdList); Map<Long, List<User>> userIdUserListMap = this .list(userQueryWrapper) .stream() .map(user -> getSafetyUser(user)) .collect(Collectors.groupingBy(User::getId)); List<User> finalUserList = new ArrayList <>(); for (Long userId : userIdList) { finalUserList.add(userIdUserListMap.get(userId).get(0 )); } return finalUserList; }
这里鱼皮使用,我使用会依旧查到自己,debug发现值也是相同的,考虑到上次的踩坑处,应该是对象类型的问题,换成equals可行(球友们可自行根据情况编写代码)if (StringUtils.isBLank(userTags) || user.getId().equals(LoginUser.getId()))
主页切换功能 vant-表单组件-switch开关,复制到主页index里面<van-switch v-model="checked" />
1 2 3 4 5 6 7 8 import { ref } from 'vue' ;export default { setup ( ) { const checked = ref (true ); return { checked }; }, };
定义一个开关切换常量,默认为关闭const isMatchMode = ref<boolean>(false); 现在不需要一次性挂载,写一个加载的方法,并且写一个监听器(当开关切换时,“更换页面”)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 const loadData = async ( ) => { let userListData; if (isMatchMode.value ) { const num = 10 ; userListData = await myAxios.get ('/user/match' , { params : { num, }, }) .then (function (response ) { console .log ('/user/match succeed' , response); return response?.data ; }) .catch (function (error ) { console .error ('/user/match error' , error); showFailToast ('请求失败' ); }) } else { userListData = await myAxios.get ('/user/recommend' , { params : { pageSize : 8 , pageNum : 1 , }, }) .then (function (response ) { console .log ('/user/recommend succeed' , response); return response?.data ?.records ; }) .catch (function (error ) { console .error ('/user/recommend error' , error); showFailToast ('请求失败' ); }) } if (userListData) { userListData.forEach ((user :UserType ) => { if (user.tags ) { user.tags = JSON .parse (user.tags ); } }) userList.value = userListData; } } watchEffect (() => { loadData (); })
todo
加载loading特效 使用骨架屏特效,放在UserListCard里面(包裹内容) 别忘了在js里添加这两个参数 到index的user-card-list里引用 同时别忘了引入loading常量,并在loadData方法里,在开始和结尾处分别使loading设置为true和false
仅加入队伍和创建队伍的人能看到队伍操作按钮 队伍操作权限控制 加入队伍: 仅非队伍创建人、且未加入队伍的人可见 更新队伍:仅创建人可见 解散队伍:仅创建人可见 退出队伍:创建人不可见,仅已加入队伍的人可见
仅加入队伍和创建队伍的人能看到队伍操作按钮(listTeam 接口要能获取我加入的队伍状态) 方案 1:前端查询我加入了哪些队伍列表,然后判断每个队伍 id 是否在列表中(前端要多发一次请求) 方案 2:在后端去做上述事情(推荐) 这里我们选择方案2
修改代码
首先为TeamUserVO太那几是否已加入队伍的字段
修改listTeam的接口,加入是否已经加入队伍的判断
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 @GetMapping("/list") public BaseResponse<List<TeamUserVO>> listTeams (TeamQuery teamQuery, HttpServletRequest request) { if (teamQuery == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } boolean isAdmin = userService.isAdmin(request); List<TeamUserVO> teamList = teamService.listTeams(teamQuery, isAdmin); final List<Long> teamIdList = teamList.stream().map(TeamUserVO::getId).collect(Collectors.toList()); QueryWrapper<UserTeam> userTeamQueryWrapper = new QueryWrapper <>(); try { User loginUser = userService.getLoginUser(request); userTeamQueryWrapper.eq("userId" , loginUser.getId()); userTeamQueryWrapper.in("teamId" , teamIdList); List<UserTeam> userTeamList = userTeamService.list(userTeamQueryWrapper); Set<Long> hasJoinTeamIdSet = userTeamList.stream().map(UserTeam::getTeamId).collect(Collectors.toSet()); teamList.forEach(team -> { boolean hasJoin = hasJoinTeamIdSet.contains(team.getId()); team.setHasJoin(hasJoin); }); } catch (Exception e) {} return ResultUtils.success(teamList); }
前端导航栏死【标题】问题 解决:使用 router.beforeEach,根据要跳转页面的 url 路径 匹配 config/routes 配置的 title 字段 配置路由里的title字段 在BasicLayout里增加根据路由切换标题 同时把原来用于测试的Toast响应(请求成功)删除,全局搜索删除 别忘了,把这句也删除 刷新,切换到不同页面,测试标签栏是否更换,以及请求成功是否不再出现
优化、上线
强制登录,自动跳转到登录页 解决:axios 全局配置响应拦截、并且添加重定向 在myAxios里配置响应拦截 这里我们要改变history 模式的实现,在main.ts里修改 当登录成功后,重定向到个人用户页面 PS:别忘了引入route 修改队伍页面的加入队伍按钮为创建队伍 在TeamPage页面,修改加入队伍为创建队伍(按钮部分) 把doJoinTeam全局修改为toAddTeam 这个按钮太丑了,我们更换它的样式,变成圆形放在右下角 写一个全局样式 在main.ts中引入 右下角的按钮:
区分公开和加密房间;加入有密码的房间,要指定密码 在TeamPage页面加入tabs标签,来区分公开还是加密 后端我们以前根据状态查询只查询公开,现在修改为当不是管理员和私人才会报权限错误 回到前端,我们在TeamPage页面实现onTabChange方法 上面定义的active是为了页面默认显示公开队伍 修改搜索队伍,传入状态 现在点击公开和加密可以切换查看不同类型的队伍 加密队伍需要输入密码才可以加入,我们这使用Dialog 弹出框组件,把它放入team-card-list.vue里(最下面的位置)
1 2 3 <van-dialog v-model :show="showPasswordDialog" title="请输入密码" show-cancel-button @confirm ="doJoinTeam" @cancel ="doJoinCancel" > <van-field v-model ="password" placeholder ="请输入密码" /> </van-dialog>
在里面修改加入doJoinTeam方法,实现doJoinCancel方法和判断是不是加密房间preJoinTeam方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 const doJoinTeam = async () => { if (!joinTeamId.value){ return ; } const res = await myAxios.post('/team/join' , { teamId: joinTeamId.value, password: password.value }); if (res?.code === 0 ) { Toast.success('加入成功' ); doJoinCancel(); } else { Toast.fail('加入失败' + (res.description ? `,${res.description}` : '' )); } } const showPasswordDialog = ref(false );const password = ref('' );const joinTeamId = ref(0 ); const preJoinTeam = (team: TeamType) => { joinTeamId.value = team.id; if (team.status === 0 ) { doJoinTeam() } else { showPasswordDialog.value = true ; } } const doJoinCancel = () => { joinTeamId.value = 0 ; password.value = '' ; }
展示已加入队伍人数 这个我们后端还未实现,所以在获取队伍列表接口,获取这个参数 首先在封装类里添加字段(TeamUserVO) 修改listTeams接口,修改整理为如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 @GetMapping("/list") public BaseResponse<List<TeamUserVO>> listTeams (TeamQuery teamQuery, HttpServletRequest request) { if (teamQuery == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } boolean isAdmin = userService.isAdmin(request); List<TeamUserVO> teamList = teamService.listTeams(teamQuery, isAdmin); final List<Long> teamIdList = teamList.stream().map(TeamUserVO::getId).collect(Collectors.toList()); QueryWrapper<UserTeam> userTeamQueryWrapper = new QueryWrapper <>(); try { User loginUser = userService.getLoginUser(request); userTeamQueryWrapper.eq("userId" , loginUser.getId()); userTeamQueryWrapper.in("teamId" , teamIdList); List<UserTeam> userTeamList = userTeamService.list(userTeamQueryWrapper); Set<Long> hasJoinTeamIdSet = userTeamList.stream().map(UserTeam::getTeamId).collect(Collectors.toSet()); teamList.forEach(team -> { boolean hasJoin = hasJoinTeamIdSet.contains(team.getId()); team.setHasJoin(hasJoin); }); } catch (Exception e) {} QueryWrapper<UserTeam> userTeamJoinQueryWrapper = new QueryWrapper <>(); userTeamJoinQueryWrapper.in("teamId" , teamIdList); List<UserTeam> userTeamList = userTeamService.list(userTeamJoinQueryWrapper); Map<Long, List<UserTeam>> teamIdUserTeamList = userTeamList.stream().collect(Collectors.groupingBy(UserTeam::getTeamId)); teamList.forEach(team -> team.setHasJoinNum(teamIdUserTeamList.getOrDefault(team.getId(), new ArrayList <>()).size())); return ResultUtils.success(teamList); }
在前端的TeamCardList里修改原来的最大人数为已加入人数 如果爆红的在队伍规范类型里添加字段
重复加入队伍的问题(加锁、分布式锁)并发请求时可能出现问题 只要我们点的足够快,就可以在同一时间内往数据库插入多条同样的数据,所以这里我们使用分布式锁(推荐) 使用两把锁,一把锁锁队伍,一把锁锁用户(实现较难,不推荐) 修改jointeam的实现方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 @Override public boolean joinTeam (TeamJoinRequest teamJoinRequest, User loginUser) { if (teamJoinRequest == null ) { throw new BusinessException (ErrorCode.PARAMS_ERROR); } Long teamId = teamJoinRequest.getTeamId(); Team team = getTeamById(teamId); Date expireTime = team.getExpireTime(); if (expireTime != null && expireTime.before(new Date ())) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "队伍已过期" ); } Integer status = team.getStatus(); TeamStatusEnum teamStatusEnum = TeamStatusEnum.getEnumByValue(status); if (TeamStatusEnum.PRIVATE.equals(teamStatusEnum)) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "禁止加入私有队伍" ); } String password = teamJoinRequest.getPassword(); if (TeamStatusEnum.SECRET.equals(teamStatusEnum)) { if (StringUtils.isBlank(password) || !password.equals(team.getPassword())) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "密码错误" ); } } long userId = loginUser.getId(); RLock lock = redissonClient.getLock("yupao:join_team" ); try { while (true ) { if (lock.tryLock(0 , -1 , TimeUnit.MILLISECONDS)) { System.out.println("getLock: " + Thread.currentThread().getId()); QueryWrapper<UserTeam> userTeamQueryWrapper = new QueryWrapper <>(); userTeamQueryWrapper.eq("userId" , userId); long hasJoinNum = userTeamService.count(userTeamQueryWrapper); if (hasJoinNum > 5 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "最多创建和加入 5 个队伍" ); } userTeamQueryWrapper = new QueryWrapper <>(); userTeamQueryWrapper.eq("userId" , userId); userTeamQueryWrapper.eq("teamId" , teamId); long hasUserJoinTeam = userTeamService.count(userTeamQueryWrapper); if (hasUserJoinTeam > 0 ) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "用户已加入该队伍" ); } long teamHasJoinNum = this .countTeamUserByTeamId(teamId); if (teamHasJoinNum >= team.getMaxNum()) { throw new BusinessException (ErrorCode.PARAMS_ERROR, "队伍已满" ); } UserTeam userTeam = new UserTeam (); userTeam.setUserId(userId); userTeam.setTeamId(teamId); userTeam.setJoinTime(new Date ()); return userTeamService.save(userTeam); } } } catch (InterruptedException e) { log.error("doCacheRecommendUser error" , e); return false ; } finally { if (lock.isHeldByCurrentThread()) { System.out.println("unLock: " + Thread.currentThread().getId()); lock.unlock(); } } }
别忘了引入RedissonClient
分布式锁 先区分多环境:前端区分开发和线上接口,后端 prod 改为用线上公网可访问的数据库 前端:Vercel (免费) 后端:微信云托管 (部署容器的平台,付费)(免备案!!!)