ArkUI + HarmonyOS 购物车结算预检:金额快照与重复提交防护【购物车系列 05】【共创稿事节】

在购物车里把三件商品勾选好,点击“去结算”,并不代表已经形成了一笔可以支付的订单。常见的缺陷是把商品勾选结果、金额显示、按钮加载状态和订单提交写在同一个点击回调中:用户双击按钮,产生两个请求;请求刚发出时又调整数量,回执却照样把新画面标成“提交成功”;甚至页面仅保存了一个合计金额,却说不清金额来自哪几行商品。工程里真正需要保护的,是一次提交对应哪份不可变的购物车快照。
BasketFlow 前四篇已经完成统一 CartLine 模型、整数分金额、Preferences V2 草稿恢复、结构迁移以及批量编辑撤销。沿用机械键盘25900分、无线鼠标12900分、便携支架7900分这三条示例商品,本篇不再讨论草稿怎样写盘,而是把它们带入“结算确认”的下一道边界:预检、冻结、提交门禁与结果归属。这里的“提交”使用本地可替换适配器,仅用于演练交互和状态转换,不构成真实订单、支付或库存扣减。
一、先划清草稿、预览和订单意图的边界
上篇保存的 DraftV2 结构包含 schema、revision 和 items,其中的 revision 是本地草稿版本,描述曾经成功持久化到什么内容。结算页则需要两个不同概念。第一是 preview:当前勾选行所形成的金额及行项目预览,随着 lines 改变即时重新计算;第二是 intent:用户按下确认时捕获的一次快照,捕获完成后不允许被界面后续编辑修改。
为什么不能直接把 this.lines 赋给 intent.items?数组赋值没有复制元素对象。如果稍后增加某种原地修改,旧意图就可能被改写。因此冻结时要明确构造新的业务行,不依赖“当前版本代码恰好没有修改原对象”这一偶然事实。我们也不把整个购物车的未选中商品带入订单意图:只提交 checked=true 的商品,并再次检查数量范围、ID唯一性和金额安全边界。否则合计显示只算两行,提交却包含第三行,服务端和用户永远对不上账。

