iOS 消耗品内购 Java 后端上线方案

蛋饼 / 时长卡 · Apple IAP · Java 后端验签 · 幂等发货 · 退款通知 · 补单

iOS 内购(一次性消耗品)Java 后端上线方案

适用项目:痞鸭游 / 云电脑 App

商品类型:一次性消耗品 Consumable

业务商品:蛋饼时长卡

不包含:订阅会员、自动续费订阅、非消耗型永久解锁

---

0. 核心结论

你们现在的内购逻辑应该这样设计:

text
iOS 负责:
1. 从后端拿 Apple Product ID
2. 用 StoreKit 向 Apple 发起购买
3. 购买成功后拿到 signedTransactionInfo
4. 把 signedTransactionInfo 发给 Java 后端
5. 后端确认发货成功后,再 finish transaction

Java 后端负责:
1. 验证 Apple 签名
2. 校验 Bundle ID / Product ID / Transaction ID / AppAccountToken
3. 做幂等
4. 给用户发放蛋饼或时长卡
5. 保存交易流水
6. 处理退款、重复回调、补单、异常订单

权益一定以后端为准。

客户端只负责拉起 Apple 支付和提交凭证,不能因为客户端说“支付成功”就直接发货。

---

1. 商品模型设计

你们没有订阅,只有消耗品:

| 业务商品 | Apple 商品类型 | 业务资产 |

|---|---|---|

| 蛋饼包 | Consumable | 虚拟币 / 蛋饼余额 |

| 时长卡 | Consumable | 云电脑可用时长 |

Apple 后台创建商品时,需要选择:

text
Type = Consumable

Apple App Store Connect 创建 Consumable / Non-Consumable 时会要求填写 Reference NameProduct IDProduct ID 就是 iOS、Java 后端、Apple 三边对齐的商品编码。

---

2. Product ID 命名建议

不要用中文,不要带空格,不要用价格作为唯一含义。

推荐:

text
pyy_danbing_60
pyy_danbing_300
pyy_danbing_680

pyy_timecard_30m
pyy_timecard_60m
pyy_timecard_180m
pyy_timecard_600m

含义:

| Product ID | 业务含义 | 发放资产 |

|---|---|---:|

| pyy_danbing_60 | 60 蛋饼包 | 蛋饼 +60 |

| pyy_danbing_300 | 300 蛋饼包 | 蛋饼 +300 |

| pyy_timecard_60m | 60 分钟时长卡 | 时长 +60 分钟 |

| pyy_timecard_600m | 600 分钟时长卡 | 时长 +600 分钟 |

重要规则

页面展示价格时,iOS 必须以 Apple 返回的价格为准,不要以后端价格为准。

后端负责告诉 iOS 有哪些商品、这些商品对应哪个 Apple Product ID、购买后发放多少资产。

---

3. App Store Connect 需要配置什么

3.1 创建内购商品

路径:

text
App Store Connect
→ 你的 App
→ Monetization / In-App Purchases
→ 创建 In-App Purchase

每个商品选择:

text
Type: Consumable
Reference Name: 内部备注,例如 60蛋饼
Product ID: pyy_danbing_60
Price: Apple 价格等级

3.2 创建 In-App Purchase Key

Java 后端如果要调用 App Store Server API,需要在 App Store Connect 创建 In-App Purchase Key。

路径:

text
App Store Connect
→ Users and Access
→ Integrations
→ In-App Purchase
→ Generate In-App Purchase Key

后端需要保存:

text
issuerId
keyId
privateKey .p8
bundleId
appAppleId

这些只放后端,不要放 iOS 客户端

3.3 配置 App Store Server Notifications

路径:

text
App Store Connect
→ 你的 App
→ App Information
→ App Store Server Notifications

建议配置:

text
Production Server URL:
https://api.yourdomain.com/apple/iap/notifications

Sandbox Server URL:
https://api.yourdomain.com/apple/iap/notifications/sandbox

通知版本选择:

text
Version 2

Apple 的通知会告诉后端关键事件,例如内购退款等。你们虽然不是订阅,也需要处理退款事件。

---

4. 整体购买流程

4.1 正常购买流程

text
1. iOS 调后端 GET /api/iap/products
   ↓
2. 后端返回业务商品 + appleProductId
   ↓
3. iOS 用 Product.products(for:) 向 Apple 拉商品详情和价格
   ↓
4. 用户点击购买
   ↓
5. iOS 先调后端 POST /api/iap/orders 创建预订单
   ↓
6. 后端返回 orderNo + appAccountToken
   ↓
7. iOS 用 StoreKit purchase 发起购买,并带上 appAccountToken
   ↓
8. Apple 支付成功
   ↓
