心链 --- 伙伴匹配系统
搜索队伍
我们选择vant组件库里的基础搜索框,复制到TeamPage页面,同时还有查询为空时,显示的无结果页面(用户页面以写过)
因为,我们一次性挂载本质性也是搜索队伍,所以我们把代码提取出来
java
/**
* 搜索队伍
* @param val
* @returns {Promise<void>}
*/
const listTeam = async (val = '') => {
const res = await myAxios.get("/team/list", {
params: {
searchText: val,
pageNum: 1,
},
});
if (res?.code === 0) {
teamList.value = res.data;
} else {
Toast.fail('加载队伍失败,请刷新重试');
}
}
挂载和搜索框修改为下图所示:(PS:搜索直接回车就可行)
更新队伍
分析:我们的更新页面和新建队伍页面类似,所以我们直接复制TeamAddPage,创建TeamUpdateTeam页面
完善TeamCardList
我们首先在队伍页面,创建一个按钮来跳转到更新页面,但是只有当前用户是队伍创建者才可以看到次按钮,我们可以直接写在TeamCardList组件里
按钮添加
由于需要判断当前用户是否为队伍创建者,我们要获取当前用户(调用以前写的方法)
写跳转按钮的逻辑
PS:别忘了引入useRouter
完整代码如下:
typescript
<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>
修改TeamUpdateTeam
删除不能修改的组件(最大人数)和固定显示的参数(initFormData),修改提交逻辑(由于是复制得来的,千万别忘了,不然就是增加队伍了)
关键是获取之前队伍的信息。引入Route,来获取上个页面传来的参数
定义变量id
挂载获取之前队伍的信息
完整代码如下:
java
<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="datetimePicker"
label="过期时间"
:placeholder="addTeamData.expireTime ?? '点击选择过期时间'"
@click="showPicker = true"
/>
<van-popup v-model:show="showPicker" position="bottom">
<van-datetime-picker
v-model="addTeamData.expireTime"
@confirm="showPicker = false"
type="datetime"
title="请选择过期时间"
:min-date="minDate"
/>
</van-popup>
<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 {useRoute, useRouter} from "vue-router";
import myAxios from "../plugins/myAxios";
import {Toast} from "vant";
import {onMounted, ref} from "vue";
import {TeamType} from "../models/team";
const router = useRouter();
const route = useRoute();
// 展示日期选择器
const showPicker = ref(false);
const minDate = new Date();
// 需要用户填写的表单数据
const addTeamData = ref({})
const id = route.query.id;
//获取之前队伍的信息
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("队伍加载失败,请刷新重试");
}
})
// 提交
const onSubmit = async () => {
const postData = {
...addTeamData.value,
status: Number(addTeamData.value.status)
}
const res = await myAxios.post("/team/update", postData);
if (res?.code === 0 && res.data) {
Toast.success('更新成功');
router.push({
path: '/team',
replace: true,
});
} else {
Toast.success('更新失败');
}
}
</script>
<style scoped>
#teamPage {
}
</style>
获取当前用户已加入的队伍
我们查询加入的队伍需要用到id的列表,所以在Teamquery里增加idList字段
获取我加入的队伍
编写后端接口
复用 listTeam 方法,只新增查询条件,不做修改(开闭原则)
获取当前用户已加入的队伍
java
/**
* 获取我加入的队伍
*
* @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);
}
修改下listTeam方法,加一层校验
加入队伍列表是经过去重的
获取当前用户创建的队伍
java
/**
* 获取我加入的队伍
* @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 logininUser = userService.getLoginUser(request);
QueryWrapper<UserTeam> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("userId",logininUser.getId());
List<UserTeam> userTeamlist = userTeamService.list(queryWrapper);
// 取出不重复的队伍 id
//teamId userId
//1,2
//1,3
//2,3
//result
//1=> 2,3
//2=> 3
Map<Long, List<UserTeam>> listMap = userTeamlist.stream().collect(Collectors.groupingBy(UserTeam::getUserId));
ArrayList<Long> idList = new ArrayList<>(listMap.keySet());
teamQuery.setIdList(idList);
List<TeamUserVO> teamList = teamService.listTeams(teamQuery,true);
return ResultUtils.success(teamList);
}
创建前端页面
我们复制一份UserPage,命名为UserUpdatePage,修改UserPage(我们只需要当前用户,修改信息,我创建的队伍,我加入的队伍)
修改UserPage如下:
typescript
<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:别忘了在路由里面添加这两个页面 因为我们把原来的用户页面改为用户更新页面,路由里也要修改
查询加入队伍页面
typescript
<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?.code === 0) {
teamList.value = res.data;
} else {
Toast.fail('加载队伍失败,请刷新重试');
}
}
// 页面加载时只触发一次
onMounted( () => {
listTeam();
})
const onSearch = (val) => {
listTeam(val);
};
</script>
<style scoped>
#teamPage {
}
</style>
查询创建队伍页面
typescript
<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?.code === 0) {
teamList.value = res.data;
} else {
Toast.fail('加载队伍失败,请刷新重试');
}
}
// 页面加载时只触发一次
onMounted( () => {
listTeam();
})
const onSearch = (val) => {
listTeam(val);
};
</script>
<style scoped>
#teamPage {
}
</style>
退出和解散队伍
1.在TeamCardList添加两个按钮来实现这两个功能
2.在js里写入按钮的方法
typescript
/**
* 退出队伍
* @param id
*/
const doQuitTeam = async (id: number) => {
const res = await myAxios.post('/team/quit', {
teamId: id
});
if (res?.code === 0) {
Toast.success('操作成功');
} else {
Toast.fail('操作失败' + (res.description ? `,${res.description}` : ''));
}
}
/**
* 解散队伍
* @param id
*/
const doDeleteTeam = async (id: number) => {
const res = await myAxios.post('/team/delete', {
id,
});
if (res?.code === 0) {
Toast.success('操作成功');
} else {
Toast.fail('操作失败' + (res.description ? `,${res.description}` : ''));
}
}
封装一个删除请求DeleteRequest
java
/**
* 通用删除请求
*
* @author yupi
*/
@Data
public class DeleteRequest implements Serializable {
private static final long serialVersionUID = 1787902631969457554L;
private long id;
}
修改删除接口
java
@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);
}
解散队伍成功
随机匹配
为了帮大家更快地发现和自己兴趣相同的朋友
匹配 1 个还是匹配多个?答:匹配多个,并且按照匹配的相似度从高到低排序
怎么匹配?(根据什么匹配)
答:标签 tags
还可以根据 user_team 匹配加入相同队伍的用户
本质:找到有相似标签的用户
举例:
用户 A:[Java, 大一, 男]
用户 B:[Java, 大二, 男]
用户 C:[Python, 大二, 女]
用户 D:[Java, 大一, 女]
1. 怎么匹配
:::danger
- 找到有共同标签最多的用户(TopN)
- 共同标签越多,分数越高,越排在前面
- 如果没有匹配的用户,随机推荐几个(降级方案)
编辑距离算法 :https://blog.csdn.net/DBC_121/article/details/104198838
最小编辑距离:字符串 1 通过最少多少次增删改字符的操作可以变成字符串 2
余弦相似度算法:https://blog.csdn.net/m0_55613022/article/details/125683937(如果需要带权重计算,比如学什么方向最重要,性别相对次要)
:::
2. 怎么对所有用户匹配,取 TOP(下一期的内容)
:::danger
直接取出所有用户,依次和当前用户计算分数,取 TOP N(54 秒)
优化方法:
- 切忌不要在数据量大的时候循环输出日志(取消掉日志后 20 秒)
- Map 存了所有的分数信息,占用内存解决:维护一个固定长度的有序集合(sortedSet),只保留分数最高的几个用户(时间换空间)e.g.【3, 4, 5, 6, 7】取 TOP 5,id 为 1 的用户就不用放进去了
- 细节:剔除自己 √
- 尽量只查需要的数据:
- 过滤掉标签为空的用户 √
- 根据部分标签取用户(前提是能区分出来哪个标签比较重要)
- 只查需要的数据(比如 id 和 tags) √(7.0s)
- 提前查?(定时任务)
- 提前把所有用户给缓存(不适用于经常更新的数据)
- 提前运算出来结果,缓存(针对一些重点用户,提前缓存)
:::
:::danger
大数据推荐,比如说有几亿个商品,难道要查出来所有的商品?
难道要对所有的数据计算一遍相似度?
匹配通用
检索 => 召回 => 粗排 => 精排 => 重排序等等
检索:尽可能多地查符合要求的数据(比如按记录查)
召回:查询可能要用到的数据(不做运算)
粗排:粗略排序,简单地运算(运算相对轻量)
精排:精细排序,确定固定排位
:::
匹配算法
:::danger
最短距离算法
- 编辑距离算法(用于计算最相似的两组标签)
- 原理:https://blog.csdn.net/DBC_121/article/details/104198838
最小编辑距离:字符串 str1 通过最少多少次增删改字符的操作可以变成字符串str2
**余弦相似度算法:**https://blog.csdn.net/m0_55613022/article/details/125683937(如果需要带权重计算,比如学什么方向最重要,性别相对次要)
新建文件夹utils 编写距离算法AlgorithmUtils
:::
java
/**
* 算法工具类
*
* @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];
}
}
测试方法
java
/**
* 算法工具类测试
*/
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);
}
}
在UserService里写入matchUsers方法并实现
在UserController里写入获取最匹配的用户的接口
java
/**
* 获取最匹配的用户
*
* @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));
}
测试
在数据库里插入标签假数据
根据最小编辑距离算法,结果排序(排除自己)应该是顺序:46235
启动后端,在knife4j接口文档里测试matchUsers,正确用户排序也是46235