心链 — 伙伴匹配系统
搜索队伍
我们选择vant组件库里的基础搜索框,复制到TeamPage页面,同时还有查询为空时,显示的无结果页面(用户页面以写过)
因为,我们一次性挂载本质性也是搜索队伍,所以我们把代码提取出来
/*** 搜索队伍* @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
完整代码如下:
<template><divid="teamCardList"><van-cardv-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-ignoreteamList: [] 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
挂载获取之前队伍的信息
完整代码如下:
<template><div id="teamAddPage"><van-form @submit="onSubmit"><van-cell-group inset><van-fieldv-model="addTeamData.name"name="name"label="队伍名"placeholder="请输入队伍名":rules="[{ required: true, message: '请输入队伍名' }]"/><van-fieldv-model="addTeamData.description"rows="4"autosizelabel="队伍描述"type="textarea"placeholder="请输入队伍描述"/><van-fieldis-linkreadonlyname="datetimePicker"label="过期时间":placeholder="addTeamData.expireTime ?? '点击选择过期时间'"@click="showPicker = true"/><van-popup v-model:show="showPicker" position="bottom"><van-datetime-pickerv-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-fieldv-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 方法,只新增查询条件,不做修改(开闭原则)
获取当前用户已加入的队伍
/*** 获取我加入的队伍** @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 userIdMap<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方法,加一层校验
加入队伍列表是经过去重的
获取当前用户创建的队伍
/*** 获取我加入的队伍* @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=> 3Map<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如下:
<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:别忘了在路由里面添加这两个页面 因为我们把原来的用户页面改为用户更新页面,路由里也要修改
查询加入队伍页面
<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>
查询创建队伍页面
<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里写入按钮的方法
/*** 退出队伍* @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
/*** 通用删除请求** @author yupi*/
@Data
public class DeleteRequest implements Serializable {private static final long serialVersionUID = 1787902631969457554L;private long id;
}
修改删除接口
@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
:::
/**
* 算法工具类
*
* @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];}
}
测试方法
/**
* 算法工具类测试
*/
public class AlgorithmUtilsTest {@Testvoid test() {String str1 = "阿尼是狗";String str2 = "阿尼不是狗";String str3 = "阿尼是鱼不是狗";// String str4 = "阿尼是猫";// 1int score1 = AlgorithmUtils.minDistance(str1, str2);// 3int score2 = AlgorithmUtils.minDistance(str1, str3);System.out.println(score1);System.out.println(score2);}@Testvoid testCompareTags() {List<String> tagList1 = Arrays.asList("Java", "大一", "男");List<String> tagList2 = Arrays.asList("Java", "大一", "女");List<String> tagList3 = Arrays.asList("Python", "大二", "女");// 1int score1 = AlgorithmUtils.minDistance(tagList1, tagList2);// 3int score2 = AlgorithmUtils.minDistance(tagList1, tagList3);System.out.println(score1);System.out.println(score2);}
}
在UserService里写入matchUsers方法并实现
在UserController里写入获取最匹配的用户的接口
/*** 获取最匹配的用户** @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