一、两次回调,只能产生一次业务结果

这次问题出现在会议签到 Demo TapRelay 的联调阶段。手机靠近桌面终端后,页面从 DISCOVERED 进入 VERIFIED,随后显示“交接完成”。体验看上去很顺,但后台却出现了两条完全相同的签到记录:会话号都是 TAP-20260930-1251-08,载荷号都是 PAY-8F31C2,提交时间只相差 84 ms。

最开始我怀疑系统回调重复。继续抓日志才发现,近场交互本身没有失控:一次回调来自首次发现,另一次来自链路恢复后的确认。它们在设备层面属于两个有效事件,却携带同一个业务载荷。问题真正发生在应用层——我们把“收到一次系统事件”直接等同于“可以执行一次业务提交”。

碰一碰的交互时间很短,用户也不会停下来确认系统到底回调了几次。网络抖动、页面恢复、前后台切换、设备重新靠近,都可能让相同载荷再次到达。如果业务端没有幂等边界,签到、领券、开门、设备配对这些动作都会被重复执行。精准碰一碰解决的是空间上的准确触发,业务系统仍然要解决时间上的重复、乱序与重放。

因此我把链路拆成四个明确状态:DISCOVERED → VERIFIED → ACCEPTED → COMMITTED。发现设备只创建会话;验签通过只说明消息可信;接受载荷只说明本地愿意处理;真正写入业务结果后才进入提交态。任何重复载荷都可以被观察,但不能再次穿过 COMMITTED 边界。

二、先把载荷变成可判断的业务信封

以前的回调只带一段 JSON,页面直接取出 ticketId 发请求。这样的代码短,却无法回答三个关键问题:消息是谁发的、是否过期、是否已经处理过。我们改成业务信封,字段由发送端生成,接收端只做验证和状态推进。

本次联调使用的数据固定如下:会话 ID 为 TAP-20260930-1251-08,载荷 ID 为 PAY-8F31C2,发送端为 DeskBoard-A,随机数为 N-73A91E,有效期 15 秒。载荷 ID 是业务幂等键,会话 ID 只用于诊断;不能拿会话 ID 替代幂等键,因为同一载荷可能在链路恢复后进入新的会话。

这段代码解决什么问题。 它把系统入口送来的原始数据收敛成强类型信封,并在状态机内完成时间窗、随机数和重复载荷校验。

type TapState = 'IDLE' | 'DISCOVERED' | 'VERIFIED' |
  'ACCEPTED' | 'COMMITTED' | 'REJECTED'

interface TapEnvelope {
  sessionId: string
  payloadId: string
  sender: string
  nonce: string
  issuedAt: number
  expiresInMs: number
  body: string
  signature: string
}

interface TapDecision {
  state: TapState
  reason: string
}

export class TapSessionCoordinator {
  private state: TapState = 'IDLE'
  private readonly maxClockSkewMs = 3000

  constructor(private store: IdempotencyStore) {}

  async accept(raw: TapEnvelope, now: number): Promise<TapDecision> {
    this.state = 'DISCOVERED'
    if (now + this.maxClockSkewMs < raw.issuedAt ||
        now - raw.issuedAt > raw.expiresInMs + this.maxClockSkewMs) {
      return this.reject('EXPIRED_OR_CLOCK_SKEW')
    }
    if (!this.verifySignature(raw) || raw.nonce !== 'N-73A91E') {
      return this.reject('SIGNATURE_OR_NONCE_INVALID')
    }
    this.state = 'VERIFIED'
    if (await this.store.has(raw.payloadId)) {
      return this.reject('DUPLICATE_PAYLOAD')
    }
    this.state = 'ACCEPTED'
    return { state: this.state, reason: 'READY_TO_COMMIT' }
  }

  markCommitted(): TapDecision {
    this.state = 'COMMITTED'
    return { state: this.state, reason: 'BUSINESS_COMMITTED' }
  }

  private verifySignature(raw: TapEnvelope): boolean {
    return raw.sender === 'DeskBoard-A' && raw.signature.length >= 32
  }

  private reject(reason: string): TapDecision {
    this.state = 'REJECTED'
    return { state: this.state, reason }
  }
}

这里故意没有在 accept() 内直接执行签到。验证与业务提交分开,页面或领域服务拿到 READY_TO_COMMIT 后才调用后台;后台成功后再写入幂等记录并执行 markCommitted()。状态变化因此可审计:验证失败不会误报“提交失败”,业务失败也不会伪装成“签名失败”。

