ArkTS + HarmonyOS 巡检补交:冲突裁决与待办安全替换(连载05/06)【共创稿事节】
“补交失败,请重试”看起来是个无害提示,在巡检系统中却可能诱导危险操作。值班员刚把“备用电源”改为已检查,设备进入离线状态;恢复链路后,系统发现另一名同事已经更新了任务。此时反复按“补交”,不会让旧数据变新,反而可能掩盖现场到底采用了谁的结果。更合适的界面不是一个无限旋转的加载圈,而是一张能说明分歧、允许用户选择的冲突处置单。
本篇沿用 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;
}
这样“当前值”来自接收端返回的任务快照,而不是本地草稿。重进应用后,这份内存快照会失效,页面应重新查询权威状态,再开放裁决按钮。

实现两条按钮时,顺序必须以持久待办为中心。选择“接受当前状态”,先 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 文档。这两处能力不要求相机、定位或蓝牙权限。
五、把用户的选择变成可恢复的数据事实
这一期没有引入新的网络协议,真正的增量是把“冲突”从一个失败提示改造成需要操作员判断的业务分支,并明确哪个请求号可以复用、哪个必须更新。现场巡检不能把传输层的“响应没回来”误判为业务上的“内容被拒绝”,更不能在冲突发生时直接用本机值覆盖别人刚刚确认的结果。后续收尾篇将围绕容量上限、错误恢复、队列出清及现场交付验收,把这条技术路径从单条链路收束成可以核查的工程约束。
更多推荐


所有评论(0)