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(
|
data class GetPayUrlResponse(
|
||||||
@SerializedName("url")
|
@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
|
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.GetPayUrlRequest
|
||||||
|
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
|
||||||
import com.yzx.kiosk.network.model.request.PrintCompleteRequest
|
import com.yzx.kiosk.network.model.request.PrintCompleteRequest
|
||||||
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
||||||
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
|
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.GetPrintInfoResponse
|
||||||
import com.yzx.kiosk.network.model.response.NetworkResponse
|
import com.yzx.kiosk.network.model.response.NetworkResponse
|
||||||
import com.yzx.kiosk.network.model.response.OscarLivesData
|
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.SocketTokenResponse
|
||||||
import com.yzx.kiosk.network.model.response.VerifyResultResponse
|
import com.yzx.kiosk.network.model.response.VerifyResultResponse
|
||||||
import com.yzx.kiosk.network.model.response.VersionResponse
|
import com.yzx.kiosk.network.model.response.VersionResponse
|
||||||
@@ -27,6 +30,7 @@ import javax.inject.Inject
|
|||||||
class NetWorkRepository @Inject constructor(
|
class NetWorkRepository @Inject constructor(
|
||||||
private val netService: NetworkService,
|
private val netService: NetworkService,
|
||||||
private val uploadService: UploadService,
|
private val uploadService: UploadService,
|
||||||
|
private val gson: Gson,
|
||||||
) {
|
) {
|
||||||
// 基础方法示例,可根据需要添加
|
// 基础方法示例,可根据需要添加
|
||||||
// fun example(): Flow<NetworkResponse<Any>> = flow {
|
// fun example(): Flow<NetworkResponse<Any>> = flow {
|
||||||
@@ -81,6 +85,23 @@ class NetWorkRepository @Inject constructor(
|
|||||||
fun getPayUrl(request: GetPayUrlRequest): Flow<NetworkResponse<GetPayUrlResponse>> = flow {
|
fun getPayUrl(request: GetPayUrlRequest): Flow<NetworkResponse<GetPayUrlResponse>> = flow {
|
||||||
emit(netService.getPayUrl(request))
|
emit(netService.getPayUrl(request))
|
||||||
}.flowOn(Dispatchers.IO)
|
}.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 {
|
fun getSocketToken(): Flow<NetworkResponse<SocketTokenResponse>> = flow {
|
||||||
emit(netService.getSocketToken())
|
emit(netService.getSocketToken())
|
||||||
@@ -97,4 +118,4 @@ class NetWorkRepository @Inject constructor(
|
|||||||
fun saveAlbumUrl(request: SaveAlbumUrlRequest): Flow<NetworkResponse<GetCodeUrlResponse>> = flow {
|
fun saveAlbumUrl(request: SaveAlbumUrlRequest): Flow<NetworkResponse<GetCodeUrlResponse>> = flow {
|
||||||
emit(netService.saveAlbumUrl(request))
|
emit(netService.saveAlbumUrl(request))
|
||||||
}.flowOn(Dispatchers.IO)
|
}.flowOn(Dispatchers.IO)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.yzx.kiosk.network.service
|
package com.yzx.kiosk.network.service
|
||||||
|
|
||||||
import com.yzx.kiosk.network.model.request.GetPayUrlRequest
|
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.PrintCompleteRequest
|
||||||
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
||||||
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
|
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.SocketTokenResponse
|
||||||
import com.yzx.kiosk.network.model.response.VerifyResultResponse
|
import com.yzx.kiosk.network.model.response.VerifyResultResponse
|
||||||
import com.yzx.kiosk.network.model.response.VersionResponse
|
import com.yzx.kiosk.network.model.response.VersionResponse
|
||||||
|
import com.google.gson.JsonElement
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
@@ -68,6 +70,11 @@ interface NetworkService {
|
|||||||
|
|
||||||
@POST("/api/oscar/order/get-pay-url")
|
@POST("/api/oscar/order/get-pay-url")
|
||||||
suspend fun getPayUrl(@Body request: GetPayUrlRequest): NetworkResponse<GetPayUrlResponse>
|
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")
|
@GET("/api/oscar/socket-token")
|
||||||
suspend fun getSocketToken(): NetworkResponse<SocketTokenResponse>
|
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.PhotoPreviewDialog
|
||||||
import com.yzx.kiosk.ui.upload.view.RetryableAsyncImage
|
import com.yzx.kiosk.ui.upload.view.RetryableAsyncImage
|
||||||
import com.yzx.kiosk.utils.QrCodeUtils
|
import com.yzx.kiosk.utils.QrCodeUtils
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.flow.collect
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -76,6 +76,12 @@ fun FaceRecognitionResultScreen(
|
|||||||
// 支付弹框状态
|
// 支付弹框状态
|
||||||
var showPayDialog by remember { mutableStateOf(false) }
|
var showPayDialog by remember { mutableStateOf(false) }
|
||||||
val payQrCodeUrl by viewModel.payQrCodeUrl.collectAsState()
|
val payQrCodeUrl by viewModel.payQrCodeUrl.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(viewModel) {
|
||||||
|
viewModel.dismissPayDialogEvents.collect {
|
||||||
|
showPayDialog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
FullScreenMode()
|
FullScreenMode()
|
||||||
|
|
||||||
@@ -265,15 +271,19 @@ fun FaceRecognitionResultScreen(
|
|||||||
|
|
||||||
// 支付弹框
|
// 支付弹框
|
||||||
if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) {
|
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(
|
WechatPayDialog(
|
||||||
qrCodeUrl = payQrCodeUrl!!,
|
qrCodeUrl = payQrCodeUrl!!,
|
||||||
totalPrice = totalPrice,
|
totalPrice = totalPrice,
|
||||||
onDismiss = { showPayDialog = false },
|
onDismiss = {
|
||||||
onPaySuccess = {
|
|
||||||
showPayDialog = false
|
showPayDialog = false
|
||||||
// 支付成功,等待 WebSocket 消息(code=5)触发跳转到支付成功页面
|
viewModel.onPayDialogDismissed()
|
||||||
// 跳转逻辑由 FaceRecognitionResultViewModel 中的 WebSocket 监听处理
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -453,8 +463,7 @@ fun FaceRecognitionPhotoItem(
|
|||||||
fun WechatPayDialog(
|
fun WechatPayDialog(
|
||||||
qrCodeUrl: String,
|
qrCodeUrl: String,
|
||||||
totalPrice: String,
|
totalPrice: String,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit
|
||||||
onPaySuccess: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
// 使用接口返回的URL生成支付二维码
|
// 使用接口返回的URL生成支付二维码
|
||||||
val qrCodeBitmap = remember(qrCodeUrl) {
|
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
|
package com.yzx.kiosk.ui.face.viewmodel
|
||||||
|
|
||||||
import android.graphics.ImageDecoder
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
@@ -11,6 +9,7 @@ import com.yzx.kiosk.base.BaseViewModel
|
|||||||
import com.yzx.kiosk.datastore.AppState
|
import com.yzx.kiosk.datastore.AppState
|
||||||
import com.yzx.kiosk.navigation.AppNavigator
|
import com.yzx.kiosk.navigation.AppNavigator
|
||||||
import com.yzx.kiosk.network.model.request.GetPayUrlRequest
|
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.request.VerifyResultRequest
|
||||||
import com.yzx.kiosk.network.model.response.FaceSearchResult
|
import com.yzx.kiosk.network.model.response.FaceSearchResult
|
||||||
import com.yzx.kiosk.network.repository.NetWorkRepository
|
import com.yzx.kiosk.network.repository.NetWorkRepository
|
||||||
@@ -26,14 +25,23 @@ import kotlinx.coroutines.flow.launchIn
|
|||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
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.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import com.yzx.kiosk.utils.LogUtils
|
import com.yzx.kiosk.utils.LogUtils
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -72,13 +80,23 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
|||||||
// 支付二维码URL
|
// 支付二维码URL
|
||||||
private val _payQrCodeUrl = MutableStateFlow<String?>(null)
|
private val _payQrCodeUrl = MutableStateFlow<String?>(null)
|
||||||
val payQrCodeUrl: StateFlow<String?> = _payQrCodeUrl.asStateFlow()
|
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 -> (重试次数, 是否加载失败)
|
// 图片加载状态:URL -> (重试次数, 是否加载失败)
|
||||||
private val _imageLoadStates = MutableStateFlow<Map<String, ImageLoadState>>(emptyMap())
|
private val _imageLoadStates = MutableStateFlow<Map<String, ImageLoadState>>(emptyMap())
|
||||||
val imageLoadStates: StateFlow<Map<String, ImageLoadState>> = _imageLoadStates.asStateFlow()
|
val imageLoadStates: StateFlow<Map<String, ImageLoadState>> = _imageLoadStates.asStateFlow()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "FaceRecognitionResultViewModel"
|
||||||
private const val MAX_RETRY_COUNT = 3
|
private const val MAX_RETRY_COUNT = 3
|
||||||
|
private const val PAY_STATUS_POLL_INTERVAL_MS = 2_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ImageLoadState(
|
data class ImageLoadState(
|
||||||
@@ -93,9 +111,13 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
|||||||
.onEach { event ->
|
.onEach { event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
is UploadPhotoEvent.PaySuccess -> {
|
is UploadPhotoEvent.PaySuccess -> {
|
||||||
// 支付成功,跳转到支付成功页面
|
LogUtils.d(TAG, "收到 WebSocket 支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, image_ids: ${event.imageIds}")
|
||||||
LogUtils.d("FaceRecognitionResultViewModel", "收到支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, image_ids: ${event.imageIds}")
|
handlePaymentSuccess(
|
||||||
navigateToPaySuccess(event.orderNumber, event.captureType, event.imageIds)
|
orderNumber = event.orderNumber,
|
||||||
|
captureType = event.captureType,
|
||||||
|
imageIds = event.imageIds,
|
||||||
|
source = "WebSocket"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// 其他事件不处理
|
// 其他事件不处理
|
||||||
@@ -330,10 +352,16 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
|||||||
flow = netWorkRepository.getPayUrl(request).asResult(),
|
flow = netWorkRepository.getPayUrl(request).asResult(),
|
||||||
showToast = false,
|
showToast = false,
|
||||||
onData = { response ->
|
onData = { response ->
|
||||||
val url = response.url
|
val url = response.url?.trim()
|
||||||
if (url.isNullOrEmpty()) {
|
val orderNumber = response.orderNumber?.trim()
|
||||||
|
if (url.isNullOrEmpty() || orderNumber.isNullOrEmpty()) {
|
||||||
|
_payQrCodeUrl.value = null
|
||||||
|
LogUtils.e(TAG, "获取支付二维码成功,但 url 或 order_number 为空")
|
||||||
ToastUtils.show("获取支付二维码失败")
|
ToastUtils.show("获取支付二维码失败")
|
||||||
} else {
|
} else {
|
||||||
|
stopPayStatusPolling()
|
||||||
|
activePaymentOrderNumber = orderNumber
|
||||||
|
paymentHandled.set(false)
|
||||||
_payQrCodeUrl.value = url
|
_payQrCodeUrl.value = url
|
||||||
onSuccess(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)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 跳转到支付成功页面
|
* 跳转到支付成功页面
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.yzx.kiosk.ui.face.viewmodel
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
|
||||||
|
import com.yzx.kiosk.network.model.response.PaySuccessMessageData
|
||||||
|
import com.yzx.kiosk.network.model.response.PaySuccessMessageResponse
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FacePayStatusDecisionTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pending and unknown statuses continue polling`() {
|
||||||
|
listOf(null, 10, 20, 0, 70).forEach { status ->
|
||||||
|
assertEquals(
|
||||||
|
FacePayStatusDecision.CONTINUE_POLLING,
|
||||||
|
decideFacePayStatus(status)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `completed status completes payment`() {
|
||||||
|
assertEquals(
|
||||||
|
FacePayStatusDecision.COMPLETE_PAYMENT,
|
||||||
|
decideFacePayStatus(30)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cancel and refund statuses stop polling`() {
|
||||||
|
listOf(40, 50, 60).forEach { status ->
|
||||||
|
assertEquals(
|
||||||
|
FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS,
|
||||||
|
decideFacePayStatus(status)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `valid face payment message is accepted`() {
|
||||||
|
assertTrue(isValidFacePaySuccessMessage("ORDER-1", message()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty or mismatched payment data is rejected`() {
|
||||||
|
assertFalse(isValidFacePaySuccessMessage("ORDER-1", null))
|
||||||
|
assertFalse(isValidFacePaySuccessMessage("OTHER", message()))
|
||||||
|
assertFalse(isValidFacePaySuccessMessage("ORDER-1", message(type = 4)))
|
||||||
|
assertFalse(isValidFacePaySuccessMessage("ORDER-1", message(captureType = 1)))
|
||||||
|
assertFalse(isValidFacePaySuccessMessage("ORDER-1", message(imageIds = emptyList())))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pay status request uses backend order number field`() {
|
||||||
|
val json = Gson().toJson(PaySuccessMessageRequest("ORDER-1"))
|
||||||
|
|
||||||
|
assertEquals("{\"order_number\":\"ORDER-1\"}", json)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pay status response deserializes nested websocket payload`() {
|
||||||
|
val json = """
|
||||||
|
{
|
||||||
|
"order_status": 30,
|
||||||
|
"order_status_name": "已完成",
|
||||||
|
"sn": "OSCAR_DEVICE_SN",
|
||||||
|
"type": 5,
|
||||||
|
"data": {
|
||||||
|
"order_number": "ORDER-1",
|
||||||
|
"capture_type": 2,
|
||||||
|
"image_id": [335, 340]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val response = Gson().fromJson(json, PaySuccessMessageResponse::class.java)
|
||||||
|
|
||||||
|
assertEquals(30, response.orderStatus)
|
||||||
|
assertEquals("ORDER-1", response.data?.orderNumber)
|
||||||
|
assertEquals(listOf(335, 340), response.data?.imageIds)
|
||||||
|
assertTrue(isValidFacePaySuccessMessage("ORDER-1", response))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun message(
|
||||||
|
type: Int = 5,
|
||||||
|
captureType: Int = 2,
|
||||||
|
imageIds: List<Int> = listOf(335, 340)
|
||||||
|
) = PaySuccessMessageResponse(
|
||||||
|
orderStatus = 30,
|
||||||
|
orderStatusName = "已完成",
|
||||||
|
sn = "OSCAR_DEVICE_SN",
|
||||||
|
type = type,
|
||||||
|
data = PaySuccessMessageData(
|
||||||
|
orderNumber = "ORDER-1",
|
||||||
|
captureType = captureType,
|
||||||
|
imageIds = imageIds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user