☰
Discuz! QQ登录插件OAuth2.0授权回调与账号绑定实战
2026/10/24 4:30:08 网站建设 项目流程

简介:面向Discuz!论坛站长与社区运营者的QQ快捷登录插件改进版2.4,由北岸团队对原版登录流程进行优化,主要解决论坛注册步骤繁琐、QQ用户转化率偏低以及登录入口兼容性不足等问题。压缩包共61个文件,整体约1MB,内含24个php文件承载授权回调、账号绑定、安装卸载与移动端登录逻辑,12个jpg和11个png用于界面与按钮素材,8个htm模板配合4个xml插件配置,另有gif、html等辅助文件,覆盖PC与移动端接入场景。已有430人学习下载,在Discuz!插件使用者中具备一定参考度。资料提供多编码插件配置、QQ回调与会员绑定处理、移动端登录类及帮助页模板,便于管理员按QQ互联要求填入IDKEY后完成对接,也方便开发者排查授权回调、账号绑定和页面展示问题,减少从零改造登录模块的时间,适合需要快速上线QQ登录、减少重复开发的站长与二次开发者参考。

1. 按钮点下去白屏,问题多半不在插件本身

上周帮人看一个 DZ 论坛,前台 QQ 登录按钮点下去,跳授权页、点同意、回来一片白。站长说换过三套插件都一样,最后发现是回调地址末尾多了一个斜杠。这套 DZ QQ 登录插件改进版 2.4,就是在反复踩这类坑之后攒出来的东西:授权、回调、绑定、解绑四条链路收在一个插件目录里,后台填好 appid 和回调地址就能跑,适合还在用 Discuz! X3.4/X3.5 搭垂直社区、又不想让新用户卡在邮箱验证这一步的站长和二次开发者。下面按“链路怎么走—文件怎么放—参数怎么填—数据怎么写—坑在哪”拆开讲,能抄的部分直接抄,抄完把域名换成自己的就行。

2. OAuth2.0 授权码流程与插件目录:先把链路和文件看明白

2.1 授权码模式在这套插件里是怎么跑通的

QQ 互联走的是标准授权码模式(authorization code),四步握手:跳授权页拿code、用code换access_token、拿access_token换openid、拿openid和 token 拉用户资料。这四步里只有第一步是用户在浏览器里完成的,后三步全部在服务端用 curl 走,所以插件真正容易出错的地方全在服务端,而不是前端按钮。

第一步的 URL 由插件主类拼出来,逻辑大致是这样:

<?php // source/plugin/qqlogin/qqlogin.class.php 片段:生成授权地址 $state = substr(md5($_G['uid'] . TIMESTAMP . random(8)), 0, 16); // state 同时落 cookie 和插件表,回调时两边都要对得上 dsetcookie('qqlogin_state', $state, 600); C::t('#qqlogin#qqlogin_state')->insert(array( 'state' => $state, 'uid' => (int)$_G['uid'], 'dateline' => TIMESTAMP, ), false, true); $params = array( 'response_type' => 'code', 'client_id' => $this->appid, // 开放平台分配的应用 ID,纯数字 'redirect_uri' => $this->callback, // 必须与开放平台后台登记的一字不差 'state' => $state, // 防 CSRF,也用来区分是哪个入口发起的 'scope' => 'get_user_info', ); $authorize_url = 'https://graph.qq.com/oauth2.0/authorize?' . http_build_query($params);

state这一段是整个流程里最容易被偷懒省掉的部分。省掉之后功能也能跑,但你会丢掉两样东西:一是 CSRF 防护,二是“这次登录是从 PC 端还是手机端发起的”这个上下文。改进版 2.4 把 state 存成了独立表pre_plugin_qqlogin_state,而不是塞进$_SESSION,原因是 DZ 的 session 在跨域回调回来时经常已经换了 session id,尤其是站点开了 CDN 又没做 cookie 透传的时候,读 session 读到空的概率相当高。