本文保留前文 CartLine 的五字段:id:number、title:string、priceFen:number、count:number、checked:boolean。这些本地固定价格只为解释整数分的计算思路;接真实交易系统时,客户端金额只能当展示或请求参数,权威单价、优惠、库存、税费和总价必须由服务端重新计算并确认。前端本地预检不能代替后端校验。这一点比纠结按钮颜色重要得多。
二、把预检做成可重复调用的纯逻辑
预检函数不读 UI、不写 Preferences,也不发送请求:输入一个购物车数组,返回合法的订单预览或明确错误。这样可以把正常、空选、损坏数量、金额溢出等场景从界面上抽出来验证。特别是合计采用整数分,不要把两位小数的浮点价格在循环中直接相加;金额必须检查 Number.isSafeInteger,避免超大数量乘积在 JavaScript 数值范围内失真。
interface CartLine {
id: number;
title: string;
priceFen: number;
count: number;
checked: boolean;
}
interface CheckoutItem {
id: number;
title: string;
priceFen: number;
count: number;
}
interface CheckoutPreview {
items: CheckoutItem[];
totalFen: number;
}
function makeCheckoutPreview(lines: CartLine[]): CheckoutPreview | null {
const selected: CartLine[] = lines.filter((row: CartLine): boolean => row.checked);
if (selected.length === 0 || selected.length > 100) { return null; }
const used: Set<number> = new Set<number>();
let total: number = 0;
const items: CheckoutItem[] = [];
for (const row of selected) {
if (!Number.isInteger(row.id) || row.id <= 0 || used.has(row.id) ||
row.title.trim().length === 0 || row.title.length > 60 ||
!Number.isSafeInteger(row.priceFen) || row.priceFen < 0 ||
!Number.isInteger(row.count) || row.count < 1 || row.count > 99) {
return null;
}
const lineTotal: number = row.priceFen * row.count;
if (!Number.isSafeInteger(lineTotal) ||
!Number.isSafeInteger(total + lineTotal)) { return null; }
total += lineTotal;
items.push({ id: row.id, title: row.title,
priceFen: row.priceFen, count: row.count });
used.add(row.id);
}
return { items: items, totalFen: total };
}
在我们的约定里,购物车可以暂时为空,但结算意图不可以为空。这一条业务规则与第三篇 validLines() 允许空数组不矛盾,因为两个函数负责不同阶段。对于真实工程,title 不应当作服务端商品身份,商品 ID 和报价凭证才是关键标识;priceFen 的合法不代表它被服务器认可。预检值若要显示人民币,请只在展示边界使用 (totalFen / 100).toFixed(2),不要把格式化字符串重新解析回业务金额。
另外,完全相同的行项目应该产生稳定的内容指纹,方便页面判断:请求等待期间用户是否改过结算内容。这里指纹仅用于本地比较,不是密码学哈希,更不是服务端幂等保证。先对行项目按 ID 排序,避免同样选中集合仅因为列表显示顺序不同,就产生不同指纹。
function previewSignature(preview: CheckoutPreview): string {
const ordered: CheckoutItem[] = preview.items.slice().sort(
(a: CheckoutItem, b: CheckoutItem): number => a.id - b.id);
return ordered.map((item: CheckoutItem): string =>
`${item.id}:${item.priceFen}:${item.count}`).join('|');
}
这里没有把商品名或金额展示文案混进指纹:显示名称可能本地化,完整合计又可从行项目推导。必要时生产系统仍应采用服务端颁发的报价版本、订单号或幂等键,不能直接把这份串联文本当安全凭证。
三、点击确认时冻结一份,不锁死所有页面状态
预览与提交最大的不同是时间。预览随当前 lines 改变,冻结快照则代表点击那一刻。订单请求一旦发出,就不应该再根据 this.lines 重算它的金额。我们采用 @State submitting 禁止同页重复点击,并增加提交代次 submitSeq,用于过滤已过期的返回结果。页面更新仍然通过 ArkUI @State 驱动,提交事务则存放为局部不可变对象。官方文档说明 @State 是组件内部的状态源,并不负责持久化,也不会自动为异步业务提供幂等性。
interface CheckoutIntent {
requestId: string;
signature: string;
snapshot: CheckoutPreview;
}
interface SubmitReply { requestId: string; accepted: boolean }
interface CheckoutTransport {
send(intent: CheckoutIntent): Promise<SubmitReply>;
}
class LocalCheckoutTransport implements CheckoutTransport {
send(intent: CheckoutIntent): Promise<SubmitReply> {
return Promise.resolve({ requestId: intent.requestId, accepted: true });
}
}
LocalCheckoutTransport 只有可预测的本地 Promise,主要用于复现按钮防重和晚到回执的分支。它并没有创建远端订单,所以结果提示应写“本地提交示例已接纳”,不写“支付成功”。真正联网时可在传输适配器里接入后端接口,并在服务端基于用户身份和幂等键去重。单凭 UI 的 enabled(false) 并不能防止客户端重试、跨设备重复请求或攻击者重复发送。
新页面文件为 entry/src/main/ets/pages/CheckoutPage.ets,通过购物车的确认入口导航到该页面,页面仍以恢复后的 CartLine[] 为输入。下面的页面片段展示三个关键变量:lines 代表当前可编辑清单,submitting 代表这一页面是否有待完成的提交,submitHint 代表反馈。为保证复现,示例默认三行均选中,合计467.00元;此前的 Preferences 读取逻辑仍可在进入结算页之前恢复真实草稿,但不需要为了示例提交再复制一次迁移代码。
@Entry
@Component
struct CheckoutPage {
@State private lines: CartLine[] = [
{ id: 1, title: '机械键盘', priceFen: 25900, count: 1, checked: true },
{ id: 2, title: '无线鼠标', priceFen: 12900, count: 1, checked: true },
{ id: 3, title: '便携支架', priceFen: 7900, count: 1, checked: true }
];
@State private submitting: boolean = false;
@State private submitHint: string = '待确认';
private submitSeq: number = 0;
private transport: CheckoutTransport = new LocalCheckoutTransport();
private async submit(): Promise<void> {
if (this.submitting) { return; }
const preview: CheckoutPreview | null = makeCheckoutPreview(this.lines);
if (preview === null) { this.submitHint = '商品预检未通过'; return; }
const signature: string = previewSignature(preview);
const seq: number = ++this.submitSeq;
const intent: CheckoutIntent = {
requestId: 'basketflow-local-' + seq.toString(),
signature: signature,
snapshot: preview
};
this.submitting = true;
this.submitHint = '正在提交本地示例意图';
try {
const reply: SubmitReply = await this.transport.send(intent);
if (seq !== this.submitSeq || reply.requestId !== intent.requestId) { return; }
const latest: CheckoutPreview | null = makeCheckoutPreview(this.lines);
const unchanged: boolean = latest !== null &&
previewSignature(latest) === signature;
this.submitHint = reply.accepted
? (unchanged ? '本地示例已接纳' : '旧快照已接纳,请重新预检当前修改')
: '本地示例未接纳,可重新确认';
} catch (error) {
if (seq === this.submitSeq) { this.submitHint = '提交失败,商品仍留在本页'; }
} finally {
if (seq === this.submitSeq) { this.submitting = false; }
}
}
build() {
Column({ space: 14 }) {
Text('结算确认').fontSize(28).fontWeight(FontWeight.Bold)
Text('BasketFlow · 订单预检').fontColor('#64748B')
ForEach(this.lines.filter((row: CartLine): boolean => row.checked),
(row: CartLine) => {
Row() {
Text(row.title).layoutWeight(1)
Text('×' + row.count.toString())
Text('¥' + (row.priceFen * row.count / 100).toFixed(2))
}.padding(14).backgroundColor('#FFFFFF')
}, (row: CartLine): string => row.id.toString())
Text('应付预览 ¥' +
((makeCheckoutPreview(this.lines)?.totalFen ?? 0) / 100).toFixed(2))
.fontSize(24).fontWeight(FontWeight.Bold)
Button(this.submitting ? '提交中…' : '确认本地示例')
.enabled(!this.submitting)
.onClick(() => { this.submit(); })
Text(this.submitHint).fontColor('#39628E')
}.padding(24).width('100%').height('100%')
.backgroundColor('#F4F7FC')
}
}

