news 2026/11/3 16:25:52

Discuz插件开发实战:从零实现单向关注功能

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Discuz插件开发实战:从零实现单向关注功能

前阵子接了一个社区站点的二次开发,需求说起来很简单:用户之间要有单向的关注关系,关注之后能在个人主页和列表里看到对方的最新主题。这类功能在各类内容平台早就标配了,可真要落到Discuz上反而不太好弄——自带的好友体系是双向验证制,广播里的关注又跟动态、排行榜绑在一起,直接改核心表又怕升级被覆盖。最后我选择按插件的方式从零实现一个关注功能,把数据表、插件骨架、接口、模板和前端交互完整走了一遍。这篇文章把整个开发过程梳理出来,覆盖数据表设计、hooks注册、关注/取关接口、关注与粉丝列表、AJAX按钮交互,以及一组调试经验和踩坑记录。给正在给Discuz做插件开发的同行参考,也让管理员能借此评估功能工作量。

1. 先把关注功能的游戏规则定下来

1.1 Discuz自带的社交关系,和“关注”不是一回事

接到需求后第一件事不是写代码,而是先盘了一下Discuz已有的社交体系,确认到底能不能直接复用。

Discuz自带的好友功能是双向确认逻辑,A加B为好友,B得同意才算数,关系是对称的,数据落在pre_common_friend表里。这个设计适合“熟人社交”,但不适合“内容订阅”。关注一个作者,并不代表要跟他建立好友关系,对方也完全不需要知道你关注了他,这是两种截然不同的产品认知。

Discuz的广播体系里其实有关注概念,但那个关注深度绑定广播feed、动态推送、同城排行这套东西。你只想要一个“关注按钮 + 关注列表”的轻量功能,强行把广播拉进来,等于为了喝杯水要烧一壶开水。另外广播相关表在升级时兼容性风险高,一旦版本迭代改了表结构,插件很容易跟着炸。

所以结论很直接:业务上要的是“单向关注”,就应该做成一个独立的插件,数据表自己建,逻辑自己维护,不碰核心表。这样以后Discuz升级,插件还能保持相对独立,出问题也方便排查。

1.2 功能边界:先做MVP,别急着做大而全

很多人在开发这类功能时容易失控,今天想加关注分组,明天想加私信通知,后天又想搞个性化推荐。我的建议是先把MVP跑通,再根据真实反馈迭代。

这次定的MVP范围就四块:

  • 个人主页显示关注按钮,点击后立即切换成“已关注”,再点可以取消。
  • 有独立的“我关注的人”列表页,分页展示关注对象、头像、关注时间。
  • 有独立的“我的粉丝”列表页,分页展示粉丝。
  • 关注数、粉丝数统计,展示在个人主页或列表页头部。

暂时不做的东西也列清楚:关注对方后不主动通知对方;不搞分组;不做双向好友关系;不做“不给看”的隐私设置。把这些边界钉死,开发过程中才不会反复返工。

2. 数据表设计:一张关系表还是一堆冗余字段

2.1 关系表结构

关注功能最核心的就是一张关系表,记录“谁关注了谁”。表名我用了pre_plugin_follow,字段结构如下:

CREATE TABLE pre_plugin_follow ( id int(10) unsigned NOT NULL AUTO_INCREMENT, uid int(10) unsigned NOT NULL DEFAULT '0', followuid int(10) unsigned NOT NULL DEFAULT '0', dateline int(10) unsigned NOT NULL DEFAULT '0', PRIMARY KEY (id), UNIQUE KEY uid_followuid (uid, followuid), KEY followuid (followuid), KEY dateline (dateline) ) ENGINE=MyISAM DEFAULT CHARSET=utf8;

uid是主动关注者的用户ID,followuid是被关注者的用户ID,dateline是关注时间。这里有个容易搞混的点:查“我关注的人”时走uid,查“我的粉丝”时走followuid,两条查询方向是反的,后面写数据访问层时要特别留意。

唯一索引uid_followuid非常关键,它从数据库层面保证了同一对关注关系不会重复插入。就算前端按钮点了两下、AJAX请求发了两次,数据库也能兜住底。followuid上的普通索引用来加速粉丝列表查询,dateline索引用于按时间排序。

