SeatFlow 锁座有效期与状态回收技术封面

“已选中”不等于“已锁座”,而“已锁座”也不等于“已付款”。当用户选好 A1、A2 后离开应用接听电话,几分钟再返回,屏幕上可能还保留漂亮的紫色座位;若应用只根据上次的倒计时数字决定能否继续付款,就会把已经失效的预留当作有效。SeatFlow 的最后一篇不再扩充网格动画,而是收束一次选座操作的生命周期:用明确的预留结果、绝对到期时刻和页面恢复时的重新核验,防止过期座位继续被当成可支付资源。

此前三篇形成了固定的工程契约:hall3-1930 表示3号厅19:30场次;5排×6列座位,初始已售 B3、C2、D5、E1;A~D 排45元,E排35元;最多同时选择4座。第二篇示例 C1 在并发售出后被移出选择,保留 A1、A2,预览总额从135元降为90元。第三篇用请求代际和库存版本阻断迟到快照。本篇继续沿用 entry/src/main/ets/pages/SeatPage.ets、CinemaSeat、SeatStatus 和 LocalSeatGateway,把“检查库存”向“预留有期限”推进一步,但不声称本地原型已经接入真实影院票务系统。

从三态座位图,拆出另一条预留状态线

SeatStatus.Free、SeatStatus.Sold、SeatStatus.Selected 只描述某一座位在当前页面如何显示,并不包含租约归属。上一轮的 loadedVersion 负责库存快照的相对新旧,也不能回答预留何时过期。设计中应有另一组状态 editing → requesting → held → expired,必要时进入 failed;只有 held 且有效期未结束,页面才可以提供“继续确认”的入口。

一次预留应包含场次、请求时冻结的座位编号、后端认可的预留标识、最终认可的票价、到期时间和当前状态。这里把到期时间表示为毫秒时间戳,以便返回前台时重新计算,而不是把剩余秒数作为权威数据反复减一。生产场景的到期时间必须由服务端给出;客户端自己的时间只用于显示近似倒计时,并应结合服务端时间偏差、重新查询和订单状态确认。

// entry/src/main/ets/model/SeatHold.ets
export interface HoldRequest {
  showId: string
  seatIds: string[]
  inventoryVersion: number
}

export interface HoldReceipt {
  holdId: string
  showId: string
  seatIds: string[]
  amount: number
  expiresAtMs: number
}

export type HoldPhase = 'editing' | 'requesting' | 'held' | 'expired' | 'failed'

这份 HoldReceipt 不应该写进原来的座位三态字段。一个预留可以同时关联两个位置,过期与取消又可能同时影响两座;把预留塞进每个座位的颜色状态,后续无法回答这两个位置是否属于同一笔请求。预留 ID 也不是支付单号,支付结果和票务出票结果需要独立的确认链路。

SeatFlow 竖屏示例:A1、A2 预留与剩余有效期

用受控网关构造租约,不把示例回执当真成交

先提供纯本地的确定性测试网关。它延续第三篇已经确认的库存版本2,额外售出位置包含 C1;如果预留的集合含有这些座位,便返回错误。只有 A1、A2 均可售时,才返回用于测试页面的 holdId 和五分钟构造有效期。下面的代码故意没有网络 API、身份认证和支付接口,因为一旦声称真实锁座,服务端必须处理同一时刻的互斥与并发订单,不是一个客户端 Promise 能保证的。

// entry/src/main/ets/service/LocalSeatGateway.ets 的本篇增量
import { HoldRequest, HoldReceipt } from '../model/SeatHold'