易错点有两个。第一,issuedAt 必须来自可信发送端,并容忍小范围时钟偏差,否则设备时间稍有不同就会误拒绝。第二,示例中的签名函数只是文章里的边界占位,实际项目要使用平台安全能力和正式密钥方案,不能用字符串长度当验签逻辑。

图中的 DevEco Studio 工程把 TapEntryAdapter、TapSessionCoordinator 和 IdempotencyStore 分开。右侧模拟器显示 VERIFIED,底部 HiLog 同时打印 payloadId=PAY-8F31C2 与 reason=READY_TO_COMMIT。红圈只标记状态推进和幂等键,避免截图变成满屏批注。

三、幂等不能只放在内存里

第一版用 Set<string> 记录已处理载荷,前台连续测试一切正常。把应用杀掉再重开,同一个载荷却又能提交。内存集合只能挡住同一进程生命周期内的重复,挡不住崩溃恢复、冷启动和跨窗口实例。

我们最终采用“两阶段写入”:先写 PROCESSING,业务成功后改为 COMMITTED;若请求超时则保留任务 ID,并由恢复流程向服务端查询结果,而不是直接再提交。这样能够处理最棘手的窗口——服务端已经成功,但客户端在收到响应之前退出。

这段代码解决什么问题。 它为载荷建立持久化租约,保证同一个 payloadId 在应用重启后仍然只能由一个执行者处理。

interface IdempotencyRecord {
  payloadId: string
  sessionId: string
  status: 'PROCESSING' | 'COMMITTED' | 'FAILED'
  updatedAt: number
  taskId: string
}

export class IdempotencyStore {
  private records: Map<string, IdempotencyRecord> = new Map()

  async has(payloadId: string): Promise<boolean> {
    const record = this.records.get(payloadId)
    return record?.status === 'PROCESSING' || record?.status === 'COMMITTED'
  }

  async acquire(envelope: TapEnvelope): Promise<IdempotencyRecord | undefined> {
    if (await this.has(envelope.payloadId)) return undefined
    const record: IdempotencyRecord = {
      payloadId: envelope.payloadId,
      sessionId: envelope.sessionId,
      status: 'PROCESSING',
      updatedAt: Date.now(),
      taskId: 'TASK-1251-08'
    }
    this.records.set(envelope.payloadId, record)
    await this.flushToPreferences(record)
    return record
  }

  async commit(payloadId: string): Promise<void> {
    const record = this.records.get(payloadId)
    if (!record) throw new Error('LEASE_NOT_FOUND')
    record.status = 'COMMITTED'
    record.updatedAt = Date.now()
    await this.flushToPreferences(record)
  }

  private async flushToPreferences(record: IdempotencyRecord): Promise<void> {
    // 实际项目中由首选项/数据库适配器完成持久化与原子写入。
    console.info(`[TapRelay] persist ${record.payloadId} ${record.status}`)
  }
}

示例用 Map 展示领域逻辑,真正落地时 flushToPreferences() 需要替换成持久化实现,并确保“检查 + 占用”是原子操作。否则两个并发回调都可能先看到不存在,然后各自写入 PROCESSING。如果底层存储不能提供事务,至少要把这段逻辑串行化到同一个任务队列。

FAILED 也不能简单等于“可以重试”。只有明确收到服务端未执行的结果,才允许释放租约;网络超时属于未知状态,要先查询 TASK-1251-08。这条边界会让代码多几行,却能避免支付、门禁、签到等不可逆业务出现双写。

四、页面只呈现事实,不替状态机做决定

UI 曾经根据按钮是否可点击推测流程状态:按钮置灰就表示正在处理,弹出成功提示就表示提交完成。旋转屏幕或页面重建后,这些视觉状态会丢失,业务却可能还在运行。后来我让页面只订阅快照,所有状态都从协调器和存储层读取。

这段代码解决什么问题。 它把系统入口、业务提交与 ArkUI 状态绑定起来,并确保第二次收到同一载荷时只展示诊断结果,不重复调用提交接口。

@Entry
@Component
struct TapRelayPage {
  @State currentState: TapState = 'IDLE'
  @State sessionId: string = 'TAP-20260930-1251-08'
  @State payloadId: string = 'PAY-8F31C2'
  @State reason: string = '等待靠近'

  private store = new IdempotencyStore()
  private coordinator = new TapSessionCoordinator(this.store)

  private async onEnvelope(envelope: TapEnvelope): Promise<void> {
    const decision = await this.coordinator.accept(envelope, Date.now())
    this.currentState = decision.state
    this.reason = decision.reason
    if (decision.reason !== 'READY_TO_COMMIT') return

    const lease = await this.store.acquire(envelope)
    if (!lease) {
      this.currentState = 'REJECTED'
      this.reason = 'DUPLICATE_PAYLOAD'
      return
    }
    await TapRelayApi.commit(lease.taskId, envelope.body)
    await this.store.commit(envelope.payloadId)
    const committed = this.coordinator.markCommitted()
    this.currentState = committed.state
    this.reason = committed.reason
  }

