设计并实现站内消息接收功能

当平台有其他用户的行为影响到我们时,我们就会接受到平台的提醒消息,这就是站内消息系统的核心业务。

消息大体可以分为两类,一是用户产生的消息,二时系统产生的消息。

先说用户产生的消息,比如用户点赞、收藏、评论某个主体时,就会触发当前主体的发起者。

库表设计如下:

id

targettype:主体类型,比如文章、用户、评论等等

targetid:主体id

messagetype:消息类型,是点赞、收藏还是其他动作 messageid:这个是触发消息的内容,比如若是评论,那么该id就是评论id,除去评论回复,这个id值应该是没啥大用的。

seuserid:发送者

reuserid:接收者

createtime:创建时间

readtime:阅读时间

isread:是否阅读

对应的数据库模型如下:

php 复制代码
const Sequelize = require('sequelize');
module.exports = function(sequelize, DataTypes) {
  return sequelize.define('message', {
    id: {
      autoIncrement: true,
      type: DataTypes.INTEGER,
      allowNull: false,
      primaryKey: true
    },
    targettype: {
      type: DataTypes.STRING(255),
      allowNull: true
    },
    targetid: {
      type: DataTypes.INTEGER,
      allowNull: true
    },
    messagetype: {
      type: DataTypes.STRING(255),
      allowNull: true
    },
    seuserid: {
      type: DataTypes.INTEGER,
      allowNull: true
    },
    reuserid: {
      type: DataTypes.INTEGER,
      allowNull: true
    },
    createtime: {
      type: DataTypes.DATE,
      allowNull: true
    },
    readtime: {
      type: DataTypes.DATE,
      allowNull: true
    },
    isread: {
      type: DataTypes.STRING(255),
      allowNull: true
    }
  }, {
    sequelize,
    tableName: 'message',
    timestamps: false,
    indexes: [
      {
        name: "PRIMARY",
        unique: true,
        using: "BTREE",
        fields: [
          { name: "id" },
        ]
      },
    ]
  });
};

先从收藏的消息做起

点赞后,前端传递数据:

后台就把这条通知信息存储到数据库里就可以,有个问题就是,如果用户多次取消收藏再重新收藏,是会在数据库中生成多条记录的,因此保险起见,在插入数据前最好先执行一次删除操作。

后端返回数据格式:

前端获取数据,同时格式化处理这个统计数据数据:

js 复制代码
      getUserMessageStatic().then(res=>{
        let {code,data} = res;
        if(code == 10000){
          data&&data.forEach(item=>{
            this.readStatic.total+=item.count
            if(item.messagetype==3){
              this.readStatic.replay = item.count
            }
            if(item.messagetype == 1||item.messagetype == 2){
              this.readStatic.remind += item.count
            }
          })
        }
      })

前端效果如下:

然后就是列表数据,后台路由:

// 复制代码
router.get('/user/getUserMessageStatic',[authMiddleware.auth],controller.getUserMessageStatic);

// 获取消息列表
router.get('/user/getUserMessageList',[authMiddleware.auth],controller.getUserMessageList);

后台controller层:

js 复制代码
  async getUserMessageList(req,res){
    if(!req.query.messagetype) {
      return res.send({
        msg:'消息类型messagetype必传!'
      })
    }
    let result = await userActionDao.getUserMessageList(req.query)
    res.send({
      msg:'成功',
      data:result,
      code:10000
    })
  }

前端得到的数据结构:

效果如下:

这些统计的是未读的数据,如何确定用户已读了呢,参考快手抖音的逻辑,只要用户点进去查看,这时候就默认用户已经阅读。 也就是说,当拉取到这些未读数据时,就要当即给这些数据打上已读的标识。 后台代码:

try 复制代码
      // 我这里就贪快
      let sql = `
    SELECT *,
      (SELECT username from user WHERE message.reuserid = user.id) reusername,
      (SELECT headimg from user WHERE message.reuserid = user.id) reheadimg,
      (SELECT username from user WHERE message.seuserid = user.id) seusername,
      (SELECT headimg from user WHERE message.seuserid = user.id) seheadimg
    FROM message
    where reuserid = :userid
    and messagetype  in (${params.messagetype.join(",")})

    `;
      let [results] = await Model.sequelize.query(sql, {
        replacements: {
          // isread:0,
          userid: params.userid,
          // messagetype:`(${params.messagetype.join(',')})`
        },
      });
      let ids = [];
      // 这里性能不是关键问题,暂时采用这种简单的写法。
      for (let item of results) {
        if (item.targettype == "1") {
          item.target = await Model.circle.findOne({
            where: {
              id: item.targetid,
            },
          });
        }
        console.log('_______________________---',item.isread)
        if (item.isread == 0) {
          ids.push(item.id);
        }
      }
      console.log(ids)
      // 获取到这些数据后,需要手动更新,但这里不再需要等待了
      if (ids.length > 0) {
        Model.message.update(
          {
            isread: 1,
          },
          {
            where: {
              id: {
                [Model.Sequelize.Op.in]: ids.map(id => parseInt(id)),
              },
            },
          }
        );
      }

      return results;
    } catch (err) {
      console.log(err);
    }

用户访问消息数据时,获取到的数据是这样的:

虽然此时状态依旧是read=0,未读状态,但实际上数据库已经修改为已读状态了,当用户下次进入,查看到的就是已读状态。 当然,用户第一次进入的时候,可以给未读消息加个红点,标识哪些是未读的:

下次再次进入,红点就消失了,并且顶部的消息状态也修改了:

前面说过,赞、收藏和评论以及关注,其实都是一张表message中的内容,为什么要将这三块单独抽离出来,最主要的原因就是数据是否需要聚合。 再聚合之前,首先要想明白,什么样的数据需要聚合,什么样的数据不需要聚合? 像比如收藏和点赞这类数据,其实数据本身并没有什么内容价值,所以需要聚合,而像评论回复这种具有内容价值的,我们就不能做聚合,那么后端代码如下:

try 复制代码
      // 我这里就贪快,尚未阅读过的数据,不聚合,阅读过的数据,聚合
      let sql = `
      select * from (

        SELECT 'juhe' as juhe,
          GROUP_CONCAT(message.id) as ids,
          GROUP_CONCAT(seuserid) as seuserids,
          reuserid,
          MAX(message.createtime) as createtime, targettype,  targetid, messagetype, isread,
          count(*) as total,
          (SELECT username from user WHERE message.reuserid = user.id) reusername,
          (SELECT headimg from user WHERE message.reuserid = user.id) reheadimg,
          GROUP_CONCAT(username) seusernames,
          GROUP_CONCAT(headimg) seheadimgs
        from message left JOIN user on user.id = message.seuserid
        WHERE reuserid = :userid
        and isread = 1
        and messagetype in (1,2)
        GROUP BY targetid, targettype, messagetype
        
        -- 只有点赞收藏且已经阅读过的数据,才可聚合
        UNION ALL
        SELECT 'not_juhe' as juhe,
        id as ids,
        seuserid as seuserids,
        reuserid,
        createtime, targettype,targetid,messagetype,isread,1 as total,
              (SELECT username from user WHERE message.reuserid = user.id) reusername,
              (SELECT headimg from user WHERE message.reuserid = user.id) reheadimg,
              (SELECT username from user WHERE message.seuserid = user.id) seusernames,
              (SELECT headimg from user WHERE message.seuserid = user.id) seheadimgs
        FROM message
        where reuserid = :userid
        AND (isread = 0 OR messagetype NOT IN (1, 2))
        order by createtime desc
        )messagelist
        WHERE messagelist.messagetype in (${params.messagetype.join(",")})
    `;
      let [results] = await Model.sequelize.query(sql, {
        replacements: {
          // isread:0,
          userid: params.userid,
          // messagetype:`(${params.messagetype.join(',')})`
        },
      });
      let ids = [];
      // 这里性能不是关键问题,暂时采用这种简单的写法。
      for (let item of results) {
        if (item.targettype == "1") {
          item.target = await Model.circle.findOne({
            where: {
              id: item.targetid,
            },
          });
        }
        console.log('_______________________---',item.isread)
        if (item.isread == 0) {
          ids.push(item.ids);
        }
      }
      ids = ids.join(',').split(',')
      console.log(ids)
      // 获取到这些数据后,需要手动更新,但这里不再需要等待了
      if (ids.length > 0) {
        Model.message.update(
          {
            isread: 1,
          },
          {
            where: {
              id: {
                [Model.Sequelize.Op.in]: ids.map(id => parseInt(id)),
              },
            },
          }
        );
      }

      return results;
    } catch (err) {
      console.log(err);
    }

