在iOS应用内购(IAP)的自动续订订阅体系中,当用户输入优惠码(Offer Code)或者通过后台配置的促销优惠(Promotional Offer)完成兑换或续费时,App Store会向开发者服务器推送Server Notification。这类通知的载荷中经常出现两个容易混淆的字段:offer_code_ref_name与promotional_offer_id。如果服务端没有正确区分它们的出现场景并准确解析,就可能把普通优惠码兑换误判为促销优惠,进而发错权益或者重复发货。

通知载荷结构与字段来源剖析
App Store Server Notification V2版本采用统一的外层结构,核心业务数据封装在data对象内。data中包含了signedPayload(V1)或直接的signedTransactionInfo、signedRenewalInfo(V2)。对于订阅相关的状态变更,例如DID_RENEW、OFFER_REDEEMED等类型,真正的优惠信息并不在外层,而是需要把signedRenewalInfo这个JWS字符串进行解码后才能拿到。
在解码后的renewalInfo字典里,offer_code_ref_name是一个字符串,它对应开发者在App Store Connect中创建的Offer Code所对应的reference name,而不是用户实际输入的码值本身。promotional_offer_id则是一个标识,指向你在App Store Connect后台「订阅」中配置的Promotional Offer。两者是互斥的:当用户使用offer code兑换时,renewalInfo里会出现offer_code_ref_name且promotional_offer_id为null;当用户通过app内调用presentCodeRedemptionSheet之外的促销逻辑(如backend offer)订阅时,才会出现promotional_offer_id。
很多服务端同学直接用JSON.parse去解析notification的body,发现根本找不到这两个字段,就是因为忽略了signedRenewalInfo是经过了JWS签名的JWT,必须经过验签和解码。下面是一段Node.js中利用jsonwebtoken库解析的示例,展示如何安全地取出字段:
const jwt = require('jsonwebtoken');
function decodeRenewalInfo(signedRenewalInfo) {
// 注意:生产环境必须先验证Apple根证书签名,此处仅做解码演示
const decoded = jwt.decode(signedRenewalInfo);
if (!decoded) {
throw new Error('无法解码renewalInfo');
}
const offerCodeRefName = decoded.offer_code_ref_name || null;
const promotionalOfferId = decoded.promotional_offer_id || null;
return { offerCodeRefName, promotionalOfferId };
}
// 假设从通知体中拿到了signedRenewalInfo
const sampleSigned = 'eyJhbGciOiJIUzI1NiJ9.eyJvZmZlcl9jb2RlX3JlZl9uYW1lIjoiV0lOVEVSPiIsInByb21vdGlvbmFsX29mZmVyX2lkIjpudWxsfQ.demo';
const result = decodeRenewalInfo(sampleSigned);
console.log(result);
不同通知类型下的解析差异与避坑
并不是所有订阅通知都会携带优惠字段。比如INITIAL_BUY(首次购买)通常没有offer_code_ref_name,除非用户首次就是通过offer code兑换入口完成的订阅。而OFFER_REDEEMED这个subtype明确代表优惠兑换动作,此时必须检查renewalInfo。如果通知类型是DID_RENEW且subtype为空,也可能是普通续费,优惠字段可能为空。
一个常见的坑是:部分开发者把transactionInfo里的字段和renewalInfo里的字段搞混。transactionInfo(signedTransactionInfo解码后)主要描述本次交易本身,如product_id、purchase_date,但它不会包含promotional_offer_id。只有renewalInfo才描述续订与优惠上下文。如果服务端只解了transactionInfo,就会漏掉优惠信息,导致无法针对优惠码用户做专属运营。
此外,Apple偶尔会对同一个兑换行为发送多次通知(例如网络重试),因此解析出offer_code_ref_name后,必须结合original_transaction_id做幂等存储。下面是一段基于数据库唯一索引处理的伪代码逻辑:
import hashlib
def handle_offer_notification(notification):
renewal = decode_jws(notification['data']['signedRenewalInfo'])
tx_id = notification['data']['signedTransactionInfo']['original_transaction_id']
offer_name = renewal.get('offer_code_ref_name')
promo_id = renewal.get('promotional_offer_id')
if offer_name:
key = hashlib.md5(('offer|' + tx_id + '|' + offer_name).encode()).hexdigest()
save_if_not_exists(key, type='offer_code', value=offer_name)
elif promo_id:
key = hashlib.md5(('promo|' + tx_id + '|' + promo_id).encode()).hexdigest()
save_if_not_exists(key, type='promo_offer', value=promo_id)
服务端校验与权益发放的最佳实践
拿到offer_code_ref_name或promotional_offer_id之后,不应仅依赖通知内容直接发放虚拟商品。正确做法是:用original_transaction_id调用App Store Server API的Get Transaction Info或者Get Renewal Info接口,二次确认服务端解码结果与Apple返回一致。这样能防止伪造通知(虽然Apple签名很难伪造,但中间代理出错也有可能)导致的误发。
在权益发放层,建议将offer_code_ref_name映射为你自己后台的「活动ID」,因为Apple的reference name可能随版本调整。而promotional_offer_id通常是稳定的UUID,可以直接作为促销计划的外键。在用户中心展示「您通过XX优惠码续费」时,注意脱敏,不要暴露完整reference name给前端,避免营销逻辑泄露。
最后,监控上需要分别统计两个字段的命中量。如果发现promotional_offer_id有值但客户端并未接入对应促销入口,可能是老版本App的兼容问题;如果offer_code_ref_name频繁出现但兑换失败率高,应检查App Store Connect里Offer Code的状态是否过期。通过结构化解析与差异化处理,才能把iOS订阅通知里的优惠信息真正用对地方。