本文约 6,581 字,预计阅读 17 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:发了两千封邮件,只有 47 个人点开链接
邮件通知的问题不是送达率,而是:邮件是别人的地盘。它会被 Gmail 归入推广标签、被企业的垃圾邮件网关拦掉、被用户习惯性忽略。一个寄托多个回头访问的社群/会员站点真正需要的,是”回到站内行为的钩子”——用户进来,看到右上角有个红点”您有 3 条新消息”,这比任何一封邮件都更可能让他停留。
WordPress 本身没有站内信功能(除非装 BuddyPress)。可选路径有三条:
- 专用插件:Front End PM(LABIB AHMED 开发,free,安装量约 3 万)、Better Messages(free/pro,支持实时。专业从事 private message)等。
- 会员/社区插件自带:BuddyPress 的消息模块、PeepSo 等。
- 自建 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 );
常见踩坑
- 未读红点永远显示缓存里的旧值:页面缓存 + admin-ajax 都被 CDN 缓存。务必 Bypass。
- Front End PM 消息框不出现:绝大部分原因是主题缺
wp_footer()/wp_head();少数是 shortcode 所在页面被页面构建器的容器接管。 - 案例:某会员站每个月给全部会员群发一次”本月精选”站内信,用的是 plugin 的 bulk 发送 + 邮件通知。上线第二个月,主机商发出警告:该站点 24 小时内外发邮件 1.8 万封,被判定为异常,SMTP 通道限流,导致找回密码邮件全堵。教训:批量站内信默认不应该同时群发邮件;需要触达用户时,邮件必须分批(每小时 N 封)+ 用户可关闭通知开关,且要与事务性邮件走不同通道/子域。
- 一边删除让对方也丢失会话:正确做法是 per-user 的
_mf_deleted_by_usersmeta 记录已删除者 ID,而不是wp_delete_post()。 - N+1 查询拖垮个人中心:列表页每条消息都
get_user_meta一次。用wp_cache或者一次性get_userdata()预取。 meta_query性能塌方:mf_messageCPT 到了十万条以后meta_queryon_mf_recipient会很慢。长远方案是独立的关系表 + 索引,或者在_mf_recipient上加索引。- REST API 暴露了私密消息:自建 CPT 时如果开了
show_in_rest => true,未授权也可能通过/wp-json/wp/v2/mf_message拉到数据。必须show_in_rest => false并做pre_get_posts权限限制。 - 评论通知与私信通知来源不同导致重复打扰:把”被回复评论””被点赞””系统公告””私信”统一到一个通知中心但要合并同源事件(比如同一篇文章 10 个赞合并为一条)。
延伸阅读:WordPress 会员插件选型、WordPress 邮件发送 SMTP 配置
总结
一句话口径:消息模型用 thread(哪怕底层是 CPT + parent meta),未读数用 user meta 原子维护,前端红点用 Ajax 获取(永远不要写进被缓存的 HTML)。 选型上:需要一个可用的成品 → Front End PM 足够应付大部分社区与会员站;需要实时 Chat 体验 → Better Messages Pro 或独立 WebSocket 服务;需要被第三方系统集成(App/小程序) → 自建 CPT + REST,把前台交给客户端。无论哪种,请务必做通知节流:在站内的红点可以频繁更新,但任何走到邮箱的打扰都必须限量。