这样之后,如果是未读数据,那么就按照正常单个访问:

如果是已读数据,那么数据将会聚合处理,比如下次再进入,那么数据将聚合,并且时间取最近行为时间:

之后就该解决评论和回复的消息了。 评论这块的功能业务逻辑其实挺复杂的,首先评论可以是对任何主体内容进行评论,比如文章,句子,以及其他评论。 而这些主题的字段又不太一样,所以需要区分出主体类型,动态构造sql来查询不同的主体。 评论的库表设计如下:

const 复制代码
module.exports = function(sequelize, DataTypes) {
  return sequelize.define('comment', {
    id: {
      autoIncrement: true,
      type: DataTypes.INTEGER,
      allowNull: false,
      primaryKey: true
    },
    pid: {
      type: DataTypes.INTEGER,
      allowNull: true,
      defaultValue: 0
    },
    targetid: {
      type: DataTypes.INTEGER,
      allowNull: true
    },
    content: {
      type: DataTypes.TEXT,
      allowNull: true
    },
    userid: {
      type: DataTypes.INTEGER,
      allowNull: true
    },
    createtime: {
      type: DataTypes.DATE,
      allowNull: true
    },
    type: {
      type: DataTypes.INTEGER,
      allowNull: true,
      defaultValue: 1,
      comment: "0:对句子的评论,1:对文字,2:对图书"
    }
  }, {
    sequelize,
    tableName: 'comment',
    timestamps: false,
    indexes: [
      {
        name: "PRIMARY",
        unique: true,
        using: "BTREE",
        fields: [
          { name: "id" },
        ]
      },
    ]
  });
};

那么根据组合不同的sql来查询,前端会得到这样的一个数据结构:

这里主要关注这几个数据,messagetarget就是用户触发的,比如A用户新增了个评论,并提醒B用户,messagetarget就是A用户新增的评论,tareget就是主体,也就是B用户发布的内容。 拿到这样的数据结构之后,基本上更多的业务工作就落在前端身上了,前端的逻辑代码写得不好,后面肯定要重新改动:

js 复制代码
<template>
  <div>
    <div v-for="(item, index) in messageLst" :key="index">
      <div class="user">
        <div>
          <img :src="$setImg(item.seheadimgs)" alt="">
          <span class="username">{{ item.seusernames }}</span>
          回复了你的
          <span v-if="item.targettype == 1">文章</span>
          <span v-if="item.targettype == '101'">评论</span>

        </div>
        <div> {{ $formatTime(new Date(item.createtime)) }}</div>
      </div>
      <div class="content_box">
        <div @click="tabModal(item)" class="content chat_text" style="font-size: 14px;">
          <span>{{ item.messagetarget.content }}</span>
        </div>
        <div v-if="item.targettype == 1" class="target_content">
          <span>{{ item.target.title }}</span>
        </div>
        <div v-else>
          <span>{{ item.target&&item.target.content }}</span>
        </div>
        <div class="content_bottom">
          <div>
            <div style="margin-right: 14px;cursor: pointer;">
              <svg-icon icon-class="blog_reply" style="color: gray;margin-right: 3px;"></svg-icon>
              <Popover trigger="click" placement="bottom-start" :width="300">
                <span slot="reference">回复</span>
                <div>
                  <CommentSubmit @submit="(value) => submitReplay(item,value)"></CommentSubmit>
                </div>
              </Popover>
            </div>

          </div>
          <div style="cursor: pointer;color: red;">举报</div>
        </div>
        <div style="height: 1px;width: 100%;background-color: #f0f0f0;margin: 10px 0;">
        </div>

      </div>
    </div>
    <Modal title="评论详情" :visible.sync="showModal" :hidefooter="true">
      <div slot="content">
        <div>
          <div class="user">
            <div>
              <img :src="$setImg(activeComment.reheadimg)" alt="">
              <span class="username">{{ activeComment.reusername }}</span>


            </div>
            <div> {{ $formatTime(new Date(activeComment.createtime)) }}</div>
          </div>
          <div style="margin: 15px 0;" v-if="activeComment.targettype == 1">
            <div class="target_content">
              <span>{{ activeComment.target.title }}</span>
            </div>
          </div>
          <div style="margin: 15px 0;" v-if="activeComment.targettype == '101'">
            <span>{{ activeComment.target.content }}</span>
          </div>
        </div>
        <div style="height: 2px ;width: 100%;margin-bottom: 25px;background-color: #f0f0f0;"></div>
        <div v-for="(item, index) in activeCommnetReplayList" :key="index">
          <div class="user">
            <div>
              <img :src="$setImg(item.headimg)" alt="">
              <span class="username">{{ item.username }}</span>
            </div>
            <div> {{ $formatTime(new Date(item.createtime)) }}</div>
          </div>
          <div class="content_box">
            <div class="content chat_text" style="font-size: 14px;">
              <span>{{ item.content }}</span>
            </div>

            <div class="content_bottom">
              <div>
                <div style="margin-right: 14px;cursor: pointer;">
                  <svg-icon icon-class="blog_reply" style="color: gray;margin-right: 3px;"></svg-icon>
                  <Popover trigger="click" placement="bottom-start" :width="300">
                    <span slot="reference">回复</span>
                    <div>

                      <CommentSubmit @submit="(value) => submitReplay(item,value)"></CommentSubmit>
                    </div>
                  </Popover>
                </div>

              </div>
              <div v-if="'item_s.isyour'" style="cursor: pointer;color: red;">举报</div>
              <el-tooltip v-else effect="dark" content="点击举报" placement="top">
                <svg-icon icon-class="more"></svg-icon>
              </el-tooltip>
            </div>
            <div style="height: 1px;width: 100%;background-color: #f0f0f0;margin: 10px 0;">
            </div>

          </div>
        </div>
      </div>
    </Modal>
  </div>
