伙伴匹配系统项目笔记

文章目录

本项目线上地址:http://www.academicnav.cn

本项目遇到的问题或者是一些重点

1、vant4中使用Toast需要注意

image-20240419164518368

2、在axios进行参数利用 get 传递到后端出现url参数错误【这个问题使后端接收不到】

image-20240419173524452

【注意看这个url中的 ?中的请求参数,我们需要的是tagList=[“杭州”,“温州”]这种形式,所以后端接收不到】

解决方法:【在百度搜 axiox get 数组】

myAxois.get('/user/search/tags',{
    params:{
        tagList:tags
    },
    paramsSerializer:params =>  {
        return qs.stringify(params,{arrayFormat: "repeat"})
    }

下面是对qs.stringify的参数介绍:【我们这个项目需要改为第三种结果,所以选择 ‘repeat’】

image-20240419181428065

3、JWT的优缺点,做登录注册这样的功能不建议使用JWT,而是用redis进行session共享。

JWT的优缺点文章:https://zhuanlan.zhihu.com/p/108999941

4、在第五期的时候,遇到一个跨域问题,解决方法是后端的@CrossOrigin要加上(allowCredentials = “true”)

5、注意前端在进行axios发请求到后端的时候,传到后端的字段例如(teamId:id),注意这里的teamId是后端的字段,而id是前端参数字段

6、解决 Webstrom 中写Vue没有代码提示如何解决?https://www.cnblogs.com/wenqiangit/p/9889402.html

伙伴匹配项目第一期

项目介绍:在此项目,用户可以在线匹配寻找自己志同道合的朋友进行学习

需求分析

1、用户去添加标签、标签的分类(要有哪些标签、怎么把标签进行分类),例如:学习方向 java/c++,工作 / 大学

2、主动搜索:允许用户根据标签去搜索其他用户【使用redis缓存】

3、组队

  1. 创建队伍
  2. 加入队伍
  3. 根据标签查询队伍
  4. 邀请其他人

4、允许用户去修改标签

前端初始化

1、以管理员命令输入 下面命令创建 vite + vue3 项目,【vite为打包工具】

npm create vite@latest

2、进入创建好的 PartnerMatch 项目,执行下面命令下载依赖

npm install

3、执行以下命令,导入安装vant

npm i vite-plugin-style-import@1.4.1 -D
npm i vant

前端主页设计

导航条:展示但钱主页面名称

主页搜索框

内容

tab栏:

  • 主页(推荐页 + 广告)
    • 搜索框
    • banner
    • 推荐信息流
  • 队伍页
  • 用户页

前端主页具体实现【使用vant3组件库进行前端设计】

vant3官方文档:https://vant-contrib.gitee.io/

首先先在src目录下创建一个layout的包用来存放组件

1、创建NavBar

  1. 创建BasicLayout,先创建NavBar,在文档中搜索NavBar组件,选择一个合适的样式,

    <template>
        <van-nav-bar
                title="标题"
                right-text="按钮"
                left-arrow
                @click-left="onClickLeft"
                @click-right="onClickRight"
        >
            <!--使用插槽,在右侧创建一个搜索图标-->
            <template #right>
                <van-icon name="search" size="18" />
            </template>
        </van-nav-bar>
    </template>
    
    <script setup>
        import { Toast } from 'vant';
        const onClickLeft = () => alert("左");
        const onClickRight = () => Toast('按钮');
    </script>
    

    并且在main.js中导入引入组件依赖。

  2. 在App.vue中导入BasicLayout。

创建TabBarvue

<van-tabbar v-model="active" @change="onChange">
    	<!--上面的@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="self">个人</van-tabbar-item>
</van-tabbar>

注意这里的name用来表示这个标签。

创建Index.vue 和 Team.vue 页面,并实现两个页面的切换

  1. 创建page包用于存放页面,创建Index.vue 和 Team.vue 页面
<div id="content">
    <template v-if="active === 'index'">
		<Index></Index>
    </template>
    <template v-if="active === 'team'">
		<Team></Team>
    </template>
</div>

设计数据库表

标签的分类(要有哪些标签、怎么把标签进行分类)

标签表

  • 建议用标签,不要用分类,更灵活。

  • 性别:男、女

  • 方向:Java、C++、Go、前端

  • 正在学:Spring

  • 目标:考研、春招、秋招、社招、考公、竞赛(蓝桥杯)、转行、跳槽

  • 段位:初级、中级、高级、王者

  • 身份:小学、初中、高中、大一、大二、大三、大四、学生、待业、已就业、研一、研二、研三

  • 状态:乐观、有点丧、一般、单身、已婚、有对象

【用户自己定义标签】?

字段:

  • id int 主键

  • 标签名 varchar 非空(必须唯一,唯一索引)

  • 上传标签的用户 userId int(如果要根据 userId 查已上传标签的话,最好加上,普通索引)

  • 父标签 id ,parentId,int(分类)

  • 是否为父标签 isParent, tinyint(0 不是父标签、1 - 父标签)

  • 创建时间 createTime,datetime

  • 更新时间 updateTime,datetime

  • 是否删除 isDelete, tinyint(0、1)

怎么查询所有标签,并且把标签分好组?按父标签 id 分组,能实现 √

根据父标签查询子标签?根据 id 查询,能实现 √

添加索引的方式

image-20240407210339859

修改用户表

用户有哪些标签?

有两种方法修改用户表。

  1. 直接在用户表补充 tags 字段,**[‘Java’, ‘男’] 存 json 字符串 **

    优点:查询方便、不用新建关联表,标签是用户的固有属性(除了该系统、其他系统可能要用到,标签是用户的固有属性)节省开发成本

    查询用户列表,查关系表拿到这 100 个用户有的所有标签 id,再根据标签 id 去查标签表。

    哪怕性能低,可以用缓存。

    缺点:用户表多一列,会有点

  2. 加一个关联表,记录用户和标签的关系

    关联表的应用场景:查询灵活,可以正查反查

    缺点:要多建一个表、多维护一个表

    重点:企业大项目开发中尽量减少关联查询,很影响扩展性,而且会影响查询性能

这里的解决方法:直接在用户表补充tags字段。【优点:查询方便、不用新建关联表,标签是用户的固有属性,节约开发成本】

初始化后端和开发后端接口

在用户中心的基础上进行修改。

开发后端根据标签搜索用户接口

搜索标签

  1. 允许用户传入多个标签,多个标签都存在才搜索出来 and。【like ‘%Java%’ and like ‘%C++%’】
  2. 允许用户传入多个标签,有任何一个标签存在就能搜索出来 or。【like ‘%Java%’ or like ‘%C++%’】

具体做法:

  1. 第一种方法使用SQL查询
  2. 使用内存查询

先在user表中添加 tags 字段表示标签列表(是一个json)

tags         varchar(1024)                      null comment '标签 json 列表'

使用SQL进行查询【模糊查询】

在service包下修改UserService和UserServiceImpl,添加一个方法【根据标签搜索用户】(这是使用数据库查询的方法)

/**
 * 根据标签搜索用户
 * @param tagNameList:用户要拥有的标签
 * @return
 */
@Override
public List<User> searchUserByTags(List<String> tagNameList) {
    //判断是否为空
    if(CollectionUtils.isEmpty(tagNameList)){
        throw new BusinessException(ErrorType.PARAMS_ERROR);
    }

    QueryWrapper<User> queryWrapper = new QueryWrapper<>();

    for(String tagName : tagNameList){
        queryWrapper = queryWrapper.like("tags",tagName);
    }

    List<User> userList = userMapper.selectList(queryWrapper);

    //利用函数式接口对userList进行脱敏,并返回。
    return userList.stream().map(this::getSafeUser).collect(Collectors.toList());
}

使用内存的查询方法如下,因为需要将字符串转化成json,所以先导入依赖

【把数据库中user表的所有数据取出来,在内存中判断这个标签是否包含我们要搜索的标签】

        <!-- https://mvnrepository.com/artifact/com.google.code.gson/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> searchUserByTags(List<String> tagNameList) {
        //判断是否为空
        if(CollectionUtils.isEmpty(tagNameList)){
            throw new BusinessException(ErrorType.PARAMS_ERROR);
        }
        //在内存中进行查询的写法
        //1、先查询所有的用户
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        List<User> userList = userMapper.selectList(queryWrapper);
        Gson gson = new Gson();

        //函数式编程,使用filter过滤
        return userList.stream().filter((user) -> {
            String tagsStr = user.getTags();
            if(StringUtils.isBlank(tagsStr)){
                return false;
            }
            //使用Gson将字符串转化为json
            Set<String> tempTagNameSet = gson.fromJson(tagsStr,new TypeToken<Set<String>>(){}.getType());
            for(String tagName : tempTagNameSet) {
                if (!tempTagNameSet.contains(tagName)) {
                    return false;
                }
            }
            return true;
        }).map(this::getSafeUser).collect(Collectors.toList());
        //利用函数式接口对userList进行脱敏,并返回。
//        return userList.stream().map(this::getSafeUser).collect(Collectors.toList());
    }

3、进行测试,在test中对上面新增的方法进行测试,使用断言

@Test
public void searchUserByTags(){
    List<String> tagNameList = Arrays.asList("java","python");
    List<User> userList = userService.searchUserByTags(tagNameList);
    Assert.assertNotNull(userList);
}

4、因为用户表user新增了一个字段tags,所以有以下地方需要改动。

  1. 首先是UserMapper.xml,在resultMap中添加

    <result property="tags" column="tags" jdbcType="VARCHAR"/>
    
  2. 在User实体类中,添加tags属性。

  3. 在UserServiceImpl的用户脱敏的方法中,将tags字段也进行脱敏。

简历要点

上面编写的根据标签搜索用户的接口中提到了两种方式进行搜索查询,1是连接数据库的查询,2是在内存中进行查询,

  1. SOL查询(实现简单,可以通过拆分查询进一步优化)
  2. 内存查询(灵活,可以通过并发一步优化)
  • 如果参数可以分析,根据用户的参数去选择查询方式,比如标签数
  • 如果参数不可分析,并且数据库连接足够,内存空间足够,可以并发同时查询,谁先返回用谁。

还可以SQL查询与内存计算相结合,比如SQL过滤掉部分tag(建议通过实际测试来分析哪种查询比较快。)

伙伴匹配项目第二期

本期的大致内容:

  1. 前端开发(搜索页面,用户信息页,用户信息修改页)
  2. 前端整合路由
  3. 后端整合Swagger + Knife4j接口文档
  4. 存量用户信息导入及同步(爬虫)

配置路由

1、在项目中安装路由:

npm install vue-router@4

2、在src中新增一个config包用来写一个全局配置的类,在config包中写一个route.ts,这个文件写路由配置

// 2. 定义一些路由
// 每个路由都需要映射到一个组件。
// 我们后面再讨论嵌套路由。
import Index from "../page/Index.vue";
import Team from "../page/Team.vue";

const routes = [
    { path: '/', component: Index },
    { path: '/team', component: Team },
]

export default routes

3、在main.ts中引入router并挂载router

// 你可以在这里输入更多的配置,但我们在这里
// 暂时保持简单
const router = VueRouter.createRouter({
    // 4. 内部提供了 history 模式的实现。为了简单起见,我们在这里使用 hash 模式。
    history: VueRouter.createWebHashHistory(),
    routes, // `routes: routes` 的缩写
})

app.use(router)

4、因为我们要在下面三个按钮中实现不同页面的跳转,由于这是使用了vant组件,所以查看官方文档搜索router就发现我们应该如何使用。

image-20240411144427117

image-20240411144401400

开发搜索页

1、同样的,设计一个页面就去找组件,到vant官网去找Search的组件。在page中新建一个search.vue,这里写搜索页面的代码,

这里是官方文档的内容:

Search 组件提供了 searchcancel 事件,search 事件在点击键盘上的搜索/回车按钮后触发,cancel 事件在点击搜索框右侧取消按钮时触发。

<form action="/">
  <van-search
    v-model="value"
    show-action
    placeholder="请输入搜索关键词"
    @search="onSearch"
    @cancel="onCancel"
  />
</form>
import { ref } from 'vue';
import { Toast } from 'vant';

export default {
  setup() {
    const value = ref('');
    const onSearch = (val) => Toast(val);
    const onCancel = () => Toast('取消');
    return {
      value,
      onSearch,
      onCancel,
    };
  },
};

我们进行以下修改:

<template>
    <van-search
            v-model="searchText"
            show-action
            placeholder="请输入搜索的标签"
            @search="onSearch"
            @cancel="onCancel"
    />
</template>

//这里在script标签中加入setup,是vue3的一个语法糖,不用export default ,setup()和将变量return。
<script setup>
    import {Toast} from "vant";
    import { ref } from 'vue';

    const searchText = ref('');
    const onSearch = (val) => Toast(val);
    const onCancel = () => Toast('取消');
</script>

<style scoped>

</style>

2、因为我们要点击搜索图标跳转到搜索页

image-20240411153547452

所以我们要在BasicLayout中进行修改:

image-20240411153641309

使用router.push即能跳转到指定的页面路径,但是router需要引用一个userRouter,并且得实例化useRouter

import {useRouter} from "vue-router";

    const router = useRouter();

    const onClickRight = () => {
        router.push("/search")
    };

进行搜索标签的设计

vant3中选择组件:TreeSelect组件的多选模式

active-id 为数组格式时,可以选中多个右侧选项。

<van-tree-select
  v-model:active-id="activeIds"
  v-model:main-active-index="activeIndex"
  :items="items"
/>
import { ref } from 'vue';

export default {
  setup() {
    const activeId = ref([1, 2]);
    const activeIndex = ref(0);
      //这些数据应从后端获取,先定义假数据
    const items = [
      {
        text: '浙江',
        children: [
          { text: '杭州', id: 1 },
          { text: '温州', id: 2 },
        ],
      },
      {
        text: '江苏',
        children: [
          { text: '南京', id: 4 },
          { text: '无锡', id: 5 },
          { text: '徐州', id: 6 },
        ],
      },
    ];

    return {
      items,
      activeId,
      activeIndex,
    };
  },
};

该组件运用在代码中,

定义选中标签展示的形式:

可关闭标签

添加 closeable 属性表示标签是可关闭的,关闭标签时会触发 close 事件,在 close 事件中可以执行隐藏标签的逻辑。

<van-tag :show="show" closeable size="medium" type="primary" @close="close">
  标签
</van-tag>
import { ref } from 'vue';

export default {
  setup() {
    const show = ref(true);
    const close = () => {
      show.value = false;
    };

    return {
      show,
      close,
    };
  },
};

最后完善 根据tagList进行搜索取消按钮关闭标签功能,具体看代码。

搜索页效果:

image-20240411221533680

开发用户页

使用的组件是:

展示箭头

设置 is-link 属性后会在单元格右侧显示箭头,并且可以通过 arrow-direction 属性控制箭头方向。

<van-cell title="单元格" is-link />
<van-cell title="单元格" is-link value="内容" />
<van-cell title="单元格" is-link arrow-direction="down" value="内容" />

首先用户页面就是展示后端用户的数据,这部分我们先根据后端的字段,定义一些假数据。

首先再src包下定义一个models包,这里定义一些实体类对象(例如user,用户),根据之前用户表填写字段并export

image-20240411221107166

这样我们就拥有一个user用户的实体类,接下来在User.vue中就能根据这个实体类创建一些假数据,

image-20240411221145575

最后使用上面组件,利用 :value 展示user的数据,点击箭头图标跳转到编辑区,这里使用 to="路径"进行跳转,

image-20240411221411756

开发用户修改页面【可供用户修改自己的个人信息】

image-20240412161013342

首先先创建一个用户修改的页面UserEdit,每点击一次箭头就跳转到UserEdit,同样的需要将UserEdit注册到 router.ts 路由中。

UserEdit中我们使用表单来展示所要修改的用户数据,并且将修改的数据以表单的形似提交给后端。【使用vant3的表单组件】

<van-form @submit="onSubmit">
    <van-field
      v-model="username"
      name="用户名"
      label="用户名"
      placeholder="用户名"
      :rules="[{ required: true, message: '请填写用户名' }]"
    />
  <div style="margin: 16px;">
    <van-button round block type="primary" native-type="submit">
      提交
    </van-button>
  </div>
</van-form>

那么问题来了,我们怎么样才能将User页面中的信息传递到UserEdit中供用户修改呢和应该怎么进行路由的跳转呢?,这时我们就要使用vue中@click方法进行事件的绑定,定义一个方法,我命名为toEdit(),并需要三个参数把用户的信息传递到事件中。【这些都是假数据,之后都需要修改】

以用户名为例:

<van-cell title="用户名" is-link :value="user.username" @click="toEdit('username','用户名',user.username)"/>

然后在 标签中填写相对应的事件逻辑。

记住使用路由必须在 vue-router 包中引入{useRouter},并且实例化:const router = useRouter();

使用router的push方法进行页面的跳转,path标明跳转路径,query将参数传递到UserEdit页面中,但注意下面这种写法需要在srcipt标签中添加 lang=“ts”

const router = useRouter();
//这里参数editKey、editName、currentValue对应上面三个参数。
const toEdit = (editKey:string,editName:string,currentValue:string) =>{
    router.push({
        path:'/user/edit',//跳转的页面
        query:{//将参数传递到跳转的页面
            editKey,
            editName,
            currentValue
        }
    })
}

那UserEdit如何接收User中传过来的参数呢?

我们将User传过来的参数(这些参数即用户现在的信息)封装成一个editUser,

import {useRoute} from "vue-router";
import {ref} from "vue";

const router = useRoute();

//接收用户页User传过来的参数,并将其
const editUser = ref({
    editKey:router.query.editKey,
    editName:router.query.editName,
    currentValue:router.query.currentValue,
})

展示的形式变成如下代码:

<van-field
                    v-model="editUser.currentValue"
                    :name="editUser.editKey"
                    :label="editUser.editName"
                    :placeholder="`请输入${editUser.editName}`"
            />

伙伴匹配系统第三期

后端整合 Swagger + Knife4j 接口文档

什么是接口文档?【一般由后端或者架构师提供,后端和前端都要使用】

  • 写接口信息的文档,每条接口包括:
    • 请求参数
    • 响应参数
      • 错误码
    • 接口地址
    • 接口名称
    • 请求类型
    • 备注
    • 请求格式

为什么需要接口文档?

  • 有个书面内容(背书或者归档),便于大家参考和查阅,便于沉淀和维护,拒绝口口相传
  • 接口文档便于前后端开发对接,前后端联调的介质。后端 => 接口文档 <= 前端
  • 好的接口文档支持在线调试、在线测试,可以作为工具提高我们的开发测试效率

怎么做接口文档?

  • 手写(比如腾讯文档、Markdown笔记)
  • 自动化接口文档生成:自动根据项目代码生成完整的文档或在线调试的网页。Swagger,Postman(侧重接口管理)。

具体的使用方式:

查看官方文档:https://doc.xiaominfo.com/v2/documentation/get_start.html

第一步在maven中引入依赖:

<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-spring-boot-starter</artifactId>
    <version>2.0.7</version>
</dependency>

第二步:创建Swagger配置依赖,代码如下:【直接复制粘贴,但注意要修改 .apis(RequestHandlerSelectors.basePackage(“你所写的接口在项目中的路径”))】

@Configuration
@EnableSwagger2WebMvc
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.github.xiaoymin.knife4j.controller"))
                .paths(PathSelectors.any())
                .build();
        return docket;
    }
}

