BasketFlow 购物车结果归档与中断恢复技术封面

订单接口返回“接纳”,屏幕刚好退出;下一次打开应用,用户看到的却仍是三件商品和一个可以再次提交的按钮。与重复点击相比,这种故障更难发现:页面只是展示一次成功,并没有为恢复提供可靠证据。另一种相反的问题是,网络请求事实上已经完成,客户端在回执到来之前被系统终止,重新打开时又自动把同一批商品提交一次。对于前五篇已经具有草稿、版本迁移、撤销与结算快照的 BasketFlow 来说,真正的收尾工作不是再添加一个漂亮的“订单成功”弹窗,而是明确订单结果、未决意图和购物车草稿各自的写入与恢复规则。

本篇沿用此前的机械键盘25900分、无线鼠标12900分、便携支架7900分,三件均选中且数量为1时的结算预览是46700分。第五篇的 CheckoutIntent 已包含请求标识、内容指纹和冻结快照;它保护了点击时刻的金额,却没有承诺设备异常退出后的状态如何恢复。我们补上的,是可被重复读取的本地记录,以及遇到不确定结果时不擅自判定成功的规则。下文的网关仍是可替换的本地示例,不代表实际支付、库存锁定或服务端订单创建。

一、把订单的三个阶段写成可以检查的事实

界面常用的“待提交、提交中、成功、失败”不够严谨。提交中如果被中断,下次进程启动时并不能知道服务器是否处理完成,因此恢复时不能直接转换为失败,更不能把草稿清空。我们将持久化的订单记录收束为三种状态:prepared 表示客户端已经保存一次准备发送的不可变意图;accepted 表示已收到匹配的、由业务适配器确认的接纳结果;rejected 表示明确拒绝。无法证明处理结论时,保留 prepared 并在界面显示“结果待核实”。

这一结构与前面 DraftV2 的 revision 不冲突。本地购物车草稿的版本用于恢复编辑内容,订单记录的 requestKey 则用于标识一次业务意图;两者没有谁天然能够代替谁。尤其不能在 accepted 之后删除所有本地 Preferences 数据,因为那可能误删用户没有勾选的商品、其他未完成操作或者以后要用来展示的订单结果。正式业务系统需要服务端提供稳定的订单查询和幂等接口,再由客户端按服务端结论推进状态。客户端 requestKey 的生成应遵循业务后端的唯一性与过期约定,下面的 demo 键只用于本地路径示例。

interface CheckoutItem {
  id: number;
  title: string;
  priceFen: number;
  count: number;
}
interface CheckoutPreview {
  items: CheckoutItem[];
  totalFen: number;
}
type JournalStage = 'prepared' | 'accepted' | 'rejected';
interface OrderJournal {
  schema: 1;
  requestKey: string;
  signature: string;
  stage: JournalStage;
  snapshot: CheckoutPreview;
  reference: string;
}
interface GatewayReply {
  requestKey: string;
  accepted: boolean;
  reference: string;
}

使用这套类型,prepared 记录一定包含冻结时的 snapshot,即使启动后购物车已被改动,恢复页仍能说明“这是哪次请求、涉及哪些商品、金额是多少”。reference 只在收到有效回执后写入,示例中的本地参考号也必须和真实服务端订单号区分。建议正式接口将支付状态单独建模:订单已接纳不等于支付完成,支付完成也不意味着所有后续履约状态已经结束。

从冻结快照到订单结果页的开发结构示意

二、先持久化准备记录,再请求适配器

操作顺序不能交换。假如先 send() 后 put(),进程恰好在两者之间结束,客户端没有记录此次提交,恢复时只会看到普通草稿;如果先保存 prepared,中断后至少知道有一次意图可能已发出。Preferences 提供 getPreferences、put 与 flush,其中 put 修改内存中的键值,flush 才是明确要求执行持久化的操作。它适合这种小型元数据和原型状态,不适合把它当作生产级交易数据库,也不具备跨设备事务能力。官方还指出 Preferences 不应作为多进程共享数据库使用。

