PAYATHON 2026

Chrome Web Store payments 停用后如何迁移

支付小周

结论

Chrome Web Store Payments 停用后,Licensing API、google.payments.inapp 和 createdTime 都不能再用于收费或管理试用。迁移时应按以下方式处理:

  1. 在旧接口仍可访问的迁移期内,引导用户通过 Google OAuth 2.0 授权。
  2. 由开发者自己的服务器查询并保存用户原有的许可证或订阅状态。
  3. 后续使用自己的账号系统、数据库和第三方支付服务管理订阅。
  4. 对于新用户,由服务器记录试用开始时间,不再尝试从 Chrome 获取可靠的扩展安装时间。

官方提到的 “license migration app” 是一次性迁移工具,并非 Licensing API 的长期替代方案。Chrome Web Store Payments 已经停止服务,因此下文涉及旧 Chrome Web Store API 的代码仅用于说明当时的迁移流程。相关端点现在是否仍可调用,要以实际接口响应为准,不应据此设计新系统。

许可证迁移流程

迁移应用通常包括两部分:

  • 扩展或网站前端,用于引导用户登录和授权。
  • 开发者服务器,用于交换 OAuth token、查询旧许可证、验证结果并写入自己的数据库。

建议使用 OAuth 2.0 Authorization Code Flow。不要在前端 JavaScript 中保存 client_secret,也不要相信前端直接提交的许可证状态。

基本流程如下:

用户打开迁移页面
    ↓
跳转到 Google OAuth 授权页面
    ↓
用户同意授权
    ↓
Google 将 authorization code 返回 PHP 回调地址
    ↓
PHP 用 code 换取 access_token
    ↓
PHP 调用旧 Chrome Web Store userlicenses API
    ↓
PHP 校验 itemId、result、accessLevel 等字段
    ↓
将迁移后的权益写入自己的数据库

1. 创建 OAuth 客户端

在 Google Cloud Console 中创建 Web application 类型的 OAuth 2.0 Client ID,并配置准确的回调地址,例如:

https://example.com/license/callback.php

该地址必须与 OAuth 客户端中的 Authorized redirect URI 完全一致,包括协议、域名、路径和尾部斜杠。

旧 Chrome Web Store API 通常使用以下只读 scope:

https://www.googleapis.com/auth/chromewebstore.readonly

这是历史接口使用的 scope。新项目不能假定它或旧许可证端点现在仍然可用。

2. 使用 JavaScript 发起授权

前端只负责获取授权地址并跳转。实际项目应由服务器生成和保存 state。下面的示例省略了页面框架,只展示相关流程:

async function migrateLicense() {
  const response = await fetch("/license/create-auth-request.php", {
    credentials: "same-origin"
  });

  if (!response.ok) {
    throw new Error("Unable to create OAuth request");
  }

  const { authorizationUrl } = await response.json();
  window.location.assign(authorizationUrl);
}

create-auth-request.php 负责生成随机 state 并构造授权地址:

<?php
session_start();

$clientId = getenv('GOOGLE_OAUTH_CLIENT_ID');
$redirectUri = 'https://example.com/license/callback.php';

$state = bin2hex(random_bytes(32));
$_SESSION['oauth_state'] = $state;

$params = [
    'client_id' => $clientId,
    'redirect_uri' => $redirectUri,
    'response_type' => 'code',
    'scope' => 'https://www.googleapis.com/auth/chromewebstore.readonly',
    'state' => $state,
    'access_type' => 'online',
    'include_granted_scopes' => 'true'
];

header('Content-Type: application/json; charset=utf-8');

echo json_encode([
    'authorizationUrl' =>
        'https://accounts.google.com/o/oauth2/v2/auth?' .
        http_build_query($params, '', '&', PHP_QUERY_RFC3986)
]);

生产环境中的 Client ID 和 Client Secret 应保存在服务器环境变量或密钥管理服务中。

3. 在 PHP 回调中交换 token

Google 返回 code 后,服务器先验证 state,再用 code 换取 access token:

