Xiuno BBS 重构记录贴(十九)消息通知系统
贰先生 10小时前

# Xiuno BBS 消息通知系统

## 1. 概述

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

| 维度 | notice(消息通知) | notify(互动通知) |
|------|-------------------|-------------------|
| 数据表 | `bbs_notice` | `bbs_notify` |
| 用途 | 管理员/系统主动发送的消息 | 用户互动自动触发(点赞、收藏、关注、回复等) |
| 类型字段 | 整数:1=公告、2=评论、3=系统、99=其他 | 字符串:`like`、`favorite`、`follow`、`reply`、`thread`、`forum_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 | 类型:`like`、`favorite`、`follow`、`reply`、`thread`、`forum_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` 表同时维护 `isread` 和 `is_read` 两个字段。所有写入操作同时更新两个字段,读取时优先使用 `is_read`。`notice_format()` 函数统一输出 `is_read` 键。

### 2.5 自动初始化

表和字段的首次创建由 `route/my.php` 自动完成(无需手动建表):
- 检测 `bbs_notice` 表是否存在,不存在则创建
- 检测 `bbs_user` 表的 `notices` 和 `unread_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.notices` 和 `user.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_read` 和 `isread` 键

**注意**:该函数依赖 `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` 字段(`notify` 或 `notice`),用于区分来源和路由标记已读请求。

#### 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)

#### 页面结构

```
┌─────────────────────────────────────────┐
│  消息                    [全部标为已读]    │
├─────────────────────────────────────────┤
│  全部 | 互动 | 公告 | 系统 | 其他         │  ← 标签页
├─────────────────────────────────────────┤
│  ┌───────────────────────────────────┐  │
│  │ 					

					
															
								
最新回复 (3)
全部楼主
  • qiye111
    8小时前 2
    0
    沙发我没有,板凳我没有,板也没有,只好站在后面排队支持! 
  • Tillreetree 版主
    7小时前 3
    0
    请务必重新编辑本贴,用markdown转HTML工具
  • Airhelym
    7小时前 4
    0
    怎么布局乱了?
返回