引擎我用了MyISAM,因为Discuz早期版本核心表基本都是这个引擎。如果你的站整体切了InnoDB,跟库保持一致就行,InnoDB在并发写入和事务上反而更稳。

2.2 统计表与计数器的取舍

关注数和粉丝数怎么展示?最粗暴的做法是每次用SELECT COUNT(*) FROM pre_plugin_follow WHERE uid=xxx现算。站点小的时候完全没问题,但一旦单表数据过了几十万行,这种COUNT在列表页反复执行会拖慢响应。

更好的做法是单独维护一张统计表,把计数结果冗余下来:

CREATE TABLE pre_plugin_follow_stat ( uid int(10) unsigned NOT NULL DEFAULT '0', following int(10) unsigned NOT NULL DEFAULT '0', fans int(10) unsigned NOT NULL DEFAULT '0', PRIMARY KEY (uid) ) ENGINE=MyISAM DEFAULT CHARSET=utf8;

following存这个用户关注了多少人,fans存这个用户有多少粉丝。每次关注或取关时,对两个用户的计数做增减。这个方案的优点是查询快,缺点是计数可能不一致,所以后面要给运维留一个重算脚本,我在第6章会专门讲。

2.3 查询规划:一次页面请求,避免N次查询

列表页最容易踩的坑是“循环里查库”。比如渲染“我关注的人”列表时,每行都要查一次被关注者的用户名和头像,页面有20行就多出20次查询,这就是典型的N+1问题。

正确做法是先把列表里的followuid收集成一个数组,然后一次性用C::t('common_member')->fetch_all($uids)批量取回来,再在模板里按uid做映射。头像也同理,拼avatar.php?uid=xxx&size=small就行,不需要额外调用接口。这种批量取数据的习惯,是所有Discuz插件开发里都应该默认遵守的。

3. 插件骨架:先让Discuz认识你的插件

3.1 目录结构与传统插件套路

Discuz插件有一套固定的组织方式,整个关注插件我放在source/plugin/follow/目录下:

source/plugin/follow/ ├── discuz_plugin_follow.xml # 插件定义与安装信息 ├── follow.class.php # 插件类,hooks方法在这里 ├── install.php # 安装脚本,建表 ├── uninstall.php # 卸载脚本,删表 ├── follow.inc.php # 主模块:关注/取关处理 ├── list.inc.php # 我关注的人列表 ├── fans.inc.php # 我的粉丝列表 ├── ajax.inc.php # AJAX接口 ├── table/ │ ├── table_plugin_follow.php # 关系表数据访问类 │ └── table_plugin_follow_stat.php # 统计表数据访问类 ├── template/ │ ├── list.htm │ └── fans.htm └── js/ └── follow.js

discuz_plugin_follow.xml是插件的“身份证”,Discuz安装插件时会解析它,知道插件叫什么、版本多少、装哪些hook、跑哪些安装脚本。手写XML容易出错,我习惯先在后台“设计新插件”生成初始骨架,再手动补充hook定义。

3.2 安装与卸载脚本

安装脚本的核心任务就是建两张表。写到install.php里:

<?php if (!defined('IN_DISCUZ')) { exit('Access Denied'); } $sql = <<<SQL CREATE TABLE IF NOT EXISTS pre_plugin_follow ( id int(10) unsigned NOT NULL AUTO_INCREMENT, uid int(10) unsigned NOT NULL DEFAULT '0', followuid int(10) unsigned NOT NULL DEFAULT '0', dateline int(10) unsigned NOT NULL DEFAULT '0', PRIMARY KEY (id), UNIQUE KEY uid_followuid (uid, followuid), KEY followuid (followuid), KEY dateline (dateline) ) ENGINE=MyISAM DEFAULT CHARSET=utf8; CREATE TABLE IF NOT EXISTS pre_plugin_follow_stat ( uid int(10) unsigned NOT NULL DEFAULT '0', following int(10) unsigned NOT NULL DEFAULT '0', fans int(10) unsigned NOT NULL DEFAULT '0', PRIMARY KEY (uid) ) ENGINE=MyISAM DEFAULT CHARSET=utf8; SQL; runquery($sql); $finish = TRUE;

