Chrome Web Store payments 停用后如何迁移
结论
Chrome Web Store Payments 停用后,Licensing API、google.payments.inapp 和 createdTime 都不能再用于收费或管理试用。迁移时应按以下方式处理:
- 在旧接口仍可访问的迁移期内,引导用户通过 Google OAuth 2.0 授权。
- 由开发者自己的服务器查询并保存用户原有的许可证或订阅状态。
- 后续使用自己的账号系统、数据库和第三方支付服务管理订阅。
- 对于新用户,由服务器记录试用开始时间,不再尝试从 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 扩展恢复历史购买记录。此时只能依靠已有订单、收据、邮件、数据库备份或人工核验,不能根据安装时间推断用户曾经购买过产品。
备注:内容仅供参考。