9. iOS 拿到 transaction.jwsRepresentation
   ↓
10. iOS 调 POST /api/iap/apple/verify
   ↓
11. Java 后端验签、校验、入库、发货
   ↓
12. 后端返回 GRANTED
   ↓
13. iOS 调 transaction.finish()
   ↓
14. iOS 刷新余额 / 时长

4.2 为什么要先创建预订单

预订单的作用:

  1. 绑定当前登录用户。
  2. 生成 appAccountToken,把 Apple 交易和你们用户绑定起来。
  3. 方便排查支付成功但发货失败的问题。
  4. 方便做风控和客服查询。

---

5. iOS 和后端的接口约定

5.1 获取商品列表

http
GET /api/iap/products
Authorization: Bearer <login_token>

响应:

json
{
  "products": [
    {
      "productCode": "DANBING_60",
      "appleProductId": "pyy_danbing_60",
      "assetType": "DANBING",
      "grantAmount": 60,
      "enabled": true,
      "sort": 1
    },
    {
      "productCode": "TIMECARD_60M",
      "appleProductId": "pyy_timecard_60m",
      "assetType": "TIME_MINUTES",
      "grantAmount": 60,
      "enabled": true,
      "sort": 2
    }
  ]
}

iOS 拿到 appleProductId 后,再调用 StoreKit:

swift
let products = try await Product.products(for: appleProductIds)

iOS 展示价格时使用:

swift
product.displayPrice

不要用后端价格。

---

5.2 创建预订单

http
POST /api/iap/orders
Authorization: Bearer <login_token>
Content-Type: application/json

请求:

json
{
  "appleProductId": "pyy_danbing_60"
}

响应:

json
{
  "orderNo": "IAP202605250001",
  "appleProductId": "pyy_danbing_60",
  "appAccountToken": "550e8400-e29b-41d4-a716-446655440000"
}

appAccountToken 建议使用 UUID。

iOS 购买时带上:

swift
let token = UUID(uuidString: appAccountToken)!
let result = try await product.purchase(options: [
    .appAccountToken(token)
])

---

5.3 验证交易并发货

http
POST /api/iap/apple/verify
Authorization: Bearer <login_token>
Content-Type: application/json

请求:

json
{
  "orderNo": "IAP202605250001",
  "signedTransactionInfo": "eyJhbGciOiJFUzI1NiIsImtpZCI6..."
}

响应成功:

json
{
  "status": "GRANTED",
  "transactionId": "2000000888888888",
  "appleProductId": "pyy_danbing_60",
  "assetType": "DANBING",
  "grantAmount": 60,
  "balance": {
    "danbing": 260,
    "timeMinutes": 120
  }
}

重复提交同一笔交易时,也应该返回成功:

json
{
  "status": "ALREADY_GRANTED",
  "transactionId": "2000000888888888"
}

这样 iOS 可以安全地调用 finish()

---

5.4 查询余额

http
GET /api/wallet/balance
Authorization: Bearer <login_token>

响应:

json
{
  "danbing": 260,
  "timeMinutes": 120
}

注意:iOS 不应该自己维护最终余额。

余额以后端为准。

---

5.5 Apple Server Notification 回调

http
POST /api/apple/iap/notifications
Content-Type: application/json

Apple 请求体:

json
{
  "signedPayload": "eyJhbGciOiJFUzI1NiIs..."
}

后端处理:

text
1. 验证 signedPayload
2. 解出 notificationType
3. 解出 signedTransactionInfo
4. 找到 transactionId / originalTransactionId / productId
5. 幂等处理通知事件
6. 如果是退款,更新交易状态并扣回权益

---

6. Java 后端依赖

推荐使用 Apple 官方 Java 库:

Maven

xml
<dependency>
    <groupId>com.apple.itunes.storekit</groupId>
    <artifactId>app-store-server-library</artifactId>
    <version>5.2.0</version>
</dependency>

Gradle

gradle
implementation 'com.apple.itunes.storekit:app-store-server-library:5.2.0'

该库支持 App Store Server API、App Store Server Notifications,并提供 SignedDataVerifier 用于验签 Apple 的 JWS 数据。

---

7. 后端配置项

建议配置成环境变量或配置中心:

yaml
apple:
  iap:
    bundle-id: com.bujingyun.pyydn
    app-apple-id: 1234567890
    issuer-id: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
    key-id: ABCDEFGHIJ
    private-key-path: /secure/apple/AuthKey_ABCDEFGHIJ.p8
    environment: PRODUCTION
    allow-sandbox-transactions: true
    root-ca-paths:
      - /secure/apple/AppleRootCA-G3.cer
      - /secure/apple/AppleRootCA-G2.cer