代码中的 requestId 只在当前组件内递增,重启后可能重新使用相同数字,故明确冠以 local 并标注为示例。真正订单接口需要跨请求稳定的服务器幂等键,通常还要包含业务主体和过期控制。此处的状态一致性保护的是单页面用户可见结果:晚到的旧响应不能让已经编辑的新预览误显示为“当前已提交”。这与上一阶段保存草稿时用 editEpoch 判断“已保存的是哪份内容”原理相通,但提交快照不能回写为草稿,也不应修改 revision。
四、用异常路径检验预检是否真的独立
可以把测试分成三个层次。第一个层次是纯函数测试:未选中任何一行时 makeCheckoutPreview 返回空;数量为0或100时拒绝;重复商品ID时拒绝;当三件商品分别为259、129、79元且数量均为1时返回46700分。再把鼠标数量变成2,预览应更新为59600分,冻结前的旧意图仍然保持原来的46700分。上述数字是固定示例数据的算术关系,不是运行测量值。
第二层是界面事件序列:快速点击确认两次,检查是否只有一条传输适配器调用;等待过程中继续修改勾选值,检查旧结果只能说明旧快照被接纳,不能自动给新预览盖章。这里的构造 Promise 可以替换成由测试用例手动控制 resolve 时机的 deferred promise,但不能依赖真实网络波动来碰运气。页面仍允许用户查看商品及返回购物车;若产品要求提交期间完全禁止编辑,应在导航层明确定义这一行为,而不是让结算页偷偷更改数据来源。
还有一个容易漏掉的页面往返场景:用户从结算确认页返回购物车,撤销一次批量勾选,再重新进入结算页。这时必须基于撤销后的实时行数据重新预检,而不是沿用上次冻结的 intent。否则购物车已经显示两件商品,结算页却还拿着旧的三件快照。更稳妥的接口设计是只传必要的业务行或统一的购物车仓库标识,每次页面进入时主动构造预览;任何缓存都要有明确的失效条件。
第三层是恢复与联动:从 Preferences V2 加载的草稿可能包含未选行、数量变更或升级恢复提示。进入结算页时统一用 makeCheckoutPreview 生成预览,失败时给出可理解的提示并返回编辑页面,不要直接抛异常。尤其在草稿曾经成功迁移、但结算预检失败时,不应擅自删除 cart_draft_v2:本地草稿是用户编辑成果,结算是否合法是另一件事。

接入正式后端前,还需要确认网络权限、认证、价格权威来源、错误码语义、超时重试和隐私字段策略。客户端指纹、按钮门禁和请求序号只能提供交互层保护;服务端幂等、库存事务和支付确认必须独立实现。至此,BasketFlow 从清单编辑推进到提交前边界;收尾一篇将处理订单结果与恢复路径的一致性审查,把可复现测试和工程发布检查合在一起。
本篇采用的 ArkUI V1 状态模式与内置列表布局,可参考华为开发者文档:状态管理概览、https://developer.huawei.com/consumer/en/doc/harmonyos-guides/arkts-state/@State 组件内状态及List 列表组件。工程不依赖任何 HarmonyOS 7 未公开能力。
更多推荐

所有评论(0)