ArkData + HarmonyOS 库存台账:坏快照隔离与发布前恢复验收 · 06【共创稿事节】

库存台账真正危险的一次故障,未必发生在用户点击“盘点提交”时。有时写入已经结束,页面也显示正确,可下一次冷启动读取到的字符串却不符合现在的数据结构:某条物料的库存成了负数,流水编号重复,或者 rows 根本不是数组。把这样的 JSON 直接当成 SupplyLedgerV2 渲染,轻则列表空白,重则又一次编辑把损坏数据覆盖成新的“正常文件”。
SupplyLedger 前五篇已经解决行级状态同步、筛选与原始对象引用、Preferences 快照、串行提交、批量盘点和受控撤销。收尾工作不再添加新业务按钮,而是将读取、校验、隔离、人工确认恢复串成一条可靠路径。目标明确:以本地单实例和少量办公耗材为边界,在不可恢复的内容面前宁可进入只读提示,也不能静默丢掉现有库存流水。
一、把坏数据当作独立状态,而不是一个 catch 分支
Preferences 存储的是键值,不是带约束的关系型表。get('supply_snapshot', '') 只能告诉我们读取了某个值,不会自动保证它符合应用升级后的业务规则。即使 JSON.parse 成功,也只意味着字符串符合 JSON 语法,并不意味着库存合法。因此校验应当发生在构造 @Observed SupplyItem 对象之前,不能先渲染后补救。
我们沿用上篇的 SupplyLedgerV2:version: 2、rows、movements 和 nextEventNumber。每个物料有稳定 id、名称 name 和非负 stock;流水包括 id、itemId、kind、quantity、stockAfter、note 和 createdAt。诊断阶段不修改原始对象,统一返回三个结果之一:ok 表示允许进入编辑模式,empty 表示新安装尚无快照,invalid 表示存在内容但不能安全采用。这里最关键的是区分 未初始化 与 损坏:空存储可以展示示例初始数据;损坏存储不能自动以示例数据回写覆盖。