关于 Sandbox 和 Production

上线后你们的正式 App 会有生产交易,但 App Review、TestFlight、沙盒账号测试会产生 Sandbox 交易。

建议:

text
生产环境后端可以识别 Production 和 Sandbox
但要记录 environment

如果你们担心沙盒交易被正式用户滥用,可以做策略:

text
1. TestFlight / 审核阶段允许 Sandbox
2. 正式环境只允许 Apple 审核账号 / 内部测试账号使用 Sandbox
3. 普通线上用户只接受 Production

---

8. 数据库设计

以下以 MySQL 为例。

8.1 商品配置表

sql
CREATE TABLE iap_product_config (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_code VARCHAR(64) NOT NULL UNIQUE,
    apple_product_id VARCHAR(128) NOT NULL UNIQUE,
    asset_type VARCHAR(32) NOT NULL COMMENT 'DANBING/TIME_MINUTES',
    grant_amount BIGINT NOT NULL,
    enabled TINYINT NOT NULL DEFAULT 1,
    sort_order INT NOT NULL DEFAULT 0,
    remark VARCHAR(255),
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL
);

示例数据:

sql
INSERT INTO iap_product_config
(product_code, apple_product_id, asset_type, grant_amount, enabled, sort_order, created_at, updated_at)
VALUES
('DANBING_60', 'pyy_danbing_60', 'DANBING', 60, 1, 1, NOW(), NOW()),
('DANBING_300', 'pyy_danbing_300', 'DANBING', 300, 1, 2, NOW(), NOW()),
('TIMECARD_60M', 'pyy_timecard_60m', 'TIME_MINUTES', 60, 1, 3, NOW(), NOW());

---

8.2 预订单表

sql
CREATE TABLE iap_order (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(64) NOT NULL UNIQUE,
    user_id BIGINT NOT NULL,
    apple_product_id VARCHAR(128) NOT NULL,
    app_account_token VARCHAR(64) NOT NULL UNIQUE,
    status VARCHAR(32) NOT NULL COMMENT 'CREATED/VERIFYING/GRANTED/CANCELLED/FAILED',
    transaction_id VARCHAR(128),
    fail_reason VARCHAR(512),
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    INDEX idx_user_id (user_id),
    INDEX idx_status (status),
    INDEX idx_transaction_id (transaction_id)
);

订单状态建议:

| 状态 | 含义 |

|---|---|

| CREATED | 后端已创建预订单,等待 Apple 支付 |

| VERIFYING | iOS 已提交交易,后端正在验证 |

| GRANTED | 验证成功并已发货 |

| CANCELLED | 用户取消支付 |

| FAILED | 验证失败或发货失败,需要人工/任务修复 |

---

8.3 Apple 交易表

sql
CREATE TABLE apple_iap_transaction (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    transaction_id VARCHAR(128) NOT NULL UNIQUE,
    original_transaction_id VARCHAR(128),
    web_order_line_item_id VARCHAR(128),
    user_id BIGINT NOT NULL,
    order_no VARCHAR(64),
    apple_product_id VARCHAR(128) NOT NULL,
    app_account_token VARCHAR(64),
    bundle_id VARCHAR(255) NOT NULL,
    environment VARCHAR(32) NOT NULL COMMENT 'SANDBOX/PRODUCTION',
    transaction_reason VARCHAR(64),
    purchase_date DATETIME,
    signed_date DATETIME,
    quantity INT NOT NULL DEFAULT 1,
    revocation_date DATETIME NULL,
    revocation_reason VARCHAR(64),
    status VARCHAR(32) NOT NULL COMMENT 'VERIFIED/GRANTED/REVOKED/FAILED',
    raw_signed_transaction_info LONGTEXT NOT NULL,
    raw_decoded_payload JSON,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    INDEX idx_user_id (user_id),
    INDEX idx_original_transaction_id (original_transaction_id),
    INDEX idx_product_id (apple_product_id),
    INDEX idx_environment (environment),
    INDEX idx_status (status)
);

关键点:

text
transaction_id 必须唯一

这是防重复发货的核心。

---

8.4 用户资产账户表

sql
CREATE TABLE user_asset_account (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    asset_type VARCHAR(32) NOT NULL COMMENT 'DANBING/TIME_MINUTES',
    balance BIGINT NOT NULL DEFAULT 0,
    version BIGINT NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_user_asset (user_id, asset_type)
);

建议:

text
蛋饼:整数
时长:分钟数整数

不要用浮点数。

---

8.5 用户资产流水表

