PAYATHON 2026

iOS Client 能否绕过 In-App Purchase 实现远程支付?

支付老李

结论

可以,前提是支付对象属于在应用之外消费的实体商品、线下服务、账单或金融交易。

在这种场景下,iOS Client 只负责用户认证、展示待付款项、提交支付指令和查询结果。实际扣款由服务端与银行或支付机构完成,无需经过 Apple 的 In-App Purchase 服务器。这种架构通常不会仅因“绕过 IAP”而被拒绝。

审核判断的重点不是交易发生在客户端还是服务端,而是用户购买的具体内容:

  • 实体商品、交通、餐饮、线下服务、公共事业账单等,通常可以使用银行卡、Apple Pay 或服务端支付。
  • 在应用内解锁或消费的数字内容、会员权益、虚拟货币、游戏功能等,通常必须使用 In-App Purchase。将扣款转移到服务端,并不会改变商品的数字属性。
  • 受监管的金融服务除了要遵守支付规则,还可能涉及经营资质、地区合规、身份验证和数据保护要求。

App Store 审核规则会调整,部分要求也会因国家或地区而异,因此无法保证某个具体应用一定能通过审核。提交前,应根据目标 storefront 核对当时有效的 App Review Guidelines。

推荐的系统架构

客户端不应直接保存银行账户密码、银行卡敏感信息或银行接口密钥。可以按以下方式划分职责:

iOS Client
    │
    ├── 登录并取得短期访问令牌
    ├── 获取待付款项
    ├── 向业务服务器提交支付确认
    └── 查询或接收支付结果
            │
            ▼
Business Server
    │
    ├── 验证用户和账单归属
    ├── 创建支付请求并保证幂等
    ├── 调用银行或支付服务商 API
    ├── 接收支付机构的 webhook
    └── 保存最终支付状态
            │
            ▼
Bank / Payment Service Provider

支付处理最好采用异步流程。银行接口返回“请求已接收”,不一定意味着资金已经结算,因此客户端不能仅凭一次 HTTP 响应就显示“支付成功”。

常见的状态模型如下:

pending -> processing -> succeeded
                      └-> failed
                      └-> requires_action

服务端应是支付状态的唯一可信来源。推送通知只能提醒客户端刷新,不能作为资金到账的最终凭证。

服务端接口设计

可以提供以下接口:

POST /v1/sessions
GET  /v1/payments?status=pending
POST /v1/payments/{paymentId}/confirm
GET  /v1/payments/{paymentId}

提交支付时应使用幂等键,防止用户连续点击或网络重试造成重复扣款:

POST /v1/payments/pay_123/confirm
Authorization: Bearer <access-token>
Idempotency-Key: 17E85127-9089-4836-9321-0F36949C7C86
Content-Type: application/json

{
  "expectedAmount": {
    "currency": "CNY",
    "minorUnits": 12800
  }
}

服务端必须根据账单重新计算金额。客户端传入的金额只能用于一致性检查,不能直接作为实际扣款金额。

服务端可以先返回:

{
  "paymentId": "pay_123",
  "status": "processing"
}

银行处理完成后,通过 webhook 通知服务端。服务端验证 webhook 签名并更新数据库,客户端再通过查询接口获取最终结果。

iOS 客户端示例

下面的示例只提交支付确认,不在设备上处理银行交易:

import Foundation

struct Money: Codable {
    let currency: String
    let minorUnits: Int
}

struct ConfirmPaymentRequest: Codable {
    let expectedAmount: Money
}

struct PaymentResponse: Codable {
    let paymentId: String
    let status: String
}

enum PaymentError: Error {
    case invalidResponse
    case server(statusCode: Int)
}

