搜索全站
插件 17 分钟阅读

WordPress 站内信与用户通知:前端消息中心的实现思路与插件选型

邮件通知打开率越来越低,站内信成为留存关键。本文对比 Front End PM 插件与自建消息中心的方案,讲清数据模型、未读数更新、轮询推送与消息/邮件配合策略,附完整代码实现。

U
UFO_PF 2019-04-21 · 阅读 17 分钟
Wo 插件
阅读提示
本文约 6,581 字,预计阅读 17 分钟。文中命令与配置均经过实测,可直接参考执行。

引言:发了两千封邮件,只有 47 个人点开链接

邮件通知的问题不是送达率,而是:邮件是别人的地盘。它会被 Gmail 归入推广标签、被企业的垃圾邮件网关拦掉、被用户习惯性忽略。一个寄托多个回头访问的社群/会员站点真正需要的,是”回到站内行为的钩子”——用户进来,看到右上角有个红点”您有 3 条新消息”,这比任何一封邮件都更可能让他停留。

WordPress 本身没有站内信功能(除非装 BuddyPress)。可选路径有三条:

  1. 专用插件:Front End PM(LABIB AHMED 开发,free,安装量约 3 万)、Better Messages(free/pro,支持实时。专业从事 private message)等。
  2. 会员/社区插件自带:BuddyPress 的消息模块、PeepSo 等。
  3. 自建 CPT:把它当作一个私有的、带权限的自定义文章类型。

本文先讲清数据模型(这才是选型的关键),再给出可运行的自建方案和未读数、通知推送的实现。

原理分析:私信是”会话”还是”文章”?

两种数据模型,决定了后面所有的复杂度:

模型 结构 优点 缺点
会话(thread)模型 thread 表 + message 表,消息挂 thread 列表渲染快、未读数好算、删除只影响自己 实现复杂,尤其双向删除
单条(CPT)模型 每条消息一个 post,靠 meta 关联 复用 WP_Query/权限/编辑界面 列表 N+1 查询,未读数难维护

Front End PM 用的是第一种的变体:它把每条消息存为一个 CPT fep_message,并用 meta 记录参与者(_fep_participants)、发送者(_fep_author)、父消息(_fep_parent)形成 thread。这是个务实的折中——既能用 WordPress 的权限/编辑器,又能通过 meta 查询算出 thread。

三个必须提前想清楚的技术点:

第一,权限模型比消息本身更麻烦。 一条消息涉及”谁能看、谁能删、谁能回复”。删除通常是”对自己隐藏”(mark as deleted per user),而不是真删除——否则一方删除会让另一方失去会话。这是最常被人忽略、也最容易做出 bug 的地方。

第二,未读数必须原子维护。 每次打开消息页面都 COUNT(*) 一遍不仅慢,还会让并发下出现读脏。正确做法是用 user meta 存一个计数,收到消息时自增、标记已读时清零(并在真正渲染时校正)。

第三,通知 ≠ 消息。 “有人给你发私信”是消息;”你的文章被点赞了”是通知。两者可以合并到一个中心(Tab 区分),但数据模型要分开——通知可以是单向、可丢弃的,消息需要持久化且可回复。

保姆级实操

步骤 1:先评估插件方案

Front End PM (FEP) 的安装与最小配置:

  • 后台安装后进入 「FEP → Settings → General,设置每页消息数(Message per page 建议 15)与最大参与者数。
  • 「FEP → Settings → Security: 打开 “Blocked users” 与 “Limit attachments”。
  • 把 [front-end-pm] 短代码放到一个”我的消息”页面上(默认会自动创建 /fep-messages/)。
  • Notification(邮件通知):建议开启,但限制单用户每日发送上限(避免邮件轰炸)。

安装后必须检查的一件事:FEP 依赖 wp_footer 输出 Ajax JS,如果你的主题没有调用 wp_footer(),消息提交会失效。

Better Messages 适合需要”Web 实时(非轮询)”的场景,它依赖 WebSocket 前端组件(共享 hosting 通常不支持),付费版才可放心用于生产。

步骤 2:自建方案的数据模型(CPT + meta)

function mf_register_message_cpt() {
    register_post_type( 'mf_message', array(
        'label'        => '站内消息',
        'public'       => false,            // 不在前台公开
        'show_ui'      => true,             // 后台可见便于排查
        'show_in_rest' => false,
        'supports'     => array( 'title', 'editor', 'author' ),
        'capability_type' => 'post',
        'map_meta_cap'    => true,
    ) );
}
add_action( 'init', 'mf_register_message_cpt' );

步骤 3:发送消息 + 维护未读数

/**
 * 发送一条站内消息
 */
function mf_send_message( $to_user_id, $subject, $body, $from_user_id = 0 ) {
    $post_id = wp_insert_post( array(
        'post_type'    => 'mf_message',
        'post_title'   => sanitize_text_field( $subject ),
        'post_content' => wp_kses_post( $body ),
        'post_status'  => 'publish',
        'post_author'  => $from_user_id ? $from_user_id : 0,
    ), true );

    if ( is_wp_error( $post_id ) ) {
        return $post_id;
    }

    update_post_meta( $post_id, '_mf_recipient', absint( $to_user_id ) );
    update_post_meta( $post_id, '_mf_read', 0 );

    // 未读数自增 + 触发清用户级缓存
    $unread = (int) get_user_meta( $to_user_id, '_mf_unread', true );
    update_user_meta( $to_user_id, '_mf_unread', $unread + 1 );

    do_action( 'mf_message_sent', $post_id, $to_user_id, $from_user_id );
    return $post_id;
}

