PAYATHON 2026

MongoDB Realm 如何在支付完成前阻止写入数据

支付阿杰

结论

不要让 Realm 的写事务一直等待支付结果。支付通常依赖网络请求或第三方 SDK,无法和本地 Realm 组成跨系统的 ACID 事务。

建议按以下流程处理:

  1. 发起支付。
  2. 使用本次支付唯一的 paymentId 或 orderId 验证结果。
  3. 确认支付状态为 Approved 后,再调用 addOrder()。
  4. 如果 Realm 写入失败,realm.write 会自动回滚本次本地写事务。
  5. 如果支付已经成功,但 Realm 写入失败,需要通过重试、服务端补单或退款来补偿。Realm 无法回滚支付。

推荐实现

点击按钮时不要立即写入订单。可以把支付和保存订单串成一个完整的业务流程:

sealed interface PaymentResult {
    data class Approved(val paymentId: String) : PaymentResult
    data class Failed(val reason: String) : PaymentResult
    data object Cancelled : PaymentResult
}

suspend fun payAndCreateOrder(
    products: List<Product>
): Result<Unit> {
    val paymentResult = paymentRepository.startPayment(products)

    return when (paymentResult) {
        is PaymentResult.Approved -> {
            orderRepository.addPaidOrder(
                products = products,
                paymentId = paymentResult.paymentId
            )
        }

        is PaymentResult.Failed -> {
            Result.failure(
                IllegalStateException("Payment failed: ${paymentResult.reason}")
            )
        }

        PaymentResult.Cancelled -> {
            Result.failure(
                CancellationException("Payment cancelled")
            )
        }
    }
}

Repository 只在支付通过后执行一次简短的事务:

suspend fun addPaidOrder(
    products: List<Product>,
    paymentId: String
): Result<Unit> {
    return runCatching {
        realm.write {
            val existingOrder = query<Order>(
                "paymentId == $0",
                paymentId
            ).first().find()

            if (existingOrder != null) {
                return@write
            }

            val order = Order().apply {
                this.paymentId = paymentId
                this.status = "PAID"

                products.forEach { product ->
                    this.products.add(product)
                }
            }

            copyToRealm(order)
        }
    }
}

代码会先按 paymentId 检查订单是否已经存在,防止支付回调、同步消息或用户重复点击造成重复写入。实际项目中还应为 paymentId 设置唯一性约束。如果当前 Realm 模型无法提供所需的唯一约束,就必须由服务端保证幂等。

从 UI 调用

按钮点击后,在 ViewModel 中启动协程,并在处理完成前禁用按钮:

class CheckoutViewModel(
    private val checkoutRepository: CheckoutRepository
) : ViewModel() {

    private val _isProcessing = MutableStateFlow(false)
    val isProcessing = _isProcessing.asStateFlow()

    fun checkout(products: List<Product>) {
        if (_isProcessing.value) return

        viewModelScope.launch {
            _isProcessing.value = true

            try {
                checkoutRepository
                    .payAndCreateOrder(products)
                    .onSuccess {
                        // 通知 UI:订单创建成功
                    }
                    .onFailure { error ->
                        // 通知 UI:支付取消、支付失败或订单保存失败
                    }
            } finally {
                _isProcessing.value = false
            }
        }
    }
}

禁用按钮只能避免用户在界面上重复操作,不能代替基于 paymentId 的幂等校验,因为支付平台仍可能重复发送回调。

如果必须先创建订单

有些支付平台要求先提供订单号。此时可以先保存一个状态为 PENDING_PAYMENT 的订单,但不能把它视为已支付订单:

suspend fun createPendingOrder(
    orderId: String,
    products: List<Product>
) {
    realm.write {
        val order = Order().apply {
            this.orderId = orderId
            this.status = "PENDING_PAYMENT"
            products.forEach(this.products::add)
        }

        copyToRealm(order)
    }
}

确认支付成功后,再更新订单状态:

suspend fun markOrderAsPaid(
    orderId: String,
    paymentId: String
) {
    realm.write {
        val order = query<Order>(
            "orderId == $0",
            orderId
        ).first().find()
            ?: error("Order not found: $orderId")

        order.status = "PAID"
        order.paymentId = paymentId
    }
}

支付失败时,可以把状态改为 PAYMENT_FAILED,也可以在确认不再需要该记录后将其删除。保留失败状态通常更方便审计和排查问题。

不要只等待任意 "Approved" 对象

如果只判断 isPaid collection 中是否出现以下内容:

{"message":"Approved"}

仍然不够安全,因为无法确认这条消息是否属于当前订单。消息至少应包含以下字段,并对它们进行校验:

{
  "orderId": "order-123",
  "paymentId": "payment-456",
  "status": "Approved"
}

查询时也必须匹配当前订单:

val payment = realm.query<PaymentStatus>(
    "orderId == $0 AND status == $1",
    orderId,
    "Approved"
).first().find()

支付结果应由可信服务端根据支付平台的签名回调来确认。客户端在本地写入的 "Approved" 对象不能作为可靠的付款凭证,否则用户可能通过修改本地数据绕过支付。

关于回滚

realm.write 本身就是本地事务。代码块正常执行完毕后提交;如果发生异常,本次 Realm 修改不会只提交一部分:

realm.write {
    copyToRealm(order)

    if (someValidationFailed) {
        error("Validation failed")
    }
}

不过,Realm 只能回滚自身的数据,无法撤销已经完成的第三方支付。例如:

  1. 支付成功。
  2. 网络中断或应用崩溃。
  3. Realm 中的订单尚未写入。

因此,生产环境通常会在服务端保存订单和支付状态,使用 paymentId 保证幂等,并提供重新同步或补单机制。如果业务要求订单保存失败后退款,就需要调用支付平台的退款接口执行补偿,而不是依赖数据库事务回滚。

备注:内容仅供参考。