鸿蒙开发历险记 · 第1期

我上线了鸿蒙应用的会员系统,测试一切正常。结果第一个真实用户反馈:"我明明买了会员,换手机后怎么恢复不了?"你打开日志一看——恢复购买的API调用后一片寂静,不成功、不失败、不超时,就像从来没调用过。
这不是一个bug,是三个环环相扣的暗坑。修完一个,下一个才露出真面目。
· 坑一:API时序冲突导致静默挂起——上一个收银台会话还没拆除,下一个查询请求就被卡死了
· 坑二:返回数据结构跟文档暗示的完全不一样——你以为拿到的是扁平对象,实际是一层JWS签名包装,真正的数据藏在base64url编码的payload里
· 坑三:查询类型选错,买得了却恢复不了——只查"已确认交付"会漏掉"已购买但未交付"的中间状态,形成死循环
三个坑排查下来,最深的感触是:鸿蒙IAP的SDK设计有不少合理之处,但文档在某些关键细节上留了太多空白,只能靠真机日志和原始数据自己补全。

用户已经购买过会员(买断制,非消耗型商品),换设备后点击"开通会员",SDK返回PRODUCT_OWNED(错误码1001860051,意思是"你已经买过了"),代码逻辑自动转入"恢复购买"流程,调用queryPurchases()——然后,什么都没发生。
日志定格在[IAPNAPI] queryPurchases called这一行,之后5~6秒内该会话再无任何输出。不resolve,不reject,不超时。就像这个API从来没被调用过。
给恢复购买链路上的每一步都加了打点日志:buyMembership → restorePurchase → restoreOwnedPurchases → findPurchase → iap.queryPurchases,跑一次真机复现。日志清晰定格在queryPurchases called——后面的几秒一片空白。
同时看queryPurchases调用发出那几毫秒里的原生日志,能发现刚失败的那次createPurchase收银台弹窗的UIExtension会话还在拆除中(Background: Background ExtensionSession、SendMessageBackWork# promise deferred等日志清晰可见)。
restorePurchase()是在catch块里同步立即调用的——上一次createPurchase的收银台会话还没拆除干净,紧接着就发起了下一个IAP请求。证据强烈指向:同一UIAbility下,上一个IAP请求的UIExtension会话尚未完全释放时,立刻发起下一个IAP请求会导致该请求悬挂,既不resolve也不reject。
华为没有公开IAP服务内部机制,所以这是推测——但日志证据非常一致,每次复现都是这个时序。
在从PRODUCT_OWNED转入恢复购买之前,插入一个短暂延时,让上一次的收银台会话有时间收尾:
} else if (code === iap.IAPErrorCode.PRODUCT_OWNED) {// 刚失败的 createPurchase() 的收银台 UIExtension 会话还在拆除中;// 立刻调用 queryPurchases() 会挂起(真机实测,不 resolve 也不 reject)。await new Promise<void>((resolve) => setTimeout(resolve, 800));await this.restorePurchase();}
800ms是经验值,不是理论推导出来的精确值——测试环境下留了一定余量。
你可能会问:800ms延时是不是一个"脏fix"?坦率说,是的——但我们没有任何API能探测"上一个UIExtension会话是否已经拆除完毕",华为也没公开这个状态。轮询没有明确的"就绪信号"可等,只能用经验延时兜底。如果未来SDK提供了状态查询能力,应该优先换成条件等待而非固定延时。这是一个务实的工程取舍,不是最优解,但是在当前SDK能力边界内唯一可行的方案。
延时修复后,挂起问题消失,queryPurchases()每次都能在100~200ms内正常返回。但恢复流程仍然失败,提示"未找到有效购买记录"——明明查到了数据,为什么还是找不到?
不再猜字段名,直接把purchaseDataList里每一条记录的原始JSON字符串打到日志里,肉眼看真实结构。
返回的不是购买对象本身,是一层JWS签名包装:
{"type": 1,"jwsPurchaseOrder": "eyJ4NWMi...<header>.<payload>.<signature>"}
jwsPurchaseOrder是一个JWS(JSON Web Signature)compact格式字符串:header.payload.signature三段式,用.分隔。真正的购买信息(productId、purchaseToken、purchaseOrderId等)以base64url编码的形式藏在中间那段payload里,不base64url解码根本读不到。
手动解码payload段之后能看到(节选):
{"purchaseOrderId": "20260701233252...","purchaseToken": "0000019f1e4fd1...","applicationId": "...","productId": "com.arcanekey.authenticator.member_lifetime","purchaseTime": 1782919975198}
另一个隐藏坑:字段名是purchaseOrderId,不是更符合直觉的orderId。如果你参照createPurchase()返回值的结构去猜,很容易猜错——因为那是另一个不同形状的响应体。
手动base64url解码JWS payload段,提取真正的购买数据:
import { util } from '@kit.ArkTS';interface PurchasePayload {productId?: string;purchaseToken?: string;purchaseOrderId?: string;}interface QueryPurchaseRecord {type?: number;jwsPurchaseOrder?: string;}function decodeJwsPurchasePayload(jws: string): PurchasePayload | null {const parts = jws.split('.');if (parts.length < 2) return null;// base64url → 标准 base64,并补齐 paddinglet segment = parts[1].replace(/-/g, '+').replace(/_/g, '/');while (segment.length % 4 !== 0) {segment += '=';}const helper = new util.Base64Helper();const decoded: Uint8Array = helper.decodeSync(segment);const json = new util.TextDecoder('utf-8').decode(decoded);return JSON.parse(json) as PurchasePayload;}for (const data of result.purchaseDataList) {const record = JSON.parse(data) as QueryPurchaseRecord;if (!record.jwsPurchaseOrder) continue;const payload = decodeJwsPurchasePayload(record.jwsPurchaseOrder);if (payload && payload.productId === MEMBER_PRODUCT_ID) {// 命中:payload.purchaseToken / payload.purchaseOrderId 可用}}
为什么手动转换base64url字符集再解码,而不是直接用Base64Helper自带的Type.BASIC_URL_SAFE选项?因为手动转换+补padding是完全确定、可测试的逻辑;BASIC_URL_SAFE选项对不带padding的输入的处理没有在文档里说清楚,与其猜测,不如自己控制这一步的确定性。
这件事暴露了鸿蒙IAP文档的一个显著缺口:返回值的JWS包装结构,文档里只字未提。只能靠打日志看原始JSON才发现。如果你正在接入IAP,先把原始返回数据打印出来看,再决定怎么解析——别信文档暗示的"扁平结构"。
即便解析逻辑修好了,某些情况下仍然查不到记录。尤其是当之前某次购买成功了,但finishPurchase()(确认交付)没有成功执行时——比如那一步被try{}catch(_){}静默吞掉了异常。
iap.PurchaseQueryType有三个值:
| ALL = 0 | |
| UNFINISHED = 1 | |
| CURRENT_ENTITLEMENT = 2 |
一笔购买必须成功调用过finishPurchase(),才会从UNFINISHED状态变成CURRENT_ENTITLEMENT。如果只查CURRENT_ENTITLEMENT(多数示例代码的默认写法),"已购买但未交付"的商品永远查不到——但华为后台依然认定用户"已拥有"该商品(createPurchase()会返回PRODUCT_OWNED),形成买不了(已拥有)、也恢复不了(查询类型不对)的死循环。
queryType的三选一,多数示例代码默认用CURRENT_ENTITLEMENT,但"已购买未交付"是个真实存在的中间状态。这不是极端边缘情况——如果你的finishPurchase()曾经在某个版本被异常处理静默吞掉了错误,你已经有用户处于这个状态了。
先查CURRENT_ENTITLEMENT,查不到再兜底查一次UNFINISHED,找到的话补上finishPurchase()再返回:
export async function restoreOwnedPurchases(context: common.UIAbilityContext): Promise<PurchaseTokenResult | null> {const entitled = await findPurchase(context, iap.PurchaseQueryType.CURRENT_ENTITLEMENT);if (entitled) return entitled;// 已购买但未确认交付的商品不会出现在 CURRENT_ENTITLEMENT 里,// 需要单独查 UNFINISHED,找到后补上 finishPurchase 让它变成正式权益。const unfinished = await findPurchase(context, iap.PurchaseQueryType.UNFINISHED);if (unfinished) {await acknowledgePurchase(context, unfinished.purchaseToken, unfinished.purchaseOrderId);return unfinished;}return null;}
为什么不一开始就只查UNFINISHED?因为CURRENT_ENTITLEMENT对应的是正常、已完成的购买,绝大多数场景第一次查询就能命中,没必要每次都多发一次请求。UNFINISHED只是补救异常状态的兜底路径,按需触发。

这个问题不是IAP本身的坑,但几乎每个"购买解锁功能"类应用都会踩:isMember可能在好几个不同代码路径下被置为true(冷启动自动恢复、购买成功、手动点"恢复购买"……),如果某个依赖会员身份的初始化逻辑只在其中一条路径里触发了,另外几条路径就会形成"看起来是会员,但相关功能没初始化"的隐藏bug。
第一版排查方法:全局搜索所有isMember = true的位置,逐一确认每一处后面是否都补齐了初始化逻辑。这个方法能治标,治不了本——实测中,同一个"忘了初始化"症状在补齐已知的几处调用后,第3处又在另一处新暴露出来了。
根本原因:把一个不变量("状态变化时必须触发初始化")分散到多处代码里维护,只要有一处漏调,bug就会再犯。
更彻底的做法:用ArkTS的@Watch装饰器把"状态变化"和"触发初始化"绑死成响应式:
@State @Watch('onIsMemberChanged') isMember: boolean = false;private onIsMemberChanged(): void {if (!this.isMember) return;this.initCloudSyncIfReady().catch((e: Error) => {console.error('[Index] onIsMemberChanged: initCloudSyncIfReady failed:', e.message);});}
改完之后,buyMembership()、restorePurchase()、冷启动逻辑这三处只需要老老实实this.isMember = true,初始化会自动触发——不可能再漏。
排查这类问题时,如果同一个"忘了初始化"的症状在补了2处以上调用点之后还在第3处冒出来,别再继续找下一处了,直接换成响应式/事件驱动的写法,从架构上根治。
☐ 恢复购买没反应/卡死 → 检查是不是在上一个IAP调用刚失败/刚成功后同步立即发起了下一个IAP调用;试着加几百毫秒延时
☐ queryPurchases()明明查到记录但解析不出商品信息 → 别信"理所当然"的扁平结构,先把原始JSON打到日志里肉眼确认;警惕jwsPurchaseOrder这类JWS/JWT包装字段,需要base64url解码payload段
☐ 恢复购买提示"未找到记录"但用户确实买过 → 检查查询是不是只用了CURRENT_ENTITLEMENT,遗漏了UNFINISHED(未完成交付)状态的历史购买
☐ 权限/会员状态变化后某些功能没生效 → 如果同一症状补了2处以上还在新地方复现,别继续找下一处,改用@Watch把"状态变化"和"触发初始化"绑死
① 先加打点日志,跑一次拿到真实证据,再下结论
② 报错信息(1001860051、1001860004)对照SDK文档逐字核实含义,不要凭经验臆测
③ 遇到"数据格式不对"类问题,直接打印原始数据肉眼看,比对着类型声明瞎猜字段名快得多、准得多
④ 一个bug修复验证通过后,主动搜索代码里是否存在"同一类问题的其他实例",而不是等用户再报一次
你在鸿蒙开发中遇到过哪些"文档没说但确实存在"的坑?欢迎留言分享。
下一篇我们聊更刺激的——AGC云数据库换设备数据全丢,从权限模型到加密方案,六个坑连环暴雷。关注不迷路。
下一篇:AGC云数据库换设备数据全丢?六个坑连环暴雷 → 关注不迷路

往期推荐
News Watch
我只说了一句话,AI就"猜"中了我的梦想书房——谷歌这步棋,比想象中更狠
2026-07-01

我用AI+下班时间开发的鸿蒙App,正式上架了:但这套隐藏流程,比写代码更让人崩溃
2026-06-26

一个@符号,正在悄悄颠覆职场——你还在假装看不见?
2026-06-24

拳打Claude,脚踢GPT,套餐一个都买不到: 智谱的"全球第一"是怎么炼成的?
2026-06-21
