如何通过 IAP 为 UWP 服务实现月付或年付
结论
一个月或一年的服务使用权,优先用 expiring durable product(有期限的 durable IAP) 表示。许可证到期并变为 inactive 后,用户可以再次购买该产品;只有许可证仍在有效期内时,才应阻止重复购买。
不建议直接用 consumable product 充当许可证。Consumable 表示一笔可消耗权益,Microsoft Store 不会替应用维护长期授权状态。如果没有自己的服务端记录,应用卸载、换机或清除数据后,剩余使用期很难可靠恢复。
授权判断不能只看 ExpirationDate,也不能把 DateTime.MaxValue 当作有效期限。应先检查 IsActive。如果需要提供 grace period,还要自行保存可信的实际到期时间,最好将其保存在服务端。
推荐的实现方式
可以分别创建月付和年付产品,例如:
servicex.monthly:有效期一个月servicex.yearly:有效期一年
这里的“月付或年付”不是自动续费订阅。实际流程如下:
- 用户购买一个有期限的 durable product。
- Store 在有效期内返回 active license。
- 到期后,许可证变为 inactive。
- 应用提示用户重新购买。
- 用户完成购买后获得新的有效期。
因此,界面和购买说明需要明确标注“到期后手动续购”,不能将其描述为自动续订。
不要只检查 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 后,应用通常需要:
- 验证购买结果。
- 将购买转换为自己的服务余额或到期时间。
- 持久化该状态。
- 正确完成 fulfillment 流程,之后才允许再次购买。
如果这些数据只保存在本机,卸载后就无法恢复。使用 consumable 并不能解决服务端持久化问题,只是把许可证管理责任全部交给应用。
只有当服务本身要求用户登录,并且服务端能够防止重复兑换、保存每笔交易及累计有效期时,才适合用 consumable 实现“购买 N 个月”的模式。
测试建议
CurrentAppSimulator 可以模拟购买成功、取消、有效和失效等基本流程,但不能证明真实 Store 在所有边界情况下都会返回完全相同的字段值。尤其不要依赖 inactive license 的 ExpirationDate。
更稳妥的做法是将 Store API 包装成自己的授权接口,并测试以下状态:
- 产品从未购买;
- 许可证 active,且存在正常到期时间;
- 许可证 inactive,
ExpirationDate = DateTimeOffset.MaxValue; - 许可证刚刚到期;
- 处于服务端记录的 grace period;
- grace period 已结束;
- Store 暂时不可访问;
- 购买完成后授权信息尚未刷新;
- 应用卸载重装或在另一台设备登录。
最后,应使用发布到 Store 的测试版本或受控账号完成端到端验证。业务判断应以 IsActive 和经过验证的持久化记录为基础,不能假设 ExpirationDate 在许可证失效后仍具有业务含义。
备注:内容仅供参考。
