伙伴匹配系统

需求分析

移动端 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免备案方式上线前后端

前端初始化

用脚手架初始化项目

  1. 创建项目
    Vite 官网
    Vite 脚手架
    npm create vue@latest
    选择语言——ts

  2. 整合组件库 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';

// https://vite.dev/config/
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,
}

  1. 使用 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>
  1. 挂载到 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 栏:

  • 主页(推荐页 + 广告 )
    • 搜索框
    • banner
    • 推荐信息流
  • 队伍页
  • 用户页(消息 - 暂时考虑发邮件)

开发

很多页面要复用组件/样式,所以用通用布局(Layout)
创建页面: pages/Index.vue,TeamPage.vue,UserPage.vue,SearchPage.vue

  1. 顶部导航栏
    引入导航组件-导航栏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

  1. 底部标签栏
    引入导航组件-标签栏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. 插槽
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);

标签接口调试

  1. 更改代码
    用户中心的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
/**
* 根据标签搜索用户 - 内存查询
* @param tagNameList 用户拥有的标签
* @return
*/
@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();
//2.在内存中判断是否包含要求的标签
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());
// Java8 Optional.ofNullable判断为空
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());
}

/**
* 根据标签搜索用户(SQL查询)
* @Deprecated 过时
* @param tagNameList
* @return
*/
@Deprecated
private List<User> searchUsersByTagsBySQL(List<String> tagNameList) {
if (CollectionUtils.isEmpty(tagNameList)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
//拼接 and 查询
//like '%Java%' and like '%Python%'
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({
// 使用 createWebHashHistory
history: createWebHashHistory(),
routes,
})

export default router; // 导出 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>

搜索功能

  1. 搜索页面
    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>

  1. 搜索按钮
    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>
  1. 标签的添加,搜索,删除
    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);

  1. 创建用户信息页
    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>

  1. 创建用户信息修改页
    新建 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) => {
//todo 把editKey currentValue editName提交到后台
console.log('onSubmit',values);
}

</script>

后端整合 Swagger + Knife4j 接口文档

  • 请求参数
  • 响应参数
    • 错误码
  • 接口地址
  • 接口名称
  • 请求类型
  • 请求格式
  • 备注

自动化接口文档生成:自动根据项目代码生成完整的文档或在线调试的网页。
国外: Knife4j, Postman(侧重接口管理)
国产: apifox, apipost, eolink

  1. 引入 Knife4j 依赖
    Knife4j
    Spring Boot 2 + OpenAPI3: 引入依赖
1
2
3
4
5
6
7
8
9
10
11
12
13
<!--添加springdoc依赖-->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.6.14</version>
</dependency>
<!--添加knife4j依赖-->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-spring-boot-starter</artifactId>
<version>4.5.0</version>
</dependency>

  1. Knife4j 的配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# springdoc-openapi项目配置
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的增强配置,不需要增强可以不配
knife4j:
enable: true
setting:
language: zh_cn

  1. 启动
    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

  2. 注解自定义生成的接口描述信息
    接口 作者名字
    @ApiOperationSupport(author = "微光zc")
    @ApiOperation(value = "写文档注释我是认真的")
    Controller模块 作者名字
    @Api(tags = "2.0.3版本-20200312")
    @ApiSupport(author = "微光zc",order = 284)

  3. 自定义文档
    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: 测试自定义标题分组
# 某一个文件夹下所有的.md文件
locations: classpath:markdown/*
-
group: 1.2.x
name: 接口签名
# 某一个文件夹下单个.md文件
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>

创建实体类和监听器

  1. 创建实体类
    定义一个实体类,该类中的每个属性对应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;
}
  1. 创建事件监听器
    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> {
/**
* 这个每一条数据解析都会来调用
*
* @param data one row value. Is is same as {@link AnalysisContext#readRowHolder()}
* @param context
*/
@Override
public void invoke(XingQiuTableUserInfo data, AnalysisContext context) {
System.out.println(data);
}

/**
* 所有数据解析完成了 都会来调用
*
* @param context
*/
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
System.out.println("已解析完成");
}
}

实现写入和读取功能

  1. 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
// Excel写入功能
@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);
}

  1. 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
