PAYATHON 2026

如何安全限制付费用户访问私有内容?

支付阿杰

结论

不要在付款成功跳转页里直接把用户标记为”已付款”,也不要相信浏览器传回的订单状态。正确的处理流程是:

  1. 用户登录后,服务端创建订单和支付会话。
  2. 支付平台通过服务端回调(Webhook)通知付款结果。
  3. 服务端验证回调签名,并向支付平台核实事件或订单状态。
  4. 服务端在数据库中记录订单和访问权益。
  5. 用户访问私有内容时,服务端检查其权益是否有效。

Google Checkout 已经停止服务,不应再用于新项目。开发时应选择仍在维护,并提供服务端 API 和签名 Webhook 的支付平台。具体使用哪一家,需要结合业务所在地、币种和合规要求决定。

WordPress 和 Drupal 都能承载付费内容,但应搭配仍在积极维护的会员、订阅或电商模块。无论选择哪种 CMS,安全校验都必须由服务端完成,不能只靠隐藏链接或前端脚本。

不要只保存一个 paid 布尔值

在用户表中增加一个 paid = true 字段,看起来简单,但很快就会遇到这些问题:

  • 同一名用户可能有多笔订单。
  • 付款可能仍在处理中,尚未最终成功。
  • 订单可能被退款、撤销或拒付。
  • 权益可能有有效期。
  • 不同商品可能对应不同内容。
  • Webhook 可能重复发送,也可能乱序到达。

更合适的做法是分别保存订单、支付事件和访问权益。例如:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    provider VARCHAR(50) NOT NULL,
    provider_order_id VARCHAR(255),
    amount_minor BIGINT NOT NULL,
    currency CHAR(3) NOT NULL,
    status VARCHAR(30) NOT NULL,
    created_at TIMESTAMP NOT NULL,
    paid_at TIMESTAMP NULL,
    UNIQUE (provider, provider_order_id)
);

CREATE TABLE payment_events (
    provider VARCHAR(50) NOT NULL,
    provider_event_id VARCHAR(255) NOT NULL,
    received_at TIMESTAMP NOT NULL,
    PRIMARY KEY (provider, provider_event_id)
);

CREATE TABLE entitlements (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    resource_key VARCHAR(255) NOT NULL,
    order_id BIGINT NOT NULL,
    status VARCHAR(30) NOT NULL,
    valid_from TIMESTAMP NOT NULL,
    valid_until TIMESTAMP NULL,
    UNIQUE (user_id, resource_key, order_id)
);

金额应以最小货币单位的整数保存,例如人民币的分或美元的美分,不要使用浮点数。

安全的支付流程

1. 先要求用户登录

创建支付订单前,服务端需要确定当前用户的身份。然后生成内部订单,把用户、商品、金额和币种写入数据库。

不要接受浏览器提交的最终金额。浏览器只需要提交商品标识,实际价格应由服务端根据商品数据计算。

app.post("/api/checkout", requireLogin, async (req, res) => {
  const product = await db.products.findById(req.body.productId);

  if (!product || !product.active) {
    return res.status(404).json({ error: "Product not found" });
  }

  const order = await db.orders.create({
    userId: req.user.id,
    productId: product.id,
    amountMinor: product.priceMinor,
    currency: product.currency,
    provider: "payment_provider",
    status: "pending"
  });

  const session = await paymentProvider.createCheckoutSession({
    amountMinor: order.amountMinor,
    currency: order.currency,
    reference: String(order.id),
    successUrl: "https://example.com/payment/return",
    cancelUrl: "https://example.com/payment/cancel"
  });

  await db.orders.setProviderOrderId(order.id, session.id);

  res.json({ checkoutUrl: session.url });
});

paymentProvider.createCheckoutSession() 只是示意接口。真实参数、签名方式和返回字段必须以所选支付平台的当前官方文档为准。

2. 通过 Webhook 确认付款结果

用户付款后,支付平台通常会向商户配置的 HTTPS 地址发送事件。服务端应以这类通知作为更新订单状态的主要依据。

付款成功后的浏览器跳转不能充当付款凭证。用户可以伪造请求、修改参数,也可以绕过支付页面,直接访问回调地址。

3. 验证 Webhook 的真实性

处理 Webhook 时,至少需要完成这些检查:

  • 使用原始 HTTP 请求体验证签名。
  • 检查签名时间戳,减少重放风险。
  • 校验事件类型。
  • 校验支付平台订单号和内部订单号。
  • 核对金额、币种和商户账户。
  • 确认当前支付状态允许交付。
  • 使用事件 ID 做幂等处理。
  • 必要时调用支付平台的服务端 API,再查询一次订单。

下面是一段与具体平台无关的结构示例:

app.post(
  "/webhooks/payment",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;

    try {
      event = paymentProvider.verifyWebhook({
        rawBody: req.body,
        signature: req.get("Payment-Signature")
      });
    } catch {
      return res.status(400).send("Invalid signature");
    }

    try {
      await db.transaction(async (tx) => {
        const alreadyProcessed = await tx.paymentEvents.exists(
          "payment_provider",
          event.id
        );

        if (alreadyProcessed) {
          return;
        }

        if (event.type === "payment.succeeded") {
          const payment = event.data;
          const order = await tx.orders.lockById(payment.reference);

          if (!order) {
            throw new Error("Unknown order");
          }

          if (
            payment.providerOrderId !== order.providerOrderId ||
            payment.amountMinor !== order.amountMinor ||
            payment.currency !== order.currency
          ) {
            throw new Error("Payment details do not match");
          }

          await tx.orders.markPaid(order.id, payment.paidAt);

          await tx.entitlements.grant({
            userId: order.userId,
            resourceKey: `product:${order.productId}`,
            orderId: order.id,
            status: "active",
            validFrom: payment.paidAt,
            validUntil: null
          });
        }

        await tx.paymentEvents.insert({
          provider: "payment_provider",
          providerEventId: event.id,
          receivedAt: new Date()
        });
      });

      return res.sendStatus(200);
    } catch (error) {
      console.error("Webhook processing failed", error);
      return res.sendStatus(500);
    }
  }
);