第二步换 token 的请求在callback.inc.php里,参数顺序不重要,但redirect_uri必须和第一步完全一致,连 URL 编码方式都要一致。第三步取 openid 返回的是一段callback( {"client_id":"xxx","openid":"yyy"} );的包裹文本,插件里统一加了fmt=json,省掉正则解析,这个细节后面第 3 章会用 curl 单独验证。

2.2 解包看目录:哪些文件在干活

把压缩包解开,核心就是一个source/plugin/qqlogin/目录,外面还有一段后台配置用的 XML。文件清单和职责对应关系如下:

文件/目录作用二次开发改动频率
discuz_plugin_qqlogin.xml安装描述文件,定义插件变量、模块、钩子挂载点低,除非要加新配置项
qqlogin.class.php主类,继承plugin_qqlogin,管钩子和入口分发高
callback.inc.php回调入口,处理 code、换 token、写绑定高
bind.inc.php首次登录的绑定页逻辑,处理用户名冲突中
unbind.inc.php解绑,清common_member_connect对应记录低
table/table_qqlogin_state.phpstate 表的 DB 层封装低
template/按钮模板、绑定页模板、移动端模板中
static/js/qqlogin.js弹窗与跳转控制,移动端 touch 绑定也在这里中

看清这个结构有个实际好处:出问题的时候能立刻定位是“配置层”还是“代码层”。比如授权页能打开但回调 500,问题一定在callback.inc.php之后;按钮点了没反应,问题在static/js或模板,跟 OAuth 一点关系都没有。我一般排查前先做这个二分,能省掉一半时间。

discuz_plugin_qqlogin.xml里的<var>节点定义了后台可见的配置项,常见的就四个:appid、appkey、callback、allownewuser。其中allownewuser控制“没绑定过的 QQ 是否允许自动创建新账号”,社区类站点一般设 1,内部论坛建议设 0,只允许已有账号绑定,防止外人随便注册进来。

2.3 安装与后台配置的落地步骤

装之前先备份source/plugin/和数据库,这一步别省。DZ 插件装坏了会导致后台应用列表白屏,恢复起来比重装还麻烦。上传和权限给到位:

# 在论坛根目录执行,假设包已解压到 /tmp/qqlogin cp -r /tmp/qqlogin/source/plugin/qqlogin ./source/plugin/ # 插件目录给 web 用户可读可执行,不要 777 chown -R www:www ./source/plugin/qqlogin find ./source/plugin/qqlogin -type d -exec chmod 755 {} \; find ./source/plugin/qqlogin -type f -exec chmod 644 {} \; # 清一遍模板与缓存目录,避免旧缓存打架 rm -rf ./data/template/* ./data/cache/*

命令本身没什么玄学,重点是最后那两行。DZ 的模板缓存是按插件标识缓存的,第二次装同标识插件时如果不清理,前台很可能还是旧按钮的 HTML,你会误以为是插件没生效。chown也别图省事用 777,插件目录里有 PHP 文件,777 等于给人留了写 shell 的口子。

上传完成后进后台「应用 → 插件 → 未安装插件」,找到 QQ 登录,点安装。安装过程会建两张表:pre_plugin_qqlogin_state和绑定关系表。装完在插件设置里填四个参数:

参数填什么常见填错
appid开放平台应用详情页的 APP ID,纯数字误填成 APP Key
appkey应用密钥,字母数字混合,不要带空格复制时带进了换行
callback完整回调地址,含协议和域名少了https://或末尾多了/
allownewuser1 允许自动注册,0 仅允许绑定与站点注册策略冲突

填完必须点一次「更新缓存」,DZ 的插件变量是缓存在pre_common_cache里的,不更新缓存你改十遍后台,前台读到的还是旧值。这个坑我在第 5 章还会再提一次,因为它出现频率实在太高。

