Apple IAP 方案 (2):Apple 内购的服务器设计方案

7,790 阅读8分钟

在之前的文章《痛苦之源!Apple 内购的应用和服务器设计(一)》中,我介绍了设计 Apple 内购服务器需要用到的各个接口以及相关的文档,并且针对各个接口给了 Python 和 Java 两个版本的示例代码。

在这篇文章中,我将介绍如何整合这些接口并设计一个 Apple 内购服务器的。

1、整体设计

这里不论 iPhone 客户端和后端统一使用苹果内购的 v2 版本开发。

注:开发该项目过程中,我使用了之前已经搭建好的服务器。该服务器使用 SpringBoot 开发,如果你希望开发一个自己的服务器,但是又没有足够的经验,你可以使用我基于自己的项目开源的服务器项目 Seed 进行开发。

该内购服务器的整体设计如下所示,

2.0-苹果IAP流程设计UML.png

当前版本,我考虑的流程主要有常规支付流程、退款流程和历史通知定时轮询三个。

1.1 常规支付流程

常规支付流程指的是从客户端发起购买到 Apple 服务器校验再到我们自己的服务器校验的过程。可能存在很多应用不依赖于自己的服务器的校验,直接在客户端调用苹果的接口校验订单信息。这样也可以,但是不够安全。这个流程中,我们会将客户端购买完成成果之后的 商品 ID、应用的 Bundle ID 以及交易的 ID 传给后端,然后由后端调用 Apple 提供的 V2 版本的接口进行订单信息的校验。

最近也看到一些 Apple 内购破解的机制,其中有一个就是使用伪装的交易 ID 进行校验。如果我们的接口内只用交易 ID 作为判断依据,就容易被黑。所以,这里需要注意需要将 商品 ID、应用的 Bundle ID 以及交易的 ID 一起作为判断的依据。

另外,我们也可以看到基于 Apple 的 v2 版本的接口,相比于基于 v1 版本接口而言,要传输的数据确实少了很多。这对于提升接口的性能至关重要。

1.2 退款处理流程

退款处理流程指的是通过监听来自 Apple 的回调的方式处理退款的流程。我们可以在 Apple Store Connect 的产品信息中填写暴露给 Apple 的接口的地址。

这里有个坑,就是提供给 Apple 的接口必须支持 https 协议,不论生产环境还是开发环境。我刚注销了一个域名,而免费 SSL 证书的子域名又无法使用 SSL. 所以,这个在开发环境一直走不通。

在用户发起退款的时候,Apple 会以服务器到服务器的形式向我们填入的接口发送一个请求,并附带一些订单的信息(本质上就是一个 http 请求)。我们可以根据该订单信息的内容将用户的指定付费内容设置为不可用即可。

1.3 历史通知定时轮询

历史通知轮询和退款处理在做的事情类似。可能是考虑到用户的服务器可能会因为宕机等情形的出现导致没有及时接收到来自 Apple 服务器的请求。所以,Apple 给我们提供了主动向 Apple 服务器查询历史通知的接口。

因此,基于该接口,我们可以做如下设计:使用 SpringBoot 的定时任务功能,间隔一段时间主动向 Apple 服务器请求历史通知。然后,按照 1.2 中的流程根据通知的类型做相应的处理即可。

考虑到历史通知可能会因为一些原因得不到正确的处理,而为了排查问题,我这里对历史通知做了落表的处理。以此用于追踪历史通知的实际处理情况,也作为排查用户客述问题的依据。

2、详细设计

2.1 领域模型设计(数据结构)

首先,应用信息数据结构 AppInfo* 新增了字段 packageName。该字段用来获取应用对应的 ID 信息,以在请求对应 IAP 服务器的时候使用。

/**
 * Apple 应用的 Bundle ID
 */
@Column(name = "bundle_id")
@ColumnInfo(comment = "Bundle Id")
private String bundleId;

然后,对商品信息数据库结构 AppGoods 中新增字段 appleGoodsId。这两个字段用来指定商品对应的苹果商品 ID。

@Column(name = "apple_goods_id")
@ColumnInfo(comment = "Apple Goods Id", added = true)
private String appleGoodsId;