  build() {
    Column({ space: 18 }) {
      Text('TapRelay 精准交接').fontSize(26).fontWeight(FontWeight.Bold)
      Text(this.currentState).fontSize(34).fontColor('#0A7F78')
      Text(`会话 ${this.sessionId}`)
      Text(`载荷 ${this.payloadId}`)
      Text(this.reason).fontColor(this.currentState === 'REJECTED' ? '#D92D20' : '#344054')
    }.padding(24).width('100%')
  }
}

页面状态在首次处理时依次经历 DISCOVERED、VERIFIED、ACCEPTED、COMMITTED,最终结果固定为 BUSINESS_COMMITTED。第二次相同载荷进入时,协调器读取持久化记录,状态直接转为 REJECTED,原因是 DUPLICATE_PAYLOAD。UI 不会再调用 TapRelayApi.commit()。

实际项目还要处理页面销毁:回调不应持有已经失效的组件引用,业务任务应该归属 UI 无关的服务对象。若提交耗时明显,应放到合适的异步执行机制中,主线程只消费结果。HarmonyOS 的 ArkTS 并发能力提供 TaskPool、Worker 等选择,但究竟使用哪一种,应根据数据是否可传递、任务时长与生命周期决定。

手机运行图展示第一次交接:12:51 收到 PAY-8F31C2,四个状态节点全部完成,任务 TASK-1251-08 已提交。红色箭头指向幂等键,强调真正控制一次性的不是页面按钮,也不是会话 ID。

五、把“重复”当作可观测结果

如果重复载荷只是在代码里 return,线上就很难判断到底是用户重复碰触、链路恢复,还是发送端持续重放。我们给每次拒绝记录四项信息:会话 ID、载荷 ID、上次提交时间、拒绝原因。日志不记录业务正文和签名原文,避免诊断数据反过来造成泄露。

本次复测连续触发两次。第一次日志为:

12:51:08.214 I TapRelay: session=TAP-20260930-1251-08 payload=PAY-8F31C2 state=VERIFIED
12:51:08.287 I TapRelay: task=TASK-1251-08 state=COMMITTED reason=BUSINESS_COMMITTED
12:51:08.371 W TapRelay: payload=PAY-8F31C2 state=REJECTED reason=DUPLICATE_PAYLOAD

84 ms 的间隔与事故现场一致,但第二条业务记录没有再次出现。诊断页同时显示“首次结果 COMMITTED”“二次处理 REJECTED”和“重复被拦截 1 次”。这比一句“操作失败”更有价值:测试人员可以确认重复事件确实到达,幂等层也确实工作。

图中的红圈标在 DUPLICATE_PAYLOAD,箭头连接到上一次提交记录。它承担的是技术解释,不是装饰。页面没有显示签名内容,只显示随机数末尾、时间窗和业务标识,符合最小化诊断原则。

六、哪些边界不能交给这一层

这套方案解决的是应用内部的去重、状态推进和恢复,不等于完整安全方案。发送端身份认证、密钥生命周期、链路加密、设备可信状态、用户授权提示,都必须遵循实际接入能力和当前 SDK 文档。文章中的 verifySignature() 仅用于表达调用位置,不能复制到生产环境。

幂等记录也不是永久保存。低风险业务可以按业务有效期清理,高风险业务应以服务端账本为准。清理时不能只看本地时间,还要确认后台任务已经进入终态。对于跨设备并发,本地存储只能减少重复请求,最终唯一性仍应由服务端数据库约束或幂等接口兜底。

最后一个边界是用户意图。精准触发不代表用户同意执行不可逆动作。签到、分享一类低风险操作可以快速完成;支付、开锁、授权等动作仍应提供清晰确认和撤销路径。近场交互缩短的是入口,不应缩短安全判断。

七、这次修正留下的工程结论

这次故障最后没有归咎于“系统回调了两次”,因为系统事件与业务动作本来就不是一一对应。真正可靠的实现要把事件看成输入,把业务提交看成受约束的状态迁移。

我们保留了三条规则:payloadId 是幂等主键,sessionId 只用于追踪;PROCESSING 是需要恢复的业务状态,不能因超时直接释放;页面永远只展示领域快照,不通过控件状态推断业务结果。复测中首次交接在 73 ms 内完成本地校验,重复事件在进入网络层之前被拦截,后台记录由两条恢复为一条。

参考资料:

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