/**
* 导入Excel
*/
public class ImportExcel {
public static void main(String[] args) {
//写法1
String fileName = "F:\\code\\星球项目\\用户中心\\user-center-backend-master\\src\\main\\resources\\testExcel.xlsx";
// 这里 需要指定读用哪个class去读,然后读取第一个sheet 文件流会自动关闭
// 这里每次会读取100条数据 然后返回过来 直接调用使用数据就行
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
/**
* 导入Excel,读取数据
*/
public class ImportExcel {
/**
* 读取数据
*/
public static void main(String[] args) {
// 写法1:JDK8+ ,不用额外写一个DemoDataListener
// since: 3.0.0-beta1
//Excel数据文件放在自己电脑上,能够找到的路径
String fileName = "E:\\Projects\\PartnerSystem\\testExcel.xlsx";
// readByListener(fileName);
synchronousRead(fileName);
}

/**
* 监听器读取
* @param fileName
*/
public static void readByListener(String fileName) {
// 这里 需要指定读用哪个class去读,然后读取第一个sheet 文件流会自动关闭
// 这里每次会读取100条数据 然后返回过来 直接调用使用数据就行
FastExcel.read(fileName, XingQiuTableUserInfo.class, new TableListener()).sheet().doRead();
}

/**
* 同步读
* 同步的返回,不推荐使用,如果数据量大会把数据放到内存里面
*/
public static void synchronousRead(String fileName) {
// 这里 需要指定读用哪个class去读,然后读取第一个sheet 同步读取会自动finish
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) {
//Excel数据文件放在自己电脑上,能够找到的路径
String fileName = "E:\\Projects\\PartnerSystem\\testExcel.xlsx";
// 这里 需要指定读用哪个class去读,然后读取第一个sheet 同步读取会自动finish
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());
}
}
  1. 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. 搜索页面
    (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";

// Set config defaults when creating the instance
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(() => {
// 请求的url、参数
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 () => {//异步调用
// 为给定 ID 的 user 创建请求
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; //返回数据 ?.可选链操作符,避免数据为null或undefined时报错
})
.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

  1. 下载 redis
    Redis 5.0.14 下载 提取码:vkoi
    redis 管理工具quick redis

  2. 引入 redis 依赖
    在springboot里引入redis,能够操作redis

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis -->
<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
<!-- https://mvnrepository.com/artifact/org.springframework.session/spring-session-data-redis -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>4.0.0</version>
</dependency>

如果启动失败去掉版本号

  1. 修改 spring-session 存储配置
    spring.session.store-type 默认是 none,表示存储在单台服务器
    store-type: redis,表示从 redis 读写 session
1
2
3
4
5
6
7
8
9
# session 失效时间(分钟)
session:
timeout: 86400
store-type: redis
# redis 配置
redis:
port: 6379
host: localhost
database: 1

为了模拟多服务器,我们需要打包项目,在另一个端口启动,这里是8081
先打包,后在target目录下打开终端运行下面的代码
java -jar .\user-center-backend-0.0.1-SNAPSHOT.jar –server.port=8081

改造用户信息接口

  1. 在UserController里面添加更新用户信息接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 更新用户信息
* @param user
* @param request
* @return
*/
@PostMapping("/update")
public BaseResponse<Integer> updateUser(@RequestBody User user, HttpServletRequest request) {
// 1. 校验参数是否为空
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
/**
* 是否为管理员
* @param request
* @return
*/
boolean isAdmin(HttpServletRequest request);

/**
* 是否为管理员
* @param loginUser
* @return
*/
boolean isAdmin(User loginUser);

/**
* 获取当前用户
* @param request
* @return
*/
User getCurrentUser(HttpServletRequest request);

/**
* 更新用户信息
* @param user
* @param loginUser
* @return
*/
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
/**
* 更新用户信息
* @param user
* @param loginUser
* @return
*/
@Override
public int updateUser(User user, User loginUser) {
Long userId = user.getId();
if (userId <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
// 2. 校验权限
// 2.1 管理员可以更新任意信息、用户只能更新自己的信息
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);
}
// 3. 触发更新
return this.baseMapper.updateById(user);
}

/**
* 是否为管理员
* @param request
* @return
*/
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;
}

/**
* 是否为管理员
* @param loginUser
* @return
*/
@Override
public boolean isAdmin(User loginUser) {
return loginUser.getUserRole() == ADMIN_ROLE;
}

/**
* 获取当前用户
* @param request
* @return
*/
@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
})
//todo 把editKey currentValue editName提交到后台
console.log('更新请求',res);
if (res.data.code === 0 && res.data.data >0){
showSuccessToast('修改成功')
router.back()
}else {
showFailToast('修改失败');
}
};
  1. 新建登录页面 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 () => {
// console.log("用户登录");
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

  1. 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的字符转变导致的,直接删去,后面再处理

  1. 前端 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 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(),
// };

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>
  1. 前端完善修改用户信息
    发现每个页面都要获取当前的用户信息,所以我们把这个方法提取出来
    建立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";


/**
* 获取用户信息
* @returns {Promise<null|any>}
*/
export const getCurrentUser = async() => {
const user = getCurrentUserState();
if (user) {
return user;
}
//从远程处获取用户信息
const res = await myAxios.get("/user/current");
if (res.data.code == 0 ) {
// setCurrentUserState(res.data);
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 () => {
// const res = await myAxios.get('/user/current');
// if (res.data.code === 0) {
// user.value = res.data;
// showSuccessToast('获取用户信息成功');
// } else {
// showFailToast('获取用户信息成功');
// }
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 currentUser = await getCurrentUser();
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>

注意踩坑处:
语法糖是支持写在外面的,但是这里面运用就不显示页面

  1. 关于调用缓存去获取当前用户信息的问题
    src/services/user.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
export const getCurrentUser = async() => {
// const user = getCurrentUserState();
// if (user) {
// return user;
// }
//从远程处获取用户信息
const res = await myAxios.get("/user/current");
if (res.data.code == 0 ) {
// setCurrentUserState(res.data);
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 核数
数据库慢?预先把数据查出来,放到一个更快读取的地方,不用再查数据库了。(缓存)
预加载缓存,定时更新缓存。(定时任务)
多个机器都要执行任务么?(分布式锁:控制同一时间只有一台机器去执行定时任务,其他机器不用重复执行了)

主页

  1. 启动前后端项目
    在 SearchResultPage.vue 中
    修改如下代码:return response?.data?.data;

  2. 编写主页(直接list列表)
    UserController.java

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 推荐页面
* @param request
* @return
*/
@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 () => {
// 为给定 ID 的 user 创建请求
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>(),{
//@ts-ignore
userList: [] as UserType[]
});

</script>
<style scoped>
/* 标签颜色*/
.van-tag--danger.van-tag--plain {
color: #002fff;
}
</style>
  1. 模拟1000万用户,再次进行查询
    我们需要插入数据:
  2. 用可视化界面:适合一次性导入、数据量可控
  3. 写程序: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));

/**
* 循环插入用户 耗时:7260ms
* 批量插入用户 1000 耗时: 4751ms
*/
@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());

}

/**
* 并发批量插入用户 100000 耗时: 26830ms
*/
@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<>();
// i 要根据数据量和插入批量来计算需要循环的次数。(鱼皮这里直接取了个值,会有问题,我这里随便写的)
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开启异步任务
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
/**
* 推荐页面
* @param request
* @return
*/
@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 {

/**
* 新的分页插件,一缓和二缓遵循mybatis的规则
* 需要设置 MybatisConfiguration#useDeprecatedExecutor = false 避免缓存出现问题(该属性会在旧插件移除后一同移除)
*/
@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 () => {
// 为给定 ID 的 user 创建请求
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;
/**
* @author Shier
* 自定义Redis序列化
*/
@Configuration
public class RedisTemplateConfig {

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
// 创建RedisTemplate对象
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
//设置连接工厂
redisTemplate.setConnectionFactory(connectionFactory);
//设置Key的序列化
redisTemplate.setKeySerializer(RedisSerializer.string());

//创建Json序列化工具
GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
//设置Value的序列化
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"));
// valueOperations.set("yupiString","dog");
// redisTemplate.delete("yupiString");
}
}

redis中可查询到缓存的数据

根据用户开发个性推荐页

开发

  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
/**
* 推荐页面
* @param request
* @return
*/
@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

  1. 定时任务

    还存在一个问题:第一个用户访问还是很慢,要实现缓存预热这里我们使用了定时的方法
    新建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 // 加载成bean
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 注解,允许定时任务

控制定时任务的执行

  1. 浪费资源,想象 10000 台服务器同时 “打鸣”
  2. 脏数据,比如重复插入

要控制定时任务在同一时间只有 1 个服务器能执行。
怎么做?

  1. 分离定时任务程序和主程序,只在 1 个服务器运行定时任务。成本太大
  2. 写死配置,每个服务器都执行定时任务,但是只有 ip 符合配置的服务器才真实执行业务逻辑,其他的直接返回。
    成本最低;但是我们的 IP 可能是不固定的,把 IP 写的太死了
  3. 动态配置,配置是可以轻松的、很方便地更新的(代码无需重启),但是只有 ip 符合配置的服务器才真实执行业务逻辑。
    问题:服务器多了、IP 不可控还是很麻烦,还是要人工修改
    - 数据库
    - Redis
    - 配置中心(Nacos、Apollo、Spring Cloud Config)
  4. 分布式锁,只有抢到锁的服务器才能执行业务逻辑。坏处:增加成本;好处:不用手动配置,多少个服务器都一样。

单机就会存在单点故障。

有限资源的情况下,控制同一时间(段)只有某些线程(用户 / 服务器)能访问到资源。
Java 实现锁:synchronized 关键字、并发包的类
问题:只对单个 JVM 有效

分布式锁
为啥需要分布式锁?

  1. 有限资源的情况下,控制同一时间(段)只有某些线程(用户 / 服务器)能访问到资源。

  2. 单个锁只对单个 JVM 有效

  3. 分布式锁实现的关键
    抢锁机制
    怎么保证同一时间只有 1 个服务器能抢到锁?
    核心思想 就是:先来的人先把数据改成自己的标识(服务器 ip),后来的人发现标识已存在,就抢锁失败,继续等待。
    等先来的人执行方法结束,把标识清空,其他的人继续抢锁。
    MySQL 数据库:select for update 行级锁(最简单)(乐观锁)
    ✔ Redis 实现:内存数据库,读写速度快 。支持 setnx、lua 脚本,比较方便我们实现分布式锁。
    setnx:set if not exists 如果不存在,则设置;只有设置成功才会返回 true,否则返回 false

  4. 注意事项

  5. 用完锁要释放(腾地方)√

  6. 锁一定要加过期时间 √

  7. 如果方法执行时间过长,锁提前过期了?问题:
    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) {
   // set lock B
   del lock
}

Redis + lua 脚本实现
Redisson–红锁(Redlock)–使用/原理

  1. Redisson 实现分布式锁
    Java 客户端,数据网格
    实现了很多 Java 里支持的接口和数据结构
    Redisson 是一个 java 操作 Redis 的客户端,提供了大量的分布式数据集来简化对 Redis 的操作和使用,可以让开发者像使用本地集合一样使用 Redis,完全感知不到 Redis 的存在。

2 种引入方式

  1. spring boot starter 引入(不推荐,版本迭代太快,容易冲突)
  2. 直接引入
    示例代码
1
2
3
4
5
6
7
8
9
10
11
12
// list,数据存在本地 JVM 内存中
List<String> list = new ArrayList<>();
list.add("yupi");
System.out.println("list:" + list.get(0));

list.remove(0);

// 数据存在 redis 的内存中
RList<String> rList = redissonClient.getList("test-list");
rList.add("yupi");
System.out.println("rlist:" + rList.get(0));
rList.remove(0);
  1. 定时任务 + 锁
    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)) {
// todo 实际要执行的方法
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();
}
}
}
  1. 看门狗机制
    redisson 中提供的续期机制
    开一个监听线程,如果方法还没执行完,就帮你重置 redis 锁的过期时间。
    原理:
  2. 监听当前线程,默认过期时间是 30 秒,每 10 秒续期一次(补到 30 秒)
  3. 如果线程挂掉(注意 debug 模式也会被它当成服务器宕机),则不会续期
    Redisson 分布式锁的watch dog自动续期机制
    Zookeeper 实现(不推荐)

