
同一张优惠券入口被连续碰两次业务系统最怕的不是页面打开两遍而是把同一个领取动作执行两遍。第二次进入的页面看似正常后台却可能多了一条申请记录。对 HarmonyOS 上计划接入精准碰一碰的服务来说真正需要提前准备的是入口事件抵达业务层之后如何定义一次、如何记住成功、失败时又允许怎样重试。本文只构建碰一碰落地页的业务模拟器TapReceipt。TAP-071是演示任务 IDACK-071是示例回执它们不是系统提供的 NFC 参数格式。这里不展示未经核实的碰一碰系统回调不宣称已经连通设备间精确触发。未来接入真实系统入口时只需要把经过平台校验的业务参数交给相同的处理入口。一、先看重复到达后最危险的分叉落地页的一次事件至少经历“收到凭证、校验输入、查已有回执、执行业务、写入回执、展示结果”六步。如果第二次事件卡在第三步前到来单纯查一次本地 Key-Value 不能保证两次都不会执行。若第一次业务已经完成、但写回执失败下一次打开还可能误以为从未完成。因此业务目标不能写成泛泛的“防重复点击”。本篇定义两个层次在同一个进程里running集合阻止相同ticket同时进入在页面重新建立后Preferences 中的成功回执可以用于展示“已处理”。真正跨设备、跨进程或涉及资产发放的唯一性要由服务端以业务凭证建立幂等约束本地缓存不能替代。演示按这组数据组织第一次TAP-071获得ACK-071第二次同凭证到达时命中已存回执界面显示“重复已拦截”触发次数2、业务执行1、本地存储“已写入”。这只是预期状态轨迹相关图片是绘制演示不是手机现场测试结果。二、存储层只负责回答一个朴素的问题HarmonyOS ArkData 的 Preferences 适合保存轻量 Key-Value。官方参考说明getPreferences用于取得实例get读取put更新缓存XML 存储模式下再调用flush持久化。文档还特别提示它不保证多进程并发安全所以这里把存储能力限定在单进程 UI 演示绝不把它包装成全局事务锁。先将“查回执”和“写回执”放进独立的ReceiptStore.ets。这样 UI 只需要调用两个含义明确的方法不知道底层实例名和 Key 的组织方式。下面代码处理的是进程内轻量成功记录不存设备身份、位置、联系方式也不把业务秘密写进普通首选项。// entry/src/main/ets/storage/ReceiptStore.ets import { preferences } from kit.ArkData; import { common } from kit.AbilityKit; export class ReceiptStore { private db?: preferences.Preferences; async open(context: common.UIAbilityContext): Promisevoid { this.db await preferences.getPreferences(context, { name: tap_receipt_demo }); } async findAck(ticket: string): Promisestring { if (!this.db) { throw new Error(store not opened); } return String(await this.db.get(receipt_${ticket}, )); } async saveAck(ticket: string, ack: string): Promisevoid { if (!this.db) { throw new Error(store not opened); } await this.db.put(receipt_${ticket}, ack); await this.db.flush(); console.info(TapReceipt persisted ${ack}); } }这里按最普通的 XML 路径演示调用flush()如果工程明确切换到官方文档所述 GSKV 存储模式持久化方式应按对应规则调整而不是机械复制。findAck的默认值是空字符串意味着尚未找到成功回执并不代表上一次业务必然失败。初始open失败时应显示“存储不可用”不能带着未初始化的实例继续执行业务。三、成功回执必须在业务完成之后记录一个常见顺序错误是先写“已处理”再向服务端提交。这样服务端失败后客户端仍会把下一次重试挡掉。反过来服务端成功而本地落盘失败又存在重试风险。两种情况都不能靠 UI 的勾选状态修好服务端需要根据同一ticket返回同一结果。下面把业务调用注入ReceiptProcessor。为了不虚构网络 SDK示例使用一个确定性的commit函数模拟业务处理换成实际请求时必须保留业务凭证、鉴权和请求超时处理。running集合负责本进程内的并发门闩finally无论成功失败都会释放避免页面一直停在“处理中”。// entry/src/main/ets/model/ReceiptProcessor.ets import { ReceiptStore } from ../storage/ReceiptStore; export interface ReceiptResult { kind: string; ack: string; } export class ReceiptProcessor { private running: Setstring new Setstring(); private store: ReceiptStore; constructor(store: ReceiptStore) { this.store store; } async handle(ticket: string, commit: (id: string) Promisestring): PromiseReceiptResult { if (!/^TAP-\d{3}$/.test(ticket)) { throw new Error(invalid ticket); } if (this.running.has(ticket)) { return { kind: busy, ack: }; } this.running.add(ticket); try { const existed await this.store.findAck(ticket); if (existed.length 0) { console.info(TapReceipt duplicatetrue ack${existed}); return { kind: duplicate, ack: existed }; } const ack await commit(ticket); await this.store.saveAck(ticket, ack); return { kind: created, ack: ack }; } finally { this.running.delete(ticket); } } }注意这不是系统级 NFC 去重 APISet也不会跨进程保留。它只管理本次应用进程中正在处理的 ID。如果提交接口可能“服务器已执行、客户端超时”commit抛错时应保留“待核实”状态先查询服务端结果再决定是否重试不能无条件重新领取。四、落地页只消费已经归一化的凭证TapLanding.ets的按钮叫“再次触发”不是“模拟 NFC 成功”。开发者可以借助它重复注入同一个TAP-071稳定观察状态变化。未来从系统入口、二维码入口或业务链接进入时可复用onArrive(ticket)的后半段但前面的参数校验、签名和来源判断仍必须按实际平台能力实现。为了减少重复触发时的视觉抖动只有处理完成后才更新ack和状态。页面销毁以后尚未结束的异步调用可能仍有结果返回因此增加active门闩避免回写已经退出的界面它不是网络取消器真正耗时请求还需要独立的生命周期管理。// entry/src/main/ets/pages/TapLanding.ets import { common } from kit.AbilityKit; import { ReceiptStore } from ../storage/ReceiptStore; import { ReceiptProcessor } from ../model/ReceiptProcessor; Entry Component struct TapLanding { State ticket: string TAP-071; State ack: string ; State attempts: number 0; State stateText: string 待处理; private active: boolean false; private store: ReceiptStore new ReceiptStore(); private processor: ReceiptProcessor new ReceiptProcessor(this.store); aboutToAppear(): void { this.active true; this.bootstrap(); } aboutToDisappear(): void { this.active false; } private async bootstrap(): Promisevoid { try { await this.store.open(getContext(this) as common.UIAbilityContext); await this.onArrive(this.ticket); } catch (e) { if (this.active) { this.stateText 存储不可用; } } } private async onArrive(ticket: string): Promisevoid { this.attempts 1; console.info(TapReceipt arrive ${ticket} attempt${this.attempts}); try { const result await this.processor.handle(ticket, async (id: string) { // 仅演示真实场景由服务端返回受信任的回执。 return ACK-${id.split(-)[1]}; }); if (!this.active) { return; } if (result.kind busy) { this.stateText 处理中; return; } this.ack result.ack; this.stateText result.kind duplicate ? 重复已拦截 : 回执已保存; } catch (e) { if (this.active) { this.stateText 待核实; } } } build() { Column({ space: 18 }) { Text(碰一碰回执).fontSize(26) Text(任务 ID ${this.ticket}) Text(this.stateText) Text(回执 ID ${this.ack || —}) Text(触发次数 ${this.attempts}) Button(再次触发).onClick(() this.onArrive(this.ticket)) Text(演示数据 · 非真机碰一碰测试) }.padding(24) } }这里代码的aboutToAppear只是启动异步初始化不把事件认定成真实系统回调。真实入口可能晚到、重入、携带无效票据或被用户拒绝页面应允许展示拒绝原因而不应默认所有进来的数据都可调用提交动作。示例里的回执字符串只是演示规则任何真实业务都应由服务端产生或校验不可以直接按字符串拼接发放权益。快速重入得到busy时只显示“处理中”不把空回执覆盖到已完成的结果上。五、运行视图只能证明设计意图不能证明接口已接通预期轨迹为首次调用onArrive(TAP-071)后写入ACK-071界面出现“回执已保存”再次点击“再次触发”findAck找到相同回执显示“重复已拦截”。对应的演示日志分别是TapReceipt arrive TAP-071 attempt1、TapReceipt persisted ACK-071、TapReceipt arrive TAP-071 attempt2与TapReceipt duplicatetrue ackACK-071。检查这条轨迹时不能因为前台显示“业务执行 1 次”就说已验证支付或优惠券发放 exactly-once。那是样例展示值。真正需要记录的是ticket、服务端请求 ID、回执状态、提交耗时范围和错误类型但日志里不要打印鉴权令牌、用户手机号或可识别身份信息。更不能把演示的10:24状态栏时间误写成业务成功时间。还要专门测试三种中断本地写入回执前杀进程、flush返回错误、服务端成功而客户端未收到回执。第一种要走服务端查询第二种要保留可见的“待核实”第三种必须依赖同一业务键的服务端幂等。这里没有虚构测试通过率或真实设备表现以上是上线前应补齐的验收项。六、把接入边界留给真实系统能力精准碰一碰解决的是场景入口与近场触达本篇代码只负责触达之后的业务重复处理。碰一碰能力的开放条件、设备限制、接入配置和参数格式应以对应官方接入文档及当前开发者资格为准本文没有发明onTapNfc()一类不存在的接口。Preferences 存的是轻量状态适合恢复 UI 的“上次已经拿到什么回执”。它不是可信交易流水也不保证跨多进程原子性。业务交付时应将完整流程拆成“入口验证、客户端单进程门闩、服务端幂等、状态回查、异常告知”五层每层负责自己的失败模式。最重要的工程取舍是把重复抵达视为正常输入而不是偶发异常。页面可以被反复打开触发可以重复发生但有价值的业务动作必须基于受信任凭证确定唯一性本地回执只是让用户不必为同一个结果反复等待。参考文档HarmonyOS Preferences APIhttps://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-data-preferencesHarmonyOS 官方精准碰一碰讨论入口https://developer.huawei.com/consumer/cn/forum/topic/0208223729721325967鸿蒙生态解决方案白皮书近场业务入口https://developer.huawei.com/consumer/cn/doc/guidebook/solution-0000002755171974全部图片和日志均为独立生成的静态演示素材不属于真实系统碰一碰联调记录。