func confirmPayment(
    paymentId: String,
    accessToken: String,
    amount: Money
) async throws -> PaymentResponse {
    let encodedPaymentId = paymentId.addingPercentEncoding(
        withAllowedCharacters: .urlPathAllowed
    ) ?? paymentId

    guard let url = URL(
        string: "https://api.example.com/v1/payments/\(encodedPaymentId)/confirm"
    ) else {
        throw URLError(.badURL)
    }

    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue(
        "Bearer \(accessToken)",
        forHTTPHeaderField: "Authorization"
    )
    request.setValue(
        UUID().uuidString,
        forHTTPHeaderField: "Idempotency-Key"
    )
    request.setValue(
        "application/json",
        forHTTPHeaderField: "Content-Type"
    )

    request.httpBody = try JSONEncoder().encode(
        ConfirmPaymentRequest(expectedAmount: amount)
    )

    let (data, response) = try await URLSession.shared.data(for: request)

    guard let httpResponse = response as? HTTPURLResponse else {
        throw PaymentError.invalidResponse
    }

    guard (200...299).contains(httpResponse.statusCode) else {
        throw PaymentError.server(statusCode: httpResponse.statusCode)
    }

    return try JSONDecoder().decode(
        PaymentResponse.self,
        from: data
    )
}

在生产环境中,不要为每次网络重试重新生成幂等键。同一次用户支付操作应复用同一个键,否则服务端可能把重试当成新的支付请求。

最终状态可以通过轮询查询:

func waitForPaymentResult(
    paymentId: String,
    accessToken: String
) async throws -> PaymentResponse {
    for _ in 0..<10 {
        let payment = try await fetchPayment(
            paymentId: paymentId,
            accessToken: accessToken
        )

        switch payment.status {
        case "succeeded", "failed":
            return payment

        case "pending", "processing", "requires_action":
            try await Task.sleep(for: .seconds(2))

        default:
            throw PaymentError.invalidResponse
        }
    }

    throw URLError(.timedOut)
}

轮询只是其中一种方式。实际项目可以结合 APNs:服务端收到银行 webhook 后发送静默推送或普通通知,客户端收到通知后重新查询支付接口。不要直接把推送消息中的 status 当作支付凭证。

可能触发审核问题的情况

支付对象实际是数字内容

如果付款后会解锁应用内功能、数字订阅、游戏道具、虚拟货币或内容访问权,服务端代扣通常仍会被视为规避 In-App Purchase。商品性质并不取决于交易是否经过 Apple 服务器。

界面引导用户到外部购买数字商品

即使应用本身不收款,在应用内放置外部支付按钮、价格说明或购买引导,也可能受到相关规则限制。具体允许哪些方式,取决于应用类型、目标地区和当时有效的政策。

应用只是一个支付入口

如果应用功能过于单薄,只展示网页、跳转到支付页或提供有限的消息交换,即使支付方式合规,也可能因功能完整性、用户价值或网页套壳等问题受到审核关注。应用应提供清晰的账单详情、交易记录、状态追踪、错误恢复和客服入口。

描述不清导致审核人员误判

App Store Connect 的审核备注应明确说明:

  • 支付对象是什么;
  • 商品或服务在哪里消费;
  • 付款不会解锁应用内数字功能;
  • 客户端只向自有服务器提交指令;
  • 服务端如何与银行或支付机构通信;
  • 审核人员如何使用测试账户验证完整流程。

如果测试环境不会真实扣款,还应说明测试状态的生成方式,并提供所有必要的测试凭据。

安全与合规注意事项

  • 所有通信都应使用 HTTPS,并正确验证服务器证书。
  • 不要在应用、日志、推送负载或分析事件中记录银行密码、完整卡号、CVV 或访问令牌。
  • 支付接口必须确认用户拥有对应账单,不能只检查 paymentId 是否存在。
  • 服务端保存金额时应使用最小货币单位,例如人民币分,避免使用浮点数。
  • 确认支付接口需要实施幂等控制、重放防护、速率限制和审计记录。
  • 银行 webhook 必须验证签名、时间戳和事件标识,同时处理重复通知和乱序通知。
  • 界面上应明确区分“请求已提交”“银行处理中”和“支付成功”。
  • 涉及银行卡数据时,应评估 PCI DSS 等要求。使用支付服务商的 tokenization,通常可以缩小敏感数据的处理范围。
  • 如果服务属于汇款、钱包、借贷、证券或其他受监管业务,还需确认运营主体、许可范围和上架地区是否匹配。

只要付款针对应用之外的商品、服务或账单,且客户端不会通过外部支付解锁应用内数字权益,由服务端完成交易并由 iOS Client 展示结果的方案通常是合理的。需要特别避免的是把原本应使用 In-App Purchase 的数字交易改名为“远程支付”,因为审核关注的是交易实质,而不是技术调用链。

备注:内容仅供参考。