PAYATHON 2026

如何为 Chrome extension 构建支付与许可证系统?

支付小周

结论

不要在 Chrome extension 或 Electron 客户端内直接处理收款,也不要把支付平台密钥、许可证生成逻辑或永久解锁凭证放进客户端。

比较稳妥的做法是:

  1. 通过第三方支付服务的托管结账页完成付款。
  2. 用自己的后端接收支付服务发送的 webhook。
  3. 后端根据 webhook 创建或更新用户的 entitlement(功能使用权)。
  4. 扩展或 Electron 应用在用户登录后,通过后端 API 查询授权状态。
  5. 退款、订阅取消、拒付和许可证吊销也通过 webhook 同步。

WordPress 许可证插件并非不能使用,但要充当后端,必须提供稳定的 HTTP API、服务端验证和吊销机制。如果必须模拟 WordPress theme/plugin 的激活流程,长期维护成本通常比搭建一个小型许可证服务更高。

推荐架构

典型流程如下:

Chrome extension / Electron app
        │
        │ 1. 请求创建结账会话
        ▼
Licensing backend
        │
        │ 2. 使用服务端密钥调用支付平台
        ▼
Hosted checkout page
        │
        │ 3. 用户完成支付
        ▼
Payment provider
        │
        │ 4. 发送签名 webhook
        ▼
Licensing backend
        │
        │ 5. 更新 entitlement
        ▼
Extension 查询授权并解锁功能

支付成功页面不能作为授权依据。用户可能伪造跳转地址,网络也可能在付款完成后中断。只有通过签名验证的 webhook,或者经后端主动向支付平台核实的交易,才能改变授权状态。

支付服务应支持托管结账、webhook、退款和订阅管理。可用产品、费率、税务代缴范围以及 Chrome Web Store 政策都可能变化,接入前需要查阅相关平台和商店的最新文档。

先确定许可证模型

开始实现前,先明确具体出售什么。

永久许可证

用户一次性付款,获得某个产品或主要版本的永久使用权:

{
  "product": "extension-pro",
  "status": "active",
  "type": "lifetime"
}

如果以后准备对大版本再次收费,可以增加 majorVersion 或 plan 字段,不要只保存一个难以扩展的布尔值。

订阅许可证

授权取决于订阅状态和当前计费周期:

{
  "product": "extension-pro",
  "status": "active",
  "type": "subscription",
  "currentPeriodEnd": "2026-10-01T00:00:00Z"
}

后端还要处理取消、续费失败、退款和拒付。取消订阅不一定会让授权立即失效,有些业务允许用户继续使用到当前计费周期结束。

设备许可证

如果要限制设备数量,应由后端维护激活记录,例如:

license_id
device_id_hash
activated_at
last_seen_at
revoked_at

设备标识只能辅助限制滥用。浏览器配置可能被删除或复制,硬件指纹也有隐私、稳定性和误判问题。一般更适合使用“用户账号 + 合理的设备数量上限”,同时提供设备解绑功能。

后端数据设计

一个小型许可证服务至少要保存以下信息:

users
  id
  email
  created_at

orders
  id
  provider
  provider_customer_id
  provider_order_id
  status
  product_id
  user_id

entitlements
  id
  user_id
  product_id
  status
  type
  expires_at
  updated_at

webhook_events
  provider
  event_id
  processed_at

webhook_events 用来保证幂等性。支付平台可能重复发送同一个 webhook,系统不能因此重复创建订单或多次延长有效期。

支付平台的 customer_id、subscription_id 和 order_id 应作为外部标识保存,不要用邮箱地址充当唯一交易标识。用户可能更换邮箱,也可能使用与付款邮箱不同的产品账号。

创建托管结账会话

下面是简化后的 Node.js 示例。支付平台相关代码采用抽象接口,因为不同服务使用的参数、签名算法和事件名称并不相同。

import express from "express";

const app = express();

