点击发布帖子按钮,输入帖子标题内容等信息后发布,以JS异步请求的方式进行提交,首先判断该线程中有无用户信息(在登录成功是会将用户信息与本线程的id进行键值对存储,保证了多线程情况下安全)即判断有无登录,已登录则初始化帖子信息将其添加至DB中,并且触发发帖事件,采用kafka信息队列来完成,将帖子信息添加至ES中,通知再将其存入Redis中计算帖子的分值,用来在最热列表中展示出来:
添加帖子
// 发布帖子
@RequestMapping(path = "/add",method = RequestMethod.POST)
@ResponseBody
public String addDiscussPost(String title,String content){
User users = hostHolder.getUsers();
if(users == null){
return CommunityUtil.getJSONString(403,"你还没有登录!");
}
DiscussPost post=new DiscussPost();
post.setUserId(users.getId());
post.setTitle(title);
post.setContent(content);
post.setCreateTime(new Date());
discussPostService.addDiscussPost(post);
// 触发发帖事件 添加至es中
Event event = new Event()
.setTopic(TOPIC_PUBLISH)
.setUserId(users.getId())
.setEntityType(ENTITY_TYPE_POST)
.setEntityId(post.getId());
eventProducer.fireEvent(event);
// 计算帖子分数
String redisKey= RediskeyUtil.getPostScoreKey();
redisTemplate.opsForSet().add(redisKey,post.getId());
return CommunityUtil.getJSONString(0,"发布成功!");
}
// 消费 发帖事件 es添加数据
@KafkaListener(topics = {TOPIC_PUBLISH})
public void handlePublishMessage(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;
}
DiscussPost discussPost =
discussPostService.selectDiscussPostById(event.getEntityId());
elasticsearchService.saveDiscussPost(discussPost);
}
评论这一块数据库表comment结构得先捋一下,entity_type字段(int型)1:表示此条评论是对帖子进行的一个评论,2:表示此条评论是对1级评论进行的2级评论;entityId字段(int型)如果entity_type字段为1则此字段存储的是帖子的id,如果entity_type字段为2则此字段存储的是对哪个1级评论进行的评论;targetId字段(int型)可为空,为空并且entityId为2则表示此评论没有哪个2级评论进行评论,不为空且entityId为2则表示此评论是对一个2级评论的回复评论。
填写完评论内容后点击回复按钮,以POST形式进行提交,附带帖子id、entity_type字段、entityId字段、entity_type字段信息;
entity_type字段为1、表示是对帖子进行的评论
entity_type字段为2、并且有targetId字段信息,表示是对2级评论进行的评论
提交以后完善comnent的信息,评论时间等等信息。触发评论事件kafak生产者,评论类系统通知,通知相关用户收到系统通知,某某在某处给你的评论进行回复。触发发帖事件 添加至es中。同时在Redis中更新帖子的分值。
提交表单,添加评论
// 添加评论
@RequestMapping(path = "/add/{discussPostId}",method = RequestMethod.POST)
public String addComment(@PathVariable("discussPostId") int discussPostId , Comment comment){
comment.setUserId(hostHolder.getUsers().getId());
comment.setStatus(0);
comment.setCreateTime(new Date());
commentService.addComment(comment);
// 触发评论事件 系统通知 信息队列 评论类系统通知
Event event = new Event()
.setTopic(TOPIC_COMMENT) // 通知主题
.setUserId(hostHolder.getUsers().getId()) // 发起用户id
.setEntityType(comment.getEntityType()) // 实体类型
.setEntityId(comment.getEntityId()) // 实体id
.setData("postId", discussPostId); // 帖子id
if(comment.getEntityType() == ENTITY_TYPE_POST){
DiscussPost discussPost = discussPostService.selectDiscussPostById(comment.getEntityId());
event.setEntityUserId(discussPost.getUserId()); // 接收用户id
}else if(comment.getEntityType() == ENTITY_TYPE_COMMENT){
Comment comment1 = commentService.findCommentById(comment.getEntityId());
event.setEntityUserId(comment1.getUserId()); // 接收用户id
}
eventProducer.fireEvent(event); // 发送
// 触发发帖事件 添加至es中
if(comment.getEntityType() == ENTITY_TYPE_POST){
event = new Event()
.setTopic(TOPIC_PUBLISH)
.setUserId(comment.getUserId())
.setEntityType(ENTITY_TYPE_POST)
.setEntityId(discussPostId);
eventProducer.fireEvent(event);
}
// 计算帖子分数
String redisKey= RediskeyUtil.getPostScoreKey();
redisTemplate.opsForSet().add(redisKey,discussPostId );
return "redirect:/discuss/detail/"+discussPostId;
}
kafka消费者,处理系统通知,包括点赞状态,评论通知,关注通知
// 消费者 接收处理通知
@KafkaListener(topics = {TOPIC_COMMENT,TOPIC_FOLLOW,TOPIC_LIKE})
public void handleCommentMessage(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()); // 接收用户id
message.setConversationId(event.getTopic()); // 通知类型 关注/点赞/回复
message.setCreateTime(new Date()); // 时间
Map content=new HashMap<>();
content.put("userId",event.getUserId()); // 发起者用户id
content.put("entityType",event.getEntityType()); // 实体类型 关注/评论/回复
content.put("entityId",event.getEntityId()); // 实体id
if(!event.getData().isEmpty()){ // 遍历添加其他数据
for(Map.Entry entry : event.getData().entrySet()){
content.put(entry.getKey(), entry.getValue());
}
}
message.setContent(JSONObject.toJSONString(content)); // 将对象转化为JSON格式存储
messageService.addMessage(message);
}
进入信息页面,点击发私信按钮,填写发送用户,以及私信内容。以JS异步方式提交,根据发送用户判断是否存在此用户,没有则返回目标用户不存在,有则完善私信message信息,接收者,发送者,发送时间等添加至数据库中
// 发送私信
@RequestMapping(path = "/letter/send",method = RequestMethod.POST)
@ResponseBody
public String sendLetter(String toName,String content){
// 目标用户
User target = userService.selectByName(toName);
if(target == null){
return CommunityUtil.getJSONString(1,"目标用户不存在!");
}
Message message=new Message();
message.setFromId(hostHolder.getUsers().getId()); // 发送者
message.setToId(target.getId()); // 接收者
if(message.getFromId() < message.getToId()){
message.setConversationId(message.getFromId()+"_"+message.getToId());
}else{
message.setConversationId(message.getToId()+"_"+message.getFromId());
}
message.setContent(content);
message.setCreateTime(new Date());
messageService.addMessage(message);
return CommunityUtil.getJSONString(0);
}
说白了就是将html界面转化为Png图片,并且上传至七牛云上进行保存,返回访问图片的链接,
kafka生成事件
// 分享网页 转化成长图
@RequestMapping(path = "/share",method = RequestMethod.GET)
@ResponseBody
public String share(String htmlUrl){
// 文件名
String fileName = CommunityUtil.generateUUID();
// 异步生成长图
Event event=new Event()
.setTopic(TOPIC_SHARE)
.setData("htmlUrl",htmlUrl)
.setData("fileName",fileName)
.setData("suffix",".png");
eventProducer.fireEvent(event);
// 返回访问路劲
Map map=new HashMap<>();
//map.put("shareUrl",domain + contextPath + "/share/image/" + fileName);
map.put("shareUrl", shareBucketUrl + "/" + fileName); //就改了下这里
return CommunityUtil.getJSONString(0,null,map);
}
// 消费分享事件
@KafkaListener(topics = TOPIC_SHARE)
public void handleShareMessage(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;
}
String htmlUrl = (String) event.getData().get("htmlUrl");
String fileName = (String) event.getData().get("fileName");
String suffix = (String) event.getData().get("suffix");
String cmd = wkImageCommand + " --quality 75 " + htmlUrl + " " + wkImageStorage + "/" + fileName + suffix;
try {
Runtime.getRuntime().exec(cmd);
logger.info("生成长图成功: " + cmd);
} catch (IOException e) {
logger.info("生成长图失败: " + e.getMessage());
}
// 启用定时器,监视该图片,一旦生成了,则上传至七牛云.
UploadTask task = new UploadTask(fileName, suffix);
Future future = taskScheduler.scheduleAtFixedRate(task, 500);//每隔半秒钟执行一遍。Future里封装了任务的状态,还可以用来停止定时器。
task.setFuture(future);//停止定时器应该在run()方法里停止,在达成某个条件之后,于是,要把返回的future传入UploadTask类的对象task里
}
/*
以下情况导致上传失败,但上传失败了,不能够线程就不停止了,线程不停止的话,时间久了,会有很多线程因为这种原因不停止,服务器就被撑爆了。
所以要考虑到这些情况,即使出现这些极端情况,也一定要停掉定时器。于是要增加两个属性(开始时间,上传次数):
1.图片一直无法生成到本地
2.网络不好无法上传图片/七牛云的服务器挂了,无法上传图片
*/
class UploadTask implements Runnable {
// 文件名称
private String fileName;
// 文件后缀
private String suffix;
// 启动任务的返回值
private Future future;
// 开始时间
private long startTime;
// 上传次数
private int uploadTimes;
public UploadTask(String fileName, String suffix) {
this.fileName = fileName;
this.suffix = suffix;
this.startTime = System.currentTimeMillis();
}
public void setFuture(Future future) {
this.future = future;
}
@Override
public void run() {
// 生成失败
if (System.currentTimeMillis() - startTime > 60000) {//30秒,还没上传成功,大概率是生成图片失败
logger.error("执行时间过长,终止任务:" + fileName);
future.cancel(true);//这行代码用来终止任务.
return;
}
// 上传失败
if (uploadTimes >= 3) {//上传3次,还不成功,大概率是网络不好/服务器挂了。文件不存在时不进行上传操作
logger.error("上传次数过多,终止任务:" + fileName);
future.cancel(true);
return;
}
String path = wkImageStorage + "/" + fileName + suffix;
File file = new File(path);
if (file.exists()) {
logger.info(String.format("开始第%d次上传[%s].", ++uploadTimes, fileName));
// 设置响应信息
StringMap policy = new StringMap();
policy.put("returnBody", CommunityUtil.getJSONString(0));
// 生成上传凭证
Auth auth = Auth.create(accessKey, secretKey);
String uploadToken = auth.uploadToken(shareBucketName, fileName, 3600, policy);//凭证uploadToken过期时间,3600秒,1小时
// 指定上传机房
UploadManager manager = new UploadManager(new Configuration(Region.region1()));//zone1()是上传到华北地区了。即setting.js里的指定上传到的服务器域名“url: "http://upload-z1.qiniup.com",”
try {
// 开始上传图片
Response response = manager.put(
path, fileName, uploadToken, null, "image/" + suffix, false);//第三个参数是上传文件类型,变量mime,值“image/.png”
// 处理响应结果
JSONObject json = JSONObject.parseObject(response.bodyString());//把返回的JSON格式字符串转为JSON对象
if (json == null || json.get("code") == null || !json.get("code").toString().equals("0")) {
logger.info(String.format("第%d次上传失败[%s].", uploadTimes, fileName));
} else {
logger.info(String.format("第%d次上传成功[%s].", uploadTimes, fileName));
future.cancel(true);
}
} catch (QiniuException e) {
logger.info(String.format("第%d次上传失败[%s].", uploadTimes, fileName));
}
} else {//如果文件不存在就什么也不做,过一会这个定时任务又会被调一次,再来上传
logger.info("等待图片生成[" + fileName + "].");
}
}
}
进入帖子的详情页面,可以对此帖子进行点赞,同时还可以对评论、对评论的2级评论进行点赞,以JS方式提交,返回点赞后状态,因此在不同的地方进行点赞所携带的参数也不一样
对帖子进行的点赞,携带的参数是entityType 帖子类型(回复/评论等...)、entityId 评论id、 entityUserId 收到赞的用户id、postId帖子的id
nice 11
处理点赞的响应,由于用户量大点赞时会非常的频繁操作考虑到数据库的性能原因将点赞的各类信息存入Redis中,通过写好的工具类把所已知的点赞信息封装成唯一的字符串,方便进行增删操作,例如某个实体的赞:like:user:entityType:entityId 将此作为Redis中的key,value则为执行点赞的用户id,为了方便进行统计单个用户所获得的赞数量,也在Resis中储存,例如某个用户收到的赞like:user:userId 将此作为Redis中的key,value则为所获得的点赞数。生成唯一的key后首先要判断是否已经赞过,再次点击则视为取消点赞。
// 点赞 写入redis 同时记录每个用户收到的赞
public void like(int userId,int entityType,int entityId,int entityUserId){ //entityType 帖子类型(回复/评论等...) entityId 评论id entityUserId 收到赞的用户id
// 事务控制
redisTemplate.execute(new SessionCallback() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
String entityLikeKey = RediskeyUtil.getEntityLikeKey(entityType,entityId);
String userLikeKey = RediskeyUtil.getUserLikeKey(entityUserId);
// 判断是否此用户对此以及赞过
Boolean isMember = operations.opsForSet().isMember(entityLikeKey, userId);
operations.multi();
if(isMember){
// 赞过 则 取消赞
operations.opsForSet().remove(entityLikeKey,userId);
// -- 用户收到的赞
// like:user:userId -> int 详情下方示例1 redisTemplate.opsForValue().set( like:user:userId , i);
operations.opsForValue().decrement(userLikeKey); // --i
}else{
// 没赞 则 赞
// like:entity:entityType:entityId -> (userid1,userid2,userid3,.....) 详情下方示例2 redisTemplate.opsForSet().add( like:entity:entityType:entityId , "101", "102", "103", "104", "105",.....);
operations.opsForSet().add(entityLikeKey,userId);
// ++ 用户收到的赞
// like:user:userId -> int 详情下方示例1 redisTemplate.opsForValue().set( like:user:userId , i);
operations.opsForValue().increment(userLikeKey); // ++i
}
return operations.exec();
}
});
}
处理此处的点赞数量,根据例如某个实体的赞:like:user:entityType:entityId 这个key可以轻松在Redis当中此处value的个数,即为此处的点赞数量
// 查询某实体点赞的数量 此帖已收到的赞
public long findEntityLikeCount(int entityType,int entityId){
String entityLikeKey=RediskeyUtil.getEntityLikeKey(entityType,entityId);
return redisTemplate.opsForSet().size(entityLikeKey);
}
处理此处相当于现已登录的用户而言是否已经赞过,赞过则显示已赞,再次点击视为取消点赞,没有赞过则显示赞,点击视为点赞,返回值为int型,可拓展更多的功能,例如踩
// 查询某人对某实体的点赞状态 显示 已赞/赞 1/0 (int 类型数据 可拓展 踩 等功能)
public int findEntityLikeStatus(int userId,int entityType,int entityId){
String entityLikeKey=RediskeyUtil.getEntityLikeKey(entityType,entityId);
return redisTemplate.opsForSet().isMember(entityLikeKey,userId) ? 1 : 0;
}
生产者:触发点赞事件,发送系统通知,点赞类型的通知,用时为了持久化存储写入数据库当中,此为异步处理不影响性能,同时在Redis中更新帖子的分值。
@RequestMapping(path = "/like",method = RequestMethod.POST)
@ResponseBody
public String like(int entityType,int entityId,int entityUserId,int postId){ //entityType 帖子类型(回复/评论等...) entityId 评论id entityUserId 收到赞的用户id
User user = hostHolder.getUsers();
// 点赞
likeService.like(user.getId(),entityType,entityId,entityUserId);
// 点赞数量
long likeCount = likeService.findEntityLikeCount(entityType, entityId);
// 点赞状态
int likeStatus = likeService.findEntityLikeStatus(user.getId(), entityType, entityId);
// 返回结果
Map map=new HashMap();
map.put("likeCount",likeCount);
map.put("likeStatus",likeStatus);
// 触发点赞事件 发送系统通知 点赞类型通知
if(likeStatus == 1){
Event event=new Event()
.setTopic(TOPIC_LIKE) // 通知主题
.setUserId(hostHolder.getUsers().getId()) // 发起用户id
.setEntityType(entityType) // 实体类型
.setEntityId(entityId) // 实体id
.setEntityUserId(entityUserId) // 接收用户id
.setData("postId",postId); // 点赞处id
eventProducer.fireEvent(event); // 发送通知
}
// 计算帖子分数
if(entityType == ENTITY_TYPE_POST){
String redisKey= RediskeyUtil.getPostScoreKey();
redisTemplate.opsForSet().add(redisKey,postId);
}
// 以JSON方式返回
return CommunityUtil.getJSONString(0,null,map);
}
进入其他用户的个人主页,点击关注按钮可对其进行关注,同时还可取消对其的关注,以JS方式提交,返回点赞后状态,携带entityType 类型、entityId 关注的用户id。同时不加重数据库的承载进行Redis的存储,生成唯一的key:followee:userId:entityType value为关注用户的id,还要将生成此用户是关注用户的粉丝,生成唯一的key:followee:entityType:entityId value为此用户的id。这样在查询我的关注或我的粉丝的十分的方便
// g关注用户
public void follow(int userId,int entityType,int entityId){ // userId 该用户id entityType 类型 3 entityId 关注的用户id
redisTemplate.execute(new SessionCallback() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
String followeeKey = RediskeyUtil.getFolloweekey(userId,entityType);
String followerKey = RediskeyUtil.getFollowerkey(entityType,entityId);
operations.multi();
// 我的关注
operations.opsForZSet().add(followeeKey,entityId,System.currentTimeMillis());
// redisTemplate.opsForZSet().add( followee:userId:entityType , entityId, System.currentTimeMillis()); followee:userId:entityType:本用户 entityId:关注的用户id
// 他的粉丝
operations.opsForZSet().add(followerKey,userId,System.currentTimeMillis());
// redisTemplate.opsForZSet().add( followee:entityType:entityId , userId, System.currentTimeMillis()); followee:entityType:entityId:本用户 userId:粉丝的用户id
return operations.exec();
}
});
}
触发关注类型的系统通知,通知其用户被此用户进行了关注
// 关注用户
@RequestMapping(path = "/follow",method = RequestMethod.POST)
@ResponseBody
public String follow(int entityType,int entityId){ // entityType 类型 3 entityId 关注的用户id
User user = hostHolder.getUsers();
followService.follow(user.getId(),entityType,entityId);
// 触发关注事件 系统通知 关注类型通知
Event event = new Event()
.setTopic(TOPIC_FOLLOW)
.setUserId(user.getId())
.setEntityType(entityType)
.setEntityId(entityId)
.setEntityUserId(entityId);
eventProducer.fireEvent(event);
return CommunityUtil.getJSONString(0,"已关注");
}
先解析一波数据库在存储通知类型数据的时候的表结构,在message表中,to_id为所属用户的id,conversation_id为通知的类型(点赞/关注/评论),content存储的此条通知的详情
假如所属用户id统一为175
例如点赞类,conversation_id为like content为:{"entityType":2,"entityId":250,"postId":295,"userId":111} :entityType表示此点赞发生在评论处,entityId表示此点赞发生在评论id为250处,postId表示此点赞发生在帖子id为295处,userId表示对其点赞的是用户id111,根据以上信息可以精确定位到发生点赞的地点;
例如关注类,conversation_id为follow content为:{"entityType":3,"entityId":175,"userId":171}:entityType表示是关注类型的通知,entityId表示是关注的用户id为175,userId表示用户171对其进行的关注;
例如评论类,conversation_id为comment content为:{"entityType":1,"entityId":295,"postId":295,"userId":171}:entityType表示是1级评论的通知,在帖子295处进行的1级评论,评论所属用户171。
通过以三种通知类型以及用户id从数据库当中读取通知,再以HashMap解析content存储的数据
// 系统通知
@RequestMapping(path = "/notice/list" , method = RequestMethod.GET)
public String getNoticeList(Model model){
User user = hostHolder.getUsers();
//查询评论类通知
Message message = messageService.findLatestNotice(user.getId(), TOPIC_COMMENT);
Map messageVo=new HashMap();
if(message != null){
messageVo.put("message",message);
// 获取JSON格式数据
String content = HtmlUtils.htmlUnescape(message.getContent());
Map data = JSONObject.parseObject(content,HashMap.class);
// 获取详情信息
messageVo.put("user",userService.selectById((Integer) data.get("userId")));
messageVo.put("entityType",data.get("entityType"));
messageVo.put("entityId",data.get("entityId"));
messageVo.put("postId",data.get("postId"));
// 主题所包含的数量
int count = messageService.findNoticeCount(user.getId(),TOPIC_COMMENT);
messageVo.put("count",count);
// 主题未读的通知数量
int unread = messageService.findNoticeUnreadCount(user.getId(),TOPIC_COMMENT);
messageVo.put("unread",unread);
model.addAttribute("commentNotice",messageVo);
}
//查询点赞类通知
message = messageService.findLatestNotice(user.getId(), TOPIC_LIKE);
messageVo=new HashMap();
if(message != null){
messageVo.put("message",message);
// 获取JSON格式数据
String content = HtmlUtils.htmlUnescape(message.getContent());
Map data = JSONObject.parseObject(content,HashMap.class);
// 获取详情信息
messageVo.put("user",userService.selectById((Integer) data.get("userId")));
messageVo.put("entityType",data.get("entityType"));
messageVo.put("entityId",data.get("entityId"));
messageVo.put("postId",data.get("postId"));
// 主题所包含的数量
int count = messageService.findNoticeCount(user.getId(),TOPIC_LIKE);
messageVo.put("count",count);
// 主题未读的通知数量
int unread = messageService.findNoticeUnreadCount(user.getId(),TOPIC_LIKE);
messageVo.put("unread",unread);
model.addAttribute("likeNotice",messageVo);
}
//查询关注类通知
message = messageService.findLatestNotice(user.getId(), TOPIC_FOLLOW);
messageVo=new HashMap();
if(message != null){
messageVo.put("message",message);
// 获取JSON格式数据
String content = HtmlUtils.htmlUnescape(message.getContent());
Map data = JSONObject.parseObject(content,HashMap.class);
// 获取详情信息
messageVo.put("user",userService.selectById((Integer) data.get("userId")));
messageVo.put("entityType",data.get("entityType"));
messageVo.put("entityId",data.get("entityId"));
// 主题所包含的数量
int count = messageService.findNoticeCount(user.getId(),TOPIC_FOLLOW);
messageVo.put("count",count);
// 主题未读的通知数量
int unread = messageService.findNoticeUnreadCount(user.getId(),TOPIC_FOLLOW);
messageVo.put("unread",unread);
model.addAttribute("followNotice",messageVo);
}
// 查询未读消息数量
int letterUnreadCount = messageService.findLetterUnreadCount(user.getId(), null);
model.addAttribute("letterUnreadCount", letterUnreadCount);
int noticeUnreadCount = messageService.findNoticeUnreadCount(user.getId(), null);
model.addAttribute("noticeUnreadCount", noticeUnreadCount);
return "site/notice";
}