本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:暂存20260913/通知插件内置化迁移方案.md。
通知插件内置化迁移方案
目标:通知从插件变为核心内置功能;mod_system 和 content_review 中所有独立通知逻辑全部剥离,统一调用
\app\Helpers\Notification::add();不保留任何插件内部的通知分支。
一、现状全景
1.1 通知插件(notifications)当前结构
| 组件 | 文件 | 职责 |
|---|---|---|
| 核心类 | Plugin.php | add/getList/getTotal/getUnreadCount/markAllRead/deleteByIds |
| 控制器 | NotificationController.php | /notifications 前台页面 |
| 数据库 | data/notifications.sqlite | 独立 SQLite 库 |
| 路由 | hook/route_register.php | 注册 /notifications 路由 |
| 导航 | hook/nav_pc_user_links.php + hook/nav_user_menu_items.php | 铃铛图标+未读徽标 |
| 业务钩子 | 6 个 hook 文件 | 监听事件写通知 |
| 视图/样式/语言 | views/ + assets/ + lang/ | 前端展示 |
1.2 mod_system 插件的通知逻辑(需全部剥离)
| 位置 | 通知相关代码 | 说明 |
|---|---|---|
Plugin.php L146-156 | buildSchema() 中建 mod_notifications 表 | 删除 |
Plugin.php L86-89 | uninstall() 中 DROP TABLE mod_notifications | 删除 |
Plugin.php L468-494 | notify() 方法:双写 mod_notifications + \Plugin\Notifications\Plugin::add() | 改写:去掉自有表写入,改为只调核心 Helper |
FrontController.php L176-198 | notifications() 方法:查 mod_notifications 表 + 标记已读 + 渲染视图 | 删除 |
hook/route_register.php L16 | 注册 /mod-system/notifications 路由 | 删除 |
views/notifications.php | 通知列表视图 | 删除 |
AdminController.php L226 | 调 Plugin::notify() 通知举报人 | 保留调用,notify() 内部改 |
FrontController.php L131 | 调 Plugin::notify() 通知被举报人 | 保留调用,notify() 内部改 |
FrontController.php L146 | 调 Plugin::notify() 自动预警通知管理员 | 保留调用,notify() 内部改 |
FrontController.php L153 | 调 Plugin::notify() 新举报通知管理员 | 保留调用,notify() 内部改 |
1.3 content_review 插件的通知逻辑(需全部剥离)
| 位置 | 通知相关代码 | 说明 |
|---|---|---|
Plugin.php L54-60 | activate() 中建 review_notifications 表 | 删除 |
Plugin.php L82 | uninstall() 中 DROP TABLE review_notifications | 删除 |
Plugin.php L101-111 | getUnreadCount() 查 review_notifications | 删除 |
Plugin.php L189-200 | approve() 中写 review_notifications 表 | 删除(改为调核心 Helper) |
Plugin.php L202-212 | approve() 中广播 review_result_after 钩子 | 删除(改为调核心 Helper) |
Plugin.php L254-264 | reject() 中写 review_notifications 表 | 删除(改为调核心 Helper) |
Plugin.php L266-276 | reject() 中广播 review_result_after 钩子 | 删除(改为调核心 Helper) |
Plugin.php L351-360 | getNotifications() 查 review_notifications | 删除 |
Plugin.php L365-375 | deleteUserNotifications() 删 review_notifications | 删除 |
Plugin.php L380-386 | markRead() 更新 review_notifications | 删除 |
hook/init_after.php L25-34 | 懒建 review_notifications 表 | 删除 |
hook/post_create_after.php L35-40 | 回帖待审时写 review_notifications | 删除(改为调核心 Helper) |
hook/thread_create_after.php L39-46 | 发帖待审时写 review_notifications | 删除(改为调核心 Helper) |
hook/avatar_upload_after.php L37-41 | 头像待审时写 review_notifications | 删除(改为调核心 Helper) |
FrontController.php L20-44 | notifications() 方法:查/删/标读 review_notifications | 删除 |
hook/route_register.php L17-18 | 注册 /review-notifications 路由 | 删除 |
views/notifications.php | 审核通知列表视图 | 删除 |
1.4 核心代码中的 6 个钩子触发点(保持不动)
| 文件 | 行号 | 钩子名 | 场景 |
|---|---|---|---|
app/Controllers/ThreadController.php | L155 | post_create_after | 回帖后 |
app/Controllers/PostController.php | L159 | thread_create_after | 发帖后 |
app/Controllers/ApiController.php | L90 | vote_after | 点赞后 |
app/Controllers/AuthController.php | L227 | auth_register_after | 注册后 |
app/Helpers/Points.php | L97 | points_award_after | 积分增加 |
app/Helpers/Points.php | L140 | points_deduct_after | 积分扣除 |
这些钩子一个都不改,其他插件(ai_assistant、medal、task_center、invite)仍通过它们做自己的事。
二、迁移步骤
第 1 步:新建核心 Helper — app/Helpers/Notification.php
数据库:notifications 表从独立库迁入核心 data/meta/business.sqlite,在 Schema::ensureBusiness() 中追加建表 DDL。
方法清单(从原插件 Plugin.php 平移 + 新增便捷方法):
| 方法 | 说明 |
|---|---|
add(int $userId, string $type, string $title, ...) | 写入一条通知(核心方法) |
getList(int $userId, int $page, int $perPage) | 分页查询 |
getTotal(int $userId) | 通知总数 |
getUnreadCount(int $userId) | 未读数量 |
markAllRead(int $userId) | 全部标记已读 |
deleteByIds(int $userId, array $ids) | 批量删除(用户掌控,不做自动清理) |
notifyReplyAggregated(int $threadId, int $replierId, int $postId) | 新增:回复通知(即时合并版,同主题未读回复合并为一条) |
notifyQuote(array $quotedUids, int $replierId, int $threadId, int $postId) | 新增:引用回复通知(向被引用人发送) |
notifyVote(string $type, int $id, int $vote, int $voterId, int $ownerId) | 新增:点赞通知 |
notifyWelcome(int $userId) | 新增:注册欢迎 |
notifyPointsGain(int $userId, int $points, string $reason, int $relatedId, string $relatedType) | 新增:积分增加通知 |
notifyPointsDeduct(int $userId, int $points, string $reason, int $relatedId, string $relatedType) | 新增:积分扣除通知(含「内容审核暂扣」过滤) |
notifyReview(int $userId, int $reviewId, string $result, string $title, string $link, string $targetType) | 新增:审核结果通知(供 content_review 调用) |
deleteByIds() 改动:原逻辑会跨库删 review_notifications,现在 review_notifications 表已不存在,去掉跨库清理逻辑。
第 2 步:新建核心 Controller — app/Controllers/NotificationController.php
从 plugins/notifications/NotificationController.php 平移,改命名空间,引用改为 app\Helpers\Notification。
第 3 步:路由迁入核心 index.php
use app\Controllers\NotificationController;
$router->get('/notifications', [NotificationController::class, 'index']);
$router->post('/notifications', [NotificationController::class, 'index']);第 4 步:视图/样式/语言包迁入核心
| 原位置 | 新位置 |
|---|---|
plugins/notifications/views/index.php | app/Views/notifications/index.php |
plugins/notifications/assets/style.css | assets/css/notifications.css |
plugins/notifications/lang/zh.php | 合并入 lang/zh.php |
plugins/notifications/lang/en.php | 合并入 lang/en.php |
plugins/notifications/lang/zh_tw.php | 合并入 lang/zh_tw.php |
第 5 步:导航入口内置化
创建 app/Views/_components/notification_bell.php,在 layouts/main.php 的 PC 顶栏(L242)和移动端菜单(L345)处 $this->include() 引入。
第 6 步:核心代码追加直接通知调用
在原有 Plugin::hook() 调用的紧后方追加 Notification:: 便捷方法调用:
| 文件 | 追加位置 | 调用 |
|---|---|---|
app/Controllers/ThreadController.php | L155 后 | Notification::notifyReplyAggregated($threadId, $userId, $postId) + Notification::notifyQuote(...) |
app/Controllers/ApiController.php | L90 后 | Notification::notifyVote($type, $id, $vote, $voterId, $ownerId) |
app/Controllers/AuthController.php | L227 后 | Notification::notifyWelcome($newUserId) |
app/Helpers/Points.php | L97 后 | Notification::notifyPointsGain(...) |
app/Helpers/Points.php | L140 后 | Notification::notifyPointsDeduct(...) |
第 7 步:改造 mod_system — 剥离全部通知逻辑
7.1 改写 Plugin::notify() 方法
原逻辑(双写):
public static function notify(int $toUid, string $type, string $title, string $content, string $url = ''): void
{
if (Settings::get(self::PREFIX . 'notify_enabled', '1') !== '1') return;
// 写 mod_notifications 自有表
self::db()->prepare("INSERT INTO mod_notifications ...")->execute([...]);
// 写统一 notifications 表
\Plugin\Notifications\Plugin::add(...);
}改为(只调核心 Helper):
public static function notify(int $toUid, string $type, string $title, string $content, string $url = ''): void
{
if (Settings::get(self::PREFIX . 'notify_enabled', '1') !== '1') return;
\app\Helpers\Notification::add(
$toUid, 'mod_' . $type, $title, $url, '', $content
);
}7.2 删除 mod_notifications 表相关代码
| 操作 | 位置 |
|---|---|
删除 buildSchema() 中 L146-156 建 mod_notifications 表的代码 | Plugin.php |
删除 uninstall() 中 L89 DROP TABLE mod_notifications | Plugin.php |
删除 FrontController::notifications() 整个方法(L176-198) | FrontController.php |
删除 hook/route_register.php L16 /mod-system/notifications 路由 | hook/route_register.php |
删除 views/notifications.php | views/notifications.php |
7.3 调用方不变
AdminController.php 和 FrontController.php 中 5 处 Plugin::notify() 调用保持原样,内部已改为核心 Helper。
第 8 步:改造 content_review — 剥离全部通知逻辑
8.1 删除 review_notifications 表相关代码
| 操作 | 位置 |
|---|---|
删除 activate() 中 L54-60 建 review_notifications 表 | Plugin.php |
删除 uninstall() 中 L82 DROP TABLE review_notifications | Plugin.php |
删除 getUnreadCount() 方法(L101-111) | Plugin.php |
删除 getNotifications() 方法(L351-360) | Plugin.php |
删除 deleteUserNotifications() 方法(L365-375) | Plugin.php |
删除 markRead() 方法(L380-386) | Plugin.php |
删除 hook/init_after.php 中 L25-34 懒建 review_notifications 表 | hook/init_after.php |
删除 FrontController::notifications() 方法(L20-44) | FrontController.php |
删除 hook/route_register.php L17-18 /review-notifications 路由 | hook/route_register.php |
删除 views/notifications.php | views/notifications.php |
8.2 改写 approve() 方法的通知部分
原逻辑(L189-212,双写 review_notifications + 广播钩子):
$pdb->prepare("INSERT INTO review_notifications ...")->execute([...]);
\app\Helpers\Plugin::hook('review_result_after', [...]);改为(只调核心 Helper):
\app\Helpers\Notification::notifyReview(
$userId, $id, 'approved', $notifyTitle, $link, $targetType
);8.3 改写 reject() 方法的通知部分
原逻辑(L254-276,双写 review_notifications + 广播钩子):
$pdb->prepare("INSERT INTO review_notifications ...")->execute([...]);
\app\Helpers\Plugin::hook('review_result_after', [...]);改为(只调核心 Helper):
\app\Helpers\Notification::notifyReview(
$userId, $id, 'rejected', $title, $link, $targetType
);8.4 改写 3 个 hook 文件中的待审通知
原逻辑:直接 INSERT review_notifications 表写待审通知。
改为:调 \app\Helpers\Notification::add()。
| 文件 | 原逻辑 | 改为 |
|---|---|---|
hook/thread_create_after.php L39-46 | INSERT review_notifications | \app\Helpers\Notification::add($userId, 'review_pending', $notifyTitle, ...) |
hook/post_create_after.php L35-40 | INSERT review_notifications | \app\Helpers\Notification::add($userId, 'review_pending', $notifyTitle, ...) |
hook/avatar_upload_after.php L37-41 | INSERT review_notifications | \app\Helpers\Notification::add($userId, 'review_pending', $notifyTitle, ...) |
8.5 删除 review_result_after 钩子广播
approve() 和 reject() 中的 \app\Helpers\Plugin::hook('review_result_after', [...]) 调用全部删除,因为:
- 该钩子无其他插件监听
- 通知已通过
Notification::add()直接写入
三、完整文件变更清单
新建文件
| 文件 | 说明 |
|---|---|
app/Helpers/Notification.php | 核心通知 Helper(含 25 种类型常量 + notifyReplyAggregated/notifyQuote/notifyVote/notifyWelcome/notifyPointsGain/notifyPointsDeduct/notifyReview/deleteByIds) |
app/Controllers/NotificationController.php | 前台通知控制器 |
app/Views/notifications/index.php | 通知列表视图 |
app/Views/_components/notification_bell.php | 导航铃铛组件 |
assets/css/notifications.css | 通知样式 |
cli/migrate_notifications_builtin.php | 数据迁移脚本 |
修改文件
| 文件 | 改动 |
|---|---|
app/SplitDB/Schema.php | ensureBusiness() 追加 notifications 表 DDL |
index.php | 追加 /notifications 路由 |
app/Views/layouts/main.php | 引入通知铃铛组件 |
lang/zh.php | 合并通知语言键 |
lang/en.php | 合并通知语言键 |
lang/zh_tw.php | 合并通知语言键 |
app/Controllers/ThreadController.php | L155 后追加 notifyReplyAggregated() + notifyQuote()(含读取 quoted_uids) |
app/Controllers/ApiController.php | L90 后追加 Notification::notifyVote() |
app/Controllers/AuthController.php | L227 后追加 Notification::notifyWelcome() |
app/Helpers/Points.php | L90-92 追加 level_up 通知;L97/L140 后追加积分通知 |
app/Views/thread/show.php | L51/L208 添加 data-quote-uid 属性 |
assets/js/editor.js | quotePost() 追加 quoted_uids 隐藏字段逻辑 |
plugins/mod_system/Plugin.php | notify() 改为核心 Helper;删除 mod_notifications 建表/删表 |
plugins/mod_system/FrontController.php | 删除 notifications() 方法 |
plugins/mod_system/hook/route_register.php | 删除 /mod-system/notifications 路由 |
plugins/content_review/Plugin.php | 删除 6 个通知方法 + approve/reject 改写 |
plugins/content_review/hook/init_after.php | 删除 review_notifications 建表 |
plugins/content_review/hook/thread_create_after.php | INSERT review_notifications → Notification::add() |
plugins/content_review/hook/post_create_after.php | INSERT review_notifications → Notification::add() |
plugins/content_review/hook/avatar_upload_after.php | INSERT review_notifications → Notification::add() |
plugins/content_review/FrontController.php | 删除 notifications() 方法 |
plugins/content_review/hook/route_register.php | 删除 /review-notifications 路由 |
删除文件
| 文件 | 说明 |
|---|---|
plugins/mod_system/views/notifications.php | 通知列表视图(已无用) |
plugins/content_review/views/notifications.php | 审核通知列表视图(已无用) |
plugins/notifications/hook/ | 全部 10 个钩子文件 |
plugins/notifications/NotificationController.php | 已迁入核心 |
plugins/notifications/views/ | 已迁入核心 |
plugins/notifications/lang/ | 已迁入核心 |
plugins/notifications/assets/ | 已迁入核心 |
改造文件
| 文件 | 说明 |
|---|---|
plugins/notifications/Plugin.php | 改为 @deprecated 兼容委托层 |
四、通知类型体系(统一 notifications 表)
4.1 类型总览(25 种)
分四大类:论坛核心、积分/商城、内容互动、系统/站务。迁移时仅实现第 ①②③组(17 种),第 ④组预留类型常量但不写触发代码。
① 论坛核心(7 种)
| type | 说明 | 触发场景 | 现状 |
|---|---|---|---|
reply | 回复通知 | 有人回复了我的帖子 | ✅ 已有,迁移 |
vote | 点赞通知 | 我的帖子/回复被点赞 | ✅ 已有,迁移 |
mention | @ 提及 | 有人在帖子/回复中 @ 了我 | ⬜ 代码中无 @ 解析功能,预留类型,暂不触发 |
reply_quote | 引用回复 | 有人引用了我的回复 | ⬜ 代码中无引用检测,预留类型,暂不触发 |
thread_new_post | 主题新回复 | 我订阅/收藏的主题有新回复 | 🔶 post_favorite 插件已存在但无通知联动,预留类型,暂不触发 |
follow | 关注通知 | 有人关注了我 | ⬜ 无关注功能,预留类型,暂不触发 |
welcome | 注册欢迎 | 用户注册成功 | ✅ 已有,迁移 |
② 积分/商城(4 种)
| type | 说明 | 触发场景 | 现状 |
|---|---|---|---|
points | 积分变动 | 积分增加或扣除 | ✅ 已有,迁移 |
level_up | 等级升级 | 积分达标触发等级提升 | 🔶 Points::award() L90-92 已有升级逻辑但无通知,本次新增触发 |
order_paid | 订单已支付 | 积分商城下单成功 | 🔶 points_mall 插件存在但无通知联动,预留类型,暂不触发 |
order_shipped | 商品发货 | 虚拟/实物商品发货 | ⬜ 无发货流程,预留类型,暂不触发 |
points_transfer | 积分转账 | 有人给我转了积分 | ⬜ 无转账功能,预留类型,暂不触发 |
red_packet_received | 红包到账 | 抢到积分红包 | 🔶 red_packet 插件存在但无通知联动,预留类型,暂不触发 |
③ 内容互动(5 种)
| type | 说明 | 触发场景 | 现状 |
|---|---|---|---|
review_pending | 内容待审核 | 发帖/回帖/头像进入审核队列 | ✅ 已有,迁移 |
review_approved | 审核通过 | 审核员通过了我的内容 | ✅ 已有(原 content_review),迁移 |
review_rejected | 审核驳回 | 审核员驳回了我的内容 | ✅ 已有(原 content_review),迁移 |
blacklist | 小黑屋通知 | 被管理员警告/禁言/封禁 | ✅ 已有(原 mod_system 的 mod_* 类型),迁移 |
friend_request | 好友申请 | 收到好友请求 | ⬜ 无社交功能,预留类型,暂不触发 |
④ 系统/站务(3 种)
| type | 说明 | 触发场景 | 现状 |
|---|---|---|---|
system | 系统通知 | 系统升级、维护公告 | ⬜ 无触发入口,预留类型,后台可手动发 |
announcement | 站内公告 | 管理员发布公告 | 🔶 announcements 插件已存在但不走通知系统,预留类型,暂不触发 |
mod_report | 举报相关 | 举报预警/处理结果 | ✅ 已有(原 mod_system),迁移 |
4.2 原有类型 → 新类型的映射关系
| 原 type(迁移前) | 新 type(迁移后) | 变化说明 |
|---|---|---|
reply | reply | 不变 |
vote | vote | 不变 |
points | points | 不变 |
welcome | welcome | 不变 |
review_pending | review_pending | 不变 |
review_approved | review_approved | 不变 |
review_rejected | review_rejected | 不变 |
mod_reported | blacklist | 统一为「小黑屋通知」类 |
mod_report_new | mod_report | 统一为「举报相关」类 |
mod_auto_audit | mod_report | 统一为「举报相关」类 |
mod_report_result | mod_report | 统一为「举报相关」类 |
| _(无)_ | level_up | 新增:积分升级时触发 |
4.3 新增触发点:level_up 等级升级通知
位置:app/Helpers/Points.php L90-92
现状:
$newLevel = self::calculateLevel($user['points']);
if ($newLevel > (int)($user['level'] ?? 0)) {
$db->query('UPDATE users SET level = :lvl WHERE id = :id', [':lvl' => $newLevel, ':id' => $userId]);
}改为:
$newLevel = self::calculateLevel($user['points']);
if ($newLevel > (int)($user['level'] ?? 0)) {
$db->query('UPDATE users SET level = :lvl WHERE id = :id', [':lvl' => $newLevel, ':id' => $userId]);
// ★ 新增:等级升级通知
$levelTitle = self::getLevelConfig($newLevel)['title'] ?? '';
\app\Helpers\Notification::add(
$userId, 'level_up',
"恭喜升级!你已达到 Lv.{$newLevel}「{$levelTitle}」",
'/profile', '', '', 0, ''
);
}4.4 类型常量定义(在 Notification Helper 中)
// app/Helpers/Notification.php 顶部
class Notification
{
// ===== 通知类型常量 =====
// ① 论坛核心
const TYPE_REPLY = 'reply';
const TYPE_VOTE = 'vote';
const TYPE_MENTION = 'mention';
const TYPE_REPLY_QUOTE = 'reply_quote';
const TYPE_THREAD_NEW_POST = 'thread_new_post';
const TYPE_FOLLOW = 'follow';
const TYPE_WELCOME = 'welcome';
// ② 积分/商城
const TYPE_POINTS = 'points';
const TYPE_LEVEL_UP = 'level_up';
const TYPE_ORDER_PAID = 'order_paid';
const TYPE_ORDER_SHIPPED = 'order_shipped';
const TYPE_POINTS_TRANSFER = 'points_transfer';
const TYPE_RED_PACKET = 'red_packet_received';
// ③ 内容互动
const TYPE_REVIEW_PENDING = 'review_pending';
const TYPE_REVIEW_APPROVED = 'review_approved';
const TYPE_REVIEW_REJECTED = 'review_rejected';
const TYPE_BLACKLIST = 'blacklist';
const TYPE_FRIEND_REQUEST = 'friend_request';
// ④ 系统/站务
const TYPE_SYSTEM = 'system';
const TYPE_ANNOUNCEMENT = 'announcement';
const TYPE_MOD_REPORT = 'mod_report';
}4.5 通知视图图标映射(迁移时同步更新)
| type 匹配 | 图标 | 颜色 |
|---|---|---|
reply / reply_quote / thread_new_post | reply | primary |
vote | star | warning |
mention | at(或 comment) | primary |
follow | user-plus | primary |
welcome | user | primary |
points / points_transfer / red_packet_received | money | success |
level_up | trophy(或 star) | success |
order_paid / order_shipped | cart(或 box) | primary |
review_pending | clock(或 info) | muted |
review_approved | check | success |
review_rejected / blacklist | close | error |
friend_request | user-plus | primary |
system / announcement | info | primary |
mod_report | warning | warning |
4.6 预留类型的激活方式
预留类型已定义常量但不写触发代码。未来对应插件开发时,只需在触发点调用:
\app\Helpers\Notification::add($userId, Notification::TYPE_MENTION, ...);无需改数据库、无需改 Helper、无需改视图——类型常量和图标映射已就位。
五、三项核心逻辑追加
5.1 引用回复通知(notifyQuote)
现状
引用功能已有前端实现:
app/Views/thread/show.phpL51/L208:帖子卡片上有data-quote-user和data-quote-content属性assets/js/editor.jsL2027-2051:quotePost()函数读取属性,生成<blockquote><cite>用户名 发表于:</cite>内容</blockquote>插入编辑器- 缺失:没有
data-quote-uid(被引用人 ID),服务端无法识别引用了谁
改动清单
① 视图层 — 添加 data-quote-uid
app/Views/thread/show.php:
② 编辑器 JS — 引用时记录被引用人 ID
assets/js/editor.js 的 quotePost() 函数中:
window.quotePost = function(el) {
var card = el.closest('[data-quote-user]');
if (!card) return;
var username = card.getAttribute('data-quote-user') || '';
var content = card.getAttribute('data-quote-content') || '';
var quoteUid = card.getAttribute('data-quote-uid') || '0'; // ★ 新增
// ... 原有逻辑 ...
// ★ 新增:将被引用人 ID 追加到隐藏字段(逗号分隔,去重)
if (quoteUid && quoteUid !== '0') {
var hidden = document.getElementById('quoted_uids');
if (!hidden) {
hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'quoted_uids';
hidden.id = 'quoted_uids';
var form = el.closest('form') || document.getElementById('replyForm');
if (form) form.appendChild(hidden);
}
var uids = hidden.value ? hidden.value.split(',') : [];
if (uids.indexOf(quoteUid) === -1) {
uids.push(quoteUid);
hidden.value = uids.join(',');
}
}
};
③ 服务端 — 回帖时检测引用并通知
app/Controllers/ThreadController.php L155 后(回帖成功,事务内):
// ★ 引用回复通知
$quotedUids = trim($_POST['quoted_uids'] ?? '');
if ($quotedUids !== '') {
\app\Helpers\Notification::notifyQuote(
array_unique(array_filter(array_map('intval', explode(',', $quotedUids)), fn($v) => $v > 0)),
(int)$currentUser['id'],
$threadId,
$postId
);
}
④ Helper 方法
app/Helpers/Notification.php:
/**
* 引用回复通知:向被引用人发送通知(排除自己引用自己)
* @param array $quotedUids 被引用人 ID 列表
* @param int $replierId 回复人 ID
* @param int $threadId 主题 ID
* @param int $postId 回复 ID
*/
public static function notifyQuote(array $quotedUids, int $replierId, int $threadId, int $postId): void
{
if (empty($quotedUids)) return;
$replierName = \app\Models\User::batchGetNames([$replierId])[$replierId] ?? '';
$thread = (new \app\Models\Thread())->find($threadId);
$title = mb_substr((string)($thread['title'] ?? ''), 0, 40);
$link = '/thread/' . $threadId . '?pid=' . $postId . '#post-' . $postId;
foreach ($quotedUids as $uid) {
if ($uid === $replierId) continue; // 自己引用自己不通知
self::add(
$uid,
self::TYPE_REPLY_QUOTE,
($replierName !== '' ? $replierName : '有人') . ' 在回复中引用了你「' . $title . '」',
$link,
$replierName,
$title,
$threadId,
'thread'
);
}
}
5.2 回复通知即时合并(notifyReplyAggregated)
设计原则
- 即时:回复发生时立刻合并,不靠定时任务
- 按主题聚合:同一主题下的多条未读回复合并为一条通知
- 标记已读后重置:用户打开通知页(标记已读)后,下一条回复重新开始计数
合并逻辑
当新回帖发生时:
1. 查询该楼主在该主题下是否有未读的 type='reply' 通知
2. 如果有 → UPDATE 那条通知的标题为"您有 N 条新回复",N++
3. 如果没有 → INSERT 新通知"您有 1 条新回复"
4. 用户点开通知页 → markAllRead() → 未读归零
5. 下次再有回复 → 回到步骤 3,重新新建
Helper 方法
app/Helpers/Notification.php:
/**
* 回复通知(即时合并版):
* 同一主题下多条未读回复合并为一条通知,标题实时更新为"N条新回复"。
* 用户标记已读后自动重置,下次回复重新计数。
*
* @param int $threadId 主题 ID
* @param int $replierId 回复人 ID
* @param int $postId 回复 ID
*/
public static function notifyReplyAggregated(int $threadId, int $replierId, int $postId): void
{
$thread = (new \app\Models\Thread())->find($threadId);
if (!$thread) return;
$ownerId = (int)$thread['user_id'];
if ($ownerId <= 0 || $ownerId === $replierId) return; // 自己回自己不通知
$replierName = \app\Models\User::batchGetNames([$replierId])[$replierId] ?? '';
$threadTitle = mb_substr((string)($thread['title'] ?? ''), 0, 40);
$link = '/thread/' . $threadId . '?pid=' . $postId . '#post-' . $postId;
try {
$db = \app\Core\Database::getInstance();
// 查找该主题下该用户的未读 reply 通知
$existing = $db->fetchOne(
"SELECT id, title FROM notifications
WHERE user_id = :uid AND type = :type AND related_id = :rid AND related_type = 'thread' AND is_read = 0
ORDER BY id DESC LIMIT 1",
[':uid' => $ownerId, ':type' => self::TYPE_REPLY, ':rid' => $threadId]
);
if ($existing) {
// 已有未读 → 从标题中提取当前计数并 +1
$count = 1;
if (preg_match('/(\d+)/', (string)($existing['title'] ?? ''), $m)) {
$count = (int)$m[1] + 1;
}
$newTitle = $replierName !== ''
? $replierName . ' 等 ' . $count . ' 人回复了你的帖子「' . $threadTitle . '」'
: '有 ' . $count . ' 条新回复「' . $threadTitle . '」';
$db->query(
"UPDATE notifications SET title = :title, link = :link, created_at = :now WHERE id = :id",
[':title' => mb_substr($newTitle, 0, 500), ':link' => $link, ':now' => date('Y-m-d H:i:s'), ':id' => (int)$existing['id']]
);
} else {
// 无未读 → 新建
$title = $replierName !== ''
? $replierName . ' 回复了你的帖子「' . $threadTitle . '」'
: '有人回复了你的帖子「' . $threadTitle . '」';
self::add(
$ownerId,
self::TYPE_REPLY,
$title,
$link,
$replierName,
$threadTitle,
$threadId,
'thread'
);
}
} catch (\Throwable $e) {
\error_log('Notification::notifyReplyAggregated error: ' . $e->getMessage());
}
}
调用点
app/Controllers/ThreadController.php L155 后:
// ★ 回复通知(即时合并版,替代原 notifyReply)
\app\Helpers\Notification::notifyReplyAggregated($threadId, (int)$currentUser['id'], $postId);
注意:原方案中的 notifyReply() 方法被 notifyReplyAggregated() 替代,不再使用。
5.3 一键全选删除 — 原封不动平移
现状
原 plugins/notifications/Plugin.php 的 deleteByIds() 方法:
- 接收用户勾选的通知 ID 数组
- 只删
WHERE id IN (...) AND user_id = ?(仅删自己的通知) - 原逻辑中有跨库同步删
review_notifications 的代码
改动
迁入核心 app/Helpers/Notification.php 后:
- 保留:
WHERE id IN (...) AND user_id = ? 的安全删除逻辑 - 删除:跨库同步删
review_notifications 的代码(该表已不存在) - 保留:视图中的全选复选框 + 批量删除按钮 + 删除确认弹窗
最终 deleteByIds()
/**
* 批量删除通知(仅限本人,防越权)
* 用户掌握完全控制权:一键全选删除,不做后台自动清理。
*/
public static function deleteByIds(int $userId, array $ids): void
{
if (empty($ids) || $userId <= 0) return;
try {
$db = \app\Core\Database::getInstance();
$ids = array_values(array_filter(array_map('intval', $ids), fn($v) => $v > 0));
if (empty($ids)) return;
$marks = implode(',', array_fill(0, count($ids), '?'));
$params = $ids;
$params[] = $userId;
$db->getConnection()->prepare(
"DELETE FROM notifications WHERE id IN ({$marks}) AND user_id = ?"
)->execute($params);
} catch (\Throwable $e) {
\error_log('Notification::deleteByIds error: ' . $e->getMessage());
}
}
视图(原封不动)
app/Views/notifications/index.php 中的删除逻辑平移自原插件视图,不做任何修改:
- 全选复选框
#checkAll - 批量删除按钮
- Alpine.js 删除确认弹窗
- POST 提交到
/notifications(NotificationController 处理)
设计理念
- 用户掌控:看完即自动标记已读(进入页面时
markAllRead()),想彻底删除随时一键全选 - 不做自动清理:不加 TTL、不加 cron 清理、不加"保留最近 N 条"——用户的删除权是绝对的
- 轻量:无定时任务开销,无额外复杂度
六、附录:赞/踩 → 纯点赞优化
6.1 现状分析
当前投票系统支持 赞(+1)和踩(-1) 两种操作:
层 文件 赞/踩相关代码 数据库 thread_votes / post_votes 表vote 列:1=赞,-1=踩,0 不存(DELETE)Helper app/Helpers/Vote.phptoggle() 接受 -1/0/1;getCounts() 返回 likes + dislikes;getCountsMulti() 同理API app/Controllers/ApiController.php L25-121接受 vote=-1;htmx 返回赞+踩两个按钮 HTML 视图 app/Views/thread/show.php L176-178主帖赞+踩两个按钮 视图 app/Views/thread/show.php L284-286回复赞+踩两个按钮 CSS assets/css/modern.css L2549-2593.vote-btn.vote-up.active(红色)/ .vote-btn.vote-down.active(绿色)/ hover 态CSS 变量 modern.css + 3 个配色变体--mn-comp-vote-up-active / --mn-comp-vote-down-active / hover-bg 变量图标 app/Core/TemplateCompiler.php L500-501like( 竖拇指)/ dislike( 倒拇指)语言 lang/zh.php L115 / en.php L115 / zh_tw.php L115thread.dislike → 踩/Dislike
QA 插件(plugins/qa/)有自己独立的投票系统(qa_votes 表 + /qa/vote 路由),与核心 Vote Helper 无关,不受影响。
6.2 改动清单(6 层)
① 数据库 — 清理历史踩记录
-- 迁移脚本中追加:清理历史踩记录(赞/踩 → 纯点赞)
DELETE FROM thread_votes WHERE vote = -1;
DELETE FROM post_votes WHERE vote = -1;
表结构不变(vote 列保留),只是不再写入 -1。未来如需彻底清理可 VACUUM。
② Vote.php — 移除踩逻辑
toggle():
// 原:接受 -1, 0, 1
if ($vote !== 1 && $vote !== -1 && $vote !== 0) return ['success' => false, 'error' => '无效'];
// 改为:只接受 0, 1(取消赞 = 0,点赞 = 1)
if ($vote !== 1 && $vote !== 0) return ['success' => false, 'error' => '无效'];
getCounts():
// 原:返回 likes + dislikes
$result[$id] = ['likes' => ..., 'dislikes' => ..., 'user_vote' => ...];
// 改为:只返回 likes(dislikes 恒为 0,保持返回结构兼容)
$result[$id] = ['likes' => ..., 'dislikes' => 0, 'user_vote' => ...];
返回结构保持 dislikes 字段(恒为 0),避免下游代码因缺字段报错。后续版本可彻底移除。
getCountsMulti():同理,统计查询去掉 SUM(CASE WHEN vote < 0 ...) 分支,dislikes 恒赋 0。
③ ApiController::vote() — 移除踩按钮
htmx 返回的 HTML 片段(L106-111):
// 原:返回赞+踩两个按钮
$html = '';
// 改为:只返回赞按钮
$html = '';
参数校验(L60):
// 原:接受 -1, 0, 1
if (!is_numeric($voteRaw) || !in_array((int)$voteRaw, [-1, 0, 1], true))
// 改为:只接受 0, 1
if (!is_numeric($voteRaw) || !in_array((int)$voteRaw, [0, 1], true))
④ 视图 — 删除踩按钮
app/Views/thread/show.php:
主帖(L178):删除整行踩按钮 <button class="vote-btn vote-down ...">
回复(L286):删除整行踩按钮 <button class="vote-btn vote-down ...">
数据默认值(L281):dislikes 字段保留(赋 0),保持结构兼容。
⑤ CSS — 清理踩样式
assets/css/modern.css:
删除/注释以下规则:
.vote-btn.vote-down.active { color: var(--mn-comp-vote-down-active); }
.vote-btn.vote-down:hover:not(.active) { background: var(--mn-comp-vote-down-hover-bg); }
CSS 变量(modern.css + 3 个配色变体):
/* 保留但不再使用,可后续清理 */
--mn-comp-vote-down-active: #4caf50; /* → 不再引用 */
--mn-comp-vote-down-hover-bg: #e8f5e9; /* → 不再引用 */
变量暂时保留(不影响功能),避免配色变体文件大面积改动。后续版本可彻底删除。
⑥ 语言键 — 保留不删
thread.dislike 语言键保留(其他插件可能引用),但不再在核心视图中使用。
6.3 通知联动(已天然兼容)
通知系统中的 notifyVote() 方法(原 hook/vote_after.php)已有过滤逻辑:
if ($vote !== 1) return; // 仅赞通知(点踩不打扰)
改为纯点赞后,ApiController 传入的 vote 值只有 1(赞)和 0(取消赞),天然兼容,无需改动。
6.4 影响评估
组件 影响 改动量 Vote.phptoggle() 收窄参数 + getCounts/getCountsMulti 去掉 dislikes 统计中 ApiController.phphtmx HTML 去掉踩按钮 + 参数校验收窄 小 thread/show.php删 2 行踩按钮 小 modern.css删 2 条踩样式规则 小 3 个配色变体 CSS 变量保留不改 无 TemplateCompiler.phpdislike 图标保留不删无 语言包 thread.dislike 保留不删无 QA 插件 独立系统,不受影响 无 通知系统 已天然兼容(只通知赞) 无
七、风险与注意事项
- mod_system 通知页面消失:原
/mod-system/notifications 页面被删除,用户需通过统一的 /notifications 页面查看所有通知(含 mod_* 类型)。mod_system 的 notify_enabled 设置开关保留。
- content_review 审核通知页面消失:原
/review-notifications 页面被删除,审核通知(pending/approved/rejected)统一在 /notifications 页面展示。
- content_review 的
review_notifications 表被彻底删除:不再有独立的审核通知表。待审/通过/驳回通知全部写入核心 notifications 表。如果需要保留审核通知的独立查看能力,可在核心 /notifications 页面增加按 type 筛选功能。
- 数据库连接:原通知用独立 SQLite 库,迁入 business.sqlite 后与核心业务共享写锁。通知写入量小(单条 INSERT),风险可控。
- plugins_cache.json:迁移后需更新,移除 notifications 的 hooks 声明。
- 回滚方案:数据迁移脚本将旧库备份为
.sqlite.migrated;兼容层保留原插件入口。mod_system/content_review 的通知代码删除后无法简单回退,建议迁移前备份这两个插件的目录。