一.需求分析
- 用户去添加标签,标签的分类(要有哪些标签、怎么把标签分类)
- 主动搜索:允许用户根据标签去搜索其他用户
- 组队
- 创建队伍
- 加入队伍‘
- 根据标签查询队伍
- 邀请其他人
- 允许用户修改标签
- 推荐
- 相似度计算算法+本地分布式计算
二.技术栈
后端
- Java编程语言+SpringBoot框架
- SpringMVC+MyBatis+Mybatis Plus(提高开发效率)
- MySQL数据库
- Redis缓存
- Swagger+Knife4j接口文档
三.数据库表设计
1.标签表(分类表)
建议用标签,不要用分类,更灵活
性别:男、女
方向:Java、c++,Go、前段
目标:考研、春招、秋招、社招、考公、竞赛(蓝桥杯)
段位:初级、中级、高级、王者
身份:小学、初中、高中、大一、大二、大三、大四、学生、待业、已就业、研一、研二、研三
状态:乐观、有点丧、一般、单身、已婚、有对象
字段:
id int 主键
标签名 varchar 非空
上传标签的用户 userId int
父标签id,parentId,int (分类)
是否为父标签 isParent,tinyint(0、1)
创建时间createTime,datetime
更新时间updateTime,datetime
是否删除isDelete,tinyint(0、1)
- 通过控制台创建数据库表
- 创建成功
2.修改用户表
用户有那些标签?
- 直接在用户表补充tags字段
- 优点:查询方便,不用新建关联表,标签是用户的固有属性(除了该系统,其他系统可能)节省开发成本
- 缺点:用户表多一列,
- 加一个关联表,记录用户和标签的关系
- 关联表的应用场景:查询灵活,可以正查反查
- 缺点:要多建一个表,多维护一个表
四.标签搜索用户功能
1.开发后端接口
- SQL查询
- 允许用户传入标签,多个标签存在才搜索出来and。like '%Java%' and like '%%%C++'
- 允许用户传入多个标签,有任何一个标签存在就能搜出来or。like '%Java%' or like '%%%C++'
- 内存查询(灵活,可以通过并发进一步优化)
- 如果参数可以分析,根据用户的参数选择查询方式,比如标签数
- 如果参数不可分析,并且数据库连接足够、内存空间足够,可以兵法同时查询,谁先返回用谁
- 还可以SQL查询与内存计算相结合,比如先用SQL过滤掉部分tag
- 建议通过实际测试来分析哪种查询比较快,数据量大的时候验证效果更没明显
- 根据标签列表搜索用户,首先进行判空,若为空,直接抛出异常
- 快捷键alt+enter,可以快速拿到返回值
- 报红问题。跟着视频里敲的代码,结果出现了报红
经过查看,发现返回值的使用错了,大小写拼写错误,前文自动生成返回值时,没有仔细看,采取了默认的返回值。
- 链式调用,首先用ofNullable封装一个可能为空对象,再用orElse给出一个默认值,如果为空的话则取orElse给的值,不为空则取值
- 报错
检查后发现数据库建库时,对应字段拼写错误
- Controller层实现
- Apifox测试接口出错,报404
经过检查发现接口路径错误,路径少了一个”/api”,可以在环境里修改,
也可以在这里修改
再次测试,接口正确返回数据
- BsaeMapper:BaseMapper 是 MyBatis-Plus 框架中的一个核心接口,主要用于简化常见的数据库 CRUD (Create, Retrieve, Update, Delete) 操作。以下是 BaseMapper 的一些特点和功能:
-
- 简化开发:通过继承 BaseMapper 接口,可以自动获得一系列预定义的数据访问方法,如查询、插入、更新、删除等,无需手动编写 SQL 语句。
- 通用方法:提供了如 selectById, selectList, insert, updateById, deleteById 等方法,适用于大多数基于实体类(POJO)的操作。
- 泛型设计:BaseMapper 是一个泛型接口,通常使用方式为 BaseMapper<T>,其中 T 是一个实体类类型,这样可以针对特定的实体类提供数据库操作。
- 扩展性强:除了基本的 CRUD 方法外,还可以根据业务需求自定义其他方法,并结合 MyBatis-Plus 的特性进行灵活扩展。
- 集成方便:在项目中引入 MyBatis-Plus 后,只需简单配置即可使用 BaseMapper,并可轻松集成到 Spring 或 Spring Boot 项目中。
- queryMapper:queryWrapper 是一个查询包装器对象,通常用于 MyBatis Plus 中来构建动态 SQL 查询条件。具体来说:
-
- 封装查询条件:queryWrapper 可以用来添加各种查询条件,如等于、不等于、大于、小于、模糊查询等。
- 支持链式调用:通过链式调用的方法,可以方便地添加多个查询条件。
- 灵活的查询方式:可以添加排序、分组等其他查询相关设置。
- 例如:.eq("column_name", value):添加等于条件。
.like("column_name", value):添加模糊查询条件。
-
- 总之,queryWrapper 用于灵活地构建复杂的查询条件,并将其传递给 selectCount 方法,从而获取符合条件的记录总数。
- 解析JSON字符串
序列化:Java对象转为json
反序列化:把json转为Java对象
Java json序列化库有很多:
-
-
- fastjson(快,但是漏洞太多)
- gson()
- jsckson
- kryo
-
- Java8特性
- stream/parallelStream:
- Optional可选类:
2.Java后端整合Swagger+Knife4j接口文档
- Swagger接口文档-CSDN博客
- 添加依赖
-
- 如果springboot version>=2.6,需要添加如下配置
spring:
mvc:
pathmatch:
matching-strategy:ANT_PATH_MATCHER
- 接口文档,文档中的内容即为接口的信息,每条接口包括:
- 请求参数
- 响应参数
- 错误码
- 接口地址
- 接口名称
- 请求类型
- 请求格式
- 备注
- 接口文档便于前段和后端开发对接,前后端联调的介质。
- Swagger接口文档原理
- 自定义Swagger配置类
- 定义需要生成接口文档的代码位置(Controller)
- 线上环境注意不要暴露接口位置!!可以通过在SwaggerConfig配置文件开头加上@Profile({"dev","test"}),
- 启动即可
- 可以通过在controller方法上添加@Api、@AplimplicitParam(name="name",value="姓名",required=true) @ApiOperation(value="向客人问好")等主角儿来自定义申海成的接口描述信息
3.存量用户信息导入及同步
- 把所有星球用户信息的导入
- 把写了自我介绍的同学的用户信息导入
4.看上了网页信息,怎样抓到(爬虫)
- 分析原网站是怎样获取这些信息的
- 用程序去调用接口(Java/python都可以)
- 处理(清洗)一下数据,之后就可以写到数据库里
- 流程:
- 从excel中导入全量用户数据,判重。 easyexcel
- (例)抓取写了自我介绍的同学信息,提取出用户昵称、用户唯一id、自我介绍信息
- 从自我介绍中提取信息,然后写入到数据库中
- easyexcel读Excel | Easy Excel 官网两种读对象的方式:
- 确定表头:建立对象
- 不确定表头:每一行数据映射为Map<String,Object>
- 两种读取模式:
- 监听器:先创建监听器,在读取文件时绑定监听器。单独抽离处理逻辑,代码清晰易于维护;一条一条处理,适用于数据量大的场景。
- 同步读:无需创建监听器,要获取完整数据。方便简单,但是数据量大时会有等待时常,也可能内存溢出。
- 使用流处理userInfoList列表。过滤掉用户名(username)为空的用户信息。将过滤后的用户信息按照用户名进行分组,并收集到一个Map中,其中键为用户名,值为用户名对应的用户信息列表。
- 注解@Profile:可以通过在SwaggerConfig配置文件开头加上@Profile({"dev","test"}),
五.用户的登录信息
共享存储
- 如何共享存储?
- Redis(基于内存的K/V数据库)此处选择Redis,因为用户信息读取/是否登录判断极其频繁,Redis基于内存,读写性能很高,简单的数据单机qps5w-10w
- Redis管理工具-quick Redis
- 引入Redis,能操作redis,安装quickredis
-
- 引入spring-session和redis的整合,使自动将session存储到redis中
- 修改spring-session存储配置spring.session.store-type,默认是none,表示存储在单台服务器
- store-type:redis,表示从redis读写session
- 在quickredis可以看到序列化后的session
- MySQL
- 文件服务器ceph
六.个人信息修改功能
更新接口
- 在service中写方法,获取用户登录信息
- 检查传入的user对象ID是否有效,无效则抛出参数错误异常。检查是否有更新操作的权限:管理员可更新任意用户,普通用户只能更新自身信息,否则抛出无权限异常。通过ID查询旧用户信息,若不存在,则抛出空值异常。最后,执行用户信息更新并返回影响行数。
- controller:处理POST请求/update。检查请求体中的User对象是否为空,若为空则抛出业务异常。假定调用者已验证管理员权限。从请求中获取已登录用户信息,并调用userService.updateUser方法更新用户信息。返回更新结果。
- 测试
- 报错:"user login failed, userAccount cannot match userPassword"
- 原因:存入数据库时对密码进行了加密,而我但是查看数据库时以为出现了乱码,进行了修改。从而造成了密码验证不对。
七.批量导入数据
导入数据
- 用可视化界面:适合一次性导入,数据量可控
- 写循环:for循环,建议分批,不要一把梭哈,要保证可控
- 执行SQL语句:适用于小数据量
- @EnableScheduling可以在Springboot中开启对定时任务的支持
- fixedDelay=3000//每隔3秒执行一次
- initialDelay=5000//首次执行的延迟为5秒
-
- 成功插入数据十万条,花费时间29秒
- 编写一次性任务:
- stopwatch:用于任务时间监控,在SPring及apache中均提供类似的任务时间监控功能。
- for循环插入数据的问题:
- 建立和释放数据库连接(批量查询解决,大幅提高插入效率)
- 20s十万条数据(批量例子)
- for循环是绝对线性的()
- 建立和释放数据库连接(批量查询解决,大幅提高插入效率)
- 并发批量插入用户数据
- 并发插入数据
-
- 并发请求过多时,数据库崩了!
-
- 十万条数据分十组,每组一万条数据
- join():
- 并发要注意执行的先后顺序无所谓,不要用到并发类的集合。
- cup密集型:分配的核心线程数=CPU-1
- IO密集型:分配的核心线程可以大于CPU核数
八.主页性能优化
1.性能优化
- 预加载缓存:定时更新缓存
- 多个机器都要执行任务吗?
分布式锁:控制同一时间只有一台机器去执行定时任务,其他机器不用重复执行了。
- 数据查询慢怎么办?
- 用缓存:提前把数据取出来保存好(通常可以保存在读写更快的介质,比如内存),就可以更快地读写。cache>内存>外存
- 缓存的实现
- Redis(分布式缓存)
- memcached(分布式)
- ehcache(单机)
- 本地缓存(Java内存Map)
- caffeine(java内存缓存,高性能)
- Google Guava
2.Redis入门
- NoSQL数据库,key-value存储系统(区别于MySQL,他储存的是键值对)
- Redis数据结构
- String字符串类型:name:”yupi”
- List列表:names:["yupi","yupi1","yupi"]
- Set集合:names:["yupi","yupi1"](值不能重复)
- Hash哈希:nameAge:{ "yupi":1,"yupi2":2 }
- Zset集合:names:{ yupi-9,yupi2-12 }(适合做排行榜)
- java里的实现方式:
- Spring Data Redis(推荐):通用的数据访问框架,定义一组增删改查的接口
- 引用
- Spring Data Redis(推荐):通用的数据访问框架,定义一组增删改查的接口
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.6.4</version>
</dependency>
-
-
- 配置Redis地址
-
spring:
# redis 配置
redis:
port: 6379
host: localhost
database: 0
-
-
- 自定义序列化
-
package com.yupi.yupao.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
@Configuration
public class RedisTemplateConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.setKeySerializer(RedisSerializer.string());
return redisTemplate;
}
}
-
- Jedis
- 独立于Spring操作Redis的
- Lettuce
- 高阶的操作Redis的java客户端
- 异步,连接池
- Redisson
- 分布式操作Redis的java客户端
- JetCache
- 对比:
- 如果用的是并且没有过多定制化要求,可以用Spring Data Redis,最方便
- 如果用的不是Spring,并且追求简单,并且没有过高的性能要求,可以哦那你换个Jedis+Jedis Pool
- 如果项目不是Spring,并且追求高性能、高定制化、可以用Lettuce、支持异步,连接池
- 如果项目是分布式的,需要用到一些分布式的特性(比如分布式锁,分布式集合),推荐用redisson
- Jedis
3.设计缓存key
不同用户看到的数据不同
redis内存不能无限增加,一定要设计过期时间!!!
4.缓存预热
- 优点:解决上述的问题,可以让用户始终访问很快
- 缺点:
- 增加开发成本。
- 预热的时机和时间如果错了,可能缓存的数据不对或者太老
- 需要占用额外空间
- 如何缓存预热
- 定时
- 模拟触发(手动触发)
- 定时任务实现
- Spring Scheduler(spring boot默认整合了)
- Quartz(独立于Spring存在的定时任务框架)
- XXL-Job之类的分布式任务调度平台(界面+sdk)
- 用定时任务每天刷新所有用户的推荐列表
- 缓存预热的意义(新增少,总用户多)
- 缓存0的空间不能太大,要预留其他缓存空间
- 缓存数据的周期(此处每天一次)
- 第一种方式实现
- 主类开启@EnableScheduling
- 给要定时执行的方法添加@Scheduling注解,指定corn表达式或者执行频率
5.控制定时任务的执行
- 原因
- 浪费资源,想象10000台服服务器一起“打鸣“
- 脏数据,比如重复插入
- 方法
- 分离定时任务程序,只在一个服务器运行定时任务。成本太大
- 写死配置,每个服务器都执行定时任务,但是ip符合配置的服务器才真实执行业务逻辑,其他的直接返回。成本最低;但是我们的ip可能不是固定的,把ip写的太死了。
- 动态配置,配置是可以轻松的,很方便地更新(代码无需重启),但是只有ip符合配置的服务器才真实执行业务逻辑。
- 数据库
- Redis
- 配置中心(Nacos,Apollo,Spring Cloud Config)
- 问题:服务器多了,ip不可控还是很麻烦,还是需要人工修改
6.分布式锁、锁
- 分布式锁,只有抢到锁的服务器才能执行业务逻辑
- 坏处:增加成本
- 好处:不用手动匹配值,多少个服务器都一样。
- 锁:有限的资源的情况下,控制同一时间段只有某些线程(用户/服务器)才能访问资源。
- Java实现锁:synchronized关键字,并发包的类
- 问题:只对单个JVM有效
- 抢锁机制
- 核心思想:先来的人把数据改成自己的标识(服务器ip),后来的人发现标识已存在,就抢锁败,继续等待,等想来的人执行方法结束,把标识清空,其他的人继续抢锁。
- MySQL数据库:select for update 行级锁(最简单)
- (乐观锁)
- Redis实现:内存数据库,读写速度快。支持setnx、lua脚本,比较方便我们实现分布式锁。
- Zookeeper实现
- 注意事项
- 用完的锁要释放
- 一定要设置过期时间
- 如果方法执行过长,锁提前过期了
- 问题:
- 连锁效应:释放掉别人的锁
- 这样还是会存在多个方法同时执行的情况
- 解决方案
- 续期
- 问题:
- 释放锁的时候,有可能判断出是自己的锁,但这时锁过期了,最后还是释放了别人的锁
- redisson实现分布式锁
- redission是一个Java操作Redis的客户端,提供了大量的分布式数据来简化对Redis的操作和使用,可以让开发者像使用本地集合一样使用Redis,完全感知不到Redis的存在
- 2种引入方式
- Spring boot starter引入(内部推荐,版本迭代太快,容易冲突)
- 直接引入
- maven引入
-
-
-
- Redisson配置:yml配置文件中已经写好了port和host
-
-
-
-
-
- 所以,直接使用@ConfigurationProperties(prefix = "spring.redis"):注解用于将配置文件中的属性值自动绑定到类的字段上
-
-
- 定时任务+锁
- waitTime设置时间为0,只抢一次,抢不到就放弃
- 注意释放锁要写在finally中
- 看门狗机制(redisson中提供的续期机制)
- Redisson 分布式锁的watch dog自动续期机制_redisson续期-CSDN博客
- 开一个监听线程,,如果方法还没执行完,就帮你重置redis锁的过期时间。
- 原理:监听当前线程,默认时间是30s,每10s续期一次,如果续期线程挂掉,则不会续期。
- 下面是quickredis中可以查看到的当前线程的ttl,经过时间过去刷新,可以看到但ttl减少到20时,会自动刷新到30.
九.组队功能
1.需求分析
- 用户可以创建一个队伍,设置队伍的人数、队伍名称(标题)、描述、超时时间P0
- 修改队伍信息
- 用户可以加入队伍(其他人,未满、未过期)
- 用户可以退出队伍(如果队长退出,权限转移给第二早加入的用户--先来后到)、
- 队长可以解散队伍
- 分享队伍=》邀请其他用户加入队伍
2.库表设计
- 队伍表team字段
- id 主键 bigint(最简单,连续,放url上比较简单,但缺点是怕爬虫)
- name 队伍名称
- discription 描述
- 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 '队伍';
- 成功建立队伍表
- 使用MyBatisX-Generator根据数据库表生成代码
- 用户-队伍表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 '用户队伍关系';
- 成功建立用户-队伍关系表
- 两个关系
- 用户加了哪些队伍
- 队伍有那些用户
- 方式
- 建立用户表-队伍关系表teamId userId(查询性能高一些,可以选择这个,不用遍历全表)
- 用户表补充已加入的队伍字段,队伍表补充已加入的用户字段(不用写多对多的代码,可以直接根据队伍查用户,根据用户查队伍)
- 增删改查
- 业务逻辑开发
3.创建队伍
- 请求参数是否为空
- 是否登录,未登录不允许创建
- 校验信息
- 队伍人数>且<=20
-
- 队伍标题<=20
-
- 描述<=512
-
- status是否公开(int)不传默认为0(公开)
-
- 如果status时加密状态,一定要有密码,且密码<=32
创建一个队伍状态枚举TeamStatusEnum
校验状态是否加密
-
- 超时时间>当前时间
-
- 校验用户最多创建五个队伍
-
- 插入队伍信息到队伍表
开启事务,如果插入失败则立即回滚
插入创建队伍
-
- 插入用户=>队伍关系到关系表
-
- controller层
-
- 测试接口报错:
org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.util.Date` from String "2011-07-09 19:02:46": not a valid representation (error: Failed to parse Date value '2011-07-09 19:02:46': Cannot parse date "2011-07-09 19:02:46": while it seems to fit format 'yyyy-MM-dd'T'HH:mm:ss.SSSX', parsing fails (leniency? null)); nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `java.util.Date` from String "2011-07-09 19:02:46": not a valid representation (error: Failed to parse Date value '2011-07-09 19:02:46': Cannot parse date "2011-07-09 19:02:46": while it seems to fit format 'yyyy-MM-dd'T'HH:mm:ss.SSSX', parsing fails (leniency? null))
at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 5, column: 19] (through reference chain: com.yupi.yupao.model.request.TeamAddRequest["expireTime"])
-
- 报错原因:
-
- 修改:
-
- 测试成功
-
- 成功创建队伍
4.查询队伍列表
- 需求分析:展示队伍列表,根据名称搜索队伍PO,信息流中不展示已过期的队伍
- 从请求参数中取出队伍名称,如果存在则作为查询条件
- 不展示已过期的队伍(根据过期时间筛选)
- 只有管理员才能查看加密还有非公开的房间
- 关联查询已加入队伍的用户信息
- 接口实现
- 建立返回给前端的封装类Vo
-
- 组合查询条件
if (teamQuery != null) {
//获取id
Long id = teamQuery.getId();
if (id != null && id > 0) {
queryWrapper.eq("id", id);
}
List<Long> idList = teamQuery.getIdList();
if (CollectionUtils.isNotEmpty(idList)) {
queryWrapper.in("id", idList);
}
String searchText = teamQuery.getSearchText();
if (StringUtils.isNotBlank(searchText)) {
//整个表达式构建了一个复合查询条件:name字段包含searchText或者description字段包含searchText,
// 并且这个复合条件作为整体与之前的条件(如果有)通过AND逻辑连接
queryWrapper.and(qw -> qw.like("name", searchText).or().like("description", searchText));
}
//获取名称
String name = teamQuery.getName();
if (StringUtils.isNotBlank(name)) {
//用like查询,允许模糊匹配
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();
TeamStatusEnum statusEnum = TeamStatusEnum.getEnumByValue(status);
if (statusEnum == null) {
statusEnum = TeamStatusEnum.PUBLIC;
}
if (!isAdmin && statusEnum.equals(TeamStatusEnum.PRIVATE)) {
throw new BusinessException(ErrorCode.NO_AUTH);
}
queryWrapper.eq("status", statusEnum.getValue());
}
-
- 不展示已过期的队伍
// expireTime is null or expireTime > now()
//最终查询结果为所有未过期的记录加上没有设置过期时间的记录
queryWrapper.and(qw -> qw.gt("expireTime", new Date()).or().isNull("expireTime"));
//根据给定的查询条件从数据库中检索出所有符合要求的团队信息,并将结果存储在teamList变量中。
List<Team> teamList = this.list(queryWrapper);
if (CollectionUtils.isEmpty(teamList)) {
return new ArrayList<>();
}
List<TeamUserVO> teamUserVOList = new ArrayList<>();
-
- 关联查询创建人的用户信息
//从团队列表中获取每个团队的信息,并创建一个包含团队信息的新对象 TeamUserVO。
// 如果某个团队的用户ID为空,则跳过该团队。
for (Team team : teamList) {
Long userId = team.getUserId();
if (userId == null) {
continue;
}
User user = userService.getById(userId);
TeamUserVO teamUserVO = new TeamUserVO();
BeanUtils.copyProperties(team, teamUserVO);
// 脱敏用户信息
//根据非空的 user 实体创建并设置 teamUserVO 的创建者信息,
// 然后将其累积到一个列表中,该列表用于收集处理过的团队用户视图对象。
if (user != null) {
UserVO userVO = new UserVO();
BeanUtils.copyProperties(user, userVO);
teamUserVO.setCreateUser(userVO);
}
teamUserVOList.add(teamUserVO);
}
-
- 根据状态查询
// 根据状态来查询
Integer status = teamQuery.getStatus();
//根据输入的状态值获取相应的团队状态枚举,若无匹配项则使用公共状态作为默认值。
TeamStatusEnum statusEnum = TeamStatusEnum.getEnumByValue(status);
if (statusEnum == null) {
statusEnum = TeamStatusEnum.PUBLIC;
}
//如果用户不是管理员且团队状态为私有,则抛出无权限的业务异常
if (!isAdmin && statusEnum.equals(TeamStatusEnum.PRIVATE)) {
throw new BusinessException(ErrorCode.NO_AUTH);
}
queryWrapper.eq("status", statusEnum.getValue());
-
- 测试接口
5.修改用户信息
- 分析
- 判断请求参数是否为空
- 查询队伍是否存在
- 只有管理员或者队伍的创建者可以修改
- 如果用户传入的新值与老值一直,就不用update了,(可自行实现,降低数据库使用次数)
- 更新成功
- 实现
- controller层
@PostMapping("/update")
public BaseResponse<Boolean> updateTeam(@RequestBody TeamUpdateRequest teamUpdateRequest, HttpServletRequest request) {
if (teamUpdateRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
User loginUser = userService.getLoginUser(request);
boolean result = teamService.updateTeam(teamUpdateRequest, loginUser);
if (!result) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "更新失败");
}
return ResultUtils.success(true);
}
-
- serviceImpl
public boolean updateTeam(TeamUpdateRequest teamUpdateRequest, User loginUser) {
if (teamUpdateRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
Long id = teamUpdateRequest.getId();
if (id == null || id <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
Team oldTeam = this.getById(id);
if (oldTeam == null) {
throw new BusinessException(ErrorCode.NULL_ERROR, "队伍不存在");
}
// 如果不是创建者并且不是管理员,则抛出异常
if (oldTeam.getUserId() != loginUser.getId() && !userService.isAdmin(loginUser)) {
throw new BusinessException(ErrorCode.NO_AUTH);
}
TeamStatusEnum statusEnum = TeamStatusEnum.getEnumByValue(teamUpdateRequest.getStatus());
if (statusEnum.equals(TeamStatusEnum.SECRET)) {
if (StringUtils.isBlank(teamUpdateRequest.getPassword())) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "加密房间必须要设置密码");
}
}
Team updateTeam = new Team();
BeanUtils.copyProperties(teamUpdateRequest, updateTeam);
return this.updateById(updateTeam);
}
- 测试
- 接口测试成功
-
- 数据库修改完成
6.用户可以加入其他队伍
- 分析
- 用户最多加入5个队伍
- 队伍必须存在,只能加入未满,未过期的队伍
- 不能重复加入已加入的队伍(幂等性)
- 禁止接入私有的队伍
- 如果加入的队伍是私密的,必须密码匹配才可以
- 修改队伍信息,补充人数
- 新增队伍-用户关联信息
- 实现
- 建立用户加入队伍实体请求类
@Data
public class TeamJoinRequest implements Serializable {
private static final long serialVersionUID = 3191241716373120793L;
/**
* id
*/
private Long teamId;
/**
* 密码
*/
private String password;
}
-
- controller层
- 接收JSON格式的TeamJoinRequest对象和HttpServletRequest对象作为参数。
- 检查TeamJoinRequest是否为空,若为空则抛出业务异常。
- 从请求中获取已登录用户信息。
- 调用teamService的joinTeam方法,传入请求参数和登录用户信息,返回是否加入团队成功的结果。
- 将结果封装为成功响应并返回
- controller层
@PostMapping("/join")
public BaseResponse<Boolean> joinTeam(@RequestBody TeamJoinRequest teamJoinRequest, HttpServletRequest request) {
if (teamJoinRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
User loginUser = userService.getLoginUser(request);
boolean result = teamService.joinTeam(teamJoinRequest, loginUser);
return ResultUtils.success(result);
}
-
- serviceImpl实现
- 参数校验:检查teamJoinRequest是否为空,如果为空,则抛出业务异常BusinessException,错误码为PARAMS_ERROR。
- 获取团队信息:通过teamId获取团队信息team。
- 检查团队是否过期:获取团队的过期时间expireTime,如果过期时间不为空且早于当前时间,则抛出业务异常,错误信息为“队伍已过期”。
- 检查团队状态:获取团队的状态status,并转换为枚举类型TeamStatusEnum。如果团队状态为PRIVATE(私有),则抛出业务异常,错误信息为“禁止加入私有队伍”。
- 验证团队密码:如果团队状态为SECRET(加密):检查密码password是否为空或与团队密码不匹配。如果密码不正确,则抛出业务异常,错误信息为“密码错误”
//判空
if (teamJoinRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
Long teamId = teamJoinRequest.getTeamId();
Team team = getTeamById(teamId);
//获取队伍过期时间,判断是否过期,若过期则抛出异常
Date expireTime = team.getExpireTime();
if (expireTime != null && expireTime.before(new Date())) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "队伍已过期");
}
//获取当前队伍状态,若为私有状态,则抛出异常
Integer status = team.getStatus();
TeamStatusEnum teamStatusEnum = TeamStatusEnum.getEnumByValue(status);
if (TeamStatusEnum.PRIVATE.equals(teamStatusEnum)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "禁止加入私有队伍");
}
// 获取队伍的密码,若为加密状态,则判断密码是否为空,若不为空则判断密码是否正确,若不正确则抛出异常
String password = teamJoinRequest.getPassword();
if (TeamStatusEnum.SECRET.equals(teamStatusEnum)) {
if (StringUtils.isBlank(password) || !password.equals(team.getPassword())) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "密码错误");
}
}
-
- 测试接口
- 测试加入队伍
- 这是添加前的5号队伍
- 测试加入队伍
- 测试接口
-
-
-
- 这是添加后的5号队伍,添加成功
-
-
-
-
- 若用户已经加入该队伍,则会返回
-
7.用户可以退出队伍
- 分析
- 校验请求参数
- 校验队伍是否存在
- 校验我是否已加入队伍
- 如果是队长
- 如果队伍只剩一人,队伍解散
- 如果队伍还有其他人,权限转移给第二早加入的用户-先来后到
- 实现
- controller层
@PostMapping("/quit")
public BaseResponse<Boolean> quitTeam(@RequestBody TeamQuitRequest teamQuitRequest, HttpServletRequest request) {
if (teamQuitRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
User loginUser = userService.getLoginUser(request);
boolean result = teamService.quitTeam(teamQuitRequest, loginUser);
return ResultUtils.success(result);
}
-
- serviceImpl(约定用户iduserId为队长id)
@Transactional(rollbackFor = Exception.class)
public boolean quitTeam(TeamQuitRequest teamQuitRequest, User loginUser) {
if (teamQuitRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
Long teamId = teamQuitRequest.getTeamId();
Team team = getTeamById(teamId);
long userId = loginUser.getId();
UserTeam queryUserTeam = new UserTeam();
queryUserTeam.setTeamId(teamId);
queryUserTeam.setUserId(userId);
QueryWrapper<UserTeam> queryWrapper = new QueryWrapper<>(queryUserTeam);
long count = userTeamService.count(queryWrapper);
if (count == 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "未加入队伍");
}
long teamHasJoinNum = this.countTeamUserByTeamId(teamId);
// 队伍只剩一人,解散
if (teamHasJoinNum == 1) {
// 删除队伍
this.removeById(teamId);
} else {
// 队伍还剩至少两人
// 是队长
if (team.getUserId() == userId) {
// 把队伍转移给最早加入的用户
// 1. 查询已加入队伍的所有用户和加入时间
QueryWrapper<UserTeam> userTeamQueryWrapper = new QueryWrapper<>();
userTeamQueryWrapper.eq("teamId", teamId);
userTeamQueryWrapper.last("order by id asc limit 2");
List<UserTeam> userTeamList = userTeamService.list(userTeamQueryWrapper);
if (CollectionUtils.isEmpty(userTeamList) || userTeamList.size() <= 1) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR);
}
UserTeam nextUserTeam = userTeamList.get(1);
Long nextTeamLeaderId = nextUserTeam.getUserId();
// 更新当前队伍的队长
Team updateTeam = new Team();
updateTeam.setId(teamId);
updateTeam.setUserId(nextTeamLeaderId);
boolean result = this.updateById(updateTeam);
if (!result) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "更新队伍队长失败");
}
}
}
// 移除关系
return userTeamService.remove(queryWrapper);
}
8.队长可以解散队伍
- 分析
- 校验请求参数
- 校验队伍是否存在
- 校验你是不是队长
- 移除所有加入队伍的关键信息
- 删除队伍
- 实现
- controller
@PostMapping("/delete")
public BaseResponse<Boolean> deleteTeam(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) {
//传入一个修改的id,如果这个id小于等于0,直接抛出参数错误
if (deleteRequest == null || deleteRequest.getId() <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
long id = deleteRequest.getId();
User loginUser = userService.getLoginUser(request);
boolean result = teamService.deleteTeam(id, loginUser);
if (!result) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "删除失败");
}
return ResultUtils.success(true);
}
-
- serviceImpl,加入事务回滚,更保险
@Transactional(rollbackFor = Exception.class)
public boolean deleteTeam(long id, User loginUser) {
// 校验队伍是否存在
Team team = getTeamById(id);
long teamId = team.getId();
// 校验你是不是队伍的队长
if (team.getUserId() != loginUser.getId()) {
throw new BusinessException(ErrorCode.NO_AUTH, "无访问权限");
}
// 移除所有加入队伍的关联信息
QueryWrapper<UserTeam> userTeamQueryWrapper = new QueryWrapper<>();
userTeamQueryWrapper.eq("teamId", teamId);
boolean result = userTeamService.remove(userTeamQueryWrapper);
if (!result) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "删除队伍关联信息失败");
}
// 删除队伍
return this.removeById(teamId);
}
十.随机匹配功能
匹配算法及介绍
- 怎么匹配
- 标签tags
- 本质:找到相似标签的用户
- 找到共同标签最多的用户
- 共同标签越多,分数越高,越排在前面
- 如果没有匹配的思路,随机推荐几个(降级方案)
- 编辑距离算法详解编辑距离算法-Levenshtein Distance-CSDN博客
- 字符串1最少可以通过多少次增删改可以变成字符串2
- 编辑距离算法工具类
public class AlgorithmUtils {
/**
* 编辑距离算法(用于计算最相似的两组标签)
* 原理:https://blog.csdn.net/DBC_121/article/details/104198838
* @param tagList1
* @param tagList2
* @return
*/
public static int minDistance(List<String> tagList1, List<String> tagList2) {
int n = tagList1.size();
int m = tagList2.size();
if (n * m == 0) {
return n + m;
}
int[][] d = new int[n + 1][m + 1];
for (int i = 0; i < n + 1; i++) {
d[i][0] = i;
}
for (int j = 0; j < m + 1; j++) {
d[0][j] = j;
}
for (int i = 1; i < n + 1; i++) {
for (int j = 1; j < m + 1; j++) {
int left = d[i - 1][j] + 1;
int down = d[i][j - 1] + 1;
int left_down = d[i - 1][j - 1];
if (!Objects.equals(tagList1.get(i - 1), tagList2.get(j - 1))) {
left_down += 1;
}
d[i][j] = Math.min(left, Math.min(down, left_down));
}
}
return d[n][m];
}
/**
* 编辑距离算法(用于计算最相似的两个字符串)
* 原理:https://blog.csdn.net/DBC_121/article/details/104198838
*
* @param word1
* @param word2
* @return
*/
public static int minDistance(String word1, String word2) {
int n = word1.length();
int m = word2.length();
if (n * m == 0) {
return n + m;
}
int[][] d = new int[n + 1][m + 1];
for (int i = 0; i < n + 1; i++) {
d[i][0] = i;
}
for (int j = 0; j < m + 1; j++) {
d[0][j] = j;
}
for (int i = 1; i < n + 1; i++) {
for (int j = 1; j < m + 1; j++) {
int left = d[i - 1][j] + 1;
int down = d[i][j - 1] + 1;
int left_down = d[i - 1][j - 1];
if (word1.charAt(i - 1) != word2.charAt(j - 1)) {
left_down += 1;
}
d[i][j] = Math.min(left, Math.min(down, left_down));
}
}
return d[n][m];
}
}
- serviceImpl
public List<User> matchUsers(long num, User loginUser) {
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.select("id", "tags");
queryWrapper.isNotNull("tags");
List<User> userList = this.list(queryWrapper);
String tags = loginUser.getTags();
Gson gson = new Gson();
List<String> tagList = gson.fromJson(tags, new TypeToken<List<String>>() {
}.getType());
// 用户列表的下标 => 相似度
List<Pair<User, Long>> list = new ArrayList<>();
// 依次计算所有用户和当前用户的相似度
for (int i = 0; i < userList.size(); i++) {
User user = userList.get(i);
String userTags = user.getTags();
// 无标签或者为当前用户自己
if (StringUtils.isBlank(userTags) || user.getId() == loginUser.getId()) {
continue;
}
List<String> userTagList = gson.fromJson(userTags, new TypeToken<List<String>>() {
}.getType());
// 计算分数
long distance = AlgorithmUtils.minDistance(tagList, userTagList);
list.add(new Pair<>(user, distance));
}
// 按编辑距离由小到大排序
List<Pair<User, Long>> topUserPairList = list.stream()
.sorted((a, b) -> (int) (a.getValue() - b.getValue()))
.limit(num)
.collect(Collectors.toList());
// 原本顺序的 userId 列表
List<Long> userIdList = topUserPairList.stream().map(pair -> pair.getKey().getId()).collect(Collectors.toList());
QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();
userQueryWrapper.in("id", userIdList);
// 1, 3, 2
// User1、User2、User3
// 1 => User1, 2 => User2, 3 => User3
Map<Long, List<User>> userIdUserListMap = this.list(userQueryWrapper)
.stream()
.map(user -> getSafetyUser(user))
.collect(Collectors.groupingBy(User::getId));
List<User> finalUserList = new ArrayList<>();
for (Long userId : userIdList) {
finalUserList.add(userIdUserListMap.get(userId).get(0));
}
return finalUserList;
}
- controller
@GetMapping("/match")
public BaseResponse<List<User>> matchUsers(long num, HttpServletRequest request) {
//限制数量,保证数据库安全
if (num <= 0 || num > 20) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
User user = userService.getLoginUser(request);
return ResultUtils.success(userService.matchUsers(num, user));
}
性能测试及优化
- 怎么取出所有用户,一次和当前当前用户计算分数,取TOP N
- 优化方法:
- 切记不要在数据量大的时候输出日志
- Map存了素偶有的分数信息,占用内存
- 解决:维护一个固定长度的集合,只保留分数最高的几个用户
- 细节:剔除自己
- 尽量只查需要的用户
- 过滤掉标签为空的用户
- 过滤掉部分标签取用户
- 只查需要的数据(如id和tags)
- 提前查?
- 提前把所有用户给缓存(不适用于经常更新的数据)
- 提前运算出来结果,缓存(针对一些重点用户,提前缓存)
- 类比大数据推荐机制
- 测试
- 数据库修改标签
-
- 接口返回值:
登录1号的账户,1号的标签为["java","研发中心","男","编程"],匹配到的用户为2号。
十一.后端优化
- 队伍操作权限控制
- 加入队伍:仅非队伍创建人,且未加入队伍的人可见
- 更新队伍:仅创建人可见
- 解散队伍:仅创建人可见
- 退出队伍:创建人不可见,仅已加入队伍的人可见
- 重复加入多个队伍的问题
- 加锁:
- 获取分布式锁:使用Redisson客户端从Redis中获取一个名为yupao:join_team的锁,确保同一时刻只有一个线程能够执行后续的业务逻辑,防止并发操作导致的数据不一致。
- 循环尝试获取锁:通过一个while(true)循环不断地尝试获取锁,直到成功为止。尝试获取锁时不设置等待时间(0毫秒),意味着立即尝试获取,但如果无法立即获取到锁,则会一直循环尝试。参数-1表示锁没有超时时间,即除非手动释放,否则将一直保持锁定状态。
- 检查用户队伍数量:查询当前用户已经加入的队伍数量,如果超过5个,则抛出BusinessException异常,提示用户最多只能创建和加入5个队伍。
- 避免重复加入队伍:检查该用户是否已经加入指定的队伍,如果已经加入,则抛出异常,防止重复加入同一队伍。
- 检查队伍容量:计算指定队伍当前已加入的用户数量,如果达到队伍的最大人数限制,则抛出异常,告知队伍已满。
- 加入队伍并保存信息:如果以上所有条件检查都通过,说明用户可以合法地加入队伍。此时创建一个新的UserTeam对象记录用户的加入信息(包括用户ID、队伍ID以及加入时间),并调用userTeamService.save()方法保存至数据库,返回保存操作的结果(true表示成功,false表示失败)。
- 异常处理:如果在尝试获取锁过程中被中断(比如通过interrupt()方法),捕获InterruptedException异常,并记录错误日志,直接返回false表示操作失败。
- 释放锁:在finally块中检查当前线程是否持有锁,如果是,则解锁,确保锁能够被正确释放,即使在保存用户加入队伍信息过程中发生异常也是如此。打印解锁的日志信息以供调试跟踪。
// 该用户已加入的队伍数量
long userId = loginUser.getId();
// 只有一个线程能获取到锁
RLock lock = redissonClient.getLock("yupao:join_team");
try {
// 抢到锁并执行
while (true) {
if (lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
System.out.println("getLock: " + Thread.currentThread().getId());
QueryWrapper<UserTeam> userTeamQueryWrapper = new QueryWrapper<>();
userTeamQueryWrapper.eq("userId", userId);
long hasJoinNum = userTeamService.count(userTeamQueryWrapper);
if (hasJoinNum > 5) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "最多创建和加入 5 个队伍");
}
// 不能重复加入已加入的队伍
userTeamQueryWrapper = new QueryWrapper<>();
userTeamQueryWrapper.eq("userId", userId);
userTeamQueryWrapper.eq("teamId", teamId);
long hasUserJoinTeam = userTeamService.count(userTeamQueryWrapper);
if (hasUserJoinTeam > 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户已加入该队伍");
}
// 已加入队伍的人数
long teamHasJoinNum = this.countTeamUserByTeamId(teamId);
if (teamHasJoinNum >= team.getMaxNum()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "队伍已满");
}
// 修改队伍信息
UserTeam userTeam = new UserTeam();
userTeam.setUserId(userId);
userTeam.setTeamId(teamId);
userTeam.setJoinTime(new Date());
return userTeamService.save(userTeam);
}
}
} catch (InterruptedException e) {
log.error("doCacheRecommendUser error", e);
return false;
} finally {
// 只能释放自己的锁
if (lock.isHeldByCurrentThread()) {
System.out.println("unLock: " + Thread.currentThread().getId());
lock.unlock();
}
}
- 后端发布(免备案)
- 微信云托管(部署服务器的平台)
-
- 数据库需要修改,把localhost改成线上公网可访问的数据库。