iOS开发基础99-iOS 内购问题与防范:越狱欺诈、重复订单、丢单与退款
iOS 内购问题与防范:越狱欺诈、重复订单、丢单与退款
内购(IAP)是 App 变现的核心,但实际开发中会遇到越狱欺诈、重复发放、丢单、退款等问题。本文从内购基本流程出发,深入解析四大核心问题(越狱欺诈、重复订单、丢单、退款)的底层原理和防范手段,补充订单状态机设计、服务器校验流程、客户端补偿机制等实战内容,给出 OC/Swift 代码示例和常见坑排查。
一、内购基本流程回顾
一句话原理
内购的核心是客户端发起支付 → 苹果扣款 → 客户端拿到收据 → 服务端向苹果验证收据 → 验证通过后发放商品,整个过程中服务端验证是安全的基石。
完整流程
客户端 服务端 苹果服务器
│ │ │
│── 1. 请求商品信息 ─────────────────────────────────────→│
│←──── 商品列表 ─────────────────────────────────────────│
│ │ │
│── 2. 发起支付 ────────────────────────────────────────→│
│ │ 用户确认支付
│←──── 3. 支付成功回调 ──────────────────────────────────│
│ (拿到 receipt) │ │
│ │ │
│── 4. 上报 receipt ────────→│ │
│ │── 5. 验证收据 ─────────→│
│ │←──── 验证结果 ──────────│
│ │ │
│ │── 6. 发放商品 │
│←──── 7. 通知客户端 ──────────│ │
│ │ │
│── 8. finishTransaction │ │
需要考虑的特殊情况
| 情况 | 说明 |
|---|---|
| 网络中断 | 支付成功但上报收据时网络断了,导致丢单 |
| 支付失败重试 | 用户取消后重新购买,可能产生多笔交易 |
| 多设备登录 | 同一账号在不同设备购买,需要统一处理 |
| 越狱设备 | 伪造支付成功,试图免费获取商品 |
| 退款 | 用户购买后向苹果申请退款,需要追回商品 |
二、核心问题一:越狱欺诈与防范
一句话原理
越狱插件通过拦截 StoreKit 的支付回调,伪造支付成功,让客户端以为支付完成了。但只要服务端向苹果服务器验证收据,伪造的收据就会露馅——服务端验证是防越狱欺诈的基础。
越狱欺诈的原理
常见越狱插件(LocaliAPStore、IAPFree 等)的工作方式:
- Hook StoreKit 的支付方法,拦截支付请求。
- 不经过苹果服务器,直接伪造一个"支付成功"的回调给客户端。
- 如果客户端自己验证收据(不经过服务端),插件还会伪造验证成功的响应。
关键认知:越狱欺诈只能欺骗客户端,无法欺骗苹果服务器。只要服务端拿收据去苹果验证,伪造的收据要么验证失败(status ≠ 0),要么
in_app为空。
正常订单 vs 越狱订单
// 正常订单:in_app 有真实交易记录
{
"status": 0,
"environment": "Production",
"receipt": {
"bundle_id": "com.example.app",
"in_app": [
{
"product_id": "com.example.gold.100",
"transaction_id": "1000000400000001",
"original_transaction_id": "1000000400000001",
"purchase_date_ms": "1484283892000",
"quantity": "1"
}
]
}
}
// 越狱伪造订单:status 可能为 0,但 in_app 为空
{
"status": 0,
"environment": "Production",
"receipt": {
"bundle_id": "com.example.app",
"in_app": [] // 空数组!
}
}
防范手段(多层校验)
1. 必须服务端验证
不要在客户端直接验证收据。客户端的所有数据都不可信,必须由服务端向苹果服务器验证。
2. 校验 in_app 非空
in_app 为空的订单直接判定为无效:
def verify_receipt(receipt_data):
result = request_apple_verify(receipt_data)
if result['status'] != 0:
return False, "验证失败"
in_app = result['receipt'].get('in_app', [])
if not in_app:
return False, "in_app 为空,疑似越狱欺诈"
return True, in_app
3. 校验 bundle_id
防止跨应用使用收据(A 应用的收据在 B 应用使用):
expected_bundle_id = "com.example.app"
if result['receipt']['bundle_id'] != expected_bundle_id:
return False, "bundle_id 不匹配"
4. 校验 product_id
不要相信客户端传过来的商品 ID,以苹果返回的 product_id 为准:
for item in in_app:
product_id = item['product_id']
if product_id not in VALID_PRODUCT_IDS:
return False, f"无效商品: {product_id}"
5. 校验 transaction_id 唯一性
每个交易有唯一的 transaction_id,用它去重(详见下一节)。
6. 校验购买时间
防止使用过期的旧收据重复提交:
purchase_time = int(item['purchase_date_ms']) / 1000
current_time = time.time()
if current_time - purchase_time > 86400 * 7: # 超过 7 天
# 可能是旧收据重用,根据业务决定是否拒绝
pass
三、核心问题二:重复订单与防重
一句话原理
同一笔交易可能因为网络重试、App 重启、多设备上报等原因被多次提交到服务端,用 transaction_id 做唯一索引去重是最可靠的防重手段。
为什么会出现重复订单
| 场景 | 说明 |
|---|---|
| 网络重试 | 客户端上报收据超时,自动重试,服务端收到两次 |
| App 重启 | 未完成的交易在启动后重新回调,再次上报 |
| 多设备 | 同一账号在 A 设备支付,B 设备也收到回调并上报 |
| 收据包含多笔交易 | 同一个 App Receipt 包含所有历史交易,每次验证都返回全部 |
用 transaction_id 防重
transaction_id 是每笔交易的唯一标识,同一笔交易的 transaction_id 永远不变。
数据库设计
-- transaction_id 设为唯一索引,从数据库层面保证不重复
CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transaction_id VARCHAR(64) NOT NULL,
original_transaction_id VARCHAR(64),
product_id VARCHAR(128) NOT NULL,
user_id VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
receipt_data TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (transaction_id)
);
CREATE UNIQUE INDEX idx_transaction_id ON orders(transaction_id);
服务端校验流程
def handle_purchase(user_id, receipt_data):
# 1. 向苹果验证
result = request_apple_verify(receipt_data)
if result['status'] != 0:
return {'success': False, 'msg': '验证失败'}
in_app = result['receipt'].get('in_app', [])
if not in_app:
return {'success': False, 'msg': '无效订单'}
# 2. 遍历所有交易,逐个处理
granted_items = []
for item in in_app:
transaction_id = item['transaction_id']
product_id = item['product_id']
# 3. 查重:transaction_id 已存在则跳过
existing = db.find_one({'transaction_id': transaction_id})
if existing:
continue # 已处理过,跳过
# 4. 创建订单(唯一索引保证并发安全)
try:
db.insert_one({
'transaction_id': transaction_id,
'product_id': product_id,
'user_id': user_id,
'status': 'success'
})
# 5. 发放商品
grant_product(user_id, product_id)
granted_items.append(product_id)
except DuplicateKeyError:
# 并发情况下唯一索引冲突,说明已被其他请求处理
continue
return {'success': True, 'items': granted_items}
关键:用数据库唯一索引而不是"先查后插",因为"先查后插"在并发下有竞态条件(两个请求同时查到不存在,然后都插入)。
订单状态机
pending ──→ verifying ──→ success ──→ refunded
│ │ │
│ │ └──→ duplicate(重复订单)
│ └──→ failed(验证失败)
└──→ expired(超时)
| 状态 | 说明 |
|---|---|
| pending | 客户端已上报,等待验证 |
| verifying | 正在向苹果验证 |
| success | 验证成功,商品已发放 |
| failed | 验证失败 |
| duplicate | 重复订单,不重复发放 |
| refunded | 用户已退款 |
四、核心问题三:丢单与补偿
一句话原理
丢单是指用户付了钱但没收到商品,通常因为支付成功后网络中断或 App 崩溃导致收据没上报。解决思路是:客户端启动时重新检查未完成交易,服务端用 transaction_id 幂等处理。
丢单场景
- 用户支付成功,客户端准备上报收据时网络断开。
- 上报过程中 App 被系统杀死或崩溃。
- 服务端验证成功但返回响应时网络断开,客户端以为失败。
客户端策略
1. App 启动时注册交易观察者
// AppDelegate 中,App 启动时立刻注册
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[[SKPaymentQueue defaultQueue] addTransactionObserver:[IAPManager sharedManager]];
return YES;
}
注册后,苹果会把所有未完成的交易(未调用 finishTransaction 的)通过 updatedTransactions: 回调回来,客户端重新上报。
2. 未完成交易的持久化(可选)
不建议用 NSUserDefaults 存收据(会被清除、不加密)。更可靠的方式是依赖 SKPaymentQueue 的未完成交易机制——只要不调用 finishTransaction,交易就一直在队列中,下次启动会重新回调。
如果确实需要本地持久化,用钥匙串(Keychain):
// 用钥匙串保存待上报的收据(比 NSUserDefaults 安全)
[SSKeychain setPassword:receiptString forService:@"com.example.iap" account:@"pendingReceipt"];
// 启动时读取
NSString *pendingReceipt = [SSKeychain passwordForService:@"com.example.iap" account:@"pendingReceipt"];
if (pendingReceipt) {
[self verifyReceipt:pendingReceipt completion:^(BOOL success) {
if (success) {
[SSKeychain deletePasswordForService:@"com.example.iap" account:@"pendingReceipt"];
}
}];
}
3. 先验证再结束交易
- (void)handlePurchased:(SKPaymentTransaction *)transaction {
// 1. 获取收据
NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
NSData *receiptData = [NSData dataWithContentsOfURL:receiptURL];
// 2. 上报服务端验证
[self verifyReceipt:receiptData completion:^(BOOL success) {
if (success) {
// 3. 验证成功,发放商品后才结束交易
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
}
// 验证失败不结束交易,下次启动还会重试
}];
}
原则:验证成功并发放商品后才调用
finishTransaction。验证失败不要结束,留给下次启动重试。
服务端策略
1. 幂等处理
同一个 transaction_id 无论上报多少次,只发放一次商品(用唯一索引保证)。
2. 记录所有订单
无论验证成功还是失败,都保存收据和验证结果,方便审计和排查:
# 保存所有收到的验证请求
db.insert_one({
'receipt_data': receipt_data,
'user_id': user_id,
'status': 'pending',
'created_at': datetime.now()
})
3. 异步验证 + 轮询
客户端上报后,服务端可以异步验证,客户端轮询结果:
客户端上报收据 → 服务端返回 order_id → 客户端轮询 /api/order/status?order_id=xxx → 获取发放结果
五、核心问题四:退款处理
一句话原理
用户购买后可以向苹果申请退款,苹果通过 Server-to-Server 通知主动告知服务端,服务端收到退款通知后要追回商品或取消会员权限。
Server-to-Server 通知配置
- 在 App Store Connect → App 信息 → 通用 → 服务器到服务器通知中填写通知 URL。
- 苹果会在订阅状态变化、退款等事件发生时,向该 URL 发送 POST 请求。
通知类型
| notification_type | 说明 |
|---|---|
INITIAL_BUY |
首次购买订阅 |
DID_RENEW |
自动续费成功 |
DID_FAIL_TO_RENEW |
续费失败(如余额不足) |
CANCEL |
用户取消订阅 |
REFUND |
用户退款(消耗型/非消耗型/订阅) |
REVOKE |
家庭共享被撤销 |
PRICE_INCREASE |
订阅涨价 |
GRACE_PERIOD |
宽限期(续费失败后仍有效) |
退款通知处理
@app.route('/apple/notification', methods=['POST'])
def apple_notification():
data = request.json
notification_type = data.get('notification_type')
transaction_id = data.get('transaction_id')
original_transaction_id = data.get('original_transaction_id')
if notification_type == 'REFUND':
# 1. 更新订单状态
db.update_one(
{'transaction_id': transaction_id},
{'$set': {'status': 'refunded', 'refunded_at': datetime.now()}}
)
# 2. 追回商品(根据业务处理)
user_id = get_user_by_transaction(transaction_id)
revoke_product(user_id, transaction_id)
# 3. 记录日志
db.insert_one({'type': 'refund_log', 'transaction_id': transaction_id, 'data': data})
elif notification_type == 'DID_RENEW':
# 订阅续费成功,延长会员时间
extend_subscription(original_transaction_id, data.get('expires_date_ms'))
return 'OK', 200
退款的延迟性
苹果退款通知不是实时的,可能有几小时到几天的延迟。应对策略:
- 依赖 Server-to-Server 通知为主。
- 定期(如每天)主动向苹果验证活跃订阅的收据,确认是否已退款。
- 用户投诉时,用
transaction_id主动查询苹果验证接口确认状态。
退款后的业务处理
| 商品类型 | 退款后处理 |
|---|---|
| 消耗型(金币) | 难以追回已消耗的虚拟币,通常做封号或扣减处理 |
| 非消耗型(去广告) | 撤销权限,下次启动时重新校验 |
| 订阅型(会员) | 立即取消会员权限,记录到期时间 |
六、订单系统设计要点
数据库表设计
-- 订单表
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
transaction_id VARCHAR(64) NOT NULL COMMENT '苹果交易ID,唯一',
original_transaction_id VARCHAR(64) COMMENT '原始交易ID(订阅恢复用)',
product_id VARCHAR(128) NOT NULL COMMENT '商品ID',
user_id BIGINT NOT NULL COMMENT '用户ID',
amount DECIMAL(10,2) COMMENT '金额',
status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '状态',
environment VARCHAR(16) COMMENT 'Sandbox/Production',
receipt_data TEXT COMMENT '收据Base64',
purchase_time BIGINT COMMENT '购买时间戳',
expires_time BIGINT COMMENT '到期时间(订阅用)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_transaction (transaction_id),
KEY idx_user (user_id),
KEY idx_status (status)
);
-- 退款日志表
CREATE TABLE refund_logs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
transaction_id VARCHAR(64) NOT NULL,
notification_type VARCHAR(32),
raw_data JSON COMMENT '苹果原始通知',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
服务端校验完整流程
1. 接收客户端上报的 receipt_data + user_id
2. 先查 transaction_id 是否已存在(快速返回,避免重复验证)
3. 向苹果生产环境验证
- status = 21007 → 转发到沙盒环境重新验证
- status ≠ 0 → 验证失败,记录日志
4. 解析返回结果
- in_app 为空 → 拒绝(疑似越狱)
- bundle_id 不匹配 → 拒绝
- 遍历 in_app,逐个处理
5. 对每个交易:
- transaction_id 查重(唯一索引)
- product_id 校验
- 插入订单 + 发放商品(事务保证原子性)
6. 返回发放结果给客户端
七、客户端代码补充(OC)
// IAPManager.m 核心方法
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray<SKPaymentTransaction *> *)transactions {
for (SKPaymentTransaction *transaction in transactions) {
switch (transaction.transactionState) {
case SKPaymentTransactionStatePurchased:
case SKPaymentTransactionStateRestored:
[self handlePurchased:transaction];
break;
case SKPaymentTransactionStateFailed:
NSLog(@"支付失败:%@", transaction.error.localizedDescription);
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
break;
default:
break;
}
}
}
- (void)handlePurchased:(SKPaymentTransaction *)transaction {
// 1. 获取收据
NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
NSData *receiptData = [NSData dataWithContentsOfURL:receiptURL];
// 收据可能为空(如真机直接 Xcode 安装),尝试刷新
if (!receiptData) {
SKReceiptRefreshRequest *refresh = [[SKReceiptRefreshRequest alloc] init];
refresh.delegate = self;
[refresh start];
return;
}
NSString *receiptString = [receiptData base64EncodedStringWithOptions:0];
// 2. 持久化待上报的收据(钥匙串)
[SSKeychain setPassword:receiptString forService:@"com.example.iap" account:@"pending"];
// 3. 上报服务端
[self.api verifyReceipt:receiptString completion:^(BOOL success, NSArray *items) {
if (success) {
// 4. 发放商品(更新 UI)
[self handleItemsGranted:items];
// 5. 清除待上报收据
[SSKeychain deletePasswordForService:@"com.example.iap" account:@"pending"];
// 6. 结束交易
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
} else {
NSLog(@"验证失败,不结束交易,下次启动重试");
}
}];
}
// App 启动时检查待上报的收据
- (void)checkPendingTransactions {
NSString *pendingReceipt = [SSKeychain passwordForService:@"com.example.iap" account:@"pending"];
if (pendingReceipt) {
[self.api verifyReceipt:pendingReceipt completion:^(BOOL success, NSArray *items) {
if (success) {
[SSKeychain deletePasswordForService:@"com.example.iap" account:@"pending"];
}
}];
}
}
八、Swift 版本对照
import StoreKit
class IAPManager: NSObject, SKPaymentTransactionObserver {
static let shared = IAPManager()
func start() {
SKPaymentQueue.default().add(self)
checkPendingReceipt()
}
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchased, .restored:
handlePurchased(transaction)
case .failed:
SKPaymentQueue.default().finishTransaction(transaction)
default:
break
}
}
}
private func handlePurchased(_ transaction: SKPaymentTransaction) {
guard let receiptURL = Bundle.main.appStoreReceiptURL,
let receiptData = try? Data(contentsOf: receiptURL) else {
// 收据为空,刷新
let refresh = SKReceiptRefreshRequest()
refresh.delegate = self
refresh.start()
return
}
let receiptString = receiptData.base64EncodedString()
// 钥匙串保存待上报收据
savePendingReceipt(receiptString)
// 上报服务端
verifyReceipt(receiptString) { [weak self] success in
if success {
self?.clearPendingReceipt()
SKPaymentQueue.default().finishTransaction(transaction)
}
}
}
private func checkPendingReceipt() {
if let receipt = loadPendingReceipt() {
verifyReceipt(receipt) { [weak self] success in
if success { self?.clearPendingReceipt() }
}
}
}
// 钥匙串操作(用 KeychainAccess 或 Security framework)
private func savePendingReceipt(_ receipt: String) { ... }
private func loadPendingReceipt() -> String? { ... }
private func clearPendingReceipt() { ... }
private func verifyReceipt(_ receipt: String, completion: @escaping (Bool) -> Void) {
// 调用服务端接口
}
}
九、常见问题与坑
Q1:越狱插件真的能伪造通过苹果验证的收据吗?
不能。越狱插件只能欺骗客户端(拦截本地回调),无法伪造能通过苹果服务器验证的收据。服务端验证时,伪造的收据要么 status 非 0,要么 in_app 为空。所以服务端验证是防越狱的基础。
Q2:为什么不能用 receipt 的 MD5 去重?
同一个 App Receipt 包含所有历史交易,每次验证返回的收据可能相同,但里面可能包含多笔交易。用收据 MD5 去重会漏掉同一收据中的新交易。正确做法是遍历 in_app 数组,用每个交易的 transaction_id 去重。
Q3:finishTransaction 什么时候调用?
验证成功并发放商品后调用。验证失败不要调用,这样下次 App 启动时交易还会回调,可以重试。但如果是永久性失败(如收据无效),也应该调用 finishTransaction 避免无限重试。
Q4:沙盒环境测试需要注意什么?
- 沙盒收据验证返回
status = 21007,服务端要转发到沙盒接口。 - 苹果审核期间用的是沙盒环境,必须支持 21007 处理。
- 沙盒环境的购买不会真实扣费。
- 沙盒订阅的时间被压缩(几分钟相当于一个月),方便测试续费。
Q5:用户退款了怎么办?
- 消耗型商品(金币):难以追回,通常记录在案,严重时封号。
- 非消耗型(去广告):撤销权限,下次启动重新校验。
- 订阅型:立即取消会员权限。
- 依赖 Server-to-Server 通知,同时定期主动验证。
Q6:多设备购买同一商品怎么办?
- 非消耗型和订阅型:可以恢复购买(restore),多设备共享。
- 消耗型:不建议跨设备共享,用 user_id + transaction_id 绑定,防止一个交易给多个账号发放。
Q7:服务端验证苹果接口超时怎么办?
- 重试机制(最多 3 次,指数退避)。
- 超时后不要结束交易,客户端下次启动重试。
- 苹果服务器偶尔不可用,要有容错和告警。
Q8:怎么防止用户用别人的账号充值?
- 服务端记录 user_id 和 transaction_id 的绑定关系。
- 同一个 transaction_id 只能给一个 user_id 发放。
- 校验 receipt 中的 bundle_id 和 product_id。
Q9:订阅型商品和消耗型处理有什么不同?
| 对比 | 消耗型 | 订阅型 |
|---|---|---|
| transaction_id | 每次购买不同 | 续费的 original_transaction_id 相同 |
| 收据 in_app | 包含所有未消耗交易 | 只包含未过期订阅 |
| 到期时间 | 无 | 有 expires_date_ms |
| 退款通知 | REFUND | REFUND / CANCEL / DID_FAIL_TO_RENEW |
| 恢复购买 | 不能恢复 | 可以恢复 |
Q10:内购被拒常见原因?
- 没有恢复购买按钮(非消耗型和订阅必须有)。
- 商品描述不清或价格不对应。
- 用了非苹果支付(微信/支付宝买虚拟商品,游戏除外)。
- 隐藏功能或引导外部支付。
- 测试时用生产环境测试(应该用沙盒)。
十、总结
- 四大核心问题:
- 越狱欺诈:服务端验证 + in_app 非空 + bundle_id/product_id 校验,多层防御。
- 重复订单:transaction_id 唯一索引去重,数据库层面保证幂等。
- 丢单:客户端启动注册 observer 重试 + 钥匙串持久化 + 服务端幂等处理。
- 退款:Server-to-Server 通知 + 定期主动验证 + 按商品类型追回。
- 关键原则:
- 客户端不可信,所有验证走服务端。
- transaction_id 是防重的唯一可靠依据。
- 先验证发放,再 finishTransaction。
- 所有订单持久化,方便审计和排查。
- 系统设计:订单状态机 + 唯一索引 + 异步验证 + 退款通知,构成完整的内购安全体系。

浙公网安备 33010602011771号