iPhone application 网站支付是否违反 Apple 支付政策
结论
这种方案被审核拒绝的风险较高。
账户、会员等级和余额由服务器管理,本身不是问题。问题在于这些余额会在 iPhone 游戏中被消耗,属于数字商品、游戏币、会员权益或游戏功能。用户在 App 内获取此类权益,通常需要使用 Apple 的 In-App Purchase(IAP)。
以下设计尤其容易违反审核规则:
- 只能在网站购买游戏余额或会员,App 内没有对应的 IAP。
- 余额不足时,App 提示用户”前往网站充值”。
- App 提供充值网页链接、二维码、价格说明或其他引导性文案。
- 以”支付发生在服务器端”为由绕过 IAP。
Apple 审核主要看用户购买的内容及其消费场景,不取决于支付接口部署在客户端还是服务器端。
为什么服务器支付仍可能违规
游戏中的 gold、silver 会员、游戏余额、游戏次数和虚拟货币,通常都属于数字权益。如果这些权益可以在 iPhone App 内使用或消耗,原则上就适用 App Store 的应用内购买规则。
以下技术安排通常没有问题:
- 用户账户注册在自己的服务器完成。
- App 使用账号或 registration ID 登录。
- 会员状态和余额由服务器保存。
- 每次游戏后由服务器扣减余额。
- App 从服务器读取剩余余额。
但余额的购买方式是另一个问题。服务器记账不能代替 IAP。即使 App 只负责收发数据,只要用户在网站购买的余额用于解锁或消费 App 内的数字服务,仍可能被认定为规避应用内购买。
此外,“余额不足,请前往网站充值”是明显的站外购买引导。如果没有适用资格、entitlement 或特定地区的例外,审核风险会更高。
推荐的合规方案
较稳妥的实现流程如下:
- 使用 StoreKit 在 iPhone App 内销售会员和游戏点数。
- Apple 完成交易后,将交易凭证或签名交易数据发送给业务服务器。
- 服务器验证交易,并确认该交易未被重复入账。
- 验证成功后,服务器增加会员权益或余额。
- 每次 gameplay 仍由服务器扣款。
- App 只显示服务器返回的最终余额。
如果游戏同时运行在 Web 和 iPhone 上,可以继续使用统一账户体系。用户在其他平台获得的权益能否在 iPhone App 中使用,以及 App 内是否必须提供相同权益的 IAP,要根据权益类型、业务模式、销售地区和当时有效的审核规则判断。
对于跨平台游戏,较为保守的设计是允许统一账户同步权益,同时在 iOS App 内提供 Apple IAP,并且不引导用户前往网站购买。
StoreKit 2 示例
下面的代码演示如何在 App 内购买游戏点数。产品必须先在 App Store Connect 中创建。
import StoreKit
enum PurchaseError: Error {
case productNotFound
case verificationFailed
}
func purchaseCredits(productID: String) async throws {
let products = try await Product.products(for: [productID])
guard let product = products.first else {
throw PurchaseError.productNotFound
}
let result = try await product.purchase()
switch result {
case .success(let verificationResult):
let transaction = try checkVerified(verificationResult)
try await sendTransactionToServer(
transactionID: String(transaction.id),
productID: transaction.productID,
signedTransaction: verificationResult.jwsRepresentation
)
await transaction.finish()
case .pending:
// 等待家长批准、付款授权或其他异步处理。
break
case .userCancelled:
break
@unknown default:
break
}
}
func checkVerified<T>(
_ result: VerificationResult<T>
) throws -> T {
switch result {
case .verified(let value):
return value
case .unverified:
throw PurchaseError.verificationFailed
}
}
func sendTransactionToServer(
transactionID: String,
productID: String,
signedTransaction: String
) async throws {
// 将交易数据提交到业务服务器。
// 服务器应验证交易,并通过 transactionID 保证幂等入账。
}
服务器端不能因为客户端声称”购买成功”就直接增加余额,至少应完成以下处理:
1. 接收 signedTransaction 和当前登录用户 ID
2. 验证 Apple 签名及交易信息
3. 检查 bundleId、productId、environment 等字段
4. 检查 transactionId 是否已经处理
5. 在数据库事务中增加余额并记录交易
6. 返回服务器计算后的最新余额
下面是一个简化的幂等入账逻辑:
BEGIN;
INSERT INTO apple_transactions (
transaction_id,
user_id,
product_id
)
VALUES (
:transaction_id,
:user_id,
:product_id
)
ON CONFLICT (transaction_id) DO NOTHING;
-- 只有成功插入新交易时才能增加余额。
UPDATE game_accounts
SET balance = balance + :credit_amount
WHERE user_id = :user_id
AND :transaction_was_inserted = TRUE;
COMMIT;
具体的签名验证方式应以当前 App Store Server API、StoreKit 和 Apple 官方文档为准,不能只依赖上述示意代码。
余额不足时应该如何提示
不建议显示:
余额不足,请前往 example.com 充值。
也不建议提供以下内容:
- “网站购买更优惠”等文案;
- 外部购买按钮;
- 可点击的网址;
- 跳转 Safari 的充值入口;
- 通过二维码或客服消息变相引导购买。
更安全的做法是在 App 内提供购买入口:
余额不足,请购买游戏点数后继续。
按钮应触发 StoreKit 商品页面,而不是打开网站。
如果决定不在 App 内提供购买功能,需要谨慎评估 App 是否还能展示和消费站外购买的游戏余额。即使 App 内没有购买链接,只保留站外充值机制,也不代表一定能通过审核。
其他注意事项
- gold、silver 等会员如果用于解锁数字游戏功能,通常也应通过 IAP 销售。
- 按次扣除的预付余额通常具有 consumable 商品特征。
- 不应使用网页视图、第三方支付 SDK 或服务器接口,在 App 内完成数字商品支付。
- 交易完成后应以服务器余额为准,防止客户端修改余额。
- 必须处理重复回调、退款、撤销、家庭批准和未完成交易。
- 如果余额具有现金价值,或者可以提现、转让,可能还会涉及金融、虚拟货币或赌博类审核要求。
- 不同 storefront、地区法规以及 Apple 授予的特定 entitlement 可能存在例外,但不能默认这些例外适用于普通游戏。
因此,当前方案中的”网站充值后在 iPhone 游戏中消费”和”余额不足时引导用户前往服务器充值”都有明显的审核风险。更稳妥的上架方案是将 iOS 端充值改为 StoreKit IAP,同时保留服务器统一验证、记账和扣款。
备注:内容仅供参考。