// 以下是已有 LocalSeatGateway 类内部新增的方法
async reserve(draft: HoldRequest): Promise<HoldReceipt> {
    const blocked: string[] = ['B3', 'C2', 'D5', 'E1', 'C1']
    if (draft.showId !== 'hall3-1930' || draft.inventoryVersion < 2) {
      throw new Error('场次或库存版本已过期')
    }
    if (draft.seatIds.length === 0 || draft.seatIds.length > 4 ||
        draft.seatIds.some((id: string) => blocked.includes(id))) {
      throw new Error('座位不可预留,请刷新后重选')
    }
    const price: number = draft.seatIds.reduce((sum: number, id: string) =>
      sum + (id.startsWith('E') ? 35 : 45), 0)
    return {
      holdId: 'demo-' + draft.showId + '-' + draft.seatIds.join('-'),
      showId: draft.showId,
      seatIds: [...draft.seatIds], amount: price,
      expiresAtMs: Date.now() + 5 * 60 * 1000
    }
  }

demo- 开头的字符串仅是按场次和所选座位拼出的示例标识,不能作为线上唯一凭证;服务端需要生成安全、不可预测、与用户及场次关联的标识。上面也没有真正构造两个设备同时提交:生产 reserve() 必须由权威系统在同一事务性边界内判断库存、建立预留并返回有时效性的结果。开发者可以先在本地测试阶段确认 UI 在成功、拒绝和过期三种回执下是否一致,再替换为正式网关。

用绝对截止时间驱动显示,而非相信定时器准点执行

定时器在后台可能被延迟、暂停或节流。假设5分钟从300开始每秒减一,用户切到后台4分钟,再回来时数字却只减少少量,这种显示不是 UI 小误差,而是可能放行已经过期的预留。因此页面状态只保留 expiresAtMs,每次重绘时通过当前时刻计算剩余秒数。定时器只触发刷新,并不决定预留的最终有效性。

与第三篇的 refreshSeq 类似,预留需要自己的 holdSeq:旧预留回执绝不能覆盖新请求。为保持 ArkUI 的响应更新,我们把倒计时文本依赖的 remainSeconds 放在 @State,但不把剩余秒数作为唯一事实来源。保存页面时也不应把示例预留的本地倒计时直接当作以后继续支付的凭据。

// SeatPage.ets 内追加的方法和字段;复用先前 seats、loadedVersion、gateway
import { HoldPhase, HoldReceipt } from '../model/SeatHold'
@State private holdPhase: HoldPhase = 'editing'
@State private remainSeconds: number = 0
@State private holdMessage: string = '选择座位后可构造预留'
private holdReceipt?: HoldReceipt
private holdSeq: number = 0
private holdTimer: number = -1

private syncRemaining(): void {
  if (!this.holdReceipt || this.holdPhase !== 'held') { return }
  const milliseconds: number = this.holdReceipt.expiresAtMs - Date.now()
  this.remainSeconds = Math.max(0, Math.ceil(milliseconds / 1000))
  if (this.remainSeconds === 0) {
    this.holdPhase = 'expired'
    this.holdMessage = '预留时间已结束,请刷新库存并重新选择'
    this.stopHoldTicker()
  }
}

private startHoldTicker(): void {
  this.stopHoldTicker()
  this.syncRemaining()
  if (this.holdPhase !== 'held') { return }
  this.holdTimer = setInterval(() => this.syncRemaining(), 1000)
}

private stopHoldTicker(): void {
  if (this.holdTimer !== -1) {
    clearInterval(this.holdTimer)
    this.holdTimer = -1
  }
}

如果用设备本地时间推算服务端过期时刻,用户手动调节时钟仍可能导致倒计时不准。真实产品更稳妥的做法是服务端同时返回服务器时间戳,以同一次响应中的 serverNowMs 估算偏移,并在临近过期、应用恢复或确认支付时再次向服务端验证。无论 UI 是否显示还有几十秒,最终是否可以付款应由服务器根据预留记录决定。

处理异步回执与前后台恢复