Swagger原理:

  1. 自定义Swagger配置类
  2. 定义需要生成接口文档的代码位置(Controller),千万要注意:线上环境不要把接口暴露出去!!!可以通过在 SwaggerConfig 配置文件开头加上 @Profile({"dev", "test"}) 限定配置仅在部分环境开启
  3. 启动即可

如果spring version >= 2.6 需要添加如下配置:

spring:
  mvc:
  	pathmatch:
      matching-strategy: ANT_PATH_MATCHER

最后直接访问:localhost:8088/api/doc,html,即可看到Swagger生成的接口文档页面。

存量用户信息导入及同步(爬虫)

看上了网页信息,怎么抓到?

  1. 分析原网站是怎么获取这些数据的?哪个接口?

按 F 12 打开控制台,查看网络请求,复制 curl 代码便于查看和执行:

curl "https://api.zsxq.com/v2/hashtags/48844541281228/topics?count=20" ^
  -H "authority: api.zsxq.com" ^
  -H "accept: application/json, text/plain, */*" ^
  -H "accept-language: zh-CN,zh;q=0.9" ^
  -H "cache-control: no-cache" ^
  -H "origin: https://wx.zsxq.com" ^
  -H "pragma: no-cache" ^
  -H "referer: https://wx.zsxq.com/" ^
  --compressed
  1. 用程序去调用接口 (java okhttp httpclient / python 都可以)
  2. 处理(清洗)一下数据,之后就可以写到数据库里

流程

  1. 从 excel 中导入全量用户数据,判重 。 easy excel:https://alibaba-easyexcel.github.io/index.html
  2. 抓取写了自我介绍的同学信息,提取出用户昵称、用户唯一 id、自我介绍信息
  3. 从自我介绍中提取信息,然后写入到数据库中
EasyExcel

两种读对象的方式:

  1. 确定表头:建立对象,和表头形成映射关系
  2. 不确定表头:每一行数据映射为 Map<String, Object>

两种读取模式:

  1. 监听器:先创建监听器、在读取文件时绑定监听器。单独抽离处理逻辑,代码清晰易于维护;一条一条处理,适用于数据量大的场景。
  2. 同步读:无需创建监听器,一次性获取完整数据。方便简单,但是数据量大时会有等待时常,也可能内存溢出。

伙伴匹配系统第四期

  1. 页面和功能开发(搜索页面,用户信息,用户修改页面)
  2. 改造用户中心,把单机登录改为分布式 session 登录
  3. 标签的整理、细节的优化

开发标签搜索页【继续完善前端】

创建一个搜索结果页,引入路由,这个页面是用户在搜索页面点击所选择的标签然后点击搜索标签跳转到此搜索结果页,显示相对应的搜索结果

  1. 所以先在搜索页中创建一个提交按钮,然后点击次按钮将所选择的标签传到搜索结果页。

    那同样这里使用路由进行传递和页面跳转

        const doSearchResult = () => {
            router.push({
                path:'/user/list',
                query:activeIds.value//将已选标签传递出去
            })
        }
    
  2. 在搜索结果页中使用useRoute路由接收搜索页传过来的标签集。

    //注意区别useRoute和useRouter,两者是不同的。
        import {useRoute} from 'vue-router'
        const route = useRoute()
        const {tags} = route.query
    
  3. 改一下用户表,新增一个 profile varchar(512) 个人简介字段

  4. 完成上面步骤,我们就要考虑如何展示搜索结果相对应的标签,这里使用 vant 的商品卡片组件。

    我们 const 一个变量来存放用户的数据(假数据)并且把他用ref包起来

    const mockUser = {//定义一些假数据
            id:1252,
            username:"张三",
            userAccount:"123456",
            avatar:"https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png",
            gender:0,
            profile:"我是大三的学java的学生,目前的专业是软件工程aaaaaaaaaaaaaaa",
            phone:"13823413627",
            email:"123456@qq.com",
            createTime:new Date(),
            role:0,
            tags:['java','c++','学生','emo','单身'],
        }
    
        //将假数据存入到ref风格的数组中,这样才能被展示出来
        const userList = ref([mockUser]);
    

继续开发后端接口【根据标签搜索用户】

之前在 UserServiceImpl 已经写了searchUserByTagsBySQL 接口逻辑,现在要在 Controller 包中创建接口实现,路径为GetMapping(“/search/tags”),注意要使用@RequestParam,这个注解将请求参数绑定到你控制器的方法参数上,别且通过设置required = false ,标识为不一定为必填项。

@GetMapping("/search/tags")
public BaseResponse<List<User>> SearchUserByTags(@RequestParam(required = false) List<String> tagList){
    if(CollectionUtils.isEmpty(tagList)){
        throw new BusinessException(ErrorType.PARAMS_ERROR);
    }
    List<User> userList = userService.searchUserByTags(tagList);
    return ResultResponse.success(userList);
}

对接前后端接口【使用axios】