<?php
session_start();

if (
    !isset($_GET['state'], $_SESSION['oauth_state']) ||
    !hash_equals($_SESSION['oauth_state'], $_GET['state'])
) {
    http_response_code(400);
    exit('Invalid OAuth state');
}

unset($_SESSION['oauth_state']);

if (!isset($_GET['code'])) {
    http_response_code(400);
    exit('Missing authorization code');
}

$tokenRequest = [
    'client_id' => getenv('GOOGLE_OAUTH_CLIENT_ID'),
    'client_secret' => getenv('GOOGLE_OAUTH_CLIENT_SECRET'),
    'code' => $_GET['code'],
    'grant_type' => 'authorization_code',
    'redirect_uri' => 'https://example.com/license/callback.php'
];

$ch = curl_init('https://oauth2.googleapis.com/token');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query($tokenRequest),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/x-www-form-urlencoded'
    ]
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

if ($body === false) {
    throw new RuntimeException(curl_error($ch));
}

curl_close($ch);

$token = json_decode($body, true, 512, JSON_THROW_ON_ERROR);

if ($status !== 200 || empty($token['access_token'])) {
    http_response_code(502);
    exit('OAuth token exchange failed');
}

$accessToken = $token['access_token'];

4. 查询旧许可证

历史上的用户许可证端点格式如下:

GET https://www.googleapis.com/chromewebstore/v1.1/userlicenses/{itemId}

其中,{itemId} 是扩展的 Chrome Web Store item ID:

<?php
$itemId = 'YOUR_EXTENSION_ITEM_ID';

$url = 'https://www.googleapis.com/chromewebstore/v1.1/userlicenses/' .
       rawurlencode($itemId);

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . $accessToken,
        'Accept: application/json'
    ]
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

if ($body === false) {
    throw new RuntimeException(curl_error($ch));
}

curl_close($ch);

$license = json_decode($body, true, 512, JSON_THROW_ON_ERROR);

if ($status !== 200) {
    http_response_code(502);
    exit('Unable to retrieve the legacy license');
}

历史响应中可能包含 itemId、result、accessLevel、createdTime 和 maxAgeSecs 等字段。具体有哪些字段以及各字段的取值,应以当时的接口响应为准。

保存权益前,至少需要执行以下检查:

<?php
$valid =
    isset($license['itemId'], $license['result']) &&
    hash_equals($itemId, (string) $license['itemId']) &&
    $license['result'] === 'YES';

if (!$valid) {
    http_response_code(403);
    exit('No valid legacy license');
}

$accessLevel = $license['accessLevel'] ?? null;
$createdTime = $license['createdTime'] ?? null;

// 将迁移结果写入自己的用户表或 entitlement 表。
// 不要只在浏览器中保存迁移状态。

OAuth 身份还需要与站内账号可靠关联。浏览器传来的邮箱地址、扩展 ID 或一段 JSON,都不足以证明用户拥有该许可证。

如果整个迁移只执行一次,通常不必保存 refresh token。查询完成后,可以撤销或丢弃 access token,并在数据库中记录迁移时间、旧权益类型和迁移依据。

“in-app free trials requests” 指什么

这里的 “in-app” 指 Chrome Web Store Payments 的应用内购买体系,不是普通的扩展安装,也不是扩展自行实现的试用机制。

具体来说,它指通过旧支付能力请求或创建免费试用权益,例如由 google.payments.inapp 相关流程触发,并由 Chrome Web Store Payments 管理的试用。服务停用后,这类请求会返回错误,扩展无法再通过该支付系统为新用户创建免费试用。

以下行为不属于这类请求:

  • 触发 chrome.runtime.onInstalled 事件;
  • 将首次运行时间写入 chrome.storage;
  • 由开发者自己的服务器创建试用记录;
  • 使用第三方支付平台提供的 trial period。

如果扩展过去依靠 Chrome Web Store Payments 自动管理免费试用,就需要把试用创建、到期判断和续费状态全部迁移到自己的后端。