调用 reserve() 前,先冻结当前选中的 seatIds,并检查仍使用版本2或更新的库存;请求期间不再让用户反复点击锁座按钮,结果回来还要比较 holdSeq。成功时页面进入 held,按回执显示服务端认可的座位与金额;失败时回到需要处理的状态,不应把点击瞬间的选中态直接伪装成锁座成功。示例中的网关立即返回,是为了验证数据通路,不能替代真实网络重试测试。

private async beginHold(): Promise<void> {
  if (this.holdPhase !== 'editing') {
    this.holdMessage = '请先刷新库存并重新选择'; return
  }
  const ids: string[] = this.selectedSeats.map((seat: CinemaSeat) => seat.id)
  if (ids.length === 0) { this.holdMessage = '请先选择座位'; return }
  const seq: number = ++this.holdSeq
  this.holdPhase = 'requesting'
  this.holdMessage = '正在检查预留条件…'
  try {
    const receipt: HoldReceipt = await this.gateway.reserve({
      showId: 'hall3-1930', seatIds: [...ids], inventoryVersion: this.loadedVersion
    })
    if (seq !== this.holdSeq) { return }
    if (receipt.showId !== 'hall3-1930' || receipt.seatIds.join('|') !== ids.join('|')) {
      throw new Error('预留回执与当前场次或选择不匹配')
    }
    this.holdReceipt = receipt
    this.holdPhase = 'held'
    this.holdMessage = '本地构造预留成功,正式购买需服务端确认'
    this.startHoldTicker()
  } catch (error) {
    if (seq !== this.holdSeq) { return }
    this.holdPhase = 'failed'
    this.holdMessage = '预留未成功,请刷新库存后重试'
  }
}

onPageShow(): void {
  this.syncRemaining()
  if (this.holdPhase === 'held') { this.startHoldTicker() }
}

onPageHide(): void { this.stopHoldTicker() }
aboutToDisappear(): void { this.stopHoldTicker(); this.holdSeq++ }

onPageShow 和 onPageHide 是页面级回调,适用于被 @Entry 修饰、由页面路由管理的自定义组件。需要注意,调用 syncRemaining() 只是本地时间核验;如果进入前台时状态已 expired,此时应让用户刷新服务端库存,不能通过把 remainSeconds 重置为300来续约。aboutToDisappear() 使尚未完成的异步回执失效,同时停止 ticker,避免销毁后的页面继续处理数据。

锁座请求、回执、倒计时与过期后的状态路径

点击网格时也必须遵守相同的状态机约束。沿用原来的 toggleSeat(id) 业务函数,但把网格按钮的入口改为包装方法:只有 editing 状态允许选择或取消座位,请求中和已预留阶段都不接受修改。这比在返回回执后试图修复被随意改变的选择集合更可靠。

private toggleSeatWhenEditable(id: string): void {
  if (this.holdPhase !== 'editing') { return }
  this.toggleSeat(id)
}

这里网格的 onClick 改为调用 toggleSeatWhenEditable(seat.id)。刷新到新的权威库存且完成选中态合并后,才允许清理旧 holdReceipt、设置 holdPhase = 'editing',并重新开放按钮。不要在失败回调内部立即恢复编辑,否则还没有核对库存就允许再次提交,反而重复引入第二篇已经解决的冲突问题。

把有效期放进页面,但让服务器决定交易事实

页面上增加一个独立的状态区域即可;不要把预留信息散布到每个 GridItem 的点击处理中。remainSeconds 通过 Math.floor() 计算分钟与两位秒数,示例竖屏展示的是 A1、A2 保留状态、90元本地预览以及剩余04:37的一个具体交互时刻。这个数字是界面设计状态,而不是声称经过真机计时的测量结果。真实系统会每秒刷新文本,但对用户最重要的是“锁定是否有效”和“过期后需要做什么”。

