
简介针对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?iddzpay:buy 下单入口 ├── notify.php # 支付回调接收端 ├── function/ │ ├── gateway.php # 支付通道适配 │ └── order.php # 订单读写与状态流转 └── template/ └── buy.htm # 会员组套餐展示页buy.php放在插件根目录是因为 Discuz 的plugin.php?iddzpay: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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT会员组购买订单表;核心设计在两个索引上uk_order_no用来对订单号去重是后面回调幂等的地基idx_uid_status用来快速取出某个用户的购买记录到期回收和续费判断都靠它。不要把uid和group_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?iddzpay:buy; return a href . $url . stylecolor:#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 的权限计算是groupid加extgroupids合并生效的很多模板把管理权限挂在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_member的groupid是否被覆盖过可能被其他插件二次修改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 脚本能不能幂等建表。这条流程通过说明你的安装脚本能应对一半的售后问题。本文还有配套的精品资源点击获取