做Discuz开发的朋友,多多少少都经历过这样的需求:论坛里用户想要“关注某人”,关注之后能在个人中心看到自己关注了谁、谁又关注了自己,甚至希望能收到被关注对象的动态提醒。这个功能在主流社交平台里是标配,但在Discuz这种传统论坛系统里,官方并没有提供现成的方案。我早年接过好几个类似的需求,一开始也觉得无从下手,后来把整个链路梳理清楚之后,发现这事其实没有想象中那么复杂。
这篇文章我想完整拆一遍“Discuz如何开发关注功能”的实操过程,从需求分析、数据表设计,到后台接口封装、前台模板改造,再到通知联动和性能优化,最后把踩过的坑一并拿出来说。无论你是打算用插件方式实现,还是想直接改模板,这篇都能给你一个可以落地参考的完整路径。
1. 需求拆解与整体设计思路
1.1 关注功能到底要解决什么问题
很多人在动手之前容易把“关注功能”想得太简单,以为就是一个按钮、一张表。但真正落地的时候,你会发现它牵连着好几个模块:
- 用户之间要建立“关注”与“被关注”的双向关系,并且要能查询列表;
- 个人主页要展示粉丝数和关注数,最好还能展示关注列表、粉丝列表;
- 帖子楼层里要有“关注”按钮,方便在阅读时直接操作;
- 被关注后,对方最好能收到系统通知;
- 如果还想做得完整一点,可以在“动态”或“广播”里推送给关注者。
也就是说,关注功能的本质不是“存两条记录”,而是一套围绕用户关系链的小型社交系统。一套合理的结构设计,比堆代码重要得多。
1.2 两种实现方案的取舍
我见过不少开发者用“用户表加字段”的方式做关注功能:在用户表里加一个字段,专门存关注者的uid列表,用逗号分隔。这种方案在数据量小的时候看着很直接,但隐患非常大。
我以某个论坛的实测数据为例:当用户量到几千、关注关系到了几万条时,这种逗号分隔字符串的查询基本就废了。你想查“谁关注了某用户”,得先把字符串取出来,再在PHP里循环处理;想查“某用户关注了谁”,还得再处理一次。如果用户量继续涨到十万级,这种写法的性能会变得很难看。
另一种方案是建独立的关系表,一条记录表示“uid 关注了 targetuid”。这是社交系统里最标准的做法。表结构简单,查询直接走索引,扩展也方便——以后想做拉黑、好友、特别关注,在这张表上加类型字段就能搞定。
我的建议是:直接采用独立关系表方案。前期多写几十行建表代码,后面能省下一大堆重构的时间。
1.3 数据流向与核心模块划分
拿我后来在某个模拟项目X里的实际设计来说,整个关注功能分成四个模块:
- 数据层:关注关系表的增删改查,以及关注数、粉丝数的统计查询;
- 业务层:关注/取消关注的逻辑处理,包括防重复、事务与计数更新;
- 展示层:个人中心页、帖子楼层的按钮与列表模板;
- 通知层:关注后向被关注者发送通知。
处理请求的流程也不复杂:用户在前台点击关注按钮,通过AJAX提交到处理脚本,脚本校验登录态和formhash,通过后写数据库,再返回最新的关注状态和数量。前端根据返回值做按钮文案切换,个人主页的列表和计数则直接通过查询数据库展示。
2. 数据表设计与数据库操作封装
2.1 关注关系表结构设计
数据表是整个功能的地基。我一般会按下面的结构来建:
CREATE TABLE pre_user_follow ( id int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增ID', uid int(10) unsigned NOT NULL DEFAULT '0' COMMENT '关注者UID', targetuid int(10) unsigned NOT NULL DEFAULT '0' COMMENT '被关注者UID', status tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:1关注 0取消', dateline int(10) unsigned NOT NULL DEFAULT '0' COMMENT '关注时间戳', PRIMARY KEY (id), UNIQUE KEY uk_follow (uid, targetuid), KEY idx_targetuid (targetuid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户关注关系表';字段没必要设太多,核心就三个关系字段:uid(谁关注)、targetuid(关注谁)、status(是否有效)。dateline用于排序和后续的时间线功能。
这里有一个关键点:唯一键要建在 (uid, targetuid) 上,这样同一个用户对同一个目标只能存在一条记录。重复关注在数据库层面就杜绝了,业务代码只需要处理“更新状态”而不是“判断后插入”,省掉很多麻烦。
2.2 索引与唯一键的设计原因
很多新人建表时容易忽略索引,等数据量上来之后才发现查询慢得离谱。我解释一下索引设计的逻辑:
uk_follow (uid, targetuid)这个联合唯一索引,解决两件事:防止重复关注,同时加速“我关注了谁”的查询。比如要查 uid=5 关注的列表,直接WHERE uid=5 ORDER BY dateline DESC,这个索引天然能覆盖。idx_targetuid单独建索引,是为了加速“谁关注了我”的查询。MySQL在联合索引里把uid放最前面,单独用targetuid查时走不了联合索引,所以必须给targetuid单独建索引。
这个设计不是拍脑袋想出来的,是根据实际查询需求倒推的。你在设计阶段能预期到哪些查询最频繁,就为那些查询建索引,这才是合理的索引思路。
2.3 基于Discuz数据库类封装读写方法
Discuz目前主流版本(X3.4、X3.5)都内置了数据库操作类,我们不需要自己封装PDO,直接用系统提供的功能,兼容性也更好。关注关系表建好之后,我把常用的方法单独做成了一个类,放在source/class/下,或者直接在插件机制里建一个类文件。
核心方法大概有这些:
<?php class follow_service { /** * 添加关注 */ public static function follow($uid, $targetuid) { if ($uid < 1 || $targetuid < 1 || $uid == $targetuid) { return false; } $data = array( 'uid' => $uid, 'targetuid' => $targetuid, 'status' => 1, 'dateline' => TIMESTAMP, ); $exists = DB::result_first("SELECT id FROM " . DB::table('user_follow') . " WHERE uid='$uid' AND targetuid='$targetuid'"); if ($exists) { DB::update('user_follow', array('status' => 1, 'dateline' => TIMESTAMP), array('id' => $exists)); } else { DB::insert('user_follow', $data); } return true; } /** * 取消关注(逻辑删除) */ public static function unfollow($uid, $targetuid) { return DB::update('user_follow', array('status' => 0), array('uid' => $uid, 'targetuid' => $targetuid)); } /** * 是否已关注 */ public static function is_followed($uid, $targetuid) { if ($uid < 1 || $targetuid < 1) { return false; } $status = DB::result_first("SELECT status FROM " . DB::table('user_follow') . " WHERE uid='$uid' AND targetuid='$targetuid'"); return $status == 1; } /** * 获取某个用户的关注列表 */ public static function following_list($uid, $limit = 20, $offset = 0) { $list = DB::fetch_all("SELECT targetuid FROM " . DB::table('user_follow') . " WHERE uid='$uid' AND status=1 ORDER BY dateline DESC LIMIT $offset,$limit"); $uids = array_map(function($item) { return $item['targetuid']; }, $list); return $uids; } /** * 获取某个用户的粉丝列表 */ public static function follower_list($targetuid, $limit = 20, $offset = 0) { $list = DB::fetch_all("SELECT uid FROM " . DB::table('user_follow') . " WHERE targetuid='$targetuid' AND status=1 ORDER BY dateline DESC LIMIT $offset,$limit"); $uids = array_map(function($item) { return $item['uid']; }, $list); return $uids; } /** * 关注数 & 粉丝数 */ public static function count_follow($uid) { $follow = DB::result_first("SELECT COUNT(*) FROM " . DB::table('user_follow') . " WHERE uid='$uid' AND status=1"); $fans = DB::result_first("SELECT COUNT(*) FROM " . DB::table('user_follow') . " WHERE targetuid='$uid' AND status=1"); return array('follow' => $follow, 'fans' => $fans); } }代码逻辑本身不复杂,但有几个细节值得说明:
- 使用
DB::table('user_follow')而不是直接写pre_user_follow,是为了兼容不同站点的表前缀; - “取消关注”做的是逻辑删除(status=0),而不是物理删除,这样用户反复关注、取关时,记录不会无限增长,也能保留历史关系;
- 关注和取关时,后面还要同步更新用户profile里的缓存计数,这个放到性能优化部分讲。
3. 前端展示与交互实现
3.1 个人主页的关注列表与粉丝列表
Discuz的个人空间地址一般是home.php?mod=space&uid=xxx,默认展示用户资料。我们要在个人主页上增加“关注”“粉丝”两个Tab页,通常需要改模板。
我见过两种做法:一种是直接修改模板文件(比如template/default/home/space_profile.htm和新增space_follow.htm);另一种是写一个独立插件页面,通过自定义导航跳过去。第一种做法直观、改动小,适合站点本身需求明确的情况;第二种更规范,升级论坛版本时不容易被覆盖。我个人的建议是:如果只是为自己站点开发,直接改模板问题不大,但改动前一定要备份;如果想做成插件分发,建议走插件的页面机制,避免影响别人的模板。
展示层最核心的是要处理“当前访客与页面主人之间的关系”:
- 如果访客还没登录,按钮显示“登录后才能关注”,点击后跳转到登录页;
- 如果访客访问的是自己的主页,不显示关注按钮;
- 如果访客已经关注了该用户,按钮显示“已关注”,否则显示“+ 关注”。
3.2 帖子楼层里的关注按钮
帖子楼层的关注按钮是出现频率最高的入口。Discuz帖子查看页的楼层模板在template/default/forum/viewthread_node.htm,作者信息区通常包含头像、用户名、等级等信息。
我一般会在用户名的下方加一个关注按钮,核心模板代码大致像这样:
<!-- 判断不是本人且已登录 --> <!--{if $_G['uid'] && $_G['uid'] != $post['authorid']}--> <a href="javascript:;" id="follow_btn_{$post[authorid]}" class="follow-btn">// 在获取到帖子列表 $postlist 之后 $authorids = array_unique(array_column($postlist, 'authorid')); $followed_uids = array(); if ($_G['uid']) { $query = DB::query("SELECT targetuid FROM " . DB::table('user_follow') . " WHERE uid='$_G[uid]' AND targetuid IN (" . implode(',', $authorids) . ") AND status=1"); while ($row = DB::fetch($query)) { $followed_uids[$row['targetuid']] = 1; } }这样每条楼层数据都带了$isfollowed状态,页面渲染时不会因为关注判断产生额外的SQL请求。
3.3 异步请求与状态切换
关注按钮的交互动作应该走AJAX,避免整个页面刷新。Discuz本身自带jquery,我们直接写一个简单的异步请求处理脚本。
前端JavaScript(放在模板底部或专门JS文件里):
$('.follow-btn').on('click', function() { var btn = $(this); var targetuid = btn.data('uid'); var status = btn.data('status'); if (typeof targetuid === 'undefined') return; $.ajax({ url: 'plugin.php?id=follow:ajax', type: 'POST', data: { targetuid: targetuid, op: status == 1 ? 'unfollow' : 'follow', formhash: '<!--{$_G['formhash']}-->' }, dataType: 'json', success: function(res) { if (res.code == 0) { if (res.is_followed == 1) { btn.text('已关注').data('status', 1); } else { btn.text('+ 关注').data('status', 0); } // 更新用户卡片里的关注数 $('#follow_count').text(res.follow_count); } else { alert(res.msg); } } }); });服务端接收逻辑:
// ajax.php 或插件入口 $targetuid = intval($_POST['targetuid']); $op = $_POST['op'] == 'follow' ? 'follow' : 'unfollow'; // 登录校验和formhash校验(安全关键) if (!$_G['uid']) { exit(json_encode(array('code' => 1, 'msg' => '未登录'))); } if (!$_G['uid'] || !submitcheck('follow', 'POST')) { exit(json_encode(array('code' => 1, 'msg' => '校验失败'))); } if ($op == 'follow') { follow_service::follow($_G['uid'], $targetuid); } else { follow_service::unfollow($_G['uid'], $targetuid); } // 同时更新计数缓存 $counts = follow_service::count_follow($targetuid); update_profile_follow_count($targetuid, $counts); exit(json_encode(array( 'code' => 0, 'is_followed' => $op == 'follow' ? 1 : 0, 'follow_count' => $counts['fans'], )));3.4 模板定制的避坑点
这里有一个容易踩的坑:Discuz的模板语法是基于PHP的,但不建议在模板里写太复杂的逻辑。模板只负责输出变量,复杂的判断和查数据尽量在PHP端完成,把结果组装成数组传给模板。
另外,Discuz模板有缓存机制。你改了模板之后可能发现页面没变化,需要在后台“工具 → 更新缓存”里刷新模板缓存,甚至可以删掉data/template目录下的编译文件来强制更新。
4. 通知联动与动态扩展
4.1 利用Discuz通知机制实现“被关注提醒”
一个完整的关注功能,少了通知体验会差一大截。用户A关注用户B,B如果能收到一条系统通知,会明显感觉到有人在“关注”自己,有助于拉回站内活跃度。
Discuz的通知系统自带notification机制,我通常是在关注成功后追加一段通知逻辑:
// 关注成功后发送通知 if ($op == 'follow' && $targetuid != $_G['uid']) { C::t('common_notification')->insert(array( 'uid' => $targetuid, 'type' => 'follow', 'new' => 1, 'authorid' => $_G['uid'], 'author' => $_G['member']['username'], 'note' => 'user_follow', 'dateline' => TIMESTAMP, )); }这里我把通知类型定义成了follow,并在语言包里加了一条对应的通知文案,比如“{author} 关注了你”。通知类型可以自行扩展,Discuz的type字段是用来区分的,不同站点自定义类型完全没问题。
还有一点要注意:频繁被同一人反复关注、取关,会产生大量通知。我建议做频率限制——比如同一个用户对同一个目标,在10分钟内只发送一条通知,避免刷屏。
4.2 关注对象的动态推送思路
如果想让关注功能更有社交属性,可以把“动态”串进来。Discuz本身有“广播”或者“动态”机制,但并不是所有版本都默认开放,很多站长干脆自己写轻量动态。
我给出一个适合大多数场景的思路:当用户发了新主题时,往一张“动态表”里写一条记录,记录uid和action(比如“发布了新主题”)以及关联的tid链接。关注了他的用户,在“我的关注动态”页面里,只需要查这张动态表即可:
SELECT * FROM pre_user_feed WHERE uid IN (SELECT targetuid FROM pre_user_follow WHERE uid='$_G[uid]' AND status=1) ORDER BY dateline DESC LIMIT 20;这个方案的好处是:查询条件简单直接,动态表可以定期清理旧数据,不至于无限膨胀。如果你想做一个类似时间线的页面,这个结构已经够用了。
动态表的建表参考:
CREATE TABLE pre_user_feed ( feedid int(10) unsigned NOT NULL AUTO_INCREMENT, uid int(10) unsigned NOT NULL DEFAULT '0' COMMENT '动态作者UID', action varchar(20) NOT NULL DEFAULT '' COMMENT '动作类型', tid int(10) unsigned NOT NULL DEFAULT '0' COMMENT '关联主题ID', dateline int(10) unsigned NOT NULL DEFAULT '0', PRIMARY KEY (feedid), KEY idx_uid (uid), KEY idx_dateline (dateline) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;发帖的时候往这张表插入一条数据,关注动态页查询时就按上面那条语句,逻辑非常清晰。
5. 性能与容错细节
5.1 关注数缓存:别每次都COUNT
很多人开发到这一步就收工了,但实际线上跑一段就会发现,个人空间被频繁访问时,每次打开都要COUNT(*)统计粉丝数和关注数,数据稍微大一点,数据库压力明显上来了。
优化方式有两种。第一种是把关注数、粉丝数冗余到用户资料表common_member_profile或common_member_status里,关注/取关操作时同步更新这两个数值。展示个人主页时根本不需要count,直接读字段。第二种是使用Discuz自带的内存缓存。把关注数缓存在$_G['setting']或者用memory驱动存储,设置一定过期时间。
我推荐第一种方式为主、第二种为辅。计数不必做到绝对实时,短暂延迟完全可以接受。只要在关注/取关时能同步更新,数据就能保持相对准确。如果真的出现少量计数不一致,后台可以写一个定时任务定期从user_follow表重新统计,把正确的值回写进去。
5.2 大V场景下的软删除与重复处理
某个用户粉丝特别多,比如上万人同时关注他,这时会出现几个问题。第一个是并发写入,几千人同时插入同一targetuid的user_follow记录,idx_targetuid这个索引会面临写入压力,但这通常不至于出现重大问题;真正需要留神的是防重复插入。
我在2.2里建的uk_follow (uid, targetuid)唯一键,在这个场景下就发挥作用了。即使两个请求同时到达,数据库也只会允许一条成功,第二条走UPDATE操作,不会出现重复记录。我建议你在业务层不要过度依赖“先查再插”,让数据库的唯一键做最终防线。
还有一个细节:如果要做“互相关注”状态,可以在查询时多返回两个字段,一是“我是否关注了他”,二是“他是否关注了我”。这个可以通过两次IN查询批量拿到,不需要每条单独算。在个人主页里展示“回关”按钮,体验会更好。
5.3 关注接口的安全与频率控制
接口安全容易被忽略。Discuz本身有防CSRF的机制,一定要在AJAX里带上formhash并校验,否则容易被恶意刷接口、批量关注或取关。
更稳妥的做法是:服务端对同一个IP、同一个uid加一个每分钟请求频率限制,比如每分钟最多操作30次。实现也不复杂,用$_G['uid']+time()在内存缓存里做一个计数器即可。也可以直接用Discuz的floodctrl机制,利用系统自带的防灌水逻辑做拦截,改动最小。
6. 常见问题与排查实录
6.1 建表语句执行报错的几个原因
很多人在执行建表SQL时碰到报错,最常见的是没选对数据库。Discuz的配置文件在config/config_global.php,里面dbname就是数据库名。执行SQL前先USE 数据库名,再执行建表语句。
另外,utf8mb4和旧版本的MySQL兼容性也要注意。如果你的MySQL版本在5.5以下,utf8mb4索引长度可能超限,会提示“Specified key was too long”。我实际处理过这种情况——最终方案有两种:一是升级MySQL版本到5.7或以上,二是把索引字段的长度改小,或者在my.cnf里调整大索引前缀限制。对关注表这种结构,前者更根本。
6.2 关注按钮状态不同步
按钮显示“已关注”,但点进去发现根本没有关注记录,这类问题大多是页面缓存造成的。我排查过好几次,最后发现模板层面做了整页缓存。Discuz的模板只缓存HTML结构,但帖子页有时候会被一些SEO类插件做静态化缓存,导致按钮状态完全停留在页面生成的那一刻。
解决办法很简单:关注操作完成后,通过AJAX刷新的是当前按钮的状态,但如果是页面级缓存,需要即时更新帖子页的按钮状态就比较麻烦。我建议对关注按钮区域做动态加载,也就是在页面加载完成后,通过一个接口批量获取当前页面所有作者的关注状态,再填充到按钮上,可以绕开静态缓存的干扰。
6.3 SQL查询越来越慢
当关注关系表数据量超过百万条之后,简单的COUNT和分页可能变慢。我在某次线上排查时发现,慢的根源在于ORDER BY dateline DESC配合LIMIT的分页查询。因为复合索引是(uid, targetuid),排序字段dateline并不在索引覆盖范围内。
优化策略是:如果在业务上不要求严格按关注时间排序,可以直接按主键id倒序排列,因为自增id的顺序和时间顺序基本一致:
SELECT targetuid FROM pre_user_follow WHERE uid='$uid' AND status=1 ORDER BY id DESC LIMIT 20;这样查询可以完全走主键,速度会快很多。如果一定要按dateline排序,就需要把索引调整成(uid, status, dateline)的复合索引,让排序字段也进入索引,这样才能避免文件排序。
6.4 模板修改后页面无变化
这个问题我在第3节提过一次,但实际遇到的人特别多,这里再单独列一下。修改Discuz模板后,如果页面没变化,先不要怀疑自己的代码,大多数情况下是模板缓存没刷新。后台“工具 → 更新缓存”里可以选择“模板缓存”,一键清除;也可以直接在服务器上删除data/template下面生成的.php缓存文件。
另外,很多第三方模板套件本身也带自己的编译缓存,需要同时清理。如果模板是别人开发的,最好先确认模板作者的更新方式,避免清不掉还找不着原因。
6.5 关注数与实际列表不一致
关注数量显示和列表数量对不上,基本是计数缓存和数据表写入不同步引起的。常见场景是:关注成功,但更新计数的代码在异常情况下没有执行;或者用户在短时间内多次关注取关,逻辑删除的记录没有被正确计数。
我的排查思路是:先直接查user_follow表,验证真实关系数据是否正确。如果真实数据正确,那问题就出在计数同步;如果真实数据也不对,那问题就出在写入逻辑。定位到具体环节之后,再跑一次全量重计:
-- 重计某用户的关注数与粉丝数并回写(示意语句) UPDATE pre_user_follow uf SET uf.status = CASE WHEN EXISTS ( SELECT 1 FROM pre_common_member m WHERE m.uid = uf.targetuid ) THEN uf.status ELSE 0 END;实际上我更推荐写一个后台脚本,用PHP遍历所有用户,重新查一次count_follow,回写到资料表里,这样更可控也更好调试。
写在最后的一些个人经验
我大概前前后后给不同类型的站点做过三四版关注功能,有纯模板改造的,也有插件化实现的。最大的体会是:Discuz虽然老,但底层扩展性并不差,关键是找对它的套路。建关系表、写业务类、改模板、挂通知,这套流程熟悉以后,实际开发一个完整的关注功能用不了多少代码。
另一个值得说的是,论坛类站点的“关注”和社交平台还不完全一样。很多论坛用户习惯了主题式浏览,关注入口一定要放在显眼的位置——比如帖子楼层作者名片区域,以及个人空间页的显眼位置。入口藏得太深,用户很难形成使用习惯。做完之后最好自己走一遍完整流程,甚至找人帮忙测一下,看看会不会出现按钮点了没反应、页面刷新后状态丢失这些问题。
最后再分享一个小技巧:上线前可以把关注表的数据量人为造到几十万条,然后跑一下列表页和计数接口,提前看看在哪一步会出现性能瓶颈。这些小成本预演,往往比上线之后再救火管用得多。