PAYATHON 2026

如何通过 IAP 为 UWP 服务实现月付或年付

支付小周

结论

一个月或一年的服务使用权,优先用 expiring durable product(有期限的 durable IAP) 表示。许可证到期并变为 inactive 后,用户可以再次购买该产品;只有许可证仍在有效期内时,才应阻止重复购买。

不建议直接用 consumable product 充当许可证。Consumable 表示一笔可消耗权益,Microsoft Store 不会替应用维护长期授权状态。如果没有自己的服务端记录,应用卸载、换机或清除数据后,剩余使用期很难可靠恢复。

授权判断不能只看 ExpirationDate,也不能把 DateTime.MaxValue 当作有效期限。应先检查 IsActive。如果需要提供 grace period,还要自行保存可信的实际到期时间,最好将其保存在服务端。

推荐的实现方式

可以分别创建月付和年付产品,例如:

  • servicex.monthly:有效期一个月
  • servicex.yearly:有效期一年

这里的“月付或年付”不是自动续费订阅。实际流程如下:

  1. 用户购买一个有期限的 durable product。
  2. Store 在有效期内返回 active license。
  3. 到期后,许可证变为 inactive。
  4. 应用提示用户重新购买。
  5. 用户完成购买后获得新的有效期。

因此,界面和购买说明需要明确标注“到期后手动续购”,不能将其描述为自动续订。

不要只检查 ExpirationDate

下面的判断方式存在问题:

Return (license.ExpirationDate - DateTime.Now).Days + GracePeriodDays > 0

原因有三个:

  • inactive product 的 ExpirationDate 可能是 DateTimeOffset.MaxValue,但这不表示许可证仍然有效。
  • TimeSpan.Days 只返回整数天部分,会发生截断。
  • 用户可以修改本地系统时间,因此它不适合作为高价值服务的唯一授权依据。

基础授权判断应以 IsActive 为准:

Function IsFeatureAllowed(productId As String) As Boolean
    Dim license = CurrentApp.LicenseInformation.ProductLicenses(productId)
    Return license IsNot Nothing AndAlso license.IsActive
End Function

只有在许可证不存在或已经失效时,才应开放购买入口:

Async Function OfferNewLicenseAsync(productId As String) As Task
    Dim license = CurrentApp.LicenseInformation.ProductLicenses(productId)

    If license Is Nothing OrElse Not license.IsActive Then
        Dim result = Await CurrentApp.RequestProductPurchaseAsync(productId)
        ' 购买后重新读取 LicenseInformation,
        ' 不要只根据购买调用是否返回来开放服务。
    End If
End Function

具体返回类型和错误处理方式取决于项目使用的是旧版 CurrentApp API,还是较新的 StoreContext API。不过,两者的判断原则相同:购买完成后,需要重新获取 Store 授权状态。

Grace period 应如何实现

如果要在 Store 暂时不可用、网络异常或许可证刷新失败时提供宽限期,必须先取得一个可信的实际到期时间。许可证变为 inactive 后,不要再根据它的 ExpirationDate 推算到期时间,因为该字段此时可能是哨兵值。

可以在许可证仍为 active 时记录到期时间:

Function IsValidExpiration(expiration As DateTimeOffset) As Boolean
    Return expiration <> DateTimeOffset.MaxValue AndAlso
           expiration <> DateTimeOffset.MinValue
End Function

服务端可以保存以下数据:

UserId
ProductId
PurchaseId
ExpirationUtc
LastVerifiedUtc

授权逻辑可以写成:

Function IsFeatureAllowed(
    license As ProductLicense,
    storedExpirationUtc As DateTimeOffset?,
    gracePeriod As TimeSpan,
    nowUtc As DateTimeOffset
) As Boolean

    If license IsNot Nothing AndAlso license.IsActive Then
        Return True
    End If

    If storedExpirationUtc.HasValue Then
        Return nowUtc <= storedExpirationUtc.Value.Add(gracePeriod)
    End If

    Return False
End Function

其中,storedExpirationUtc 应来自此前成功验证并保存的许可证记录。如果只保存在本地,卸载应用后仍会丢失。要支持卸载后恢复或跨设备恢复,就应将记录绑定到应用账户,并保存在服务端。

宽限期也不应在每次安装后重新计算,否则用户可能通过反复卸载获得无限宽限。

为什么 consumable product 不理想

Consumable 适合表示次数、点数或额度,例如“100 次处理”或“10 GB 流量”,不适合表示由 Store 持续维护的许可证。购买 consumable 后,应用通常需要:

  1. 验证购买结果。
  2. 将购买转换为自己的服务余额或到期时间。
  3. 持久化该状态。
  4. 正确完成 fulfillment 流程,之后才允许再次购买。

如果这些数据只保存在本机,卸载后就无法恢复。使用 consumable 并不能解决服务端持久化问题,只是把许可证管理责任全部交给应用。

只有当服务本身要求用户登录,并且服务端能够防止重复兑换、保存每笔交易及累计有效期时,才适合用 consumable 实现“购买 N 个月”的模式。

测试建议

CurrentAppSimulator 可以模拟购买成功、取消、有效和失效等基本流程,但不能证明真实 Store 在所有边界情况下都会返回完全相同的字段值。尤其不要依赖 inactive license 的 ExpirationDate。

更稳妥的做法是将 Store API 包装成自己的授权接口,并测试以下状态:

  • 产品从未购买;
  • 许可证 active,且存在正常到期时间;
  • 许可证 inactive,ExpirationDate = DateTimeOffset.MaxValue;
  • 许可证刚刚到期;
  • 处于服务端记录的 grace period;
  • grace period 已结束;
  • Store 暂时不可访问;
  • 购买完成后授权信息尚未刷新;
  • 应用卸载重装或在另一台设备登录。

最后,应使用发布到 Store 的测试版本或受控账号完成端到端验证。业务判断应以 IsActive 和经过验证的持久化记录为基础,不能假设 ExpirationDate 在许可证失效后仍具有业务含义。

备注:内容仅供参考。