这里建议查官方文档进行开发。https://www.axios-http.cn/docs/instance,创建一个plugin包存放axios配置

  1. 查看文档,创建(const)一个Axios实例,这种做法类似于 axios是一个类,而我们const了一个axios类的对象进行使用。

    const myAxois = axios.create({
        baseURL: 'http://localhost:8088:/api/',
    });
    
  2. 为了查看前端响应的效果明显,这里使用axios中的拦截器,在请求拦截器和响应拦截器中添加一个console.log输出看结果。

    // 为了查看前端的响应效果,这里添加请求拦截器
    myAxois.interceptors.request.use(function (config) {
        console.log("我要发请求了")
        // 在发送请求之前做些什么
        return config;
    }, function (error) {
        // 对请求错误做些什么
        return Promise.reject(error);
    });
    
    // 添加响应拦截器
    myAxois.interceptors.response.use(function (response) {
        console.log("我收到你的请求了")
        // 2xx 范围内的状态码都会触发该函数。
        // 对响应数据做点什么
        return response;
    }, function (error) {
        // 超出 2xx 范围的状态码都会触发该函数。
        // 对响应错误做点什么
        return Promise.reject(error);
    });
    
  3. 配置axios接收的响应,我们在搜索标签页(Search.vue)中,点击选中的标签,点击搜索就会进入搜索结果页(SearchResultPage.vue),所以我们要在搜索结果页中处理前端发送到后端的哪个接口的操作

    1. 使用一个勾子(onMounted)获取axios方法,查看官方文档向后端发请求。

      image-20240419202741035

    2. 然而配置完axios后,会出现跨域问题,(跨域问题的解决方法在用户中心项目中有讲解过),这里我们使用对后端进行跨域解决先,后面再优化,在Controller类中标上 @CrossOrigin(origin = “前端的路径”)

    3. 解决完跨域问题后,出现参数错误的问题,

      image-20240419173511138

      image-20240419173524452

      【注意看这个url中的 ?中的请求参数,我们需要的是tagList=[“杭州”,“温州”]这种形式,所以后端接收不到】

      这时就要将参数进行序列化:

      myAxois.get('/user/search/tags',{
          params:{
              tagList:tags
          },
          paramsSerializer:params =>  {
              return qs.stringify(params,{arrayFormat: "repeat"})
          }
      
    4. 至此,接口打通,但是又要将数据库判断完的逻辑返回到前端并输出出来,那结果在哪里呢?

      结果就在response中,response有什么,我们console.log(response)发现如下:

      image-20240419204204777

      repsonse中包含data包含data,我们最里层的data即为后端判断完成所返回的值,我们将return回去。

    5. 创建一个对象 const userListData 来接收这个axios请求操作的返回值,由于数据库中的tags字段写得是json格式,所以我们要将搜索出来的user展示在前端必须进行json的解析,然后赋值给userList.

      onMounted(async () => {
              const userListData = await myAxois.get('/user/search/tags',{
                  params:{
                      tagList:tags
                  },
                  paramsSerializer:params =>  {
                      return qs.stringify(params,{arrayFormat: "repeat"})
                  }
              })
              .then(function (response) {
                  console.log("/user/search/tags success",response);
                  showSuccessToast("请求成功!")
                  return response.data?.data;//这里的?是因为怕第一个data为空,可选列操作符
              })
              .catch(function (error) {
                  console.error("/user/search/tags error",error);
                  showFailToast("请求失败!")
              });
              if(userListData){
                  //因为后端存放的tags标签数据是json格式的字符,所以要将其解析成字符串的格式
                  userListData.forEach(user =>{
                      if(user.tags){
                          user.tags = JSON.parse(user.tags);
                      }
                  })
                  userList.value = userListData
              }
          })
      

改造用户中心,把单机登录改为分布式session登录

Session共享

种 session 的时候注意范围,cookie.domain

比如两个域名:

aaa.yupi.com

bbb.yupi.com

如果要共享 cookie,可以种一个更高层的公共域名,比如 yupi.com

为什么服务器 A 登录后,请求发到服务器 B,不认识该用户?

用户在 A 登录,所以 session(用户登录信息)存在了 A 上

结果请求 B 时,B 没有用户信息,所以不认识。例如下图:

image-20240420143637348

解决方案:共享存储 ,而不是把数据放到单台服务器的内存中

image-20240420143808442

如何共享存储?【优先选择redis】

  1. Redis(基于内存的 K / V 数据库)此处选择 Redis,因为用户信息读取 / 是否登录的判断极其频繁 ,Redis 基于内存,读写性能很高,简单的数据单机 qps 5w - 10w
  2. MySQL
  3. 文件服务器 ceph

如何使用redis实现Session共享

  1. 首先先在本地windows安装redis,Redis 5.0.14 下载:

    链接:https://pan.baidu.com/s/1XcsAIrdeesQAyQU2lE3cOg

    提取码:vkoi

    下载完成后配置环境变量。

  2. 在后端项目中引入 spring 整和redis的依赖。安装完成依赖后可以在yml中配置redis一些属性:

    spring:
        redis:
            host: localhost
            port: 6379
            database: 0
    
    <!-- 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>2.3.4.RELEASE</version>
    </dependency>
    
    
  3. 下载安装redis的管理工具,这个工具可以用于可视化看到redis库中的存入的数据。

  4. 引入 spring-session 和 redis 的整合,使得自动将 session 存储到 redis 中:

    <!--引入session自动存入redis的依赖-->
    <dependency>
        <groupId>org.springframework.session</groupId>
        <artifactId>spring-session-data-redis</artifactId>
        <version>2.3.3.RELEASE</version>
    </dependency>
    
  5. 修改 spring-session 存储配置 spring.session.store-type

    默认是 none,表示存储在单台服务器

    store-type: redis,表示从 redis 读写 session

  6. 最后进行测试,使用maven打包一个jar包,使用window的终端进入到这个jar包的目录中,并且输入 java -jarjava -jar .\user-center-0.0.1-SNAPSHOT.jar --server.port=8081 在8081端口开启本项目,又在idea中启动项目,默认在8080中启动,这样一个电脑上就能跑两个(多个)项目,测试发现:在8080端口中进行用户登录后,在8081端口也能获取到登录用户的信息,至此ession共享完成。

伙伴匹配项目第五期

本期的任务:

  1. 用户修改页面前端、后端开发和联调

开发后端更新用户信息接口

因为前端的user用户个人信息页面需要提供用户修改自己的信息的功能,所以要写一个后端的接口来实现此功能。

修改用户个人信息需要有权限,一是管理员可以修改任何用户的信息,二是用户自己修改自己的信息,所以要获取当前登录的用户是否为所要修改的用户。

1、既然要获取当前的登录用户,所要在UserService中封装一个获取当前用户的方法,为什么要在UserService中呢?–> 因为这样可以在任何地方进行调用

@Override
public User getLoginUser(HttpServletRequest request) {
    if(request == null) {
        return null;
    }

    //获取当前登录用户的信息【这是用户中心的内容】
    User loginUser = (User) request.getSession().getAttribute(USER_LOGIN_STATE);

    //如果当前登录的用户信息为空,则用户修改的不是自己的信息,抛出异常无权限。
    if(loginUser == null){
        throw njavaew BusinessException(ErrorType.NO_AUTH);
    }
    return loginUser;
}

2、同时要判断是否为管理员,之前在Controller中写了一个 isAdmin() 方法来判断是否为管理员,这里我们要把它放到UserService中方便调用,并且重载一遍 isAdmin方法传入的参数为 User loginUser,

/**
     * 判断是否位管理员
     * @param request
     * @return
     */
boolean isAdmin(HttpServletRequest request);

/**
     * 重载一下isAdmin,让它除了支持传入request外,也能传入当前登录的用户,这样也可以判断是否为管理员
     * @param loginUser
     * @return
     */
boolean isAdmin(User loginUser);

3、在UserService中创建updateUser方法,这里填写更新用户的逻辑

4、在Controller中使用GetMapping,创建一个update方法,里面所需要两个参数,User user:所要修改的用户信息,HttpServletRequest request:用来获取当前登录的用户。

开发用户登录页面【并联调后端UserLogin接口】

原本是应该根据上面后端的写修改用户信息接口来写对应的前端,但是这需要获取到当前登录用户的权限并(根据规则用户只能修改自己的信息,管理员可以修改任何用户的信息)进行修改

1、使用vant3f的orm表单组件进行登录页面的快速开发:

image-20240422220751890

2、使用axios对接后端用户登录接口,需要传入两个参数UserAccount,UserPssword

const userAccount = ref('');
const userPassword = ref('');

//点击登录按钮所触发的事件
const onSubmit = async () => {
    const res = await myAxois.post('/user/login',{
        userAccount:userAccount.value,
        userPassword:userPassword.value,

    })
    //记住这里的两个data是因为axios对res后端返回的结果进行了两层封装,所以.data.data才能获取后端的返回值
    //code为res中的一个状态码,0表示能够成功对接后端
    if(res.data.code === 0 && res.data.data){
        showSuccessToast("登录成功!");
        router.replace("/");
    }else{
        showFailToast("登录失败!")
    }
}

完善用户信息页(User.vue)【并联调后端GetUserCurrent接口】

1、用户信息页之前定义了一些假数据,这里我们需要访问后端的获取当前用户信息的接口(getCurrentUser)。

​ 同样用到axios进行对后端的对接,并且定义一个ref风格的user来接收后端返回的对象。

const user = ref()
onMounted(async () => {
    //获取当前登录的用户信息
    const res = await myAxois.get('/user/current');
    if(res.data.code === 0 && res.data.data){
        showSuccessToast("成功!")
        user.value = res.data.data;
        // console.log("这是user的值"+res.data.data);

    }else{
        showFailToast("失败!")
    }
})

2、但是又因为考虑到后面的页面可能会用到当前的所登录的用户信息,然后每次都要使用axios向后端访问(GetUserCurrent接口)所以我们创建一个service包,创建一个user.ts,来同一访问 /user/current

import myAxois from "../plugin/MyAxios";

export const getCurrentUser = async () =>{
    return await myAxois.get('user/current')
}

所以获取用户信息的axios代码:

const user = ref()
    onMounted(async () => {
        //获取当前登录的用户信息
        const res = await getCurrentUser();
        if(res.data.code === 0 && res.data.data){
            showSuccessToast("获取当前用户成功!")
            user.value = res.data.data;
        }else{
            showFailToast("获取当前用户失败!")
        }
    })

注意的点

如何判断用户到底登没登录呢?,这时用户登录后必须携带cookie传递到后端,后端根据这个凭证来判断当前是否已经登录,并且获取当前用户信息也是通过cookie判断当前登录的用户,没有cookie默认没有登录。

axios默认是发送请求的时候不会带上cookie的,需要通过设置withCredentials: true来解决。

在 MyAxios.ts 这个对axios配置的文件中,加上 myAxois.defaults.withCredentials = true;

image-20240422222136712

这样的话原本不会出现的跨域问题又会再次出现,原因是后端认为所携带的cookie是跨域的,这里要修改@CrossOrigin():

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这个配置说明后端允许前端 http://localhost:5173 所携带的cookie允许跨域。

完善用户修改页(UserEdit.vue)【联调后端update接口】

使用axios访问后端 /user/update (update接口),需要传入的参数为用户的id,因为要根据用户的id来判断修改哪一个,并且需要传入修改的内容。

1、因为要传入当前用户的id值来在数据库中修改对应的字段,所以这里就需要调用上面写的 user.ts

2、因为我们前端的设置是用户点击哪个信息就修改哪个信息(例如:点击电话就修改电话,点击邮箱就修改邮箱),所以这里要使用js中的一个语法糖进行动态的修改 [editUser.value.editKey]

//获取当前登录用户信息
const currentUser = ref('')
onMounted(async () => {
    const res = await getCurrentUser();
    if(res.data.code === 0 && res.data.data){
        // showSuccessToast("成功!")
        currentUser.value = res.data.data;
    }else{
        // showFailToast("失败!")
    }
})

//提交修改后的数据给后端
const onSubmit = (values) => {
    myAxois.post('/user/update',{
        //后端更新修改用户信息接口需要传入当前用户的id和和所要修改的信息
        //因为我们要修改的信息不是整个用户的信息,而是User页面中根据用户点击想修改哪一条的信息进行修改
        //所以[editUser.value.editKey]动态的获取用户所选择修改的项
        // : 分号前面是后端的参数名,后面是前端向后端传递的参数
        'id':currentUser.value.id,
        [editUser.value.editKey]:editUser.value.currentValue
    })
        .then(function (response) {
        console.log("/user/update",response);
        showSuccessToast("修改成功!")
        router.back()
    })
        .catch(function (error) {
        console.error("/user/update",error);
        showFailToast("修改失败!")
    });
};

伙伴匹配项目第六期

本期任务:

  1. 开发主页(默认推荐和自己兴趣相当的用户)
  2. 优化主页的性能(缓存 + 定时任务 + 分布式锁)

开发主页

  1. 主页展示推荐匹配的用户,这里仍然以列表的形式进行展示,因为这个展示的形式与用户搜索结果页几乎一样(SearchResultPage),所以我们将其抽出形成组件

新建一个包Component,最重要的问题是,这是一个子组件,而使用它的 index 和 SearchResultPage是父组件,父组件向后端请求的结果如何给子组件进行展示呢? 这里要先定义一个类型,这个类型里面写子组件接收父组件传过来的参数的类型,然后使用defineProps<上面定义的类型>

<template>
    <van-card
            v-for="user in userList"
            :tag="user.id"
            :desc="user.profile"
            :title="user.username"
            :thumb="user.avatar"
    >
        <template #tags>
            <van-tag plain type="danger" v-for="tag in user.tags" style="margin-right:10px ">
                {{tag}}
            </van-tag>
        </template>
        <template #footer>
            <van-button size="mini">联系我</van-button>
        </template>
    </van-card>
    <van-empty description="搜索结果为空" v-if="!userList || userList.length < 1"/>
</template>

<script setup lang="ts">
    //子组件如何接收父组件传过来的参数?
    //使用defineProps
    import {User} from "../models/user";
    import {defineProps} from 'vue'
	//这个UserCardListPros里面写子组件接收父组件传过来的参数的类型
    interface UserCardListProps {
        userList:User[];
    }
    const pros = defineProps<UserCardListProps>();
</script>
  1. 编写后端查询所要推荐的用户的接口,用的是get请求,无需传入参数,这里先将数据库中的全部数据都作为推荐,所以就是一个全部查询

    /**
         * 主页推荐用户接口,暂时先查询出所有用户
         * @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> userlist = userList.stream().map(user -> {
            return userService.getSafeUser(user);
        }).collect(Collectors.toList());
        return ResultResponse.success(userlist);
    }
    
  2. 最后i ndex 页面模仿 SearchResultPage 使用axios请求向后端 /user/recommend 接口发送请求,返回的数据用 userList 接收

    const userList = ref([]);
    
        //利用axios向后端发起请求,将前端所选中的标签数据传到后端进行逻辑的判定
        onMounted(async () => {
            const userListData = await myAxois.get('/user/recommend',{
                params:{},
            })
                .then(function (response) {
                    console.log("/user/recommend success",response);
                    showSuccessToast("请求成功!")
                    return response.data?.data;//这里的?是因为怕第一个data为空,可选列操作符
                })
                .catch(function (error) {
                    console.error("/user/recommend error",error);
                    showFailToast("请求失败!")
                });
            if(userListData){
                //因为后端存放的tags标签数据是json格式的字符,所以要将其解析成字符串的格式
                userListData.forEach(user =>{
                    if(user.tags){
                        user.tags = JSON.parse(user.tags);
                    }
                })
                userList.value = userListData
            }
        })
    

数据批量插入到数据库【开启定时任务】

现在我们想模拟1000一条假数据插入到数据库中,该怎么做呢,怎么开启定时任务呢?

  1. 首先在Spingboot的启动类中添加上注解 @EnableScheduling 表示在spring中开启对定时任务的支持

    image-20240426211916489
  2. 在once包中创建 InsertUser 并添加上@Component 注解标识为一个bean,使用@Resource 注解注入UserMapper

    使用@Scheduled开启定时任务 initialDelay=5000表示项目启动后5秒执行 fixedRate表示第一次任务过后等待多少秒再次执行,这里我们设置Long.MAX_VALUE,表示一个比较大的时间,也就相当于只执行一次

    @Deprecated
    @Scheduled(initialDelay = 5000,fixedRate = Long.MAX_VALUE)
    public void InsertUser(){
        final int INSERT_NUM = 1000;
        for (int i = 0; i < INSERT_NUM; i++) {
            User user = new User();
            user.setUsername("假Hines");
            user.setUserAccount("fakeHines");
            user.setAvatar("https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png");
            user.setGender((byte) 0);
            user.setUserPassword("12345678");
            user.setPhone("1512616163");
            user.setEmail("12345@qq.com");
            user.setUserStatus(0);
            user.setRole(0);
            user.setTags("[]");
            userMapper.insert(user);
        }
    }
    
    

    经过上面这种方法,效率低下,主要的原因是因为:没执行一条语句都要创建一次数据库连接,然后进行插入,再将连接关闭,导致效率低

    image-20240426212742552

提高数据的插入效率【批量插入】

我们不使用上面UserMapper进行插入,我们使用UserService的saveBatch进行批量插入,这个也是mybtis封装的方法。那该怎么用?

saveBatch的要传入的第一个参数是一个数组,第二个传入的参数是每批执行的数量。

我们要先创建一个ArrayList数组,将所有的user数据都放入到ArrayList中,然后使用saveBatchjava

//设置定时任务, initialDelay=5000表示项目启动后5秒执行,fixedRate=Long的最大值是让任务只执行一次
    @Scheduled(initialDelay = 5000,fixedRate = Long.MAX_VALUE)
    public void doInsertUser(){
        final int INSERT_NUM = 1000;
        List<User> list = new ArrayList<>();
        for (int i = 0; i < INSERT_NUM; i++) {
            User user = new User();
            user.setUsername("假Hines");
            user.setUserAccount("fakeHines");
            user.setAvatar("https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png");
            user.setGender((byte) 0);
            user.setUserPassword("12345678");
            user.setPhone("1512616163");
            user.setEmail("12345@qq.com");
            user.setUserStatus(0);
            user.setRole(0);
            user.setTags("[]");
            list.add(user);
        }
        //数据插入进行分批操作(以100个为一批进行插入)
        userService.saveBatch(list,100);

使用并发提高插入效率【回看】

我们将插入的假数据增加到十万条,那我们这里将其分成十组,每组一万条数据并发的插入数据库中。

  1. 首先创建一个任务数组,这里数组中存放这十个任务
  2. 定义一个j 这个j变量表示后面while循环进行的次数,使用for循环(i < 10)遍历十次,每次用一个while循环,while循环中d创建User假数据并且加入到一个list中,直到 j % 10000 == 0,即执行了10000次就跳出循环
  3. 在上面for循环(i < 10)中当while跳出后,使用CompletableFuture.runAsync形成一个任务,并把它加入到任务数组中
  4. 最后使用 CompletableFuture.allof 执行任务数组。
public void doConCurrentInsertUser(){
    final int INSERT_NUM = 100000;// 十万条数据
    //将十万条数据分十组,相当于有十个线程任务
    int j = 0;

    //定义一个任务数组
    List<CompletableFuture<Void>> futureList = new ArrayList<>();

    for (int i = 0; i < 10; i++) {
        List<User> list = new ArrayList<>();
        while(true){
            j++;
            User user = new User();
            user.setUsername("假Hines");
            user.setUserAccount("fakeHines");
            user.setAvatar("https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png");
            user.setGender((byte) 0);
            user.setUserPassword("12345678");
            user.setPhone("1512616163");
            user.setEmail("12345@qq.com");
            user.setUserStatus(0);
            user.setRole(0);
            user.setTags("[]");
            list.add(user);
            if(j % 10000 == 0){
                break;
            }
        }
        //新建一个异步的任务,每个任务都去执行saveBatch操作(使用默认的线程池)
        CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
            userService.saveBatch(list, 100);
        });
        futureList.add(future);
    }
    //执行这十个任务
    CompletableFuture.allOf(futureList.toArray(new CompletableFuture[]{})).join();
}

当然这里不一定要将十万条数据分为10组,也可以分为20组,效率会快一点但不多

注意:不是分的任务越多执行的效率就会越快,因为这里我们用的是默认的线程池(ForkJoinPool),当你的任务数超过了线程池中的最大线程数,就会让一个线程执行两个任务,这样反而更加慢了。

继续优化(自定义线程池)【第六期 1:42:22 ~ 1:45:48】

我们可以自定义一个线程池, new ThreadPoolExecutor,注意创建ThreadPoolExecutor对象所学要的参数

  1. 第一个参数:当前线程池的线程数
  2. 第二个参数:当前线程池的最大线程数
  3. 第三个参数:线程的存活时间
  4. 第四个参数:时间单位(线程到了所设定的时间内没有使用就会自动回收)
  5. 第五个参数:任务队列

注意:当任务队列满了,当前线程数就会增加,当当前的线程数已经达到了最大线程数的时候,就无法再添加了,这时可以传入第六个参数指定任务的策略,默认不传入的话就是中断

首页(index.vue)进行分页查询【后端recommend接口进行分页查询】

经过上面的数据插入,我们现在有很多条的假数据,要把它们显示再前端的 index.vue 页面必须进行分页。

  1. 设置分页插件,这里需要进入mybatis-plus官网,搜索分页就会显示分页插件的配置,把他复制到我们代码的config包中,因为这是一个配置类

    @Configuration
    @MapperScan("scan.your.mapper.package")
    public class MybatisPlusConfig {
    
        /**
         * 添加分页插件
         */
        @Bean
        public MybatisPlusInterceptor mybatisPlusInterceptor() {
            MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
            interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));//如果配置多个插件,切记分页最后添加
            //interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); 如果有多数据源可以不配具体类型 否则都建议配上具体的DbType
            return interceptor;
        }
    }
    
  2. 进行分页修改,这里需要使用userService中的page方法,该方法需要传入两个参数,第一个参数要传入分页参数,指定每页的大小和当前页数是第几页,第二个参数传入的是QueryWrapper,

    @GetMapping("/recommend")
    public BaseResponse<List<User>> recommendUsers( long pageSize,long pageNum, HttpServletRequest request){
    
        /*查询用户信息*/
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        //进行分页操作需要执行每页的大小和当前是第几页
        Page<User> userPage = userService.page(new Page<>(pageNum,pageSize), queryWrapper);
        return ResultResponse.success(userPage);
    }
    
  3. 修改第前端 index.vue 修改传入的参数为 pageSize 和 pageNum,这里设置为每页大小8,当前页面为第一页,并且返回的数据被 records 封装

    const userListData = await myAxois.get('/user/recommend',{
        params:{
            pageSize:8,
            pageNum:1,
        },
    })
    .then(function (response) {
        console.log("/user/recommend success",response);
        showSuccessToast("请求成功!")
        return response.data?.data.records;//records是mybatis进行分页操作所返回结果的封装
    })
    

伙伴匹配项目第七期

那经过上一期在数据库中插入大量的数据,这时查询对数据库的查询就会比较慢了,而且加载页面(即使做了分页进行优化)大于1秒,性能太慢,那这该怎么解决呢

这就应该使用缓存,提前把数据取出来保存好(通常保存到读写更快的介质,比如内存),就可以更快地读写。

缓存的实现

  • Redis(分布式缓存)
  • memcached(分布式)
  • Etcd(云原生架构的一个分布式存储,存储配置,扩容能力)

  • ehcache(单机)

  • 本地缓存(Java 内存 Map)

  • Caffeine(Java 内存缓存,高性能)

  • Google Guava

Redis的五大数据结构(面试考点)

首先什么是redis?

NoSQL,key - value 存储系统(区别于 MySQL,他存储的是键值对)

String 字符串类型:例如:name:“yupi”

List列表:names:[“yupi”,“dogyupi”,“yupi”]

Set集合:names[“yupi”](值不能重复)

Hash哈希:nameAge:{“yupi”:1,“dogyupi”:2}

Zset集合:names:{yupi - 9,dogyupi - 12} (适合做排行榜)

