MongoDB Realm 如何在支付完成前阻止写入数据
结论
不要让 Realm 的写事务一直等待支付结果。支付通常依赖网络请求或第三方 SDK,无法和本地 Realm 组成跨系统的 ACID 事务。
建议按以下流程处理:
- 发起支付。
- 使用本次支付唯一的
paymentId或orderId验证结果。 - 确认支付状态为
Approved后,再调用addOrder()。 - 如果 Realm 写入失败,
realm.write会自动回滚本次本地写事务。 - 如果支付已经成功,但 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 只能回滚自身的数据,无法撤销已经完成的第三方支付。例如:
- 支付成功。
- 网络中断或应用崩溃。
- Realm 中的订单尚未写入。
因此,生产环境通常会在服务端保存订单和支付状态,使用 paymentId 保证幂等,并提供重新同步或补单机制。如果业务要求订单保存失败后退款,就需要调用支付平台的退款接口执行补偿,而不是依赖数据库事务回滚。
备注:内容仅供参考。