停用后如何确定试用开始时间

Chrome 扩展 API 没有提供可长期依赖的准确安装时间查询接口。chrome.runtime.onInstalled 会在安装时运行,但它只是事件通知,不能用于日后查询安装历史。

Licensing API 中的 createdTime 表示许可证记录的创建时间。在某些旧流程中,它可能接近安装时间或试用开始时间,但不能普遍视为扩展的精确安装时间。

推荐方案:由服务器记录首次激活时间

用户首次登录或首次连接服务器时,由服务器创建试用记录:

CREATE TABLE entitlements (
    user_id BIGINT NOT NULL,
    product_id VARCHAR(128) NOT NULL,
    trial_started_at TIMESTAMP NULL,
    trial_ends_at TIMESTAMP NULL,
    subscription_status VARCHAR(32) NOT NULL,
    PRIMARY KEY (user_id, product_id)
);

服务器需要以原子方式完成首次创建,防止重复请求重置试用:

<?php
$trialDays = 14;

$trialStartedAt = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$trialEndsAt = $trialStartedAt->modify("+{$trialDays} days");

// 使用 INSERT ... ON CONFLICT DO NOTHING、INSERT IGNORE
// 或数据库事务,确保已有记录不会被覆盖。

试用是否有效,应根据服务器时间判断:

<?php
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$trialEndsAt = new DateTimeImmutable($row['trial_ends_at']);

$isTrialActive = $now < $trialEndsAt;

不要把浏览器传来的当前时间作为授权依据,否则用户修改系统时间就能延长试用。

本地记录只能作为辅助信息

扩展可以在首次安装时记录本地时间,用于界面展示或离线提示:

chrome.runtime.onInstalled.addListener(async ({ reason }) => {
  if (reason !== "install") {
    return;
  }

  const { firstInstalledAt } =
    await chrome.storage.local.get("firstInstalledAt");

  if (!firstInstalledAt) {
    await chrome.storage.local.set({
      firstInstalledAt: new Date().toISOString()
    });
  }
});

但这个值不能单独用于控制付费权益,原因包括:

  • 卸载后重新安装可能导致本地数据丢失;
  • 用户可以清除扩展存储;
  • 本地状态可能被修改;
  • 同一用户可能在多台设备上安装扩展;
  • onInstalled 也可能在扩展更新或 Chrome 更新时触发,因此必须检查 reason。

更稳妥的做法是让本地时间只用于改善使用体验,最终授权由服务器根据账号和服务器端记录决定。

已有用户和新用户应分别处理

对于在迁移期内已经拥有许可证的用户:

  • 读取并保存旧许可证状态;
  • 如果需要保留原试用时间或购买时间,同时保存旧 createdTime;
  • 明确记录该时间来自旧许可证,不要将其重新定义为精确安装时间;
  • 分别确定旧永久许可证、订阅和免费试用对应的新权益。

对于迁移完成后的新用户:

  • 在首次登录或首次激活时,由服务器创建权益;
  • 由服务器记录 trial_started_at 和 trial_ends_at;
  • 通过自己的支付服务接收订阅状态和 webhook;
  • 扩展每次启动时,从服务器获取短期有效的权益状态;
  • 不再调用 Chrome Web Store Payments 或 Licensing API。

注意事项

OAuth access token 只能证明用户授权应用访问特定 Google API,并不等同于站内登录。迁移过程中仍需建立明确的账号绑定关系。

迁移接口还要防止同一份旧许可证被绑定到多个站内账号。可以为旧许可证身份设置唯一约束,并保留审计记录,以便处理人工申诉。

不要把 client_secret、支付密钥或 webhook secret 打包进扩展。扩展代码对用户可见,所有保密凭据都应留在服务器。

如果没有在旧 API 可用期间导出许可证,现在通常无法仅凭 Chrome 扩展恢复历史购买记录。此时只能依靠已有订单、收据、邮件、数据库备份或人工核验,不能根据安装时间推断用户曾经购买过产品。

备注:内容仅供参考。