app.post("/api/checkout", requireUser, async (req, res) => {
  const user = req.user;
  const productId = "extension-pro";

  const session = await paymentProvider.createCheckoutSession({
    productId,
    customerReference: user.id,
    customerEmail: user.email,
    successUrl: "https://example.com/payment/success",
    cancelUrl: "https://example.com/payment/cancel",
    metadata: {
      userId: user.id,
      productId
    }
  });

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

paymentProvider 的 API 密钥只能放在后端。扩展端只获取一次性的结账 URL:

async function startCheckout() {
  const response = await fetch("https://api.example.com/api/checkout", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${await getAccessToken()}`,
      "Content-Type": "application/json"
    }
  });

  if (!response.ok) {
    throw new Error(`Checkout request failed: ${response.status}`);
  }

  const { checkoutUrl } = await response.json();

  await chrome.tabs.create({
    url: checkoutUrl
  });
}

使用 Manifest V3 时,需要在 host_permissions 中声明后端域名:

{
  "manifest_version": 3,
  "name": "Example Extension",
  "version": "1.0.0",
  "permissions": ["storage"],
  "host_permissions": [
    "https://api.example.com/*"
  ]
}

不要把支付页面嵌入扩展弹窗。托管结账页通常涉及重定向、身份验证、银行卡验证和第三方脚本,放在普通浏览器标签页中打开更加可靠。

使用 webhook 发放授权

后端需要保留 webhook 请求的原始 body,因为许多支付平台会用原始字节计算签名。不能先解析 JSON,再对重新序列化的内容验签。

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

    try {
      event = paymentProvider.verifyWebhook({
        rawBody: req.body,
        signature: req.headers["payment-signature"],
        secret: process.env.PAYMENT_WEBHOOK_SECRET
      });
    } catch {
      return res.status(400).send("Invalid webhook signature");
    }

    const alreadyProcessed = await webhookEventExists(event.id);

    if (alreadyProcessed) {
      return res.sendStatus(200);
    }

    await database.transaction(async (tx) => {
      if (event.type === "payment.completed") {
        const userId = event.metadata.userId;
        const productId = event.metadata.productId;

        await tx.entitlements.upsert({
          userId,
          productId,
          status: "active",
          type: "lifetime",
          expiresAt: null
        });
      }

      if (
        event.type === "payment.refunded" ||
        event.type === "subscription.terminated"
      ) {
        await tx.entitlements.update({
          userId: event.metadata.userId,
          productId: event.metadata.productId,
          status: "revoked"
        });
      }

      await tx.webhookEvents.insert({
        provider: "configured-provider",
        eventId: event.id,
        processedAt: new Date()
      });
    });

    res.sendStatus(200);
  }
);

示例中的事件名称只是架构占位符,不能直接套用到具体支付平台。实际实现必须使用该平台定义的事件类型、签名请求头和验签 SDK。

还要检查 webhook 中的商品、金额、币种和交易状态,不能仅凭 metadata 中存在 userId 就发放授权。

扩展如何验证授权

最简单的方式是让用户登录,再查询自己的 entitlement:

async function fetchEntitlement() {
  const token = await getAccessToken();

  const response = await fetch(
    "https://api.example.com/api/me/entitlements/extension-pro",
    {
      headers: {
        Authorization: `Bearer ${token}`
      }
    }
  );

  if (!response.ok) {
    throw new Error(`Entitlement request failed: ${response.status}`);
  }

  return response.json();
}

后端只返回必要的信息:

{
  "product": "extension-pro",
  "status": "active",
  "expiresAt": null,
  "checkedAt": "2026-09-02T08:30:00Z"
}

扩展随后缓存查询结果:

async function refreshLicense() {
  const entitlement = await fetchEntitlement();

  await chrome.storage.local.set({
    entitlement,
    entitlementCachedAt: Date.now()
  });

  return entitlement.status === "active";
}

缓存可以改善离线体验,但不能充当永久授权记录。例如,可以让已成功验证的用户离线使用若干天,超过宽限期后再要求联网验证。宽限期应根据产品情况决定,没有适用于所有应用的固定时长。

是否需要许可证密钥

如果用户始终通过账号登录,通常不需要传统许可证密钥。账号与 entitlement 的关系已经足够清楚,用户也不用复制一长串字符。

如果业务确实需要许可证密钥,例如不要求用户注册账号、支持企业批量采购或线下分发,可以生成高熵随机值:

import crypto from "node:crypto";

function createLicenseKey() {
  return crypto.randomBytes(24).toString("base64url");
}

数据库不应明文保存完整密钥,可以像处理密码一样只保存摘要:

function hashLicenseKey(licenseKey) {
  return crypto
    .createHash("sha256")
    .update(licenseKey, "utf8")
    .digest("hex");
}

许可证密钥本身应有足够高的随机性,因此可以使用 SHA-256 进行数据库查找并降低泄露风险。如果许可证由用户选择,随机性很低,则应使用专门的密码哈希方案,而不是普通 SHA-256。

激活请求可以写成:

POST /api/licenses/activate
Authorization: Bearer <user-access-token>
Content-Type: application/json

{
  "licenseKey": "generated-license-key",
  "deviceId": "installation-identifier"
}

后端检查许可证状态、产品类型和设备上限,再返回授权结果。客户端不能只检查密钥格式,因为攻击者可以直接删除这段检查代码。

身份认证的选择

Chrome extension 常用的认证方式有两种:

  • 用户在网站完成登录,再通过一次性授权码把会话交给扩展。
  • 使用 OAuth/OIDC,并采用适合公共客户端的 Authorization Code Flow with PKCE。

扩展和 Electron 应用都属于 public client,无法安全保存 OAuth client secret。即使对 secret 进行打包、压缩或混淆,仍然可以被提取。

一次性授权码应符合以下要求:

  • 有效时间短;
  • 只能使用一次;
  • 与目标用户和客户端绑定;
  • 兑换后立即失效;
  • 不在 URL 中直接传递长期 access token。

Access token 也应设置较短的有效期。是否在扩展中保存 refresh token,要结合认证服务能力和风险模型决定。一旦保存,就需要考虑恶意扩展、浏览器同步、调试接口和本机恶意软件造成的泄露风险。

WordPress 插件是否适合

可以通过以下问题判断:

  • 是否提供正式、稳定的 REST API?
  • 能否在服务端创建、激活、停用和查询许可证?
  • 能否处理退款、续费失败和订阅取消?
  • 是否支持 webhook 或可靠的支付状态同步?
  • 是否具备幂等处理、访问控制、速率限制和审计日志?
  • API 是否支持自定义产品,而非强依赖 WordPress plugin/theme 更新机制?
  • 插件升级后,扩展使用的接口是否有兼容性承诺?

如果大部分答案都是“是”,它可以作为许可证管理后台,Chrome extension 则是另一个 API client。

如果激活时必须伪造 WordPress 站点 URL、theme slug 或 plugin version,说明数据模型与实际业务不匹配。短期内可能可以运行,但退款处理、设备解绑和账号迁移会越来越难维护。

一种折中方案是在插件前增加自己的后端适配层:

Extension → 自有 API → WordPress licensing plugin

不要让扩展直接调用 WordPress 管理接口。适配层可以隐藏插件凭据、统一认证并限制请求频率。以后更换许可证系统时,也不必更新所有客户端。

Electron 应用的差异

Electron 可以通过系统浏览器打开结账页:

import { shell } from "electron";

await shell.openExternal(checkoutUrl);

付款后,可以通过 HTTPS 页面提醒用户返回应用,也可以注册自定义协议或应用链接实现回跳。回跳只能改善使用体验,不能证明付款成功。应用仍需向后端查询 entitlement。

Electron 同样无法安全保存永久密钥。用户可以访问应用程序文件和运行环境,也能读取打包资源、修改 JavaScript 或拦截本地函数。代码签名、完整性检查和混淆可以增加修改成本,但不能取代服务端授权。

如果核心功能完全离线运行,许可证校验最终都有可能被绕过。这种情况下,更现实的目标是减少普通盗用,而不是追求客户端绝对不可破解。

容易踩到的坑

把解锁状态只存到本地

下面的逻辑没有实际安全性:

await chrome.storage.local.set({ isPro: true });

用户既可以修改本地数据,也可以修改扩展代码。真正的 entitlement 必须保存在服务器中,本地状态只能作为缓存。

在扩展中保存支付密钥

Manifest、JavaScript bundle、source map 和网络请求都能被检查。任何发送到客户端的 secret 都应视为公开信息。

只处理付款成功

完整的系统还要考虑:

  • 重复 webhook;
  • webhook 延迟或乱序;
  • 退款与部分退款;
  • 拒付;
  • 订阅取消和续费失败;
  • 用户更换邮箱;
  • 设备丢失与解绑;
  • 支付成功但客户端没有收到回跳;
  • 支付平台暂时不可用;
  • 删除账号和隐私合规要求。

每次操作都在线验证

频繁验证会增加延迟,后端一旦出现短暂故障,也会直接影响付费用户。通常应该设置短期缓存和离线宽限期,并在扩展启动、缓存过期或执行重要操作前刷新授权。

依靠签名 JWT 实现永久离线授权

后端签名的许可证可以防止用户自行生成有效许可证,却无法阻止用户修改客户端并跳过验签代码。采用离线许可证时,应明确它只能增加破解难度,无法建立不可绕过的安全边界。

一个可落地的最小方案

独立开发者的第一版不必设计得太重:

  1. 选择支持 hosted checkout 和签名 webhook 的支付服务。
  2. 建立用户登录系统。
  3. 创建 orders、entitlements 和 webhook_events 三组数据。
  4. 后端提供创建结账会话、查询 entitlement 和处理 webhook 的接口。
  5. Chrome extension 在普通标签页中打开结账页。
  6. 付款后由 webhook 发放权限,扩展通过轮询或手动刷新获取授权状态。
  7. 在本地缓存最近一次有效结果,并设置合理的离线宽限期。
  8. 在管理后台提供查询、吊销、恢复和设备解绑功能。
  9. 上线前测试重复通知、退款、取消订阅、断网和账号迁移。

这套设计不依赖 CWS Payments,也适用于 Electron。以后更换支付服务时,只需替换后端的支付适配层,扩展使用的 entitlement API 可以保持不变。

备注:内容仅供参考。