下面把持久化封装在 entry/src/main/ets/common/OrderJournalStore.ets。context 由 Stage 模型的页面获取并传入,成功与失败都要显式处理;序列化为 JSON 字符串前应确保字段经过业务校验。考虑到第五篇已有 makeCheckoutPreview() 与 previewSignature(),这里直接使用它们,而不是复制购物车计算规则,避免六篇连载逐渐长出不同的金额算法。

import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

class OrderJournalStore {
  private context: common.UIAbilityContext;
  constructor(context: common.UIAbilityContext) {
    this.context = context;
  }
  async save(record: OrderJournal): Promise<void> {
    const pref = await preferences.getPreferences(
      this.context, 'basketflow_order_journal');
    await pref.put('latest_order', JSON.stringify(record));
    await pref.flush();
  }
  async readText(): Promise<string> {
    const pref = await preferences.getPreferences(
      this.context, 'basketflow_order_journal');
    const value = await pref.get('latest_order', '');
    return typeof value === 'string' ? value : '';
  }
}

这是“单个最近订单”的示例存储。正式产品若需要订单历史、分页、查询条件或跨进程访问,应选择明确的数据结构和数据库方案,不要在一个 Preferences 键中无限追加数组。flush() 返回并不表示服务器接受了业务请求;它只意味着本地存储步骤完成。若 save(prepared) 抛异常,本次示例应停止调用传输适配器并提示“本地准备记录保存失败”,避免产生无法恢复的请求;若接纳回执已经拿到但 save(accepted) 失败,则属于“已收到接纳、归档失败”,不能简单把请求再次发送。

三、让提交结果始终绑定同一份快照

在原来的 CheckoutPage.ets 里,submitSeq 用于隔离晚到的 Promise;最终收尾时仍然需要它,但更多了可恢复的 Journal。做法是:点击确认时生成 CheckoutPreview,复制商品明细,构造 requestKey 和签名,先将准备记录 flush,再调用适配器;收到回执后确认请求键一致,才将 stage 写入本地。页面显示文字只由被接纳的当前记录派生,不凭 catch 推测服务端结论。

interface CheckoutGateway {
  submit(record: OrderJournal): Promise<GatewayReply>;
}
class LocalCheckoutGateway implements CheckoutGateway {
  submit(record: OrderJournal): Promise<GatewayReply> {
    return Promise.resolve({
      requestKey: record.requestKey,
      accepted: true,
      reference: 'LOCAL-ORDER-DEMO'
    });
  }
}

async function commitDemo(
  store: OrderJournalStore,
  gateway: CheckoutGateway,
  prepared: OrderJournal
): Promise<OrderJournal> {
  await store.save(prepared);
  const reply = await gateway.submit(prepared);
  if (reply.requestKey !== prepared.requestKey) {
    throw new Error('mismatched request key');
  }
  const resolved: OrderJournal = {
    ...prepared,
    stage: reply.accepted ? 'accepted' : 'rejected',
    reference: reply.reference
  };
  await store.save(resolved);
  return resolved;
}

特别要注意上面的 throw:匹配失败后没有把记录改写为拒绝,而是让它保持在 prepared,因为未匹配的回执不能作为当前意图的证据。同理,传输抛出的超时错误不等于服务器拒绝;这是分布式请求的一条基本边界。正式场景中,遇到不确定结果应查询该 requestKey 对应的服务器订单,再根据服务器权威状态更新本地记录。若后端没有幂等键或查询接口,客户端不应把“自动重试到成功”包装成可靠交易方案。

订单恢复页示意:已确认结果与保留草稿的边界

有一个和第四篇“撤销”有关的细节值得单独检查:订单准备阶段冻结的是选中行,用户随后从购物车撤销勾选,并不会改变已经发出的 snapshot。恢复结果页要展示提交时的冻结明细,而购物车页要展示编辑后的最新草稿。两者可以金额不同,这不一定是数据错误;错误在于用同一个 UI 数组同时扮演历史快照与当前编辑状态。

四、启动恢复只读取事实,不擅自补发

恢复页 entry/src/main/ets/pages/OrderResultPage.ets 的最小任务,是从 OrderJournalStore 读取记录、验证结构、展示用户能理解的状态。prepared 显示“结果待核实,勿重复提交”;accepted 显示“本地示例已接纳”;rejected 显示“本次已明确拒绝,可返回修改”。对 JSON 的处理必须谨慎:字段可能缺失、存储值可能来自旧版本,JSON.parse 可能抛异常,数字可能超出有效金额范围。如果结构不合法,保留错误提示并避免删除原始记录,以便日后有受控迁移工具能够诊断。