两个细节提醒一下:第一,runquery按分号拆分语句,多张表可以放在一个字符串里连续执行;第二,如果你的论坛改过数据表前缀,把pre_换成你的实际前缀,别用我这里的写死值直接上生产。

卸载脚本反向操作,把两张表drop掉:

<?php if (!defined('IN_DISCUZ')) { exit('Access Denied'); } DB::query('DROP TABLE IF EXISTS %t', array('plugin_follow')); DB::query('DROP TABLE IF EXISTS %t', array('plugin_follow_stat')); $finish = TRUE;

卸载是破坏性操作,我的习惯是卸载前先做一个备份确认,弹出一行提示告诉管理员“将删除关注关系数据”,避免误点。

3.3 hooks注册:让插件代码嵌进Discuz页面

关注按钮要出现在个人主页,关注中心的入口要出现在顶部导航,这两个位置都需要hook。XML里的注册方式如下(节选):

<?xml version="1.0" encoding="ISO-8859-1"?> <root> <item id="Title"><![CDATA[用户关注]]></item> <item id="Version">1.0</item> <item id="Description"><![CDATA[轻量级单向关注插件]]></item> <item id="Copyright"><![CDATA[某开发者]]></item> <item id="installfile"><![CDATA[install.php]]></item> <item id="uninstallfile"><![CDATA[uninstall.php]]></item> <item id="hooks"> <item id="global_usernav_extra1"><![CDATA[usernav]]></item> <item id="home_space_sidebar"><![CDATA[spacebtn]]></item> </item> </root>

这里的逻辑是:global_usernav_extra1这个hook位触发时,调用插件类里叫usernav的方法;home_space_sidebar触发时,调用spacebtn方法。hook的具体名称要以你手里Discuz版本的后台hook列表为准,不同小版本会有一点出入。我自己用的home_space_sidebar在多数X3版本都能用,如果你的版本没有,换成home_space_top等可用hook就行,方法写法完全一致。

4. 核心逻辑:关注/取关到底经历了什么

4.1 数据访问层:把SQL收口到table类里

Discuz的C::t()机制是标准的数据访问方式。我建了两个table类,一个是关系表操作,一个是统计表操作。

table/table_plugin_follow.php:

<?php if (!defined('IN_DISCUZ')) { exit('Access Denied'); } class table_plugin_follow extends discuz_table { public function __construct() { $this->_table = 'plugin_follow'; $this->_pk = 'id'; parent::__construct(); } public function check_follow($uid, $followuid) { return DB::result_first( 'SELECT id FROM %t WHERE uid=%d AND followuid=%d LIMIT 1', array($this->_table, $uid, $followuid) ); } public function add_follow($uid, $followuid) { return DB::insert($this->_table, array( 'uid' => $uid, 'followuid' => $followuid, 'dateline' => TIMESTAMP, ), true); } public function remove_follow($uid, $followuid) { return DB::delete($this->_table, array( 'uid' => $uid, 'followuid' => $followuid, )); } public function count_by_uid($uid) { return DB::result_first( 'SELECT COUNT(*) FROM %t WHERE uid=%d', array($this->_table, $uid) ); } public function count_by_followuid($followuid) { return DB::result_first( 'SELECT COUNT(*) FROM %t WHERE followuid=%d', array($this->_table, $followuid) ); } public function fetch_following($uid, $start, $limit) { return DB::fetch_all( 'SELECT * FROM %t WHERE uid=%d ORDER BY id DESC LIMIT %d,%d', array($this->_table, $uid, $start, $limit) ); } public function fetch_fans($followuid, $start, $limit) { return DB::fetch_all( 'SELECT * FROM %t WHERE followuid=%d ORDER BY id DESC LIMIT %d,%d', array($this->_table, $followuid, $start, $limit) ); } }

