PAYATHON 2026

如何在 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。基本流程如下:

  1. 从 App Store 查询商品。
  2. 展示商店返回的本地化价格。
  3. 调用 purchase() 打开系统购买界面。
  4. 验证签名交易。
  5. 发放 coins 或 Premium 权益。
  6. 完成交易。
  7. 监听后续的续订、退款和撤销变化。

下面是一个简化示例:

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 并不可靠。用户可能更换设备、重新安装应用或申请退款,客户端数据也可能被篡改。

建议采用以下数据流:

  1. 客户端完成商店购买。
  2. 客户端把交易标识或 purchaseToken 发给业务服务端。
  3. 服务端调用 Apple 或 Google 的服务端 API 验证交易。
  4. 服务端根据交易唯一标识执行幂等检查。
  5. 验证成功后增加 coins,或更新 Premium 有效期。
  6. 服务端返回最终的权益状态。
  7. 服务端继续接收商店实时通知。
  8. 定期主动查询异常或遗漏的状态,并完成对账。

可以保存以下数据:

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 的支付流程才能稳定运行。

备注:内容仅供参考。