Google/Apple billing APIs 如何识别购买者账号?
结论
Apple 和 Google 的 Billing API 不会向开发者提供购买者的 AppleID、Google 账号或设备 UUID,因此不能依赖这些标识判断订阅属于哪个 app 账号。
合适的做法是:
- 发起购买时,将当前 app 账号的不可逆标识传给商店;
- 由服务端验证 Apple transaction 或 Google purchase token;
- 将商店侧稳定的订阅标识与 app 内部账号唯一绑定;
- 恢复购买时读取已有绑定,不能将订阅重新绑定给当前登录账号。
“恢复购买”只能证明当前设备上的商店账号拥有这笔交易,不能证明当前登录的 app 账号就是最初购买者。
为什么不能直接识别 AppleID 或 Google ID
应用商店会隔离商店账号与开发者自己的账号系统。正常情况下,应用无法获得:
- AppleID;
- Google 账号;
- 能跨安装稳定追踪用户的设备 UUID;
- 可供服务端关联真实商店身份的邮箱等信息。
即使应用获得了某种设备标识,也无法处理多设备、换机和家庭共享等情况。真正需要绑定的是”订阅交易”与”app 账号”,不是”设备”与”用户”。
服务端应该保存什么
可以建立一张订阅绑定表,至少保存:
platform
app_user_id
store_subscription_key
product_id
status
expires_at
created_at
updated_at
各字段的用途如下:
- Apple:通常使用经服务端验证的
originalTransactionId标识一条自动续订链; - Google Play:使用经验证的
purchaseToken作为订单凭证,并处理换购、升级或重新订阅时的linkedPurchaseToken; app_user_id:应用自己的内部用户 ID;store_subscription_key:需要设置唯一约束,防止同一订阅因并发请求绑定到两个账号。
例如:
CREATE UNIQUE INDEX uq_subscription_binding
ON subscription_bindings(platform, store_subscription_key);
防止重复绑定要依靠服务端的唯一约束和数据库事务,不能依赖客户端判断。
Apple 的处理方式
发起购买时传入 appAccountToken
使用 StoreKit 2 时,可以为当前 app 账号生成一个 UUID,并在购买时通过 appAccountToken 传入:
let result = try await product.purchase(
options: [.appAccountToken(accountToken)]
)
这里的 accountToken 应满足这些条件:
- 由服务端生成或确定;
- 同一个 app 账号始终使用相同的值;
- 不直接包含用户名、邮箱、手机号等个人信息;
- 服务端保存
accountToken -> app_user_id映射。
购买完成后,Apple 的交易数据以及 App Store Server API 的返回结果中可能包含这个 appAccountToken。服务端验证交易时,需要检查:
bundleId是否属于自己的应用;productId是否正确;- 交易签名是否有效;
appAccountToken是否属于当前 app 账号;originalTransactionId是否已绑定其他 app 账号;- 订阅当前是否处于可以授予权益的状态。
appAccountToken 只用于辅助关联账号,不能代替交易签名验证。
用 originalTransactionId 固定订阅归属
自动续订生成的新交易会有不同的 transaction ID,但通常可以通过 originalTransactionId 归入同一条续订链。首次验证成功时,应以原子操作完成绑定:
async function bindAppleSubscription({
currentUserId,
signedTransaction
}) {
const transaction = await verifyAppleTransaction(signedTransaction);
const subscriptionKey = transaction.originalTransactionId;
const mappedUserId = await findUserByAppAccountToken(
transaction.appAccountToken
);
if (mappedUserId && mappedUserId !== currentUserId) {
throw new Error("APP_ACCOUNT_TOKEN_MISMATCH");
}
return database.transaction(async (tx) => {
const existing = await tx.findSubscriptionForUpdate(
"apple",
subscriptionKey
);
if (existing && existing.appUserId !== currentUserId) {
throw new Error("SUBSCRIPTION_ALREADY_LINKED");
}
await tx.upsertSubscription({
platform: "apple",
storeSubscriptionKey: subscriptionKey,
appUserId: currentUserId,
productId: transaction.productId,
expiresAt: transaction.expiresDate
});
});
}
verifyAppleTransaction() 只是示意名称。实际实现需要验证 Apple 的签名和证书链,或者使用可信的 App Store Server API 数据完成校验。
恢复购买时不要重新绑定
账号 B 发起恢复后,客户端可以将恢复到的交易提交给服务端。服务端收到交易后,应查询原有绑定:
async function restoreAppleSubscription(currentUserId, signedTransaction) {
const transaction = await verifyAppleTransaction(signedTransaction);
const binding = await findBinding(
"apple",
transaction.originalTransactionId
);
if (!binding) {
return handleUnboundLegacyPurchase(currentUserId, transaction);
}
if (binding.appUserId !== currentUserId) {
throw new Error("SUBSCRIPTION_BELONGS_TO_ANOTHER_ACCOUNT");
}
return grantEntitlement(currentUserId, transaction);
}
在问题描述的场景中,处理结果应当是:
- 账号 A 首次购买后,服务端保存
originalTransactionId -> A; - 用户登录账号 B 并恢复购买;
- 服务端查询绑定后,仍会确认该订阅属于 A;
- B 无法获得权益,客户端提示”该订阅已关联其他账号”。
是否在提示中展示账号 A 的部分信息,需要考虑隐私问题。通常提示用户切换回原账号,或者联系客服处理即可。
Google Play 的处理方式
发起购买时传入混淆后的账号标识
Google Play Billing 支持在购买参数中传入 obfuscatedAccountId,必要时也可以传入 obfuscatedProfileId:
val flowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(productDetailsParamsList)
.setObfuscatedAccountId(obfuscatedAccountId)
.build()
billingClient.launchBillingFlow(activity, flowParams)
不要直接将数据库用户 ID、邮箱或手机号用作 obfuscatedAccountId。服务端可以通过 HMAC 计算这个值:
import crypto from "node:crypto";
function makeObfuscatedAccountId(userId, secret) {
return crypto
.createHmac("sha256", secret)
.update(`billing-account:${userId}`)
.digest("hex");
}
HMAC 比普通哈希更适合这个场景,因为外部人员无法通过枚举常见用户 ID 反推出账号。
服务端验证并绑定 purchaseToken
客户端购买成功后,将 purchaseToken、商品标识和当前 app 账号的登录凭证发送给服务端。服务端通过 Google Play Developer API 验证订阅,再执行绑定:
async function bindGoogleSubscription({
currentUserId,
packageName,
purchaseToken
}) {
const subscription = await verifyGoogleSubscription(
packageName,
purchaseToken
);
const expectedAccountId = makeObfuscatedAccountId(
currentUserId,
process.env.BILLING_HMAC_SECRET
);
const externalAccountId =
subscription.externalAccountIdentifiers?.externalAccountId;
if (externalAccountId && externalAccountId !== expectedAccountId) {
throw new Error("GOOGLE_ACCOUNT_IDENTIFIER_MISMATCH");
}
await bindTokenChainAtomically({
platform: "google",
currentUserId,
purchaseToken,
linkedPurchaseToken: subscription.linkedPurchaseToken,
productId: subscription.productId,
expiresAt: subscription.expiresAt
});
}
在 Google Play 中,升级、降级、替换或重新订阅都可能产生新的 purchaseToken。如果验证结果包含 linkedPurchaseToken,服务端需要沿用已有绑定关系,不能将新 token 视为一笔完全无关的订阅,再绑定给另一个账号。
例如:
oldPurchaseToken -> account A
newPurchaseToken -> linkedPurchaseToken: oldPurchaseToken
此时,newPurchaseToken 也应归属于 A。
具体字段结构取决于实际使用的 Google Play Developer API 接口版本,应以验证接口的响应为准。
旧购买没有账号标识怎么办
appAccountToken 和 obfuscatedAccountId 只能用于采用这些字段后发起的购买,历史交易可能并不包含它们。
对于尚未绑定的旧购买,可以设计一次性认领流程:
- 服务端验证交易是否真实有效;
- 查询
originalTransactionId或purchaseToken是否已有绑定; - 如果没有绑定,允许当前账号首次认领;
- 通过数据库事务和唯一约束完成绑定;
- 完成绑定后,不再允许其他账号自行认领。
如果一笔购买既没有账号标识,也没有历史服务端记录,服务端就无法根据商店凭证判断购买发生时登录的是 app 账号 A 还是 B。此时只能执行事先定义的认领规则或客服申诉流程,不能可靠地自动推断。
Cordova 应用需要注意什么
Cordova 不会改变这套整体设计。需要确认所使用的内购插件能否提供:
- Apple 的已签名交易数据、transaction ID、
originalTransactionId和appAccountToken; - Google Play 的
purchaseToken、产品信息和账号混淆参数; - 恢复购买的结果;
- 处理续订、退款、撤销等状态变化所需的数据。
如果插件只返回”购买成功”,却不提供可供服务端验证的凭证,就无法构建可靠的订阅系统。此时需要升级插件、扩展原生 bridge,或者改用支持服务端验证的实现。
还要处理的边界情况
不要在客户端直接授予权益
客户端回调、恢复结果和本地缓存都可能被篡改。客户端只负责收集凭证,最终是否授予权益,应由服务端根据验证结果决定。
服务器通知不能省略账号绑定
App Store Server Notifications 和 Google Real-time Developer Notifications 可以报告续订、过期、退款、撤销等变化,但不会处理 app 账号的归属。服务端收到通知后,需要通过已经保存的交易绑定找到对应用户,再更新其权益。
明确定义解绑和转移规则
如果业务允许将订阅从账号 A 转移到账号 B,需要另行设计受控流程,例如:
- 要求用户先登录账号 A;
- 执行二次验证;
- 检查账号 B 是否存在冲突订阅;
- 记录审计日志;
- 在一个数据库事务中完成转移。
不能将”恢复购买”作为转移订阅的入口,否则任何能够使用该 AppleID 或 Google 账号的人,都可能将权益绑定到另一个 app 账号。
家庭共享需要单独制定规则
如果某种订阅允许家庭共享,执行恢复的人可能不是最初付款者。商店允许其使用订阅,并不意味着你的业务必须将同一订阅永久绑定到多个独立的 app 账号。
是否支持家庭成员共享、允许共享给多少个账号,以及如何识别家庭成员,都需要单独制定产品规则。
推荐的完整流程
首次购买:
登录 app 账号
↓
生成或读取该账号的 appAccountToken / obfuscatedAccountId
↓
发起商店购买
↓
客户端把交易凭证发送到服务端
↓
服务端向 Apple / Google 验证
↓
原子绑定商店订阅标识与 app 账号
↓
服务端授予权益
恢复购买:
登录 app 账号
↓
从商店恢复交易
↓
客户端把凭证发送到服务端
↓
服务端验证交易
↓
查询既有绑定
↓
绑定属于当前账号:恢复权益
绑定属于其他账号:拒绝并提示切换账号
历史交易尚未绑定:进入一次性认领流程
核心原则是:商店负责证明交易有效,应用服务端负责记录这笔交易属于哪个 app 账号。读取 AppleID、Google ID 或设备 UUID 既无法实现,也不是解决账号归属问题所需要的方式。
备注:内容仅供参考。