为便于排查,错误码使用稳定的英文标识,例如 DUPLICATE_ROW_ID 和 BROKEN_MOVEMENT。页面可以翻译为“物料编号重复”“操作记录异常”,而不是直接向用户展示 JS 报错栈。错误码应表示可修复的问题类别,不应记录用户输入的备注、物料采购详情或完整快照到普通日志中。
二、纯函数先完成核验,再决定是否能加载
把数据约束集中在 SnapshotGuard.ets,既便于补充测试案例,也避免每个页面自己写一套不同的判定。JSON.parse 后的类型断言只是编译期描述,绝不能代替运行时检查。下例刻意限制为当前系列实际使用的字段;若以后新增供应商或多仓库字段,应提升版本并补上新的校验条件,而不是放松老版本的所有限制。
// entry/src/main/ets/store/SnapshotGuard.ets
import { SupplyLedgerV2 } from '../model/StockMovement';
export interface LoadCheck {
status: 'ok' | 'empty' | 'invalid';
reason: string;
value?: SupplyLedgerV2;
}
function isCount(n: number): boolean {
return Number.isSafeInteger(n) && n >= 0;
}
export function inspectText(raw: string): LoadCheck {
if (raw.length === 0) return { status: 'empty', reason: 'EMPTY' };
let data: SupplyLedgerV2;
try { data = JSON.parse(raw) as SupplyLedgerV2; }
catch (_e) { return { status: 'invalid', reason: 'BAD_JSON' }; }
if (!data || data.version !== 2 || !Array.isArray(data.rows) ||
!Array.isArray(data.movements) ||
!isCount(data.nextEventNumber)) {
return { status: 'invalid', reason: 'BAD_SCHEMA' };
}
const ids: Set<string> = new Set<string>();
for (const row of data.rows) {
if (typeof row.id !== 'string' || row.id.length === 0 ||
typeof row.name !== 'string' || !isCount(row.stock)) {
return { status: 'invalid', reason: 'BROKEN_ROW' };
}
if (ids.has(row.id)) {
return { status: 'invalid', reason: 'DUPLICATE_ROW_ID' };
}
ids.add(row.id);
}
for (const movement of data.movements) {
if (!ids.has(movement.itemId) || !isCount(movement.quantity) ||
movement.quantity === 0 || !isCount(movement.stockAfter) ||
(movement.kind !== 'IN' && movement.kind !== 'OUT')) {
return { status: 'invalid', reason: 'BROKEN_MOVEMENT' };
}
}
return { status: 'ok', reason: 'OK', value: data };
}
注意一处边界:历史流水可能按上篇策略仅保留最近 100 条,而原始业务编号不会因为裁剪归零,所以不能要求 movements.length === nextEventNumber。如果存在被删除的物料,旧流水的 itemId 也可能指向历史编号;当前示例没有删除物料功能,因此采用“必须能在现有 rows 中找到”这一收紧规则。未来加入删除流程时,应改成保留墓碑记录或调整历史外键策略,不能继续照搬这个条件。
进一步建议再核对流水 id 唯一、createdAt 是合理有限整数、note 是字符串,以及单条流水的 stockAfter 不超限。这里还存在一个业务一致性取舍:假如仅凭当前库存与最后一条流水的 stockAfter 对比,历史批次被裁剪后仍可能误判,因此当前守卫只检验结构与可用性,历史账务一致性留给专门的审计逻辑。对错误做出不同等级的分类,意味着恢复时可以判断是整个文件不可读,还是单独某条历史记录异常;不要在守卫里擅自丢弃“看着不对”的流水,因为删除历史记录本身也是一次需要解释的业务操作。这里为了突出入口校验,展示主要分支;正式扩展数据模型时可为不同错误配套测试用例。还要对字符串大小设置上限,避免本地损坏内容耗尽内存;这属于接入层约束,不宜在多个业务组件里重复实现。
三、先保留证据,再开放恢复操作
启动时有三条路径。正常路径把 v2 快照经过 rehydrate 转成页面上的响应式对象,刷新筛选视图;全新安装按既有默认物料构造展示,但初次落盘仍应由明确的初始化步骤完成;坏快照路径则让页面进入只读核验界面。不能在 catch 中写 this.items = defaults; await save(defaults),因为它把“恢复显示”和“覆盖坏文件”混为一个动作。
专门的 InventoryRecovery.ets 使用不同的键保存待处理文本,下面的入口假设 store 已获得 Preferences 对象并封装 getText/putTextAndFlush。保存失败要停止后续流程,不能继续执行修复。
// entry/src/main/ets/store/InventoryRecovery.ets
import { InventoryStore } from './InventoryStore';
import { inspectText, LoadCheck } from './SnapshotGuard';
export async function inspectBeforeOpen(
store: InventoryStore): Promise<LoadCheck> {
const raw: string = await store.readText();
const check: LoadCheck = inspectText(raw);
if (check.status !== 'invalid') return check;
// 只保留一个隔离槽,正式系统应增加容量限制与用户确认。
await store.putTextAndFlush('quarantine_supply_v2', raw);
return check;
}
这里的 readText() 复用 A-03 的实现,读取键 supply_snapshot;putTextAndFlush() 是对已有 InventoryStore 的工程扩展,不是华为 SDK 的新接口。与 A-04 使用的 writeText() 一样,必须坚持 put 后显式 flush,只有写入异步操作返回后才告知用户隔离完成。增加的方法如下,直接接在原类内部即可:
// InventoryStore.ets:加在已有 InventoryStore 类内部
async putTextAndFlush(key: string, raw: string): Promise<void> {
if (!this.db) throw new Error('存储未初始化');
await this.db.put(key, raw);
await this.db.flush();
}
尤其要注意,写入隔离槽成功不等于能够静默删除原键:保留原 supply_snapshot 才能在排查后决定采取什么恢复动作。单槽隔离只能避免“边读边覆盖”,并不等于提供了多代可恢复备份;如需多版本备份,应使用有保留策略的独立文件或数据库,并考虑敏感信息保护。
恢复按钮也不能只看 JSON 是否可解析。建议设置两步:先从历史已知正常快照、用户自行保存的备份文本或应用默认数据构造候选快照;再次调用 inspectText(),确认校验通过后展示预计变化的物料数量和流水条数,再让用户明确选择“采用此版本”。提交仍由 A-04 的 StockCommitQueue 串行执行,保证恢复操作不与普通入库、盘点相互覆盖。对于需要保留旧流水的业务,恢复应记录“来自哪个来源、由谁确认”,不能悄悄重写账面数量却说这是一次普通盘点。

