有一次做近场触发演示,我在两部设备之间连续靠近了两次。第一下界面给出“已受理”,第二下服务端却生成了两条业务记录。更麻烦的是,第一条回执在网络抖动后晚到了几秒,页面已经显示失败,实际业务却已完成。那时候我才意识到:近场触发只是入口,不代表业务确认。

这轮我把小 Demo 叫作 TapReceipt,用一笔模拟门禁通行凭证核验业务,把“触发、受理、回执、重试、对账”完整接起来。业务单号固定为 ord_20261009_17,本轮触碰会话为 tc_20261009_17。演示路径里发生了 1 次重复触发拦截,第 2/3 次补偿请求返回确认,最终进入 ACK_CONFIRMED。

这里先划一条技术边界:华为碰一碰能力中,NFC 常承担近场触发,后续连接、传输或业务跳转会依赖具体方案和蓝牙、Wi-Fi 等能力;不同设备、系统版本和接入资格需要按官方指南验证。本文只把近场事件已经到达应用之后的业务回执作为讨论重点。下文的 TouchIngress、AckGateway、ReceiptStore 都是 Demo 自己定义的适配层,不是系统 SDK 的真实类名;图片与日志是为文章制作的示意记录,不能当作真机测试报告。

一、同一秒触发两次,究竟应当创建几个业务动作

这类问题很容易被一句“防抖就好了”带偏。把按钮点击间隔设成三秒,只能限制某一页面实例的连续操作。如果应用被重新拉起、设备重复发送同一业务意图,或者网络层把请求重新投递,三秒防抖并不会阻止服务端重复落单。

我把链路分为四个不同的事实。TOUCH_RECEIVED 表示应用接收到了近场入口事件;ORDER_ACCEPTED 表示业务订单在可信存储中已受理;ACK_PENDING 表示等待对端或服务端确认;ACK_CONFIRMED 才表示完成了最终回执核对。它们不是四个漂亮的进度节点,而是四个不同的责任主体:设备、应用、业务服务以及最终状态仓库。

最初的错误写法是在触碰回调里直接执行 createOrder()。任何重复触发都会重新创建订单,而且 UI 显示“创建成功”很容易被误认为“对端已经成功执行”。修复时,我把业务单号作为稳定标识,把动作名纳入幂等键:本轮使用 ord_20261009_17:ACK。这个 Key 必须覆盖从发起直到最终对账的整个周期,不能在页面退到后台时清掉。

下面的第一段代码并不试图实现 NFC 接口,它解决的是入口处如何减少同一进程里的重复提交。ReceiptStore 的 claim() 需要在数据库层保证原子语义;如果是远端服务,应以服务端唯一约束或原子事务作为最终防线。

interface TouchIntent {
  orderId: string
  touchToken: string
  action: string
  receivedAt: number
}

interface ReceiptStore {
  claim(key: string, intent: TouchIntent): Promise<boolean>
}

class TouchIngress {
  constructor(private store: ReceiptStore) {}

  async accept(intent: TouchIntent): Promise<boolean> {
    const idempotencyKey = `${intent.orderId}:${intent.action}`
    const claimed = await this.store.claim(idempotencyKey, intent)
    if (!claimed) {
      console.info(`TapReceipt duplicate blocked: ${intent.orderId}`)
      return false
    }
    console.info(`TapReceipt TOUCH_RECEIVED: ${intent.orderId}`)
    return true
  }
}

这段代码刻意不使用设备发现时间戳做幂等键。时间戳会变,用户第二次碰一碰得到的新会话标识也可能不同,但一笔业务应当只履行一次。如果产品语义是“同一个人连续签到两次代表两次独立动作”,就应在业务创建时分配两个不同的业务单号,而不是让技术层猜用户意图。

这还牵出另一个边界:去重不等于忽略所有第二次请求。第二次触发如果携带的是同一订单,应回传第一次的当前状态,告诉页面“正在处理”或“已经确认”;直接丢弃而不返回状态,会让用户误以为手机没有响应,又去触碰第三次。

二、先把状态表写清楚,再安排超时重试

我后来把 UI 里的成功提示拆开了。近场识别成功显示“设备已靠近”;服务端接受订单显示“订单已受理”;真正收到确认才显示“业务已确认”。三句话在体验上差别很小,在异常处理上差别非常大。

状态模型中还保留 ACK_TIMEOUT 和 NEEDS_RECONCILE。前者是某次请求等待超过期限,并非业务绝对失败;后者说明本地仍不知道服务端最终结果,必须通过查询接口对账。最危险的写法,是在超时回调里立即把订单置成 FAILED,再允许用户重新创建一笔新订单。这样一旦先前请求只是在回程网络上丢包,双重执行就可能出现。