redission实现分布式锁

  1. 引入依赖
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;
/**
* Redisson 配置
*/
@Configuration
@ConfigurationProperties(prefix = "spring.redis")
@Data
public class RedissonConfig {
private String host;
private String port;

@Bean
public RedissonClient redissonClient() {
// 1. 创建配置
Config config = new Config();
String redisAddress = String.format("redis://%s:%s", host, port);
// 使用单个Redis,没有开集群 useClusterServers() 设置地址和使用库
config.useSingleServer().setAddress(redisAddress).setDatabase(3);
// 2. 创建实例
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,数据存在本地 JVM 内存中
List<String> list = new ArrayList<>();
list.add("shier");
System.out.println("list:" + list.get(0));

// list.remove(0);

// 数据存在 redis 的内存中
RList<String> rList = redissonClient.getList("test-list");
rList.add("shier");
System.out.println("rlist:" + rList.get(0));
// rList.remove(0);

// map
Map<String, Integer> map = new HashMap<>();
map.put("shier", 10);
map.get("shier");

RMap<Object, Object> map1 = redissonClient.getMap("test-map");
}
}

定时任务 + 锁

  1. 修改定时任务
    waitTime 设置为 0,只抢一次,抢不到就放弃
  2. 注意释放锁要写在 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

业务流程:

  1. 生成分享链接(分享二维码)
  2. 用户访问链接,可以点击加入
  3. 队伍人满后发送消息通知 P1

