/**
* 用户注册
* @param user
* @return Map 返回错误提示消息,如果返回的 map 为空,则说明注册成功
*/
public Map register(User user) {
Map map = new HashMap<>();
if (user == null) {
throw new IllegalArgumentException("参数不能为空");
}
if (StringUtils.isBlank(user.getUsername())) {
map.put("usernameMsg", "账号不能为空");
return map;
}
if (StringUtils.isBlank(user.getPassword())) {
map.put("passwordMsg", "密码不能为空");
return map;
}
if (StringUtils.isBlank(user.getEmail())) {
map.put("emailMsg", "邮箱不能为空");
return map;
}
// 验证账号是否已存在
User u = userMapper.selectByName(user.getUsername());
if (u != null) {
map.put("usernameMsg", "该账号已存在");
return map;
}
// 验证邮箱是否已存在
u = userMapper.selectByEmail(user.getEmail());
if (u != null) {
map.put("emailMsg", "该邮箱已被注册");
return map;
}
// 注册用户
user.setSalt(CommunityUtil.generateUUID().substring(0, 5)); // salt
user.setPassword(CommunityUtil.md5(user.getPassword() + user.getSalt())); // 加盐加密
user.setType(0); // 默认普通用户
user.setStatus(0); // 默认未激活
user.setActivationCode(CommunityUtil.generateUUID()); // 激活码
// 随机头像(用户登录后可以自行修改)
user.setHeaderUrl(String.format("http://images.nowcoder.com/head/%dt.png", new Random().nextInt(1000)));
user.setCreateTime(new Date()); // 注册时间
userMapper.insertUser(user);
// 给注册用户发送激活邮件
Context context = new Context();
context.setVariable("email", user.getEmail());
// http://localhost:8080/echo/activation/用户id/激活码
String url = domain + contextPath + "/activation/" + user.getId() + "/" + user.getActivationCode();
context.setVariable("url", url);
String content = templateEngine.process("/mail/activation", context);
mailClient.sendMail(user.getEmail(),"激活 Echo 账号", content);
return map;
}
首先,登录的时候会随机生成验证码,如何把这个验证码和当前用户对应起来,实现验证码的校验呢?
显然,由于这个时候用户还没有登录,我们是没有办法通过用户的 id 来唯一的对应它的验证码的。所以这个时候我们考虑生成一个随机的 id 来暂时的代替这个用户,将其id和对应的验证码暂时存入 Redis 中(60s)。并且在 Cookie中暂时存一份为这个用户生成的随机 id(60s)。
其中生成验证码和进行校验分别是两个URL请求地址。
这样,当用户点击登录按钮后,就会去 Redis中获取这个随机 id和验证码,去Cookie查询对应的验证码,判断用户输入的验证码是否一致。
用户输入用户名和密码并且校验完验证码之后,就登录成功了,那我们如何在一次请求中去保存这个用户的状态?如何回显用户的信息呢?
做法可以设计一个类如下图:
解释一下,每个用户登录成功后,我们都会为其生成一个随机的唯一的登录凭证实体类对象 LoginTicket
(包含用户 id、登录凭证字符串 ticket、是否有效、过期时间),我们把这个登录凭证实体类对象永久的存储在 Redis 中(key 就是登录凭证字符串 ticket)。而所谓登录凭证的无效,就是指用户登出后,这个凭证就会被设置为无效状态;凭证的默认过期时间是 30分。
存储完 LoginTicket
后,我们就可以根据它来获取用户的状态了。我们定义了一个拦截器 LoginTicketInterceptor
,每次请求之前都会从 Redis获取到 ticket,然后根据 ticket 去 Redis 中查看这个用户的登录凭证 LoginTicket
是否过期和是否有效,只有登录凭证有效且没有过期才会执行请求,不然就会跳转到登录界面。
如果该用户的登录凭证有效且没有过期,那我们就可以在本次请求中持有这个用户的信息了。如何持有呢?这里我们考虑使用 ThreadLocal
保存用户信息,ThreadLocal
在每个线程中都创建了一个用户信息副本,也就是说每个线程都可以访问自己内部的用户信息副本变量,从而实现了线程隔离,来看下 HostHolder
类:
所以将登录成功后要保存的信息为:
将生成的凭证保存到Redis上,并且设置过期时间,置state为1,其中key为凭证,value为LoginTicket
类。
然后每次请求首先经过拦截器,通过Cookie获取ticket凭证。凭借ticket凭证从Redis获取LoginTicket
类的信息。如果存在就将通过LoginTicket
类的用户id查询用户信息其保存到ThreadLocal中,否则拦截。
从Redis根据凭证删除LoginTicket
信息,执行ThreadLocal的remove()方法清空用户信息。
因为每次请求需要在拦截器中通过通过Cookie获取凭证,然后去Redis获取LoginTicket
类。如果通过验证则会每次去数据库查询用户信息,这样导致每一次请求访问都会去数据库查询造成巨大的访问压力。
为了避免这种情况,所以拦截器首先去Redis查询用户信息,如果有则直接保存到ThreadLocal,否则再去数据库查询用户信息,再保存到Redis中。
这里使用前端富文本编译器功能可以让用户可以上传图片和视频以及文字功能。
上传图片,删除图片,下载图片使用的是阿里云OSS功能
上传视频、删除视频,播放视频使用的是阿里云视频点播功能
如上图所示,帖子有分类模块,在发布帖子的时候可以选择分类的模块
如上图所示
这里保存着用户的id,文章内容、图片url、视频播放url、文字类型、帖子点赞统计、评论统计、状态、创建时间、修改时间等
使用MybatisPlus分页+阿里云OSS图片功能+阿里云视频点播功能,这样可以直观的展示最终内容。
论坛首页进去的内容按照热门进行排行的,如果要求分页显示热门度排行前10的帖子功能。
首先是实体类如下:
帖子表
点赞表
对于该帖子赞数的统计思路:
发布定时任务,定时通过该帖子的id在点赞表中查询到该帖子的点赞统计数量保存到数据库的帖子表中。
对于所有帖子,根据其点赞数量通过order by进行倒叙排序,并分页进行输出。
这样就可以获得热门帖子信息。
Redis+Canal+MySQL binlog实现缓存一致性
如上图,因为帖子的访问量比较巨大,所以使用redis实现对热门帖子的缓存,但是帖子会存在数据库和缓存不一致问题,这里使用redis+MySQL的binlog实现缓存一致性
对于该项目而言,通过canal监听数据库的binlog,每当帖子发送修改的时候,就会通过canal去监听mysql的binlog,然后去通过更新redis里面的数据。
如果直接点击帖子下面的评论,该评论为帖子评论 ,就会在数据库中标记为帖子评论,并且生成唯一评论标识符,关联该帖子的id
如果在帖子点击回复功能,那么该评论为用户评论,就会在数据库中标记为用户评论,并且生成唯一评论标识符,关联该用户的id。
在评论中还存在某用户回复某用户这个功能。这个功能和上述一样,只需要前端根据当前评论id和保存的用户id就,并且使用分页功能按时间排序就可以实现该功能。
该功能显示目前有多少用户与其对话,并显示其他用户的信息,以及已读未读状态,包含列表功能 。
对于如何查找该用户与哪一个用户直接进行过聊天,直接通过conversation_id字段进行匹配。因为该字段保存的时候两个用户id的字符串,中间用间隔号“_”来间隔,所以使用字符串截取获取两个用户id。
为了避免重复统计,这里使用Set来去重,并且conversation_id的命名规则按照字典顺序,左边小右边大,比如102和101之间对话,就保存为"101_102"这种格式。这样可以根据模糊匹配从数据库拿到所有数据,然后保存到Set集合中,再获取里面的数据并进行字符串截取获取两个用户id,这样就能拿到对话用户的数量以及id。
每一次会话会保存发送方id,接收方id,会话状态,会话表示,以及会话时间。
在实体类有form_id和to_id,这两个id分别对应发送方form_id和接收方to_id。当获取到conversation_id时候,就可以根据这个conversation_id来获取双方的会话信息按照时间排序显示数据。
比如两个用户id为101和102。对于当前等于用户101而言,自己发送一条消息,那么发送方是自己,接收方是102,反之亦然。这样就可以根据当前用户id来根据时间排序获取对话消息并以分页功能展示。
对于发送消息,在会话表根据字典排序保存当前登录用户id和对方会话用户id到conversation_id中,并保存发送方id和接收方id,以及status状态默认设置为0未读,发送消息的内容和发送时间。
首先根据登录用户id通过conversation_id经过模糊匹配查询到与该用户会话相关数据。从得到数据中条件查询to_id为登录用户id并且status=0的数据,这样就可以获取相应的数据进行数量统计。
conservation_id
都是 112_113
。这样,通过这个字段我们就能查出来 112 和 113 之间的私信往来了。当然,这个字段是冗余的,我们可以通过 from_id 和 to_id 推演出来,但是有了这个字段方便后面的查询等操作key=目标id_点赞用户id
value={time: long}
不管是用户还是帖子,都这样设计。
这样有个好处就是如果统计帖子点赞数量或者该用户评论被赞数量,只需要通过redis模糊匹配目标id就可以知道被赞的数量
Set keys = redisTemplate.keys("目标id_" + "*");
int size = keys.size();
如果要看该帖子或者该评论是否被该用户点赞,只需要通过查询key<目标id,用户id>获取其值判断是否有内容就知道是否被赞了
Set keys = redisTemplate.keys(RedisUtils.setKey(目标id, 用户id));
if(keys.size()==0){
System.out.println("未点赞");
}else{
System.out.println("已点赞");
}
根据key<目标id,用户id>,value<时间戳>保存到reids中
redisTemplate.opsForSet().add(RedisUtils.setKey(目标id,用户id),RedisUtils.setValue());
设计定时任务,过一段时间保存到mysql中,并统计表中统计点赞总数
可以直接删除key<目标id,用户id>,这样就会取消点赞,被赞目标总数量会下降1。
Boolean delete = redisTemplate.delete(RedisUtils.setKey("目标id","用户id"));
if (delete){
System.out.println("取消成功");
}else{
System.out.println("取消失败");
}
总赞数构成:帖子获赞总数+评论或者总数
在mysql实体类中查看点赞总数
这是RedisUtils实体类
public class RedisUtils {
/**
* 点赞设置key
* @param Id1 目标id
* @param Id2 点赞用户id
* @return
*/
public static String setKey(String Id1,String Id2){
return Id1+"_"+Id2;
}
/**
* 点赞设置value
* 设置点赞时间戳
* @return
*/
public static Map setValue(){
Map map=new HashMap<>();
Instant instant = LocalDateTime.now().toInstant(ZoneOffset.ofHours(8));
long millisecond = instant.toEpochMilli();
map.put("time",millisecond);
return map;
}
}
系统通知是一个很常见且必要的需求,当发生点赞、关注、评论操作的时候,系统就会给相应的用户发送通知。
对于流量巨大的社交网站,系统通知的需求是非常庞大的,那如果只是和私信或者发帖功能一样单纯地用 Ajax 做个异步,显然是远远不够的。所以为了保证系统的性能,这里非常有必要使用消息队列(消息队列三大作用:解耦、异步、消峰),Echo 中选用的是 Kafka。
整体来看就两个需求,发送系统通知和显示系统通知:
TOPIC_LIKE
)TOPIC_FOLLOW
)TOPIC_COMMNET
)整体逻辑就是,当发生比如点赞操作的时候,就会触发消息队列的点赞事件,然后消费者消费这个事件,具体的消费逻辑就是往系统通知表里面插入一条数据(系统通知也使用私信那张表 message
,不过系统通知的 from_id
在代码里写死了为 1,表示是系统发送出来的,所以这也就是为什么说大家在部署的时候一定要注意在 user 表中事先存储一个 id = 1 的用户)。
消费者想要通过消费这个消息实现往数据库表 message 中插入一条记录的目的,那么这个消息或者说事件是不是就应该具备 message 表中的所有字段,或者说从消息中能够推出这些字段。
另外,MQ是发布订阅模型,一对多,消息以 Topic(主题)进行分类,生产者将消息发布到某个Topic 中,消费者可以订阅该 Topic。以点赞事件为例,看下图:
发送方为点赞、关注、评论操作时候,接收方为响应的用户
效果类似b站这样
当点赞评论关注的时候,就会发送数据,然后接收到的数据进行分类识别
代码:
/**
* 消费评论、点赞、关注事件
* @param record
*/
@KafkaListener(topics = {TOPIC_COMMNET, TOPIC_LIKE, TOPIC_FOLLOW})
public void handleMessage(ConsumerRecord record) {
if (record == null || record.value() == null) {
logger.error("消息的内容为空");
return ;
}
Event event = JSONObject.parseObject(record.value().toString(), Event.class);
if (event == null) {
logger.error("消息格式错误");
return ;
}
// 发送系统通知
Message message = new Message();
message.setFromId(SYSTEM_USER_ID);
message.setToId(event.getEntityUserId());
message.setConversationId(event.getTopic());
message.setCreateTime(new Date());
Map content = new HashMap<>();
content.put("userId", event.getUserId());
content.put("entityType", event.getEntityType());
content.put("entityId", event.getEntityId());
if (!event.getData().isEmpty()) { // 存储 Event 中的 Data
for (Map.Entry entry : event.getData().entrySet()) {
content.put(entry.getKey(), entry.getValue());
}
}
message.setContent(JSONObject.toJSONString(content));
messageService.addMessage(message);
}
这里使用的是使用guava来实现布隆过滤器
首先从guava进行判断数据是否存在,如果返回数据则说明过滤器有数据,如果不返回数据,说明布隆过滤器不存在该数据,则从redis进行数据的获取