[开发]通知插件内置化迁移方案

👑Lv.11 元老 🌞 骨干会员
2026-10-25 01:05:45

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:暂存20260913/通知插件内置化迁移方案.md。

通知插件内置化迁移方案

目标:通知从插件变为核心内置功能;mod_system 和 content_review 中所有独立通知逻辑全部剥离,统一调用 \app\Helpers\Notification::add();不保留任何插件内部的通知分支。


一、现状全景

1.1 通知插件(notifications)当前结构

组件文件职责
核心类Plugin.phpadd/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-156buildSchema() 中建 mod_notifications 表删除
Plugin.php L86-89uninstall() 中 DROP TABLE mod_notifications删除
Plugin.php L468-494notify() 方法:双写 mod_notifications + \Plugin\Notifications\Plugin::add()改写:去掉自有表写入,改为只调核心 Helper
FrontController.php L176-198notifications() 方法:查 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-60activate() 中建 review_notifications 表删除
Plugin.php L82uninstall() 中 DROP TABLE review_notifications删除
Plugin.php L101-111getUnreadCount() 查 review_notifications删除
Plugin.php L189-200approve() 中写 review_notifications 表删除(改为调核心 Helper)
Plugin.php L202-212approve() 中广播 review_result_after 钩子删除(改为调核心 Helper)
Plugin.php L254-264reject() 中写 review_notifications 表删除(改为调核心 Helper)
Plugin.php L266-276reject() 中广播 review_result_after 钩子删除(改为调核心 Helper)
Plugin.php L351-360getNotifications() 查 review_notifications删除
Plugin.php L365-375deleteUserNotifications() 删 review_notifications删除
Plugin.php L380-386markRead() 更新 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-44notifications() 方法:查/删/标读 review_notifications删除
hook/route_register.php L17-18注册 /review-notifications 路由删除
views/notifications.php审核通知列表视图删除

1.4 核心代码中的 6 个钩子触发点(保持不动)

文件行号钩子名场景
app/Controllers/ThreadController.phpL155post_create_after回帖后
app/Controllers/PostController.phpL159thread_create_after发帖后
app/Controllers/ApiController.phpL90vote_after点赞后
app/Controllers/AuthController.phpL227auth_register_after注册后
app/Helpers/Points.phpL97points_award_after积分增加
app/Helpers/Points.phpL140points_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.phpapp/Views/notifications/index.php
plugins/notifications/assets/style.cssassets/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.phpL155 后Notification::notifyReplyAggregated($threadId, $userId, $postId) + Notification::notifyQuote(...)
app/Controllers/ApiController.phpL90 后Notification::notifyVote($type, $id, $vote, $voterId, $ownerId)
app/Controllers/AuthController.phpL227 后Notification::notifyWelcome($newUserId)
app/Helpers/Points.phpL97 后Notification::notifyPointsGain(...)
app/Helpers/Points.phpL140 后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_notificationsPlugin.php
删除 FrontController::notifications() 整个方法(L176-198)FrontController.php
删除 hook/route_register.php L16 /mod-system/notifications 路由hook/route_register.php
删除 views/notifications.phpviews/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_notificationsPlugin.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.phpviews/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-46INSERT review_notifications\app\Helpers\Notification::add($userId, 'review_pending', $notifyTitle, ...)
hook/post_create_after.php L35-40INSERT review_notifications\app\Helpers\Notification::add($userId, 'review_pending', $notifyTitle, ...)
hook/avatar_upload_after.php L37-41INSERT 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.phpensureBusiness() 追加 notifications 表 DDL
index.php追加 /notifications 路由
app/Views/layouts/main.php引入通知铃铛组件
lang/zh.php合并通知语言键
lang/en.php合并通知语言键
lang/zh_tw.php合并通知语言键
app/Controllers/ThreadController.phpL155 后追加 notifyReplyAggregated() + notifyQuote()(含读取 quoted_uids)
app/Controllers/ApiController.phpL90 后追加 Notification::notifyVote()
app/Controllers/AuthController.phpL227 后追加 Notification::notifyWelcome()
app/Helpers/Points.phpL90-92 追加 level_up 通知;L97/L140 后追加积分通知
app/Views/thread/show.phpL51/L208 添加 data-quote-uid 属性
assets/js/editor.jsquotePost() 追加 quoted_uids 隐藏字段逻辑
plugins/mod_system/Plugin.phpnotify() 改为核心 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.phpINSERT review_notifications → Notification::add()
plugins/content_review/hook/post_create_after.phpINSERT review_notifications → Notification::add()
plugins/content_review/hook/avatar_upload_after.phpINSERT 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(迁移后)变化说明
replyreply不变
votevote不变
pointspoints不变
welcomewelcome不变
review_pendingreview_pending不变
review_approvedreview_approved不变
review_rejectedreview_rejected不变
mod_reportedblacklist统一为「小黑屋通知」类
mod_report_newmod_report统一为「举报相关」类
mod_auto_auditmod_report统一为「举报相关」类
mod_report_resultmod_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_postreplyprimary
votestarwarning
mentionat(或 comment)primary
followuser-plusprimary
welcomeuserprimary
points / points_transfer / red_packet_receivedmoneysuccess
level_uptrophy(或 star)success
order_paid / order_shippedcart(或 box)primary
review_pendingclock(或 info)muted
review_approvedchecksuccess
review_rejected / blacklistcloseerror
friend_requestuser-plusprimary
system / announcementinfoprimary
mod_reportwarningwarning