系统(接口)设计

  1. 创建队伍
  • 用户可以创建队伍(设置人数、队伍名、描述、超时时间),最多5个
  • 队长、剩余的人数
  • 公开/ private/ 加密

信息流中不展示已过期的队伍
请求参数是否为空
是否登录,未登录不允许创建

校验信息
a队伍人数 > 1 且 <= 20
b队伍标题 <= 20
c描述 <= 512
d status 是否公开(int)不传默认为 0(公开)
e如果 status 是加密状态,一定要有密码,且密码 <= 32
f超时时间 > 当前时间
g校验用户最多创建 5 个队伍
插入队伍信息到队伍表
插入用户 => 队伍关系到关系表

  1. 查询队伍列表
    分页展示队伍列表,根据名称、最大人数等搜索队伍 P0,信息流中不展示已过期的队伍
    1从请求参数中取出队伍名称等查询条件,如果存在则作为查询条件
    2不展示已过期的队伍(根据过期时间筛选)
    3可以通过某个关键词同时对名称和描述查询
    4只有管理员才能查看加密还有非公开的房间
    5关联查询已加入队伍的用户信息
    6关联查询已加入队伍的用户信息(可能会很耗费性能,建议大家用自己写 SQL 的方式实现)
1
2
3
4
5
6
7
8
// 1. 自己写 SQL
// 查询队伍和创建人的信息
// select * from team t left join user u on t.userId = u.id
// 查询队伍和已加入队伍成员的信息
// select *
// from team t
// left join user_team ut on t.id = ut.teamId
// left join user u on ut.userId = u.id;
  1. 修改队伍信息
    1判断请求参数是否为空
    2查询队伍是否存在
    3只有管理员或者队伍的创建者可以修改
    4如果用户传入的新值和老值一致,就不用 update 了(可自行实现,降低数据库使用次数)
    5如果队伍状态改为加密,必须要有密码
    6更新成功

  2. 用户可以加入队伍
    其他人、未满、未过期,允许加入多个队伍,但是要有个上限 P0
    1用户最多加入 5 个队伍
    2队伍必须存在,只能加入未满、未过期的队伍
    3不能加入自己的队伍,不能重复加入已加入的队伍(幂等性)
    4禁止加入私有的队伍
    5如果加入的队伍是加密的,必须密码匹配才可以
    6新增队伍 - 用户关联信息
    注意,一定要加上事务注解!!!!

  3. 用户可以退出队伍
    请求参数:队伍 id
    1校验请求参数
    2校验队伍是否存在
    3校验我是否已加入队伍
    4如果队伍
    a只剩一人,队伍解散
    b还有其他人
    ⅰ如果是队长退出队伍,权限转移给第二早加入的用户 —— 先来后到只用取 id 最小的 2 条数据
    ⅱ非队长,自己退出队伍

  4. 队长可以解散队伍
    请求参数:队伍 id
    业务流程:
    1校验请求参数
    2校验队伍是否存在
    3校验你是不是队伍的队长
    4移除所有加入队伍的关联信息
    5删除队伍

  5. 获取当前用户已加入的队伍

  6. 获取当前用户创建的队伍
    复用 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)

  1. 复制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自带的

  1. 新建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 {
/**
* id
*/
private Long id;

/**
* id 列表
*/
private List<Long> idList;

/**
* 搜索关键词(同时对队伍名称和描述搜索)
*/
private String searchText;

/**
* 队伍名称
*/
private String name;

/**
* 描述
*/
private String description;

/**
* 最大人数
*/
private Integer maxNum;

/**
* 用户id
*/
private Long userId;

/**
* 0 - 公开,1 - 私有,2 - 加密
*/
private Integer status;
}
  1. 在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;
}
  1. 细化接口(根据具体的需求)
    这边我们会运用到队伍的状态,即公开,私有等
    所以提前写一个队伍状态枚举类
    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;
}
}
  1. 正式开始细化接口
    在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) {
//1.请求参数是否为空
if (team == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
//2.是否登录,未登录不允许创建
if (loginUser == null) {
throw new BusinessException(ErrorCode.NO_AUTH);
}
final long userId = loginUser.getId();
//3.检验信息
//(1).队伍人数>1且<=20
int maxNum = Optional.ofNullable(team.getMaxNum()).orElse(0);//如果为空,直接赋值为0
if (maxNum < 1 || maxNum > 20) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "队伍人数不满足要求");
}
//(2).队伍标题 <=20
String name = team.getName();
if (StringUtils.isBlank(name) || name.length() > 20) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "队伍标题不满足要求");
}
// 3. 描述<= 512
String description = team.getDescription();
if (StringUtils.isNotBlank(description) && description.length() > 512) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "队伍描述过长");
}
//4.status 是否公开,不传默认为0
int status = Optional.ofNullable(team.getStatus()).orElse(0);
TeamStatusEnum statusEnum = TeamStatusEnum.getEnumByValue(status);
if (statusEnum == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "队伍状态不满足要求");
}

