PAYATHON 2026

如何在 MediaWiki 中实现付费订阅和 payment gateway?

支付阿杰

结论

可以实现,但不建议重写 MediaWiki,也不要只在数据库中增加一个“已付款”字段,再直接修改核心代码。

更稳妥的做法是:

  1. 用 MediaWiki 用户组表示会员权限,例如 paid_member。
  2. 用访问控制扩展限制特定命名空间,或者将付费内容放到独立的私有 wiki。
  3. 通过支付平台的 subscription API 和 webhook 获取订阅状态。
  4. 编写一个小型 MediaWiki 扩展或独立同步服务,根据支付状态添加或移除用户组。
  5. 验证 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 的支付服务,让支付平台处理支付工具和续费。

典型流程如下:

  1. 用户登录 MediaWiki。
  2. 用户点击“订阅”。
  3. 后端创建支付平台的订阅结账会话。
  4. 用户在支付平台托管的页面完成付款。
  5. 支付平台向服务器发送 webhook。
  6. 服务器验证 webhook 签名。
  7. 更新本地订阅记录。
  8. 将用户加入或移出 paid_member。
  9. 定时任务再次查询支付平台,修复遗漏或乱序的事件。

不要根据“付款成功跳转页”开通权限。跳转请求来自浏览器,可能被重复访问或伪造,也可能在途中丢失。最终状态应以通过签名验证的 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,可以使用下面的组合:

  1. 将付费页面集中到 Premium: 命名空间。
  2. 使用与当前 MediaWiki 版本兼容的命名空间访问控制扩展。
  3. 创建 paid_member 和 complimentary 用户组。
  4. 使用支付服务提供的托管结账和周期订阅功能。
  5. 编写小型 MediaWiki 扩展,维护支付账号与 user_id 的映射。
  6. 通过签名 webhook 更新订阅表和用户组。
  7. 使用定时任务对账支付状态。
  8. 单独保护附件、缓存、API、搜索、导出和转嵌入口。
  9. 上线前分别以匿名用户、免费用户、付费用户和管理员身份完成全面测试。

需要开发的是“支付状态与 MediaWiki 用户组之间的同步层”,不是重写 MediaWiki。现有扩展可以负责部分访问控制,但支付业务、账号关联和异常对账通常仍需根据所选支付平台编写集成代码。

备注:内容仅供参考。