当平台有其他用户的行为影响到我们时,我们就会接受到平台的提醒消息,这就是站内消息系统的核心业务。
消息大体可以分为两类,一是用户产生的消息,二时系统产生的消息。
先说用户产生的消息,比如用户点赞、收藏、评论某个主体时,就会触发当前主体的发起者。
库表设计如下:
id
targettype:主体类型,比如文章、用户、评论等等
targetid:主体id
messagetype:消息类型,是点赞、收藏还是其他动作 messageid:这个是触发消息的内容,比如若是评论,那么该id就是评论id,除去评论回复,这个id值应该是没啥大用的。
seuserid:发送者
reuserid:接收者
createtime:创建时间
readtime:阅读时间
isread:是否阅读
对应的数据库模型如下:
phpconst 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>
到这一步,其他用户评论我们的文章或评论,就可以收到通知消息,当然,在通知消息中查看详情,就是上节所提到的:对于评论回复,直接在当前页面展开详情弹框:

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