//5.如果status是加密状态,一定要密码 且密码<=32
String password = team.getPassword();
if (TeamStatusEnum.SECRET.equals(statusEnum)) {
if (StringUtils.isBlank(password) || password.length() > 32) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "密码设置不正确");
}
}
//6.超出时间 > 当前时间
Date expireTime = team.getExpireTime();
if (new Date().after(expireTime)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "超出时间 > 当前时间");
}

//7.校验用户最多创建5个队伍
//todo 有bug。可能同时创建100个队伍
QueryWrapper<Team> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("userId", userId);
long hasTeamNum = this.count(queryWrapper);
if (hasTeamNum >= 5) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户最多创建5个队伍");
}
//8.插入队伍消息到队伍表
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, "创建队伍失败");
}
//9. 插入用户 ==> 队伍关系 到关系表
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;
}
  1. 优化完成控制层
    这里我们因为完善了业务层,所以在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;

/**
* 用户id
*/
private Long userId;

/**
* 0 - 公开,1 - 私有,2 - 加密
*/
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);
}
  1. 测试
    启动项目,好像刚刚的请求参数封装类没起作用(这里跟鱼皮的一样),只能自己删除再输入
    成功的插入
    ps:这里过期时间的获取可从控制台输入一下代码来实现,单单的输入年月日会导致数据库里的时间增加8小时(应该是时区的问题)
    多次发送添加请求,当插入5次之后,再插入会报错
    测试事务是否起作用
    注意要在方法上添加如下注解
    @Transactional(rollbackFor = Exception.class)
    首先数据库的数据删至4条,别忘了把对应的用户表关系也删除,保持一致
    修改TeamServiceImpl,目的是骗过编译器,直接创建队伍失败
    稍微修改下参数,发送,确实报用户关系失败
    数据库里也没用增加数据,证实了事务起作用,最后别忘了把刚刚增加的代码所删除(骗过编译器)

