总结各个功能实现的简单思路,自己按心情总结的,持续更新中~~
源码百度云地址:链接:https://pan.baidu.com/s/1ix2MN2-JKwbeGrIg59WAQw
提取码:yhk3
前端表单提交后,依次检验用户信息是否完整,并通过mailClient.process("/mail/activation",content)将模板和内容发邮件到指定邮箱,里面包含一条激活链接,里面拼接了用户id和code。
链接对应的url作为邮箱激活的path,将用户id和code接收后调用service层,查看用户状态是否已激活,已激活则返回状态到controller层,否则验证code是否与用户对应code是否一致,一致则成功激活,更改用户状态,并返回状态值,controller接收到状态值后返回对应状态信息填充到前端。
设计一个登陆凭证类包含用户id,ticket用户登陆的唯一凭证,过期时间,登陆状态,在service层验证用户名,密码之后,生成登陆凭证,并将生成UUID作为ticket传入map返回controller层。
controller层从session中取出验证码校验,查看是否记住我,如果已勾选记住我,则将登陆凭证的过期时间设置长一点,调用service层登陆逻辑,若返回中有ticket则返回登录成功。
登陆凭证类相当于一个session的作用,若全部校验成功则生成一个唯一标识登陆凭证,这样可以通过唯一标识直接访问。
每次发出请求都要渲染登录消息这一栏,所以用拦截器实现,在preHandler中从cookie中取出ticket,验证用户凭证后通过,为了便于后面业务中可能常用到用户信息,可以在验证通过后将用户信息暂存,考虑到线程并发安全问题,编写一个HostHolder类用threadLocal保存用户信息。
对登陆后才能访问的方法进行拦截,采用自定义注解的方式,若方法有该注解且未持有该用户,则进行拦截。
构建前缀树,将敏感词写成文本读入前缀树,做敏感词过滤。
service层过滤敏感词,controller层先判断是否持有该用户,在发送帖子成功或失败时返回json字符串用于异步显示消息,不用重复刷新页面,可用fastjson将转换成json字符串封装为工具类。
评论类的属性有评论id;userId;entityType(评论对象类型,帖子或评论);entityId(评论对象id);targetId(回复目标的用户id);content;status;createTime;
查看一个帖子评论时,先根据查询帖子对应信息,再根据帖子中的用户id查询用户信息,用于显示帖子自身的信息。
再根据帖子entityType和帖子id作为entityId在comment表中查询帖子对应评论,返回一个List,
对于返回的comment集合中只有关于comment自身的信息,所以需要遍历list,依次查询评论对应的用户信息,以及以后会补充的点赞信息等,由于评论有关于帖子的评论和关于评论的回复,所以还需要在遍历中获得关于评论的回复信息。因此用一个List
添加评论时,由于在帖子的表中有评论数量的属性,所以在添加评论时要同时更新帖子中评论数量,即构建事务。
评论成功后应跳转到帖子页面,所以还要从前端携带帖子id传回后端,便于跳转页面。(帖子详情页的路径带有帖子id)。
会话表:id,from_id,to_id,conversationId,status,createTime。
查询会话列表总条数,且每条会话只显示最新的私信内容。先按照conversationId进行分组,当状态合法且from_id或to_id有一个当前用户id,且from_id不能为1,1是系统消息,从中筛选id最大的那条私信内容就是最新的需要显示的私信内容。
同样会话列表数为最大id的数量。
controller层先从ThreadLocal中获取当前用户信息,根据用户id查询会话列表,同样需要将会话列表遍历,将每条会话信息放入List
还需要查询总的未读私信数和会话数。
对于具体的会话页,可根据conversationId查询所有会话,返回一个list,同样遍历list,将信息本身和发送消息的用户存入List
进入了会话页,说明所有消息已读,此时需要改变状态,可能需要改变多条消息的状态为已读,所以封装一个方法将用户id查询得到的消息列表传入,判断这些消息中当前用户为接收消息的用户即to_id且消息状态为未读的所有消息,返回未读消息列表。
SQL语句:
<update id="updateStatus">
update message set status = #{status}
where id in
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
foreach>
update>
发送完私信异步返回状态信息,显示成功发送
前端将会话对象名字传入,通过名字查询对象id,通过ThreadLocal获取当前用户id,判断id值大小进行拼接为conversationId,添加会话。
所有异常抛出后都会到达表现层,所以可以直接在表现层统一捕捉处理,使用@ControllerAdvice注解实现,可配置只扫描带有controller注解的类,减少工作量,构建方法,方法上加上@ExceptionHandler用于捕捉所有异常信息,在该方法中遍历捕捉到的异常信息,e.getStackTrace(),对其进行日志记录,记录完错误信息需要给浏览器一个响应,这里需要做个处理,对于普通请求可以直接重定向到错误页面,但对于异步请求则需要返回json字符串,可通过request的请求头进行判断。
拦截器和控制通知是针对controller层或异常的,统一日志记录可能需要记录业务层的信息,所以采用AOP形式记录,对所有业务层的方法进行记录,某某用户在某个时刻访问了业务层的某个方法。用户ip可以从request中获得,request通过RequestContextHolder对象获取。
点赞功能主要用redis进行记录,redis记录时关键在于对key键值的设置,根据其实现功能进行规律设计,可单独配置一个工具类,用于生成redis的key键值。
对于点赞功能分为两方面,一方面是对实体类的点赞统计,如帖子评论等,另一部分是对用户类的点赞统计,该用户收到多少个赞。
对实体类的点赞统计,用set数据结构存储,key值由entityType和entityId拼接而成,field为userId,说明是该用户对这个实体点赞。查询该用户是否对该实体点过赞需要查询,key值和field值都一致的成员是否存在。查询该实体类收到的赞,即统计该entityType和entityId拼接而成的key值的成员数。
对用户类的点赞统计,可以用string数据结构存储,无论是对帖子点赞还是对评论点赞,只要是关于该用户收到的赞都进行统一计数,所以只需要用string结构存储即可。
用户对实体点赞时,实体类的userId即帖子或评论的作者收到的点赞数会发生变化,这两件事是同时发生的,是一个事务。具体流程为先查询当前用户是否对该实体点过赞,即redis中是否存在key值(entityType和entityId)和field值(userId)都一致的成员,如果存在,则取消点赞,将entityUserId构成的key对应的value减一,即entity作者收到的赞减一。若不存在,则进行点赞,将entityUserId构成的key对应的value加一。
所以在进行点赞功能时要传入两个userId,一个是当前用户的userId,对实体点赞,另一个是实体类的作者用户id,对收到的赞进行加减。
注意,redis的事务中其实质是将命令批量放入队列中,等提交后一次性处理,所以查询命令在事务中不起作用,应该在事务开启前查询。
为了便于管理,redis以followee被关注对象为key和floweryer关注别人即粉丝对象为key分别进行操作。
followee被关注者,可以关注帖子或用户==》关注列表
followee:userId:entityType->zset(entityId,now)用zset数据结构存储,当前关注时间为分数值,可用于排序。
follower粉丝,可以是帖子或用户的粉丝
follower:entityType:entityId->zset(userId,now)
关注事件发生时,当前用户关注列表增加实体对象,followee的粉丝列表增加当前用户,两件事当做一个事务同时处理。需要将用户id,实体类型,实体id都传入,用户的followee列表添加实体id记录,entityId的follower列表添加用户userId记录。
取消关注的逻辑同理,只需要将对应redis中的键值对删除
是否已关注由前端js样式处理
在关注列表中查询,看是否能查询出来分数,能查询出来,则表示已关注。
service层,关注列表对应的是followee:userId:entityType,entityType为用户查询出来的set集合,对其进行倒序范围查询,范围根据分页信息得出。
得到set集合后,需要遍历集合,用List
控制层:
由于可能访问的是别人的关注列表,注意在url中将该页面的用户id传入。根据用户id查询得到一个关注列表集合后,需要遍历这个列表,对列表的用户成员进行判断,看当前浏览用户是否关注了这个用户。仍将结果装载到原来那个map中,一起返回
与关注列表同理,粉丝列表对应的是follower:entityType:entityId,entityType为用户,同样在service层做一次循环,装载粉丝用户信息,关注时间等,返回List集合。在controller层再做一次循环判断,看自己是否已关注该粉丝。
验证码需要经常访问刷新,且不需要长久存储,所以可以使用redis存储,设置一个短期过期时间,优化之前是存储在session中的。验证码是在登录之前进行验证,所以在刷新验证码或获得验证码时,就向cookie中存一个随机字符串作为验证码存储redis中的key值,cookie设置过期时间,这样在登录时,就可以先获得cookie中的随机字符串,再通过随机字符串从redis中获得验证码与用户输入的验证码进行对比验证。
有一个登陆凭证的类,里面用userId,ticket,登陆状态,过期时间等的属性。在成功登陆后生成随机字符串作为ticket用户登陆的唯一凭证,之后就可以用ticket来判断登陆是否有效,登陆有效则将其放入cookie中,拦截器对方法拦截时比较cookie中的ticket和数据库中的ticket是否一致。
之前是将其存放在数据库中,现在改存为redis中,便于登陆凭证经常使用。退出时,将登陆凭证状态设置为无效。不删除,便于之后对用户注册信息,上次登陆时间,一年登陆时间等进行统计。
登陆时生成登陆凭证,需要重新构造,存储在redis中
退出时需要重新构造,将redis中的ticket类取出改变其状态,再放回
查询登陆凭证的方法也需要重新构造,从redis中查询。
对于一些常用的方法调用,将其返回结果用redis缓存,过会删除,提高效率。如通过id查找用户,先在缓存中查找,找不到再去数据库中找,找到后返回并更新缓存,用户信息发生变化时,直接将缓存删除,从数据库中查找并更新缓存,其实还有部分隐患,具体可查看另一篇博客数据库和redis数据一致性
kafka是分布式消息发布/订阅系统,BBS论坛项目利用Kafka完成系统自动发送消息的功能。kafka消息生产者将消息send(topic,context)发送出去,消费者监听topic,发送消息,@KafkaListener(topics={""})不需要用户对其进行操作,属于异步操作。
生产者只需要发送topic和内容,为了更好的传递消息内容,创建一个event类,里面包含topic(事件的主题),userid(触发这个事件的用户,发出点赞、评论动作的用户),entityType,entityid(通过这个实体类触发的事件,如对帖子点赞则是通过帖子触发的事件,关注用户则是通过被关注对象触发的事件),entityUserid(对于帖子,评论这些实体就是作者id,对于用户就还是用户id),为便于扩展,设置一个map的成员变量用于存放其他可能需要的数据。当用户触发事件后,构建一个event对象,对应属性填充后将event对象转换为JSON字符串通过kafka生产者发送消息。用户不用管消费者何时消费,只需要触发生产者发送消息事件即可。
对于消费者,系统发送消息和私信功能类似,只是from_id为固定的系统id,可在数据库中默认用1,此外conversationid不再需要用from_id和to_id拼接,显得冗余,可以用于存储topic字符串,表明该消息是哪个主题触发的。
消费者用ConsumerRecord接收生产者发送的context内容,为了发送站内消息,需要先将生产者发送的内容还原为event对象,然后创建一个message对象,message的from_id为固定的系统id,to_id从还原的event对象中获取,即event对象的entityUserid,conversationid为event的topic,创建时间就是当前时间。message的内容需要拼接,如XX为你的帖子XX点赞,点击查看详情。虽然不用自己在后端拼接,但后端需要存储相应数据,便于查询时用于前端显示。所以也用一个map存储,将用户id(发出事件的人,即点赞的人,从event中获取),entityid,entityType(触发事件的实体类),还有event对象中map传过来的用于扩展的数据都存在消息内容的map中,最后将该map转换json字符串。至此message已经全部属性补充完整,可以调用message的service方法,将其存入数据库中。
评论,点赞,关注时都会触发系统消息事件,所以需要在这些功能的controller层调用kafka的生产者方法生成事件。发送事件时创建一个事件类,topic,userid,entityId,entityType,entityuserid以及扩展的dataMap,将各属性相应填充完整,调用kafka的生产者方法发送即可,消费者会自动发送消息。对于评论点赞的触发事件,可能是对评论进行回复或点赞,也可能是对帖子进行评论或点赞,所以需要先对其类型进行判断,如果是评论,则查询评论数据表得到发出评论的用户id,如果是帖子,则查询帖子的数据库表得到帖子的作者。
此外,在点击查看详情时,对于给帖子点赞或评论等事件,需要跳转到帖子页面,所以在dataMap中存入postid,便于之后使用。对于关注事件只需关联关注者个人主页不需要帖子id。
通过以上逻辑,系统消息已全部加入message的数据库表,在消息列表栏只需显示每个主题的最新一条消息,消息详情则需要显示每条消息。
数据库查询条件:from_id为1,to_id为userid,conversationid为topic,且状态有效的id最大的那条消息就是该topic的最新一条系统通知消息。同理,某主题下的通知数,则是满足这些条件的count(id)数量,未读通知数,则将状态条件改为未读。
数据库查询到对应数据后要对message进行处理,主要是对message的内容进行处理,因为在存储时,message的内容存储的map转换而成的json字符串,需要将其标签进行反转义后,将其转换回map对象。然后用新的mapVO集合统一装起来,mapVO需要存储原本的message对象,还需要存储message的content转换回map中装载的内容,contentMap中存储了userid(触发事件的用户id,需要根据这个id查询出用户对象放入mapVO中),entityType,entityid,以及评论事件和点赞事件会有的postid,全都要存入mapVO中。
此外还需要将查询得到的该主题的消息数量,未读消息数也存入mapVO中。
在进入通知详情页时,需要将topic通过url传入后端,系统通知详情页需要将该主题下的分页范围内的多条通知都查询出来,按照创建时间进行倒序排列。返回得到一个list列表,用一个List
当点进详情页时要注意将多条未读消息设为已读消息,此处逻辑与私信模块一样,先从所有通知列表中找到当前用户为to_id且状态为未读的消息,返回一个未读消息列表,service层接收到列表,在SQL中遍历列表,将其通知消息状态改为已读。
在页面最上面一栏会显示总的未读消息,因为每个页面都有,需要时刻显示,所以设置一个拦截器来统计未读消息数量,拦截器可重写postHandle方法,在模板渲染之前调用,在拦截器中查询未读通知数和未读私信数。最后配置拦截器拦截所有动态请求。
搜索模块采用ElasticSearch实现,Spring整合ElasticSearch时需要配置集群名字和集群端口,ES默认有两个端口,9200为http访问端口,9300为tcp访问端口。注意ES和redis都依赖netty实现,ES在启动时发现netty已经启动就不会再启动netty进而可能会导致冲突,所以需要对其进行配置,在启动项目时调用初始化回调方法,设置系统netty在运行时间可重复依赖。
ES中索引对应数据库,types对应数据表,documents对应行,fields对应列
索引是一个拥有几分相似特征的文档的集合。一个索引由一个名字来标识,并在对这个索引的文档进行索引,搜索,更新和删除时,都要使用这个名字,在一个集群中,可以任意多的索引。
type:一个类型是索引的一个逻辑上的分类/分区
field:相当于数据库的字段,对文档数据根据不同属性进行的分类标识
映射mapping:处理数据的方式和规则方面做一些限制
文档document:一个可被索引的基础信息单元
要使用ES进行关键词搜索,需要将帖子的相关内容都存入ES中。可以使用ElasticTemplate和ElasticRepository,常用ElasticRepository,直接在对应实体类上配置相关注解。实体类上注解indexName,type,shard,replicas,指明索引名字,类型,分片和副本数,相当于指定数据库,数据表。然后在实体类的相关成员变量注解主键@Id,@Field(type=FieldType.Integer),对于需要搜索的内容设置解析器,如在帖子内容上配置:@Field(type=FieldType.Text,analyzer=“ik_max_word”,searchAnalyzer=“ik_smart”)解析时尽可能分解更多的词,搜索时尽可能比照需要查询的词,然后再智能查询相关的。配置完实体类和相关注解后,实体类和ES索引的对应行列就对应起来了,将会通过ElasticRepository,spring自动生成索引。然后要定义一个接口实现ElasticRepository泛型指定之前配置的实体类类型和主键类型。之后可以直接用这个接口调用save,delete等方法,对ES中的数据进行操作。
ES实现关键字高亮是对关键字添加前后缀标签。用Repository实现高亮功能比较麻烦,需要对方法进行重写,不重写就只是实现了高亮的逻辑,但没有返回高亮结果。可以直接用Template,将查询到的结果用mapper进行处理,返回结果。
对于帖子搜索功能,在添加帖子时需要在更新数据库的同时,把帖子存入ES,由于帖子中还存有评论数量,所以在添加对帖子的评论时,也需要更新帖子的内容,此时可用kafka消息通知,设计帖子topic,监听帖子发布和添加评论事件。当发生时,将所需的数据通过事件发送给消费者,消费者端收到后查询数据库更新后的数据存储到ES中。
实现帖子搜索逻辑,得到ES查询的结果,用List
SpringSecurity底层基于filter实现对用户请求的拦截,通常会让user实体类实现userDetails接口,重写其方法,返回用户权限等,userService层则实现UserDetailService,重写方法loadUserByUsername,UserDetailService是security底层的一个接口,Security判断用户权限,检查登陆时用到该接口。
利用Security实现用户权限认证等需要编写configure类继承WebSecurityConfigureAdapter类,重写configure方法,在方法中可自定义认证规则,Authentication用于封装认证信息接口,认证后将认证信息封装到tocken里,tocken会被springSecurity的filter截取,存储到SecurityContext,判断是否有权限就是通过SecurityContext取出tocken进行判断。
本项目之前是通过拦截器进行权限控制。用了两个拦截器,一个用于拦截请求显示登录用户,判断cookie中的ticket是否一致以及是否有效,然后将用户信息存入threadlocal中便于之后使用。另一个对所有自定义注解的方法进行拦截,若没有登陆就不能调用方法。现在改用SpringSecurity进行用户权限验证,先配置Security的配置类,继承WebSecurityConfigureAdapter,配置需要拦截的请求,若有任意权限则不需要拦截,以及配置权限不够或没有权限时,细致划分对异步请求和同步请求的处理,异步请求返回json字符串,同步请求则重定向到登录页面。
通过配置完成了对用户权限的配置,但还需要将用户的认证与权限相对应。所以需要补充userService中添加一个获得用户权限的方法,用户的权限通过用户的type确定,同时为了复用原本的认证逻辑,可以将之前拦截器中判定是否持有用户时,将相应用户,用户密码,用户权限封装到UsernamePasswordAuthenticationToken中,再将token放入SecurityContext中,这样Security就可以使用该权限完成对用户的验证。用户退出时也需要清理SecurityContext。
CSRF攻击:某网站在用户还没退出对A网站的登陆时盗用用户的cookie,模拟用户身份访问服务器,提交表单向服务器提交数据,如账户转移等,来谋取利益,多发生在表单提交时。
Security可以预防CSRF攻击,在提交表单时会生成一个隐藏的tocken,表单中有一个随机tocken,每次请求都不一样。
Security防止CSRF攻击是在每个页面生成token,即CRSF令牌。在发送异步请求之前将csrf的令牌放在请求的消息头。
置顶,加精都是对帖子中属性状态的更新,因为帖子内容改变,所以要触发发帖事件topic,在发帖事件的消费者端更新ES中帖子的数据。对于删帖,数据库只需要改变帖子的状态,对于ES需要删除数据行,所以还要通过Kafka设置一个删帖事件topic,在ES中删除帖子。
在前端进行置顶等按钮进行权限控制显示,只有版主和管理员才能看到相应的按钮。在前端引入spring-security的命名空间,进行权限控制显示。
HyperLogLog:用于去重计数,但有误差
UV:可用HyperLogLog统计浏览ip数,包括游客访问。redis中按照日期或日期区间进行存储,统计某日或某段时间的访客数量。统计日期范围时,要先整理日期范围内的redis的key值,然后对每个key值下的数据进行union整合操作。
Bitmap:安慰存储数据,支持大量的连续的数据的布尔值
DAU:用bitmap存储每个用户的访问状态,如果登陆则记为活跃,否则为0,不活跃。同样redis的key根据日期进行设置,可以统计每天或一段时间内的日活跃数。对于一段时间的日活跃数统计,也需要先整理这段时间的key值,然后对其进行or操作,只要该用户这段时间里有一天是登陆状态则为活跃。
用拦截器实现记录用户,拦截器中无论是否登陆状态都对该用户的ip进行统计,日活跃量则需要先判断是否为登陆用户。
在查询访客数量和活跃用户数量时,需要限定输入日期格式,用@DataTimeFormat注解指定日期格式,若传入的格式不一致则报400错误。
SpringQuartz为定时任务调度,Spring线程池中ThreadPoolTaskSchedule也可以完成定时任务的功能,但不适合用于分布式,因为Spring的定时线程池是基于内存配置,配置参数在内存,分布式服务器之间无法共享,导致分布式中一个定时任务启动全部服务器的定时任务都启动,浪费资源。SpringQuartz则是基于数据库的,配置参数都在数据库,分布式中可通过加锁优先抢到数据库中的参数配置。
配置文件:配置jobDetail和Trigger,只有第一次调用时需要配置到数据库,之后直接调用数据库。
根据帖子是否加精,评论数量,收藏数以及发布时间计算帖子分数,进而根据分数进行排序。
为了提高效率,在点赞,评论等行为发生后不立即进行分数计算,而是将帖子加入redis缓存,之后统一计算。redis中只存储帖子分数发生变化的id,用set去重存储。
配置SpringQuartz定时任务,计算帖子分数,在定时任务中取出redis的所有对应key的value值,即帖子id值,然后对每篇帖子的分数进行重新计算,重新计算完之后更新数据库的同时要将ES中的数据一起更新。
对帖子的排列,在在前端传一个mode参数,用@RequestParam(name="",defaultValue="")可赋予默认值,在数据库根据mode的值进行不同排序,如果为0,则默认先看置顶,再按时间排序,为1则先置顶之后根据分数排序。