sql
CREATE TABLE user_asset_ledger (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    asset_type VARCHAR(32) NOT NULL,
    direction VARCHAR(16) NOT NULL COMMENT 'IN/OUT',
    amount BIGINT NOT NULL,
    balance_after BIGINT NOT NULL,
    source_type VARCHAR(64) NOT NULL COMMENT 'APPLE_IAP/APPLE_REFUND/CLOUD_USAGE/ADMIN',
    source_id VARCHAR(128) NOT NULL,
    remark VARCHAR(255),
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_source_once (source_type, source_id, asset_type, direction),
    INDEX idx_user_asset (user_id, asset_type),
    INDEX idx_created_at (created_at)
);

这张表非常重要。

后面客服查账、处理退款、排查用户说“没到账”,都靠它。

---

8.6 Apple 通知事件表

sql
CREATE TABLE apple_iap_notification_event (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    notification_uuid VARCHAR(128) NOT NULL UNIQUE,
    notification_type VARCHAR(128),
    subtype VARCHAR(128),
    environment VARCHAR(32),
    transaction_id VARCHAR(128),
    original_transaction_id VARCHAR(128),
    signed_payload LONGTEXT NOT NULL,
    decoded_payload JSON,
    process_status VARCHAR(32) NOT NULL COMMENT 'PENDING/PROCESSED/FAILED',
    fail_reason VARCHAR(512),
    received_at DATETIME NOT NULL,
    processed_at DATETIME NULL,
    INDEX idx_transaction_id (transaction_id),
    INDEX idx_status (process_status)
);

通知也必须幂等。

Apple 可能重试通知,你们不能因为重复通知重复扣款或重复操作。

---

9. 验签逻辑

9.1 初始化 SignedDataVerifier

java
import com.apple.itunes.storekit.model.Environment;
import com.apple.itunes.storekit.verification.SignedDataVerifier;

import java.io.FileInputStream;
import java.io.InputStream;
import java.util.Set;

public class AppleVerifierFactory {

    public SignedDataVerifier createVerifier(
            String bundleId,
            Long appAppleId,
            Environment environment,
            Set<String> rootCaPaths
    ) throws Exception {

        Set<InputStream> rootCAs = new java.util.HashSet<>();
        for (String path : rootCaPaths) {
            rootCAs.add(new FileInputStream(path));
        }

        boolean enableOnlineChecks = true;

        return new SignedDataVerifier(
                rootCAs,
                bundleId,
                appAppleId,
                environment,
                enableOnlineChecks
        );
    }
}

生产环境建议准备两个 verifier:

text
productionVerifier
sandboxVerifier

验证时按策略尝试。

---

9.2 交易验证核心代码

示例代码只展示核心逻辑,实际项目要接入你们的 DAO、事务、日志和异常体系。

java
import com.apple.itunes.storekit.model.Environment;
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
import com.apple.itunes.storekit.verification.SignedDataVerifier;
import com.apple.itunes.storekit.verification.VerificationException;

public class AppleIapVerifyService {

    private final SignedDataVerifier productionVerifier;
    private final SignedDataVerifier sandboxVerifier;
    private final IapOrderRepository orderRepository;
    private final IapProductRepository productRepository;
    private final AppleTransactionRepository transactionRepository;
    private final UserAssetService userAssetService;

    public VerifyResult verifyAndGrant(Long loginUserId, String orderNo, String signedTransactionInfo) {
        DecodedTransaction decoded = verifySignedTransaction(signedTransactionInfo);

        JWSTransactionDecodedPayload payload = decoded.payload();

        String transactionId = payload.getTransactionId();
        String productId = payload.getProductId();

        if (transactionId == null || transactionId.isBlank()) {
            throw new BizException("APPLE_TRANSACTION_ID_EMPTY");
        }

        if (productId == null || productId.isBlank()) {
            throw new BizException("APPLE_PRODUCT_ID_EMPTY");
        }

        IapProductConfig product = productRepository.findEnabledByAppleProductId(productId)
                .orElseThrow(() -> new BizException("UNKNOWN_APPLE_PRODUCT_ID"));

        IapOrder order = orderRepository.findByOrderNo(orderNo)
                .orElseThrow(() -> new BizException("ORDER_NOT_FOUND"));

        if (!order.getUserId().equals(loginUserId)) {
            throw new BizException("ORDER_USER_MISMATCH");
        }

        if (!order.getAppleProductId().equals(productId)) {
            throw new BizException("ORDER_PRODUCT_MISMATCH");
        }

        String appAccountToken = payload.getAppAccountToken() == null
                ? null
                : payload.getAppAccountToken().toString();

        if (appAccountToken != null && !appAccountToken.equalsIgnoreCase(order.getAppAccountToken())) {
            throw new BizException("APP_ACCOUNT_TOKEN_MISMATCH");
        }

        if (payload.getRevocationDate() != null) {
            throw new BizException("TRANSACTION_ALREADY_REVOKED");
        }

        return grantIdempotently(loginUserId, order, product, decoded, signedTransactionInfo);
    }

