☰
HarmonyOS ArkData Preferences:碰一碰落地页重入凭证幂等【鸿蒙心迹】
2026/10/11 23:22:43 网站建设 项目流程

同一张优惠券入口被连续碰两次,业务系统最怕的不是页面打开两遍,而是把同一个领取动作执行两遍。第二次进入的页面看似正常,后台却可能多了一条申请记录。对 HarmonyOS 上计划接入精准碰一碰的服务来说,真正需要提前准备的,是入口事件抵达业务层之后如何定义一次、如何记住成功、失败时又允许怎样重试。

本文只构建碰一碰落地页的业务模拟器TapReceipt。TAP-071是演示任务 ID,ACK-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): Promise<void> { this.db = await preferences.getPreferences(context, { name: 'tap_receipt_demo' }); } async findAck(ticket: string): Promise<string> { if (!this.db) { throw new Error('store not opened'); } return String(await this.db.get(`receipt_${ticket}`, '')); } async saveAck(ticket: string, ack: string): Promise<void> { 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: Set<string> = new Set<string>(); private store: ReceiptStore; constructor(store: ReceiptStore) { this.store = store; } async handle(ticket: string, commit: (id: string) => Promise<string>): Promise<ReceiptResult> { 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 duplicate=true 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 去重 API,Set也不会跨进程保留。它只管理本次应用进程中正在处理的 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(): Promise<void> { 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): Promise<void> { 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 attempt=1、TapReceipt persisted ACK-071、TapReceipt arrive TAP-071 attempt=2与TapReceipt duplicate=true ack=ACK-071。

检查这条轨迹时,不能因为前台显示“业务执行 1 次”就说已验证支付或优惠券发放 exactly-once。那是样例展示值。真正需要记录的是ticket、服务端请求 ID、回执状态、提交耗时范围和错误类型,但日志里不要打印鉴权令牌、用户手机号或可识别身份信息。更不能把演示的10:24状态栏时间误写成业务成功时间。

还要专门测试三种中断:本地写入回执前杀进程、flush返回错误、服务端成功而客户端未收到回执。第一种要走服务端查询,第二种要保留可见的“待核实”,第三种必须依赖同一业务键的服务端幂等。这里没有虚构测试通过率或真实设备表现,以上是上线前应补齐的验收项。

六、把接入边界留给真实系统能力

精准碰一碰解决的是场景入口与近场触达,本篇代码只负责触达之后的业务重复处理。碰一碰能力的开放条件、设备限制、接入配置和参数格式应以对应官方接入文档及当前开发者资格为准;本文没有发明onTapNfc()一类不存在的接口。

Preferences 存的是轻量状态,适合恢复 UI 的“上次已经拿到什么回执”。它不是可信交易流水,也不保证跨多进程原子性。业务交付时应将完整流程拆成“入口验证、客户端单进程门闩、服务端幂等、状态回查、异常告知”五层,每层负责自己的失败模式。

最重要的工程取舍是:把重复抵达视为正常输入,而不是偶发异常。页面可以被反复打开,触发可以重复发生,但有价值的业务动作必须基于受信任凭证确定唯一性;本地回执只是让用户不必为同一个结果反复等待。

参考文档:

  • HarmonyOS Preferences API:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-data-preferences
  • HarmonyOS 官方精准碰一碰讨论入口:https://developer.huawei.com/consumer/cn/forum/topic/0208223729721325967
  • 鸿蒙生态解决方案白皮书(近场业务入口):https://developer.huawei.com/consumer/cn/doc/guidebook/solution-0000002755171974

全部图片和日志均为独立生成的静态演示素材,不属于真实系统碰一碰联调记录。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询