Discuz会员组支付插件开发:从订单到权限流转的完整实践
2026/9/15 22:16:34 网站建设 项目流程

简介:针对Discuz论坛系统的支付购买会员组商业版插件源码,主要面向社区站长、PHP开发者及需要搭建付费会员体系的论坛运营者。该插件让用户可通过支付宝、微信等在线支付方式购买指定会员组,获得高级发帖权限、内容可见度等差异化权益,帮助站点实现会员分级与增值变现。压缩包共123个文件,约1.21MB,以51个PHP文件与54个PNG图片为主,同时包含CSS样式、XML配置、GIF动态图、JavaScript及HTM模板等,PHP负责业务逻辑与支付接口,图片用于后台界面与演示说明,适合对照参考或直接部署。资源附带清晰的目录结构,演示截图展示后台配置流程,pem证书文件提示需关注支付签名安全;已有265人学习下载。开发者可基于PHP源码深入理解会员组购买流程,按需修改价格策略、支付渠道或界面样式,快速集成到自己的Discuz站点中。

1. Discuz会员组支付插件,绕不开的“三笔账”

Discuz 的会员体系在common_member里只存组 id,但支付购买会员组这件事却牵扯订单、支付流水、到期时间三张完全不同的账。第一次做这个需求的开发者,往往以为开通收款账号后直接改groupid字段就能完事,真正写出来才发现三方支付的回调并不保证送达,同一笔交易可能被通道重复推送,而用户组改完之后没有人负责到期回收。标题要解决的核心,是把「收银台支付成功」翻译成「会员组权限变更」这一条状态流转链路。

所以下面顺着「插件入口 → 订单结构 → 支付回调 → 组权限流转 → 商业版源码交付」展开。对 3 年以内的 PHP 开发者,每一环都给了可以直接复制的代码和建表 SQL;对 5 年以上的老手,重点看幂等处理和到期回收这两节,它们正是很多 dx 源码包里最容易缺的安全设计。

2. Discuz插件先把骨架立稳:manifest.xml、钩子注册与会员组订单表

2.1 manifest.xml 与目录结构:Discuz 靠什么识别一个插件

Discuz 后台对插件的识别,靠的是source/plugin/{identifier}/目录下的discuz_plugin_{identifier}.xml文件。这个 xml 由后台「插件管理-导入插件」时生成或识别,最稳妥的做法不是手动发明格式,而是先装一个最小插件、再从后台导出一份参考骨架。常见目录是:

source/plugin/dzpay/ ├── discuz_plugin_dzpay.xml # 插件元信息,后台识别入口 ├── install.php # 安装时建表、建配置项 ├── uninstall.php # 卸载时清理数据 ├── class.php # 钩子类定义,导航/用户中心挂载点 ├── buy.php # plugin.php?id=dzpay:buy 下单入口 ├── notify.php # 支付回调接收端 ├── function/ │ ├── gateway.php # 支付通道适配 │ └── order.php # 订单读写与状态流转 └── template/ └── buy.htm # 会员组套餐展示页

buy.php放在插件根目录是因为 Discuz 的plugin.php?id=dzpay:buy会把buy.php当作独立脚本包含,写起来最直接。xml 文件手动改的地方不多,但下面几个item必须稳住:

item 字段对应关系影响
identifier必须等于目录名不一致会直接导致钩子失效
name后台插件列表显示名只影响展示,不参与路由
version版本号,用于升级脚本判断改动目录后必须同步
dependencies依赖的其他插件跨插件调用时才需要关心

注意:xml 的根节点 schema 随 Discuz 大版本有些差异,安装失败时先去后台「插件管理」看具体错误,而不要随便套用网上旧教程里的 XML 模板。

2.2 会员组订单表:把 uid、group_id、expire_time 揉进同一张表

支付类插件不建议往common_member里堆业务字段,要单独建订单表。订单需要覆盖四种状态:待支付、已支付、已退款、已过期。建表语句如下:

CREATE TABLE pre_dzpay_order ( order_id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单主键', order_no VARCHAR(32) NOT NULL COMMENT '插件侧订单号,唯一', uid INT UNSIGNED NOT NULL COMMENT '购买会员 uid', group_id SMALLINT UNSIGNED NOT NULL COMMENT '要购买的目标会员组 id', amount DECIMAL(10,2) NOT NULL COMMENT '实付金额,单位元', status TINYINT NOT NULL DEFAULT '0' COMMENT '0=待支付 1=已支付 2=已退款 3=已过期', trade_no VARCHAR(64) DEFAULT '' COMMENT '支付通道成功流水号', expire_time INT UNSIGNED DEFAULT '0' COMMENT '到期时间戳,0 表示永久', create_time INT UNSIGNED NOT NULL COMMENT '下单时间戳', pay_time INT UNSIGNED DEFAULT '0' COMMENT '实际支付时间戳', PRIMARY KEY (order_id), UNIQUE KEY uk_order_no (order_no), KEY idx_uid_status (uid, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员组购买订单表';

核心设计在两个索引上:uk_order_no用来对订单号去重,是后面回调幂等的地基;idx_uid_status用来快速取出某个用户的购买记录,到期回收和续费判断都靠它。不要把uidgroup_id直接塞进common_member的扩展字段里,续费和到期回收时统计成本会变高。

提示:install.php 里写建表语句时,表前缀不要写死pre_,要用DB::table('dzpay_order')去拿当前站点的实际前缀,否则迁移到不同前缀的论坛时插件会直接装不上。

2.3 用钩子把“购买会员”入口挂进用户中心

Discuz 的钩子机制是靠class.php里的类方法暴露给不同区域的。想在用户导航挂一个「升级会员」的链接,常见写法是:

<?php if (!defined('IN_DISCUZ')) { exit('Access Denied'); } class plugin_dzpay { public function global_usernav_extra1() { global $_G; if (empty($_G['uid'])) { return ''; } $url = 'plugin.php?id=dzpay:buy'; return '<a href="' . $url . '" style="color:#e4393c;">升级会员</a>'; } }

类名必须以plugin_加插件标识符开头,Discuz 才能在加载时把它识别为钩子类;方法名就是钩子名。global_usernav_extra1是 Discuz X3 系列导航栏右侧的常见钩子,如果你的模板改过导航结构,钩子名也要按模板变量调整。目录名和钩子名对不上时,最常见现象是后台显示「插件已安装」但前端一直没有入口,这时先查class.php的类名和方法名拼写,而不是去改模板。

钩子里只做两件事:判断登录态、输出入口链接;真正的下单逻辑放在buy.php。钩子层保持薄,不要把订单判断写在导航输出里,否则每次页面渲染都会多一次数据库查询。

3. 支付接入:从下单签名到回调验签的订单幂等设计

3.1 下单参数与签名构造:先排字典序再拼 key

从支付通道的角度看一次购买流程,通常是这样:用户在插件页面点「立即支付」,插件向网关发起下单请求,网关返回跳转链接或二维码串,用户完成支付后由网关异步通知你的服务器。这里最容易被忽视的是签名顺序。对称签名类通道的通用规则是:把除sign外的参数按字典序排序,用&连接,再拼接api_key,计算 MD5 或 HMAC。拼串时不要把sign本身算进去,也不要漏掉空参数字段的过滤:

function build_pay_params($order, $config) { $params = array( 'app_id' => $config['app_id'], 'order_no' => $order['order_no'], 'amount' => $order['amount'], 'subject' => '购买会员组', 'notify_url' => $config['notify_url'], 'return_url' => $config['return_url'], ); ksort($params); $parts = array(); foreach ($params as $key => $value) { if ($value === '' || $value === null) { continue; } $parts[] = $key . '=' . $value; } return $params + array( 'sign' => strtoupper(md5(implode('&', $parts) . '&key=' . $config['api_key'])), ); }

这段代码里ksort和空值过滤是签名能验证通过的命门。不同通道的差异点主要有三个:签名算法(MD5、HMAC-SHA256 或 RSA)、sign是否转大写、key拼在串前还是串后。你在写gateway.php时最好把签名算法做成配置项,而不是固定在函数里。

做成商业版插件,建议先定义支付通道接口:

interface PaymentGateway { public function createOrder(array $order, array $config): string; public function verifyNotify(array $params, array $config): bool; public function queryOrder(string $orderNo, array $config): array; }

支付宝、微信分别用一个类实现这个接口,调用方只依赖接口类型。这样后续要增加其他收银通道,只需要写一个新类并改配置,订单表和回调入口都不用动。

3.2 回调处理:验签、查单、改单三步走

notify.php是支付插件的咽喉,这里出错就是「钱扣了没到账」的投诉来源。回调整体分三步:验签、查本地订单、改业务状态。参考代码:

<?php define('IN_DISCUZ', true); require_once '../../source/class/class_core.php'; $discuz = C::app(); $discuz->init(); function verify_notify_params($params, $config) { $sign = $params['sign']; unset($params['sign']); ksort($params); $parts = array(); foreach ($params as $key => $value) { if ($value === '' || $value === null) { continue; } $parts[] = $key . '=' . $value; } $expected = strtoupper(md5(implode('&', $parts) . '&key=' . $config['api_key'])); return hash_equals($expected, strtoupper($sign)); } $params = $_POST; $config = get_plugin_config(); if (!verify_notify_params($params, $config)) { exit('fail'); } $order = C::t('dzpay_order')->fetch_by_order_no($params['order_no']); if (!$order) { exit('fail'); } if ($order['status'] == 1) { exit('success'); } if ($order['status'] != 0) { exit('fail'); } // 业务更新: 把目标会员组写进 common_member grant_member_group($order['uid'], $order['group_id']); // 更新订单状态 C::t('dzpay_order')->update_order_status( $order['order_no'], 1, $params['trade_no'], TIMESTAMP ); exit('success');

逻辑说明:status == 1时直接返回success,这是幂等处理的关键——通道几乎都会重试通知,不改业务状态只确认成功,避免重复发货;status != 0说明订单已退款或已过期,不能再把用户组升上去。

注意,同一个通道可能出现「通知里没带trade_no」的情况,这时你应该回查网关的订单查询接口,以查询结果为准,而不是直接放弃。把查询接口也放进PaymentGateway接口里的原因就在这里。

3.3 消息重试与支付网关抽象

通道回调失败是常态,不是异常。常见做法是网关按 15 秒、30 秒、1 分钟、3 分钟的重试间隔推送,如果你的服务器响应慢、验签代码抛了异常,很容易出现一次支付被推送五六次的情况。所以notify.php最前面就应该用try/catch包住验签,任何异常都exit('fail')让网关重试,而不是让 PHP 直接输出一串堆栈。

日志也要落。每笔回调至少记录原始参数、验签结果、订单号、处理结果四项。做商业版源码交付时,我会在后台放一个「支付回调日志」页面,买家排查问题时不用去服务器翻日志文件,这个功能比多写几个营销接口更实际。

4. 购买会员组后的权限流转:回调改组、到期回收与续费状态机

4.1 回调成功更新会员组的执行顺序

支付成功后把用户组写进common_member,看起来是一行UPDATE的事,但执行顺序有三个坑:一是不能把管理员组降级,二是用户已经是目标组时不要重复写,三是不能把extgroupids覆盖掉。建议把改动收敛成一个方法:

function grant_member_group($uid, $target_gid) { $member = C::t('common_member')->fetch($uid); if (empty($member)) { return false; } // 管理员组(实际id按论坛配置调整)不参与付费购买逻辑 $founder_group_id = 1; if ($member['groupid'] == $founder_group_id) { return false; } if ($member['groupid'] == $target_gid) { return false; } C::t('common_member')->update($uid, array('groupid' => $target_gid)); return true; }

这里刻意只改groupid,不动extgroupids。原因在于 Discuz 的权限计算是groupidextgroupids合并生效的,很多模板把管理权限挂在groupid上,直接覆盖扩展组可能会连带出其他权限问题。回调里先判断、再更新,最后更新订单状态,这个顺序不能反:订单状态若先改了,后面更新用户组时抛异常,就会出现钱扣了组没变的脏数据。

4.2 到期时间计算与续费叠加

到期时间必须用时间戳来计算,不要存成datetime字符串,跨时区、跨版本迁移都会麻烦。会员到期点在业务上是一个「过期阈值」,判断方式是当前时间大于expire_time就回收。

续费场景比首次购买复杂一点,核心是叠加原则:老用户续费,新到期时间应该基于当前剩余时长累加,而不是从当前时间重新起算。举例说明:

场景原 expire_time当前时间续费时长新 expire_time
未过期续费1750000000174500000030天1752592000
已过期续费1740000000174500000030天1747592000

第一个场景新到期时间是原expire_time + 30天,第二个场景是当前时间 + 30天。判断逻辑一句话就能写清楚:剩余秒数大于0时,以原expire_time为基准叠加,否则以当前时间为基准

4.3 支付成功但组没变的排查优先级

这类工单是支付插件售后出现频率最高的。买到源码包的运营者通常第一反应是找支付通道索赔,实际上问题大多出在自己站点配置。按下面顺序排查,比胡乱改代码效率高得多:

排查顺序检查点说明
1订单表status是否变成 1状态是 0 说明回调根本没成功
2common_membergroupid是否被覆盖过可能被其他插件二次修改
3回调日志有没有trade_no为空为空说明走的是手动补单流程
4是否开了 Redis 或 Memcached 缓存用户组被缓存后要等缓存过期才刷新

如果订单状态已经是 1、数据库里groupid也正确、但论坛前台显示还是旧组,基本可以断定是缓存层问题。Discuz 对用户组信息有运行时缓存,支付过去前几秒前台不刷新是正常的,设置一个 60 秒内的缓存等待窗口,培训客服时也讲清楚,能省掉大量无效重置工单。

5. 商业版 discuz 源码的授权与交付细节

既然标题强调“商业版”和“dz源码”,发布前最后一个环节就是代码保护和授权。看到过很多开发者辛苦写完插件,结果被直接复制到别的站点免费使用,问题不在于买家,而在于你交付的压缩包里没有授权校验入口。

商业版授权最常见的模式是「本地公钥校验 + 远程补发」。在插件的入口文件顶部加载授权检查:

function dzpay_license_check() { $license = C::t('dzpay_config')->fetch_value('license'); if (empty($license)) { return array('status' => 0, 'msg' => '未授权'); } // license 内容格式: 域名|到期时间戳|签名 $parts = explode('|', $license); if (count($parts) !== 3) { return array('status' => 0, 'msg' => '授权格式错误'); } $data = $parts[0] . '|' . $parts[1]; $sig = base64_decode($parts[2]); // 用内置公钥验签 $publicKey = <<<EOD -----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC... -----END PUBLIC KEY----- EOD; $ok = openssl_verify($data, $sig, $publicKey, OPENSSL_ALGO_SHA256); return array('status' => ($ok === 1) ? 1 : 0, 'msg' => '验签失败'); }

验签逻辑放在插件初始化文件里,收到检查结果后,把插件主功能入口包在一个条件判断后面。公钥直接内置在源码里,私钥留在你本地签授权文件;买家换个域名就把授权文件作废,不需要发新版。想要更严可以再加远程接口,但注意要做本地缓存,别让每次页面请求都外呼授权服务器,否则站点一卡,工单就全来了。

部署侧也有两个容易忽略的点。一是 PHP 7.x 环境里 IonCube 加密仍是很多商业插件的选择,但 PHP 8 后对这类加密扩展的兼容变化很大,如果源码面向的是新站,加密不如混淆加授权校验,避免「装不上」成为售后主流问题。二是交付包里的uninstall.php千万别写DROP TABLE却忘了留备份动作,商业版买家一旦误操作卸载,数据不可恢复,信任就一次性败光。

最后补一个小技巧:启动和安装支持封包前,在测试站把uninstall.php跑一遍,然后重新安装,看看 install 脚本能不能幂等建表。这条流程通过,说明你的安装脚本能应对一半的售后问题。

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

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

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

立即咨询