如何在 MediaWiki 中实现付费订阅和 payment gateway?
结论
可以实现,但不建议重写 MediaWiki,也不要只在数据库中增加一个“已付款”字段,再直接修改核心代码。
更稳妥的做法是:
- 用 MediaWiki 用户组表示会员权限,例如
paid_member。 - 用访问控制扩展限制特定命名空间,或者将付费内容放到独立的私有 wiki。
- 通过支付平台的 subscription API 和 webhook 获取订阅状态。
- 编写一个小型 MediaWiki 扩展或独立同步服务,根据支付状态添加或移除用户组。
- 验证 webhook 签名,保证处理幂等,并定期对账。
如果内容有严格的保密要求,建议使用独立私有 wiki。MediaWiki 的页面、附件、缓存、搜索、API、转嵌和历史版本相互关联,仅拦截页面读取很容易留下缺口。
先确定访问控制范围
整个 wiki 都需要付费才能访问
这种情况可以直接使用 MediaWiki 自带的用户权限系统:
// LocalSettings.php
$wgGroupPermissions['*']['read'] = false;
$wgGroupPermissions['user']['read'] = false;
$wgGroupPermissions['paid_member']['read'] = true;
// 允许未登录用户访问登录页和订阅说明页
$wgWhitelistRead = [
'Main Page',
'Subscription',
'Special:UserLogin',
'Special:CreateAccount',
];
其中,paid_member 是自定义用户组。订阅生效时把用户加入该组,订阅失效时再移除。
$wgWhitelistRead 及相关权限配置在不同 MediaWiki 版本中的行为可能不同,部署前应根据实际使用的版本进行验证。
只有部分内容需要付费
可以把付费内容集中到单独的命名空间,例如:
define( 'NS_PREMIUM', 3000 );
define( 'NS_PREMIUM_TALK', 3001 );
$wgExtraNamespaces[NS_PREMIUM] = 'Premium';
$wgExtraNamespaces[NS_PREMIUM_TALK] = 'Premium_talk';
然后使用支持命名空间读取限制的扩展,只向 paid_member 授予 Premium: 命名空间的 read 权限。
可以评估 Lockdown 等扩展,但安装前必须确认它是否支持当前 MediaWiki 版本,还要检查它能否覆盖 API、搜索、历史版本、转嵌和文件访问等入口。网页界面打不开,并不代表内容无法通过其他入口泄露。
概念上的配置通常类似:
$wgNamespacePermissionLockdown[NS_PREMIUM]['read'] = [ 'paid_member' ];
$wgNamespacePermissionLockdown[NS_PREMIUM]['edit'] = [ 'paid_member' ];
具体变量名和配置格式应以所安装扩展的文档为准,不能把这段示例直接当成适用于所有版本的通用配置。
推荐的系统结构
支付平台与 MediaWiki 不应直接共享业务逻辑。整个流程可以分成三层:
用户购买订阅
↓
支付平台创建或更新 subscription
↓
支付平台向 webhook 发送事件
↓
验证签名并保存订阅状态
↓
将 MediaWiki 用户加入或移出 paid_member
↓
MediaWiki 根据用户组决定访问权限
支付平台负责款项和订阅状态,MediaWiki 只处理身份与权限。这样就不必把信用卡信息、退款逻辑和支付重试机制放进 wiki。
数据库如何保存会员状态
不要修改 MediaWiki 核心的 user 表。直接改动核心表结构,升级时很容易遇到兼容问题。自定义扩展应创建独立表,例如:
CREATE TABLE subscription_membership (
user_id BIGINT NOT NULL PRIMARY KEY,
provider VARCHAR(32) NOT NULL,
provider_customer_id VARCHAR(191) NOT NULL,
provider_subscription_id VARCHAR(191) NOT NULL,
status VARCHAR(32) NOT NULL,
current_period_end DATETIME NULL,
last_event_id VARCHAR(191) NULL,
updated_at DATETIME NOT NULL
);
CREATE UNIQUE INDEX subscription_provider_id
ON subscription_membership (
provider,
provider_subscription_id
);
至少应保存以下信息:
- MediaWiki 的
user_id - 支付平台的 customer ID
- subscription ID
- 当前订阅状态
- 当前计费周期结束时间
- 最后处理的 webhook event ID
- 最后更新时间
不要只保存一个布尔字段:
is_paid = 1
订阅通常还会有 active、trialing、past_due、canceled、unpaid 等状态。哪些状态允许访问,需要由明确的业务规则决定。例如:
function subscriptionAllowsAccess(
string $status,
?DateTimeImmutable $periodEnd
): bool {
if ( !in_array( $status, [ 'active', 'trialing' ], true ) ) {
return false;
}
return $periodEnd === null ||
$periodEnd > new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
}
是否允许 past_due 用户继续访问,要看业务是否设置了付款宽限期。不同支付平台对状态的定义也未必完全相同。
如何关联支付账号与 MediaWiki 用户
比较安全的方式是让用户先登录 MediaWiki,再进入付款流程。创建支付订单或 Checkout Session 时,在支付平台支持的字段中写入不可伪造的内部标识,例如:
$checkoutData = [
'customer_email' => $user->getEmail(),
'metadata' => [
'mediawiki_user_id' => (string)$user->getId(),
],
];
具体字段名由支付平台决定。实现时需要注意:
- 使用稳定的内部
user_id,不要只依赖用户名或邮箱。 - 不要信任浏览器在付款完成后传回的 URL 参数。
- 不要根据用户自行提交的 customer ID 开通权限。
- 创建付款会话时,把订单与当前登录用户绑定。
- webhook 收到事件后,还要通过本地映射表核对所属用户。
邮箱可能被修改或复用,也可能在支付平台和 MediaWiki 中使用不同的地址,因此不适合作为唯一关联键。
payment gateway 的实现方式
通常不需要自行实现完整的 payment gateway。可以选择支持周期订阅和 webhook 的支付服务,让支付平台处理支付工具和续费。
典型流程如下:
- 用户登录 MediaWiki。
- 用户点击“订阅”。
- 后端创建支付平台的订阅结账会话。
- 用户在支付平台托管的页面完成付款。
- 支付平台向服务器发送 webhook。
- 服务器验证 webhook 签名。
- 更新本地订阅记录。
- 将用户加入或移出
paid_member。 - 定时任务再次查询支付平台,修复遗漏或乱序的事件。
不要根据“付款成功跳转页”开通权限。跳转请求来自浏览器,可能被重复访问或伪造,也可能在途中丢失。最终状态应以通过签名验证的 webhook 或支付平台 API 查询结果为准。
webhook 处理示例
下面是一个与具体支付平台无关的结构示例:
function handleWebhook( string $payload, string $signature ): void {
$event = verifyAndParseWebhook( $payload, $signature );
if ( eventAlreadyProcessed( $event->id ) ) {
return;
}
beginTransaction();
try {
markEventAsProcessed( $event->id );
$subscription = normalizeSubscriptionEvent( $event );
$userId = findMediaWikiUserId(
$subscription->provider,
$subscription->customerId,
$subscription->subscriptionId
);
saveSubscriptionStatus(
$userId,
$subscription->status,
$subscription->currentPeriodEnd,
$event->id
);
synchronizePaidGroup( $userId, $subscription );
commitTransaction();
} catch ( Throwable $e ) {
rollbackTransaction();
throw $e;
}
}
签名验证必须使用支付平台提供的官方 SDK 或官方算法。不能只检查请求来源 IP,也不能把 webhook secret 放进代码仓库。
将订阅状态同步到 MediaWiki 用户组
自定义扩展可以通过 MediaWiki 的用户组管理服务维护 paid_member。下面的示例只用于说明结构:
use MediaWiki\MediaWikiServices;
function grantPaidAccess( int $userId ): void {
$services = MediaWikiServices::getInstance();
$user = $services
->getUserFactory()
->newFromId( $userId );
if ( !$user->isRegistered() ) {
throw new RuntimeException( 'MediaWiki user not found' );
}
$services
->getUserGroupManager()
->addUserToGroup( $user, 'paid_member' );
}
function revokePaidAccess( int $userId ): void {
$services = MediaWikiServices::getInstance();
$user = $services
->getUserFactory()
->newFromId( $userId );
if ( !$user->isRegistered() ) {
return;
}
$services
->getUserGroupManager()
->removeUserFromGroup( $user, 'paid_member' );
}
获取 UserGroupManager 的方式和方法签名可能随 MediaWiki 版本变化,应以目标版本的接口为准。在扩展中,最好通过依赖注入取得服务,避免在业务代码的不同位置反复调用 MediaWikiServices::getInstance()。
同步逻辑可以写成:
function synchronizePaidGroup(
int $userId,
Subscription $subscription
): void {
if (
subscriptionAllowsAccess(
$subscription->status,
$subscription->currentPeriodEnd
)
) {
grantPaidAccess( $userId );
} else {
revokePaidAccess( $userId );
}
}
如果会员权限有到期时间,也可以使用用户组过期机制,但不能只做一次性写入。支付失败、退款、撤销付款和管理员手动取消等情况,仍需通过 webhook 或对账任务同步。
必须处理的支付事件
至少要考虑以下事件:
- 首次付款成功
- 自动续费成功
- 自动续费失败
- 订阅取消
- 到期后终止
- 退款
- 撤销付款或争议
- 试用开始与结束
- 套餐变更
- webhook 重复发送
- webhook 乱序到达
权限同步应以“当前订阅对象的最终状态”为准,不要机械地把每个事件转换成一次加组或退组操作。例如,收到付款失败事件后,可以重新查询 subscription。如果订阅仍处于允许访问的宽限期,就不应立即移除权限。
定期对账不可省略
即使 webhook 的实现没有问题,网络故障、服务停机或配置错误仍可能造成事件遗漏。因此需要安排定期任务:
每小时或每天:
1. 查询本地仍具有 paid_member 权限的用户
2. 向支付平台查询对应订阅
3. 比较本地状态与远端状态
4. 修正数据库记录和用户组
5. 记录异常并通知管理员
任务必须支持重复执行并保持幂等。重复处理同一个事件或执行同一次同步,不应创建重复的会员记录,也不应错误地延长订阅。
访问控制的常见漏洞
文件可能绕过页面权限
如果 Web 服务器直接从文件目录提供上传文件,MediaWiki 的页面权限检查可能不会执行。私有附件应通过受控下载入口提供,并检查当前用户的权限。
缩略图、对象存储公开地址、CDN 缓存和历史文件版本也要一并检查。
缓存可能向错误用户返回内容
付费页面不应进入面向匿名用户的公共缓存。反向代理、CDN 和页面缓存需要正确区分认证状态与用户权限,否则已授权用户访问后,缓存可能把同一页面返回给未授权用户。
内容可能通过其他功能泄露
需要测试这些入口:
- MediaWiki API
- 搜索结果和摘要
- 页面历史及旧版本
- 页面差异
- RSS、Atom 等 feed
- 导出功能和数据库 dump
- 模板转嵌
- 分类页和反向链接
- 通知邮件
- 调试日志和缓存
Special:页面- REST 接口
付费页面引用公开模板通常问题不大,但公开页面转嵌付费页面可能造成内容泄露,因此需要明确限制跨权限边界的转嵌。
管理员手动加组会产生双重状态
如果管理员可以手动添加 paid_member,需要先确定这种操作表示什么:
- 永久赠送权限;
- 仍由支付状态控制;
- 或单独的人工授权。
一种较稳妥的设计是使用两个用户组:
paid_member 由支付系统自动维护
complimentary 由管理员手动维护
访问规则允许其中任意一个组,这样自动对账就不会误删管理员赠送的权限。
独立私有 wiki 何时更合适
遇到以下情况时,可以把付费内容部署到独立 wiki:
- 内容有较高商业价值或严格的保密要求;
- 公开内容与付费内容的转嵌关系复杂;
- 大量附件也需要保护;
- 使用 CDN、全文搜索或外部索引服务;
- 无法完整审计所有读取入口;
- 不同会员层级需要完全不同的内容集合。
公开 wiki 和会员 wiki 可以使用统一登录,再由订阅系统决定用户能否进入会员站点。这样会增加部署成本,但安全边界比逐页限制更清楚。
推荐落地方案
对于一般的订阅型 MediaWiki,可以使用下面的组合:
- 将付费页面集中到
Premium:命名空间。 - 使用与当前 MediaWiki 版本兼容的命名空间访问控制扩展。
- 创建
paid_member和complimentary用户组。 - 使用支付服务提供的托管结账和周期订阅功能。
- 编写小型 MediaWiki 扩展,维护支付账号与
user_id的映射。 - 通过签名 webhook 更新订阅表和用户组。
- 使用定时任务对账支付状态。
- 单独保护附件、缓存、API、搜索、导出和转嵌入口。
- 上线前分别以匿名用户、免费用户、付费用户和管理员身份完成全面测试。
需要开发的是“支付状态与 MediaWiki 用户组之间的同步层”,不是重写 MediaWiki。现有扩展可以负责部分访问控制,但支付业务、账号关联和异常对账通常仍需根据所选支付平台编写集成代码。
备注:内容仅供参考。
