feat: add payment status polling fallback
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
package com.yzx.kiosk.network.model.request
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class PaySuccessMessageRequest(
|
||||
@SerializedName("order_number")
|
||||
val orderNumber: String
|
||||
)
|
||||
@@ -4,6 +4,9 @@ import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class GetPayUrlResponse(
|
||||
@SerializedName("url")
|
||||
val url: String?
|
||||
val url: String?,
|
||||
|
||||
@SerializedName("order_number")
|
||||
val orderNumber: String?
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.yzx.kiosk.network.model.response
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class PaySuccessMessageResponse(
|
||||
@SerializedName("order_status")
|
||||
val orderStatus: Int?,
|
||||
|
||||
@SerializedName("order_status_name")
|
||||
val orderStatusName: String?,
|
||||
|
||||
@SerializedName("sn")
|
||||
val sn: String?,
|
||||
|
||||
@SerializedName("type")
|
||||
val type: Int?,
|
||||
|
||||
@SerializedName("data")
|
||||
val data: PaySuccessMessageData?
|
||||
)
|
||||
|
||||
data class PaySuccessMessageData(
|
||||
@SerializedName("order_number")
|
||||
val orderNumber: String?,
|
||||
|
||||
@SerializedName("capture_type")
|
||||
val captureType: Int?,
|
||||
|
||||
@SerializedName("image_id")
|
||||
val imageIds: List<Int>?
|
||||
)
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.yzx.kiosk.network.repository
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.yzx.kiosk.network.model.request.GetPayUrlRequest
|
||||
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
|
||||
import com.yzx.kiosk.network.model.request.PrintCompleteRequest
|
||||
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
||||
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
|
||||
@@ -13,6 +15,7 @@ import com.yzx.kiosk.network.model.response.GetPayUrlResponse
|
||||
import com.yzx.kiosk.network.model.response.GetPrintInfoResponse
|
||||
import com.yzx.kiosk.network.model.response.NetworkResponse
|
||||
import com.yzx.kiosk.network.model.response.OscarLivesData
|
||||
import com.yzx.kiosk.network.model.response.PaySuccessMessageResponse
|
||||
import com.yzx.kiosk.network.model.response.SocketTokenResponse
|
||||
import com.yzx.kiosk.network.model.response.VerifyResultResponse
|
||||
import com.yzx.kiosk.network.model.response.VersionResponse
|
||||
@@ -27,6 +30,7 @@ import javax.inject.Inject
|
||||
class NetWorkRepository @Inject constructor(
|
||||
private val netService: NetworkService,
|
||||
private val uploadService: UploadService,
|
||||
private val gson: Gson,
|
||||
) {
|
||||
// 基础方法示例,可根据需要添加
|
||||
// fun example(): Flow<NetworkResponse<Any>> = flow {
|
||||
@@ -81,6 +85,23 @@ class NetWorkRepository @Inject constructor(
|
||||
fun getPayUrl(request: GetPayUrlRequest): Flow<NetworkResponse<GetPayUrlResponse>> = flow {
|
||||
emit(netService.getPayUrl(request))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
fun getPaySuccessMessage(
|
||||
request: PaySuccessMessageRequest
|
||||
): Flow<NetworkResponse<PaySuccessMessageResponse>> = flow {
|
||||
val response = netService.getPaySuccessMessage(request)
|
||||
val parsedData = response.data
|
||||
?.takeIf { it.isJsonObject }
|
||||
?.let { gson.fromJson(it, PaySuccessMessageResponse::class.java) }
|
||||
|
||||
emit(
|
||||
NetworkResponse(
|
||||
data = parsedData,
|
||||
code = response.code,
|
||||
message = response.message
|
||||
)
|
||||
)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
fun getSocketToken(): Flow<NetworkResponse<SocketTokenResponse>> = flow {
|
||||
emit(netService.getSocketToken())
|
||||
@@ -97,4 +118,4 @@ class NetWorkRepository @Inject constructor(
|
||||
fun saveAlbumUrl(request: SaveAlbumUrlRequest): Flow<NetworkResponse<GetCodeUrlResponse>> = flow {
|
||||
emit(netService.saveAlbumUrl(request))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.yzx.kiosk.network.service
|
||||
|
||||
import com.yzx.kiosk.network.model.request.GetPayUrlRequest
|
||||
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
|
||||
import com.yzx.kiosk.network.model.request.PrintCompleteRequest
|
||||
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
||||
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
|
||||
@@ -16,6 +17,7 @@ import com.yzx.kiosk.network.model.response.OscarLivesData
|
||||
import com.yzx.kiosk.network.model.response.SocketTokenResponse
|
||||
import com.yzx.kiosk.network.model.response.VerifyResultResponse
|
||||
import com.yzx.kiosk.network.model.response.VersionResponse
|
||||
import com.google.gson.JsonElement
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
@@ -68,6 +70,11 @@ interface NetworkService {
|
||||
|
||||
@POST("/api/oscar/order/get-pay-url")
|
||||
suspend fun getPayUrl(@Body request: GetPayUrlRequest): NetworkResponse<GetPayUrlResponse>
|
||||
|
||||
@POST("/api/oscar/order/pay-success-message")
|
||||
suspend fun getPaySuccessMessage(
|
||||
@Body request: PaySuccessMessageRequest
|
||||
): NetworkResponse<JsonElement>
|
||||
|
||||
@GET("/api/oscar/socket-token")
|
||||
suspend fun getSocketToken(): NetworkResponse<SocketTokenResponse>
|
||||
|
||||
@@ -46,7 +46,7 @@ import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionResultViewModel
|
||||
import com.yzx.kiosk.ui.upload.view.PhotoPreviewDialog
|
||||
import com.yzx.kiosk.ui.upload.view.RetryableAsyncImage
|
||||
import com.yzx.kiosk.utils.QrCodeUtils
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.collect
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -76,6 +76,12 @@ fun FaceRecognitionResultScreen(
|
||||
// 支付弹框状态
|
||||
var showPayDialog by remember { mutableStateOf(false) }
|
||||
val payQrCodeUrl by viewModel.payQrCodeUrl.collectAsState()
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.dismissPayDialogEvents.collect {
|
||||
showPayDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
FullScreenMode()
|
||||
|
||||
@@ -265,15 +271,19 @@ fun FaceRecognitionResultScreen(
|
||||
|
||||
// 支付弹框
|
||||
if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) {
|
||||
val selectedUrls = photoList.filter { selectedPhotos.contains(it.url) }.map { it.url }
|
||||
DisposableEffect(payQrCodeUrl) {
|
||||
viewModel.startPayStatusPolling()
|
||||
onDispose {
|
||||
viewModel.stopPayStatusPolling()
|
||||
}
|
||||
}
|
||||
|
||||
WechatPayDialog(
|
||||
qrCodeUrl = payQrCodeUrl!!,
|
||||
totalPrice = totalPrice,
|
||||
onDismiss = { showPayDialog = false },
|
||||
onPaySuccess = {
|
||||
onDismiss = {
|
||||
showPayDialog = false
|
||||
// 支付成功,等待 WebSocket 消息(code=5)触发跳转到支付成功页面
|
||||
// 跳转逻辑由 FaceRecognitionResultViewModel 中的 WebSocket 监听处理
|
||||
viewModel.onPayDialogDismissed()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -453,8 +463,7 @@ fun FaceRecognitionPhotoItem(
|
||||
fun WechatPayDialog(
|
||||
qrCodeUrl: String,
|
||||
totalPrice: String,
|
||||
onDismiss: () -> Unit,
|
||||
onPaySuccess: () -> Unit
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
// 使用接口返回的URL生成支付二维码
|
||||
val qrCodeBitmap = remember(qrCodeUrl) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.yzx.kiosk.ui.face.viewmodel
|
||||
|
||||
import com.yzx.kiosk.network.model.response.PaySuccessMessageResponse
|
||||
|
||||
internal enum class FacePayStatusDecision {
|
||||
CONTINUE_POLLING,
|
||||
COMPLETE_PAYMENT,
|
||||
STOP_WITH_TERMINAL_STATUS
|
||||
}
|
||||
|
||||
internal fun decideFacePayStatus(orderStatus: Int?): FacePayStatusDecision = when (orderStatus) {
|
||||
30 -> FacePayStatusDecision.COMPLETE_PAYMENT
|
||||
40, 50, 60 -> FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS
|
||||
else -> FacePayStatusDecision.CONTINUE_POLLING
|
||||
}
|
||||
|
||||
internal fun isValidFacePaySuccessMessage(
|
||||
expectedOrderNumber: String,
|
||||
message: PaySuccessMessageResponse?
|
||||
): Boolean {
|
||||
val paymentData = message?.data ?: return false
|
||||
return message.type == 5 &&
|
||||
paymentData.orderNumber == expectedOrderNumber &&
|
||||
paymentData.captureType == 2 &&
|
||||
!paymentData.imageIds.isNullOrEmpty()
|
||||
}
|
||||
+178
-7
@@ -1,7 +1,5 @@
|
||||
package com.yzx.kiosk.ui.face.viewmodel
|
||||
|
||||
import android.graphics.ImageDecoder
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
@@ -11,6 +9,7 @@ import com.yzx.kiosk.base.BaseViewModel
|
||||
import com.yzx.kiosk.datastore.AppState
|
||||
import com.yzx.kiosk.navigation.AppNavigator
|
||||
import com.yzx.kiosk.network.model.request.GetPayUrlRequest
|
||||
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
|
||||
import com.yzx.kiosk.network.model.request.VerifyResultRequest
|
||||
import com.yzx.kiosk.network.model.response.FaceSearchResult
|
||||
import com.yzx.kiosk.network.repository.NetWorkRepository
|
||||
@@ -26,14 +25,23 @@ import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import com.yzx.kiosk.utils.LogUtils
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -72,13 +80,23 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
// 支付二维码URL
|
||||
private val _payQrCodeUrl = MutableStateFlow<String?>(null)
|
||||
val payQrCodeUrl: StateFlow<String?> = _payQrCodeUrl.asStateFlow()
|
||||
|
||||
// 支付弹框关闭事件(支付完成、取消或退款时由 ViewModel 驱动关闭)
|
||||
private val _dismissPayDialogEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val dismissPayDialogEvents: SharedFlow<Unit> = _dismissPayDialogEvents.asSharedFlow()
|
||||
|
||||
private var activePaymentOrderNumber: String? = null
|
||||
private var paymentPollingJob: Job? = null
|
||||
private val paymentHandled = AtomicBoolean(false)
|
||||
|
||||
// 图片加载状态:URL -> (重试次数, 是否加载失败)
|
||||
private val _imageLoadStates = MutableStateFlow<Map<String, ImageLoadState>>(emptyMap())
|
||||
val imageLoadStates: StateFlow<Map<String, ImageLoadState>> = _imageLoadStates.asStateFlow()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FaceRecognitionResultViewModel"
|
||||
private const val MAX_RETRY_COUNT = 3
|
||||
private const val PAY_STATUS_POLL_INTERVAL_MS = 2_000L
|
||||
}
|
||||
|
||||
data class ImageLoadState(
|
||||
@@ -93,9 +111,13 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
.onEach { event ->
|
||||
when (event) {
|
||||
is UploadPhotoEvent.PaySuccess -> {
|
||||
// 支付成功,跳转到支付成功页面
|
||||
LogUtils.d("FaceRecognitionResultViewModel", "收到支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, image_ids: ${event.imageIds}")
|
||||
navigateToPaySuccess(event.orderNumber, event.captureType, event.imageIds)
|
||||
LogUtils.d(TAG, "收到 WebSocket 支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, image_ids: ${event.imageIds}")
|
||||
handlePaymentSuccess(
|
||||
orderNumber = event.orderNumber,
|
||||
captureType = event.captureType,
|
||||
imageIds = event.imageIds,
|
||||
source = "WebSocket"
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
// 其他事件不处理
|
||||
@@ -330,10 +352,16 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
flow = netWorkRepository.getPayUrl(request).asResult(),
|
||||
showToast = false,
|
||||
onData = { response ->
|
||||
val url = response.url
|
||||
if (url.isNullOrEmpty()) {
|
||||
val url = response.url?.trim()
|
||||
val orderNumber = response.orderNumber?.trim()
|
||||
if (url.isNullOrEmpty() || orderNumber.isNullOrEmpty()) {
|
||||
_payQrCodeUrl.value = null
|
||||
LogUtils.e(TAG, "获取支付二维码成功,但 url 或 order_number 为空")
|
||||
ToastUtils.show("获取支付二维码失败")
|
||||
} else {
|
||||
stopPayStatusPolling()
|
||||
activePaymentOrderNumber = orderNumber
|
||||
paymentHandled.set(false)
|
||||
_payQrCodeUrl.value = url
|
||||
onSuccess(url)
|
||||
}
|
||||
@@ -352,6 +380,149 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 二维码弹框显示时启动支付状态轮询。重复调用不会创建多个轮询任务。
|
||||
*/
|
||||
fun startPayStatusPolling() {
|
||||
val orderNumber = activePaymentOrderNumber
|
||||
if (orderNumber.isNullOrEmpty() || paymentHandled.get()) {
|
||||
LogUtils.i(TAG, "未启动支付状态轮询:当前没有有效待支付订单")
|
||||
return
|
||||
}
|
||||
if (paymentPollingJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
|
||||
paymentPollingJob = viewModelScope.launch {
|
||||
LogUtils.d(TAG, "开始轮询支付状态 - order_number: $orderNumber")
|
||||
while (
|
||||
isActive &&
|
||||
!paymentHandled.get() &&
|
||||
activePaymentOrderNumber == orderNumber
|
||||
) {
|
||||
queryPayStatus(orderNumber)
|
||||
delay(PAY_STATUS_POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 二维码弹框关闭时停止 HTTP 轮询,WebSocket 监听仍保持有效。 */
|
||||
fun stopPayStatusPolling() {
|
||||
paymentPollingJob?.cancel()
|
||||
paymentPollingJob = null
|
||||
}
|
||||
|
||||
fun onPayDialogDismissed() {
|
||||
stopPayStatusPolling()
|
||||
}
|
||||
|
||||
private suspend fun queryPayStatus(orderNumber: String) {
|
||||
try {
|
||||
val response = netWorkRepository
|
||||
.getPaySuccessMessage(PaySuccessMessageRequest(orderNumber))
|
||||
.first()
|
||||
|
||||
if (!response.isSucceeded) {
|
||||
LogUtils.e(
|
||||
TAG,
|
||||
"查询支付状态业务失败 - order_number: $orderNumber, code: ${response.code}, msg: ${response.message}"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val message = response.data
|
||||
when (decideFacePayStatus(message?.orderStatus)) {
|
||||
FacePayStatusDecision.CONTINUE_POLLING -> {
|
||||
LogUtils.d(
|
||||
TAG,
|
||||
"订单尚未完成,继续轮询 - order_number: $orderNumber, status: ${message?.orderStatus}, name: ${message?.orderStatusName}"
|
||||
)
|
||||
}
|
||||
|
||||
FacePayStatusDecision.COMPLETE_PAYMENT -> {
|
||||
if (!isValidFacePaySuccessMessage(orderNumber, message)) {
|
||||
LogUtils.e(TAG, "支付完成消息校验失败,等待下一次查询或 WebSocket - order_number: $orderNumber, message: $message")
|
||||
return
|
||||
}
|
||||
|
||||
val paymentData = checkNotNull(message?.data)
|
||||
handlePaymentSuccess(
|
||||
orderNumber = paymentData.orderNumber,
|
||||
captureType = paymentData.captureType,
|
||||
imageIds = paymentData.imageIds.orEmpty(),
|
||||
source = "HTTP"
|
||||
)
|
||||
}
|
||||
|
||||
FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS -> {
|
||||
handleTerminalPayStatus(message?.orderStatus, message?.orderStatusName)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e(TAG, "查询支付状态异常 - order_number: $orderNumber, error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTerminalPayStatus(orderStatus: Int?, orderStatusName: String?) {
|
||||
if (!paymentHandled.compareAndSet(false, true)) {
|
||||
return
|
||||
}
|
||||
|
||||
stopPayStatusPolling()
|
||||
activePaymentOrderNumber = null
|
||||
_payQrCodeUrl.value = null
|
||||
_dismissPayDialogEvents.tryEmit(Unit)
|
||||
|
||||
val statusText = orderStatusName?.takeIf { it.isNotBlank() } ?: when (orderStatus) {
|
||||
40 -> "已取消"
|
||||
50 -> "已退款"
|
||||
60 -> "部分退款"
|
||||
else -> "状态异常"
|
||||
}
|
||||
ToastUtils.show("订单$statusText")
|
||||
LogUtils.i(TAG, "订单进入终态,停止支付轮询 - status: $orderStatus, name: $statusText")
|
||||
}
|
||||
|
||||
private fun handlePaymentSuccess(
|
||||
orderNumber: String?,
|
||||
captureType: Int?,
|
||||
imageIds: List<Int>,
|
||||
source: String
|
||||
) {
|
||||
val expectedOrderNumber = activePaymentOrderNumber
|
||||
if (
|
||||
expectedOrderNumber.isNullOrEmpty() ||
|
||||
orderNumber != expectedOrderNumber ||
|
||||
captureType != 2 ||
|
||||
imageIds.isEmpty()
|
||||
) {
|
||||
LogUtils.i(
|
||||
TAG,
|
||||
"忽略不匹配的支付成功消息 - source: $source, expected: $expectedOrderNumber, actual: $orderNumber, capture_type: $captureType, image_ids: $imageIds"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val knownImageIds = originalResults.map { it.id }.toSet()
|
||||
if (!knownImageIds.containsAll(imageIds)) {
|
||||
LogUtils.e(TAG, "支付成功消息包含未知图片,暂不跳转 - source: $source, image_ids: $imageIds")
|
||||
return
|
||||
}
|
||||
|
||||
if (!paymentHandled.compareAndSet(false, true)) {
|
||||
LogUtils.d(TAG, "支付成功已处理,忽略重复消息 - source: $source, order_number: $orderNumber")
|
||||
return
|
||||
}
|
||||
|
||||
LogUtils.d(TAG, "确认支付成功 - source: $source, order_number: $orderNumber")
|
||||
stopPayStatusPolling()
|
||||
_payQrCodeUrl.value = null
|
||||
_dismissPayDialogEvents.tryEmit(Unit)
|
||||
navigateToPaySuccess(orderNumber, captureType, imageIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到支付成功页面
|
||||
|
||||
Reference in New Issue
Block a user