如何在 iOS 和 Android 中使用设备存储卡支付?
结论
应用内的 coins 和 Premium 通常属于数字商品或数字服务,应分别接入:
- iOS:Apple StoreKit / In-App Purchase
- Android:Google Play Billing
付款时,系统会使用用户 Apple ID 或 Google Play 账户中已配置的支付方式。应用不需要,也不应读取、保存或传输银行卡信息。Apple Pay、Google Pay 以及自行接入的信用卡支付通常不适用于这类应用内数字内容。
订阅费用由 App Store 或 Google Play自动扣取。应用负责发起购买、交付权益和验证交易,同时通过服务端通知与商店 API 同步续订、取消、退款、过期等状态。
商品应该如何设计
先在两个商店的后台配置对应商品。
| 业务内容 | 商品类型 | 示例商品 ID |
|---|---|---|
| 100 coins | 消耗型商品 | coins_100 |
| 500 coins | 消耗型商品 | coins_500 |
| Premium 1 个月 | 自动续订订阅 | premium_monthly |
| Premium 3 个月 | 自动续订订阅或对应基础方案 | premium_quarterly |
| Premium 12 个月 | 自动续订订阅或对应基础方案 | premium_yearly |
商品 ID 进入生产环境后通常不适合随意修改,最好提前统一命名规则。
coins 应设为消耗型商品,以便同一用户重复购买。Premium 则应设为自动续订订阅,由商店按照用户选择的周期续费。
在 iOS 中,可以把不同周期的 Premium 放入同一个订阅组,方便用户切换方案。Android 可以用一个订阅商品搭配多个 base plan,也可以根据业务结构拆分成多个商品。商店能力和后台字段可能因政策、地区和控制台版本而变化,上线前应以当时的官方控制台为准。
iOS 实现方式
现代 iOS 项目可以使用 StoreKit 2。基本流程如下:
- 从 App Store 查询商品。
- 展示商店返回的本地化价格。
- 调用
purchase()打开系统购买界面。 - 验证签名交易。
- 发放 coins 或 Premium 权益。
- 完成交易。
- 监听后续的续订、退款和撤销变化。
下面是一个简化示例:
import StoreKit
enum PurchaseError: Error {
case failedVerification
}
final class StoreManager {
private let productIDs = [
"coins_100",
"premium_monthly",
"premium_quarterly",
"premium_yearly"
]
func loadProducts() async throws -> [Product] {
try await Product.products(for: productIDs)
}
func purchase(_ product: Product) async throws {
let result = try await product.purchase()
switch result {
case .success(let verificationResult):
let transaction = try verify(verificationResult)
// 先在服务端验证并交付权益。
// coins 商品必须确保只增加一次余额;
// 订阅商品则更新 premium 有效期和状态。
await transaction.finish()
case .pending:
// 交易等待家长批准、付款确认或其他处理。
break
case .userCancelled:
break
@unknown default:
break
}
}
private func verify<T>(
_ result: VerificationResult<T>
) throws -> T {
switch result {
case .verified(let value):
return value
case .unverified:
throw PurchaseError.failedVerification
}
}
}
应用启动后还要恢复当前有效的订阅:
func refreshEntitlements() async throws {
for await result in Transaction.currentEntitlements {
let transaction = try verify(result)
if transaction.productID.hasPrefix("premium_"),
transaction.revocationDate == nil {
// 将当前用户标记为 Premium。
// 更稳妥的做法是把交易发送给服务端统一核验。
}
}
}
交易状态也需要持续监听:
func observeTransactions() -> Task<Void, Never> {
Task.detached {
for await result in Transaction.updates {
guard case .verified(let transaction) = result else {
continue
}
// 将交易发送到服务端,更新订阅或补发商品。
await transaction.finish()
}
}
}
客户端签名验证可以及时处理购买结果。不过,只要权益具有实际价值,就仍建议由服务端保存交易,并通过 App Store Server API 和 App Store Server Notifications 同步最终状态。
Android 实现方式
Android 应使用 Google Play Billing Library。应用连接 Google Play 后查询 ProductDetails,再打开系统购买界面完成支付。
以下代码展示了核心结构。具体方法签名可能随 Billing Library 版本变化:
class BillingManager(
private val activity: Activity
) : PurchasesUpdatedListener {
private val billingClient = BillingClient.newBuilder(activity)
.setListener(this)
.enablePendingPurchases(
PendingPurchasesParams.newBuilder()
.enableOneTimeProducts()
.build()
)
.build()
fun connect() {
billingClient.startConnection(
object : BillingClientStateListener {
override fun onBillingSetupFinished(
billingResult: BillingResult
) {
if (billingResult.responseCode ==
BillingClient.BillingResponseCode.OK
) {
// 查询商品及用户现有购买记录。
}
}
override fun onBillingServiceDisconnected() {
// 稍后重连,建议使用退避策略。
}
}
)
}
override fun onPurchasesUpdated(
billingResult: BillingResult,
purchases: MutableList<Purchase>?
) {
if (billingResult.responseCode ==
BillingClient.BillingResponseCode.OK
) {
purchases.orEmpty().forEach { purchase ->
handlePurchase(purchase)
}
}
}
private fun handlePurchase(purchase: Purchase) {
if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) {
return
}
// 将 purchase.purchaseToken 和商品信息发送到服务端验证。
// 验证成功并完成权益交付后,再确认或消耗购买。
}
}
启动购买时,应使用查询得到的 ProductDetails,不能由客户端自行决定价格:
fun launchPurchase(
productDetails: ProductDetails,
offerToken: String? = null
) {
val paramsBuilder =
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
if (offerToken != null) {
paramsBuilder.setOfferToken(offerToken)
}
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(paramsBuilder.build()))
.build()
billingClient.launchBillingFlow(activity, billingFlowParams)
}
Premium 订阅验证成功后,需要确认购买:
fun acknowledgeSubscription(purchase: Purchase) {
if (purchase.isAcknowledged) return
val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
billingClient.acknowledgePurchase(params) { result ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
// 已确认。
}
}
}
coins 是消耗型商品。服务端成功增加余额后,应执行消费操作,让用户能够再次购买同一商品:
fun consumeCoins(purchaseToken: String) {
val params = ConsumeParams.newBuilder()
.setPurchaseToken(purchaseToken)
.build()
billingClient.consumeAsync(params) { result, _ ->
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
// 商品已消费,可以再次购买。
}
}
}
不要在服务端确认交易前消费商品,否则可能出现已经扣款,但 coins 没有到账且难以补发的问题。Google Play 会处理未及时确认的购买,包括退款,因此不能遗漏确认逻辑。
自动续订是如何发生的
自动续订不需要应用定时向银行卡发起扣款。大致流程如下:
用户选择订阅
↓
商店展示系统购买界面
↓
商店完成首次扣款
↓
应用及服务端验证交易
↓
开通 Premium 权益
↓
到达续订日期
↓
App Store / Google Play 自动尝试扣款
↓
服务端收到续订、失败、退款或取消通知
↓
更新用户订阅状态和有效期
“取消订阅”通常是关闭下一周期的自动续订,并不代表当前权益立即终止。一般情况下,用户仍然可以使用 Premium,直到已付款周期结束。
扣款失败后,商店可能进入重试期、宽限期或账号保留状态。是否继续提供权益,要根据商店返回的状态和已配置的宽限期策略判断,不能只看用户过去是否购买过订阅。
推荐的服务端机制
仅在客户端保存 isPremium = true 并不可靠。用户可能更换设备、重新安装应用或申请退款,客户端数据也可能被篡改。
建议采用以下数据流:
- 客户端完成商店购买。
- 客户端把交易标识或
purchaseToken发给业务服务端。 - 服务端调用 Apple 或 Google 的服务端 API 验证交易。
- 服务端根据交易唯一标识执行幂等检查。
- 验证成功后增加 coins,或更新 Premium 有效期。
- 服务端返回最终的权益状态。
- 服务端继续接收商店实时通知。
- 定期主动查询异常或遗漏的状态,并完成对账。
可以保存以下数据:
user_id
platform
product_id
transaction_id
original_transaction_id
purchase_token
purchase_type
status
purchased_at
expires_at
auto_renew_enabled
revoked_at
不要在这张表中保存银行卡号、CVV 等信息。应用内购买原本就不需要获取这些数据。
coins 的幂等处理
coins 很容易出现重复发放。例如,客户端没有及时收到响应时,可能会再次提交同一笔交易。服务端应使用商店交易 ID 建立唯一约束:
BEGIN;
INSERT INTO payment_transactions (
platform,
transaction_id,
user_id,
product_id
)
VALUES (
:platform,
:transaction_id,
:user_id,
:product_id
)
ON CONFLICT DO NOTHING;
-- 只有确实插入了新交易时,才增加 coins。
COMMIT;
实际 SQL 要根据数据库类型调整。原则是一笔商店交易只能发放一次权益。
Premium 状态判断
不要长期使用”首次购买时间加固定天数”来推算订阅状态。续订、退款、升降级和账单重试都会让这种计算失真。
更合适的判断条件如下:
subscription.status 允许访问
AND current_time < subscription.expires_at
AND subscription.revoked_at IS NULL
其中,status 和 expires_at 必须来自经过验证的商店交易,不能直接采用客户端上传的值。
订阅恢复与跨设备处理
用户更换手机或重新安装应用后,不应被要求重新购买仍在有效期内的订阅。
iOS 应提供”恢复购买”入口,并通过 StoreKit 同步交易:
try await AppStore.sync()
Android 可以查询当前有效购买,再把返回的购买令牌发送到服务端验证。
如果同一个业务账号可以同时登录 iOS 和 Android,建议把商店购买绑定到业务 user_id,由服务端统一返回 Premium 状态。同时要明确以下规则:
- 在 iOS 上购买的订阅仍由 Apple 管理和扣费。
- 在 Android 上购买的订阅仍由 Google Play 管理和扣费。
- 不要因跨平台登录而重复创建订阅。
- 需要处理退出登录、更换账号和误绑定等情况。
- 购买前可以使用平台提供的账户关联字段,帮助服务端识别业务用户,但不能把客户端传入的用户标识作为付款验证依据。
测试方式
不要直接使用生产银行卡反复测试。应使用:
- iOS 的 StoreKit Testing 和 App Store Sandbox 测试账号。
- Google Play Console 的测试轨道和 license tester。
- 商店提供的缩短订阅周期,测试续订、取消和到期。
- 服务端通知测试功能,检查通知签名和幂等处理。
至少应覆盖以下场景:
- 正常购买 coins。
- 重复提交同一笔交易。
- 支付待处理后成功。
- 支付待处理后失败。
- 用户取消购买界面。
- 订阅正常续订。
- 用户关闭自动续订。
- 扣款失败及恢复。
- 退款或撤销。
- 更换设备和重新安装。
- 网络中断后补发权益。
- 服务端通知重复或乱序到达。
常见误区
不要使用 Apple Pay 或 Google Pay 绕过应用商店销售 coins 和 Premium。它们通常用于实体商品、线下服务或商店政策允许的其他场景。数字内容是否必须使用应用内购买,还要结合应用类别、销售地区和当时有效的商店政策来确认。
不要信任客户端传来的 productId、价格、到期时间或”支付成功”标记。客户端只负责发起购买流程,最终权益必须以验证过的商店交易为准。
如果商品查询失败,不要自行填写价格并继续销售。价格、币种、税费和本地化内容都应来自商店返回的商品信息。
不要把”自动续订已关闭”直接视为”订阅已失效”。在当前周期结束前,用户通常仍有访问权。
也不要假设商店通知只会发送一次,或一定会按顺序到达。服务端必须以幂等方式处理通知。遇到状态冲突时,应通过商店 API 查询最新状态。
客户端负责商品展示和购买体验,Apple 与 Google 负责收款和自动续订,业务服务端负责验证交易、记账和管理权益状态。三者职责分开后,coins 和 Premium 的支付流程才能稳定运行。
备注:内容仅供参考。