4.6 预留类型的激活方式

预留类型已定义常量但不写触发代码。未来对应插件开发时,只需在触发点调用:

\app\Helpers\Notification::add($userId, Notification::TYPE_MENTION, ...);

无需改数据库、无需改 Helper、无需改视图——类型常量和图标映射已就位。


五、三项核心逻辑追加

5.1 引用回复通知(notifyQuote)

现状

引用功能已有前端实现:

  • app/Views/thread/show.php L51/L208:帖子卡片上有 data-quote-user 和 data-quote-content 属性
  • assets/js/editor.js L2027-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() 方法:

  1. 接收用户勾选的通知 ID 数组
  2. 只删 WHERE id IN (...) AND user_id = ?(仅删自己的通知)
  3. 原逻辑中有跨库同步删 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)
Helperapp/Helpers/Vote.phptoggle() 接受 -1/0/1;getCounts() 返回 likes + dislikes;getCountsMulti() 同理
APIapp/Controllers/ApiController.php L25-121接受 vote=-1;htmx 返回赞+踩两个按钮 HTML
视图app/Views/thread/show.php L176-178主帖赞+踩两个按钮
视图app/Views/thread/show.php L284-286回复赞+踩两个按钮
CSSassets/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(&#xf087; 竖拇指)/ dislike(&#xf088; 倒拇指)
语言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 插件独立系统,不受影响无
通知系统已天然兼容(只通知赞)无


七、风险与注意事项

  1. mod_system 通知页面消失:原 /mod-system/notifications 页面被删除,用户需通过统一的 /notifications 页面查看所有通知(含 mod_* 类型)。mod_system 的 notify_enabled 设置开关保留。
  1. content_review 审核通知页面消失:原 /review-notifications 页面被删除,审核通知(pending/approved/rejected)统一在 /notifications 页面展示。
  1. content_review 的 review_notifications 表被彻底删除:不再有独立的审核通知表。待审/通过/驳回通知全部写入核心 notifications 表。如果需要保留审核通知的独立查看能力,可在核心 /notifications 页面增加按 type 筛选功能。
  1. 数据库连接:原通知用独立 SQLite 库,迁入 business.sqlite 后与核心业务共享写锁。通知写入量小(单条 INSERT),风险可控。
  1. plugins_cache.json:迁移后需更新,移除 notifications 的 hooks 声明。
  1. 回滚方案:数据迁移脚本将旧库备份为 .sqlite.migrated;兼容层保留原插件入口。mod_system/content_review 的通知代码删除后无法简单回退,建议迁移前备份这两个插件的目录。
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 1 次 | 回复 0 次

全部回复 (0)

暂无回复

请 登录