完善查询,更新,加入队伍接口

  1. 为了保护数据不被暴露,所以新建封装类
    新建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
/**
* 队伍和用户信息封装类(脱敏)
*
* @author yupi
*/
@Data
public class TeamUserVO implements Serializable {

private static final long serialVersionUID = 163478861968488713L;
/**
* id
*/
private Long id;

/**
* 队伍名称
*/
private String name;

/**
* 描述
*/
private String description;

/**
* 最大人数
*/
private Integer maxNum;

/**
* 过期时间
*/
private Date expireTime;

/**
* 用户id
*/
private Long userId;

/**
* 0 - 公开,1 - 私有,2 - 加密
*/
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 {

/**
* id
*/
private long id;

/**
* 用户昵称
*/
private String username;

/**
* 账号
*/
private String userAccount;

/**
* 用户头像
*/
private String avatarUrl;

/**
* 性别
*/
private Integer gender;

/**
* 电话
*/
private String phone;

/**
* 邮箱
*/
private String email;

/**
* 标签列表 json
*/
private String tags;

/**
* 状态 0 - 正常
*/
private Integer userStatus;

/**
* 创建时间
*/
private Date createTime;

/**
*
*/
private Date updateTime;

/**
* 用户角色 0 - 普通用户 1 - 管理员
*/
private Integer userRole;

private static final long serialVersionUID = 1L;
}
  1. 在TeamService里编写查询队伍方法并在TeamServiceImpl里实现
    首先在TeamService里面实现listTeams方法并实现
    编写业务层,只有管理员才能查看加密还有非公开的房间,所以需要从请求中获得是否为管理员
1
2
3
4
5
6
7
8
/**
* 搜索队伍
*
* @param teamQuery
* @param isAdmin
* @return
*/
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());
}

//不展示已过期的队伍
//expireTime is null or expireTime > now()
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);
}
  1. 在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
/**
* 用户登录请求体
*
* @author shaosao
*/
@Data
public class TeamUpdateRequest implements Serializable {

private static final long serialVersionUID = -6043915331807008592L;

/**
* id
*/
private Long id;

/**
* 队伍名称
*/
private String name;

/**
* 描述
*/
private String description;

/**
* 过期时间
*/
private Date expireTime;

/**
* 0 - 公开,1 - 私有,2 - 加密
*/
private Integer status;

/**
* 密码
*/
private String password;
}
  1. 在TeamService里面实现updateTeam方法并实现
1
2
3
4
5
6
7
8
9
/**
* 更新队伍
*
* @param teamUpdateRequest
* @param loginUser
* @return
*/
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. 最后编写实现类
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, "加密房间必须要设置密码");
}
}
  1. 在TeamService里编写用户加入队伍方法并在TeamServiceImpl里实现
    同样在请求包里封装一个用户加入队伍请求体
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 用户加入队伍请求体
*
* @author shaosao
*/
@Data
public class TeamJoinRequest implements Serializable {

private static final long serialVersionUID = -24663018187059425L;

/**
* id
*/
private Long teamId;

/**
* 密码
*/
private String password;
}

在TeamService里面实现joinTeam方法并实现

1
2
3
4
5
6
7
8
/**
* 加入队伍
*
* @param teamJoinRequest
* @return
*/
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. 用户可以退出队伍
    新建退出请求体