最后一步是跑到前台实际点一次。别用管理员账号测,管理员登录会被 DZ 的connect逻辑特殊处理,测出来的结果和普通用户不一致。开一个浏览器无痕窗口,用没注册过的普通身份走一遍完整流程,能看到绑定页、能填用户名、能登录成功,才算真装好了。

3. 回调地址与 appid 配置:三个参数决定成败

3.1 redirect_uri 的三处一致性校验

回调地址是这个插件里最容易翻车的参数,没有之一。它同时存在于三个地方:开放平台后台登记的授权回调域、DZ 插件后台配置的callback、以及callback.inc.php里拼给 token 接口的那个值。这三处必须完全一致,任何一处不同都会在换 token 那一步拿到error=100010之类的返回。

“一致”的判定比多数人想的严格。http和https算两个地址;带www和不带www算两个;末尾有没有斜杠算两个;带不带非标准端口(比如:8080)算两个。测试环境从http://test.xxx.com换到https://www.xxx.com,三处都得改,漏一处就白屏。

我的做法是把这个地址做成唯一来源,插件后台填一次,代码里全部读配置,不硬编码:

<?php // callback.inc.php 开头:回调地址统一从插件变量取,不做任何拼接 $this->callback = $_G['cache']['plugin']['qqlogin']['callback']; if (empty($this->callback)) { showmessage('qqlogin_callback_missing', '', array(), array('handle' => false)); } // 换 token 时原样带上,不 urldecode、不重新拼参数 $token_url = 'https://graph.qq.com/oauth2.0/token?grant_type=authorization_code' . '&client_id=' . $this->appid . '&client_secret=' . $this->appkey . '&code=' . urlencode($code) . '&redirect_uri=' . urlencode($this->callback) . '&fmt=json';

注意urlencode只加在参数值上,整个 URL 不能再编一次,否则https://里的冒号和斜杠会被编成%3A%2F%2F,服务端解出来是个坏地址。这个错误的表现很迷惑:授权页能正常打开(因为第一步浏览器会自动处理编码),只有回调那一步失败,很多人会误判成 appkey 错了。

3.2 appid/appkey 与签名参数

appid和appkey一个是对外的身份标识,一个是对内的密钥,性质完全不同。appid出现在前端跳转 URL 里,泄露了无所谓;appkey只在服务端换 token 时用,绝不能出现在任何前端可见的地方。我见过有模板把appkey写进 JS 变量方便“调试”,这等于把应用密钥公开了。

state的校验逻辑也在这层。改进版 2.4 用“cookie + 数据表”双份比对:

<?php // 回调第一件事:校验 state,两个来源必须同时命中 $state = isset($_GET['state']) ? trim($_GET['state']) : ''; $cookie_state = isset($_COOKIE['qqlogin_state']) ? $_COOKIE['qqlogin_state'] : ''; if (empty($state) || empty($cookie_state) || !hash_equals($cookie_state, $state)) { showmessage('qqlogin_state_invalid', '', array(), array('handle' => false)); } // 命中后立刻删掉,state 一次性使用 C::t('#qqlogin#qqlogin_state')->delete_by_state($state); dsetcookie('qqlogin_state', '', -1);

用hash_equals而不是==是防止时序攻击,虽然在这个场景下风险很低,但成本几乎为零,没理由不用。删除也必须在校验通过后立刻做,否则同一个 state 可以被重放,攻击者拿到一次回调 URL 就能反复触发绑定动作。

access_token的缓存策略也值得说一句。token 默认有效期 90 天,refresh_token30 天内可以续期。插件把 token 存在common_member_connect.conuintoken里,每次登录前先查有没有可用 token,没有才重新走授权。这样用户第二次点登录时是静默的,不会反复弹授权页。但要注意:用户在开放平台那边“取消授权”之后,本地缓存的 token 会失效,插件必须能识别error=100016这类返回并清掉本地记录,否则用户会卡在“点了没反应”的状态里出不来。

3.3 用 curl 先跑通授权链路再回插件

