You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何避免预约按钮场景下Firestore写入竞态条件?

解决方案

1. 加载状态与按钮交互优化

不需要两个变量管理禁用状态,单个isLoading变量即可满足需求,点击按钮后直接将按钮替换为加载Spinner,既能阻止重复点击,也能给用户明确的操作反馈,修改后的ReservationButtonView示例:

struct ReservationButtonView: View {
    var workoutClass: WorkoutClass
    @ObservedObject var viewModel: WorkoutClassViewModel
    @EnvironmentObject var authViewModel: AuthViewModel
    var body: some View {
        if viewModel.isLoading {
            ProgressView()
                .font(.title)
                .tint(.white)
        } else if checkIsReserved(uid: authViewModel.user?.uid ?? "", reservedUsers: workoutClass.reservedUsers ?? []) {
            Button(action: {
                viewModel.isLoading = true
                viewModel.removeReservation(
                    documentId: workoutClass.id!,
                    reservedUserDetails: ["uid": authViewModel.user?.uid as Any, "photoURL": authViewModel.user?.photoURL?.absoluteString ?? "" as Any, "displayName": authViewModel.user?.displayName ?? "Bruin Fitness Member" as Any],
                    uid: authViewModel.user?.uid ?? ""
                )
            }){
                Label(
                    title: { Text("取消预约")
                        .font(.title) },
                    icon: { Image(systemName: "person.badge.minus")
                        .font(.title) }
                )
            }
        } else {
            Button(action: {
                viewModel.isLoading = true
                viewModel.addReservation(
                    documentId: workoutClass.id!,
                    reservedUserDetails: ["uid": authViewModel.user?.uid as Any, "photoURL": authViewModel.user?.photoURL?.absoluteString ?? "" as Any, "displayName": authViewModel.user?.displayName ?? "Bruin Fitness Member" as Any],
                    uid: authViewModel.user?.uid ?? ""
                )
            }){
                Label(
                    title: { Text("预约")
                        .font(.title) },
                    icon: { Image(systemName: "person.badge.plus")
                        .font(.title) }
                )
            }
        }
    }
}

把原来的.disabled去掉,直接根据isLoading切换显示Spinner,交互更清晰。

2. 取消预约场景免事务实现方案

可以不用事务,直接使用Firestore带条件的更新操作即可保证数据一致性,核心逻辑是更新前先判断用户确实在预约列表中,条件不满足则更新直接失败,不会误改reservationCnt:

// 优化后的removeReservation方法,无需事务
func removeReservation(documentId: String, reservedUserDetails: [String: Any], uid: String){
    let decrement = FieldValue.increment(-1)
    let removeUser = FieldValue.arrayRemove([reservedUserDetails])
    let classReference = db.document("schedules/Redwood City/dates/\(self.stateDate.dbDateFormat)/classes/\(documentId)")
    // 带条件更新:只有用户uid在预约列表中时才执行更新
    classReference.updateData([
        "reservationCnt": decrement,
        "reservedUsers": removeUser
    ]) { error in
        defer {
            self.isLoading = false
        }
        if let error = error {
            // 条件不满足或网络错误时打印日志
            print("取消预约失败:\(error.localizedDescription)")
        }
    }
}

同理添加预约也可以用带条件的更新替代事务,判断用户不在预约列表中才执行增量和加人操作,性能比事务更好。

3. 现有逻辑风险修复

你当前的代码存在一个隐藏问题:arrayRemove是全对象匹配逻辑,如果后续用户修改了昵称、头像,存储在reservedUsers中的用户信息和当前传入的reservedUserDetails不一致,会导致删除失败。建议的优化方案:

  • 方案一:reservedUsers数组只存储用户uid,用户头像、昵称等信息从用户表实时查询,避免冗余数据同步问题
  • 方案二:用户修改个人信息时,同步更新该用户所有预约课程中的用户信息

4. 最终流程优化建议

  • 前端第一层防护:点击后立刻切加载状态,阻止重复触发
  • 服务端第二层防护:用带条件的更新保证计数和预约列表的一致性,无需事务
  • 状态同步:监听课程文档的实时快照,按钮的预约/取消状态直接和服务端返回的reservedUsers绑定,避免本地状态和服务端不一致

内容的提问来源于stack exchange,提问作者Thomas Burke

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.27 12:15:05