下面这段 ReceiptState 演示业务层的单向状态收敛。终态不能被迟到的“等待中”回调覆盖,非法的跳跃也要留下诊断信号,而不是悄悄改页面。

enum ReceiptState {
  TOUCH_RECEIVED = 'TOUCH_RECEIVED',
  ORDER_ACCEPTED = 'ORDER_ACCEPTED',
  ACK_PENDING = 'ACK_PENDING',
  ACK_TIMEOUT = 'ACK_TIMEOUT',
  ACK_CONFIRMED = 'ACK_CONFIRMED',
  NEEDS_RECONCILE = 'NEEDS_RECONCILE'
}

function reduceReceipt(oldState: ReceiptState,
  next: ReceiptState): ReceiptState {
  if (oldState === ReceiptState.ACK_CONFIRMED) {
    return oldState
  }
  if (next === ReceiptState.ACK_CONFIRMED) {
    return next
  }
  if (oldState === ReceiptState.TOUCH_RECEIVED &&
      next === ReceiptState.ORDER_ACCEPTED) return next
  if (oldState === ReceiptState.ORDER_ACCEPTED &&
      next === ReceiptState.ACK_PENDING) return next
  if (oldState === ReceiptState.ACK_PENDING &&
      next === ReceiptState.ACK_TIMEOUT) return next
  if (oldState === ReceiptState.ACK_TIMEOUT &&
      (next === ReceiptState.ACK_PENDING ||
       next === ReceiptState.NEEDS_RECONCILE)) return next
  if (oldState === ReceiptState.NEEDS_RECONCILE &&
      next === ReceiptState.ACK_PENDING) return next
  return oldState // 丢弃非法或倒退的状态更新
}

这里的代码保留了便于阅读的最小判定,正式项目还应枚举完整的合法转移表,对未知枚举值、撤销、拒绝、取消等终态采取明确处理。一个页面如果只需要展示三步,也可以把内部六种状态映射成三种 UI 文案,但不能反过来把内部语义压缩成三个布尔值。

在这张运行态示意图中,当前还是 ACK_PENDING。订单是 ord_20261009_17,会话是 tc_20261009_17,重试位置为 2/3,并记录重复触发已拦截 1 次。这里的 2/3 应理解为进入第二次尝试,而不是已经成功两次;最后的确认结果将在详情页展示。

我对这个页面的验收要求很简单:订单号不能在重试期间改变,ACK_TIMEOUT 只能触发查询或补偿,不应重建业务单;重复入口必须返回第一次订单的当前状态。这样页面刷新、前后台切换或设备重新靠近,都不应改变业务的唯一性。

三、弱网补偿要重试“查询和确认”,不要重试“创建业务”

在模拟网络异常时,最有代表性的场景不是彻底断网,而是请求已被服务端接受,但响应包没有顺利返回。客户端看到超时,服务端可能已经执行完成。工程上常说的“至少一次投递”与“至多一次执行”就在这里产生冲突:为了可靠送达,我们需要允许重试;为了避免重复履行,我们又不能让每次重试生成一个新动作。

我在 TapReceipt 中约定:创建订单使用固定幂等键;补偿阶段优先调用 queryAck(orderId) 查询服务端状态;只有服务端明确表示仍未收到确认,才允许用同一个业务 Key 发起 requestAck()。业务网关必须识别重复 Key,并返回之前的处理记录。客户端自己实现的 Set 只能减轻重复请求,不能代替服务端幂等。

补偿也不能无限循环。一次网络抖动如果引发几十个设备同时刷新,错误的快速重试可能反而把后台推向雪崩。所以本轮设置三次尝试,并为每次尝试记录 attempt、nextRunAt 和最近错误码。下面这段代码展示的不是系统定时任务 API,而是业务补偿策略,真正的持久化调度由工程自己的队列承担。

interface AckResult {
  confirmed: boolean
  serverVersion: number
}
interface AckGateway {
  queryAck(orderId: string): Promise<AckResult>
  requestAck(orderId: string, key: string): Promise<AckResult>
}

async function compensate(orderId: string,
  attempt: number, gateway: AckGateway): Promise<AckResult> {
  const existing = await gateway.queryAck(orderId)
  if (existing.confirmed) {
    return existing
  }
  if (attempt > 3) {
    throw new Error('NEEDS_RECONCILE')
  }
  return gateway.requestAck(orderId, `${orderId}:ACK`)
}

第 2/3 次补偿成功的示例流程如下:17:24:03 收到触发,17:24:04 订单受理;17:24:05 首次回执等待超时;17:24:08 补偿任务携带同一 orderId 发起第二次查询/确认;17:24:10 获取 ACK_CONFIRMED。这里的时间与次数是一组固定的演示数据,不是对 NFC 触发延迟、网络性能或服务稳定性的基准测试。

