心链12-----队伍页业务完善+匹配算法实现随机匹配(最短距离算法)

心链 --- 伙伴匹配系统

搜索队伍

我们选择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

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

编辑距离算法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 秒)

优化方法:

  1. 切忌不要在数据量大的时候循环输出日志(取消掉日志后 20 秒)
  2. Map 存了所有的分数信息,占用内存解决:维护一个固定长度的有序集合(sortedSet),只保留分数最高的几个用户(时间换空间)e.g.【3, 4, 5, 6, 7】取 TOP 5,id 为 1 的用户就不用放进去了
  3. 细节:剔除自己 √
  4. 尽量只查需要的数据:
    1. 过滤掉标签为空的用户 √
    2. 根据部分标签取用户(前提是能区分出来哪个标签比较重要)
    3. 只查需要的数据(比如 id 和 tags) √(7.0s)
  5. 提前查?(定时任务)
    1. 提前把所有用户给缓存(不适用于经常更新的数据)
    2. 提前运算出来结果,缓存(针对一些重点用户,提前缓存)
      :::
      :::danger
      大数据推荐,比如说有几亿个商品,难道要查出来所有的商品?
      难道要对所有的数据计算一遍相似度?
匹配通用

检索 => 召回 => 粗排 => 精排 => 重排序等等

检索:尽可能多地查符合要求的数据(比如按记录查)

召回:查询可能要用到的数据(不做运算)

粗排:粗略排序,简单地运算(运算相对轻量)

精排:精细排序,确定固定排位

:::

匹配算法

:::danger

最短距离算法

新建文件夹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

相关推荐
GanGuaGua1 小时前
Linux系统:线程的互斥和安全
linux·运维·服务器·c语言·c++·安全
lsnm1 小时前
【LINUX网络】IP——网络层
linux·服务器·网络·c++·网络协议·tcp/ip
多恩Stone1 小时前
【3DV 进阶-2】Hunyuan3D2.1 训练代码详细理解下-数据读取流程
人工智能·python·算法·3d·aigc
不掰手腕1 小时前
在UnionTech OS Server 20 (统信UOS服务器版) 上离线安装PostgreSQL (pgsql) 数据库
linux·数据库·postgresql
Lynnxiaowen2 小时前
今天继续昨天的正则表达式进行学习
linux·运维·学习·正则表达式·云计算·bash
努力学习的小廉2 小时前
深入了解linux系统—— POSIX信号量
linux·运维·服务器
刘一说2 小时前
CentOS部署ELK Stack完整指南
linux·elk·centos
从零开始的ops生活2 小时前
【Day 50 】Linux-nginx反向代理与负载均衡
linux·nginx
IT成长日记2 小时前
【Linux基础】Linux系统配置IP详解:从入门到精通
linux·运维·tcp/ip·ip地址配置
惯导马工2 小时前
【论文导读】IDOL: Inertial Deep Orientation-Estimation and Localization
深度学习·算法