java中是如何使用redis?(实现的方式有哪几种)

java中实现redis的方式:

  1. Spring Data Redis

    Spring Data:通用的数据访问框架,定义了一组 增删改查 的接口

  2. Jedis

    独立于 Spring 操作 Redis 的 Java 客户端,要配合 Jedis Pool 使用

  3. Lettuce

    高阶 的操作 Redis 的 Java 客户端,异步、连接池

(若使用Redisson是项目的亮点)Redisson:分布式操作 Redis 的 Java 客户端,让你像在使用本地的集合一样操作 Redis(分布式 Redis 数据网格)

对比

  1. 如果你用的是 Spring,并且没有过多的定制化要求,可以用 Spring Data Redis,最方便
  2. 如果你用的不是 SPring,并且追求简单,并且没有过高的性能要求,可以用 Jedis + Jedis Pool
  3. 如果你的项目不是 Spring,并且追求高性能、高定制化,可以用 Lettuce,支持异步、连接池

  • 如果你的项目是分布式的,需要用到一些分布式的特性(比如分布式锁、分布式集合),推荐用 redisson

本项目采用第一种方式:Spring Data Redis【这里之前共享session配置过】

1)引入

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <version>2.6.4</version>
</dependency>

2)配置 Redis 地址

spring:
  # redis 配置
  redis:
    port: 6379
    host: localhost
    database: 0

项目中使用redis将数据存入内存

利用 SpringBoot 中的 RedisTemplate 进行内存的插入,但是SpringBoot封装redis进行了一层序列化操作(默认用的是JdkSerializationRedisSerializer,java原生的序列化方法),所以我们存入的键会是以乱码的形式存入redis内存中

自定义序列化(一般上网找,不用自己写)

所以java中操作redis存入内存需要先自定义序列化,在Config包中定义一个RedisTemplateConfig类

  1. 在类中用@Configuration注解标识为spring的配置类
  2. 创建一个方法,返回值为RedisTemplate<String,Object>,参数为 RedisConnectionFactory redisConnectionFactory并用@Bean方法注入到spring中
  3. 在方法中创建一个RedisTemplate对象,redisTemplate.setConnectionFactory(),将连工厂加入
  4. redisTemplate.setKeySerializer(),这里进行字符串序列化
  5. 返回redisTemplate对象返回
@Configuration
public class RedisTemplateConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate .setConnectionFactory(redisConnectionFactory);
        //将redis存入的键进行字符串序列化
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        return redisTemplate;
    }
}

注意引入一个库先写测试类:

@SpringBootTest
public class RedisTest {

    @Resource
    private RedisTemplate redisTemplate;

    @Test
    public 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(0L);
        user.setUsername("hines");

        valueOperations.set("yupiUser",user);

        //查
        Object yupi = valueOperations.get("yupiString");
        Assertions.assertTrue("dog".equals((String) yupi));
        Object yupiInt = valueOperations.get("yupiInt");
        Assertions.assertTrue(1 ==(Integer) yupiInt);
        Object yupiDouble = valueOperations.get("yupiDouble");
        Assertions.assertTrue(2.0 == (Double) yupiDouble);
    }
}

修改recommend接口,改写缓存

因为需要根据不同的用户推荐不同的页面内容,所以要根据登录用户的id来判断哪个用户,

设计缓存:

  1. 不同用户看到的数据不同,缓存中键的格式:systemId:moduleId:func:options(不要和别人冲突)
  2. ==redis内存不能无限增加,一定要设置过期时间!!!==这里设置过期时间(这里为1分钟)
@GetMapping("/recommend")
public BaseResponse<Page<User>> recommendUsers( long pageSize,long pageNum, HttpServletRequest request){
    //根据不同的用户推荐不同的内容,用户根据id区分
    User loginUser = userService.getLoginUser(request);

    //因为公司中为了减少成本,会将多个项目用同一个redis,所以避免key值重复必须自定义
    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 ResultResponse.success(userPage);
    }
    //无缓存查数据库
    QueryWrapper<User> queryWrapper = new QueryWrapper<>();
    //进行分页操作需要执行每页的大小和当前是第几页
    userPage = userService.page(new Page<>(pageNum,pageSize), queryWrapper);

    //写入缓存,将数据库中查出的userPage直接放入缓存
    try { //这里之所以用try - catch 进行捕获,是因为即使redis写入出错了,仍能查数据库返回给前端
        valueOperations.set(redisKey,userPage,1, TimeUnit.MINUTES);
    } catch (Exception e) {
        log.error("redis set key error!");
    }

    return ResultResponse.success(userPage);
}

缓存预热

那经过上一步recommend改写完缓存后,用户推荐页的加载能到达秒开的速度,性能得到了很大的提升,但是仍然存在一个问题:第一个用户加载的时候仍会出现卡顿,之后的用户才不会卡顿

所以这里需要进行缓存的预热

缓存预热的优点:

  1. 能够解决第一个用户仍存在加载卡顿的问题,同时也能在一定的情况下保护数据库

缓存预热的缺点:

  1. 增加开发成本(你要额外的开发,设计)
  2. 预热的时机或时间如果错了,可能你的缓存的数据不对或者太老
  3. 需要占用而外的空间

怎么进行缓存预热?

两种方法:

  1. 启动定时任务(使用这种)
  2. 模拟触发

具体实现

用定时任务,每天刷新所有用户的推荐列表

注意点:

  1. 缓存预热的意义(新增少、总用户多)
  2. 缓存的空间不能太大,要预留给其他缓存空间
  3. 缓存数据的周期(此处每天一次)

定时任务实现的方法有三种:

  1. Spring Scheduler(spring boot 默认整合了)
  2. Quartz(独立于 Spring 存在的定时任务框架)
  3. XXL-Job 之类的分布式任务调度平台(界面 + sdk)

本项目实现第一种方式:

  1. 主类开启 @EnableScheduling
  2. 给要定时执行的方法添加 @Scheduling 注解,指定 cron 表达式或者执行频率

不要去背 cron 表达式!!!!!

  • https://cron.qqe2.com/
  1. 因为数据库中的用户很多,这里不每一个用户都预热,只预热 id为1的用户,那么这里创建一个ArrayList数组,存入1L
  2. 遍历上面这个数组,然后将recommend接口所写的redis缓存那段代码复制过来即可。
@Component
@Slf4j
public class PreRedisJob {
    @Resource
    private UserService userService;
    @Resource
    private RedisTemplate<String,Object> redisTemplate;

    //重点用户
    private List<Long> userList = Arrays.asList(1L);//id为1的用户

    /**
     * 每天执行次定时任务,预热推荐用户页面
     */
    @Scheduled(cron = "0 50 0,15 * * *")
    public void PreCacheJob(){
        for (Long userId : userList) {
            //因为公司中为了减少成本,会将多个项目用同一个redis,所以避免key值重复必须自定义
            String redisKey = String.format("partner:user:recommend:%s",userId);

            ValueOperations<String,Object> valueOperations = redisTemplate.opsForValue();
            //无缓存查数据库
            QueryWrapper<User> queryWrapper = new QueryWrapper<>();
            //进行分页操作需要执行每页的大小和当前是第几页
            Page<User> userPage = userService.page(new Page<>(1,20), queryWrapper);

            //写入缓存,将数据库中查出的userPage直接放入缓存
            try { //这里之所以用try - catch 进行捕获,是因为即使redis写入出错了,仍能查数据库返回给前端
                valueOperations.set(redisKey,userPage,1, TimeUnit.MINUTES);
            } catch (Exception e) {
                log.error("redis set key error!");
            }
        }

    }
}

伙伴匹配系统第八期

上一期因为第一个访问的用户因后端数据量太大,页面加载出现卡顿(第一次访问没有缓存),我们就是用定时任务进行缓存的预热,但是接下来又有一个问题

若这个项目部署到多台服务器上,那每台服务器都会执行次定时任务,这样会造成资源的浪费,并且会存在一些脏数据,比如:重复插入,如何解决?

image-20240430214455894

解决办法:

  1. 分离定时任务程序和主程序,只在1个服务器运行定时任务。【缺点:成本太大】

  2. 写死配置,例:在定时任务的代码中写上 if(ip == "10.0.0.1") 才执行定时任务,否则直接返回,这样每个服务器都执行定时任务,但是只有 ip 符合配置的服务器才真实执行业务逻辑,其他的直接返回。【优点:成本最低】;【缺点:但是企业项目中我们的 IP 可能是不固定的,把 IP 写的太死了】

  3. 动态配置,跟第二种方式一样只有 ip 符合配置的服务器才真实执行业务逻辑,但是可以不同将这段逻辑写到代码中,可以写在配置里,可以是很轻松(代码无需重启),很方便的更新,例如:

    1. 数据库
    2. redis
    3. 配置中心(Nacos,Apollo,Spring Cloud Config)

    【缺点:服务器多了,IP不可控还是很麻烦,还是要人工修改】

  4. 分布式锁:只有抢到锁的服务器才能执行业务逻辑。【缺点:增加成本】【优点:不用手动配置,多少个服务器都一样】

锁【37:51~47:24】【面试考】

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

java实现锁的方式:synchronized关键字、并发包的类

存在的问题:只对单个jvm有效

分布式锁

锁是针对单机(一台服务器)的话,那分布式锁就是处理对多个服务器。

为啥需要分布式锁

  1. 有限资源的情况下,控制同一时间只有某些线程(用户/服务器)能访问到资源
  2. 单个锁只对jvm有效

分布式锁实现的关键

抢锁机制的核心思想:先来的人先把数据改为自己标识(服务器ip),后来的人发现标识已经存在了,就抢锁失败,进行等待。等先来的人执行方法结束,把标识清空,其他人继续抢锁。

MySQL 数据库:select for update 行级锁(最简单)

(乐观锁)

✔ Redis 实现:内存数据库,读写速度快 。支持 setnx、lua 脚本,比较方便我们实现分布式锁。

setnx:set if not exists 如果不存在,则设置;只有设置成功才会返回 true,否则返回 false

注意事项

  1. 用完的锁一定要释放

  2. 锁一定要加过期时间

  3. 如果方法执行时间过长,锁提前就过期了?举例:【进程A 方法没执行完,锁过期,进程B就会进来又设置了一个锁,并且当第一个进行执行完了,就将锁删除了】

    产生的问题:

    1. 连锁效应:释放掉别人的锁
    2. 这样还是会存在多个方法同时执行的情况

    解决的方法:续期

    boolean end = false;
    
    new Thread(() -> {
        if (!end)}{
        续期
    })
    
    end = true;
    
    
    1. 释放锁的时候,有可能先判断出是自己的锁,但这时锁过期了,另一个线程看见没锁了,进来执行并设置锁了,在开始的线程执行完后还是释放了别人的锁
    // 原子操作
    if(get lock == A) {
        // set lock B
        del lock
    }
    

    解决方法:使用Redis + lua 脚本实现

    1. Redis 如果是集群(而不是只有一个 Redis),如果分布式锁的数据不同步怎么办?【难点,面试答出来很厉害】

      推荐文章:https://blog.csdn.net/feiying0canglang/article/details/113258494

java中使用Redisson实现分布式锁

那上面所说的锁和分布式锁,在java Spring项目中应该如何使用呢? ——> Redisson

Redisson 是一个 java 操作 Redis 的客户端,提供了大量的分布式数据集来简化对 Redis 的操作和使用,可以让开发者像使用本地集合一样使用 Redis,完全感知不到 Redis 的存在。

两种引入方式:

  1. spring boot starter 引入(不推荐,版本迭代太快,容易冲突)https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter
  2. 直接引入:https://github.com/redisson/redisson#quick-start 【跟着redisson中的官方文档的快速开始进行引入】(推荐)

1、所以按照第二种(官方文档)方式进行引入,在官方文档开始开始中把依赖引进来

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.29.0</version>
</dependency>

2、在config包中创建一个RedisConfig类并标注上@Configuration注解

3、创建一个方法 返回值为 RedissonClient 的方法并标注为@Bean,这样在其他地方就行使用@Resource注解调用这个方法

4、在文档给出的配置示例中进行更改,因为要传入redis的地址与端口号,这个配置我们已经在yml文件中配置过了,所以这里使用@ConfigurationProperties(prefix = “”)进行传值。java

@Configuration
@ConfigurationProperties(prefix = "spring.redis")
@Data
public class RedisConfig {

    //注意这个变量名要与yml一致
    private String host;
    private String port;

    @Bean
    public RedissonClient redissonClient(){
        //1、创建配置
        Config config = new Config();
        String address = String.format("redis://%s:%s",host,port);
        config.useSingleServer().setAddress(address).setDatabase(3);
        //2、创建实例
        RedissonClient redissonClient = Redisson.create(config);
        return redissonClient;
    }
}

定时任务运用redisson实现分布式锁

那么成功配置完Redisson,就应该将上面所讲的锁和分布式锁的理论在定时任务中进行实现。

  1. 先使用@Resource注解引入 redissonClient 。
  2. 在 redissonClient 使用getLock方法获取锁,这里同样需要将这把锁命名,命名的规则同样也是 项目名:方法:lock,这样就能获取一个锁的对象 lock
  3. lock.tryLock(),这个方法表示尝试获取锁,能够获取锁就返回true,这个方法还需传入参数,第一个参数为等待时间,第二个参数为过期时间
  4. 因为 lock.tryLock() 表示的是能否获取锁,所以这里将进行判断,能过获取锁就执行定时任务,并且.tryLock() 方法会抛出异常,所以一定要 try-catch
  5. 用完的锁一定要释放,这里一定要在finally中释放锁,lock.unlock(),因为如果上面try的代码出异常不会释放锁,finally的代码一定执行

@Component
@Slf4j
public class PreRedisJob {
    @Resource
    private UserService userService;
    
    @Resource
    private RedisTemplate<String,Object> redisTemplate;

    @Resource
    private RedissonClient redissonClient;

    //重点用户
    private List<Long> userList = Arrays.asList(1L);//id为1的用户

    /**
     * 每天执行次定时任务,预热推荐用户页面
     */
    @Scheduled(cron = "0 50 0,15 * * *")
    public void PreCacheJob(){
        RLock lock = redissonClient.getLock("partner:precachejob:docache:lock");//给锁起一个名字,这里命名的方式与redisKey一致
        //如果抢到锁了就执行定时任务,没有抢到就直接返回
        try {
            if(lock.tryLock(0,30000L,TimeUnit.MILLISECONDS)){//第一个参数为等待时间,第二参数为过期时间
                for (Long userId : userList) {
                    //因为公司中为了减少成本,会将多个项目用同一个redis,所以避免key值重复必须自定义
                    String redisKey = String.format("partner:user:recommend:%s",userId);

                    ValueOperations<String,Object> valueOperations = redisTemplate.opsForValue();
                    //无缓存查数据库
                    QueryWrapper<User> queryWrapper = new QueryWrapper<>();
                    //进行分页操作需要执行每页的大小和当前是第几页
                    Page<User> userPage = userService.page(new Page<>(1,20), queryWrapper);

                    //写入缓存,将数据库中查出的userPage直接放入缓存
                    try { //这里之所以用try - catch 进行捕获,是因为即使redis写入出错了,仍能查数据库返回给前端
                        valueOperations.set(redisKey,userPage,1, TimeUnit.MINUTES);
                    } catch (Exception e) {
                        log.error("redis set key error!",e);
                    }
                }
            }
        } catch (InterruptedException e) {
            log.error("PreCacheJob error");
        }finally {
            //注意:锁的释放一定要在finally中,如果上面try的代码出异常不会释放锁,finally的代码一定执行
            lock.unlock();
        }
    }
}

代码写完后我们来对一遍之前理论所说的注意事项:

  1. 用完的锁一定要释放 ——> 这里再finally中实现了。
  2. 锁一定要加过期时间 ——> 这里在 tryLock 的时候传入第二个参数就是设置过期时间
  3. 如果方法执行时间过长,锁提前就过期了?怎么解决 ——> 这里 redisson中存在一个看门狗的一种机制,能够进行续期,【下面讲解一下看门狗】
  4. 有可能先判断出是自己的锁,但这时锁过期了,最后还是释放了别人的锁,怎么解决 ——> redisson中在释放锁的时候就实现了lua脚本了。