// SeatPage.ets 中 build() 的原座位 Grid 之后追加
Column({ space: 8 }) {
  Text('座位预留')
    .fontSize(18).fontWeight(FontWeight.Bold)
  Text(this.holdMessage).fontSize(13).fontColor('#62647A')
  if (this.holdPhase === 'held') {
    Text(`剩余 ${Math.floor(this.remainSeconds / 60)}:${
      (this.remainSeconds % 60).toString().padStart(2, '0')}`)
      .fontSize(28).fontColor('#653DE0')
  }
  Button(this.holdPhase === 'held' ? '已预留(示例)' : '构造预留座位')
    .enabled(this.holdPhase === 'editing')
    .onClick(() => this.beginHold())
}.padding(16).width('100%')

测试至少覆盖四条路径。第一条选中 A1、A2,刷新库存为版本2后预留,核对座位编号、90元金额与回执相同。第二条将 C1 加回选中集合,网关应拒绝,页面不能进入 held。第三条手动构造已过去的 expiresAtMs,使页面直接进入 expired 并禁止继续购买。第四条把页面切到后台再回来,核对剩余时间是按截止时间重算,而不是按定时器执行次数累加。第五条在请求发出后销毁页面,再返回的旧回执不得触发页面更新。

对线上产品还必须补上服务端幂等创建预留、续约策略、用户会话校验、发票与票价重核、支付成功后的出票结果回查、页面退出时释放预留等链路。这些属于服务端系统契约,不应通过本篇的本地构造 API 伪装为已经实现。应用侧可以明确提供“预留已过期,重新选座”的解释与入口,不保留误导性的可支付按钮。

这一步还需要一份与按钮逻辑对应的状态恢复规则。若预留被拒绝,页面不能悄悄将 failed 变回 editing 并自动重提;应先调用上一轮的 refreshSnapshot(),获取新的权威库存,再让用户确认仍想选择的座位,最后清除过期 holdReceipt。如果某个已选座位在刷新后售出,沿用第二篇的局部回滚规则,只移除冲突 ID 并重新计算展示金额。这样既不丢掉全部用户选择,也不会让旧回执污染新的预留请求。

还有一个经常漏掉的分支:用户在预留有效期内主动返回或切换场次。返回列表只是 UI 导航,不等于后台预留已经释放;在线上实现中,需要结合取消预留接口、失败补偿和服务端自动过期机制处理,不能依靠 aboutToDisappear() 去完成交易清理。它的职责仅是本地计时器与异步回调的安全退出。对于支付页,进入前应使用预留 ID 查询状态,并对已过期、已支付、已撤销等情况提供明确的重新进入路径。

测试时不要只观察圆形倒计时是否流畅,应检查事件顺序。例如请求响应后立即将应用切到后台,返回时刻已经超过 expiresAtMs,预期是显示过期提示,而不是先闪现“仍有剩余”再延迟一秒关闭按钮。可以通过注入可控的时钟函数执行这类测试,避免为了制造过期而等待真实五分钟;但测试夹具与生产时钟来源应严格分离。

连载收尾:从可点击网格到有边界的交易入口

回看 SeatFlow 四篇,第一篇解决座位模型与选中上限,第二篇处理并发冲突后的局部回滚,第三篇保证库存版本与异步响应不会相互倒灌,最后这一篇把有效期和页面生命周期补齐。四项工程约束彼此不同:UI 状态不能替代库存真相,本机选择不能替代服务器预留,倒计时数字不能替代到期判定,旧回执不能替代当前事务。

验证这套原型时,宜保留一组固定的构造输入和可重放用例,并逐步将 LocalSeatGateway 替换为带鉴权、错误码和权威时间的票务服务。至少在正式上线前让服务端承担互斥锁座、库存扣减、支付回调与出票确认。本篇只提供一条清晰的客户端实现路径,避免把“选座界面完成”误当作“交易系统完成”。

参考资料:华为 ArkUI Grid 组件、自定义组件生命周期、https://developer.huawei.com/consumer/en/doc/harmonyos-guides/arkts-state/@State 状态管理。

Logo

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

更多推荐