1
2
3
4
5
6
7
8
@Data
public class TeamQuitRequest implements Serializable {
private static final long serialVersionUID = -2038884913144640407L;
/**
* id
*/
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
/**
* 退出队伍
* @param teamQuitRequest
* @param loginUser
* @return
*/
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) {
//把队伍转移给最早加入的用户
//1.查询已加入队伍的所有用户和加入时间
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
/**
* 获取某队伍当前人数
*
* @param teamId
* @return
*/
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被成功删除
到此为止退出功能基本实现

  1. 在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
/**
* 删除队伍
* @param id
* @param loginUser
* @return
*/
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
/**
* 根据 id 获取队伍信息
*
* @param teamId
* @return
*/
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成功实现,建议大家先按鱼皮的写,如果报错,就可以替换下!

前端设计

  1. 新建一个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>

  1. 设计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>
<!--这里选择Stepper不进器里的限制输入范围-->
<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>
  1. 设计队伍列表
    首先要定义队伍类型(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,
// todo 定义枚举值类型,更规范
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>(),{
//@ts-ignore
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>(), {
// @ts-ignore
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>

注意:

  1. 这里 thumb 引入了图片(显示更美观),可以把自己心仪的图片放入assets里并引入
  2. 加入队伍里面失败,写的形式是模板字符串,可自行了解
  3. 引入样式,原来的图片过于宽,要指定高度关闭自适应,这里我们使用样式穿透,不然不起作用,如果我们给部分组件引入的样式不起作用,都可以使用样式穿透!

匹配用户

用户匹配方法

前端不同页面怎么传递数据?

  1. url querystring(xxx?id=1) 比较适用于页面跳转
  2. url(/team/:id,xxx/1)
  3. hash (/team#1)
  4. localStorage
  5. context(全局变量,同页面或整个项目要访问公共变量)

随机匹配
找到有相似标签的用户
举例:
用户 A:[Java, 大一, 男]
用户 B:[Java, 大二, 男]
用户 C:[Python, 大二, 女]
用户 D:[Java, 大一, 女]

  1. 怎么匹配
    找到有共同标签最多的用户(TopN)
    共同标签越多,分数越高,越排在前面
    如果没有匹配的用户,随机推荐几个(降级方案)

编辑距离算法
最小编辑距离:字符串 1 通过最少多少次增删改字符的操作可以变成字符串 2

余弦相似度算法
(如果需要带权重计算,比如学什么方向最重要,性别相对次要)

  1. 怎么对所有用户匹配,取 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 字段。

前端页面开发

  1. 搜索框
    vant-表单组件-搜索
    复制到TeamPage页面,同时还有查询为空时,显示的无结果页面(用户页面以写过)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 搜索队伍
* @param val
* @returns {Promise<void>}
*/
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);
}

测试:搜索一个队伍,和查询一个不存在的队伍

  1. 更新页面
    复制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>(), {
// @ts-ignore
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}` : ''));
}
}

/**
* 跳转至更新队伍页
* @param id
*/
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,来获取上个页面传来的参数
定义变量id
const 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>
<!--这里选择Stepper不进器里的限制输入范围-->
<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. 查看个人已加入队伍
    (1)编写后端接口
    复用 listTeam 方法,只新增查询条件,不做修改(开闭原则)
    获取当前用户已加入的队伍
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 获取我创建的队伍
*
* @param teamQuery
* @param request
* @return
*/
@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
/*
*id列表
*/
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
/**
* 获取我加入的队伍
*
* @param teamQuery
* @param request
* @return
*/
@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);
// 取出不重复的队伍 id
// teamId userId
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([]);

/**
* 搜索队伍
* @param val
* @returns {Promise<void>}
*/
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([]);

/**
* 搜索队伍
* @param val
* @returns {Promise<void>}
*/
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里测试了一下,只能传一个状态参数,类型为整型,这代表了我们不能同查询多个状态的队伍,回到前端由于知识浅薄无法解决传参问题,只能显示公开状态的队伍。拉了鱼皮完整的代码(好像也没解决),不知道有无大佬能够解决这个问题!

  1. 退出和解散队伍
    在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
/**
* 退出队伍
* @param id
*/
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}` : ''));
}
}

/**
* 解散队伍
* @param id
*/
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
/**
* 通用删除请求
*
* @author yupi
*/
@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

余弦相似度算法
(如果需要带权重计算,比如学什么方向最重要,性别相对次要)

  1. 匹配用户后端编写
    这里使用了编辑距离算法
    把这个方法放在工具类(新建一个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
/**
* 算法工具类
*
* @author yupi
*/
public class AlgorithmUtils {

/**
* 编辑距离算法(用于计算最相似的两组标签)
*
* @param tagList1
* @param tagList2
* @return
*/
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];
}