看门狗机制

redisson 中提供的续期机制

开一个监听线程,如果方法还没执行完,就帮你重置 redis 锁的过期时间。

原理:

  1. 监听当前线程,默认过期时间是 30 秒,每 10 秒续期一次(补到 30 秒)
  2. 如果线程挂掉(注意 debug 模式也会被它当成服务器宕机),则不会续期

推荐阅读文章:https://blog.csdn.net/qq_26222859/article/details/79645203

伙伴匹配系统第九期

这里第九期包含了十,十一,都是后端队伍接口的开发,和部分前端对接

实现组队功能

需求分析

(p0是需求最高优先级,p1第二高,p2,p3以此类推)

  1. 用户可以创建一个队伍,设置队伍的人数、队伍的名称(标题)、描述、超时时间【p0】

用户创建队伍最多5个

  1. 展示队伍列表,根据名称搜索队伍,信息流中不展示已过期的队伍【p0】
  2. 修改队伍信息【p1】
  3. 用户可以加入队伍(其他人、未满、未过期),允许加入多个队伍,但是要有个上限【p0】
  4. 用户可以退出队伍(如果队长退出,权限转移给第二早加入的用户 —— 先来后到)【p1】
  5. 队长可以解散队伍【p0】
  6. 分享队伍或邀请其他用户加入队伍。【p1】

队伍和用户-队伍表的设计

用户与队伍的关系:

  1. 用户加入了哪些队伍
  2. 队伍有哪些用户

方式:

  1. 创建用户-队伍关系表 teamId userId (便于修改,查询性能高一点,可以选择这个,不用全表遍历)【本项目采用这种方式】
  2. 用户表补充已加入的队伍字段,队伍表补充已加入的用户字段(便于查询,不用写多对多的代码,可以直接根据队伍查用户、根据用户查队伍)

队伍表 team

  • id 主键 bigint(最简单、连续,放 url 上比较简短,但缺点是爬虫)
  • 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  null comment '过期时间',
    userId            bigint comment '用户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 '队伍';

用户 - 队伍表 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,
    isDelete     tinyint  default 0                 not null comment '是否删除'
)
    comment '用户队伍关系';

接下来就是根据数据库表使用mybaits生成器生成对应的代码,注意:生成器会将 tinyint 的字段变为Byte,这里要手动改为Integer,并且在isDelete字符添加上@TableLogic注解

编写队伍(最原始的)增删改查

注意:增删改都是用Post请求,查询使用Get请求

增删改没什么好说的,详情看TeamController类,只要注意传出的参数和updateById()方法传入的是一个类而不是一个id

增:

/**
     * 添加队伍
     * @param team
     * @return
     */
    @PostMapping("/add")
    public BaseResponse<Long> addTeam(@RequestBody Team team){
        if(team == null){
            throw new BusinessException(ErrorType.PARAMS_ERROR);
        }
        boolean save = teamService.save(team);
        if(!save){
            throw new BusinessException(ErrorType.NULL_ERROR);
        }
        //因为要返回的是添加成功数目,这里mybatis添加成功后会在属性上生成一个id
        return ResultResponse.success(team.getId());
    }

/**
     * 根据id删除队伍
     * @param id
     * @return
     */
    @PostMapping("/delete")
    public BaseResponse<Boolean> deleteTeam(long id){
        if(id <= 0){
            throw new BusinessException(ErrorType.PARAMS_ERROR);
        }
        boolean result = teamService.removeById(id);
        if(!result){
            throw new BusinessException(ErrorType.NULL_ERROR);
        }
        return ResultResponse.success(result);
    }

 /**
     * 更新队伍
     * @param team
     * @return
     */
    @PostMapping("/update")
    public BaseResponse<Boolean> updateTeam(@RequestBody Team team){
        if(team == null){
            throw new BusinessException(ErrorType.PARAMS_ERROR);
        }
        boolean result = teamService.updateById(team);
        if(!result){
            throw new BusinessException(ErrorType.NULL_ERROR);
        }
        return ResultResponse.success(result);
    }

注意一下参数操作

  1. 根据某个id查询队伍

  2. 查询所有队伍注意:并不能将查询到的队伍的所有属性都传给前端,所以这里要写一个请求参数包装类】下面是需要注意的点:

    1. 这里需要写一个请求参数包装类(teamQuery),将所需要的字段提供给前端,【此类写在在bean包中的dto包中】

    2. 使用teamService.list查询是发现接收的参数只能是team,所以要将team强转为teamQuery,如何强转?

    3. 这里需要引入依赖

      <!--强转工具类-->
      <!-- https://mvnrepository.com/artifact/commons-beanutils/commons-beanutils -->
      <dependency>
          <groupId>commons-beanutils</groupId>
          <artifactId>commons-beanutils</artifactId>
          <version>1.9.4</version>
      </dependency>
      

    查询所有队伍完整代码:

    /**
     * 查询所有队伍
     * @param teamQuery:请求参数包装类
     * @return
     */
    @GetMapping("/list")
    public BaseResponse<List<Team>> getTeamList(TeamQuery teamQuery){
        if(teamQuery == null){
            throw new BusinessException(ErrorType.PARAMS_ERROR);
        }
    
        //team转化为teamQuery的操作,这里使用了apache的一个工具类进行转换
        Team team = new Team();
        try {
            BeanUtils.copyProperties(team,teamQuery);
        } catch (Exception e) {
            throw new BusinessException(ErrorType.SYSTEM_ERROR);
        }
        //使用teamService.list查询是发现接收的参数只能是team,所以要将team强转为teamQuery
        QueryWrapper<Team> queryWrapper = new QueryWrapper<>(team);
        List<Team> list = teamService.list(queryWrapper);
        if(list == null){
            throw new BusinessException(ErrorType.NULL_ERROR);
        }
        return ResultResponse.success(list);
    }
    
  3. 分页查询所有队伍

    1. 这里需要定义一个请求参数包装类(PageRequest),封装的属性是页面大小(pageSize)和当前页面(pageNum)并填写上默认值10,1【这个类写在common包中】
    2. PageRequest实现 Serializable 接口进行序列化,并且生成对应的序列化id —— > 【序列化是将对象实例的状态存储到存储媒介的过程。将对象的公共字段、私有字段以及类的名称(包括类所在的程序集)转换为字节流,再把字节流写入数据流中。随后对对象进行反序列化时,将创建出与原对象相同的副本。】
    3. teamQuery 继承 PageRequest
    4. 控制层实现分页

为什么需要请求参数包装类?

  1. 请求参数名称 / 类型和实体类不一样
  2. 有一些参数用不到,如果要自动生成接口文档,会增加理解成本
  3. 对个实体类映射到用一个对象

为什么需要包装类?

  1. 可能有些字段需要隐藏,不能返回给前端
  2. 有些字段某些方法是不关心的

根据需求分许在业务层编写队伍功能的具体逻辑

1、创建队伍逻辑

具体逻辑:用户可以 创建 一个队伍,设置队伍的人数、队伍名称(标题)、描述、超时时间 【P0】

业务逻辑:

  1. 请求参数是否为空?
  2. 是否登录,未登录不允许创建
  3. 校验信息
    1. 队伍人数 > 1 且 <= 20
    2. 队伍标题 <= 20
    3. 描述 <= 512
    4. status 是否公开(int)不传默认为 0(公开)
    5. 如果 status 是加密状态,一定要有密码,且密码 <= 32
    6. 超时时间 > 当前时间
    7. 校验用户最多创建 5 个队伍
  4. 插入队伍信息到队伍表
  5. 插入用户 => 队伍关系到关系表

根据上面的业务逻辑,在 service 中实现

TeamServiceImpl具体实现创建队伍(createTeam)的业务逻辑:【说一下需要注意的点】

  1. 根据业务逻辑一步一步实现,但需要注意的是我们将状态(0 - 公开,1 - 私有,2 - 加密)这三个状态设置成枚举类(TeamStatusEnums),【这个类写在common包中】,同时这个类需要注意的是要写一个根据队伍status属性的数值获取对应的状态码的方法
  2. 在【第五点中校验用户最多创建 5 个队伍】,那首先需要获取当前登录用户的id,再判断id是否与队伍表中的id字段的值一致,然后使用count方法数出当前用户创建了多少个队伍
  3. 【插入队伍信息到队伍表】,这个逻辑使用save方法把team插入到表中,在此之前需要将team的id设置为null(因为自增),team的userId设置为当前登录用户的id,注意:还需要进行事务的回滚,在此 TeamServiceImpl 类上加上注解@Transactional(rollbackFor = Exception.class) 表示开启事务,事务回滚抛异常【但仍然存在问题:有bug,用户可以创建100个队伍【需要使用锁】】

完整的创建队伍业务层的代码:

@Override
    public long createTeam(Team team, User loginUser) {
        //1. 请求参数是否为空?
        if(team == null){
            throw new BusinessException(ErrorType.PARAMS_ERROR);
        }
        //2. 是否登录,未登录不允许创建
        if(loginUser == null){
            throw new BusinessException(ErrorType.NOT_LOGIN);
        }
        long userId = loginUser.getId();
        //3. 校验信息
        //   1. 队伍人数 > 1 且 <= 20
        int teamNum = Optional.ofNullable(team.getMaxNum()).orElse(0);
        if(teamNum < 1 || teamNum > 20){
            throw new BusinessException(ErrorType.PARAMS_ERROR,"队伍人数错误");
        }
        //   2. 队伍标题 <= 20
        String teamName = team.getName();
        if(StringUtils.isBlank(teamName) || teamName.length() > 20){
            throw new BusinessException(ErrorType.PARAMS_ERROR,"队伍标题错误");
        }
        //   3. 描述 <= 512
        String description = team.getDescription();
        if(StringUtils.isNotBlank(description) && description.length() > 512){
            throw new BusinessException(ErrorType.PARAMS_ERROR,"队伍描述错误");
        }
        //获取当前队伍的状态
        int status =Optional.ofNullable(team.getStatus()).orElse(0);
        TeamStatusEnums teamStatusEnums = TeamStatusEnums.getEnumByNum(status);
        //   4. status 是否公开(int)不传默认为 0(公开)
        if(teamStatusEnums == null){
            throw new BusinessException(ErrorType.PARAMS_ERROR,"队伍状态错误");
        }
        //   5. 如果 status 是加密状态,一定要有密码,且密码 <= 32
        String password = team.getPassword();
        if(TeamStatusEnums.SECRET.equals(teamStatusEnums)){//如果 status 是加密状态
            if(StringUtils.isBlank(password) || password.length() > 32){
                throw new BusinessException(ErrorType.PARAMS_ERROR,"密码设置错误");
            }
        }
        //   6. 超时时间 > 当前时间
        Date expireTime = team.getExpireTime();
        if(new Date().after(expireTime)){//当前时间在过期时间之后,那就异常
            throw new BusinessException(ErrorType.PARAMS_ERROR,"超时时间异常");
        }

        //   7. 校验用户最多创建 5 个队伍
        // todo 有bug,用户可以创建100个队伍【需要使用锁】
        QueryWrapper<Team> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("userId",userId);//判断当前队伍是由当前用户创建的
        long count = this.count(queryWrapper);
        if(count > 5){
            throw new BusinessException(ErrorType.PARAMS_ERROR,"用户最多创建5个队伍");
        }
        //4. 插入队伍信息到队伍表
        team.setId(null);
        team.setUserId(userId);
        boolean save = this.save(team);
        if(!save){
            throw new BusinessException(ErrorType.PARAMS_ERROR,"队伍插入不成功");
        }
        //5. 插入用户  => 队伍关系到关系表
        UserTeam userTeam = new UserTeam();
        userTeam.setUserId(userId);
        userTeam.setTeamId(team.getId());
        userTeam.setJoinTime(new Date());

        boolean result = userTeamService.save(userTeam);
        if(!result){
            throw new BusinessException(ErrorType.PARAMS_ERROR,"用户队伍关系表插入不成功");
        }
        return team.getId(); //这个team的id是新插入后team的id
    }

接下来修改控制层中创建(添加)队伍的方法,的save方法进行插入数据,现在改为上面所写的 createTeam 方法,因为要获取登录的用户信息,所以请求参数中还要添加一个HttpServletRequest,最后在Swagger文档发现一个问题(如下图),所需要传入的参数真的要整个team的属性吗?(这些创建时间,是否删除等都不需要传入),所以我们还是要创建一个请求参数封装类【方便前后端联调】

image-20240508141124302

/**
     * 创建(添加)队伍
     * @param teamAddRequest
     * @return
     */
    @PostMapping("/add")
    public BaseResponse<Long> addTeam(@RequestBody TeamAddRequest teamAddRequest, HttpServletRequest request){
        if(teamAddRequest == null){
            throw new BusinessException(ErrorType.PARAMS_ERROR);
        }
        User loginUser = userService.getLoginUser(request);
        Team team = new Team();

        //将使用apache工具将teamAddRequest传给team
        try {
            BeanUtils.copyProperties(team,teamAddRequest);
        } catch (Exception e) {
            e.printStackTrace();
        }

        //调用业务逻辑层的方法进行校验与插入
        long teamId = teamService.createTeam(team,loginUser);
        if(teamId == 0){
            throw new BusinessException(ErrorType.NULL_ERROR);
        }
        //因为要返回的是添加成功数目,这里mybatis添加成功后会在属性上生成一个id
        return ResultResponse.success(team.getId());
    }
2、查询队伍列表

展示队伍列表,根据名称搜索队伍,信息流中不展示已过期的队伍

  1. 从请求参数中取出队伍名称等查询条件,如果存在则作为查询条件
  2. 不展示已过期的队伍(根据过期时间筛选)
  3. 可以通过某个关键词同时对名称和描述查询
  4. 只有管理员才能查看加密还有非公开的房间
  5. 关联获取到创建人的用户信息
  6. 关联查询已加入队伍的用户信息(可能会很耗费性能,建议大家用自己写 SQL 的方式实现)【todo】

TeamServiceImpl具体实现查询队伍列表(listTeam)的业务逻辑:【说一下需要注意的点】

实现逻辑之前,因为查询出来的信息不是所有都返回个前端展示在页面上,关联用户的用户密码并不需要传,还有队伍中的password属性等也不需要传,所以这里仍要写两个封装类,UserVOTeamUserVO,分别将一些敏感的字段剔除。【具体代码在 bean包中的vo包中查看详情】

  1. 【一、从请求参数中取出队伍名称等查询条件】,这个就将传过来的teamQuery中的字段每个都拿出来,并且判断是否为空,创建一个查询控制器wrapper进行对应字段的查询

  2. 【二、不展示已过期的队伍】,那就是不过期的队伍才进行展示,这里需要使用到QueryWrapper的lambda表达式,因为每一个QueryWrapper都是通过AND来进行拼接的,所以这里还需要使用Lambda表达式类拼接多一个过期时间的查询逻辑

  3. 【三、可以通过某个关键词同时对名称和描述查询】,这里在teamQuery中添加多一个字段 searchText ,通过这个字段对name和description两个字段同时进行模糊查询

    queryWrapper.and(qw->qw.like("name",searchText).or().like("description",searchText));
    
    
  4. 【四、只有管理员才能查看加密还有非公开的房间】,这里需要controller中的getTeamList方法添加多一个HttpSrevletRequest参数,通过userService调用 isAdmin() 方法判断是否为管理员(无需获取登录用户的信息去判断是否为管理员,这个方法会根据session来判断),最后isAdmin方法会返回boolean类型并传给业务逻辑层

  5. 【五、关联获取到创建人的用户信息】,这里需要在 TeamUserVO 中创建一个字段 UserVO CreateUser;用来存放创建人的用户信息,将查询出来的所有数据teamList先进行遍历(每一个数据为team),new一个TeamUserVO,将team 通过BeanUtils传给 TeamUserVO ,通过UserService的getById方法获取到创建人的信息,new一个UserVO,将创建人的信息 user 通过 BeanUtils 传给UserVO,并通过set方法传到 TeamUserVO

具体代码:

/**
     * 查询队伍
     * @param teamQuery
     * @param isAdmin
     * @return
     */
    @Override
    public List<TeamUserVO> listTeam(TeamQuery teamQuery, boolean isAdmin) {
        QueryWrapper<Team> queryWrapper = new QueryWrapper<>();
        /*1、从请求参数中取出队伍名称等查询条件,如果存在则作为查询条件*/
        if(teamQuery != null){

            /*3、可以通过某个关键词同时对名称和描述查询*/
            String searchText = teamQuery.getSearchText();
            if(StringUtils.isNotBlank(searchText)){
                queryWrapper.and(qw->qw.like("name",searchText).or().like("description",searchText));
            }

            //根据队伍id查询出队伍
            Long id = teamQuery.getId();
            if(id != null){
                queryWrapper.eq("id",id);
            }
            //根据队伍名称查询队伍
            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("maxNum",maxNum);
            }

            //根据创建人查询
            Long userId = teamQuery.getUserId();
            if(userId != null && userId > 0){
                queryWrapper.eq("userId",userId);
            }

            //根据队伍的状态查询出队伍
            Integer status = teamQuery.getStatus();

            /*4、只有管理员才能查看加密还有非公开的房间*/
            TeamStatusEnums statusEnums = TeamStatusEnums.getEnumByNum(status);

            //如果队伍的状态为空则设置为公开的
            if(statusEnums == null){
                statusEnums = TeamStatusEnums.PUBLIC;
            }
            if(!statusEnums.equals(TeamStatusEnums.PUBLIC) && !isAdmin){
                throw new BusinessException(ErrorType.NO_AUTH);
            }
            queryWrapper.eq("status",statusEnums.getNum());

        }

        /*2、不展示已过期的队伍(根据过期时间筛选)*/
        //使用queryWrapper过滤器,满足过期时间不为空和过期时间大于当前时间的数据才能查出来
        //这里使用的是lambda表达式,因为每个queryWrapper都是通过and连接的,所以这里用queryWrapper连接一个查询表达式
        queryWrapper.and(qw -> qw.isNull("expireTime").or().gt("expireTime",new Date()));


        List<Team> teamList = this.list(queryWrapper);
        if(teamList == null){
            return new ArrayList<>();
        }

        ArrayList<TeamUserVO> teamUserVOList = new ArrayList<>();

        /*5、关联查询创建人的用户信息*/
        for(Team team : teamList){
            Long userId = team.getUserId();
            if(userId == null){
                continue;
            }

            //将查询到的队伍进行封装再传给前端(主要将一些敏感字段不输出给前端)
            TeamUserVO teamUserVO = new TeamUserVO();
            try {
                BeanUtils.copyProperties(teamUserVO,team);
            } catch (Exception e) {
                e.printStackTrace();
            }

            //获取到创建人的用户信息
            //并且对用户信息进行封装(将密码等一些敏感信息不输出给前端)
            User user = userService.getById(userId);
            if(user != null){
                UserVO userVO = new UserVO();
                try {
                    BeanUtils.copyProperties(userVO,user);
                } catch (Exception e) {
                    e.printStackTrace();
                }

                //将队伍创建者的信息通过teamUser封装类传给前端
                teamUserVO.setCreateUser(userVO);
            }

            //将封装好的每一个结果都传入到这个数组中返回给前端
            teamUserVOList.add(teamUserVO);

        }

        return teamUserVOList;
    }
3、修改队伍信息
  1. 判断请求参数是否为空
  2. 查询队伍是否存在
  3. 只有管理员个用户的创建者才可能修改队伍
/**
     * 更新队伍
     *
     * @param teamUpdateRequest
     * @param loginUser
     * @return
     */
    @Override
    public boolean updateTeam(TeamUpdateRequest teamUpdateRequest, User loginUser) {
        if (teamUpdateRequest == null) {
            throw new BusinessException(ErrorType.PARAMS_ERROR);
        }
        Long id = teamUpdateRequest.getTeamId();
        if (id == null || id < 0) {
            throw new BusinessException(ErrorType.PARAMS_ERROR);
        }
        
        //根据id查询队伍,并校验队伍是否存在
        Team oldTeam = this.getById(id);
        if (oldTeam == null) {
            throw new BusinessException(ErrorType.NULL_ERROR, "队伍不存在");
        }
        if (loginUser.getId() != oldTeam.getUserId() && userService.isAdmin(loginUser)) {
            throw new BusinessException(ErrorType.NO_AUTH);
        }

        TeamStatusEnums statusEnums = TeamStatusEnums.getEnumByNum(teamUpdateRequest.getStatus());

        if (statusEnums == TeamStatusEnums.SECRET) {
            if (StringUtils.isBlank(oldTeam.getPassword()) && StringUtils.isBlank(teamUpdateRequest.getPassword())) {
                throw new BusinessException(ErrorType.PARAMS_ERROR, "加密队伍需要设置密码");
            }
        }

        Team updateTeam = new Team();
        try {
            //将teamUpdateRequest(需要修改的信息)传给updateTeam,因为下面的this.updateById只接收updateTeam类型的参数
            BeanUtils.copyProperties(updateTeam, teamUpdateRequest);
        } catch (Exception e) {
            e.printStackTrace();
        }
        boolean result = this.updateById(updateTeam);

        return result;
    }

4、用户加入队伍
  1. 用户最多加入5个队伍
  2. 队伍必须存在,用户不能加入已满的队伍或已过期的队伍
  3. 不能加入自己的队伍,不能重复加入已加入的队伍
  4. 禁止加入私有的队伍
  5. 如果队伍是加密的,必须密码匹配才可加入
  6. 新增 队伍-用户 关联关系
5、用户退出队伍

用户可以退出队伍(如果队长退出,权限转移给第二早加入的用户 —— 先来后到)

请求的参数:teamId

  1. 判断请求的参数
  2. 校验队伍是否存在
  3. 校验用户是否已经加入队伍
  4. 如果队伍
    1. 只剩一个人,队伍解散
    2. 不止一个人的话:
      1. 如果队长退出队伍,权限转移给第二早加入的用户 —— 先来后到
      2. 不是队长,用户自行退出队伍

TeamServiceImpl具体实现查询退出队伍(quitTeam)的业务逻辑:【说一下需要注意的点】

  1. 因为向向服务器传递的参数只需要是所删除队伍的id即可,所以也要将参数进行封装(TeamQuitRequest)中
  2. 【权限转移给第二早加入的用户 —— 先来后到】,这里根据关联表中的id大小来判断谁先到,id小的为先到的用户,实现这一逻辑就需使用QueryWrapper中的 last 方法写sql语句。
/**
     * 用户退出队伍
     * @param teamQuitRequest
     * @param loginUser
     * @return
     */
    @Override
    public boolean quitTeam(TeamQuitRequest teamQuitRequest, User loginUser) {
        if(teamQuitRequest == null){
            throw new BusinessException(ErrorType.PARAMS_ERROR);
        }
        /*1、校验队伍是否存在*/
        Long teamId = teamQuitRequest.getTeamId();//前端传过来的所要删除队伍的id
        Team team = this.getById(teamId);//所要删除的队伍信息
        if(team == null){
            throw new BusinessException(ErrorType.PARAMS_ERROR,"队伍不存在");
        }

        /*2、校验用户是否已经加入队伍*/
        long userId = loginUser.getId();
        QueryWrapper<UserTeam> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("userId",userId);
        queryWrapper.eq("teamId",teamId);
        long count = userTeamService.count(queryWrapper);
        if(count == 0){
            throw new BusinessException(ErrorType.PARAMS_ERROR,"用户未加入队伍");
        }

        /*3、查看队伍的人数*/
        QueryWrapper userTeamQueryWrapper = new QueryWrapper();
        userTeamQueryWrapper.eq("teamId",teamId);
        long teamHashJoinNum = userTeamService.count(userTeamQueryWrapper); //统计关联表的teamId的数量就能知道有多少人加入队伍
        /*3、只剩一个人,队伍解散*/
        if(teamHashJoinNum == 1){
            this.removeById(teamId);
            return userTeamService.remove(userTeamQueryWrapper);//删除关联表中teamId = 所要删除队伍的id的数据
        }else{
            /*3、不止一个人的话:如果队长退出队伍,权限转移给第二早加入的用户 —— 先来后到*/
             if(team.getUserId() == userId){ //是队长,并且队长要退出队伍
                 userTeamQueryWrapper = new QueryWrapper();
                 //如何判断第二早加入的用户?
                 //这里根据关联表中teamId相同的id的大小来判断,teamId相等代表加入的是同一个队伍
                 //最先加入的id值大,所以这里我们将其进行从小到大进行排序并取出前两条数据,第一条为队长,第二条即为所查
                 userTeamQueryWrapper.eq("teamId",teamId);
                 userTeamQueryWrapper.last("order by id asc limit 2");
                 List<UserTeam> list = userTeamService.list(userTeamQueryWrapper);
                 if(CollectionUtils.isEmpty(list) || list.size() <= 1){
                     throw new BusinessException(ErrorType.SYSTEM_ERROR);
                 }

                 //第二早加入的用户
                 UserTeam nextUser = list.get(1);
                 Long nextUserUserId = nextUser.getUserId();

                 //更新当前队伍的队长
                 Team updateTeam = new Team();
                 updateTeam.setId(teamId);
                 updateTeam.setUserId(nextUserUserId);
                 boolean result = this.updateById(updateTeam);
                 if(!result){
                     throw new BusinessException(ErrorType.SYSTEM_ERROR,"更新队长失败");
                 }
                 
                 //删除掉关联表中退出的用户(队长)的这条队伍的信息
                 return userTeamService.remove(queryWrapper);
             }else{ //不是队长,直接删除关联表的这条用户的信息
                 return userTeamService.remove(queryWrapper);
             }
        }
    }
6、队长解散队伍

业务流程:

  1. 校验请求参数
  2. 校验队伍是否存在
  3. 校验你是不是队伍的队长
  4. 移除所有加入队伍的关联信息
  5. 删除队伍

编写前端队伍部分代码实现与后端接口对接

1、编写队伍页(Team.vue)

  1. 添加一个按钮位为创建队伍按钮,点击按钮就会跳转到创建队伍页(TeamAdd.vue)
  2. 所以要创建一个新页面(TeamAdd.vue),这个页面要填写的是创建队伍所要向后端传递的参数
  3. 队伍页(Team.vue)所要写的是展示所有公开的队伍,所以这里要使用axios向后端发送请求,不用传递请求参数
  4. 展示请求后后端传来的数据,我们创建一个组件TeamCardList 使用vant组件中的商品卡片来展示(tags标签也是这样展示的)
<template>
    <van-button type="primary" @click="doAddTeam">创建队伍</van-button>

    <team-card-list :team-list="teamList"></team-card-list>

</template>

<script setup>
    import {useRouter} from 'vue-router'
    import TeamCardList from '../components/TeamCardList.vue'
    import {onMounted,ref} from "vue";
    import myAxois from "../plugin/MyAxios";
    import {showFailToast, showSuccessToast} from "vant";
    const router = useRouter();
    const doAddTeam = () =>{
      router.push({
       path:"/team/add"
      })
    }

    const teamList = ref([]);

    onMounted(async () => {
        const res = await myAxois.get("/team/list");
        if(res.data.code === 0){
            showSuccessToast("success");
            teamList.value = res.data.data;
        }else{
            showFailToast("fail")
        }
    })
</script>

2、编写组件TeamCardList展示队伍的样式

这是一个子组件,这里同样需要获取父组件中的获取的后端参数,获取的步骤为

  1. 创建一个interface 的一个对象(这里命名为teamCardListPros),里面要写(父组件传过来的属性名:类型)

  2. 使用defineProps:泛型的类型为上面创建的接口

    const pros = defineProps<teamCardListProps>();
    
  3. 这样父组件所要传递的teamList就传到子组件中使用了。

  4. 使用vant中的商品卡片样式,展示队伍

3、编写创建队伍页(TeamAdd.vue)

  1. 因为创建队伍,后端所要接收的参数为:

    "name": "",
    "description": "",
    "expireTime": "",
    "maxNum": 3,
    "status":0,
    "password": ""
    
  2. 所要创建一个变量initFormData,来定义上面这些参数,又由于用户需要输入这些参数,所以要创建一个变量addTeamData 并用 ref() 包含initFormData

    const initFormData = {
        "name": "",
        "description": "",
        "expireTime": "",
        "maxNum": 3,
        "status":0,
        "password": ""
    }
    
    const addTeamData = ref({...initFormData});
    
  3. 使用vant组件表单的形式,让用户填写对应的数据,v-model,是用户填写的数据,这里的最大问题是过期时间的设置,需要进行时间的拼接

    <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
                       clickable
                       name="date-picker"
                       label="过期时间"
                       :value="addTeamData.expireTime"
                       v-model="addTeamData.expireTime"
                       :placeholder="'点击选择过期时间'"
                       @click="showPicker = true"
                       />
            <van-popup v-model:show="showPicker" position="bottom">
                <van-picker-group
                                  title="设定过期日期"
                                  :tabs="['选择日期', '选择时间']"
                                  @confirm="onConfirm"
                                  @cancel="showPicker = false"
                                  >
                    <van-date-picker
                                     v-model="currentDate"
                                     :min-date="minDate"
                                     />
                    <van-time-picker
                                     v-model="currentTime"
                                     :columns-type="columnsType"
                                     />
                </van-picker-group>
            </van-popup>
    		 //----------------------------------------------------------------
            
            <van-field name="stepper" label="最大人数" >
                <template #input>
    				<van-stepper v-model="addTeamData.maxNum" max="10" />
                </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-model="addTeamData.password"
                               v-if="Number(addTeamData.status) === 2"
                               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>
    
    
  4. 编写表单的提交事件onSumbit,将用户填写好的数据通过axios传递给后端(/team/add),注意:用户填写的状态值是字符串的形式,这里需要将addTeamData中的status转成整形,怎么转?

    1. 创建一个名为 postData 的变量,在中先取出addTeamData的所有值,并将 status用Number进行转换

      //因为用户填写的状态是字符串类型,所以这里将addTeamData中的status转成整形
      //并赋值给postData
      const postData = {
          ...addTeamData.value, //...为
          status: Number(addTeamData.value.status),
      }
      
    2. 使用axios向后端接口传递参数,若能成功传递则通过router跳转到队伍页(Team.vue)

      //提交
      const onSubmit = async () =>{
          //因为用户填写的状态是字符串类型,所以这里将addTeamData中的status转成整形
          //并赋值给postData
          const postData = {
              ...addTeamData.value,
              status: Number(addTeamData.value.status),
          }
          console.log(postData)
          const res = await myAxois.post("/team/add",postData);
          if(res.data.code === 0 && res.data.data){
              showSuccessToast("创建成功");
              router.push({
                  path:"/team",
                  replace:true,//请求重定向
              })
          }else{
              showFailToast("创建失败")
          }
      }
      

伙伴匹配系统第十二期

前端搜索队伍

  1. 在队伍页(Team.vue)中添加一个搜索框

  2. 搜索框填入的是seachText字段,因为搜索队伍和查询队伍所调用的后端接口相同,不同的是搜索队伍需要传入一个searchText字段参数,根据用户填写的信息进行查询

  3. 因为访问的后端接口相同,所以对axios访问 /team/list,进行封装

    /**
         * 搜索队伍
         * @param val
         * @returns {Promise<void>}
         * @constructor
         */
    const ListTeam = async (val = '') =>{
        const res = await myAxois.get("/team/list",{
            params:{
                searchText:val,
    
            }
        });
        if(res.data.code === 0){
            showSuccessToast("success");
            teamList.value = res.data.data;
        }else{
            showFailToast("fail")
        }
    }
    
    
  4. 查询队伍和搜索队伍分别调用这个方法即可

    onMounted(async () => {
        ListTeam();
    })
    
    const searchList = ref('')
    const onSearch = (val) =>{
        ListTeam(val);
    }
    