四、用预设故障样本做最终验收
这一步的验证重点不在 FPS,而在可重复的状态转换。准备五份本地测试输入:合法的 v2 JSON;空字符串;故意删掉一个右花括号的字符串;包含重复 id 的数组;把 stock 改为负数的快照。先用独立纯函数测试输入与返回状态,确认不存在对原对象的修改;再把样本通过调试构建写入 supply_snapshot,冷启动页面验证禁止编辑与隔离逻辑。完成后删掉测试文件,恢复真实业务样本。
在参考盘点样例中,A4 复印纸为 22 包,中性笔为 8 盒,USB-C 转接头为 5 个。这个数值承接 A-05 批次示例,并不是声称真实仓库有多少库存。正常数据读取后显示三个物料卡片、版本 2 核验结果和“查看变更记录”入口;遇到损坏时,应替换为“快照需要核验”与“保留原始数据”操作说明,不能再显示“保存成功”。

测试矩阵至少包含六个判定点:①空快照只走初始化,不显示坏数据警告;②合法数据恢复后,筛选与详情使用相同业务 ID;③负库存不能进入可编辑视图;④备份写入失败时,不修改原 supply_snapshot;⑤恢复候选在提交前二次核验;⑥恢复提交后强制冷启动重新读取,检查三类物料与流水编号仍保持一致。若出现 put 成功但 flush 拒绝,当前存储内容可能处在不确定状态,提示用户重新读取确认,不要直接断言“肯定没写入”。
核验页的用户操作应尽量可见:进入隔离状态时,先显示橙色提示“数据需要核验”,下面依次给出错误类型、原始内容已保留的确认状态、可选择的恢复来源和取消入口。恢复来源的按钮在未验证前不可提交,来源为空时不能用默认样本代替用户原有数据;提交后最好重新打开同一个 Preferences 实例并做第二次校验,再把只读状态解除。若为了教学展示使用 A4 22 包、中性笔 8 盒和 USB-C 5 个这样的数值,要明确这些是示例数据,并在真实部署时与采购系统的账面数据逐项核对。
同样需要覆盖连续点击、页面销毁后异步回调返回、输入超长文本以及资源占用。用户快速双击“采用此版本”时,页面锁只能减少同页面重复操作,最终还要依赖串行提交和幂等标识,不能把 UI 的 disabled 状态当成事务隔离。多进程或多设备共享账本不适合沿用 Preferences 单实例方案:官方文档明确提醒 Preferences 不具备多进程线程安全保障,业务增长到多仓库、多审批人员时应重新评估 RDB、服务器协调与操作冲突模型。
至此,SupplyLedger 六篇形成一条完整闭环:从观察数据变化开始,经过筛选编辑、保存回退、串行流水、盘点撤销,最终落到启动核验与错误恢复。它的价值不是页面有多少按钮,而是每次变化都能说明“数据来自哪里、何时确认、失败后怎么处理”。正式发布前还应使用目标 DevEco Studio SDK 将本系列模块组合编译,在预览环境上完成以上回归;代码片段展示的是工程增量,实际项目必须核对引用路径和模块拼装。
更多推荐



所有评论(0)