很多真实故障来自设备时间不一致。A 设备显示 17:24:10,B 设备显示 17:24:08,并不一定代表 B 的操作先发生。因此跨端排序不能单靠手机本地时钟。更可靠的办法是业务事件附带服务端版本号或单调递增序列,让最终状态只接受更高版本的数据。

四、日志要能证明同一订单最终只生效一次

我以前也喜欢在日志里打印一句 success 就算任务结束。现在处理跨端触发,最少要求五类字段可以串起来:orderId、touchToken、idempotencyKey、attempt、serverVersion。如果不同请求共用了相同业务单号,查询日志就能复原它们何时出现、为何被拦截、最终由哪次补偿完成。

DevEco Studio 拟真截图里,左侧是 TapReceipt 工程目录,中间是 TapReceiptPage.ets 的回执处理逻辑,右侧模拟器显示等待回执的业务页,底部是 reconcileAck、duplicate blocked、ack timeout 和最终状态变化的示意日志。截图并不能证明代码已在特定机型上跑通,它的作用是说明工程排障时该把哪些证据放在同一视野。

具体做法上,我会给每个入口事件生成一次诊断 Trace,但不把 Trace 当幂等 Key。用户连续触碰两次,Trace 当然不同,订单与动作却相同。在服务端建立唯一索引之后,第一条请求可以创建处理记录,第二条应返回已有记录或明确的“处理中”状态。最怕的是后端把唯一冲突直接变成通用 500,导致客户端误判为永久失败。

如果服务端返回“处理中”,前台应进入 ACK_PENDING 并允许用户查看状态;如果查询结果显示已确认,应该立即结束补偿队列;如果返回业务拒绝,则需一个明确的业务失败终态,不再用网络重试掩盖拒绝原因。超时、拒绝、无权限、确认成功,是四种不同的结论。

五、页面离开以后,补偿任务为什么不能靠组件存活

一个经常被忽略的细节是:碰一碰业务可能在页面关闭后才收到最终确认。如果补偿逻辑绑在页面的局部变量上,用户退回首页、系统回收页面或应用切后台后,之前排队的回调就没有清楚的接收对象。

我把这类任务放进应用层的 ReceiptRepository,页面只是订阅 ReceiptState 的可见窗口。退到后台后,是否允许继续执行,必须按 HarmonyOS 当前后台任务权限与实际业务场景选择能力;不能把普通 Promise 当成系统保证永远运行的后台任务。即使后台调度不成立,重进应用时也要依靠持久化的待处理记录补偿查询,而不是依赖上一次页面实例是否还在。

第四段代码解决“迟到回调是否能把确认结果覆盖成超时”的问题。这里用服务端版本号做保护,并在写库成功之后通知 UI,防止页面先刷新成一个没有持久化依据的状态。

interface ReceiptSnapshot {
  orderId: string
  version: number
  state: ReceiptState
}

class ReceiptRepository {
  private cached: ReceiptSnapshot | undefined

  async applyServerSnapshot(next: ReceiptSnapshot): Promise<boolean> {
    if (this.cached && next.version <= this.cached.version) {
      console.info(`TapReceipt stale callback ignored: ${next.orderId}`)
      return false
    }
    await this.saveToStore(next) // 项目自定义的原子持久化方法
    this.cached = next
    return true
  }

  private async saveToStore(next: ReceiptSnapshot): Promise<void> {
    // 此处接 RelationalStore 事务或服务端快照存储
  }
}

正式实现中,还要在写入时检查数据库内已有版本,不能只在进程内比较 cached。多个组件、多个窗口或并发任务有可能同时更新同一订单;真正可靠的比较与写入必须在同一事务中进行。这里的示例重点是说明“状态落库先于 UI 成功提示”,不是把完整事务接口展开成大段样板代码。

六、从 ACK_PENDING 到 ACK_CONFIRMED,应该验收哪些东西

最终详情页用来验收三个维度,而不只是放一个绿色对勾。首先是业务唯一性:ord_20261009_17 保持不变,幂等键是 orderId + action,重复触发拦截计数为 1。其次是补偿过程:第 2/3 次尝试成功,待处理操作 0 个,事件时间线能解释此前为什么显示等待回执。最后是状态一致性:前台状态为 ACK_CONFIRMED,17:26 的 RECONCILED 表明本地再次查询并与服务端完成对账。

我会专门做三组失败注入。第一组在业务受理成功后故意丢弃网络响应,验证下一次查询是否只拿回旧订单而不重新创建;第二组连续模拟两次相同触发,验证服务端只有一个动作结果;第三组让第一次请求的超时回调晚于最终确认回调到达,验证页面仍保持 ACK_CONFIRMED。这三组比“手机靠近后弹窗速度快不快”更能说明业务是否真正闭环。

