Xiuno BBS 重构记录贴(十九)消息通知系统

Xiuno BBS 消息通知系统

1. 概述

Xiuno BBS 的站内通知由两套独立系统合并构成,在前端统一展示:

维度 notice(消息通知) notify(互动通知)
数据表 bbs_notice bbs_notify
用途 管理员/系统主动发送的消息 用户互动自动触发(点赞、收藏、关注、回复等)
类型字段 整数:1=公告、2=评论、3=系统、99=其他 字符串:likefavoritefollowreplythreadforum_post
管理后台 有(发送、列表、删除)
计数器 user.notices + user.unread_notices(反范式) 动态统计(无冗余字段)
模型文件 model/notice.func.php model/notify.func.php

核心特性

  • 两套系统在顶部导航栏合并显示未读徽章和下拉菜单
  • 通知列表页按类型分标签页,interact 标签页合并展示互动通知
  • 单条/全部标记已读(AJAX,无页面刷新)
  • 查看详情时自动标记已读再跳转
  • 管理后台发送和管理通知
  • 插件可通过模型函数集成

2. 数据库设计

2.1 bbs_notice 表

sql 复制代码
CREATE TABLE IF NOT EXISTS `bbs_notice` (
    `nid`         int(11) unsigned NOT NULL auto_increment,
    `fromuid`     int(11) unsigned NOT NULL default '0',
    `recvuid`     int(11) unsigned NOT NULL default '0',
    `create_date` int(11) unsigned NOT NULL default '0',
    `isread`      tinyint(3) unsigned NOT NULL default '0',
    `is_read`     tinyint(1) unsigned NOT NULL default '0',
    `type`        tinyint(3) unsigned NOT NULL default '0',
    `message`     longtext NOT NULL,
    PRIMARY KEY (`nid`),
    KEY (`fromuid`, `type`),
    KEY (`recvuid`, `type`),
    KEY (`recvuid`, `is_read`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
字段 类型 说明
nid int(11) unsigned AI 主键
fromuid int(11) unsigned 发送者用户ID
recvuid int(11) unsigned 接收者用户ID
create_date int(11) unsigned 创建时间(Unix 时间戳)
isread tinyint(3) unsigned 是否已读(旧字段,兼容保留)
is_read tinyint(1) unsigned 是否已读(新字段,推荐使用)
type tinyint(3) unsigned 类型:0=全部、1=公告、2=评论、3=系统、99=其他
message longtext 消息内容(支持 HTML)

2.2 bbs_notify 表

字段 类型 说明
nid int AI 主键
uid int 接收者用户ID
from_uid int 触发者用户ID
type varchar 类型:likefavoritefollowreplythreadforum_post
tid int 关联帖子ID
pid int 关联回帖ID
content text 内容摘要
create_date int 创建时间(Unix 时间戳)
is_read tinyint 是否已读

2.3 bbs_user 表扩展字段

字段 类型 说明
notices mediumint(8) unsigned 总通知数(反范式计数器,notice 系统专用)
unread_notices mediumint(8) unsigned 未读通知数(反范式计数器,notice 系统专用)

2.4 is_read 与 isread 兼容

bbs_notice 表同时维护 isreadis_read 两个字段。所有写入操作同时更新两个字段,读取时优先使用 is_readnotice_format() 函数统一输出 is_read 键。

2.5 自动初始化

表和字段的首次创建由 route/my.php 自动完成(无需手动建表):

  • 检测 bbs_notice 表是否存在,不存在则创建
  • 检测 bbs_user 表的 noticesunread_notices 字段是否存在,不存在则添加

3. 数据模型 API

3.1 notice 模型(model/notice.func.php)

模型在 model.inc.php 中无条件加载,所有页面均可调用。

基础 CRUD(双下划线前缀)
函数 签名 说明
notice__create `(array $arr): int FALSE`
notice__update (int $nid, array $arr): bool 按 nid 更新
notice__read `(int $nid): array NULL`
notice__delete (int $nid): bool 按 nid 删除
notice__find (array $cond, array $orderby, int $page, int $pagesize): array 底层分页查询
业务逻辑函数
函数 签名 说明
notice_send `(int fromuid, int recvuid, string message, int type=99): int FALSE`
notice_find_by_recvuid (int $recvuid, int $page, int $pagesize, int $type): array 按接收者查询(已格式化)
notice_update (int $nid, array $arr=NULL): bool 标记单条已读,递减 unread_notices
notice_update_by_recvuid (int $recvuid, array $arr=NULL): bool 全部标记已读,置 unread_notices=0
notice_delete (int $nid): bool 删除单条,更新计数器
notice_delete_by_recvuid (int $recvuid): bool 清空用户所有通知,重置计数器
notice_find (array $cond, int $page, int $pagesize): array 分页查询(已格式化)
notice_find_latest_by_recvuid (int $recvuid, int $pagesize=5): array 获取最新 N 条(下拉菜单用)
notice_count (array $cond): int 按条件统计总数
notice_count_unread (int $recvuid): int 统计未读数(优先用 is_read
notice_send() 详解
php 复制代码
$nid = notice_send($fromuid, $recvuid, $message, $type);
  • $fromuid$recvuid 必须大于 0 且不相等,否则返回 FALSE
  • $type 传 0 会自动转为 99
  • 成功后自动递增 user.noticesuser.unread_notices
notice_format() 格式化

为通知记录补充展示信息(原地修改引用参数):

  • create_date_fmt --- 人类可读时间
  • from_username / from_user_avatar_url --- 发送者信息
  • recv_username / recv_user_avatar_url --- 接收者信息
  • name / class / icon --- 来自 global $notice_menu 的类型信息
  • 统一 is_readisread

注意 :该函数依赖 global $notice_menu,在路由层需提前声明。若未定义,函数内有默认值兜底。

3.2 notify 模型(model/notify.func.php)

函数 签名 说明
notify_create (int $uid, int $from_uid, string $type, int $tid=0, int $pid=0, string $content=''): mixed 创建互动通知(自己互动自己不创建)
notify_read (int $nid): array 读取单条(已格式化)
notify_find_by_uid (int $uid, int $page, int $pagesize): array 按接收者查询(已格式化)
notify_count_unread (int $uid): int 统计未读数
notify_mark_read (int $nid): mixed 标记单条已读
notify_mark_all_read (int $uid): bool 标记全部已读(直接 SQL)
notify_delete_by_uid (int $uid): mixed 删除用户所有通知
notify_delete_by_tid (int $tid): mixed 删除帖子关联的所有通知
notify_format (array &$notify): void 格式化:补充用户名、头像、URL、类型标签、消息摘要
notify_format() 类型映射
type 值 type_label 语言键 summary 语言键 消息模板
like notify_type_label_like notify_summary_like {用户} 赞了你的回帖
reply notify_type_label_reply notify_summary_reply {用户} 回复了你的评论
follow notify_type_label_follow notify_summary_follow {用户} 关注了你
favorite notify_type_label_favorite notify_summary_favorite {用户} 收藏了你的帖子
thread notify_type_label_thread notify_summary_thread {用户} 发了新帖: {内容}
forum_post notify_type_label_forum_post notify_summary_forum_post {用户} 版块有新帖: {内容}

4. HTTP 路由

4.1 路由注册

php 复制代码
// index.inc.php(前端)
case 'notice': include _include(APP_PATH.'route/notice.php'); break;
case 'my':    include _include(APP_PATH.'route/my.php'); break;

4.2 notice 路由(route/notice.php)

端点 方法 权限 说明
notice-mark_read POST 登录用户 标记已读(单条/全部)
notice-unread_count GET 登录用户 获取未读数(纯文本)
notice-dropdown GET 登录用户 下拉菜单通知列表(HTML 片段)
notice-create GET/POST 管理员 发送通知
notice-list GET 管理员 通知列表
notice-delete POST 管理员 删除通知
POST notice-mark_read

参数

参数 类型 必填 说明
csrf_token string CSRF 令牌
notice_id int 通知ID(标记单条时必填)
all int 传 1 标记全部已读

响应

json 复制代码
{"code": 0, "message": "操作成功", "unread_count": 3}

4.3 my 路由中的通知相关端点(route/my.php)

端点 方法 说明
my-notice GET 通知列表页(合并 notice + notify)
my-notice-interact GET 互动通知标签页
my-notice-{type} GET 按类型筛选(1/3/99)
my-notice POST 通知操作(readall/readone/delete)
my-notify_unread GET 合并未读徽章 HTML
my-notify_dropdown GET 合并下拉菜单 HTML
my-notify_mark_read POST 同时标记两个系统全部已读
my-notify_read-{nid} POST 标记单条 notify 已读
GET my-notice 通知列表页

URL 模式

  • ?my-notice.htm --- 全部通知
  • ?my-notice-interact.htm --- 互动通知(notify 来源)
  • ?my-notice-{type}.htm --- 按类型筛选(1=公告、3=系统、99=其他)
  • ?my-notice-{type}-{page}.htm --- 分页

类型菜单$notice_menu):

key 名称 说明
0 全部 默认标签页
interact 互动 合并展示 notify 系统的互动通知
1 公告 notice type=1
3 系统 notice type=3
99 其他 notice type=99

数据合并逻辑

  • type=0(全部):同时查询 notify 和 notice,按时间倒序合并
  • type=interact(互动):只查询 notify 系统
  • type=数字(公告/系统/其他):只查询 notice 系统对应类型

每条合并后的通知包含 source 字段(notifynotice),用于区分来源和路由标记已读请求。

POST my-notice 通知操作
act 值 参数 说明
readall uid 标记两个系统全部已读
readone nid 标记单条 notice 已读
delete nid 删除单条 notice
POST my-notify_mark_read

同时标记两个系统全部已读,返回合并后的未读数:

json 复制代码
{"code": 0, "message": "操作成功", "unread_count": 0}
POST my-notify_read-{nid}

标记单条 notify 已读。URL 格式:?my-notify_read-{nid}.htm

json 复制代码
{"code": 0, "message": "操作成功", "unread_count": 2}

4.4 管理后台端点

所有后台端点需要管理员权限($gid == 1)。

GET/POST notice-create --- 发送通知

POST 参数

参数 类型 必填 说明
csrf_token string CSRF 令牌
message string 通知内容
recvuid int 接收者 UID

通知类型固定为 1(公告)。不能给自己发通知。

5. 前端集成

5.1 模板文件

文件 用途
view/htm/my_notice.htm 用户通知列表页(含标签页、标记已读、查看详情)
view/htm/header_nav.inc.htm 顶部导航栏铃铛、下拉菜单、未读徽章

5.2 通知列表页(my_notice.htm)

页面结构
css 复制代码
┌─────────────────────────────────────────┐
│  消息                    [全部标为已读]    │
├─────────────────────────────────────────┤
│  全部 | 互动 | 公告 | 系统 | 其他         │  ← 标签页
├─────────────────────────────────────────┤
│  ┌───────────────────────────────────┐  │
│  │ 🔔 公告              3分钟前      │  │  ← 类型 + 时间
│  │ 系统通知摘要                      │  │  ← 摘要(未读蓝色)
│  │ 通知详情内容...  [标为已读] [详情] │  │  ← 详情 + 操作按钮
│  └───────────────────────────────────┘  │
│  ┌───────────────────────────────────┐  │
│  │ ❤ 赞了你              5分钟前     │  │
│  │ 有人赞了你的回帖                  │  │
│  │ 用户A 赞了你的回帖    [详情]      │  │  ← 已读无"标为已读"按钮
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘
CSS 样式类
选择器 说明
.notice-card 通知卡片(圆角边框)
.notice-card.notice-unread 未读通知:浅主色背景 + 3px 左侧蓝色竖条
.notice-card .notice-summary 摘要标题(未读时蓝色)
.notice-mark-read "标为已读"按钮
.notice-view-detail "查看详情"按钮
图标映射

模板中 _notice_icon($source, $type) 函数根据来源和类型返回 Tabler Icons 图标名:

source type 图标
notify like heart-filled
notify reply message
notify follow user-plus
notify favorite star-filled
notify thread file-text
notify forum_post news
notice 1 speakerphone
notice 2 message
notice 3 file-text
其他 - bell

5.3 JavaScript 交互

通知页面使用原生 fetch() API,不依赖 jQuery 或 XN 对象。所有交互逻辑集中在 my_notice.htm<script> 中。

单条标为已读(.notice-mark-read)

点击后根据 data-source 属性路由到不同端点:

  • source=notify → POST my-notify_read-{nid}.htm
  • source=notice → POST notice-mark_read(带 notice_id 参数)

成功后:

  1. 移除卡片的 notice-unread 样式
  2. 移除"标为已读"按钮
  3. 更新顶部未读徽章
  4. 显示 Toast 提示
javascript 复制代码
// 请求示例
fetch('?my-notify_read-123.htm', {
    method: 'POST',
    headers: {'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest'},
    body: 'csrf_token=xxx'
});
全部标为已读(#notice-mark-all-read)

POST 到 my-notice,参数 act=readall,同时标记两个系统全部已读。

成功后:

  1. 移除所有卡片的 notice-unread 样式
  2. 移除所有"标为已读"按钮
  3. 移除"全部标为已读"按钮本身
  4. 清空顶部未读徽章
查看详情(.notice-view-detail)
  • 已读状态:直接跳转到 data-url
  • 未读状态:先 AJAX 标记已读,完成后跳转

5.4 顶部导航栏集成

header_nav.inc.htm 中的通知铃铛合并了两个系统的未读数:

php 复制代码
$notify_unread = notify_count_unread($uid);
$notice_unread_count = notice_count_unread($uid);
$total_unread = $notify_unread + $notice_unread_count;
下拉菜单

展开时 AJAX 加载 my-notify_dropdown,返回两个系统合并后的最新 5 条通知 HTML。每条通知项包含 data-niddata-source 属性,点击时自动标记已读。

下拉菜单中的"标为全部已读"

POST 到 my-notify_mark_read,同时标记两个系统全部已读。

未读徽章轮询

通过定时请求 my-notify_unread 更新铃铛上的红点徽章。

6. 管理后台

6.1 菜单配置

admin/menu.conf.php 中注册:

php 复制代码
'notice' => array(
    'url'  => url('notice-list'),
    'text' => lang('notice'),
    'icon' => 'icon-bell',
    'tab'  => array(
        'list' => array('url' => url('notice-list'),   'text' => lang('notice_admin_notice_list')),
        'post' => array('url' => url('notice-create'), 'text' => lang('notice_admin_send_notice')),
    )
),

6.2 管理模板

文件 用途
admin/view/htm/admin_notice_create.htm 发送通知表单
admin/view/htm/admin_notice_list.htm 通知列表页
admin/view/htm/admin_notice_list.inc.htm 通知列表项模板

6.3 发送通知流程

  1. 管理员访问 ?notice-create.htm
  2. 填写消息内容和接收者 UID
  3. 提交表单,后端调用 notice_send($uid, $recvuid, $message, 1)
  4. 通知类型固定为 1(公告)
  5. 成功后返回操作成功提示

7. 插件集成指南

7.1 发送通知

model/notice.func.php 已作为核心模型无条件加载,插件可直接调用:

php 复制代码
// 发送通知
$nid = notice_send($from_uid, $to_uid, '您有一条新的消息', 99);
if ($nid !== FALSE) {
    message(0, '通知发送成功');
} else {
    message(-1, '通知发送失败');
}

7.2 发送互动通知

php 复制代码
// 创建互动通知(如点赞)
notify_create($post_uid, $current_uid, 'like', $tid, $pid);

7.3 读取未读数

php 复制代码
// notice 系统未读数
$notice_unread = notice_count_unread($uid);

// notify 系统未读数
$notify_unread = notify_count_unread($uid);

// 合并未读数
$total = $notice_unread + $notify_unread;

7.4 标记已读

php 复制代码
// notice 单条已读
notice_update($nid);

// notice 全部已读
notice_update_by_recvuid($uid);

// notify 单条已读
notify_mark_read($nid);

// notify 全部已读
notify_mark_all_read($uid);

7.5 插件 Hook 点

Hook 位置 文件 说明
my_nav_notice_before.htm my_notice.htm 通知标签页列表前
my_nav_notice_after.htm my_notice.htm 通知标签页列表后
my_common_start.htm my_notice.htm 通知页面顶部
my_common_end.htm my_notice.htm 通知页面底部
my_notice_js.htm my_notice.htm 通知 JS 代码区后

7.6 防御性调用

如需兼容旧版,可用 function_exists 检查:

php 复制代码
if (function_exists('notice_send')) {
    notice_send($from, $to, $msg, 99);
}

新版中通知模型已作为核心功能始终加载,此检查非必须。

8. 数据流图

scss 复制代码
┌──────────────┐  ┌──────────────┐
│  用户互动      │  │  管理员发送    │
│  (点赞/回复等) │  │  (admin POST) │
└──────┬───────┘  └──────┬───────┘
       │                 │
       ▼                 ▼
┌──────────────┐  ┌──────────────┐
│ notify_create│  │ notice_send  │
│              │  │ user__update │
└──────┬───────┘  │ (计数器++)   │
       │          └──────┬───────┘
       ▼                 ▼
┌──────────────┐  ┌──────────────┐
│  bbs_notify  │  │  bbs_notice  │
└──────┬───────┘  └──────┬───────┘
       │                 │
       └────────┬────────┘
                │
    ┌───────────┼───────────┐
    ▼           ▼           ▼
┌────────┐ ┌────────┐ ┌────────────┐
│通知列表 │ │顶部铃铛 │ │下拉菜单     │
│my-notice│ │徽章红点 │ │最新5条合并  │
│         │ │        │ │按时间排序   │
│ 合并展示 │ │合并未读数│ │            │
└────────┘ └────────┘ └────────────┘

9. 语言键

通知相关语言键定义在 lang/zh-cn/bbs_common.php

notice 系统语言键

键名 中文 使用场景
notice 消息 页面标题、菜单
notice_my_error 操作错误 通用错误
notice_my_update_failed 操作失败 标记已读失败
notice_my_update_sucessfully 操作成功 操作成功
notice_my_update_readed 已标为已读 单条标记已读
notice_my_update_allread 已全部标为已读 全部标记已读
notice_my_nomessage 没有相关消息 空状态提示
notice_admin_send_notice 发送通知 后台发送标题
notice_admin_send_notice_message_empty 内容不能为空 验证错误
notice_admin_send_notice_recvuid_empty 接收人不能为空 验证错误
notice_admin_send_notice_user_empty 用户不存在 验证错误
notice_admin_send_notice_self 不能给自己发通知 验证错误

notify 系统语言键

键名 中文 使用场景
notify_type_label_like 点赞类型标签
notify_type_label_reply 回复 回复类型标签
notify_type_label_follow 关注 关注类型标签
notify_type_label_favorite 收藏 收藏类型标签
notify_type_label_thread 新帖 新帖类型标签
notify_type_label_forum_post 版块帖子 版块帖子类型标签
notify_summary_like 有人赞了你 点赞摘要
notify_summary_reply 有人回复了你 回复摘要
notify_summary_follow 有人关注了你 关注摘要
notify_summary_favorite 有人收藏了帖子 收藏摘要
notify_summary_thread 有人发了新帖 新帖摘要
notify_summary_forum_post 版块有新帖 版块帖子摘要
notify_btn_mark_read 标为已读 标记已读按钮
notify_btn_view_detail 查看详情 查看详情按钮
mark_all_read 全部标为已读 全部标记按钮

通用语言键

键名 中文 说明
operate_successfully 操作成功 通用成功提示
parameters_error 参数错误 参数校验失败
insufficient_privilege 权限不足 非管理员访问管理接口
please_login 请先登录 未登录用户访问
not_exists 不存在 记录未找到

10. 文件清单

文件路径 角色
model/notice.func.php notice 数据模型:CRUD + 业务逻辑 + 格式化
model/notify.func.php notify 数据模型:CRUD + 格式化
model.inc.php 模型加载器(无条件加载两个模型)
index.inc.php 前端路由注册(notice case)
admin/index.inc.php 后台路由注册(notice case)
route/notice.php notice 路由:mark_read、unread_count、dropdown、create、list、delete
route/my.php my 路由:通知列表页、合并接口、数据库自动初始化
view/htm/my_notice.htm 前端通知列表模板 + JS
view/htm/header_nav.inc.htm 顶部导航栏铃铛、下拉菜单、徽章
admin/view/htm/admin_notice_create.htm 后台发送通知表单
admin/view/htm/admin_notice_list.htm 后台通知列表
admin/view/htm/admin_notice_list.inc.htm 后台通知列表项模板
admin/menu.conf.php 后台菜单配置
lang/zh-cn/bbs_common.php 中文语言包
lang/en-us/bbs_common.php 英文语言包

11. 常见问题

Q: 通知列表页点击"标为已读"报错?

确保 my_notice.htm 中有 .notice-mark-read 按钮的点击事件处理。该按钮的 AJAX 请求根据 data-source 属性路由到不同端点:

  • source=notifymy-notify_read-{nid}.htm
  • source=noticenotice-mark_read

Q: 顶部铃铛未读数不准确?

未读数由两个系统合并计算。如果 user.unread_notices 反范式计数器与实际不一致,可通过后台执行 SQL 修复:

sql 复制代码
UPDATE bbs_user u SET unread_notices = (
    SELECT COUNT(*) FROM bbs_notice WHERE recvuid = u.uid AND is_read = 0
);

Q: 如何新增通知类型?

  1. route/my.php$notice_menu 数组中添加新类型
  2. notice_format() 的默认 $notice_menu 中同步添加
  3. my_notice.htm_notice_icon() 函数中添加图标映射
  4. 添加对应的语言键

Q: 通知系统的 CSRF 保护?

所有 POST 端点都需要 csrf_token 参数,通过 CsrfService::check() 验证。前端从 <meta name="csrf-token"> 获取令牌。

相关推荐
wulisongsong1 小时前
双重检验锁的单例模式在高并发下的可见性问题
后端
贰先生1 小时前
Xiuno BBS 重构记录贴(十八)插件兼容扫描器
后端
神奇小汤圆1 小时前
阿里面试官:什么才是可工程化落地的RAG项目
后端
ZPYZTech1 小时前
用 Wails + Go + Vue3 开发桌面软件,聊聊踩过的坑
后端
好家伙VCC3 小时前
区块链双向支付通道实战:从签名到结算
java·后端·区块链·asp.net
我登哥MVP3 小时前
Spring Boot 从“会用”到“精通”:参数解析原理
java·spring boot·后端·spring·servlet·maven·intellij-idea
JustHappy4 小时前
古法编程秘籍(五):什么是进程和线程?从软件到 CPU 的一次完整旅程
前端·后端·代码规范
BLSxiaopanlaile4 小时前
关于常见 map的一些比较探究
后端
花大师4 小时前
基于深度学习的鼠标轨迹真实性检测系统
后端