</template>
<script>
import songfromitem from '@/components/control/songform/form-item.vue';
import songfrom from '@/components/control/songform/Form.vue';
import songinput from '@/components/control/input/input.vue'

import songbutton from '@/components/control/Button.vue'

import radio from '@/components/control/radio/radio.vue'
import { uploadFile, editUser, getUserMessageList,addComment } from '@/api/user';
import {
  getReplay
} from '@/api/open'
import Popover from '@/components/control/popover/Popover.vue';
import CommentSubmit from '@/components/complex/commentSubmit/CommentSubmit.vue';
import Modal from '@/components/control/Modal.vue';
// import UserEditVue from '@/components/UserEdit.vue';
export default {
  components: {
    songbutton,
    songfrom,
    songfromitem,
    songinput,
    radio,
    Popover,
    CommentSubmit,
    Modal
  },
  data() {
    return {
      messageLst: [],
      userForm: {
        headimg: '',
        username: '',
        email: '',
        usersign: '',
        phone: '',
        sex: ''
      },
      rules: [],
      edit: false,
      showModal: false,
      activeComment: {},
      activeCommnetReplayList: []
    }
  },
  mounted() {
    getUserMessageList({
      // 1,2messagetype表示点赞和收藏
      messagetype: [3]
    }).then(res => {
      let { code, data } = res;
      if (code == 10000) {
        this.messageLst = data
      }
    })
    console.log(this.$store.getters.userinfo)
    this.userForm = JSON.parse(JSON.stringify(this.$store.getters.userinfo))
  },
  methods: {
    submitReplay(item,content){
      if (!content) {
        return this.$message.error('请输入回复内容')
      }
      // return console.log(item)
      addComment({
        pid: item.targetid,
        targetid: item.targetid,
        content: content,
        type: item.targettype
      }).then(res => {
        // this.showAllReply(piditem)
        // return console.log(res)
        
        this.$sendMessage({
            targettype:101,
            targetid:item.id,
            reuserid:item.userid??item.seuserids,
            messagetype:3,//评论
            messageid:res.data.id,
          })
        // this.getComment()
      })
    },
    tabModal(item) {
      this.activeComment = item
      console.log(item)
      if (this.activeComment.targettype != '101') return
      getReplay({
        id: item.target.id
      }).then(res => {
        console.log(res)
        this.activeCommnetReplayList = res.data
      })
      this.showModal = true
    },
    tips() {
      this.$message.error('暂时不提供修改密码功能')

    },
    getFile(event) {
      // let fileblob = new Blob(event.target.files)
      let formdata = new FormData();
      formdata.append('file', event.target.files[0]);
      // formdata.append('folderid', this.currentid)
      uploadFile(formdata).then(res => {
        console.log(res)
        this.userForm.headimg = res.data
        // this.getUserFileList(this.currentid)
      })
    },

    tabEdit() {
      this.edit = !this.edit
      this.userForm = JSON.parse(JSON.stringify(this.$store.getters.userinfo))
    },
    saveUserForm() {
      console.log(this.userForm)
      editUser(this.userForm).then(res => {
        console.log(res)
        this.initUser()
        this.edit = false
      })
    },
    initUser() {
      this.$store.dispatch('user/getUserInfo').then(data => {
        this.userForm = data
      })
    },
  }

}
</script>
<style scoped lang="less">
.target_content {
  // background-color: #f0f0f0;
  margin: 10px 0;
  padding: 5px 0;
  padding-left: 10px;
  border-left: 5px solid #f0f0f0;
  background-color: #f9f9f9f9;

}