这里要再次强调fetch_following和fetch_fans的区别:前者查的是uid=当前用户,返回的每一行里的followuid才是你关注的人;后者查的是followuid=当前用户,返回的每一行里的uid才是你的粉丝。搞反了,列表页就全乱了。

统计表的table类:

<?php if (!defined('IN_DISCUZ')) { exit('Access Denied'); } class table_plugin_follow_stat extends discuz_table { public function __construct() { $this->_table = 'plugin_follow_stat'; $this->_pk = 'uid'; parent::__construct(); } public function increase_following($uid) { DB::query( 'INSERT INTO %t (uid, following) VALUES (%d, 1) ON DUPLICATE KEY UPDATE following=following+1', array($this->_table, $uid) ); } public function decrease_following($uid) { DB::query( 'UPDATE %t SET following=GREATEST(following-1,0) WHERE uid=%d', array($this->_table, $uid) ); } public function increase_fans($uid) { DB::query( 'INSERT INTO %t (uid, fans) VALUES (%d, 1) ON DUPLICATE KEY UPDATE fans=fans+1', array($this->_table, $uid) ); } public function decrease_fans($uid) { DB::query( 'UPDATE %t SET fans=GREATEST(fans-1,0) WHERE uid=%d', array($this->_table, $uid) ); } }

GREATEST(following-1, 0)是为了防止计数变成负数。计数异常在并发场景下真的会出现,加个下限保护是有必要的。

4.2 关注与取关接口

整个插件的核心入口是follow.inc.php,通过plugin.php?id=follow访问。它接收表单提交,区分add和remove两个动作。

<?php if (!defined('IN_DISCUZ')) { exit('Access Denied'); } require_once DISCUZ_ROOT . './source/plugin/follow/table/table_plugin_follow.php'; require_once DISCUZ_ROOT . './source/plugin/follow/table/table_plugin_follow_stat.php'; $uid = intval($_G['uid']); $op = isset($_GET['op']) ? trim($_GET['op']) : ''; $viewuid = intval($_GET['uid']); if (!$uid) { showmessage('请先登录后再使用关注功能', 'member.php?mod=logging&action=login', array(), array('showdialog' => true)); } if ($op != 'add' && $op != 'remove') { showmessage('非法操作'); } if (!submitcheck('followsubmit')) { showmessage('表单校验失败'); } if ($viewuid == $uid) { showmessage('不能关注自己'); } $target = C::t('common_member')->fetch($viewuid); if (!$target) { showmessage('用户不存在'); } if ($op == 'add') { if (!C::t('plugin_follow')->check_follow($uid, $viewuid)) { C::t('plugin_follow')->add_follow($uid, $viewuid); C::t('plugin_follow_stat')->increase_following($uid); C::t('plugin_follow_stat')->increase_fans($viewuid); } showmessage('关注成功', "home.php?mod=space&uid=$viewuid"); } else { if (C::t('plugin_follow')->check_follow($uid, $viewuid)) { C::t('plugin_follow')->remove_follow($uid, $viewuid); C::t('plugin_follow_stat')->decrease_following($uid); C::t('plugin_follow_stat')->decrease_fans($viewuid); } showmessage('已取消关注', "home.php?mod=space&uid=$viewuid"); }

几个关键校验一个都不能少:登录校验、操作类型校验、submitcheck表单校验、不能关注自己、目标用户必须存在。尤其是submitcheck,它会同时校验表单里的followsubmit字段和formhash令牌,没有这一步,任何人发一个POST请求就能帮你批量关注人。

4.3 AJAX接口独立拆出来

关注按钮在个人主页上,如果点一下先跳转页面再跳回来,体验非常烂。所以我在ajax.inc.php里提供JSON接口:

<?php if (!defined('IN_DISCUZ')) { exit('Access Denied'); } require_once DISCUZ_ROOT . './source/plugin/follow/table/table_plugin_follow.php'; require_once DISCUZ_ROOT . './source/plugin/follow/table/table_plugin_follow_stat.php'; header('Content-Type: application/json; charset=utf-8'); $uid = intval($_G['uid']); $followuid = intval($_GET['followuid']); $op = isset($_GET['op']) ? trim($_GET['op']) : ''; $formhash = isset($_POST['formhash']) ? $_POST['formhash'] : ''; function follow_json($code, $msg, $data = array()) { $result = array('code' => $code, 'msg' => $msg); if ($data) { $result['data'] = $data; } exit(json_encode($result)); } if (!$uid) { follow_json(-1, '请先登录'); } if ($followuid == $uid) { follow_json(-1, '不能关注自己'); } if (!C::t('common_member')->fetch($followuid)) { follow_json(-1, '用户不存在'); } if ($formhash != $_G['formhash']) { follow_json(-1, '安全校验失败'); } if ($op == 'add') { if (!C::t('plugin_follow')->check_follow($uid, $followuid)) { C::t('plugin_follow')->add_follow($uid, $followuid); C::t('plugin_follow_stat')->increase_following($uid); C::t('plugin_follow_stat')->increase_fans($followuid); } follow_json(0, '关注成功', array('status' => 1)); } elseif ($op == 'remove') { if (C::t('plugin_follow')->check_follow($uid, $followuid)) { C::t('plugin_follow')->remove_follow($uid, $followuid); C::t('plugin_follow_stat')->decrease_following($uid); C::t('plugin_follow_stat')->decrease_fans($followuid); } follow_json(0, '已取消关注', array('status' => 0)); } follow_json(-1, '非法操作');

注意AJAX接口里的formhash校验用的是$_G['formhash'],这是Discuz为当前会话生成的动态令牌,每次登录后会变。前端必须把模板里的这个值传过来,POST提交,不能用GET直接把formhash放URL里,否则既不好看又容易暴露。

4.4 列表查询:关注列表和粉丝列表

列表页的通用逻辑是分页 + 批量取用户资料。以“我关注的人”为例,list.inc.php核心代码:

<?php if (!defined('IN_DISCUZ')) { exit('Access Denied'); } require_once DISCUZ_ROOT . './source/plugin/follow/table/table_plugin_follow.php'; require_once DISCUZ_ROOT . './source/plugin/follow/table/table_plugin_follow_stat.php'; $uid = intval($_G['uid']); if (!$uid) { showmessage('请先登录'); } $page = max(1, intval($_GET['page'])); $perpage = 20; $start = ($page - 1) * $perpage; $count = C::t('plugin_follow')->count_by_uid($uid); $list = C::t('plugin_follow')->fetch_following($uid, $start, $perpage); $uids = array(); foreach ($list as $row) { $uids[$row['followuid']] = $row['followuid']; } $members = array(); if ($uids) { $members = C::t('common_member')->fetch_all($uids); } $multipage = multi($count, $perpage, $page, 'plugin.php?id=follow:list'); include template('follow:list');

multi是Discuz内置分页函数,直接传count、每页数、当前页、URL模板就行,自动生成分页HTML。粉丝列表页逻辑完全对称,把count_by_uid换成count_by_followuid,fetch_following换成fetch_fans,然后把$row['uid']收集成数组即可。

4.5 动态聚合:关注后能看到什么

关注功能的灵魂在于“关注了就能看到对方的新内容”。如果只是加个关系、列个名单,这个功能显得很鸡肋。所以在MVP稳定之后,我加了一个动态聚合查询:在关注中心首页,把被关注者发布的最新主题汇总展示。

实现方式不复杂,先把当前用户关注的uid列表取出来,然后用IN条件查主题表:

$followlist = C::t('plugin_follow')->fetch_following($uid, 0, 500); $uids = array(); foreach ($followlist as $row) { $uids[] = intval($row['followuid']); } if ($uids) { $feeds = DB::fetch_all( 'SELECT tid, subject, authorid, dateline FROM %t WHERE authorid IN (%n) AND displayorder >= 0 ORDER BY dateline DESC LIMIT 20', array('forum_thread', $uids) ); }

%n是Discuz DB层处理IN参数列表的占位符,会自动把数组拼接成带引号的列表,同时做安全过滤。这个查询在数据量上来后会有性能压力,上线时记得给forum_thread.authorid + dateline建组合索引。真要做得更细,可以做成定时刷新的缓存,但MVP阶段直接查也够用。