然后,对支付信息数据结构 AppPayment 新增三个字段,用来记录苹果支付的信息。这个数据结构主要用来追踪用户的支付信息,

/** 苹果产品 ID */
@Column(name = "apple_product_id")
@ColumnInfo(comment = "Apple Product Id", added = true)
private String appleProductId;

/** 苹果应用的 Bundle ID */
@Column(name = "apple_bundle_id")
@ColumnInfo(comment = "Apple Bundle Id", added = true)
private String appleBundleId;

/** 苹果交易的 ID */
@Column(name = "apple_transaction_id")
@ColumnInfo(comment = "Apple Transaction Id", added = true)
private String appleTransactionId;

然后,新增历史通知数据结构 AppleNotification。该数据结构用来持久化从 Apple Store 查询到的历史通知信息。这个表结构中定义了一些枚举和类,这里的枚举没有映射成整数类型,因为内部使用,没做进一步处理。这里的类类型是以字符串的形式存储到数据库中的,在读取和持久化的时候会使用 json 进行序列化和反序列化。数据结构参考文档:notificationhistoryresponse.

@Data
@Table(name = "gt_apple_notification")
@TableInfo(comment = "Apple Notification")
@EqualsAndHashCode(callSuper = true)
public class AppleNotification extends AbstractPo {

    @Column(name = "notification_uuid")
    @ColumnInfo(comment = "Notification uuid")
    private String notificationUUID;

    /**
     * 第一次发送失败的原因
     */
    @Column(name = "first_result")
    @ColumnInfo(comment = "First send result")
    private FirstSendAttemptResult firstResult;

    @Column(name = "notification_type")
    @ColumnInfo(comment = "Notification type")
    private AppleNotificationType notificationType;

    @Column(name = "subtype")
    @ColumnInfo(comment = "Notification subtype")
    private AppleNotificationSubType subtype;

    @Column(name = "data", length = 2000)
    @ColumnInfo(comment = "Notification data")
    private AppleNotification.Data data;

    @Column(name = "summary", length = 2000)
    @ColumnInfo(comment = "Notification summary")
    private AppleNotification.Summary summary;

    @Column(name = "version")
    @ColumnInfo(comment = "Version")
    private String version;

    @Column(name = "signed_date")
    @ColumnInfo(comment = "Singed date")
    private Date signedDate;

    /**
     * 通知的状态
     */
    @Column(name = "status")
    @ColumnInfo(comment = "Notification status", added = true)
    private Status status;
}

最后,对会员信息数据结构 UserVipInfo* 改动。新增的两个字段分别用来追踪订单信息和禁用某个会员信息(比如用户退款等时候禁用,而不是删除,不能删除,需要保留数据记录)。

/**
 * 生成该会员记录的订单的 ID
 */
@Column(name = "payment_id")
@ColumnInfo(comment = "App Payment Id", added = true)
private Long paymentId;

/**
 * 该会员信息启用/禁用
 */
@Column(name = "enable")
@ColumnInfo(comment = "User vip info enabled or disabled", added = true)
private Boolean enable;

2.2 涉及的 Apple 接口

下面是整体流程设计中用到的 Apple 的接口,

/** 苹果内购订单信息相关 API */
public interface AppleTransactionApi {

    /**
     * 获取订单的历史信息,文档:
     * https://developer.apple.com/documentation/appstoreserverapi/get_transaction_history
     * *
     * @param originalTransactionId 原始的订单 ID
     * @return 历史记录响应信息
     */
    @GET("/inApps/v1/history/{originalTransactionId}")
    Call<AppleHistoryResponse> getTransactionHistory(
            @Header("Authorization") String authorization,
            @Path("originalTransactionId") String originalTransactionId);

    /**
     * 给 Apple 服务器发送通知请求,文档:
     * https://developer.apple.com/documentation/appstoreserverapi/request_a_test_notification
     *
     * @param authorization authorization
     * @return 请求结果
     */
    @POST("/inApps/v1/notifications/test")
    Call<AppleNotificationTestResponse> testNotification(@Header("Authorization") String authorization);

