“补交失败,请重试”看起来是个无害提示,在巡检系统中却可能诱导危险操作。值班员刚把“备用电源”改为已检查,设备进入离线状态;恢复链路后,系统发现另一名同事已经更新了任务。此时反复按“补交”,不会让旧数据变新,反而可能掩盖现场到底采用了谁的结果。更合适的界面不是一个无限旋转的加载圈,而是一张能说明分歧、允许用户选择的冲突处置单。

本篇沿用 ShiftCheck 现场巡检台:三项 CheckTask { id, title, location, done }、本机 shiftcheck_draft_v1 Preferences 文件、tasks_v1 草稿键、pending_confirm_v1 待办键,以及 PendingConfirm { requestId, taskId, wantedDone, createdAtMs }。上一期已经把补交队列控制为单条、保存后再发送,示例接收端 DemoCommandGateway 会按照请求号去重;本篇不重复“如何入队”,重点处理接收端明确拒绝的版本冲突。示例接收端的状态不代表真实服务器,也不提供多设备审计能力。

冲突裁决与待办安全替换封面

一、先回答:这笔命令到底失败在哪里

失败至少分为三个事实层次。第一种是本机写入失败:队列快照没有可靠保存,此时不能显示“已经入队”。第二种是传输不确定:例如请求超时,接收端是否执行未知,重试必须携带原来的 requestId,不能另造一笔。第三种才是我们本次关心的版本冲突:接收端返回 accepted=false,并给出 serverVersion 与当前权威任务列表。这里的“拒绝”是明确结果,旧命令不能只更新 baseVersion 后无条件重放。

实际场景中,用户需要比较“我的期望值”和“对方的当前值”,而不是盯着两个抽象版本号。以“备用电源”为例,队首命令期望 wantedDone=true,接收端当前该项为 false。允许的动作是放弃本机修改、以权威值为准,或者在理解差异后重新创建一次确认。第二个动作是新的业务意图,必须申请新的请求号;旧请求号只可用于同一请求体的幂等重试。对于已经收到明确确认成功的命令,则不能出现在冲突裁决页面。

// entry/src/main/ets/common/ConflictReview.ets
import { CheckTask, ConfirmResult } from './RevisionGateway';
import { PendingConfirm } from './PendingConfirm';

export interface ConflictCard {
  taskId: string;
  title: string;
  expected: boolean;
  authoritative: boolean;
  serverVersion: number;
}

export function makeConflictCard(command: PendingConfirm,
  result: ConfirmResult): ConflictCard {
  if (result.accepted) { throw new Error('已确认命令不能进入冲突处置'); }
  const task: CheckTask | undefined = result.tasks.find(
    (item: CheckTask) => item.id === command.taskId);
  if (!task) { throw new Error('权威列表中已不存在该任务'); }
  return { taskId: command.taskId, title: task.title,
    expected: command.wantedDone, authoritative: task.done,
    serverVersion: result.serverVersion };
}

提取 ConflictCard 是为了让 UI 只展示可解释的业务差异。它不会反向修改源命令,也不会把 UI 上的“已检查”当成服务端确认。任务被删除也应进入“需要管理员处理”的异常状态,而不是把 undefined 强转成一个新任务。正式联调还应补充操作时间、操作员和接收端回执信息,供值班员判断谁的确认更可信。

二、两种裁决方式,持久化顺序要一致

上一期 replayPending() 在冲突时设置 conflictTaskId,但没保留完整的 ConfirmResult。现在给页面加 @State private review: ConflictCard | null = null 和私有 lastConflict: ConfirmResult | null。收到明确的拒绝结果后先保存 lastConflict,再用 makeConflictCard() 得到卡片;例如将上一期 replayPending() 的拒绝分支补成:

if (!result.accepted) {
  this.lastConflict = result;
  this.review = makeConflictCard(item, result);
  this.pendingText = '版本冲突,等待人工裁决';
  return;
}

这样“当前值”来自接收端返回的任务快照,而不是本地草稿。重进应用后,这份内存快照会失效,页面应重新查询权威状态,再开放裁决按钮。

ShiftCheck 冲突裁决的完整竖屏界面设计

实现两条按钮时,顺序必须以持久待办为中心。选择“接受当前状态”,先 savePending([]) 并等待 flush(),成功后再清空页面待办并替换任务视图。否则手机在清空界面后突然退出,下次启动仍会看见一条本来已经放弃的请求。选择“仍要按我的状态确认”,应原子替换队首命令,不能先把旧命令删除,再启动异步入队:在两个动作之间发生崩溃会导致意图消失。这里的“原子”仅指在这个示例的一次 Preferences 队列值更新,而非多个存储文件之间的数据库事务。

// Index.ets:沿用上一期的 savePending()、pending、tasks、serverVersion
private lastConflict: ConfirmResult | null = null;
@State private review: ConflictCard | null = null;
@State private reviewBusy: boolean = false;