5. 前端展示与交互落地

5.1 个人主页关注按钮:hook方法怎么接数据

按钮要出现在个人主页,靠的是home_space_sidebarhook。插件类follow.class.php里的方法如下:

<?php if (!defined('IN_DISCUZ')) { exit('Access Denied'); } class plugin_follow { public function usernav() { global $_G; if ($_G['uid']) { return '<li><a href="plugin.php?id=follow:list">关注中心</a></li>'; } return ''; } public function spacebtn() { global $_G; if (!$_G['uid']) { return ''; } $spaceuid = intval($_GET['uid']); if (!$spaceuid) { $spaceuid = $_G['uid']; } if ($spaceuid == $_G['uid']) { return ''; } $followed = C::t('plugin_follow')->check_follow($_G['uid'], $spaceuid); $btnText = $followed ? '已关注' : '加关注'; $btnClass = $followed ? ' flw_done' : ''; $html = '<div class="flw_wrap"> <a href="javascript:;" class="flw_btn' . $btnClass . '" ><!--{if !$list}--> <div class="tip">还没有关注任何人,去个人主页找找感兴趣的人吧。</div> <!--{else}--> <ul class="flw_clist"> <!--{loop $list $row}--> <li> <a href="home.php?mod=space&uid=$row[followuid]" target="_blank"> <img src="avatar.php?uid=$row[followuid]&size=small" width="48" height="48" alt="" /> </a> <div class="flw_info"> <a href="home.php?mod=space&uid=$row[followuid]"> <!--{eval echo htmlspecialchars($members[$row[followuid]][username]);}--> </a> <p>关注时间: <!--{eval echo dgmdate($row[dateline], 'Y-m-d H:i');}--> </p> </div> <a href="javascript:;" class="flw_btn flw_done" >(function (window, document) { 'use strict'; function toggleFollow() { var btn = this; var uid = btn.getAttribute('data-uid'); var url = btn.getAttribute('data-url'); var formhash = btn.getAttribute('data-formhash'); var done = btn.classList.contains('flw_done'); var op = done ? 'remove' : 'add'; var xhr = new XMLHttpRequest(); xhr.open('POST', url, true); xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); xhr.onreadystatechange = function () { if (xhr.readyState !== 4) { return; } if (xhr.status === 200) { var res; try { res = JSON.parse(xhr.responseText); } catch (e) { return; } if (res.code === 0) { if (op === 'add') { btn.classList.add('flw_done'); btn.textContent = '已关注'; } else { btn.classList.remove('flw_done'); btn.textContent = '加关注'; } } if (res.msg) { window.alert(res.msg); } } }; xhr.send('op=' + op + '&followuid=' + uid + '&formhash=' + encodeURIComponent(formhash)); } function init() { var btns = document.querySelectorAll('.flw_btn'); for (var i = 0; i < btns.length; i++) { btns[i].addEventListener('click', toggleFollow); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })(window, document);

按钮的两种状态完全靠flw_done这个class区分。点的时候如果按钮已经是“已关注”状态,操作类型就是取消关注;成功后前端把class和文字一并切回来。这个设计简单直接,不需要额外维护一个全局状态对象。

有一点要注意:如果同一个页面里有多个关注按钮(比如列表页),事件不能重复绑定。上面的代码用querySelectorAll收集所有按钮并逐个绑定,同一个按钮只绑一次,不会有问题。但如果脚本被加载两次,按钮会被绑两次监听,点击一次发两个请求。所以我用$_G['follow_js_loaded']标志位保证脚本只输出一次,这是实测踩过的坑。

5.4 基础样式

按钮好看不好看直接影响用户愿不愿意点。我写了最简样式:

.flw_wrap { margin: 10px 0; text-align: center; } .flw_btn { display: inline-block; padding: 6px 16px; border: 1px solid #1b9; border-radius: 15px; color: #1b9; background: #fff; cursor: pointer; text-decoration: none; font-size: 13px; } .flw_btn:hover { background: #1b9; color: #fff; } .flw_btn.flw_done { background: #e2f5ef; color: #999; border-color: #c8e6d9; cursor: default; }

样式文件建议放在插件自己的template目录或css目录里,通过hook输出<link>标签带出来,不要往Discuz全局样式文件里塞代码,否则卸载插件时残留样式会很难清理。

6. 常见问题与排查心得

6.1 问题速查表

开发过程中我把遇到过的典型问题整理成了一张速查表,直接照表排查能省很多时间。

现象可能原因解决办法
修改插件代码后无效果Discuz插件缓存未更新后台“插件”列表点击“更新”,或强制刷新缓存
安装时提示文件无法写入插件目录权限不足检查source/plugin/follow/目录可读写
调用C::t('plugin_follow')报类不存在table类路径或类名不对确认文件在source/plugin/follow/table/table_plugin_follow.php,类名必须是table_plugin_follow
AJAX请求返回404模块地址写错正确格式是plugin.php?id=follow:ajax,不是plugin.php?id=follow&mod=ajax
按钮点击无响应JS未加载或加载两次用浏览器控制台看脚本是否执行,检查$_G['follow_js_loaded']标志位
关注按钮显示了但点完没变化formhash校验失败确认模板里输出的是$_G['formhash'],且通过POST提交
重复关注出现数据异常前端重复提交依赖唯一索引兜底,代码里先check_follow再插入
关注数和粉丝数对不上异常流程导致计数偏了跑一次计数重算脚本,见6.3
头像显示不出来相对路径在不同页面失效模板里改成<img src="{$_G[siteroot]}avatar.php?uid=xxx&size=small">
插件升级后old表还在安装脚本跳过建表卸载重装前先手动备份并确认旧表结构

6.2 性能与安全细节

性能上,列表页的分页查询要走索引,条件字段就是uid或followuid,这两个字段上的索引必须建。前面批量取用户资料那段逻辑,就是避免N+1查询的关键,务必保留。

安全上,有几个细节值得反复强调:

  • 所有从URL或表单里取得的ID必须intval,不能直接拼接进SQL。
  • 所有写操作(关注、取关)必须是POST请求,且带上formhash校验。
  • 所有模板里输出的用户名必须转义。
  • 不要信任客户端传来的“当前登录状态”,一律以$_G['uid']为准。
  • AJAX接口要设置header('Content-Type: application/json; charset=utf-8'),避免响应被当成HTML解析,同时在PHP顶层统一异常处理,防止把PHP报错直接吐给前端。

我见过不少插件写SQL时直接拼接字符串,这是Discuz插件安全里最不能忍的一条。用DB::fetch_all的预处理占位符没有多麻烦,收益却是实打实的。

6.3 计数重算脚本

统计表的计数在极端并发下可能和真实数据不一致,所以运维上必须留一个对账手段。我给站点做了一个后台命令入口,定时跑一段重算逻辑:

TRUNCATE TABLE pre_plugin_follow_stat; INSERT INTO pre_plugin_follow_stat (uid, following) SELECT uid, COUNT(*) FROM pre_plugin_follow GROUP BY uid; INSERT INTO pre_plugin_follow_stat (uid, fans) SELECT followuid, COUNT(*) FROM pre_plugin_follow GROUP BY followuid ON DUPLICATE KEY UPDATE fans=VALUES(fans);

这段SQL先把统计表清空,然后从关系表重新聚合出每个用户的关注数和粉丝数。VALUES(fans)在较新的MySQL版本里可能会标记为废弃语法,如果你的数据库版本比较高,可以改成标准的as new别名写法,或者干脆离职后再手动更新。实测下来,每天低峰期跑一次,计数基本能保持准确。

对账脚本还有一个额外好处:如果将来上了缓存,可以用它来做缓存预热,先把统计表刷成最新值,再让页面从统计表读数据。

6.4 后续还能往哪个方向扩展

这个插件的架构留了比较干净的扩展位。最自然的方向是把“关注动态”做成一个独立tab,除了主题,还能把回复、点评、资源更新等行为聚合进来,这时候可以建一张pre_plugin_follow_feed表,由各个内容模块在产生新内容时主动写入,列表页直接查这个表就行。

第二个方向是关注分组。关系表加一个gid字段,用户可以把关注的人分成“技术”“生活”“同事”等组,列表页按组过滤。这个需求一旦确认,SQL从单表条件变成两表联查,索引设计需要重新规划,建议等真实用户提出来再做。

第三个方向是关注通知。加了一个follow关系后,可以往pre_common_notice表写一条通知,告诉被关注者“有新粉丝”。这个功能对流量站来说能有效提升用户回访率,但也要考虑信息骚扰的问题,开发时建议做成后台开关,默认关闭。

我现在把关注功能做成独立插件后,最深的体会是:Discuz二次开发的核心不是把功能写出来,而是把功能“装”进去。数据表独立、hooks注册规范、模板输出干净、JS不污染全局,这套边界守住了,后续升级维护都会轻松很多。最后再分享一个小技巧:每次改完插件,我习惯先在本地关掉Discuz的插件缓存(后台更新插件),改一次看一眼效果,别等代码写完再集中调试,否则你根本分不清问题出在逻辑还是缓存。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 16:25:52

把可复用经验封装给AI:一份可落地的Skill设计实操指南

1. 从一段重复劳动说起&#xff1a;我为什么开始琢磨 Skill 这件事1.1 一个让我崩溃的场景&#xff1a;每周都要重新教一遍 AI一年多前&#xff0c;我开始频繁用 AI 辅助写工作汇报&#xff0c;每次都要在对话框里粘贴同样的一堆东西&#xff1a;业务背景、分析维度、输出格式、…

作者头像 李华
网站建设 2026/11/3 16:25:18

AI编程王炸组合:Claude、Codex、Grok分工协作实战指南

别的不说&#xff0c;先把结论放在前头&#xff1a;单模型做副手&#xff0c;多模型才能组王炸。最近这段时间&#xff0c;我放弃“一个模型通吃所有需求”的执念&#xff0c;把工具链切成了三块——Claude 系模型负责思考架构、Codex 系模型负责批量落地、Grok 系模型负责实时…

作者头像 李华
网站建设 2026/10/28 19:38:34

未来软件只剩三件套:大脑+技能+护栏,Agent架构改造实战

过去半年&#xff0c;我一直在琢磨一个事儿&#xff1a;当通用Agent可以自己拆解目标、调用工具、处理异常的时候&#xff0c;传统那个"页面接口数据库"的铁三角&#xff0c;到底还有多大存在价值&#xff1f;答案是&#xff0c;正在被一种更极简的形态替代——就是标…

作者头像 李华
网站建设 2026/10/30 17:45:50

Metasploit四种安装方式深度对比与选型指南

1. 这不是“装个软件”那么简单&#xff1a;Metasploit安装背后的真实逻辑Metasploit不是普通意义上的渗透测试工具&#xff0c;它是一套高度模块化、持续演进的安全研究协作平台。很多人第一次接触时只把它当成“漏洞利用器”&#xff0c;结果装完发现连基础模块都加载失败&am…

作者头像 李华
网站建设 2026/10/28 12:34:08

国产化底座性能调优实战:从6秒到800毫秒的完整方法论

接手一套刚完成底座替换的人事系统时&#xff0c;业务方反馈首页要6秒才出数据&#xff0c;月底报表生成经常跑到超时。当时所有人第一反应都是“国产化不行”&#xff0c;但真正逐层查下来&#xff0c;发现CPU占用不高、内存也够&#xff0c;瓶颈全藏在默认参数和部署姿势上。…

作者头像 李华
网站建设 2026/10/29 13:07:47

NI USB-6008/6501 C#采集实战:DAQmx .NET底层握手与同步避坑指南

简介&#xff1a;本资源是一套面向C#初学者与工业数据采集开发者的NI USB系列采集卡通讯实战Demo&#xff0c;聚焦USB-6501与USB-6008两款经济型总线供电板卡的快速上手与底层交互。项目通过调用NI官方动态链接库&#xff08;DLL&#xff09;&#xff0c;完整实现数字I/O控制、…

作者头像 李华