    private DecodedTransaction verifySignedTransaction(String signedTransactionInfo) {
        try {
            JWSTransactionDecodedPayload payload =
                    productionVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
            return new DecodedTransaction(payload, Environment.PRODUCTION);
        } catch (VerificationException productionError) {
            try {
                JWSTransactionDecodedPayload payload =
                        sandboxVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
                return new DecodedTransaction(payload, Environment.SANDBOX);
            } catch (VerificationException sandboxError) {
                throw new BizException("APPLE_TRANSACTION_VERIFY_FAILED");
            }
        }
    }
}

---

10. 发货必须用数据库事务

发货流程必须在一个事务内完成:

text
1. 插入 apple_iap_transaction
2. 判断 transaction_id 是否已存在
3. 更新用户资产余额
4. 写 user_asset_ledger
5. 更新订单状态
6. 提交事务

伪代码:

java
@Transactional
public VerifyResult grantIdempotently(
        Long userId,
        IapOrder order,
        IapProductConfig product,
        DecodedTransaction decoded,
        String rawSignedTransactionInfo
) {
    String transactionId = decoded.payload().getTransactionId();

    Optional<AppleIapTransaction> existing =
            transactionRepository.findByTransactionId(transactionId);

    if (existing.isPresent()) {
        AppleIapTransaction tx = existing.get();

        if (!tx.getUserId().equals(userId)) {
            // 同一 Apple 交易被不同用户提交,属于高危风险
            throw new BizException("TRANSACTION_ALREADY_BOUND_TO_OTHER_USER");
        }

        if ("GRANTED".equals(tx.getStatus())) {
            return VerifyResult.alreadyGranted(transactionId);
        }

        // 如果之前验证过但发货失败,可以进入补发逻辑
        return retryGrant(tx, product);
    }

    AppleIapTransaction tx = transactionRepository.insertVerifiedTransaction(
            userId,
            order.getOrderNo(),
            transactionId,
            decoded.payload(),
            decoded.environment(),
            rawSignedTransactionInfo
    );

    long quantity = decoded.payload().getQuantity() == null
            ? 1
            : decoded.payload().getQuantity();

    long grantAmount = product.getGrantAmount() * quantity;

    userAssetService.increaseBalance(
            userId,
            product.getAssetType(),
            grantAmount,
            "APPLE_IAP",
            transactionId,
            "Apple 内购发货:" + product.getAppleProductId()
    );

    transactionRepository.markGranted(transactionId);
    orderRepository.markGranted(order.getOrderNo(), transactionId);

    return VerifyResult.granted(
            transactionId,
            product.getAppleProductId(),
            product.getAssetType(),
            grantAmount
    );
}

为什么要幂等

iOS 可能重复提交同一个 signedTransactionInfo,比如:

  1. 网络超时。
  2. 用户杀 App。
  3. 后端返回丢包。
  4. Transaction.updates 再次收到交易。
  5. 用户点“重试发货”。

这些情况下,后端都必须做到:

text
同一个 transactionId 只发货一次

---

11. iOS 端配合逻辑

11.1 购买成功后不要立刻 finish

正确顺序:

text
Apple purchase success
↓
拿到 transaction.jwsRepresentation
↓
提交给后端 verify
↓
后端返回 GRANTED / ALREADY_GRANTED
↓
iOS 再 transaction.finish()

如果网络失败或后端失败:

text
不要 finish

这样 StoreKit 后续还能通过 Transaction.updates 把未完成交易推给 App,iOS 可以继续补单。

11.2 iOS 伪代码

swift
func buy(product: Product, order: IapOrder) async throws {
    let appAccountToken = UUID(uuidString: order.appAccountToken)!

    let result = try await product.purchase(options: [
        .appAccountToken(appAccountToken)
    ])

    switch result {
    case .success(let verification):
        let transaction = try checkVerified(verification)

        let response = try await api.verifyApplePurchase(
            orderNo: order.orderNo,
            signedTransactionInfo: transaction.jwsRepresentation
        )

        if response.status == "GRANTED" || response.status == "ALREADY_GRANTED" {
            await transaction.finish()
            await refreshBalance()
        }

    case .userCancelled:
        break

    case .pending:
        // 家庭共享/家长批准等场景,等待 Transaction.updates
        break

    @unknown default:
        break
    }
}

11.3 App 启动时监听 Transaction.updates