前端更新队伍

  1. 前端更新队伍同样使用表单的形式进行更新,所以这里复制 TeamAdd.vue 页面,将名字改为 TeamUpdate.vue 并进行路由的注册

  2. 那我们先理一下更新队伍的逻辑,在 Team.vue 页面中搜索出来的队伍上添加一个更新队伍的按钮,【不是队伍的创建人看不见此按钮】,点击该按钮跳转到 TeamUpdate.vue,【这里必须传递所点击的队伍的id】,TeamUpdate.vue 回显原队伍的信息(最大人数不能修改),然后点击提交更新用户信息并跳转到 Team.vue

    1. 在 Team.vue 页面中搜索出来的队伍上添加一个更新队伍的按钮,【不是队伍的创建人看不见此按钮】,所以这里要获取当前的登录用户,用户的id等于team.userId就展示该按钮

      <van-button v-if="team.userId === currentUser?.id" plain type="primary" size="small" @click="doUpdateTeam(team.id)">更新队伍</van-button>
      
      //--------------因为更新队伍按钮只有用户的创建人才能看到,所以这里先获取当前的登录用户信息----
      const currentUser = ref();
      onMounted(async () =>{
          const res = await getCurrentUser();
          if(res.data.code === 0){
              currentUser.value = res.data.data;
          }
          else{
              showFailToast("获取用户信息失败")
          }
      })
      
    2. 点击更新队按钮,将id传给TeamUpdate.vue,并进行跳转

      //点击更新队伍按钮进行跳转与传递队伍id
      const doUpdateTeam = (id:number) =>{
          router.push({
              path:"/team/update",
              query:{
                  id,
              }
          })
      }
      
    3. TeamUpdate.vue 回显原队伍的信息,这里使用axios访问后端 /team/get 接口,这是更具id来获取队伍的信息的接口,只用 route 接收 TeamCardList,传过来的id,并在访问后端前判断该id是否为空

      //接收 TeamCardList传过来的id
      const id = route.query.id;
      
      /**
           * 当这个页面加载的时候获取所要更新的队伍信息
           */
      onMounted(async () =>{
          if(id <= 0){
              showFailToast("队伍更新失败")
              return;
          }
          const res = await myAxois.get("/team/get",{
              params:{
                  id,
              }
          })
          if(res?.data.code === 0){
             addTeamData.value = res.data.data;
             }else{
              showFailToast("Fail")
          }
      })
      
    4. 点击提交更新用户信息并跳转到 Team.vue,使用axios访问后端接口,这里同样需要注意的是用户选择的 status 是字符串类型,要将其转换成number类型才能向后端传递

      /**
           * 点击提交按钮,更新队伍信息
           * @returns {Promise<void>}
           */
      const onSubmit = async () =>{
          const postData = {
              ...addTeamData.value,
              status:Number(addTeamData.value.status)
          }
          const res = await myAxois.post("/team/update",postData);
          console.log(postData)
          if(res.data.code === 0){
              showSuccessToast("更新队伍成功");
              router.push({
                  path:"/team/",
                  replace:true
              })
          }else{
              showFailToast("更新队伍失败")
          }
      }
      
      

      功能完成

编写查询我创建的队伍接口

查询我创建的队伍只要根据team表中的userId字段查询,userId字段 = 当前登录用户的id,即为所求。但这里为了符合开闭原则(尽量复用已有代码),这里仍可以调用 teamService 中的 listTeam 方法,因为这个方法已经写了根据创建人id进行查询的逻辑,但状态逻辑限定了只有管理员才能查询出该队伍,要将权限设置为true

/**
     * 查询我创建的队伍(查询team表中userId字段)
     * @param teamQuery
     * @param request
     * @return
     */
@GetMapping("/list/my/create")
public BaseResponse<List<TeamUserVO>> getMyCreateTeam(TeamQuery teamQuery, HttpServletRequest request) {
    if (teamQuery == null) {
        throw new BusinessException(ErrorType.PARAMS_ERROR);
    }

    User loginUser = userService.getLoginUser(request);
    teamQuery.setUserId(loginUser.getId());
    //这里设置管理员的权限为true,是因为我创建的队伍
    List<TeamUserVO> myTeamList = teamService.listTeam(teamQuery, true);
    return ResultResponse.success(myTeamList);
}

编写查询我加入的队伍

这里就需要查看关系表了,这里仍能够复用 teamService 中的 listTeam 方法,首先要明确,teamService是对team表进行操作,而team表中的id是每一个队伍的唯一标识,所以我们可以先查询出关系表中当前登录的userId对应哪些teamId,将对应的teamId形成一个列表传递到listTeam方法中。

具体实现:

  1. 在TeamQuery中新增一个字段属性 List<Long> idList ,用来存储关系表中查询出来的对应的teamId
  2. 首先先根据当前登录的userId,查询出关系表中userId字段对应当前登录用户id的所有数据,以一个List的形式
  3. 将查询到的所有数据使用stream流进行对应teamId的分组,teamId为key,userId为value
  4. 最后获取到teamId的列表,传给 TeamQuery的idList字段
  5. 调用 teamService 中的 listTeam 方法
  6. 在listTeam方法中,若idList 不为空,idList包含在team表中id的数据

cotroller中的代码:

/**
     * 查询我加入的队伍
     * @param teamQuery
     * @param request
     * @return
     */
@GetMapping("/list/my/join")
public BaseResponse<List<TeamUserVO>> getMyJoinTeam(TeamQuery teamQuery, HttpServletRequest request) {
    if (teamQuery == null) {
        throw new BusinessException(ErrorType.PARAMS_ERROR);
    }
    User loginUser = userService.getLoginUser(request);

    QueryWrapper<UserTeam> queryWrapper = new QueryWrapper<>();
    queryWrapper.eq("userId",loginUser.getId());
    List<UserTeam> userTeamList = userTeamService.list(queryWrapper);//查询出来关系表中当前登录用户对应的id列表

    //将userId与对应的teamId进行对应的分组
    //下面的这一行代码:userId和对用的teamId进行分组,teamId为key,userId为value,就行形成下面类似例子
    //比如:userId=1的用户加入了,队伍2,3,4。就会表示为:2 => 1,3 => 1,4 => 1
    Map<Long, List<UserTeam>> listMap = userTeamList.stream().collect(Collectors.groupingBy(UserTeam::getTeamId));
    List<Long> idList = new ArrayList<>(listMap.keySet());
    teamQuery.setIdList(idList);

    //这里设置管理员的权限为true,是因为我创建的队伍
    List<TeamUserVO> myTeamList = teamService.listTeam(teamQuery, true);
    return ResultResponse.success(myTeamList);
}

listTeam方法中增加的代码:

//根据idList查询出当前用户所加入的队伍
List<Long> idList = teamQuery.getIdList();
if(idList.size() > 0){
    queryWrapper.in("id",idList);
}

前端实现(我创建的队伍)和(我加入的队伍)

将User.vue复制一份改名为UserUpdate.vue,这里将 User.vue 改造一下,这里放入4个单元格,分别是:当前用户、修改信息、我创建的队伍、我加入的队伍vue(就是说UserUpdate.vue,写的是原User.vue的内容)

<template>
    <template v-if="user">
        <van-cell title="当前用户" :value="user.username" size="large" />
        <van-cell title="修改信息" is-link to="/user/update" />
        <van-cell title="我创建的队伍" is-link to="/user/create" />
        <van-cell title="我加入的队伍" is-link to="/user/join" />
    </template>
</template>
  1. 新建UserTeamCreate.vue和UserTeamJoin.vue两个页面,(这两个页面都是Team.vue复制过来的),路由注册
  2. 使用axios实现接口

前端退出队伍和解散队伍

在 TeamCardList.vue,添加退出队伍按钮和解散队伍按钮,点击该按钮进行axios请求访问,注意后端参数字段是teamId

<!--todo 仅加入了队伍的人才能看到退出队伍按钮-->
    <van-button  plain type="primary" size="small" @click="doQuitTeam(team.id)">退出队伍</van-button>

<van-button v-if="team.userId === currentUser?.id" plain type="primary" size="small"
@click="doDeleteTeam(team.id)">解散队伍</van-button>

//退出队伍功能
const doQuitTeam = async (id:number) =>{
    const res = await myAxois.post("/team/quit",{
        teamId:id
    });
    if(res.data.code === 0){
        showSuccessToast("退出成功")
    }else{
        showFailToast("退出失败")
    }
}

//解散队伍功能
const doDeleteTeam = async (id:number) =>{
    const res = await myAxois.post("/team/delete",{
        teamId:id
    });
    if(res.data.code === 0){
        showSuccessToast("解散成功")
    }else{
        showFailToast("解散失败")
    }
}

随机匹配

为了帮大家更快地发现和自己兴趣相同的朋友

根据标签的相似度进行随机匹配,本质:找到有相似标签的用户。

1. 怎么匹配

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

这里使用编辑距离算法

编辑距离算法:https://blog.csdn.net/DBC_121/article/details/104198838

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

这个算法是怎么实现的呢?

比如:

用户 A:[Java, 大一, 男]

用户 B:[Java, 大二, 男]

用户 C:[Python, 大二, 女]

用户A需要修改一次变成用户B,用户A需要修改三次变成用户C,所以A-B的距离 < A-C 的距离,所以B更相似(编辑距离算法的实现)

具体运用编辑距离算法

  1. 参考https://blog.csdn.net/DBC_121/article/details/104198838中的代码,创建一个AlgorithmUtils类,将其复制下来,并进行修改,传入的是标签列表参数java

    /**
         * 编辑距离算法(用于计算最相似的两组标签)
         * https://blog.csdn.net/DBC_121/article/details/104198838
         * @param list1:标签1
         * @param list2:标签2
         * @return
         */
    public static int minDistance(List<String> list1, List<Integer> list2){
        int n = list1.size();
        int m = list2.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(list1.get(i - 1),list2.get(j - 1)))
                    left_down += 1;
                d[i][j] = Math.min(left, Math.min(down, left_down));
            }
        }
        return d[n][m];
    }
    

2. 怎么对所有用户匹配,取 TOP

直接取出所有用户,依次和当前用户计算分数,取 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. 提前运算出来结果,缓存(针对一些重点用户,提前缓存)

大数据推荐,比如说有几亿个商品,难道要查出来所有的商品?

难道要对所有的数据计算一遍相似度?

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

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

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

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

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

3、使用编辑距离算法根据标签找出最匹配的用户

  1. 在 UserController 中创建一个matchUser方法,参数为【num:展示多少个最佳匹配用户,最多20个】和request 用来获取当前登录用户

    /**
         * 获取最佳匹配用户
         * @param num:展示多少个最佳匹配用户,最多20个
         * @param request
         * @return
         */
    @GetMapping("/match")
    public BaseResponse<List<UserVO>> matchUser(long num, HttpServletRequest request){
        if(num <= 0 || num > 20){
            throw new BusinessException(ErrorType.PARAMS_ERROR);
        }
        User loginUser = userService.getLoginUser(request);
        List<UserVO> list = userService.matcherUser(num, loginUser);
        return ResultResponse.success(list);
    
    }
    
  2. UserService编写List<UserVO> matcherUser(long num, User loginUser);,在实现类中实现该方法。

    1. 首先先查出只包含id和tags这两列的所有用户信息,并且过滤掉标签为空的
    2. 取出当前登录的用户的标签,并且将json格式转成字符串
    3. 使用List列表存放pair这个数据结构,pair这个数据结构存放的是键值对,所以在pair中存储 key:用户信息(user),value:相似度 【用户列表下标 => 相似度】
    4. 遍历第一步查询出来的所有用信息,取出标签并调用编辑距离算法这个工具类进行计算,距离越小,权重越大,并将其存放到pair中再存放到List中
    5. 遍历完成后,对List中的pair中的value进行从小到大的排序(将距离进行排序)
    6. 因为我们第一步只查询了id和tags,所以List中的pair中的user只有这两个字段,所以要使用queryWrapper中的in查询数据库中对用id的所有信息
    7. 但是由于in查询会打乱我们第五步的排序效果,这里又需要使用map进行重新排序【这里逻辑挺乱的,需要多看】
    8. 脱敏
    /**
         * 匹配最佳用户
         * @param num
         * @param loginUser
         * @return
         */
    @Override
    public List<UserVO> matcherUser(long num, User loginUser) {
        //1、首先先查出只包含id和tags这两列的所有用户信息,并且过滤掉标签为空的
        QueryWrapper queryWrapper = new QueryWrapper();
        queryWrapper.select("id","tags");
        queryWrapper.isNotNull("tags");
        List<User> userList = this.list(queryWrapper);
    
        //2、取出当前登录的用户的标签,并且如果是json格式的标签就转成字符串
        String loginUserTags = loginUser.getTags();
        Gson gson = new Gson();
        List<String> loginUserTagList = gson.fromJson(loginUserTags, new TypeToken<List<String>>() {
        }.getType());
    
        //3、使用List中存放pair的数据结构存储 pair中存储 key:用户信息(user),value:相似度 【用户列表下标 => 相似度】
        List<Pair<User,Long>> list = new ArrayList<>();
    
        //依次计算当前用户和与所有用户的相似度
        for(int i = 0; i < userList.size();i++){
    
            //将取出user转成userVO
            User user = userList.get(i);
    
            //无标签或者当前用户为自己就不进行计算
            if(user.getTags() == null || user.getId() == loginUser.getId()){
                continue;
            }
    
            //将当前遍历的标签的json格式转化成字符串
            String tags = user.getTags();
            List<String> userTagList = gson.fromJson(tags, new TypeToken<List<String>>() {
            }.getType());
    
            //使用编辑距离算法进行计算
            long distance = AlgorithmUtils.minDistance(loginUserTagList, userTagList);
    
            //把它放到List中
            list.add(new Pair<User,Long>(user,distance));
        }
    
        //按编辑距离从小到大排序
        List<Pair<User, Long>> topTagList = list.stream().sorted((a, b) -> (int) (a.getValue() - b.getValue()))
            .limit(num)
            .collect(Collectors.toList());
    
        //因为topTagList的存放的键是用户(User),但这里的User只有id和tags字段,所以这里将所有的User的id取出来
        List<Long> matchUserIdList = topTagList.stream().map(pair -> pair.getKey().getId()).collect(Collectors.toList());
    
        //因为我们第一步之查出id和tags的信息,所以现在查询对应id的用户的信息
        QueryWrapper<User> userVOQueryWrapper = new QueryWrapper<>();
        userVOQueryWrapper.in("id",matchUserIdList);
    
        //注意这里使用queryWrapper中的in进行查询,所查询来的用户的id没有根据topTagList中的user中的id进行排序
        //例如:matchUserIdList的user中的id = 1,3,2
        //而通过wrapper查询出来的user的id = 1,2,3
        //接下来的做法要使用map记录user的id和user的信息 像这样:1 => user1 2 => user2 3 => user3
        //再通过遍历matchUserIdList,get(id)取出对用用户信息
        Map<Long, List<User>> userIdUserListMap = this.list(userVOQueryWrapper)
            .stream()
            .collect(Collectors.groupingBy(User::getId));
    
        List<User> matchUserList = new ArrayList<>();
        for(Long id : matchUserIdList){
            matchUserList.add(userIdUserListMap.get(id).get(0));
        }
    
        //脱敏
        List<UserVO> matchUserVOList = new ArrayList<>();
        for (User user : matchUserList) {
            UserVO userVO = new UserVO();
            BeanUtils.copyProperties(user,userVO);
            matchUserVOList.add(userVO);
        }
    
        return matchUserVOList;
    }
    

编写前端最佳匹配实现

  1. 在 index.vue 页面上添加一个vant组件按的开关,用来控制是普通的推荐页面还是最佳匹配推荐出来的页面。

    <van-cell center title="心动模式">
        <template #right-icon>
    		<van-switch v-model="isMatchMode" size="24"/>
        </template>
    </van-cell>
    

    上面这个开关需要一个v-model并且是boolean类型,所以在

伙伴匹配项目最后一期【优化上线】

本期主要优化之前的遗留问题,并且尽可能将项目上线

优化退出队伍按钮和加入队伍按钮

  • 加入队伍:仅非队伍创建人、且未加入队伍的人可见
  • 更新队伍:仅创建人可见
  • 解散队伍:仅创建人可见
  • 退出队伍:创建人不可见,仅已加入队伍的人可见

更新队伍按钮和解散队伍按钮都只需要判断一个当前登录的用户id与队伍的userId是否一致即可判断出是否是队伍创建人