.user {
  display: flex;
  align-items: center;
  justify-content: space-between;

  &>div {
    display: flex;
    align-content: center;
  }

  &>div:nth-of-type(2) {
    font-size: 12px;
    color: var(--place_holder)
  }

  img {
    width: 25px;
    height: 25px;
    margin-right: 10px;
    background-color: black;
    border-radius: 50%;
    object-fit: cover;
  }

  .username {
    // font-size: 13px;
    white-space: nowrap;
    color: var(--font_color1);
  }
}

.content_box {
  margin-left: 33px;
  margin-bottom: 10px;
  margin-top: 10px;
  align-items: center;
  position: relative;

  .content {
    font-size: 18px;
    margin: 10px 0;
    margin-top: 0;
  }

  .content_bottom {
    margin-top: 10px;
    // margin-bottom: 20px;
    // padding-bottom: 10px;
    // border-bottom: 1px solid #f0f0f0;
    display: flex;
    align-items: center;
    justify-content: space-between;
    position: relative;

    &>span {
      font-size: 12px;
      color: #666;
    }

    &>div {
      display: flex;
      align-items: center;
      font-size: 12px;
      color: #666;
    }
  }

  .reply_box {


    border-radius: 5px;

  }

  .reply {
    margin-top: 15px;

    &>div:nth-of-type(1) {
      .user {
        display: flex;
        align-items: center;

        img {
          width: 20px;
          height: 20px;
          margin-right: 5px;
        }

        .username {
          font-size: 14px;
          font-weight: 600;
          white-space: nowrap;
        }

        .content_bottom {
          margin-top: 5px;
          display: flex;
          align-items: center;
          justify-content: space-between;

          &>span {
            font-size: 12px;
            color: #666;
          }

          &>div {
            display: flex;
            align-items: center;
            font-size: 12px;
            color: #666;
          }
        }
      }
    }
  }
}
</style>

到这一步,其他用户评论我们的文章或评论,就可以收到通知消息,当然,在通知消息中查看详情,就是上节所提到的:对于评论回复,直接在当前页面展开详情弹框:

对于文章,则不需要弹框,直接跳转至文章详情即可。

相关推荐
zwjapple7 分钟前
docker-compose一键部署全栈项目。springboot后端,react前端
前端·spring boot·docker
tan180°2 小时前
MySQL表的操作(3)
linux·数据库·c++·vscode·后端·mysql
像风一样自由20202 小时前
HTML与JavaScript:构建动态交互式Web页面的基石
前端·javascript·html
aiprtem3 小时前
基于Flutter的web登录设计
前端·flutter
浪裡遊3 小时前
React Hooks全面解析:从基础到高级的实用指南
开发语言·前端·javascript·react.js·node.js·ecmascript·php
优创学社23 小时前
基于springboot的社区生鲜团购系统
java·spring boot·后端
why技术3 小时前
Stack Overflow,轰然倒下!
前端·人工智能·后端
幽络源小助理3 小时前
SpringBoot基于Mysql的商业辅助决策系统设计与实现
java·vue.js·spring boot·后端·mysql·spring
GISer_Jing3 小时前
0704-0706上海,又聚上了
前端·新浪微博
止观止4 小时前
深入探索 pnpm:高效磁盘利用与灵活的包管理解决方案
前端·pnpm·前端工程化·包管理器