swift
Task.detached {
    for await result in Transaction.updates {
        do {
            let transaction = try checkVerified(result)

            let response = try await api.verifyApplePurchase(
                orderNo: findOrderNoIfPossible(transaction),
                signedTransactionInfo: transaction.jwsRepresentation
            )

            if response.status == "GRANTED" || response.status == "ALREADY_GRANTED" {
                await transaction.finish()
            }
        } catch {
            // 记录日志,稍后重试
        }
    }
}

如果找不到 orderNo,后端可以提供一个备用接口:

http
POST /api/iap/apple/verify-without-order

但这个接口必须要求用户已登录,并严格校验 appAccountToken 或其他绑定关系。

---

12. 消耗品不建议做“恢复购买”

Apple 的“恢复购买”主要用于非消耗品和订阅。

你们是消耗品,用户买的是蛋饼和时长卡,本质上应该由你们后端账户体系保存余额。

所以你们的“恢复”逻辑应该是:

text
用户登录账号
↓
App 调 GET /api/wallet/balance
↓
后端返回蛋饼余额和时长余额

不要指望 Apple 自动恢复已经消耗的蛋饼或时长卡。

---

13. 退款处理

虽然是一次性消耗品,也必须处理退款。

13.1 退款通知来了后做什么

当 Apple 通知某个交易退款:

text
1. 验证通知 signedPayload
2. 解出 transactionId
3. 找到原交易
4. 将交易状态改为 REVOKED
5. 写退款流水
6. 扣回对应资产

13.2 扣回策略

对蛋饼和时长卡,推荐策略:

情况 A:用户余额足够

text
原来买了 60 蛋饼
当前余额 >= 60
直接扣回 60

情况 B:用户余额不够

例如用户买了 60 蛋饼,已经花了 50,只剩 10。

有三种策略:

| 策略 | 说明 |

|---|---|

| 扣到 0,记录欠款 | 推荐 |

| 允许余额为负 | 风控清晰,但产品体验差 |

| 不扣回,只记录风险 | 用户友好,但容易被薅羊毛 |

推荐:

text
扣到 0 + 记录 debtAmount + 标记风控

后续用户充值时优先抵扣欠款,或者限制部分功能。

13.3 退款表字段建议

可以在 apple_iap_transaction 上加:

text
revocation_date
revocation_reason
refund_status
refund_processed_at

同时资产流水写:

text
source_type = APPLE_REFUND
source_id = transactionId
direction = OUT

---

14. Apple Server Notifications 处理

14.1 通知验签

java
import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload;

public void handleNotification(String signedPayload) {
    ResponseBodyV2DecodedPayload payload =
            signedPayloadVerifier.verifyAndDecodeNotification(signedPayload);

    String notificationUUID = payload.getNotificationUUID();
    String notificationType = payload.getNotificationType().getValue();
    String subtype = payload.getSubtype() == null ? null : payload.getSubtype().getValue();

    // 先落库,保证幂等
    saveNotificationEventIfAbsent(notificationUUID, notificationType, subtype, signedPayload);

    // 再按类型处理
}

14.2 消耗品重点关注的通知

你们不是订阅,所以大部分订阅状态不用管。重点关注:

text
REFUND
CONSUMPTION_REQUEST

处理建议:

| notificationType | 处理 |

|---|---|

| REFUND | 标记交易退款,扣回权益 |

| CONSUMPTION_REQUEST | Apple 请求你们提供消耗情况,用于退款决策 |

| 其他订阅类通知 | 记录日志,不影响权益 |

14.3 CONSUMPTION_REQUEST 怎么处理

当 Apple 询问某笔消耗品是否已被用户消费,你们应该根据自己的流水回答:

text
1. 该 transactionId 是否发货成功
2. 发放的蛋饼/时长是否已使用
3. 使用比例多少
4. 用户是否存在异常退款历史

这需要调用 App Store Server API 提交 consumption information。

建议上线前至少做到:

text
收到 CONSUMPTION_REQUEST 后落库 + 告警

如果来不及做自动回复,至少不要丢失事件,方便人工处理。

---

15. 补单机制

上线项目必须有补单机制。

15.1 iOS 端补单

iOS 每次启动:

text
监听 Transaction.updates
检查未 finish 的 transaction
重新提交后端 verify

15.2 后端补单

后端定时任务扫描:

text
iap_order.status IN ('CREATED', 'VERIFYING')
且 created_at 超过 10 分钟

对于没有交易凭证的订单,只能标记超时或等待客户端补交。

对于已经有 transaction_id 但未发货成功的订单:

text
重试发货

15.3 客服补单

后台管理系统需要支持:

text
按 userId 查询订单
按 transactionId 查询交易
按 orderNo 查询订单
手动重试发货
查看 Apple 原始 payload
查看资产流水

---

16. 风控规则

建议至少加这些规则:

16.1 同一 transactionId 多用户提交

text
高危,拒绝发货,记录安全日志

16.2 Product ID 不存在

text
拒绝发货

16.3 appAccountToken 不匹配

text
拒绝发货

16.4 Bundle ID 不匹配

text
拒绝发货

16.5 Sandbox 交易策略

text
正式环境普通用户不要随便接受 Sandbox 交易

可以配置白名单:

text
review_user_ids
internal_test_user_ids

16.6 退款率过高

记录用户退款次数:

text
refund_count_30d
refund_amount_30d

超过阈值可以:

text
限制购买
限制使用云电脑
进入人工审核

---

17. 错误码建议

| 错误码 | 含义 | iOS 处理 |

|---|---|---|

| ORDER_NOT_FOUND | 订单不存在 | 提示重试/重新下单 |

| ORDER_USER_MISMATCH | 订单不属于当前用户 | 重新登录 |

| UNKNOWN_APPLE_PRODUCT_ID | 商品 ID 未配置 | 提示商品不可用 |

| APPLE_TRANSACTION_VERIFY_FAILED | Apple 验签失败 | 不 finish,稍后重试 |

| TRANSACTION_ALREADY_BOUND_TO_OTHER_USER | 交易已绑定其他用户 | 提示联系客服 |

| TRANSACTION_ALREADY_REVOKED | 交易已退款/撤销 | 不发货 |

| GRANT_FAILED | 发货失败 | 不 finish,稍后重试 |

| ALREADY_GRANTED | 已发货 | finish 并刷新余额 |

---

18. 上线前测试清单

18.1 正常购买

text
购买 60 蛋饼
后端发货
余额增加
交易流水存在
iOS finish

18.2 重复提交

text
同一个 signedTransactionInfo 连续提交 5 次
只能发货一次
后续返回 ALREADY_GRANTED

18.3 网络中断

text
Apple 支付成功后断网
iOS 不 finish
恢复网络后 Transaction.updates 补单
后端发货一次

18.4 商品错配

text
order 是 pyy_danbing_60
transaction 是 pyy_timecard_60m
后端拒绝发货

18.5 用户错配

text
A 用户订单
B 用户提交
后端拒绝

18.6 退款

text
Apple 退款通知
交易状态变 REVOKED
扣回蛋饼/时长
产生 APPLE_REFUND 流水

18.7 Sandbox / TestFlight / App Review

text
沙盒账号购买
TestFlight 购买
审核账号购买
后端能识别 environment

---

19. Java 后端最小类结构

建议:

text
com.pyy.iap
├── controller
│   ├── IapProductController
│   ├── IapOrderController
│   ├── AppleIapVerifyController
│   └── AppleNotificationController
├── service
│   ├── AppleIapVerifyService
│   ├── IapOrderService
│   ├── IapProductService
│   ├── UserAssetService
│   └── AppleNotificationService
├── repository
│   ├── IapProductRepository
│   ├── IapOrderRepository
│   ├── AppleTransactionRepository
│   ├── UserAssetRepository
│   └── UserAssetLedgerRepository
├── model
│   ├── IapProductConfig
│   ├── IapOrder
│   ├── AppleIapTransaction
│   ├── UserAssetAccount
│   └── UserAssetLedger
└── config
    ├── AppleIapProperties
    └── AppleVerifierConfig

---

20. Spring Boot Controller 示例

20.1 验证接口

java
@RestController
@RequestMapping("/api/iap/apple")
public class AppleIapVerifyController {

    private final AppleIapVerifyService appleIapVerifyService;

    public AppleIapVerifyController(AppleIapVerifyService appleIapVerifyService) {
        this.appleIapVerifyService = appleIapVerifyService;
    }

    @PostMapping("/verify")
    public ApiResponse<VerifyResult> verify(
            @AuthenticationPrincipal LoginUser loginUser,
            @RequestBody AppleVerifyRequest request
    ) {
        VerifyResult result = appleIapVerifyService.verifyAndGrant(
                loginUser.getUserId(),
                request.getOrderNo(),
                request.getSignedTransactionInfo()
        );

        return ApiResponse.success(result);
    }
}

请求 DTO:

java
public class AppleVerifyRequest {
    private String orderNo;
    private String signedTransactionInfo;

    public String getOrderNo() {
        return orderNo;
    }

    public String getSignedTransactionInfo() {
        return signedTransactionInfo;
    }
}

---

20.2 通知接口

java
@RestController
@RequestMapping("/api/apple/iap")
public class AppleNotificationController {

    private final AppleNotificationService appleNotificationService;

    public AppleNotificationController(AppleNotificationService appleNotificationService) {
        this.appleNotificationService = appleNotificationService;
    }

