同一张优惠券入口被连续碰两次,业务系统最怕的不是页面打开两遍,而是把同一个领取动作执行两遍。第二次进入的页面看似正常,后台却可能多了一条申请记录。对 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

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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