此外还要看操作边界。用户明确取消时,不能仅从页面移除订单记录而放任后端任务继续;如果动作已经确认,取消就可能需要补偿性撤销,而不能把确认记录删掉。敏感业务如门禁、支付或身份核验还必须建立独立授权链路,不能仅凭一个 NFC 触发或客户端传来的订单号判断用户有权执行。本文只是模拟门禁通行凭证的业务回执,不实现真实开锁授权。

1. 同一订单来自两个进程时,谁能成为最终写入者

单机页面的去重做完后,我会故意再启动一条完全不同的入口,例如应用正在前台时收到近场事件,同时后台收到一次业务同步通知。两条消息都指向 ord_20261009_17,看起来触发渠道不同,却不能允许两个消费者分别改写订单。这里要建立一个规则:读到旧状态的人不一定拥有写权限,真正决定成功的是带条件的事务更新。比如当前数据库版本为 5,两个消费者都准备改成 6,只有第一个提交成功;另一个发现版本冲突后必须重新读取,而不是覆盖。这样一来,“最后一个抵达的回调”不再天然拥有最终解释权。

这一点和服务端幂等相辅相成。服务端幂等管住外部副作用,本地事务管住 UI 与缓存状态。即使两端都写着 ACK_CONFIRMED,如果一条记录没有携带对应的服务端签名或确认编号,也不能把它和可信最终结果混为一谈。我会在测试库里保留 traceId、attempt、serverVersion 和 committedAt,但正式产品应避免把完整设备标识或个人敏感数据直接写入明文日志。排障字段要足够解释链路,同时仍然符合必要的数据最小化原则。

还有个我曾经忽略的场景:客户端离线太久,补偿队列在第二天重启。不能因为上次是第二次重试,这次就无条件从第三次发送开始。正确处理取决于服务端幂等键有效期、订单业务时效和设备授权是否仍然有效。若业务已经过期,应走查询和人工处理分支,不允许旧触发在未来某个时间突然重新执行。系统级入口所带来的便利,绝不能绕过业务时效控制。

2. 给客服和运维看的结果,不应该只是“网络异常”

产品真正上线后,研发人员能够打开 HiLog,普通用户和现场工作人员却不会。所以我在诊断页之外还设计一张简化的凭证页面:显示业务单号、最近确认时间、最终状态和“重新查询”入口。处于等待中时,按钮文案不写“再发一次”,而是“查询回执”,避免诱导用户重复完成业务。只有确认查询仍未到达服务端,才由受控补偿任务执行重试。这个文案差异很小,却能明显改变用户在弱网时的行为。

服务端监控也不应只盯请求成功率。我会按日统计同一业务 Key 的重复命中量、回执从受理到确认的耗时分布、超过重试上限仍未对账的订单数量,以及已确认订单被客户端错误显示失败的次数。最后一项尤其重要,因为它代表了用户感知与真实业务结果不一致。灰度发布时可以先观察这些差异指标,再扩大设备与场景覆盖,而不是只看拉起页面的成功率。

验收报告里应明确说明验证前提:哪些设备支持当前近场交互、是否处于有网状态、怎样模拟回程丢包、客户端和服务端是否使用相同的 Key 规则。只有把这些条件固定下来,另一个团队才能复现 第 2/3 次补偿成功,而不会把演示数据误写成某个 HarmonyOS 版本保证的行为。对于门禁类敏感业务,真正的通行授权和撤销能力仍由可信后台控制,回执链路只是其中一层可观察的工程保障。

七、把这个小系统交给下一位开发者之前

我最后保留了一份很朴素的检查清单:幂等 Key 是否由稳定业务身份构成;创建与回执是否分离;超时是否进入待核对而非直接失败;重试是否沿用同一个 Key;服务端是否有唯一约束;页面重进是否能恢复订单状态;迟到回调是否会造成状态倒退;最终截图、日志与状态库是否指向同一个订单。

这几项中,最值得优先保证的其实是服务端唯一性与业务状态收敛。其它的排队、进度条、日志格式都可以逐步优化;但只要同一个触发可能产生两次业务执行,或者成功回执可能被一个超时结果盖掉,整个产品就仍然带着隐患。

回头看这轮 TapReceipt,我越来越觉得精准碰一碰真正难的不是“碰到以后能不能拉起页面”,而是 用户以为一次动作已经发生,系统能不能提供唯一、可靠、可追溯的证明。把这件事做扎实之后,近场入口才不只是一个灵巧的交互,而是可用于真实产品流程的可靠起点。

资料与适配说明:碰一碰能力概览可查阅 https://developer.huawei.com/consumer/cn/onehop-kit/ 。具体可使用的触发方式、设备适配、授权和接口签名,请以项目目标设备及官方当期指南为准。本文的业务网关接口和执行数字均属于演示方案。

Logo

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

更多推荐