/**
* 编辑距离算法(用于计算最相似的两个字符串)
* 原理:https://blog.csdn.net/DBC_121/article/details/104198838
*
* @param word1
* @param word2
* @return
*/
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 = "鱼皮是鱼不是狗";
// String str4 = "鱼皮是猫";
// 1
int score1 = AlgorithmUtils.minDistance(str1, str2);
// 3
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", "大二", "女");
// 1
int score1 = AlgorithmUtils.minDistance(tagList1, tagList2);
// 3
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
/**
* 获取最匹配的用户
*
* @param num
* @param request
* @return
*/
@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
/**
* 匹配用户
* @param num
* @param loginUser
* @return
*/
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())
// if (StringUtils.isBLank(userTags) || user.getId().equals(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());
// 原本顺序的 userId 列表
List<Long> userIdList = topUserPairList.stream().map(pair -> pair.getKey().getId()).collect(Collectors.toList());
QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
userQueryWrapper.in("id", userIdList);
// 1, 3, 2
// User1、User2、User3
// 1 => User1, 2 => User2, 3 => User3
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

  1. 加载loading特效
    使用骨架屏特效,放在UserListCard里面(包裹内容)
    别忘了在js里添加这两个参数
    到index的user-card-list里引用
    同时别忘了引入loading常量,并在loadData方法里,在开始和结尾处分别使loading设置为true和false

  2. 仅加入队伍和创建队伍的人能看到队伍操作按钮
    队伍操作权限控制
    加入队伍: 仅非队伍创建人、且未加入队伍的人可见
    更新队伍:仅创建人可见
    解散队伍:仅创建人可见
    退出队伍:创建人不可见,仅已加入队伍的人可见

仅加入队伍和创建队伍的人能看到队伍操作按钮(listTeam 接口要能获取我加入的队伍状态)
方案 1:前端查询我加入了哪些队伍列表,然后判断每个队伍 id 是否在列表中(前端要多发一次请求)
方案 2:在后端去做上述事情(推荐)
这里我们选择方案2

修改代码

  1. 首先为TeamUserVO太那几是否已加入队伍的字段
  2. 修改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);
// 1、查询队伍列表
List<TeamUserVO> teamList = teamService.listTeams(teamQuery, isAdmin);
final List<Long> teamIdList = teamList.stream().map(TeamUserVO::getId).collect(Collectors.toList());
// 2、判断当前用户是否已加入队伍
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);
// 已加入的队伍 id 集合
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);
}
  1. 前端导航栏死【标题】问题
    解决:使用 router.beforeEach,根据要跳转页面的 url 路径 匹配 config/routes 配置的 title 字段
    配置路由里的title字段
    在BasicLayout里增加根据路由切换标题
    同时把原来用于测试的Toast响应(请求成功)删除,全局搜索删除
    别忘了,把这句也删除
    刷新,切换到不同页面,测试标签栏是否更换,以及请求成功是否不再出现

优化、上线

  1. 强制登录,自动跳转到登录页
    解决:axios 全局配置响应拦截、并且添加重定向
    在myAxios里配置响应拦截
    这里我们要改变history 模式的实现,在main.ts里修改
    当登录成功后,重定向到个人用户页面  PS:别忘了引入route
    修改队伍页面的加入队伍按钮为创建队伍
    在TeamPage页面,修改加入队伍为创建队伍(按钮部分)
    把doJoinTeam全局修改为toAddTeam
    这个按钮太丑了,我们更换它的样式,变成圆形放在右下角
    写一个全局样式
    在main.ts中引入
    右下角的按钮:
  2. 区分公开和加密房间;加入有密码的房间,要指定密码
    在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);

/**
* 判断是不是加密房间,是的话显示密码框
* @param team
*/
const preJoinTeam = (team: TeamType) => {
joinTeamId.value = team.id;
if (team.status === 0) {
doJoinTeam()
} else {
showPasswordDialog.value = true;
}
}

const doJoinCancel = () => {
joinTeamId.value = 0;
password.value = '';
}
  1. 展示已加入队伍人数
    这个我们后端还未实现,所以在获取队伍列表接口,获取这个参数
    首先在封装类里添加字段(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);
// 1、查询队伍列表
List<TeamUserVO> teamList = teamService.listTeams(teamQuery, isAdmin);
final List<Long> teamIdList = teamList.stream().map(TeamUserVO::getId).collect(Collectors.toList());
// 2、判断当前用户是否已加入队伍
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);
// 已加入的队伍 id 集合
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) {}
// 3、查询已加入队伍的人数
QueryWrapper<UserTeam> userTeamJoinQueryWrapper = new QueryWrapper<>();
userTeamJoinQueryWrapper.in("teamId", teamIdList);
List<UserTeam> userTeamList = userTeamService.list(userTeamJoinQueryWrapper);
// 队伍 id => 加入这个队伍的用户列表
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里修改原来的最大人数为已加入人数
如果爆红的在队伍规范类型里添加字段

  1. 重复加入队伍的问题(加锁、分布式锁)并发请求时可能出现问题
    只要我们点的足够快,就可以在同一时间内往数据库插入多条同样的数据,所以这里我们使用分布式锁(推荐)
    使用两把锁,一把锁锁队伍,一把锁锁用户(实现较难,不推荐)
    修改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(免费)
后端:微信云托管(部署容器的平台,付费)(免备案!!!)