签名验证通常要求使用尚未经过 JSON 解析的原始请求体。如果 Web 框架提前转换了请求体,签名校验可能会失败。中间件的具体顺序应按照支付平台 SDK 的要求配置。

如何保护私有页面

权益检查必须在服务端完成。用户每次请求私有内容时,服务端都要根据当前登录用户查询有效权益:

async function requireEntitlement(resourceKey) {
  return async function (req, res, next) {
    if (!req.user) {
      return res.status(401).send("Login required");
    }

    const allowed = await db.entitlements.hasActiveEntitlement({
      userId: req.user.id,
      resourceKey,
      now: new Date()
    });

    if (!allowed) {
      return res.status(403).send("Payment required");
    }

    next();
  };
}

app.get(
  "/private/course-1",
  requireLogin,
  await requireEntitlement("product:course-1"),
  async (req, res) => {
    res.render("private-course-1");
  }
);

实际项目通常不能在注册路由时使用 await requireEntitlement(...),除非这个工厂函数确实返回 Promise。更常见的写法是同步返回一个异步中间件:

function requireEntitlement(resourceKey) {
  return async function (req, res, next) {
    if (!req.user) {
      return res.status(401).send("Login required");
    }

    const allowed = await db.entitlements.hasActiveEntitlement({
      userId: req.user.id,
      resourceKey,
      now: new Date()
    });

    if (!allowed) {
      return res.status(403).send("Payment required");
    }

    next();
  };
}

app.get(
  "/private/course-1",
  requireLogin,
  requireEntitlement("product:course-1"),
  showPrivateCourse
);

如果私有内容是 PDF、视频或压缩包,不要把文件直接放进可公开访问的静态目录。可以采用以下方式:

  • 应用程序完成鉴权后,再读取并返回文件。
  • 把文件存入私有对象存储,鉴权后生成短时有效的签名 URL。
  • 使用支持鉴权的 CDN,并把链接有效期设置得较短。

签名 URL 可以减少链接长期传播的风险,但无法阻止已经取得内容的用户复制、录屏或重新发布。

退款、拒付和订阅也要处理

系统不能只处理付款成功事件。根据业务模式,还需要考虑:

  • payment.succeeded:激活权益。
  • payment.failed:保留或关闭待付款订单。
  • refund.succeeded:按照退款规则撤销权益。
  • chargeback 或争议事件:暂停权益,交由人工复核。
  • subscription.renewed:延长有效期。
  • subscription.canceled:决定立即终止权益,还是允许用户使用到当前周期结束。

不同支付平台使用的事件名称并不相同,不能直接照搬这些示例。应按照平台文档,明确建立”支付状态、订单状态、权益状态”之间的映射。

Webhook 可能延迟、重复或乱序到达。处理逻辑必须具备幂等性,并尽量根据支付对象的最终状态更新记录,不能假定事件只会按顺序到达一次。

WordPress 和 Drupal 是否合适

WordPress 和 Drupal 都能用于付费内容,但适合的场景有所不同。

WordPress 更适合内容发布、课程和会员文章等常见业务,搭建速度通常较快。可以使用成熟的会员或电商扩展管理订单、订阅、角色及受限内容。

Drupal 的权限、角色和内容实体模型更灵活,适合权限规则复杂、内容类型较多或需要深度定制的系统。不过,它的开发和维护成本通常也更高。

选择插件或模块时,不能只看它是否提供”购买”按钮,还要确认它是否支持:

  • 服务端 Webhook 验签。
  • 重复事件的幂等处理。
  • 退款和订阅状态同步。
  • 根据商品授予具体内容的访问权限。
  • 后台对账和异常订单处理。
  • 持续维护及安全更新。
  • 与当前 CMS、PHP 和支付平台 API 兼容。

CMS 中普通的”订阅者”角色不一定等同于付费权益。如果所有付费用户获得的权限完全相同,而且永久有效,可以使用角色模型。如果存在多个商品、套餐或到期时间,则应单独维护会员资格或权益记录。

需要特别避免的做法

  • 根据 success_url 的访问结果标记付款成功。
  • 相信浏览器提交的金额、商品名称或支付状态。
  • 只用 CSS 或 JavaScript 隐藏私有内容。
  • 只隐藏页面链接,却允许用户直接访问页面或文件地址。
  • 收到 Webhook 后不验证签名。
  • 不校验金额、币种、订单号和商户账户。
  • 让同一个 Webhook 重复增加权益或延长有效期。
  • 把支付平台密钥放进前端代码或公开仓库。
  • 在日志中完整记录银行卡信息、密钥或敏感个人资料。
  • 假定付款成功后不会再发生退款或拒付。

支付平台应作为付款事实的来源,本地订单负责保存业务记录,entitlements 则用于控制访问权限。将三者分开后,付款确认、权限授予、退款撤权和审计的职责会更清晰,也能减少伪造或重复回调导致私有内容泄露的风险。

备注:内容仅供参考。