资讯动态

HarmonyOS 7 + ArkTS:精准碰一碰会话去重与安全交接【鸿蒙心迹】

发布时间:2026/10/1 8:00:44 来源:尧图企业网站定制
一、两次回调只能产生一次业务结果这次问题出现在会议签到 DemoTapRelay的联调阶段。手机靠近桌面终端后页面从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 替代幂等键因为同一载荷可能在链路恢复后进入新的会话。这段代码解决什么问题。它把系统入口送来的原始数据收敛成强类型信封并在状态机内完成时间窗、随机数和重复载荷校验。typeTapStateIDLE|DISCOVERED|VERIFIED|ACCEPTED|COMMITTED|REJECTEDinterfaceTapEnvelope{sessionId:stringpayloadId:stringsender:stringnonce:stringissuedAt:numberexpiresInMs:numberbody:stringsignature:string}interfaceTapDecision{state:TapState reason:string}exportclassTapSessionCoordinator{privatestate:TapStateIDLEprivatereadonlymaxClockSkewMs3000constructor(privatestore:IdempotencyStore){}asyncaccept(raw:TapEnvelope,now:number):PromiseTapDecision{this.stateDISCOVEREDif(nowthis.maxClockSkewMsraw.issuedAt||now-raw.issuedAtraw.expiresInMsthis.maxClockSkewMs){returnthis.reject(EXPIRED_OR_CLOCK_SKEW)}if(!this.verifySignature(raw)||raw.nonce!N-73A91E){returnthis.reject(SIGNATURE_OR_NONCE_INVALID)}this.stateVERIFIEDif(awaitthis.store.has(raw.payloadId)){returnthis.reject(DUPLICATE_PAYLOAD)}this.stateACCEPTEDreturn{state:this.state,reason:READY_TO_COMMIT}}markCommitted():TapDecision{this.stateCOMMITTEDreturn{state:this.state,reason:BUSINESS_COMMITTED}}privateverifySignature(raw:TapEnvelope):boolean{returnraw.senderDeskBoard-Araw.signature.length32}privatereject(reason:string):TapDecision{this.stateREJECTEDreturn{state:this.state,reason}}}这里故意没有在accept()内直接执行签到。验证与业务提交分开页面或领域服务拿到READY_TO_COMMIT后才调用后台后台成功后再写入幂等记录并执行markCommitted()。状态变化因此可审计验证失败不会误报“提交失败”业务失败也不会伪装成“签名失败”。易错点有两个。第一issuedAt必须来自可信发送端并容忍小范围时钟偏差否则设备时间稍有不同就会误拒绝。第二示例中的签名函数只是文章里的边界占位实际项目要使用平台安全能力和正式密钥方案不能用字符串长度当验签逻辑。图中的 DevEco Studio 工程把TapEntryAdapter、TapSessionCoordinator和IdempotencyStore分开。右侧模拟器显示VERIFIED底部 HiLog 同时打印payloadIdPAY-8F31C2与reasonREADY_TO_COMMIT。红圈只标记状态推进和幂等键避免截图变成满屏批注。三、幂等不能只放在内存里第一版用Setstring记录已处理载荷前台连续测试一切正常。把应用杀掉再重开同一个载荷却又能提交。内存集合只能挡住同一进程生命周期内的重复挡不住崩溃恢复、冷启动和跨窗口实例。我们最终采用“两阶段写入”先写PROCESSING业务成功后改为COMMITTED若请求超时则保留任务 ID并由恢复流程向服务端查询结果而不是直接再提交。这样能够处理最棘手的窗口——服务端已经成功但客户端在收到响应之前退出。这段代码解决什么问题。它为载荷建立持久化租约保证同一个payloadId在应用重启后仍然只能由一个执行者处理。interfaceIdempotencyRecord{payloadId:stringsessionId:stringstatus:PROCESSING|COMMITTED|FAILEDupdatedAt:numbertaskId:string}exportclassIdempotencyStore{privaterecords:Mapstring,IdempotencyRecordnewMap()asynchas(payloadId:string):Promiseboolean{constrecordthis.records.get(payloadId)returnrecord?.statusPROCESSING||record?.statusCOMMITTED}asyncacquire(envelope:TapEnvelope):PromiseIdempotencyRecord|undefined{if(awaitthis.has(envelope.payloadId))returnundefinedconstrecord:IdempotencyRecord{payloadId:envelope.payloadId,sessionId:envelope.sessionId,status:PROCESSING,updatedAt:Date.now(),taskId:TASK-1251-08}this.records.set(envelope.payloadId,record)awaitthis.flushToPreferences(record)returnrecord}asynccommit(payloadId:string):Promisevoid{constrecordthis.records.get(payloadId)if(!record)thrownewError(LEASE_NOT_FOUND)record.statusCOMMITTEDrecord.updatedAtDate.now()awaitthis.flushToPreferences(record)}privateasyncflushToPreferences(record:IdempotencyRecord):Promisevoid{// 实际项目中由首选项/数据库适配器完成持久化与原子写入。console.info([TapRelay] persist${record.payloadId}${record.status})}}示例用Map展示领域逻辑真正落地时flushToPreferences()需要替换成持久化实现并确保“检查 占用”是原子操作。否则两个并发回调都可能先看到不存在然后各自写入PROCESSING。如果底层存储不能提供事务至少要把这段逻辑串行化到同一个任务队列。FAILED也不能简单等于“可以重试”。只有明确收到服务端未执行的结果才允许释放租约网络超时属于未知状态要先查询TASK-1251-08。这条边界会让代码多几行却能避免支付、门禁、签到等不可逆业务出现双写。四、页面只呈现事实不替状态机做决定UI 曾经根据按钮是否可点击推测流程状态按钮置灰就表示正在处理弹出成功提示就表示提交完成。旋转屏幕或页面重建后这些视觉状态会丢失业务却可能还在运行。后来我让页面只订阅快照所有状态都从协调器和存储层读取。这段代码解决什么问题。它把系统入口、业务提交与 ArkUI 状态绑定起来并确保第二次收到同一载荷时只展示诊断结果不重复调用提交接口。EntryComponentstruct TapRelayPage{StatecurrentState:TapStateIDLEStatesessionId:stringTAP-20260930-1251-08StatepayloadId:stringPAY-8F31C2Statereason:string等待靠近privatestorenewIdempotencyStore()privatecoordinatornewTapSessionCoordinator(this.store)privateasynconEnvelope(envelope:TapEnvelope):Promisevoid{constdecisionawaitthis.coordinator.accept(envelope,Date.now())this.currentStatedecision.statethis.reasondecision.reasonif(decision.reason!READY_TO_COMMIT)returnconstleaseawaitthis.store.acquire(envelope)if(!lease){this.currentStateREJECTEDthis.reasonDUPLICATE_PAYLOADreturn}awaitTapRelayApi.commit(lease.taskId,envelope.body)awaitthis.store.commit(envelope.payloadId)constcommittedthis.coordinator.markCommitted()this.currentStatecommitted.statethis.reasoncommitted.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.currentStateREJECTED?#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: sessionTAP-20260930-1251-08 payloadPAY-8F31C2 stateVERIFIED 12:51:08.287 I TapRelay: taskTASK-1251-08 stateCOMMITTED reasonBUSINESS_COMMITTED 12:51:08.371 W TapRelay: payloadPAY-8F31C2 stateREJECTED reasonDUPLICATE_PAYLOAD84 ms 的间隔与事故现场一致但第二条业务记录没有再次出现。诊断页同时显示“首次结果 COMMITTED”“二次处理 REJECTED”和“重复被拦截 1 次”。这比一句“操作失败”更有价值测试人员可以确认重复事件确实到达幂等层也确实工作。图中的红圈标在DUPLICATE_PAYLOAD箭头连接到上一次提交记录。它承担的是技术解释不是装饰。页面没有显示签名内容只显示随机数末尾、时间窗和业务标识符合最小化诊断原则。六、哪些边界不能交给这一层这套方案解决的是应用内部的去重、状态推进和恢复不等于完整安全方案。发送端身份认证、密钥生命周期、链路加密、设备可信状态、用户授权提示都必须遵循实际接入能力和当前 SDK 文档。文章中的verifySignature()仅用于表达调用位置不能复制到生产环境。幂等记录也不是永久保存。低风险业务可以按业务有效期清理高风险业务应以服务端账本为准。清理时不能只看本地时间还要确认后台任务已经进入终态。对于跨设备并发本地存储只能减少重复请求最终唯一性仍应由服务端数据库约束或幂等接口兜底。最后一个边界是用户意图。精准触发不代表用户同意执行不可逆动作。签到、分享一类低风险操作可以快速完成支付、开锁、授权等动作仍应提供清晰确认和撤销路径。近场交互缩短的是入口不应缩短安全判断。七、这次修正留下的工程结论这次故障最后没有归咎于“系统回调了两次”因为系统事件与业务动作本来就不是一一对应。真正可靠的实现要把事件看成输入把业务提交看成受约束的状态迁移。我们保留了三条规则payloadId是幂等主键sessionId只用于追踪PROCESSING是需要恢复的业务状态不能因超时直接释放页面永远只展示领域快照不通过控件状态推断业务结果。复测中首次交接在 73 ms 内完成本地校验重复事件在进入网络层之前被拦截后台记录由两条恢复为一条。参考资料HarmonyOS 碰一碰分享相关开发文档HarmonyOS ArkTS 语言基础类库

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