排查这类问题,我习惯先绕开 DZ 和浏览器,用 curl 把手动流程走一遍。这样能明确区分是“平台侧配置问题”还是“插件代码问题”。

# 变量先准备好,值从插件后台和开放平台后台抄过来 APPID="你的appid" APPKEY="你的appkey" REDIRECT_URI="https://bbs.example.com/plugin.php?id=qqlogin:callback" CODE="浏览器回调地址里 code= 后面的那串" # 第 2 步:code 换 token curl -s "https://graph.qq.com/oauth2.0/token?grant_type=authorization_code&client_id=${APPID}&client_secret=${APPKEY}&code=${CODE}&redirect_uri=${REDIRECT_URI}&fmt=json" # 第 3 步:token 换 openid curl -s "https://graph.qq.com/oauth2.0/me?access_token=${ACCESS_TOKEN}&fmt=json" # 第 4 步:拉用户资料,看 nickname 能不能取到 curl -s "https://graph.qq.com/user/get_user_info?access_token=${ACCESS_TOKEN}&oauth_consumer_key=${APPID}&openid=${OPENID}"

第二步返回里如果带error,基本可以断定是三处redirect_uri不一致,把返回的error_description读一遍,比在插件里瞎猜快得多。第三步返回openid说明 appkey 是对的。第四步返回的nickname里可能带表情符号,DZ 的老表结构是utf8不是utf8mb4,直接入库会把表情截断甚至报错,插件里做了strip_emoji过滤,这个在第 4 章会讲到。

code是一次性的,有效期很短,通常几分钟。如果第二步报code expired,回浏览器重新点一次登录拿新 code 就行,别对着旧 code 反复试。curl 验证通过之后再回插件点一次,如果插件还是失败而 curl 成功,问题就一定在插件的代码路径上,重点看callback.inc.php有没有对参数做了多余处理。

4. 账号绑定与用户表写入:从 openid 到 uid 的落地逻辑

4.1 connect 表与 common_member 的对应关系

OAuth 链路走通只是拿到身份,真正把“这个 QQ 是谁”落到“这个人是论坛的哪个 uid”,靠的是common_member_connect这张表。它和common_member是一对一关系,uid做主键,conopenid上必须有唯一索引:

CREATE TABLE IF NOT EXISTS `pre_common_member_connect` ( `uid` int(10) unsigned NOT NULL, `conisbind` tinyint(1) unsigned NOT NULL DEFAULT '0', `conopenid` char(32) NOT NULL DEFAULT '', `conuin` char(40) NOT NULL DEFAULT '', `conuintoken` char(32) NOT NULL DEFAULT '', `conqqnick` varchar(100) NOT NULL DEFAULT '', `condeletetime` int(10) unsigned NOT NULL DEFAULT '0', PRIMARY KEY (`uid`), UNIQUE KEY `conopenid` (`conopenid`), KEY `conisbind` (`conisbind`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

conopenid上的唯一索引不是可选项。缺了它,并发情况下同一个 QQ 可能绑出两个 uid,用户下次登录随机命中其中一个,表现为“我的帖子怎么少了一半”。conuintoken存的是 access_token,注意长度给够,别用char(16)这种拍脑袋的长度。

condeletetime是软删除标记,用户解绑时不物理删除记录,只置这个字段。这么设计的原因是防止“解绑后马上重绑”触发唯一索引冲突,同时也留了一条审计痕迹。真要彻底清理由定时任务处理超过一定天数的记录。

4.2 绑定、解绑与重名冲突的处理代码

首次登录的判断顺序很关键,必须“先查 openid,再决定是登录还是注册”:

<?php // callback.inc.php:拿到 openid 之后的核心分发逻辑 $openid = daddslashes($openid); $bind = C::t('#qqlogin#qqlogin_bind')->fetch_by_openid($openid); if ($bind && $bind['uid']) { // 已绑定:直接写登录态,更新 token C::t('#qqlogin#qqlogin_bind')->update_token($bind['uid'], $access_token); setloginstatus(array('uid' => $bind['uid'], 'username' => $bind['username']), 2592000); showmessage('qqlogin_succeed', dreferer(), array(), array('handle' => false)); } // 未绑定:allownewuser 为 0 时直接拒绝 if (!$_G['cache']['plugin']['qqlogin']['allownewuser']) { showmessage('qqlogin_not_bind', '', array(), array('handle' => false)); } // 允许注册则跳到绑定页,由用户确认用户名 include template('qqlogin:bind');

setloginstatus的第三个参数是 cookie 有效期,单位秒,2592000是 30 天。这个值别设太长,社区类站点一般 7 到 30 天比较合适。

重名冲突发生在自动注册那一步。QQ 昵称里有大量重复,直接拿昵称当用户名注册,第二次就会撞common_member的用户名唯一索引。插件的做法是加后缀递增:

<?php // bind.inc.php:用户名去重,最多试 20 次 $base = preg_replace('/[^\x{4e00}-\x{9fa5}a-zA-Z0-9_]/u', '', $nickname); $base = $base === '' ? 'qquser' : mb_substr($base, 0, 12, 'UTF-8'); $username = $base; for ($i = 1; $i <= 20; $i++) { if (!C::t('common_member')->fetch_by_username($username)) { break; } $username = $base . '_' . $i; } if ($i > 20) { showmessage('qqlogin_username_conflict', '', array(), array('handle' => false)); }

preg_replace那行把昵称里的表情和标点全部滤掉,只留中文、字母、数字、下划线。不过滤的话utf8表直接报Incorrect string value。mb_substr限长 12 是照顾 DZ 默认用户名长度限制,改过站点配置的按自己的来。

4.3 老用户迁移:把历史账号挂到 openid 上

站点从别的方式切过来时,会有一批“已经在论坛注册过、但没绑定过 QQ”的老用户。这批人第一次点 QQ 登录会走到注册分支,结果多出一个新账号,帖子还在老账号上,用户会直接骂人。

处理办法是在绑定页给一个“已有账号”入口,让用户输入用户名密码验证一次,验证通过后把 openid 挂到老 uid 上:

-- 迁移脚本:把已知对应关系的用户批量补绑定记录 INSERT INTO `pre_common_member_connect` (`uid`, `conisbind`, `conopenid`, `conuin`, `conuintoken`, `conqqnick`, `condeletetime`) SELECT m.`uid`, 1, t.`openid`, '', '', t.`nickname`, 0 FROM `pre_common_member` m INNER JOIN `pre_plugin_qqlogin_migrate` t ON t.`username` = m.`username` WHERE m.`uid` > 0 ON DUPLICATE KEY UPDATE `conisbind` = 1, `conqqnick` = VALUES(`conqqnick`);

ON DUPLICATE KEY UPDATE让脚本可以重复跑,跑第二遍不会报主键冲突,也不会把已有记录改坏。pre_plugin_qqlogin_migrate是临时对照表,跑完就删。执行前先在从库或者备份上跑一遍SELECT版本,确认行数和预期对得上,血泪经验是这类脚本一定要先看结果再落库。

5. 避坑与排查:五条踩坑记录

现象一:回调后整页白屏,日志里没有任何 DZ 报错。原因通常是 PHP 致命错误被display_errors关掉了,或者插件文件带 BOM 头导致headers already sent。解决:临时在config/config_global.php里开$_config['debug'] = 1,去data/log/下看当天的xxx_log.php,BOM 用grep -rl $'\xEF\xBB\xBF' source/plugin/qqlogin/一次就能找出来,批量转成无 BOM 的 UTF-8。

现象二:授权页能打开,点同意后回到站点提示参数错误。原因是三处redirect_uri不一致,或者callback参数被二次 URL 编码。解决:按第 3 章的办法先用 curl 单独验证,看返回的error_description。改完配置记得更新缓存,插件变量读的是缓存不是数据库。

现象三:用户每次点登录都变成新注册,账号越积越多。原因是common_member_connect缺conopenid唯一索引,或者判断逻辑写反了(先查 uid 再查 openid)。解决:先SHOW INDEX FROM pre_common_member_connect确认唯一索引在,再检查callback.inc.php的分支顺序,必须是 openid 优先。已经产生的重复账号要按 openid 分组人工合并,别写脚本自动删。

现象四:PC 端正常,手机端点了完全没反应。原因多半是模板里按钮绑的是click事件,而移动端浏览器在某些情况下会吞掉这个事件;另一种是本站已全站 HTTPS,但插件模板里硬编码了http://的跳转地址,被浏览器按混合内容拦掉。解决:事件改成touchstart和click都绑,检查模板里所有 URL 是否走$_G['siteurl']。用手机浏览器远程调试看一眼 Console,有没有 blocked 提示一目了然。

现象五:换了域名或上了 HTTPS 之后,所有老用户全部掉登录。原因是 cookie 域、后台站点 URL、开放平台回调三处没同步,导致 state 校验失败或者 token 换不出来。解决:换域名时把这三处列成清单逐项改,改完让用户重新授权一次。这一步没有后悔药,唯一的办法是换域名前就做好清单。

6. 移动端与模板兼容的进阶调优:一次回归验证清单

改完模板和绑定逻辑之后,别急着放开给用户用。我一般跑一遍固定清单,把六种组合都点一遍,任一项不对就不上线:

场景预期结果重点检查
PC 未登录点按钮跳授权页,回来进绑定页state cookie 是否写入成功
PC 已绑定用户点按钮静默登录,不弹授权页conuintoken是否被复用
手机浏览器点按钮跳转或唤起授权,回来正常按钮事件绑定、siteurl协议
微信内置浏览器打开至少给出明确提示,不白屏UA 判断分支是否存在
已绑定用户解绑再绑能正常解绑,重绑不报冲突软删除字段是否生效
后台关闭自动注册新用户看到提示而非报错allownewuser分支

模板这块改动最小、收益最大的一个点是按钮的降级。移动端很多时候唤起授权会失败,如果没有兜底,用户看到的就是一个死按钮:

<!-- template/qqlogin/button.htm:移动端同时绑 touchstart 和 click,防止事件被吞 --> <a href="{$qqlogin_url}" class="qqlogin-btn" id="qqloginBtn" rel="nofollow"> <span class="qqlogin-icon"></span>QQ 登录 </a> <script type="text/javascript"> var btn = document.getElementById('qqloginBtn'); var jumping = false; function go(e) { if (jumping) { return; } jumping = true; if (e && e.preventDefault) { e.preventDefault(); } window.location.href = btn.getAttribute('href'); } btn.addEventListener('touchstart', go, {passive: false}); btn.addEventListener('click', go, false); </script>

jumping这个标志位防的是移动端同时触发touchstart和click导致连跳两次,第二次带上来的 state 已经失效,用户看到的就是一次莫名其妙的白屏。{passive: false}是为了在touchstart里能调preventDefault,不加的话部分浏览器会忽略这个调用。

还有一个容易被忽略的细节:rel="nofollow"。登录入口被搜索引擎爬到会产生大量无效的授权页索引,加上这个属性省事。

最后说个习惯。这套插件我从第一版跟到现在,每次升级前都会做同一件事:把当前source/plugin/qqlogin/整个目录打包留一份,命名为qqlogin_日期_版本.rar放到独立目录,然后才动手覆盖。因为插件升级失败时后台应用列表会直接白屏,进不去后台就没法回滚,只能靠文件覆盖回去。这个习惯让我至少两次在十分钟内恢复了一个正在做活动的论坛。从那以后我每次动插件目录,都强制走一遍“打包—记录改动点—再覆盖”的流程,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询