function readJournal(raw: string): OrderJournal | null {
  try {
    const data: OrderJournal = JSON.parse(raw) as OrderJournal;
    if (data.schema !== 1 ||
      (data.stage !== 'prepared' && data.stage !== 'accepted' &&
       data.stage !== 'rejected') ||
      typeof data.requestKey !== 'string' || data.requestKey.length === 0 ||
      typeof data.signature !== 'string' ||
      !Array.isArray(data.snapshot.items) ||
      !Number.isSafeInteger(data.snapshot.totalFen) ||
      data.snapshot.totalFen < 0) {
      return null;
    }
    return data;
  } catch (error) {
    return null;
  }
}

function journalHint(record: OrderJournal | null): string {
  if (record === null) { return '暂无可读取的订单记录'; }
  if (record.stage === 'prepared') { return '结果待核实,请勿重复提交'; }
  if (record.stage === 'rejected') { return '本次请求被拒绝,请返回修改'; }
  return '本地示例请求已接纳';
}

这个入口故意不调用 gateway.submit()。恢复动作发生在页面重新出现、窗口重建或应用重启之后,如果它直接重发,恰好可能把一次不确定订单变成两次真实请求。业务上正确的下一步是“状态查询与对账”,不是“无条件重新扣一次”。代码中只示例了基本类型校验;正式生产须对每个明细的 ID、数量、整数分、签名一致性以及长度上限进行完整验证,必要时对旧 schema 做显式版本迁移。客户端存储可被清除、损坏或修改,本地归档不能代替服务端的可信交易事实。

五、用故障注入完成六篇连载的回归收尾

这条工程主线可以组织成一组不依赖偶然网络质量的回归用例。第一组针对购物车:空选不允许结算,三件商品总价应为46700分,数量变更只影响新预览;撤销操作只回滚当前编辑,不回滚已冻结订单。第二组针对草稿:V1旧结构经校验迁移到V2,损坏 JSON 不得使页面崩溃;写入失败后不能显示“已保存”。第三组针对交易意图:连点只产生一次当前页面请求,晚到回执与 requestKey 不匹配时不得更改当前结果;即使用户编辑新购物车,旧快照的金额仍固定。

还要人为注入三个中断位置:prepared 持久化前中断,预期不发送请求;prepared 成功后、网关回执前中断,恢复时显示“待核实”;接纳回执后、accepted 归档前中断,本地仍保留待核实状态,后续应由服务端查询纠正。对于同一台测试设备,可在调试构建中通过可控 Promise 暂停请求,而不是用不可靠的断网操作赌时序。所有预期都是对状态机的验证计划,并非真实测得的通过率、性能或支付结果。

准备记录、请求和归档之间的故障切点

最后还需要审视保存边界。Preferences 的本地小体量 KV 存储足以示范恢复状态,但真实支付与订单系统需要后端幂等、身份鉴别、敏感字段最小化、权威价格校验以及订单状态查询。LOCAL-ORDER-DEMO 这样的编号不能当真实凭证,也不能在正式接口中复用。发布构建中应去除本地测试按钮和预设网关,并对异常消息进行用户可理解的转换,不能把内部异常栈直接展示出来。若涉及多账号,还要明确退出登录后的本地数据隔离策略。

六篇 BasketFlow 文章至此形成连续的责任链:列表编辑负责当前状态,Preferences 草稿保存负责恢复编辑成果,迁移与撤销负责本地状态安全,结算预检负责冻结一次业务意图,而本篇的订单 Journal 只负责保留该意图及已知结果。工程上并不要求所有页面都保存同样的数据;相反,清楚知道哪些状态可以覆盖、哪些结果需要保留历史,才是避免重复请求与恢复混乱的关键。相关接口参考华为开发者官方 Preferences API、ArkUI 状态管理概览 和 Stage 应用模型说明;本例不使用未经核实的专属系统能力。

Logo

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

更多推荐