// 示例:文章被点赞时发一条通知(把"通知"也走同一套中心)
add_action( 'mf_message_sent', function( $post_id, $to_user_id ) {
    // 这里可以做:合并同类型通知、节流、触发 WebSocket /轮训推送
    if ( function_exists( 'mf_bump_polling_flag' ) ) {
        mf_bump_polling_flag( $to_user_id );
    }
}, 10, 2 );

步骤 4:已读标记

add_action( 'template_redirect', function() {
    if ( ! is_page( 'messages' ) || ! is_user_logged_in() ) {
        return;
    }
    global $wpdb;
    // 一次性把当前用户所有未读标为已读(避免 N+1)
    $uid = get_current_user_id();
    $ids = get_posts( array(
        'post_type'      => 'mf_message',
        'posts_per_page' => -1,
        'fields'         => 'ids',
        'meta_query'     => array(
            array( 'key' => '_mf_recipient', 'value' => $uid, 'compare' => '=', 'type' => 'NUMERIC' ),
            array( 'key' => '_mf_read', 'value' => 0, 'compare' => '=', 'type' => 'NUMERIC' ),
        ),
    ) );
    foreach ( $ids as $id ) {
        update_post_meta( $id, '_mf_read', 1 );
    }
    update_user_meta( $uid, '_mf_unread', 0 );
} );

步骤 5:未读红点(必须避开页面缓存)

这是线上最容易翻车的地方。别把它写进 HTML,用 Ajax 拉:

add_action( 'wp_ajax_mf_unread_count', function() {
    if ( ! is_user_logged_in() ) {
        wp_send_json_success( array( 'count' => 0 ) );
    }
    wp_send_json_success( array(
        'count' => (int) get_user_meta( get_current_user_id(), '_mf_unread', true ),
    ) );
} );
(function () {
    var badge = document.querySelector('.mf-msg-badge');
    if (!badge) { return; }
    function poll() {
        fetch('/wp-admin/admin-ajax.php?action=mf_unread_count', { credentials: 'same-origin' })
            .then(function (r) { return r.json(); })
            .then(function (d) {
                var n = d.data && d.data.count ? d.data.count : 0;
                badge.textContent = n;
                badge.hidden = n === 0;
            }).catch(function () {});
    }
    poll();
    setInterval(poll, 60000); // 60 秒轮询,别设太小
})();

同样的 CDN Bypass 规则必须给 admin-ajax.php 配上(见上一篇点赞文章的说明)。

步骤 6:邮件与站内信的配合

站内信负责”回到站内”,邮件负责”唤醒沉睡用户”。做法是两者互补,并对邮件做节流:

// 每天最多给同一用户发 3 封通知邮件(避免被判为垃圾邮件源)
add_filter( 'mf_should_email_notify', function( $should, $user_id ) {
    $sent = (int) get_transient( 'mf_mail_cnt_' . $user_id );
    if ( $sent >= 3 ) {
        return false;
    }
    set_transient( 'mf_mail_cnt_' . $user_id, $sent + 1, DAY_IN_SECONDS );
    return $should;
}, 10, 2 );

常见踩坑

  1. 未读红点永远显示缓存里的旧值:页面缓存 + admin-ajax 都被 CDN 缓存。务必 Bypass。
  2. Front End PM 消息框不出现:绝大部分原因是主题缺 wp_footer() / wp_head();少数是 shortcode 所在页面被页面构建器的容器接管。
  3. 案例:某会员站每个月给全部会员群发一次”本月精选”站内信,用的是 plugin 的 bulk 发送 + 邮件通知。上线第二个月,主机商发出警告:该站点 24 小时内外发邮件 1.8 万封,被判定为异常,SMTP 通道限流,导致找回密码邮件全堵。教训:批量站内信默认不应该同时群发邮件;需要触达用户时,邮件必须分批(每小时 N 封)+ 用户可关闭通知开关,且要与事务性邮件走不同通道/子域。
  4. 一边删除让对方也丢失会话:正确做法是 per-user 的 _mf_deleted_by_users meta 记录已删除者 ID,而不是 wp_delete_post()。
  5. N+1 查询拖垮个人中心:列表页每条消息都 get_user_meta 一次。用 wp_cache 或者一次性 get_userdata() 预取。
  6. meta_query 性能塌方:mf_message CPT 到了十万条以后 meta_query on _mf_recipient 会很慢。长远方案是独立的关系表 + 索引,或者在 _mf_recipient 上加索引。
  7. REST API 暴露了私密消息:自建 CPT 时如果开了 show_in_rest => true,未授权也可能通过 /wp-json/wp/v2/mf_message 拉到数据。必须 show_in_rest => false 并做 pre_get_posts 权限限制。
  8. 评论通知与私信通知来源不同导致重复打扰:把”被回复评论””被点赞””系统公告””私信”统一到一个通知中心但要合并同源事件(比如同一篇文章 10 个赞合并为一条)。

延伸阅读:WordPress 会员插件选型、WordPress 邮件发送 SMTP 配置

总结

一句话口径:消息模型用 thread(哪怕底层是 CPT + parent meta),未读数用 user meta 原子维护,前端红点用 Ajax 获取(永远不要写进被缓存的 HTML)。 选型上:需要一个可用的成品 → Front End PM 足够应付大部分社区与会员站;需要实时 Chat 体验 → Better Messages Pro 或独立 WebSocket 服务;需要被第三方系统集成(App/小程序) → 自建 CPT + REST,把前台交给客户端。无论哪种,请务必做通知节流:在站内的红点可以频繁更新,但任何走到邮箱的打扰都必须限量。

U
UFO_PF作者

专注 WordPress 生态的实战作者,内容以「可复制、可验证」为第一原则。

留下你的想法

邮箱地址不会被公开。带 * 的为必填项。