    @PostMapping("/notifications")
    public ResponseEntity<Void> handle(@RequestBody AppleNotificationRequest request) {
        appleNotificationService.handle(request.getSignedPayload());
        return ResponseEntity.ok().build();
    }
}

DTO:

java
public class AppleNotificationRequest {
    private String signedPayload;

    public String getSignedPayload() {
        return signedPayload;
    }
}

Apple 通知接口不走用户登录,因为它是 Apple 服务器调用。

安全性靠:

text
signedPayload 验签

同时建议加:

text
IP 访问日志
速率限制
告警

---

21. 资产扣减逻辑

用户使用云电脑时,如果消耗时长:

java
@Transactional
public void consumeTime(Long userId, long minutes, String sessionId) {
    UserAssetAccount account = assetRepository.lockByUserIdAndAssetType(
            userId,
            "TIME_MINUTES"
    );

    if (account.getBalance() < minutes) {
        throw new BizException("TIME_NOT_ENOUGH");
    }

    long newBalance = account.getBalance() - minutes;

    assetRepository.updateBalance(account.getId(), newBalance, account.getVersion());

    ledgerRepository.insert(
            userId,
            "TIME_MINUTES",
            "OUT",
            minutes,
            newBalance,
            "CLOUD_USAGE",
            sessionId,
            "云电脑使用扣减"
    );
}

注意:

text
资产扣减也要写流水

否则后续无法解释“为什么用户时长少了”。

---

22. 不要做的事情

22.1 不要只靠客户端发货

错误:

text
iOS purchase success
→ iOS 本地加蛋饼

这是不安全的。

22.2 不要相信客户端传的 productId

客户端可以传:

json
{
  "productId": "pyy_danbing_999999"
}

后端必须以 Apple JWS 解出来的 productId 为准。

22.3 不要重复发货

没有 transaction_id unique 的系统不能上线。

22.4 不要忽略退款

消耗品也会退款。

忽略退款会导致被刷。

22.5 不要把 .p8 私钥放 App

In-App Purchase Key 只能放后端。

---

23. 推荐上线策略

第一阶段:沙盒联调

text
1. App Store Connect 创建 Consumable 商品
2. 后端配置 SANDBOX verifier
3. iOS 沙盒账号购买
4. 验证发货
5. 测重复提交和网络中断

第二阶段:TestFlight

text
1. 上传 TestFlight
2. 后端允许 Sandbox environment
3. 内部测试购买
4. 确认 Apple 通知能到后端

第三阶段:App Review

text
1. 给审核账号准备测试账号
2. 让审核能看到购买入口
3. 后端允许审核账号 Sandbox 交易
4. 确保购买后能看到蛋饼/时长到账

第四阶段:正式上线

text
1. 开启 Production verifier
2. 保留 Sandbox 兼容策略
3. 开启通知告警
4. 开启交易异常告警
5. 每日对账

---

24. 对账

建议每日任务:

text
1. 统计 Apple 交易表
2. 统计资产流水
3. 统计订单表
4. 找出已支付未发货
5. 找出已退款未扣回
6. 找出交易和流水金额不一致

至少输出:

text
transaction_id
user_id
apple_product_id
order_no
status
grant_status
refund_status

---

25. 你们前后端最终协作边界

iOS 负责

text
1. 展示商品
2. 调 Apple StoreKit 购买
3. 拿 signedTransactionInfo
4. 调后端 verify
5. 后端成功后 finish
6. 展示后端余额

Java 后端负责

text
1. 商品配置
2. 创建预订单
3. 验签 Apple 交易
4. 幂等发货
5. 保存交易和流水
6. 处理退款通知
7. 提供余额查询
8. 提供客服补单能力

App Store Connect 负责

text
1. 维护 Apple 商品
2. 维护价格
3. 维护 Server Notification URL
4. 维护 In-App Purchase Key

---

26. 参考资料

https://developer.apple.com/help/app-store-connect/manage-in-app-purchases/create-consumable-or-non-consumable-in-app-purchases

https://developer.apple.com/help/app-store-connect/configure-in-app-purchase-settings/generate-keys-for-in-app-purchases

https://developer.apple.com/help/app-store-connect/configure-in-app-purchase-settings/enter-server-urls-for-app-store-server-notifications

https://github.com/apple/app-store-server-library-java

---

27. 最终一句话

你们这个项目不要把内购当成“支付成功回调”来做。

正确做法是:

text
Apple 交易 = 支付凭证
Java 验签 = 确认凭证有效
transactionId 幂等 = 防止重复发货
资产流水 = 解释每一次蛋饼/时长变化
Server Notifications = 处理退款和后续状态

这样才是可以上线的消耗品内购方案。