    /**
     * 获取历史通知,文档:
     * https://developer.apple.com/documentation/appstoreserverapi/get_notification_history
     *
     * @return 历史通知响应
     */
    @POST("/inApps/v1/notifications/history")
    Call<AppleNotificationHistoryResponse> getNotificationHistory(
            @Header("Authorization") String authorization,
            @Body AppleNotificationHistoryRequest request);

    /** 获取历史通知,带分页参数版本。 */
    @POST("/inApps/v1/notifications/history")
    Call<AppleNotificationHistoryResponse> getNotificationHistory(
            @Header("Authorization") String authorization,
            @Query("paginationToken") String paginationToken,
            @Body AppleNotificationHistoryRequest request);
}

在校验订单信息的时候,我们使用 /inApps/v1/history 这个接口,然后将从 Apple 查询到的订单的商品 ID、应用 ID 以及交易 ID 作对比来判断信息是否正确。若正确,则给予用户对应的商品,否则返回错误信息。

而接受 Apple 回调的逻辑则是直接从 Apple 传入的 Payload 中解析历史通知信息,然后处理该通知。代码如下,

@Override
public ResponseEntity<Object> handleNotification(String signedPayload) {
    DecodedJWT decodedJWT = JWT.decode(signedPayload);
    DecodedJWTReader reader = new DecodedJWTReader(decodedJWT);
    NotificationHistory.Payload payload = new NotificationHistory.Payload(reader);
    PackVo<Object> handlePackVo = handleNotification(payload);
    // 返回处理的码给 Apple Store 服务器
    if (handlePackVo.isSuccess()) {
        return ResponseEntity.ok().build();
    }
    return ResponseEntity.badRequest().build();
}

注意,返回给 Apple 服务器的结果码的意义。200 则表示这个通知被正确地处理,否则表示该通知处理失败。

而历史通知定时轮询的逻辑则如下所示,以每小时一次的形式请求历史通知并处理。

/**
 * 每一小时执行一次的任务:Apple Store 的历史通知定时查询
 */
@Async(value = "applicationTaskExecutor")
@Scheduled(cron = "0 0 0/1 * * ?")
public void handleAppleStoreNotifications() {
    log.info("Triggered Apple Store timely history notifications.");
    PackVo<Object> packVo = applePayService.handleHistoryNotification();
    if (!packVo.isSuccess()) {
        log.error("Failed to process Apple Store history notifications.");
    }
    }

这里用了 /inApps/v1/notifications/history 接口,以分页请求的形式以获取所有的历史通知信息,

private PackVo<AppleNotificationHistoryResponse> getNotificationHistory(String bundleId) {
    String authorization = getAuthorization(bundleId);
    if (StrUtil.isEmpty(authorization)) {
        return failure(ERROR_APPLE_IAP_FAILED_TO_GET_AUTHORIZATION);
    }
    AppleTransactionApi appleTransactionApi = new Retrofit.Builder()
            .baseUrl(host)
            .addConverterFactory(RetrofitManager.getFactory())
            .build().create(AppleTransactionApi.class);
    Response<AppleNotificationHistoryResponse> response;
    try {
        AppleNotificationHistoryRequest request = new AppleNotificationHistoryRequest();
        long current = System.currentTimeMillis();
        request.setStartDate(current - 24*60*60*1000); // 过去 24 小时
        request.setEndDate(current);
        response = appleTransactionApi.getNotificationHistory(authorization, request).execute();
        AppleNotificationHistoryResponse appleNotificationHistoryResponse = response.body();
        if (response.isSuccessful() && appleNotificationHistoryResponse != null) {
            appleNotificationHistoryResponse.decode();
            appleNotificationService.saveAppleNotificationHistoryResponse(appleNotificationHistoryResponse);
            return PackVo.success(appleNotificationHistoryResponse);
        } else {
            log.error("Failed to request history notification [{}] [{}]", response.code(), response);
            return failure(ERROR_APPLE_IAP_REQUEST_FAILED);
        }
    } catch (IOException e) {
        log.error("Failed to request history notifications from apple server due to IO exception: ", e);
        return failure(ERROR_APPLE_IAP_REQUEST_FAILED);
    }
}

总结

以上就是 Apple 内购服务器的设计方案,主要是提供一个整体的设计思路,实际的代码是和业务结合的,一堆校验逻辑,没有太大的参考价值。

如有疑问,可在评论区交流。