private async decideConflict(keepMine: boolean): Promise<void> {
  if (this.reviewBusy || !this.review || !this.lastConflict ||
      this.pending.length !== 1 || this.lastConflict.accepted) { return; }
  const original: PendingConfirm = this.pending[0];
  if (this.review.taskId !== original.taskId) { return; }
  this.reviewBusy = true;
  const authoritative: ConfirmResult = this.lastConflict;
  try {
    let nextQueue: PendingConfirm[] = [];
    if (keepMine) {
      nextQueue = [{ ...original,
        requestId: `sc-${Date.now()}-${++this.requestSerial}`,
        createdAtMs: Date.now() }];
    }
    await this.savePending(nextQueue);
    this.pending = nextQueue;
    this.serverVersion = authoritative.serverVersion;
    this.tasks = authoritative.tasks.map((item: CheckTask): CheckTask => {
      if (keepMine && item.id === original.taskId) {
        return { ...item, done: original.wantedDone };
      }
      return { ...item };
    });
    this.queueDraft();
    this.review = null;
    this.lastConflict = null;
    this.pendingText = keepMine ? '已建立新待办,请再次确认' : '已放弃本机修改';
  } catch (_) {
    this.pendingText = '裁决写入未确认,请重新核查本地队列';
  } finally {
    this.reviewBusy = false;
  }
}

这段代码假定示例系统“一次只审一笔”,并在用户裁决期间禁止再次点击补交;否则 savePending() 与 replayPending() 交错操作仍可能出现覆盖。在真正的团队巡检应用里,新请求号应采用保证唯一性的生成方案,并把“基于哪个版本重做确认”随命令一并保存;示例代码沿用上一章内存中的 serverVersion,应用重启后需要重新建立权威基线,不能沿用一个猜测版本。

三、让状态机替代无休止的自动重试

判断动作是否可用,比给按钮换颜色更重要。待补交 可尝试发送;发送中 禁止二次点击;结果未知 仅允许沿用旧请求号查询或重试;版本冲突 要先裁决;已确认 可以清除待办,但只有持久化删除成功才能更新本机角标;本机写入失败 必须阻止继续操作。不要把“失败次数超过三次”直接等同于“业务命令无效”,也不能因为页面关闭就暗中吞掉旧请求。

本机待办冲突裁决的状态转移图

这张状态图特意把“网络未知”和“业务拒绝”放在不同分支。超时的请求保持相同 requestId,进入待查询;只有明确拒绝且附带权威数据时,才开放“接受当前值”和“保留我的意图”两个动作。按钮名称还需要与最终结果区分:后者并不意味着已经同步成功,它只是生成了一笔新的待确认命令。

队列和草稿分别写在两个 Preferences 键中,不代表它们可以跨键强事务提交。比如 pending_confirm_v1 写成空数组后,写 tasks_v1 的 queueDraft() 失败,下次启动可能看到“无待办、草稿却仍是旧值”。恢复顺序应该固定为“读取队列 → 必要时按队列恢复本机展示 → 读取可用的权威快照或请求刷新”,并让未成功持久化的展示状态可被再次刷新。大规模业务可以转向更明确的事务型本地数据模型,不能无限扩展单个 JSON 字符串。

四、按失败注入而不是按按钮次数验收

回归检验可以在 DemoCommandGateway 中加入可控开关来构造三类结果。先勾选“备用电源”,确认队列保存;关掉受控链路时绝不发出请求。再把接收端 serverVersion 改掉并返回拒绝:卡片应显示“备用电源:本机已检查 / 当前未检查”,按钮不应叫“同步成功”。选择接受权威值后检查队列变为空,刷新页面不会恢复被放弃的勾选。

另一次测试选择保留意图,观察旧命令号变化为新命令号,待办数量仍为 1,页面提示“请再次确认”;只有再次发出并得到接收端确认后才可显示成功。最后在 savePending(nextQueue) 前注入抛错,核对页面不应该清掉原命令,再取消注入重试。若失败出现在 putSync() 之后、flush() 期间,Preferences 缓存和磁盘状态可能不一致;此时必须冻结自动补交,重新读取并核对 pending_confirm_v1,不能仅凭内存 pending 保证旧请求一定安全。还要单独构造网络超时:此时绝不开放冲突裁决,而是保留原号等待状态明确。

明确拒绝、未知结果与本地失败的分支对照

这个验证方案依赖可控示例数据,并不宣称存在真实生产网络延迟或服务端回执。Preferences 的 putSync() 与 flush() 语义可查阅华为 User Preferences 官方参考;UI 的差异列表使用 ArkUI ForEach 时应固定按 taskId 生成键,详见 ForEach 文档。这两处能力不要求相机、定位或蓝牙权限。

五、把用户的选择变成可恢复的数据事实

这一期没有引入新的网络协议,真正的增量是把“冲突”从一个失败提示改造成需要操作员判断的业务分支,并明确哪个请求号可以复用、哪个必须更新。现场巡检不能把传输层的“响应没回来”误判为业务上的“内容被拒绝”,更不能在冲突发生时直接用本机值覆盖别人刚刚确认的结果。后续收尾篇将围绕容量上限、错误恢复、队列出清及现场交付验收,把这条技术路径从单条链路收束成可以核查的工程约束。

Logo

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

更多推荐