剩余的两个按钮需要这样做:

  1. 在后端的 TeamUserVO 类中加多一个字段 private boolean hasJoin = false; 用来表示当前用户是否已经加入队伍,默认是false
  2. 改造查询所有队伍接口(“/list”),原本的接口默认不传入参数的话就将所有的队伍都查出来了,那么我们首先将这所有队伍的id都取出来形成列表
  3. 在关联表中进行查询,创建查询条件,userId字段等于当前登录用户的id,teamId字段必须在所有队伍id列表中,这样就能获取到当前用户加入哪些队伍的列表
  4. 将其中的 teamId 取出来形成一个Set集合,遍历查询出所有队伍的列表,若某个对队伍的id在Set集合中,就设置该队伍的 hasJoin为true
  5. 、最后前端通过hasJoin字段来进行判断即可

导航栏标题写死的问题

思路:

根据路由的变化改变导航栏的标题。

  1. 在路由文件 router.ts 中每一个路由地址加上 title = “对应标题的名称”,

  2. 在导航栏 BasicLayout.vue 中的vant的NavBar导航的title改为动态获取的,在

解决前端跳转到登录页的问题

解决思路:

根据请求的返回值 response 中的 code 的数值来判断用户是否登录,若 response.data.code === 40100,就证明未登录

在全局请求拦截器中(myAxios.ts中的 myAxois.interceptors.response.use(function (response) {} 里)进行请求拦截

myAxois.interceptors.response.use(function (response) {
    console.log("我收到你的请求了",response.data.code)

    //进行请求拦截,用户未登录,跳转到登录页【根据响应码进行判断是否未登录】
    if(response?.data?.code === 40100){
        window.location.href = '/user/login'
    }

    // 2xx 范围内的状态码都会触发该函数。
    // 对响应数据做点什么
    return response;


}, function (error) {
    // 超出 2xx 范围的状态码都会触发该函数。
    // 对响应错误做点什么
    return Promise.reject(error);
});

优化创建队伍按钮样式

将创建队伍按钮变成一个圆加号

  1. 在创建队伍按钮中添加一个class = "add-button" 的样式,用来写css

  2. 载全局css样式中,写如下:

    .add-button{
        border-radius: 50%;
        left: 320px;
        width: 50px;
        height: 50px;
    }
    

前端加密队伍显示不出来和前端加密密码配对问题

加密队伍显示不出来

是因为后端接口的条件为:如果不传任何参数查询出所有的队伍信息,但是这里我们将status为空设置为public,所以只能每次都查询来用户为public的队伍,并且下面的逻辑是如果状态不是public并且不是管理员就会抛出异常。

  1. 所以这里我们将公开的房间和加密的房间区分展示在 Team.vue 页面,使用vant的标签页进行区分

    <van-tabs v-model:active="active" @change="onChange">
        <van-tab title="公开" name="public"></van-tab>
        <van-tab title="加密" name="secret"></van-tab>
    </van-tabs>
    
  2. 根据标签页中的name属性来区分是哪种状态,并根据name的值进行搜索

    const active = ref('public')
    const onChange = (name) =>{
        if(name === "public"){
            ListTeam(searchList.value,0);
        }else{
            ListTeam(searchList.value,2);
        }
    }
    
  3. 私密的状态只有创建人才能看到,所以在我创建的队伍页面也能展示私密的队伍,所以改造后端接口(/list/my/create),创建一个在teamService中创建一个新的方法

    /**
         * 查询我创建的队伍
         * @param teamQuery
         * @return
         */
    @Override
    public List<TeamUserVO> listMyCreateTeam(TeamQuery teamQuery) {
        Long userId = teamQuery.getUserId();
        if(userId == null){
            throw new BusinessException(ErrorType.NOT_LOGIN);
        }
        //查询出当前登录用户所有的队伍
        QueryWrapper<Team> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("userId",userId);
        List<Team> teamList = this.list(queryWrapper);
    
        List<TeamUserVO> teamUserVOList = new ArrayList<>();
        for (Team team : teamList) {
            TeamUserVO teamUserVO = new TeamUserVO();
            BeanUtils.copyProperties(team,teamUserVO);
            teamUserVOList.add(teamUserVO);
        }
        return teamUserVOList;
    }
    

前端加密密码配对

用户加入加密的队伍需要输入对用的密码,这里使用vant中的弹出框组件,并结合文本框进行密码的输入与传递。

<van-dialog v-model:show="showPasswordDialog" title="请输入密码" show-cancel-button @confirm="doJoinTeam" @cancel="doCancleTeam">
    <van-field v-model="password"  placeholder="请输入密码" />
</van-dialog>

逻辑:点击加入队伍按钮,要判断当前队伍的状态,如果是0,则将当前的队伍的id传给后端接口(/team/join),如果是0则弹出弹出框进行密码的输入,将密码和当前的队伍id传给后端接口。

  1. 定义一个ref风格的变量 joinTeamId,和输入框的变量password

    const joinTeamId = ref(0);
    const showPasswordDialog = ref(false);//这是控制弹出框的变量,false不弹出,true弹出
    const password = ref('');
    
  2. 改造加入队伍按钮的点击事件,事件改为:@click="preJoinTeam(team)

    <van-button v-if="currentUser?.id !== team.userId && !team.hasJoin" plain type="primary" size="small" @click="preJoinTeam(team)">加入队伍</van-button>
    
  3. 定义preJoinTeam事件,将joinTeamId 等于当前队伍的id,并且根据传来的team,对其状态进行判断,若是公开的则直接调用dojoinTeam方法,否则则弹出弹出框

    const preJoinTeam = (team:Team) =>{
        joinTeamId.value = team.id
        if(team.status === 0){
            doJoinTeam()
        }else{
            showPasswordDialog.value = true
        }
    }
    
  4. 改造doJoinTeam,方法,访问后端的参数为 joinTeamidpassword

    const doJoinTeam = async () =>{
        if(!joinTeamId.value){
            return
        }
        const res = await myAxois.post("/team/join",{
            teamId:joinTeamId.value,
            password:password.value
        })
        if(res.data.code === 0){
            showFailToast("加入成功");
        }else{
            showFailToast("加入失败")
        }
    }
    
  5. 最后写一个 doCancleTeam 方法,弹出框点击取消按钮,将password清空,将joinTeamId置为0

    //这个是dialog的取消方法
    const doCancleTeam = () =>{
        password.value = '';
        joinTeamId.value = 0;
    }
    

展示队伍加入的人数

同样在后端接口(/team/list)中改。

//查询加入队伍的用户人数
QueryWrapper<UserTeam> userTeamJoinQueryWrapper = new QueryWrapper<>();
userTeamJoinQueryWrapper.in("teamId",teamIdList);
List<UserTeam> userTeamLists = userTeamService.list(userTeamJoinQueryWrapper);
Map<Long, List<UserTeam>> joinTeamMap = userTeamLists.stream().collect(Collectors.groupingBy(UserTeam::getId));
teamUserVOList.forEach(team ->{
    team.setJoinNum(joinTeamMap.getOrDefault(team.getId(),new ArrayList<>()).size());
});
  1. 首先明确,原本的接口已经将所有的队伍都查询出来了(出来一些私密状态),

重复加入队伍的问题(锁、分布式锁)并发请求可能出现问题【1:19:06】

前端实现退出登录接口

原本后端的logout接口有问题,将注销逻辑改为一下代码,改代码的逻辑:去到session并删除中的登录键。前端使用按钮进行实现。

/**
     * 注销逻辑
     *
     * @param request
     */
@Override
public int UserLogout(HttpServletRequest request) {
    //之前登录时候定义的session的键为常量
    request.getSession().removeAttribute(USER_LOGIN_STATE);
    return 1;
}

扩展功能之添加好友(√)

创建新好友申请表(√)

好友申请表的字段包含:

  • 申请人id(发送申请的用户id)【fromId】,
  • 接收人id(接收申请的用户id)【receiveId】,
  • 申请的状态(0-未通过,1-已同意,2-已过期,3-已撤销)
  • 申请备注【remark】

在用户表 User 中创建一个字段 friendIds 好友的id,类型为varchar(512)注意里面的值是json格式。注意改表后需要修改三个地方:实体类,userMapper.xml,getSafeUser()(脱敏方法)

申请添加好友接口 (有todo)

todo:使用锁确保不能重复申请

流程:用户选择某个用户,点击申请添加为好友,填写申请备注,前端将 当前登录的用户 和 所选用户的id 和 申请备注 传给后端

传给后端的参数:当前登录用户(request),所选用户id(reviceId),备注(remark)

  1. 参数判断
  2. 备注字符不能大于102个字符
  3. 如果备注为空,默认填写其用户名
  4. 不能添加自己为好友
  5. 不能重复申请

展示申请的列表接口(√)

流程:用户A想添加用户B为好友,用户A提交了好友申请,用户B应该查看到申请的消息,该消息包括申请人的头像,用户名,备注。

形参:当前登录用户(request);返回的参数应为:用户A的头像,用户名,备注。

  1. 通过用户好友管理表找到是谁(fromId)给我发的好友申请,并查出备注
  2. 根据第一步的 fromId 查询出其头像与用户名
  3. 最后传给friendsVO,(脱敏)

展示我的申请接口(√)

流程:点击我的申请能够查看我申请的信息

前端传过来的参数:当前登录用户(request)

  1. 查询出好友管理表中fromId对应当前用户id,并且状态必须为0的好友申请
  2. 根据上一步获取的好友申请,获取到receiveId所对应的用户信息
  3. 脱敏返回

同意申请为好友接口(√)

流程:用户可以点击选择同意与拒绝,若选择点击不同意前端进行对应的反馈处理,同意则访问此接口。

传给后端的参数:申请人的id,当前登录的用户(request)

  1. 将根据申请人的 id 将好友管理表的的状态改为 1 (已同意)
  2. friendsIds这个字段进行修改,注意传入的是json格式,将申请人的id写入接收者的friendsIds,将接收者的id写入申请人的friendsIds,【下面是具体步骤】
    1. 首先先将申请的数据(friendsList)在好友申请管理表中取出来,进行判断,该数组为空抛出异常,该数组的长度大于1抛异常
    2. 【需要解决的问题是要将:申请人的id写入接收者的friendsIds ;接收者的id写入申请人的friendsIds,并注意是json格式,并且要修改状态】
      1. 我们可以将friendsList继续遍历,遍历中我通过 userService查询出申请人和接收者中对应User表中的所有信息。
      2. 创建一个工具类,将json数组转化成Long类型的set集合
      3. 取出申请人和接收者对应的 friendsIds,并将调用工具类将其转换成set集合
      4. 在申请人的set集合中加入接收者的id,在接收者的set集合中加入申请人的id
      5. 然后分别将这两个set转成json格式,并设置在其第一步的实体类中
      6. 将friends的状态给位1(已同意)
      7. 最后使用update操作,分别进行更新操作,但需要注意:万一更新中出现了问题,要考虑原子性(要么都做要么都不做),这里使用一个 AtomicBoolean

拒绝申请为好友接口(√)

流程:用户点击拒绝按钮拒绝称为好友,就把好友管理表中对应的记录进行逻辑删除

传给后端的参数:申请人id,当前登录用户(request)

  1. 根据申请人id和当前登录用户查询出对应的记录
  2. 进行逻辑删除(加上原子性)

展示我的好友接口(√)

流程:用户点击我的好友就能展示出我所添加的好友。

参数:当前登录的用户(request),返回的参数:UserVO

  1. 查询出当前登录用户的 friendsIds对应的User信息并传给封装类

前端展示好友申请列表(√)

模仿index页面,将用户信息以卡片的形式提交

前端查看申请列表(√)

注意tags标签是json格式,前端使用JSON.parse进行转换

整改用户卡片,点击卡片看该用户信息(√)

判断当前某个用户是否为我的好友接口(√)

流程:用户在 index.vue 点击某个用户会显示这个用户是否是我的好友,不是则展示添加好友按钮,是就展示删除好友按钮

前端传来的参数:所选用户的id,当前登录用户(request)

前端好友消息提示(√)

思路:后端写接口统计数量,注意:是统计状态为不同意的,因为同意了表示用户已经看过了,而拒绝会删除此记录

    /**
     * 查看申请数量
     * @param request
     * @return
     */
    @GetMapping("/count")
    public BaseResponse<Long> CountApply(HttpServletRequest request){
        User loginUser = userService.getLoginUser(request);
        long userId = loginUser.getId();
        QueryWrapper<Friends> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("receiveId",userId);
        long count = friendsService.count(queryWrapper);
        return ResultResponse.success(count);
    }

删除好友接口(√)

思路:取出当前登录用户的好友friendsIds,注意取出来的是json格式,这时要将其转成Long类型的set集合,删除集合中对应的好友id,最后将其update回用户表中。

前端传过来的参数:被删除的用户id,当前登录用户(request)

  1. 查询出登录的friendsIds,将其转成set,删除set对应的id,将set转成json,最后更新用户表friendsIds字段
  2. 查询出被删除用户的id,将其转成set,删除set对应的当前登录用户的id,将set转成json,最后更新用户表friendsIds字段

前端界面显示好友申请数(√)

image-20240602090531680

在导航栏设置当路由路径为(“/”)即主页面时,调用后端(/friend/count)接口将变量num改为后去到的值。

onMounted(async () =>{
    if(route.path === '/'){
        const res = await myAxois.get("/friend/count");
        if(res.data.code === 0){
            num.value = res.data.data;
        }else{
            showFailToast("统计失败")
        }
    }
})

扩展功能之添加标签接口(√)

在用户信息栏中添加多一个选项,点击可以实现添加标签,标签可以选择像search.vue页面搜索标签一样进行选择添加,用户也可以自己输入标签。

用户手动输入标签(todo)

扩展功能之发帖子

建表

创建一个帖子表(poster),字段如下

id:主键

userId(创建人id):bigint

title(文章标题)

context(文章内容)

views(阅读的人)【应该存的是json(像好友一样)】

comments(评论量)

status(0-公开,1-仅好友可见,2-仅在同一队伍的人可见)

createTime(创建时间)

updateTime(更新时间)

isDelete(逻辑删除)

创建评论表(comment)(todo,未必能实现)

id:主键

posterId(帖子的id)

userId(评论人的id)

context(评论内容)

createTime(创建时间)

updateTime(更新时间)

isDelete(逻辑删除)

创建帖子接口

流程:用户点击创建帖子,就可以在前端进行编辑,点击提交进行后端进行创建

前端传过来的参数:title,content,当前登录用户(request),status(谁可见)

  1. 参数判断
  2. 标题的长度不能超过50个字符

删除帖子接口

流程:用户只能以删除自己创建的帖子

前端传过来的参数:帖子的id,当前登录用户(request)

  1. 根据当前登录的用户查询帖子表判断是否为该帖子的创建人
  2. 进行逻辑删除

展示帖子接口

流程:用户打开帖子栏,能够查看到所有帖子,用户也可以点击某个指定的帖子查看其详情

前端传过来的参数:帖子的id,当前登录用户

  1. 若前端没传帖子id,则展示状态为0的帖子,
  2. 若传了帖子id则查看对应帖子的status进行判断
    1. 若是0为公开的,可以查看
    2. 若是1,则要判断当前登录用户与帖子的创建人是否为好友
    3. 若是2,则要查询 user_team表看是否在帖子创建人的队伍中

项目上线【1:42:03】

重点:数据库一定要搞好,本项目用到了redis缓存,所以推荐使用宝塔,宝塔下载redis软件,就能能跑通redis,还有数据库,只要在宝塔中下载就行,不知道为什么只能下载5.几的版本,最好能下载8版本,因为5版本会因为时间字段CURRENT_TIMESTAMP而插入不了数据库,

跨域的解决,前端访问的是(域名/api => http://www.academicnav.cn/api,一定要有api),后端通过@CrossOrigin注解,填入的是域名,这样就能解决跨域问题

伙伴匹配线上待优化的点

  1. 底部导航栏的好友icon没有显示出申请数量,需要刷新才能显示
  2. 前端性别0于1的区分(√)
  3. 用户量不大,先改为查询展示所有用户(或者分页)
  4. 切换心动模式出错
  5. 我的申请为空时会出错(√)
  6. 队伍页切换加密的时候若刚开始没有加密队伍也会出错(√)
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值