项目初始化
需求分析
- 用户去添加标签,标签的分类(要有哪些标签,怎么把标签进行分类) 学习方向 java/c++,工作/大学
- 主动搜索,允许用户根据标签去搜索其他用户
- Redis缓存
- 组队
- 创建队伍
- 加入队伍
- 根据标签查询队伍
- 邀请其他人
- 允许用户去修改标签
- 推荐
- 相似度计算算法+本地式分布式计算
技术栈
前端
- Vue3 开发框架
- Vant UI (基于Vue的移动组件库) (React版Zent)
- Vite
- nginx来单机部署
后端
- Java+Springboot框架
- SpringMVC+Mybatis+MybatisPlus
- MySQL数据库
- Redis缓存
- Swagger+Knife4j接口文档
前端项目初始化
Vant:https://vant-contrib.gitee.io/vant/#/zh-CN/home
用脚手架初始项目
yarn create vite
整合组件库 Vant
yarn add vant
按需引入组件:
yarn add @vant/auto-import-resolver unplugin-vue-components -D
配置插件:
import vue from '@vitejs/plugin-vue';
import Components from 'unplugin-vue-components/vite';
import { VantResolver } from '@vant/auto-import-resolver';
export default {
plugins: [
vue(),
Components({
resolvers: [VantResolver()],
}),
],
};
注意!
有些样式样式还是需要自己引入
前端主页+组件
导航条:展示当前页面名称:
主页搜索框=>搜索页=>搜索结果页(标签筛选页面)
内容
tab栏:
- 主页(推荐页)
- 搜索框
- banner
- 推荐信息流
- 队伍页
- 用户页(消息->考虑邮件发送方式)
添加navbar导航栏
<van-nav-bar
title="标题"
left-text="返回"
right-text="按钮"
left-arrow
@click-left="onClickLeft"
@click-right="onClickRight"
>
<template #right>
<van-icon name="search" size="18" />
</template>
</van-nav-bar>
const onClickLeft = () => history.back();
const onClickRight = () => showToast('按钮');
页面效果如下:
添加tabbar标 签栏
vue页面
<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>
ts
const active = ref("index");
const onChange = (index) => showToast(`标签 ${index}`);
效果如下
页面的切换,组件化思想
<div id="content">
<template v-if="active==='index'">
<Index/>
</template>
<template v-else-if="active==='team'">
<Team/>
</template>
</div>
数据库设计
标签的分类(要有哪些标签,怎么把标签进行分类)
标签表(分类表)
建议用标签,不要用分类,更灵活
性别:男,女
方向:Java,c++,go,前端
目标:考研,春招,秋招,社招,考公,竞赛,转行,跳槽
段位:初级,中级,高级,王者
身份:大一,大二,大三,大四,学生,待业,以就业,研一,研二,研三
状态:乐观,消极,一般,单身,已婚,有对象
标签表
字段 | 类型 | 备注 |
---|---|---|
id | int | 主键 |
tagName | varchar | 标签名,唯一,索引 |
userId | int | 上传标签的用户,普通索引 |
parentId | int | 父标签id |
isParent | tinyint | 是否为父标签 |
createTime | datetime | 创建时间 |
updateTime | datetime | 修改时间 |
isDelete | tinyint | 是否删除 |
怎么查询所有标签,并且把标签分好组? 根据父标签id查询
根据父标签查询子标签?根据id查询
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 default 0 not null comment '0-否 1-是',
createTime datetime default CURRENT_TIMESTAMP null comment '创建时间',
updateTime datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP comment '更新时间',
isDelete tinyint default 0 not null comment '是否删除'
)
comment '标签';
修改用户表
用户有哪些标签?
-
直接在用户表补充tags字段,['java','男'] 存json字符串
优点:查询方便,不用新建 关联表,标签是用户的固有属性(除了该系统,其他系统可能要用到,标签是用户的固有属性)
查询用户列表,查关系表拿到这100个用户的所有标签id,再根据标签id去查标签表
哪怕性能低,可以用缓存
缺点:用户表多一列,会有点
-
加一个关联表,记录用户和标签的关系
关联表的应用场景:查询灵活,可以正查反查
缺点:要多建一个表,多维护一个表
重点:企业大项目开发中尽量减少关联查询,很影响扩展性,而且会影响查询性能
选择第一种
alter table user
add tags varchar(1024) null comment '标签列表';
添加索引
create unique index tagName_idx
on tag (tagName);
create index userId_idx
on tag (userId);
后端接口开发
搜索标签
- 允许用户传入多个标签,多个标签都存在才搜索出来 and
- 允许用户传入多个标签,有任何一个标签存在就能搜索出来 or
两种方式:
- SQL查询 (实现简单)
- 内存查询 (灵活 ,可以通过并发进一步优化)
如果参数可以分析,根据用户的参数去选择查询方式,比如标签数
如果不可以分析,并且数据库足够,内存足够,可以并发查询,谁先返回用谁
解析JSON字符串:
序列化:把Java对象转为json
反序列化:把json转为Java对象
json序列化库:
- Fastjson alibaba (快,漏洞太多)
- gson (google )
- jackson
- kryo
<!-- https://mvnrepository.com/artifact/com.google.code.gson/gson -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.9</version>
</dependency>
代码:
/**
* 根据标签搜索用户
*
* @param tagNameList 标签列表
* @return 用户列表
*/
@Override
public List<User> searchUsersByTags(List<String> tagNameList) {
if (CollectionUtils.isEmpty(tagNameList)) {
throw new BussinessException(Code.PARAMS_ERROR);
}
//return searchUsersByTagsBySQL(tagNameList);
return searchUsersByTagsByMemory(tagNameList);
}
private List<User> searchUsersByTagsByMemory(List<String> tagNameList) {
//先查询所有用户
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
List<User> userList = userMapper.selectList(queryWrapper);
//在内存中判断:
Gson gson = new Gson();
List<User> users = userList.stream().filter(user -> {
String tagsStr = user.getTags();
if (StringUtils.isBlank(tagsStr)) {
return false;
}
Set<String> set = gson.fromJson(tagsStr, new TypeToken<Set<String>>() {
}.getType());
set = Optional.ofNullable(set).orElse(new HashSet<>());
for (String tagName : tagNameList) {
if (!set.contains(tagName)) {
return false;
}
}
return true;
}).map(this::getSafetyUser).collect(Collectors.toList());
return users;
}
@Deprecated //废弃
private List<User> searchUsersByTagsBySQL(List<String> tagNameList) {
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
for (String tagName : tagNameList) {
queryWrapper = queryWrapper.like("tags", tagName);
}
List<User> userList = userMapper.selectList(queryWrapper);
List<User> users = userList.stream().map(this::getSafetyUser).collect(Collectors.toList());
return users;
}
前端整合路由
https://router.vuejs.org/zh/installation.html
安装
vue-router
yarn add vue-router@4
配置类route.ts
import * as VueRouter from 'vue-router'
import Index from "../pages/Index/index.vue";
import Team from "../pages/Team/index.vue";
import User from "../pages/User/index.vue";
const routes = [
{path: '/', component: Index},
{path: '/team', component: Team},
{path: '/user', component: User},
]
const router = VueRouter.createRouter({
history: VueRouter.createWebHashHistory(),
routes, // `routes: routes` 的缩写
})
export default router
搜索页面
前端vue
<van-nav-bar
title="标题"
left-text="返回"
right-text="按钮"
left-arrow
@click-left="onClickLeft"
@click-right="onClickRight"
>
<template #right>
<van-icon name="search" size="18"/>
</template>
</van-nav-bar>
<div id="content">
<router-view/>
</div>
<van-tabbar v-model="active" @change="onChange" route>
<van-tabbar-item icon="home-o" name="index" to="/">主页</van-tabbar-item>
<van-tabbar-item icon="search" name="team" to="/team">队伍</van-tabbar-item>
<van-tabbar-item icon="friends-o" name="user" to="/user">个人</van-tabbar-item>
</van-tabbar>
添加选择标签组件:
<van-divider content-position="left">已选标签</van-divider>
<template v-if="activeIds.length===0">请选择标签</template>
<van-row gutter="16">
<van-col v-for="tag in activeIds">
<van-tag closeable size="small" type="primary" @close="doClose(tag)">
{{ tag }}
</van-tag>
</van-col>
</van-row>
<van-divider content-position="left">选择标签</van-divider>
<van-tree-select
v-model:active-id="activeIds"
v-model:main-active-index="activeIndex"
:items="filterTagList"
/>
ts代码
//搜索框文字
const searchText = ref('');
//已选中标签
const activeIds = ref([]);
//当前激活的标签
const activeIndex = ref(0);
const tagList = [
{
text: '性别',
children: [
{text: '男', id: '男'},
{text: '女', id: '女'},
],
},
{
text: '年级',
children: [
{text: '大一', id: '大一'},
{text: '大二', id: '大二'},
],
},
]
const filterTagList = ref(tagList);
//搜索 过滤
const onSearch = () => {
filterTagList.value = tagList.map(parentTag => {
const tempChildren = [...parentTag.children];
const tempParentTag = {...parentTag};
tempParentTag.children = tempChildren.filter(childTag => {
return childTag.text.includes(searchText.value);
});
return tempParentTag;
});
}
//清空搜索框
const onCancel = () => {
showToast('取消');
searchText.value = '';
filterTagList.value = tagList;
}
//关闭标签
const doClose = (tag) => {
activeIds.value = activeIds.value.filter(item => {
return item !== tag;
});
}
效果如下:
新建一个用户模型
user.d.ts
/**
* 用户类别
*/
export type UserType = {
id: number;
username: string;
userAccount: string;
avatarUrl?: string;
profile?: string;
gender:number;
phone: string;
email: string;
userStatus: number;
userRole: number;
planetCode: string;
tags: string;
createTime: Date;
};
个人页面
vue页面:
<template>
<van-cell title="用户名" is-link :value="user.username"
@click="toEdit('username','用户名',user.username)"/>
<van-cell title="账号" is-link :value="user.userAccount"/>
<van-cell title="头像" is-link :value="user.avatarUrl">
<VanImage :src="user.avatarUrl" height="48px" alt="cxk"/>
</van-cell>
<van-cell title="性别" is-link :value="user.gender"
@click="toEdit('gender','性别',user.gender)"/>
<van-cell title="电话" is-link :value="user.phone"
@click="toEdit('phone','电话',user.phone)"/>
<van-cell title="邮箱" is-link :value="user.email"
@click="toEdit('email','邮箱',user.email)"/>
<van-cell title="ikun编号" is-link :value="user.ikunCode"/>
<van-cell title="注册时间" is-link :value="user.createTime.toDateString()"/>
</template>
ts路由跳转传参:
<script setup lang="ts">
import {useRouter} from "vue-router";
const user = {
id: 1,
username: 'ikun',
userAccount: 'ikun',
avatarUrl: "https://s2.loli.net/2023/10/16/QRiUYmDLB2vZuE6.webp",
gender: '男',
phone: "114514",
email: "1@qq.com",
ikunCode: 1,
createTime: new Date()
}
const router = useRouter();
const toEdit = (editKey: string, editName: string, currentValue: string) => {
router.push({
path: '/user/edit',
query: {
editKey,
editName,
currentValue,
}
})
}
</script>
效果如下:
编辑页面
vue
<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>
ts逻辑:
<script setup lang="ts">
import {useRoute} from "vue-router";
import {ref} from "vue";
const route = useRoute();
const editUser = ref({
editKey: route.query.editKey,
editName: route.query.editName,
currentValue: route.query.currentValue
})
console.log(route.query)
const onSubmit = (values) => {
//todo 提交到后台
console.log('onSubmit', values)
}
</script>
效果如下:
整合接口文档
什么是接口文档?写接口信息的文档,每条接口包括:
- 请求参数
- 响应参数
- 错误码
- 接口地址
- 接口名称
- 接口类型
- 请求格式
- 备注
一般是后端或者负责人来提供,后端和前端都要用
- 便于沉淀和维护
- 便于前端和后端对接,前后端联调
- 在线测试,作为工具
怎么做:
- 手写
- 自动化接口文档生成 postman ,swagger,apifox,apipost
导入包:
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>2.0.7</version>
</dependency>
自定义配置类:
@Configuration
@EnableSwagger2WebMvc
@Profile({"dev","test"})
public class Knife4jConfiguration {
@Bean(value = "defaultApi2")
public Docket defaultApi2() {
Docket docket=new Docket(DocumentationType.SWAGGER_2)
.apiInfo(new ApiInfoBuilder()
//.title("swagger-bootstrap-ui-demo RESTful APIs")
.description("# swagger-bootstrap-ui-demo RESTful APIs")
.termsOfServiceUrl("http://www.xx.com/")
.contact("xx@qq.com")
.version("1.0")
.build())
//分组名称
.groupName("2.X版本")
.select()
//这里指定Controller扫描包路径
.apis(RequestHandlerSelectors.basePackage("com.yunfei.ikunfriend.controller"))
.paths(PathSelectors.any())
.build();
return docket;
}
}
注意:springboot2.6和swagger不兼容,需要增加配置application.yml
spring:
mvc:
pathmatch:
matching-strategy: ANT_PATH_MATCHER
访问链接:http://localhost:8080/api/doc.html
成功:
前端开发
前端页面跳转传值
- Query =>url 附加参数,传递的值长度有限
搜索页传递参数:
const doSearchResult = () => {
router.push({
path: '/user/list',
query: {
tags: activeIds.value
}
})
}
搜索结果页面获取参数:
const route = useRoute();
const {tags} = route.query;
数据库新增一列个人简介:
增加搜索接口:
@GetMapping("/search/tags")
public Result<List<User>> searchUsersByTags(@RequestParam(required = false) List<String> tagNameList) {
if (CollectionUtils.isEmpty(tagNameList)) {
throw new BussinessException(Code.PARAMS_ERROR);
}
List<User> users = userService.searchUsersByTags(tagNameList);
return ResultUtils.success(users);
}
前端整合axios
https://axios.nodejs.cn/docs/intro
yarn add axios
配置请求:
import axios from "axios";
const request = axios.create({
baseURL: "http://localhost:8080/api"
});
request.interceptors.request.use(function (config) {
console.log("发送请求")
return config;
}, function (error) {
return Promise.reject(error);
});
request.interceptors.response.use(function (response) {
console.log("响应请求")
return response;
}, function (error) {
return Promise.reject(error);
});
export default request;
发送请求:
onMounted(async () => {
const userListData = await request.get('/user/search/tags', {
params: {tagNameList: tags},
paramsSerializer: params => {
return qs.stringify(params, {indices: false})
}
}).then(function (response) {
return response.data?.data;
}).catch(function (error) {
})
if (userListData) {
userListData.forEach(user => {
if (user.tags) {
user.tags = JSON.parse(user.tags);
}
})
userList.value = userListData;
}
})
结果:
组队功能
需求分析
理想场景:
和别人一起参加竞赛,做项目,可以发起队伍或者加入别人的队伍
用户可以创建一个队伍,设置队伍的人数,队伍名称(标题),描述,超时时间
队长,剩余人数
聊天?
公开 or 加密
不展示过期的队伍
修改队伍信息
用户可以加入队伍(其他人,未满,未过期)
是否需要队长同意
用户可以退出队伍(如果是队长,权限转给第二个进入的用户)
队长可以解散队伍
邀请其他用户加入队伍,分享队伍
实现
数据库设计
队伍表team
字段 | 类型 | 说明 |
---|---|---|
id | bigint | 主键 |
name | 队伍名称 | |
description | 描述 | |
maxNum | 最大人数 | |
expireTime | 过期时间 | |
userId | 用户id | |
status | 0-公开,1-私有,2-加密 | |
password | 密码 | |
createTime | 创建时间 | |
updateTime | 更新时间 | |
isDelete | 是否删除 |
-- 队伍表
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 not null comment '过期时间',
userId bigint comment '队长id',
status int default 0 not null comment '0-公开,1-私有,2-加密 ',
password varchar(256) null comment '密码',
createTime datetime default CURRENT_TIMESTAMP null comment '创建时间',
updateTime datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP comment '更新时间',
isDelete tinyint default 0 not null comment '是否删除'
)
comment '队伍';
两个关系:
- 用户加入了哪些队 伍
- 队伍有哪些用户?
建立 用户-队伍表 user_team
字段 | 类型 | 说明 |
---|---|---|
id | 主键 | |
userId | 用户 id | |
teamId | 队伍Id | |
joinTime | 加入时间 | |
createTime | 创建时间 | |
updateTime | 更新时间 | |
isDelete | 是否删除 |
-- 队伍表
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 comment '更新时间',
isDelete tinyint default 0 not null comment '是否删除'
)
comment '用户-队伍表';
增删改查
创建队伍:
- 请求参数是否为空
- 是否登录,未登录不允许创建
- 校验信息
- 队伍
>1 且<=20
- 队伍标题
- 描述
<=512
- status是否公开
- 如果是加密,必须要有密码
- 超时时间
>
当前时间 - 校验用户最多创建五个队伍
- 队伍
- 插入队伍信息到队伍表
- 插入用户=>队伍关系到关系表
@Override
@Transactional(rollbackFor = Exception.class)
public long addTeam(Team team, User loginUser) {
//1. 请求参数是否为空
if (team == null) {
throw new BussinessException(Code.PARAMS_ERROR);
}
//2. 是否登录,未登录不允许创建
if (loginUser == null) {
throw new BussinessException(Code.NOT_LOGIN);
}
//3. 校验信息
// 1. 队伍>1 且<=20
int maxNum = Optional.ofNullable(team.getMaxNum()).orElse(0);
if (maxNum < 1 || maxNum > 20) {
throw new BussinessException(Code.PARAMS_ERROR, "队伍人数不满足要求");
}
// 2. 队伍标题
String name = team.getName();
if (StringUtils.isBlank(name) || name.length() > 20) {
throw new BussinessException(Code.PARAMS_ERROR, "队伍标题不满足要求");
}
// 3. 描述<=512
String description = team.getDescription();
if (StringUtils.isNotBlank(description) && description.length() > 512) {
throw new BussinessException(Code.PARAMS_ERROR, "队伍描述过长");
}
// 4. status是否公开
Integer status = Optional.ofNullable(team.getStatus()).orElse(0);
if (status < 0 || status > 3) {
throw new BussinessException(Code.PARAMS_ERROR, "队伍状态不满足要求");
}
// 5. 如果是加密,必须要有密码
String password = team.getPassword();
if (status.equals(TeamStatusEnum.PASSWORD)) {
if (StringUtils.isBlank(password) || password.length() > 32) {
throw new BussinessException(Code.PARAMS_ERROR, "密码设置不正确");
}
}
// 6. 超时时间>当前时间
Date expireTime = team.getExpireTime();
if (new Date().after(expireTime)) {
throw new BussinessException(Code.PARAMS_ERROR, "超时时间不正确");
}
// 7. 校验用户最多创建五个队伍
QueryWrapper<Team> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("userId", loginUser.getId());
long hasTeamCount = this.count(queryWrapper);
if (hasTeamCount >= 5) {
throw new BussinessException(Code.PARAMS_ERROR, "最多创建五个队伍");
}
//4. 插入队伍信息到队伍表
team.setId(null);
team.setUserId(loginUser.getId());
boolean save = this.save(team);
if (!save) {
throw new BussinessException(Code.PARAMS_ERROR, "创建队伍失败");
}
//5. 插入用户=>队伍关系到关系表
Long teamId = team.getId();
UserTeam userTeam = new UserTeam();
userTeam.setUserId(loginUser.getId());
userTeam.setTeamId(teamId);
userTeam.setJoinTime(new Date());
boolean save1 = userTeamService.save(userTeam);
if (!save1) {
throw new BussinessException(Code.PARAMS_ERROR, "创建队伍失败");
}
return teamId;
}
查询队伍列表
分页展示队伍列表,根据名称、最大人数等搜索队伍 P0,信息流中不展示已过期的队伍
- 从请求参数中取出队伍名称等查询条件,如果存在则作为查询条件
- 不展示已过期的队伍(根据过期时间筛选)
- 可以通过某个关键词同时对名称和描述查询
- 只有管理员才能查看加密还有非公开的房间
- 关联查询已加入队伍的用户信息
- 关联查询已加入队伍的用户信息(可能会很耗费性能,建议大家用自己写 SQL 的方式实现)
@Override
public List<TeamUserVO> listTeams(TeamQueryDTO teamQueryDto, boolean isAdmin) {
QueryWrapper<Team> queryWrapper = new QueryWrapper<>();
if (teamQueryDto != null) {
Long teamId = teamQueryDto.getId();
if (teamId != null && teamId > 0) {
queryWrapper.eq("id", teamId);
}
String name = teamQueryDto.getName();
if (StringUtils.isNotBlank(name)) {
queryWrapper.like("name", name);
}
String description = teamQueryDto.getDescription();
if (StringUtils.isNotBlank(description)) {
queryWrapper.like("description", description);
}
Integer maxNum = teamQueryDto.getMaxNum();
if (maxNum != null && maxNum > 0) {
queryWrapper.eq("maxNum", maxNum);
}
Long userId = teamQueryDto.getUserId();
if (userId != null && userId > 0) {
queryWrapper.eq("userId", userId);
}
Integer status = teamQueryDto.getStatus();
if (status == null) {
status = 0;
}
if (status > -1) {
queryWrapper.eq("status", status);
}
if (!isAdmin && !status.equals(TeamStatusEnum.PUBLIC)) {
throw new BussinessException(Code.PARAMS_ERROR, "只能查看公开的队伍");
}
String searchText = teamQueryDto.getSearchText();
if (StringUtils.isNotBlank(searchText)) {
queryWrapper.and(wrapper -> wrapper.like("name", searchText)
.or().like("description", searchText));
}
}
//不展示已过期的队伍
queryWrapper.and(wrapper -> wrapper.gt("expireTime", new Date())
.or().isNull("expireTime"));
List<Team> teamList = this.list(queryWrapper);
if (CollectionUtils.isEmpty(teamList)) {
return new ArrayList<>();
}
//关联查询用户信息
//查询队伍和已加入队伍成员信息
log.info("teamList size:{}", teamList.size());
List<TeamUserVO> teamUserVOList = new ArrayList<>();
for (Team team : teamList) {
Long userId = team.getUserId();
if (userId == null) {
continue;
}
User user = userService.getById(userId);
if (user == null) {
continue;
}
TeamUserVO teamUserVO = new TeamUserVO();
BeanUtils.copyProperties(team, teamUserVO);
UserVO userVO = new UserVO();
BeanUtils.copyProperties(user, userVO);
teamUserVO.setCreateUser(userVO);
teamUserVOList.add(teamUserVO);
}
return teamUserVOList;
}