diff --git a/app/src/androidTest/java/com/yzx/kiosk/ui/face/ElectronicCompletionScreenTest.kt b/app/src/androidTest/java/com/yzx/kiosk/ui/face/ElectronicCompletionScreenTest.kt new file mode 100644 index 0000000..74c5bbb --- /dev/null +++ b/app/src/androidTest/java/com/yzx/kiosk/ui/face/ElectronicCompletionScreenTest.kt @@ -0,0 +1,67 @@ +package com.yzx.kiosk.ui.face + +import android.graphics.Bitmap +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.platform.app.InstrumentationRegistry +import com.google.zxing.BinaryBitmap +import com.google.zxing.MultiFormatReader +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.HybridBinarizer +import com.yzx.kiosk.theme.AppTheme +import com.yzx.kiosk.ui.common.view.FullScreenMode +import com.yzx.kiosk.ui.face.view.ElectronicCompletionContent +import com.yzx.kiosk.utils.QrCodeUtils +import java.io.File +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class ElectronicCompletionScreenTest { + @get:Rule val compose = createComposeRule() + + @Test fun downloadQrIsScannableAndReturnWorks() { + val url = "https://example.test/album/electronic-completion" + val qr = QrCodeUtils.generateStyledQrBitmap(url, size = 800, cornerRadius = 0f).asImageBitmap() + var returned = false + compose.setContent { + FullScreenMode() + AppTheme { + // Match the reference/kiosk aspect ratio even on a taller test phone. + Box(Modifier.fillMaxWidth().aspectRatio(941f / 1672f).testTag("completion")) { + ElectronicCompletionContent(qr, false, 90, "4001234567", {}, { returned = true }) + } + } + } + compose.onNodeWithText("客服电话:4001234567").assertIsDisplayed() + val bitmap = compose.onNodeWithTag("completion").captureToImage().asAndroidBitmap() + val pixels = IntArray(bitmap.width * bitmap.height) + bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + assertEquals(url, MultiFormatReader().decode(BinaryBitmap(HybridBinarizer( + RGBLuminanceSource(bitmap.width, bitmap.height, pixels) + ))).text) + val context = InstrumentationRegistry.getInstrumentation().targetContext + File(context.getExternalFilesDir(null), "electronic-completion.png").outputStream().use { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) + } + compose.onNodeWithContentDescription("返回首页").performClick() + compose.runOnIdle { assertTrue(returned) } + } + + @Test fun failedQrOffersWorkingRetry() { + var retried = false + compose.setContent { + AppTheme { ElectronicCompletionContent(null, true, 70, "4001234567", { retried = true }, {}) } + } + compose.onNodeWithText("二维码加载失败").assertIsDisplayed() + compose.onNodeWithText("重新加载").performClick() + compose.runOnIdle { assertTrue(retried) } + } +} diff --git a/app/src/androidTest/java/com/yzx/kiosk/ui/face/RecentPhotosFlowTest.kt b/app/src/androidTest/java/com/yzx/kiosk/ui/face/RecentPhotosFlowTest.kt index 11a37b0..4ca2a94 100644 --- a/app/src/androidTest/java/com/yzx/kiosk/ui/face/RecentPhotosFlowTest.kt +++ b/app/src/androidTest/java/com/yzx/kiosk/ui/face/RecentPhotosFlowTest.kt @@ -51,9 +51,19 @@ class RecentPhotosFlowTest { private lateinit var createResultVm: () -> FaceRecognitionResultViewModel private val scopedResultVms = mutableListOf() private lateinit var faceVm: FaceRecognitionViewModel + private lateinit var testSocket: WebSocketService private lateinit var nav: AppNavigator private var restoreConfig: (() -> Unit)? = null private val requests = CopyOnWriteArrayList() + private lateinit var testRepository: NetWorkRepository + private var completionVm: ElectronicCompletionViewModel? = null + @Volatile private var electronicAmount: String? = null + @Volatile private var completionStatus = 30 + @Volatile private var payFails = false + @Volatile private var queryFails = false + @Volatile private var quoteFails = false + @Volatile private var completedMode = "electronic" + @Volatile private var qrFails = false @Volatile private var recentCode = 200 @Volatile private var recentBody = """{"count":0,"results":[]}""" @@ -63,6 +73,7 @@ class RecentPhotosFlowTest { val component = (app as dagger.hilt.internal.GeneratedComponentManager<*>).generatedComponent() val providerField = component.javaClass.getDeclaredField("webSocketServiceProvider").apply { isAccessible = true } val socket = (providerField.get(component) as javax.inject.Provider<*>).get() as WebSocketService + testSocket = socket val store = app.appStoreDataSource val oldRemote = store.getBindBoxUrl() val oldLan = store.getBindBoxLanUrl() @@ -84,14 +95,28 @@ class RecentPhotosFlowTest { val recent = req.url.encodedPath.endsWith("/api/photos/recent") val body = when { recent -> recentBody + req.url.encodedPath.endsWith("verify-result") && quoteFails -> + """{"code":100001,"msg":"项目已下线,请重新选择","data":{}}""" req.url.encodedPath.endsWith("verify-result") -> { val buffer = okio.Buffer() req.body!!.writeTo(buffer) val ids = Gson().fromJson(buffer.readUtf8(), com.google.gson.JsonObject::class.java).getAsJsonArray("image_id") val amount = "${ids.size() * 2}.00" - """{"code":100000,"data":{"price_image":"2.00","amount":"$amount"}}""" + val extra = electronicAmount?.let { ",\"price_electronic\":\"$it\",\"amount_electronic\":\"$it\"" }.orEmpty() + """{"code":100000,"data":{"price_image":"2.00","amount":"$amount"$extra}}""" } - req.url.encodedPath.endsWith("get-pay-url") -> """{"code":100000,"data":{"url":"https://example.test/mock-pay","order_number":"MOCK-RECENT-1"}}""" + req.url.encodedPath.endsWith("get-pay-url") && payFails -> + """{"code":100001,"msg":"电子版价格不可用,请重新选择","data":{}}""" + req.url.encodedPath.endsWith("get-pay-url") -> if (electronicAmount == null) + """{"code":100000,"data":{"url":"https://example.test/mock-pay","order_number":"MOCK-RECENT-1"}}""" + else """{"code":100000,"data":{"url":${if (electronicAmount == "0.00") "null" else "\"https://example.test/mock-pay\""},"order_number":"MOCK-RECENT-1","purchase_mode":"electronic","amount":"$electronicAmount","order_status":${if (electronicAmount == "0.00") 30 else 10}}}""" + req.url.encodedPath.endsWith("pay-success-message") && queryFails -> + """{"code":100001,"msg":"预选缓存已失效,请重新选择照片","data":{}}""" + req.url.encodedPath.endsWith("pay-success-message") -> + """{"code":100000,"data":{"order_status":$completionStatus,"type":5,"data":{"order_number":"MOCK-RECENT-1","capture_type":2,"image_id":[9],"purchase_mode":"$completedMode"}}}""" + req.url.encodedPath.endsWith("save-album-url") -> if (qrFails) + """{"code":500,"msg":"mock failure"}""" else + """{"code":100000,"data":{"url":"https://example.test/album/MOCK-RECENT-1"}}""" else -> """{"count":0,"results":[]}""" } Response.Builder().request(req).protocol(Protocol.HTTP_1_1).code(if (recent) recentCode else 200) @@ -101,6 +126,7 @@ class RecentPhotosFlowTest { .addConverterFactory(GsonConverterFactory.create()).build() val service = retrofit.create(FaceSearchService::class.java) val repository = NetWorkRepository(retrofit.create(NetworkService::class.java), retrofit.create(UploadService::class.java), Gson()) + testRepository = repository compose.runOnUiThread { nav = AppNavigator() createResultVm = { FaceRecognitionResultViewModel(nav, app.appState, repository, service, socket, app.appStoreDataSource) } @@ -113,6 +139,7 @@ class RecentPhotosFlowTest { compose.runOnUiThread { if (::resultVm.isInitialized) resultVm.viewModelScope.cancel() if (::faceVm.isInitialized) faceVm.viewModelScope.cancel() + completionVm?.viewModelScope?.cancel() scopedResultVms.forEach { it.viewModelScope.cancel() } restoreConfig?.invoke() } @@ -262,7 +289,7 @@ class RecentPhotosFlowTest { compose.onNodeWithText("全选").performClick() compose.waitUntil(10_000) { resultVm.totalPrice.value == "4.00" } assertEquals(2, resultVm.selectedPhotos.value.size) - compose.onNodeWithText("支付4.00元").assertIsEnabled() + compose.onNodeWithText("支付打印版\n4元").assertIsEnabled() screenshot("recent-photos-results") compose.onAllNodesWithText("点击预览")[0].performClick() compose.onNodeWithContentDescription("关闭").assertIsDisplayed().performClick() @@ -270,9 +297,10 @@ class RecentPhotosFlowTest { assertEquals(1, resultVm.selectedPhotos.value.size) compose.runOnIdle { resultVm.toggleSelectAll() } assertEquals(2, resultVm.selectedPhotos.value.size) + compose.waitUntil(10_000) { resultVm.totalPrice.value == "4.00" } // Exercise the shared payment code against a fake response without opening polling UI. var payUrl: String? = null - compose.runOnIdle { resultVm.getPayUrl { payUrl = it } } + compose.runOnIdle { resultVm.getPayUrl(PurchaseMode.PRINT) { payUrl = it } } compose.waitUntil(10_000) { payUrl != null } assertEquals("https://example.test/mock-pay", payUrl) val field = FaceRecognitionResultViewModel::class.java.getDeclaredField("activePaymentOrderNumber").apply { isAccessible = true } @@ -283,8 +311,8 @@ class RecentPhotosFlowTest { scope.launchCollect(nav) { route = it } } compose.runOnIdle { - val method = FaceRecognitionResultViewModel::class.java.getDeclaredMethod("handlePaymentSuccess", String::class.java, Integer::class.java, List::class.java, String::class.java).apply { isAccessible = true } - method.invoke(resultVm, "MOCK-RECENT-1", 2, listOf(9, 3), "test") + val method = FaceRecognitionResultViewModel::class.java.getDeclaredMethod("handlePaymentSuccess", String::class.java, Integer::class.java, List::class.java, String::class.java, String::class.java).apply { isAccessible = true } + method.invoke(resultVm, "MOCK-RECENT-1", 2, listOf(9, 3), null, "test") } compose.waitUntil(10_000) { route != null } assertTrue(route!!.startsWith("printing?")) @@ -292,12 +320,171 @@ class RecentPhotosFlowTest { job.cancel() } + private fun showElectronicSelection(amount: String?) { + electronicAmount = amount + val image = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) } + val file = File(context.cacheDir, "electronic-fixture.png") + file.outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) } + val url = file.toURI().toString() + val photos = listOf(FaceSearchResult(9, url, url, url, url)) + compose.setContent { FaceRecognitionResultScreen(results = photos, viewModel = resultVm) } + compose.waitUntil(10_000) { resultVm.photoList.value.isNotEmpty() } + compose.runOnIdle { resultVm.toggleSelectAll() } + compose.waitUntil(10_000) { !resultVm.quoteLoading.value && resultVm.totalPrice.value == "2.00" } + } + + @Test fun paidElectronicUsesOrderModeAndRejectsMismatchedCompletion() { + showElectronicSelection("1.00") + val routes = CopyOnWriteArrayList() + val collector = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).launchCollect(nav) { routes.add(it) } + try { + completedMode = "print" + compose.onNodeWithText("打印版 2元/张").assertIsDisplayed() + compose.onNodeWithText("电子版 1元/张").assertIsDisplayed() + screenshot("purchase-two-modes") + compose.onNodeWithText("支付电子版\n1元").performClick() + compose.waitUntil(10_000) { requests.any { it.url.encodedPath.endsWith("pay-success-message") } } + compose.onNodeWithText("微信支付").assertIsDisplayed() + compose.onNodeWithText("订单金额 1元").assertDoesNotExist() + assertTrue(routes.isEmpty()) + completedMode = "electronic" + compose.waitUntil(10_000) { routes.size == 1 } + assertTrue(routes.single().startsWith("electronic_completion?")) + compose.runOnIdle { + val method = FaceRecognitionResultViewModel::class.java.getDeclaredMethod("handlePaymentSuccess", String::class.java, Integer::class.java, List::class.java, String::class.java, String::class.java).apply { isAccessible = true } + method.invoke(resultVm, "MOCK-RECENT-1", 2, listOf(9), "electronic", "duplicate WebSocket") + } + compose.waitForIdle() + assertEquals(1, routes.size) + val order = requests.first { it.url.encodedPath.endsWith("get-pay-url") } + val buffer = okio.Buffer().also { order.body!!.writeTo(it) } + val body = Gson().fromJson(buffer.readUtf8(), com.google.gson.JsonObject::class.java) + assertEquals("electronic", body.get("purchase_mode").asString) + assertEquals(0, body.getAsJsonArray("video_id").size()) + } finally { collector.cancel() } + } + + @Test fun zeroAndMissingElectronicPricesCannotCreateOrders() { + showElectronicSelection("0.00") + compose.onNodeWithText("电子版暂不可购买").assertIsNotEnabled() + compose.onNodeWithText("免费领取电子版").assertDoesNotExist() + compose.runOnIdle { resultVm.getPayUrl(PurchaseMode.ELECTRONIC) { error("zero price must not create a link") } } + assertFalse(requests.any { it.url.encodedPath.endsWith("get-pay-url") }) + electronicAmount = null + compose.runOnIdle { resultVm.retryQuote() } + compose.waitUntil(10_000) { !resultVm.quoteLoading.value } + compose.onNodeWithText("电子版暂不可购买").assertIsNotEnabled() + compose.onNodeWithText("支付打印版\n2元").assertIsEnabled() + } + + private fun pushCompletion(number: String = "MOCK-RECENT-1", mode: String = "electronic") { + val json = """{"code":5,"data":{"sn":"MOCK","type":5,"data":{"order_number":"$number","capture_type":2,"image_id":[9],"purchase_mode":"$mode"}}}""" + val method = WebSocketService::class.java.getDeclaredMethod("handleMessage", String::class.java).apply { isAccessible = true } + method.invoke(testSocket, json) + } + + @Test fun pendingTypeFiveCannotCompleteButNestedWebSocketCan() { + completionStatus = 10 + showElectronicSelection("1.00") + val routes = CopyOnWriteArrayList() + val collector = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).launchCollect(nav) { routes.add(it) } + try { + compose.onNodeWithText("支付电子版\n1元").performClick() + compose.waitUntil(10_000) { requests.any { it.url.encodedPath.endsWith("pay-success-message") } } + compose.waitForIdle() + assertTrue(routes.isEmpty()) + compose.onNodeWithText("微信支付").assertIsDisplayed() + compose.runOnIdle { pushCompletion(number = "OTHER") } + compose.waitForIdle() + assertTrue(routes.isEmpty()) + compose.runOnIdle { pushCompletion() } + compose.waitUntil(10_000) { routes.size == 1 } + compose.runOnIdle { pushCompletion() } + compose.waitForIdle() + assertEquals(1, routes.size) + assertTrue(routes.single().startsWith("electronic_completion?")) + } finally { collector.cancel() } + } + + @Test fun expiredPreselectionClosesPaymentAndRefreshesQuote() { + showElectronicSelection("1.00") + queryFails = true + val quotesBefore = requests.count { it.url.encodedPath.endsWith("verify-result") } + compose.onNodeWithText("支付电子版\n1元").performClick() + compose.waitUntil(10_000) { requests.count { it.url.encodedPath.endsWith("verify-result") } > quotesBefore && !resultVm.quoteLoading.value } + assertNull(resultVm.payQrCodeUrl.value) + compose.onNodeWithText("微信支付").assertDoesNotExist() + compose.onNodeWithText("支付电子版\n1元").assertIsEnabled() + } + + @Test fun rejectedLinkRestoresButtonsAndQuoteBusinessErrorsRemainVisible() { + showElectronicSelection("1.00") + payFails = true + val quotesBefore = requests.count { it.url.encodedPath.endsWith("verify-result") } + compose.onNodeWithText("支付电子版\n1元").performClick() + compose.waitUntil(10_000) { requests.count { it.url.encodedPath.endsWith("verify-result") } > quotesBefore && !resultVm.quoteLoading.value && !resultVm.creatingOrder.value } + assertNull(resultVm.payQrCodeUrl.value) + compose.onNodeWithText("支付电子版\n1元").assertIsEnabled() + quoteFails = true + compose.runOnIdle { resultVm.retryQuote() } + compose.waitUntil(10_000) { resultVm.quoteError.value != null } + compose.onNodeWithText("项目已下线,请重新选择").assertIsDisplayed() + compose.onNodeWithText("支付打印版\n--元").assertIsNotEnabled() + } + + @Test fun selectionChangesDiscardTheOldQrAndOrder() { + completionStatus = 10 + showElectronicSelection("1.00") + var url: String? = null + compose.runOnIdle { resultVm.getPayUrl(PurchaseMode.ELECTRONIC) { url = it } } + compose.waitUntil(10_000) { url != null } + assertNotNull(resultVm.payQrCodeUrl.value) + compose.runOnIdle { resultVm.toggleSelectAll() } + assertNull(resultVm.payQrCodeUrl.value) + val routes = CopyOnWriteArrayList() + val collector = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).launchCollect(nav) { routes.add(it) } + try { + compose.runOnIdle { pushCompletion() } + compose.waitForIdle() + assertTrue(routes.isEmpty()) + } finally { collector.cancel() } + } + + @Test fun electronicCompletionRetriesQrWithoutPrintingOrPaperChanges() { + val app = context.applicationContext as com.yzx.kiosk.App + val papersBefore = app.appStoreDataSource.getRemainingPaperNum() + compose.runOnIdle { + completionVm = ElectronicCompletionViewModel(nav, app.appState, app.appStoreDataSource, testRepository) + } + qrFails = true + compose.setContent { + ElectronicCompletionScreen("MOCK-RECENT-1", viewModel = completionVm!!) + } + compose.waitUntil(10_000) { completionVm!!.qrCodeFailed.value } + compose.onNodeWithText("领取成功").assertDoesNotExist() + compose.onNodeWithText("支付成功").assertDoesNotExist() + compose.onNodeWithText("返回首页").assertDoesNotExist() + compose.onNodeWithText("二维码加载失败").assertIsDisplayed() + qrFails = false + compose.onNodeWithText("重新加载").performClick() + compose.waitUntil(10_000) { completionVm!!.qrCodeUrl.value.isNotEmpty() } + compose.onNodeWithContentDescription("电子版照片下载二维码").assertIsDisplayed() + compose.onNodeWithText("请在屏幕下方拿取照片").assertDoesNotExist() + screenshot("electronic-completion") + compose.runOnIdle { + completionVm!!.initialize("MOCK-RECENT-1") + } + assertTrue(requests.all { it.url.encodedPath.endsWith("save-album-url") }) + assertEquals(2, requests.size) + assertEquals(papersBefore, app.appStoreDataSource.getRemainingPaperNum()) + } + @Test fun errorCanRetryIntoEmptyState() { recentCode = 403 compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) } compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.ERROR } compose.onNodeWithText("照片加载失败,请重试").assertIsDisplayed() - compose.onNodeWithText("支付0元").assertIsNotEnabled() + compose.onNodeWithText("支付打印版\n--元").assertIsNotEnabled() recentCode = 200 compose.onNodeWithText("重试").performClick() compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY } diff --git a/app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt b/app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt index ac7cf06..420786e 100644 --- a/app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt +++ b/app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt @@ -112,6 +112,10 @@ class LocalAudioPlayService @Inject constructor( // 同一结果页根据入口选择播报,避免最近照片误播识别成功。 val audioKey = localAudioKeyForRoute(route) + if (audioKey == ELECTRONIC_COMPLETION_AUDIO_KEY) { + stop() + return + } val audioResId = ROUTE_TO_AUDIO_MAP[audioKey] if (audioResId == null) { diff --git a/app/src/main/java/com/yzx/kiosk/audio/LocalAudioRoute.kt b/app/src/main/java/com/yzx/kiosk/audio/LocalAudioRoute.kt index e47e3a4..445f47c 100644 --- a/app/src/main/java/com/yzx/kiosk/audio/LocalAudioRoute.kt +++ b/app/src/main/java/com/yzx/kiosk/audio/LocalAudioRoute.kt @@ -3,11 +3,16 @@ package com.yzx.kiosk.audio import com.yzx.kiosk.navigation.routes.AppRoutes import java.net.URLDecoder +internal const val ELECTRONIC_COMPLETION_AUDIO_KEY = "electronic_completion_silent" + internal const val RECENT_PHOTOS_AUDIO_KEY = "recent_photos_result" /** Keep result-page audio tied to the entry source, not just the destination name. */ internal fun localAudioKeyForRoute(route: String): String { val baseRoute = route.substringBefore("?") + if (baseRoute == AppRoutes.ELECTRONIC_COMPLETION) { + return ELECTRONIC_COMPLETION_AUDIO_KEY + } if (baseRoute != AppRoutes.FACE_RECOGNITION_RESULT) return baseRoute val source = route.substringAfter("?", "").substringBefore("#") diff --git a/app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt b/app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt index 97ff5a9..79b399c 100644 --- a/app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt +++ b/app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt @@ -209,6 +209,18 @@ fun AppNavHost( ) } + // 电子版订单完成页,不创建打印页面或打印 ViewModel。 + composable( + route = AppRoutes.ELECTRONIC_COMPLETION_PATTERN, + arguments = listOf( + navArgument("orderNumber") { type = NavType.StringType }, + ), + ) { entry -> + com.yzx.kiosk.ui.face.view.ElectronicCompletionScreen( + orderNumber = entry.arguments?.getString("orderNumber").orEmpty(), + ) + } + // 协议页面 agreementScreen() } diff --git a/app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt b/app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt index db70bcd..6bf2c8c 100644 --- a/app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt +++ b/app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt @@ -8,6 +8,12 @@ object AppRoutes { const val UPLOAD_PHOTO = "upload_photo" const val PHOTO_SELECT = "photo_select" const val PRINTING = "printing" + const val ELECTRONIC_COMPLETION = "electronic_completion" + const val ELECTRONIC_COMPLETION_PATTERN = "$ELECTRONIC_COMPLETION?orderNumber={orderNumber}" + + fun buildElectronicCompletionRoute(orderNumber: String): String = + "$ELECTRONIC_COMPLETION?orderNumber=${java.net.URLEncoder.encode(orderNumber, "UTF-8")}" + const val PRINT_SUCCESS = "print_success" const val FACE_RECOGNITION = "face_recognition" const val FACE_RECOGNITION_RESULT = "face_recognition_result" diff --git a/app/src/main/java/com/yzx/kiosk/network/model/request/GetPayUrlRequest.kt b/app/src/main/java/com/yzx/kiosk/network/model/request/GetPayUrlRequest.kt index f9d7ddb..06577ff 100644 --- a/app/src/main/java/com/yzx/kiosk/network/model/request/GetPayUrlRequest.kt +++ b/app/src/main/java/com/yzx/kiosk/network/model/request/GetPayUrlRequest.kt @@ -6,6 +6,10 @@ data class GetPayUrlRequest( @SerializedName("type") val type: Int, @SerializedName("image_id") - val imageId: List + val imageId: List, + @SerializedName("purchase_mode") + val purchaseMode: String? = null, + @SerializedName("video_id") + val videoId: List? = null ) diff --git a/app/src/main/java/com/yzx/kiosk/network/model/request/VerifyResultRequest.kt b/app/src/main/java/com/yzx/kiosk/network/model/request/VerifyResultRequest.kt index 2681930..cef877c 100644 --- a/app/src/main/java/com/yzx/kiosk/network/model/request/VerifyResultRequest.kt +++ b/app/src/main/java/com/yzx/kiosk/network/model/request/VerifyResultRequest.kt @@ -6,6 +6,8 @@ data class VerifyResultRequest( @SerializedName("type") val type: Int, @SerializedName("image_id") - val imageId: List + val imageId: List, + @SerializedName("video_id") + val videoId: List? = null ) diff --git a/app/src/main/java/com/yzx/kiosk/network/model/response/GetPayUrlResponse.kt b/app/src/main/java/com/yzx/kiosk/network/model/response/GetPayUrlResponse.kt index acdb7f3..9166c8d 100644 --- a/app/src/main/java/com/yzx/kiosk/network/model/response/GetPayUrlResponse.kt +++ b/app/src/main/java/com/yzx/kiosk/network/model/response/GetPayUrlResponse.kt @@ -7,6 +7,12 @@ data class GetPayUrlResponse( val url: String?, @SerializedName("order_number") - val orderNumber: String? + val orderNumber: String?, + @SerializedName("purchase_mode") + val purchaseMode: String? = null, + @SerializedName("amount") + val amount: String? = null, + @SerializedName("order_status") + val orderStatus: Int? = null ) diff --git a/app/src/main/java/com/yzx/kiosk/network/model/response/PaySuccessMessageResponse.kt b/app/src/main/java/com/yzx/kiosk/network/model/response/PaySuccessMessageResponse.kt index afdd8c2..e66f90e 100644 --- a/app/src/main/java/com/yzx/kiosk/network/model/response/PaySuccessMessageResponse.kt +++ b/app/src/main/java/com/yzx/kiosk/network/model/response/PaySuccessMessageResponse.kt @@ -27,5 +27,8 @@ data class PaySuccessMessageData( val captureType: Int?, @SerializedName("image_id") - val imageIds: List? + val imageIds: List?, + + @SerializedName("purchase_mode") + val purchaseMode: String? = null ) diff --git a/app/src/main/java/com/yzx/kiosk/network/model/response/VerifyResultResponse.kt b/app/src/main/java/com/yzx/kiosk/network/model/response/VerifyResultResponse.kt index dc284e8..c6c0ca8 100644 --- a/app/src/main/java/com/yzx/kiosk/network/model/response/VerifyResultResponse.kt +++ b/app/src/main/java/com/yzx/kiosk/network/model/response/VerifyResultResponse.kt @@ -6,6 +6,10 @@ data class VerifyResultResponse( @SerializedName("price_image") val priceImage: String?, @SerializedName("amount") - val amount: String? + val amount: String?, + @SerializedName("price_electronic") + val priceElectronic: String? = null, + @SerializedName("amount_electronic") + val amountElectronic: String? = null ) diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/view/ElectronicCompletionScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/face/view/ElectronicCompletionScreen.kt new file mode 100644 index 0000000..e3ce2ad --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/face/view/ElectronicCompletionScreen.kt @@ -0,0 +1,187 @@ +package com.yzx.kiosk.ui.face.view + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft +import androidx.compose.material.icons.rounded.Call +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import com.yzx.kiosk.R +import com.yzx.kiosk.navigation.routes.AppRoutes +import com.yzx.kiosk.theme.AppTheme +import com.yzx.kiosk.ui.common.view.FullScreenMode +import com.yzx.kiosk.ui.face.viewmodel.ElectronicCompletionViewModel +import com.yzx.kiosk.utils.QrCodeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun ElectronicCompletionScreen( + orderNumber: String, + viewModel: ElectronicCompletionViewModel = hiltViewModel(), +) { + val countdown by viewModel.countdown.collectAsState() + val qrUrl by viewModel.qrCodeUrl.collectAsState() + val failed by viewModel.qrCodeFailed.collectAsState() + val hotline by viewModel.hotline.collectAsState() + LaunchedEffect(orderNumber) { viewModel.initialize(orderNumber) } + var renderAttempt by remember { mutableIntStateOf(0) } + val renderedQr by produceState>(null to false, qrUrl, renderAttempt) { + value = null to false + if (qrUrl.isNotBlank()) { + value = withContext(Dispatchers.Default) { + val result = runCatching { + QrCodeUtils.generateStyledQrBitmap( + content = qrUrl, size = 800, qrColor = android.graphics.Color.BLACK, + bgColor = android.graphics.Color.WHITE, cornerRadius = 0f, + ).asImageBitmap() + } + result.getOrNull() to result.isFailure + } + } + } + val returnHome = { viewModel.closeAllExcept(AppRoutes.HOME) } + BackHandler(onBack = returnHome) + FullScreenMode() + ElectronicCompletionContent( + bitmap = renderedQr.first, + failed = failed || renderedQr.second, + countdown = countdown, + hotline = hotline, + onRetry = { + renderAttempt += 1 + viewModel.retryQrCode() + }, + onReturnHome = returnHome, + ) +} + +/** Reference is 941 × 1672. Uniform density keeps typography and spacing in proportion + * on the portrait kiosk, without stretching the QR code on other display sizes. */ +@Composable +internal fun ElectronicCompletionContent( + bitmap: ImageBitmap?, + failed: Boolean, + countdown: Int, + hotline: String, + onRetry: () -> Unit, + onReturnHome: () -> Unit, +) { + val primary = MaterialTheme.colorScheme.primary + val resources = LocalContext.current.resources + // Only the decorative phone and footer are used from the artwork. The QR, copy, + // hotline, buttons and all panels below are native, live Compose content. + val artwork = remember(resources) { + val source = BitmapFactory.decodeResource(resources, R.drawable.electronic_completion_artwork) + val phone = Bitmap.createBitmap(source, 34, 354, 424, 635).asImageBitmap() + val footer = Bitmap.createBitmap(source, 0, 1440, 941, 232).asImageBitmap() + source.recycle() + phone to footer + } + BoxWithConstraints(Modifier.fillMaxSize().background(Color.White), contentAlignment = Alignment.Center) { + val density = LocalDensity.current + val scale = minOf(maxWidth.value / 941f, maxHeight.value / 1672f) + val canvasHeight = maxOf(1672f, maxHeight.value / scale) + CompositionLocalProvider(LocalDensity provides Density(density.density * scale, fontScale = 1f)) { + Box(Modifier.requiredSize(941.dp, canvasHeight.dp).background(Color.White)) { + Box(Modifier.fillMaxWidth().height(186.dp).background(primary)) { + IconButton(onClick = onReturnHome, modifier = Modifier.offset(20.dp, 91.dp).size(80.dp)) { + Icon(Icons.AutoMirrored.Rounded.KeyboardArrowLeft, "返回首页", tint = Color.White, modifier = Modifier.size(58.dp)) + } + Text("获取电子照片", Modifier.align(Alignment.BottomCenter).padding(bottom = 30.dp), + color = Color.White, fontSize = 42.sp, fontWeight = FontWeight.Bold) + Text("${countdown}秒后返回首页", Modifier.align(Alignment.TopEnd).padding(top = 28.dp, end = 36.dp), + color = Color.White.copy(alpha = .9f), fontSize = 23.sp) + } + Box(Modifier.offset(y = 186.dp).fillMaxWidth().height(138.dp).background(primary.copy(alpha = .055f)), + contentAlignment = Alignment.Center) { + Text("请使用微信扫描下方二维码,获取电子版照片/视频\n可多人多次扫描", + color = Color(0xFF55585C), fontSize = 29.sp, lineHeight = 46.sp, textAlign = TextAlign.Center) + } + Image(artwork.first, "电子照片保存到手机示意图", Modifier.offset(34.dp, 354.dp).size(424.dp, 635.dp), + contentScale = ContentScale.FillBounds) + Box(Modifier.offset(483.dp, 354.dp).size(424.dp, 635.dp) + .background(Color(0xFFF5F5F6), RoundedCornerShape(28.dp))) { + Box(Modifier.offset(27.dp, 34.dp).size(369.dp, 354.dp) + .background(Color.White, RoundedCornerShape(26.dp)), contentAlignment = Alignment.Center) { + when { + bitmap != null -> Image(bitmap, "电子版照片下载二维码", Modifier.size(340.dp), + filterQuality = FilterQuality.None) + failed -> Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("二维码加载失败", fontSize = 26.sp, color = Color(0xFF55585C)) + TextButton(onClick = onRetry) { Text("重新加载", fontSize = 26.sp) } + } + else -> CircularProgressIndicator(Modifier.size(52.dp), color = primary) + } + } + Text("请使用微信扫描", Modifier.offset(y = 410.dp).fillMaxWidth(), + color = Color(0xFF444444), fontSize = 29.sp, textAlign = TextAlign.Center) + Text("获取电子版照片/视频", Modifier.offset(y = 462.dp).fillMaxWidth(), + color = Color.Black, fontSize = 32.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + Text("可多人多次扫描", Modifier.offset(y = 530.dp).fillMaxWidth(), + color = Color(0xFF555555), fontSize = 29.sp, textAlign = TextAlign.Center) + } + Box(Modifier.offset(34.dp, 1020.dp).size(873.dp, 387.dp) + .background(primary.copy(alpha = .055f), RoundedCornerShape(28.dp))) { + Icon(Icons.Rounded.Info, null, Modifier.offset(39.dp, 37.dp).size(50.dp), tint = primary) + Text("温馨提示", Modifier.offset(109.dp, 35.dp), color = primary, fontSize = 40.sp, fontWeight = FontWeight.Bold) + TipRow(1, "请使用微信扫描二维码获取电子版照片 / 视频。", Modifier.offset(40.dp, 122.dp)) + TipRow(2, "照片和视频将以高清原图的形式提供,可多次下载。", Modifier.offset(40.dp, 186.dp)) + TipRow(3, "如遇问题,请联系客服。", Modifier.offset(40.dp, 250.dp)) + Row(Modifier.offset(109.dp, 312.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Call, null, Modifier.size(37.dp), tint = primary) + Spacer(Modifier.width(24.dp)) + Text("客服电话:${hotline.takeIf { it.isNotBlank() } ?: "暂无"}", + fontSize = 31.sp, color = primary, fontWeight = FontWeight.SemiBold) + } + } + Image(artwork.second, null, Modifier.align(Alignment.BottomCenter).size(941.dp, 232.dp), + contentScale = ContentScale.FillBounds) + } + } + } +} + +@Composable +private fun TipRow(number: Int, text: String, modifier: Modifier = Modifier) { + val primary = MaterialTheme.colorScheme.primary + Row(modifier, verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(46.dp).background(primary.copy(alpha = .1f), CircleShape), contentAlignment = Alignment.Center) { + Text(number.toString(), color = primary, fontSize = 30.sp) + } + Spacer(Modifier.width(23.dp)) + Text(text, color = Color(0xFF55585C), fontSize = 29.sp, lineHeight = 40.sp) + } +} + +@Preview(name = "电子版完成页", widthDp = 941, heightDp = 1672, showBackground = true) +@Composable +private fun ElectronicCompletionPreview() { + AppTheme { + ElectronicCompletionContent(null, false, 90, "12345678910", {}, {}) + } +} diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionResultScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionResultScreen.kt index 8e2a4a8..707342d 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionResultScreen.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionResultScreen.kt @@ -1,7 +1,10 @@ package com.yzx.kiosk.ui.face.view +import com.yzx.kiosk.utils.formatPriceDisplay import android.graphics.Bitmap import androidx.compose.foundation.ExperimentalFoundationApi +import com.yzx.kiosk.ui.face.viewmodel.PurchaseMode +import androidx.compose.ui.text.style.TextAlign import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -77,6 +80,12 @@ fun FaceRecognitionResultScreen( val selectedPhotos by viewModel.selectedPhotos.collectAsState() val pricePerPhoto by viewModel.pricePerPhoto.collectAsState() val totalPrice by viewModel.totalPrice.collectAsState() + val electronicPrice by viewModel.electronicPrice.collectAsState() + val electronicTotal by viewModel.electronicTotal.collectAsState() + val creatingOrder by viewModel.creatingOrder.collectAsState() + val quoteLoading by viewModel.quoteLoading.collectAsState() + val quoteError by viewModel.quoteError.collectAsState() + val paymentAmount by viewModel.paymentAmount.collectAsState() val imageLoadStates by viewModel.imageLoadStates.collectAsState() var isOpeningRecentPhotos by remember { mutableStateOf(false) } @@ -139,62 +148,46 @@ fun FaceRecognitionResultScreen( shape = RoundedCornerShape(18.dp), colors = CardDefaults.cardColors(containerColor = Color(0xFFF4F4F4)) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "已选择", - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - color = Color.Black - ) - Text( - modifier = Modifier.padding(top = 4.dp), - text = selectedCount.toString(), - fontSize = 28.sp, - fontWeight = FontWeight.Bold, - color = Color(0xFF0073FF) - ) - Text( - text = "张", - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - color = Color.Black - ) - } - Spacer(modifier = Modifier.height(18.dp)) - Text( - text = "打印${pricePerPhoto}元/张", - fontSize = 21.sp, - color = Color(0xFF000000) - ) - } - - Button( - onClick = { - viewModel.getPayUrl { url -> - showPayDialog = true + Column(Modifier.fillMaxWidth().padding(24.dp)) { + Text("已选择 ${selectedCount} 张", fontSize = 24.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(16.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) { + PurchaseMode.entries.forEach { mode -> + val isPrint = mode == PurchaseMode.PRINT + val unit = if (isPrint) pricePerPhoto else electronicPrice + val total = if (isPrint) totalPrice else electronicTotal + Column(Modifier.weight(1f)) { + Text("${mode.label} ${formatPriceDisplay(unit)}元/张", fontSize = 21.sp) + Spacer(Modifier.height(12.dp)) + Button( + onClick = { viewModel.getPayUrl(mode) { showPayDialog = true } }, + enabled = selectedCount > 0 && total != "--" && !creatingOrder && !quoteLoading, + modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp), + shape = RoundedCornerShape(18.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (isPrint) Color(0xFF0073FF) else Color(0xFFE3EFFF), + contentColor = if (isPrint) Color.White else Color(0xFF1756A9), + ), + ) { + Text( + text = if (!isPrint && total == "--" && !quoteLoading) + "电子版暂不可购买" else "支付${mode.label}\n${formatPriceDisplay(total)}元", + textAlign = TextAlign.Center, + fontSize = 24.sp, + lineHeight = 32.sp, + fontWeight = FontWeight.Bold, + ) + } } - }, - modifier = Modifier - .height(72.dp) - .widthIn(min = 180.dp), - shape = RoundedCornerShape(18.dp), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073FF)), - enabled = selectedCount > 0 - ) { - Text( - text = if (selectedCount > 0) "支付${totalPrice}元" else "支付0元", - fontSize = 24.sp, - color = Color.White, - fontWeight = FontWeight.Bold - ) + } + } + if (quoteLoading) { + Text("正在获取价格…", modifier = Modifier.padding(top = 12.dp)) + } else if (quoteError != null || totalPrice == "--") { + Text(quoteError ?: "打印价格暂不可用", modifier = Modifier.padding(top = 12.dp)) + TextButton(onClick = { viewModel.retryQuote() }, enabled = !creatingOrder && !quoteLoading) { + Text("重新获取价格") + } } } } @@ -343,7 +336,7 @@ fun FaceRecognitionResultScreen( } // 支付弹框 - if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) { + if (showPayDialog && payQrCodeUrl != null) { DisposableEffect(payQrCodeUrl) { viewModel.startPayStatusPolling() onDispose { @@ -353,7 +346,7 @@ fun FaceRecognitionResultScreen( WechatPayDialog( qrCodeUrl = payQrCodeUrl!!, - totalPrice = totalPrice, + totalPrice = paymentAmount, onDismiss = { showPayDialog = false viewModel.onPayDialogDismissed() diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/ElectronicCompletionViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/ElectronicCompletionViewModel.kt new file mode 100644 index 0000000..2a81462 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/ElectronicCompletionViewModel.kt @@ -0,0 +1,80 @@ +package com.yzx.kiosk.ui.face.viewmodel + +import androidx.lifecycle.viewModelScope +import com.yzx.kiosk.base.BaseViewModel +import com.yzx.kiosk.datastore.AppState +import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.navigation.AppNavigator +import com.yzx.kiosk.navigation.routes.AppRoutes +import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest +import com.yzx.kiosk.network.repository.NetWorkRepository +import com.yzx.kiosk.utils.ToastUtils +import com.yzx.kiosk.utils.LogUtils +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** Download-only completion: deliberately has no printer or print-download dependencies. */ +@HiltViewModel +class ElectronicCompletionViewModel @Inject constructor( + navigator: AppNavigator, + appState: AppState, + appStoreDataSource: AppStoreDataSource, + private val netWorkRepository: NetWorkRepository, +) : BaseViewModel(navigator = navigator, appState = appState) { + private val _countdown = MutableStateFlow(90) + val countdown = _countdown.asStateFlow() + private val _qrCodeUrl = MutableStateFlow("") + val qrCodeUrl = _qrCodeUrl.asStateFlow() + private val _qrCodeFailed = MutableStateFlow(false) + val qrCodeFailed = _qrCodeFailed.asStateFlow() + val hotline = MutableStateFlow(appStoreDataSource.getHomepageServicePhone()).asStateFlow() + private var orderNumber: String? = null + private var qrJob: Job? = null + + fun initialize(orderNumber: String) { + if (this.orderNumber != null) return + if (orderNumber.isBlank()) { + _qrCodeFailed.value = true + return + } + this.orderNumber = orderNumber + retryQrCode() + viewModelScope.launch { + while (_countdown.value > 0) { + delay(1_000) + _countdown.value -= 1 + } + navigator.closeAllExcept(AppRoutes.HOME) + } + } + + fun retryQrCode() { + val number = orderNumber ?: return + if (qrJob?.isActive == true) return + _qrCodeFailed.value = false + qrJob = viewModelScope.launch { + try { + val response = netWorkRepository.saveAlbumUrl(SaveAlbumUrlRequest(number)).first() + val url = response.data?.url?.trim() + if (response.isSucceeded && !url.isNullOrEmpty()) { + _qrCodeUrl.value = url + } else { + _qrCodeFailed.value = true + ToastUtils.show(response.message?.takeIf { it.isNotBlank() } ?: "二维码加载失败,请重试") + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _qrCodeFailed.value = true + LogUtils.e("ElectronicCompletion", "获取二维码失败: ${e.message}") + } + } + } +} diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FacePayStatusDecision.kt b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FacePayStatusDecision.kt index 06a62b0..8b15111 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FacePayStatusDecision.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FacePayStatusDecision.kt @@ -10,8 +10,8 @@ internal enum class FacePayStatusDecision { internal fun decideFacePayStatus(orderStatus: Int?): FacePayStatusDecision = when (orderStatus) { 30 -> FacePayStatusDecision.COMPLETE_PAYMENT - 40, 50, 60 -> FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS - else -> FacePayStatusDecision.CONTINUE_POLLING + 10 -> FacePayStatusDecision.CONTINUE_POLLING + else -> FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS } internal fun isValidFacePaySuccessMessage( @@ -19,7 +19,7 @@ internal fun isValidFacePaySuccessMessage( message: PaySuccessMessageResponse? ): Boolean { val paymentData = message?.data ?: return false - return message.type == 5 && + return message.orderStatus == 30 && message.type == 5 && paymentData.orderNumber == expectedOrderNumber && paymentData.captureType == 2 && !paymentData.imageIds.isNullOrEmpty() diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FacePurchase.kt b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FacePurchase.kt new file mode 100644 index 0000000..eb2ee72 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FacePurchase.kt @@ -0,0 +1,60 @@ +package com.yzx.kiosk.ui.face.viewmodel + +import com.yzx.kiosk.network.model.response.GetPayUrlResponse +import java.math.BigDecimal +import java.math.RoundingMode + +enum class PurchaseMode(val wireValue: String, val label: String) { + PRINT("print", "打印版"), ELECTRONIC("electronic", "电子版") +} + +internal fun money(value: String?): String? { + if (value == null || !Regex("[0-9]+(?:\\.[0-9]{1,2})?").matches(value)) return null + return value.toBigDecimalOrNull()?.setScale(2, RoundingMode.UNNECESSARY)?.toPlainString() +} + +internal fun isPositiveMoney(value: String?): Boolean = + money(value)?.toBigDecimal()?.let { it > BigDecimal.ZERO } == true + +internal fun electronicQuoteAvailable(unit: String?, total: String?, hasPhotos: Boolean): Boolean = + isPositiveMoney(unit) && money(total) != null && (!hasPhotos || isPositiveMoney(total)) + +internal data class FacePaymentOrder( + val number: String, + val mode: PurchaseMode, + val imageIds: Set, + val amount: String, + val legacyPrint: Boolean, +) { + fun matches(number: String?, captureType: Int?, ids: List, purchaseMode: String?): Boolean = + this.number == number && captureType == 2 && ids.isNotEmpty() && + ids.size == ids.toSet().size && imageIds == ids.toSet() && + (purchaseMode == mode.wireValue || (legacyPrint && purchaseMode == null)) +} + +internal fun createFacePaymentOrder( + response: GetPayUrlResponse, + mode: PurchaseMode, + ids: List, + quotedAmount: String, +): FacePaymentOrder? { + val number = response.orderNumber?.trim()?.takeIf { it.isNotEmpty() } ?: return null + // Only an entirely old-style print response may omit the new order fields. + val legacy = mode == PurchaseMode.PRINT && response.purchaseMode == null && response.amount == null + if (!legacy && response.purchaseMode != mode.wireValue) return null + val amount = money(if (legacy) quotedAmount else response.amount) ?: return null + if (ids.isEmpty()) return null + val order = FacePaymentOrder(number, mode, ids.toSet(), amount, legacy) + if (mode == PurchaseMode.ELECTRONIC && !isPositiveMoney(amount)) return null + // The link represents a pending preselection, never proof of purchase. + if (!legacy && response.orderStatus != 10) return null + if (response.url.isNullOrBlank()) return null + return order +} + +/** Versioning also rejects responses from an old A selection after A -> B -> A. */ +internal class QuoteRevision { + private var revision = 0L + fun next(): Long = ++revision + fun isCurrent(candidate: Long): Boolean = revision == candidate +} diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt index 189173b..e529d34 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt @@ -12,7 +12,6 @@ import com.yzx.kiosk.network.model.response.FaceSearchResult import com.yzx.kiosk.network.service.FaceSearchService import com.yzx.kiosk.network.service.buildRecentPhotosUrl import com.yzx.kiosk.network.repository.NetWorkRepository -import com.yzx.kiosk.network.result.asResult import com.yzx.kiosk.ui.upload.viewmodel.FileMapData import com.yzx.kiosk.ui.upload.viewmodel.PhotoData import com.yzx.kiosk.utils.ToastUtils @@ -100,6 +99,24 @@ class FaceRecognitionResultViewModel @Inject constructor( private val _totalPrice = MutableStateFlow("--") val totalPrice: StateFlow = _totalPrice.asStateFlow() + private val _electronicPrice = MutableStateFlow("--") + val electronicPrice = _electronicPrice.asStateFlow() + private val _electronicTotal = MutableStateFlow("--") + val electronicTotal = _electronicTotal.asStateFlow() + private val _creatingOrder = MutableStateFlow(false) + val creatingOrder = _creatingOrder.asStateFlow() + private val _quoteLoading = MutableStateFlow(false) + val quoteLoading = _quoteLoading.asStateFlow() + private val _quoteError = MutableStateFlow(null) + val quoteError = _quoteError.asStateFlow() + private val _paymentAmount = MutableStateFlow("--") + val paymentAmount = _paymentAmount.asStateFlow() + private val _paymentMode = MutableStateFlow(PurchaseMode.PRINT) + val paymentMode = _paymentMode.asStateFlow() + private var activeOrder: FacePaymentOrder? = null + private val quoteRevision = QuoteRevision() + private var quoteJob: Job? = null + // 支付二维码URL private val _payQrCodeUrl = MutableStateFlow(null) val payQrCodeUrl: StateFlow = _payQrCodeUrl.asStateFlow() @@ -139,6 +156,7 @@ class FaceRecognitionResultViewModel @Inject constructor( orderNumber = event.orderNumber, captureType = event.captureType, imageIds = event.imageIds, + purchaseMode = event.purchaseMode, source = "WebSocket" ) } @@ -234,6 +252,7 @@ class FaceRecognitionResultViewModel @Inject constructor( * 切换单张图片选中状态 */ fun togglePhotoSelection(url: String) { + if (_creatingOrder.value) return val current = _selectedPhotos.value.toMutableSet() if (current.contains(url)) { current.remove(url) @@ -251,6 +270,7 @@ class FaceRecognitionResultViewModel @Inject constructor( * 切换全选状态 */ fun toggleSelectAll() { + if (_creatingOrder.value) return if (_photoList.value.isEmpty()) return val allUrls = _photoList.value.map { it.url }.toSet() val newSelected = if (_selectedPhotos.value.size == allUrls.size) { @@ -280,100 +300,109 @@ class FaceRecognitionResultViewModel @Inject constructor( * 验证结果接口 */ private fun verifyResult(imageIds: List) { - viewModelScope.launch { + clearActivePayment() + _quoteLoading.value = true + _quoteError.value = null + val revision = quoteRevision.next() + quoteJob?.cancel() + _pricePerPhoto.value = "--" + _totalPrice.value = "--" + _electronicPrice.value = "--" + _electronicTotal.value = "--" + quoteJob = viewModelScope.launch { try { - val request = VerifyResultRequest( - type = 2, - imageId = imageIds - ) - - handleResultWithData( - flow = netWorkRepository.verifyResult(request).asResult(), - showToast = false, - onData = { response -> - // 更新单价(使用接口返回的 price_image) - val priceImage = response.priceImage - if (!priceImage.isNullOrEmpty()) { - _pricePerPhoto.value = priceImage - } else { - _pricePerPhoto.value = "--" - } - - // 更新总价(使用接口返回的 amount) - val amount = response.amount - if (!amount.isNullOrEmpty()) { - _totalPrice.value = amount - } else { - _totalPrice.value = "--" - } - - LogUtils.d("FaceRecognitionResultViewModel", "验证结果成功 - 单价: $priceImage, 总价: $amount") - }, - onError = { msg, _ -> - LogUtils.e("FaceRecognitionResultViewModel", "验证结果失败: $msg") - // 接口失败时,保持当前值或设置为默认值 - if (imageIds.isEmpty()) { - _pricePerPhoto.value = "--" - _totalPrice.value = "--" - } - } - ) - } catch (e: Exception) { - LogUtils.e("FaceRecognitionResultViewModel", "验证结果异常: ${e.message}") - if (imageIds.isEmpty()) { - _pricePerPhoto.value = "--" - _totalPrice.value = "--" + val response = netWorkRepository.verifyResult(VerifyResultRequest(2, imageIds.distinct(), videoId = emptyList())).first() + if (!quoteRevision.isCurrent(revision)) return@launch + if (!response.isSucceeded) { + _quoteError.value = response.message?.takeIf { it.isNotBlank() } ?: "获取报价失败,请重试" + return@launch } + val data = response.data + if (data == null) { + _quoteError.value = "报价数据不完整,请重试" + return@launch + } + val printUnit = money(data.priceImage) + val printTotal = money(data.amount) + if (printUnit != null && printTotal != null) { + _pricePerPhoto.value = printUnit + _totalPrice.value = printTotal + } + val electronicUnit = money(data.priceElectronic) + val electronicTotal = money(data.amountElectronic) + if (electronicQuoteAvailable(electronicUnit, electronicTotal, imageIds.isNotEmpty())) { + _electronicPrice.value = checkNotNull(electronicUnit) + _electronicTotal.value = checkNotNull(electronicTotal) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LogUtils.e(TAG, "获取报价失败: ${e.message}") + if (quoteRevision.isCurrent(revision)) _quoteError.value = "网络异常,请重新获取价格" + } finally { + if (quoteRevision.isCurrent(revision)) _quoteLoading.value = false } } } - /** - * 获取支付URL - */ - fun getPayUrl(onSuccess: (String) -> Unit) { + private fun clearActivePayment() { + stopPayStatusPolling() + activeOrder = null + activePaymentOrderNumber = null + _payQrCodeUrl.value = null + _dismissPayDialogEvents.tryEmit(Unit) + } + + private fun recoverPayment(message: String) { + clearActivePayment() + ToastUtils.show(message) + retryQuote() + } + + fun retryQuote() = verifyResult(getSelectedImageIds()) + + fun getPayUrl(mode: PurchaseMode, onSuccess: (String) -> Unit) { + if (_creatingOrder.value) return + val selectedIds = getSelectedImageIds().distinct() + val quotedAmount = if (mode == PurchaseMode.PRINT) _totalPrice.value else _electronicTotal.value + if (selectedIds.isEmpty() || money(quotedAmount) == null || _quoteLoading.value) return + if (mode == PurchaseMode.ELECTRONIC && !isPositiveMoney(quotedAmount)) return + clearActivePayment() + _creatingOrder.value = true viewModelScope.launch { - val selectedIds = getSelectedImageIds() - if (selectedIds.isEmpty()) { - ToastUtils.show("请选择要打印的照片") - return@launch - } showLoading() try { - val request = GetPayUrlRequest( - type = 2, - imageId = selectedIds - ) - - handleResultWithData( - flow = netWorkRepository.getPayUrl(request).asResult(), - showToast = false, - onData = { response -> - 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) - } - }, - onError = { msg, _ -> - LogUtils.e("FaceRecognitionResultViewModel", "获取支付URL失败: $msg") - ToastUtils.show("获取支付二维码失败") - }, - onEnd = { - hideLoading() - } - ) + val response = netWorkRepository.getPayUrl( + GetPayUrlRequest(2, selectedIds, mode.wireValue, videoId = emptyList()) + ).first() + if (!response.isSucceeded) { + recoverPayment(response.message?.takeIf { it.isNotBlank() } ?: "获取支付链接失败,请重新选择") + return@launch + } + val data = response.data + val order = if (data != null) + createFacePaymentOrder(data, mode, selectedIds, quotedAmount) else null + if (order == null) { + recoverPayment("支付链接信息无效,请重新选择") + return@launch + } + stopPayStatusPolling() + activeOrder = order + activePaymentOrderNumber = order.number + paymentHandled.set(false) + _paymentAmount.value = order.amount + _paymentMode.value = order.mode + val url = checkNotNull(data?.url).trim() + _payQrCodeUrl.value = url + onSuccess(url) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - LogUtils.e("FaceRecognitionResultViewModel", "获取支付URL异常: ${e.message}") - ToastUtils.show("获取支付二维码失败") + LogUtils.e(TAG, "创建订单失败: ${e.message}") + ToastUtils.show("创建订单失败,请重试") + } finally { + _creatingOrder.value = false + hideLoading() } } } @@ -420,14 +449,17 @@ class FaceRecognitionResultViewModel @Inject constructor( .getPaySuccessMessage(PaySuccessMessageRequest(orderNumber)) .first() + if (activePaymentOrderNumber != orderNumber || paymentHandled.get()) return if (!response.isSucceeded) { LogUtils.e( TAG, "查询支付状态业务失败 - order_number: $orderNumber, code: ${response.code}, msg: ${response.message}" ) + recoverPayment(response.message?.takeIf { it.isNotBlank() } ?: "订单已失效,请重新选择照片") return } + if (activePaymentOrderNumber != orderNumber) return val message = response.data when (decideFacePayStatus(message?.orderStatus)) { FacePayStatusDecision.CONTINUE_POLLING -> { @@ -448,6 +480,7 @@ class FaceRecognitionResultViewModel @Inject constructor( orderNumber = paymentData.orderNumber, captureType = paymentData.captureType, imageIds = paymentData.imageIds.orEmpty(), + purchaseMode = paymentData.purchaseMode, source = "HTTP" ) } @@ -470,16 +503,17 @@ class FaceRecognitionResultViewModel @Inject constructor( stopPayStatusPolling() activePaymentOrderNumber = null + activeOrder = null _payQrCodeUrl.value = null _dismissPayDialogEvents.tryEmit(Unit) val statusText = orderStatusName?.takeIf { it.isNotBlank() } ?: when (orderStatus) { 40 -> "已取消" 50 -> "已退款" - 60 -> "部分退款" else -> "状态异常" } ToastUtils.show("订单$statusText") + retryQuote() LogUtils.i(TAG, "订单进入终态,停止支付轮询 - status: $orderStatus, name: $statusText") } @@ -487,19 +521,13 @@ class FaceRecognitionResultViewModel @Inject constructor( orderNumber: String?, captureType: Int?, imageIds: List, + purchaseMode: String?, 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" - ) + val order = activeOrder ?: return + if (!order.matches(orderNumber, captureType, imageIds, purchaseMode)) { + LogUtils.i(TAG, "忽略不匹配的支付成功消息 - source: $source, expected: $expectedOrderNumber") return } @@ -518,16 +546,16 @@ class FaceRecognitionResultViewModel @Inject constructor( stopPayStatusPolling() _payQrCodeUrl.value = null _dismissPayDialogEvents.tryEmit(Unit) - navigateToPrinting(orderNumber, captureType, imageIds) + navigateAfterPurchase(orderNumber, captureType, imageIds, order) } /** - * 支付成功后直接跳转到打印页面 + * 按已确认订单的模式进入打印流程或独立电子版完成页 * @param orderNumber 订单号 * @param captureType 抓拍类型 * @param imageIds 图片ID数组(从支付成功消息中获取) */ - private fun navigateToPrinting(orderNumber: String?, captureType: Int?, imageIds: List) { + private fun navigateAfterPurchase(orderNumber: String?, captureType: Int?, imageIds: List, order: FacePaymentOrder) { viewModelScope.launch { if (orderNumber.isNullOrBlank() || captureType == null) { LogUtils.e( @@ -567,7 +595,11 @@ class FaceRecognitionResultViewModel @Inject constructor( .setPopUpTo(AppRoutes.FACE_RECOGNITION_RESULT, inclusive = true) .build() - val route = AppRoutes.buildPrintingRoute(jsonArray, orderNumber, captureType) + val route = if (order.mode == PurchaseMode.ELECTRONIC) { + AppRoutes.buildElectronicCompletionRoute(orderNumber) + } else { + AppRoutes.buildPrintingRoute(jsonArray, orderNumber, captureType) + } toPage(route, navOptions) } } diff --git a/app/src/main/java/com/yzx/kiosk/ui/upload/view/PhotoSelectScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/upload/view/PhotoSelectScreen.kt index f60a341..e2b4a5d 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/upload/view/PhotoSelectScreen.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/upload/view/PhotoSelectScreen.kt @@ -1,5 +1,6 @@ package com.yzx.kiosk.ui.upload.view +import com.yzx.kiosk.utils.formatPriceDisplay import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -150,7 +151,7 @@ fun PhotoSelectScreen( } Spacer(modifier = Modifier.height(18.dp)) Text( - text = "打印${pricePerPhoto}元/张", + text = "打印${formatPriceDisplay(pricePerPhoto)}元/张", fontSize = 21.sp, color = Color(0xFF000000) ) @@ -170,7 +171,7 @@ fun PhotoSelectScreen( enabled = selectedCount > 0 ) { Text( - text = if (selectedCount > 0) "支付${totalPrice}元" else "支付0元", + text = if (selectedCount > 0) "支付${formatPriceDisplay(totalPrice)}元" else "支付0元", fontSize = 24.sp, color = Color.White, fontWeight = FontWeight.Bold diff --git a/app/src/main/java/com/yzx/kiosk/ui/upload/view/UploadPhotoScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/upload/view/UploadPhotoScreen.kt index 4950291..82b294e 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/upload/view/UploadPhotoScreen.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/upload/view/UploadPhotoScreen.kt @@ -1,5 +1,6 @@ package com.yzx.kiosk.ui.upload.view +import com.yzx.kiosk.utils.formatPriceDisplay import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -259,7 +260,7 @@ fun UploadPhotoScreen( // 打印价格 ServiceInfoRow( label = "打印价格", - value = printPrice, + value = formatPriceDisplay(printPrice), valueColor = Color(0xFFEF4444) ) diff --git a/app/src/main/java/com/yzx/kiosk/utils/PriceDisplay.kt b/app/src/main/java/com/yzx/kiosk/utils/PriceDisplay.kt new file mode 100644 index 0000000..1efdc52 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/utils/PriceDisplay.kt @@ -0,0 +1,8 @@ +package com.yzx.kiosk.utils + +private val decimalPrice = Regex("[0-9]+\\.[0-9]+") + +/** Display only; also preserves units such as 元/张 and placeholders such as --. */ +fun formatPriceDisplay(value: String): String = decimalPrice.replace(value) { + it.value.toBigDecimal().stripTrailingZeros().toPlainString() +} diff --git a/app/src/main/java/com/yzx/kiosk/websocket/PurchaseCompletedMessage.kt b/app/src/main/java/com/yzx/kiosk/websocket/PurchaseCompletedMessage.kt new file mode 100644 index 0000000..8ff86ab --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/websocket/PurchaseCompletedMessage.kt @@ -0,0 +1,30 @@ +package com.yzx.kiosk.websocket + +import com.google.gson.JsonObject + +/** New gateway: code -> data(type=5) -> data(order). Older gateways used a flat data object. */ +internal fun parsePurchaseCompletedEvent(message: JsonObject): UploadPhotoEvent.PaySuccess? = runCatching { + if (message.get("code")?.asString != "5") return null + val envelope = message.getAsJsonObject("data") ?: return null + val order = if (envelope.has("data")) { + if (envelope.get("type")?.asInt != 5) return null + envelope.getAsJsonObject("data") ?: return null + } else { + if (envelope.has("type") && envelope.get("type")?.asInt != 5) return null + envelope + } + val number = order.get("order_number")?.takeUnless { it.isJsonNull }?.asString + ?.takeIf { it.isNotBlank() } ?: return null + val captureType = order.get("capture_type")?.asInt ?: return null + if (captureType !in listOf(1, 2)) return null + val imageIds = order.get("image_id")?.takeUnless { it.isJsonNull } + ?.asJsonArray?.map { it.asInt }.orEmpty() + if (captureType == 2 && imageIds.isEmpty()) return null + UploadPhotoEvent.PaySuccess( + orderNumber = number, + captureType = captureType, + imageIds = imageIds, + fileMap = order.get("file_map")?.takeUnless { it.isJsonNull }?.toString().orEmpty(), + purchaseMode = order.get("purchase_mode")?.takeUnless { it.isJsonNull }?.asString, + ) +}.getOrNull() diff --git a/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt b/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt index cd87c51..116c748 100644 --- a/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt +++ b/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt @@ -390,46 +390,13 @@ class WebSocketService @Inject constructor( } } "5" -> { - // 支付成功消息 - LogUtils.d(TAG, ">>> 收到支付成功消息 (code=5) <<<") - try { - val dataElement = jsonObject.get("data") - if (dataElement != null && dataElement.isJsonObject) { - val dataObj = dataElement.asJsonObject - val orderNumber = dataObj.get("order_number")?.asString - val captureType = dataObj.get("capture_type")?.asInt - - val imageIdList = mutableListOf() - if (dataObj.has("image_id")) { - val imageIdArray = dataObj.get("image_id") - if (imageIdArray != null && imageIdArray.isJsonArray) { - imageIdArray.asJsonArray.forEach { element -> - element.asInt.let { imageIdList.add(it) } - } - } - } - - var fileMap = "" - if (dataObj.has("file_map")){ - fileMap = dataObj.get("file_map").toString() - } - - LogUtils.d(TAG, "支付成功 - order_number: $orderNumber, capture_type: $captureType, image_id: $imageIdList,fileMap:$fileMap") - - // 发送支付成功事件 - CoroutineScope(Dispatchers.Main).launch { - _uploadPhotoEvents.emit( - UploadPhotoEvent.PaySuccess( - orderNumber = orderNumber, - captureType = captureType, - imageIds = imageIdList, - fileMap = fileMap - ) - ) - } + val event = parsePurchaseCompletedEvent(jsonObject) + if (event != null) { + CoroutineScope(Dispatchers.Main).launch { + _uploadPhotoEvents.emit(event) } - } catch (e: Exception) { - LogUtils.e(TAG, "解析支付成功消息失败: ${e.message}") + } else { + LogUtils.e(TAG, "忽略无效的购买完成消息") } } "4" -> { @@ -974,6 +941,7 @@ sealed class UploadPhotoEvent { val orderNumber: String?, val captureType: Int?, val fileMap:String, - val imageIds: List + val imageIds: List, + val purchaseMode: String? = null ) : UploadPhotoEvent() } diff --git a/app/src/main/res/drawable-nodpi/electronic_completion_artwork.png b/app/src/main/res/drawable-nodpi/electronic_completion_artwork.png new file mode 100644 index 0000000..e9cb0df Binary files /dev/null and b/app/src/main/res/drawable-nodpi/electronic_completion_artwork.png differ diff --git a/app/src/test/java/com/yzx/kiosk/audio/LocalAudioRouteTest.kt b/app/src/test/java/com/yzx/kiosk/audio/LocalAudioRouteTest.kt index a8ad038..043ca9b 100644 --- a/app/src/test/java/com/yzx/kiosk/audio/LocalAudioRouteTest.kt +++ b/app/src/test/java/com/yzx/kiosk/audio/LocalAudioRouteTest.kt @@ -5,6 +5,12 @@ import org.junit.Assert.assertEquals import org.junit.Test class LocalAudioRouteTest { + @Test fun `electronic completion suppresses printing audio while print routes retain it`() { + assertEquals(ELECTRONIC_COMPLETION_AUDIO_KEY, localAudioKeyForRoute(AppRoutes.buildElectronicCompletionRoute("ORDER"))) + assertEquals(AppRoutes.PRINTING, localAudioKeyForRoute(AppRoutes.buildPrintingRoute("[]", "ORDER", 1))) + assertEquals(AppRoutes.PRINTING, localAudioKeyForRoute("printing?urls=electronic%3Dtrue")) + } + @Test fun `browse recent photos selects neutral audio`() { assertEquals(RECENT_PHOTOS_AUDIO_KEY, localAudioKeyForRoute(AppRoutes.buildRecentPhotosRoute())) assertEquals(RECENT_PHOTOS_AUDIO_KEY, localAudioKeyForRoute("face_recognition_result?results=%5B%5D&source=recent")) diff --git a/app/src/test/java/com/yzx/kiosk/navigation/routes/AppRoutesTest.kt b/app/src/test/java/com/yzx/kiosk/navigation/routes/AppRoutesTest.kt index f98843a..da59c25 100644 --- a/app/src/test/java/com/yzx/kiosk/navigation/routes/AppRoutesTest.kt +++ b/app/src/test/java/com/yzx/kiosk/navigation/routes/AppRoutesTest.kt @@ -23,6 +23,14 @@ class AppRoutesTest { assertFalse(route.contains("token=a&size=4x6")) } + @Test + fun `electronic completion has an independent route with encoded order`() { + val paid = AppRoutes.buildElectronicCompletionRoute("ORDER +&1") + assertEquals("electronic_completion", paid.substringBefore("?")) + assertEquals("ORDER +&1", URLDecoder.decode(paid.substringAfter("orderNumber="), "UTF-8")) + assertFalse(AppRoutes.buildPrintingRoute("[]", "UPLOAD-1", 1).contains("electronic")) + } + @Test fun `printing route leaves missing order metadata empty`() { val route = AppRoutes.buildPrintingRoute("[]", null, null) diff --git a/app/src/test/java/com/yzx/kiosk/ui/face/viewmodel/FacePayStatusDecisionTest.kt b/app/src/test/java/com/yzx/kiosk/ui/face/viewmodel/FacePayStatusDecisionTest.kt index 081c4df..a59484f 100644 --- a/app/src/test/java/com/yzx/kiosk/ui/face/viewmodel/FacePayStatusDecisionTest.kt +++ b/app/src/test/java/com/yzx/kiosk/ui/face/viewmodel/FacePayStatusDecisionTest.kt @@ -12,8 +12,8 @@ import org.junit.Test class FacePayStatusDecisionTest { @Test - fun `pending and unknown statuses continue polling`() { - listOf(null, 10, 20, 0, 70).forEach { status -> + fun `only pending status continues polling`() { + listOf(10).forEach { status -> assertEquals( FacePayStatusDecision.CONTINUE_POLLING, decideFacePayStatus(status) @@ -31,7 +31,7 @@ class FacePayStatusDecisionTest { @Test fun `cancel and refund statuses stop polling`() { - listOf(40, 50, 60).forEach { status -> + listOf(null, 20, 0, 40, 50, 60, 70).forEach { status -> assertEquals( FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS, decideFacePayStatus(status) @@ -42,6 +42,7 @@ class FacePayStatusDecisionTest { @Test fun `valid face payment message is accepted`() { assertTrue(isValidFacePaySuccessMessage("ORDER-1", message())) + assertFalse(isValidFacePaySuccessMessage("ORDER-1", message().copy(orderStatus = 10))) } @Test diff --git a/app/src/test/java/com/yzx/kiosk/ui/face/viewmodel/FacePurchaseTest.kt b/app/src/test/java/com/yzx/kiosk/ui/face/viewmodel/FacePurchaseTest.kt new file mode 100644 index 0000000..1342aea --- /dev/null +++ b/app/src/test/java/com/yzx/kiosk/ui/face/viewmodel/FacePurchaseTest.kt @@ -0,0 +1,100 @@ +package com.yzx.kiosk.ui.face.viewmodel + +import com.google.gson.Gson +import com.yzx.kiosk.network.model.request.GetPayUrlRequest +import com.yzx.kiosk.network.model.response.GetPayUrlResponse +import com.yzx.kiosk.network.model.response.PaySuccessMessageResponse +import com.yzx.kiosk.network.model.response.VerifyResultResponse +import org.junit.Assert.* +import org.junit.Test + +class FacePurchaseTest { + private val ids = listOf(101, 102) + private fun response(mode: String? = "electronic", amount: String? = "2.00", status: Int? = 10, url: String? = "https://pay.example/order") = + GetPayUrlResponse(url, "ORDER-1", mode, amount, status) + + @Test fun `missing and malformed prices never become free`() { + listOf(null, "", "--", "-1.00", "NaN", "1e2", "1.001").forEach { assertNull(money(it)) } + assertEquals("0.00", money("0.00")) + assertEquals("3.00", money("3")) + } + + @Test fun `electronic price is independent of video price`() { + val old = Gson().fromJson("""{"price_image":"3.00","price_video":"0.00","amount":"6.00"}""", VerifyResultResponse::class.java) + assertNull(old.priceElectronic) + assertNull(old.amountElectronic) + val updated = Gson().fromJson("""{"price_image":"3.00","amount":"6.00","price_electronic":"1.00","amount_electronic":"2.00"}""", VerifyResultResponse::class.java) + assertEquals("1.00", updated.priceElectronic) + assertEquals("2.00", updated.amountElectronic) + } + + @Test fun `only latest quote survives A B A selection changes`() { + val revisions = QuoteRevision() + val firstA = revisions.next() + val b = revisions.next() + val secondA = revisions.next() + assertFalse(revisions.isCurrent(firstA)) + assertFalse(revisions.isCurrent(b)) + assertTrue(revisions.isCurrent(secondA)) + } + + @Test fun `old upload requests do not send purchase mode`() { + assertEquals("""{"type":1,"image_id":[101,102]}""", Gson().toJson(GetPayUrlRequest(1, ids))) + assertTrue(Gson().toJson(GetPayUrlRequest(2, ids, "electronic")).contains("\"purchase_mode\":\"electronic\"")) + } + + @Test fun `old backend supports printing but never electronic orders`() { + val old = GetPayUrlResponse("https://pay.example", "ORDER-1") + val print = createFacePaymentOrder(old, PurchaseMode.PRINT, ids, "6.00")!! + assertTrue(print.matches("ORDER-1", 2, ids, null)) + assertFalse(print.matches("ORDER-1", 2, ids, "electronic")) + assertNull(createFacePaymentOrder(old, PurchaseMode.ELECTRONIC, ids, "2.00")) + } + + @Test fun `actual amount comes from order rather than quote`() { + val order = createFacePaymentOrder(response(amount = "1.50"), PurchaseMode.ELECTRONIC, ids, "2.00")!! + assertEquals("1.50", order.amount) + } + + @Test fun `electronic cannot be free or bypass payment with a completed link response`() { + assertNull(createFacePaymentOrder(response(amount = "0.00"), PurchaseMode.ELECTRONIC, ids, "0.00")) + assertNull(createFacePaymentOrder(response(amount = "0.00", status = 30, url = null), PurchaseMode.ELECTRONIC, ids, "0.00")) + assertNull(createFacePaymentOrder(response(status = 30), PurchaseMode.ELECTRONIC, ids, "2.00")) + assertNull(createFacePaymentOrder(response(mode = "print"), PurchaseMode.ELECTRONIC, ids, "2.00")) + assertNull(createFacePaymentOrder(response(url = null), PurchaseMode.ELECTRONIC, ids, "2.00")) + assertNull(createFacePaymentOrder(response(amount = null), PurchaseMode.ELECTRONIC, ids, "2.00")) + } + + @Test fun `empty selection may display base price but selected electronic photos require positive total`() { + assertTrue(electronicQuoteAvailable("5.00", "0.00", false)) + assertFalse(electronicQuoteAvailable("5.00", "0.00", true)) + assertFalse(electronicQuoteAvailable("0.00", "0.00", false)) + assertFalse(electronicQuoteAvailable(null, null, true)) + assertTrue(electronicQuoteAvailable("3.50", "17.50", true)) + } + + @Test fun `face photo requests explicitly exclude videos without changing upload requests`() { + val json = Gson().toJson(GetPayUrlRequest(2, ids, "electronic", emptyList())) + assertTrue(json.contains("\"video_id\":[]")) + val quote = com.yzx.kiosk.network.model.request.VerifyResultRequest(2, ids, emptyList()) + assertTrue(Gson().toJson(quote).contains("\"video_id\":[]")) + assertEquals("""{"type":1,"image_id":[101,102]}""", Gson().toJson(com.yzx.kiosk.network.model.request.VerifyResultRequest(1, ids))) + } + + @Test fun `success must match exact order mode source and purchased photos`() { + val order = createFacePaymentOrder(response(), PurchaseMode.ELECTRONIC, ids, "2.00")!! + assertTrue(order.matches("ORDER-1", 2, ids.reversed(), "electronic")) + assertFalse(order.matches("OTHER", 2, ids, "electronic")) + assertFalse(order.matches("ORDER-1", 1, ids, "electronic")) + assertFalse(order.matches("ORDER-1", 2, ids, "print")) + assertFalse(order.matches("ORDER-1", 2, ids, null)) + assertFalse(order.matches("ORDER-1", 2, listOf(101), "electronic")) + assertFalse(order.matches("ORDER-1", 2, ids + 103, "electronic")) + assertFalse(order.matches("ORDER-1", 2, ids + 101, "electronic")) + } + + @Test fun `HTTP completion retains purchase mode`() { + val result = Gson().fromJson("""{"order_status":30,"type":5,"data":{"order_number":"ORDER-1","capture_type":2,"image_id":[101,102],"purchase_mode":"electronic"}}""", PaySuccessMessageResponse::class.java) + assertEquals("electronic", result.data?.purchaseMode) + } +} diff --git a/app/src/test/java/com/yzx/kiosk/websocket/PurchaseCompletedMessageTest.kt b/app/src/test/java/com/yzx/kiosk/websocket/PurchaseCompletedMessageTest.kt new file mode 100644 index 0000000..057fe7e --- /dev/null +++ b/app/src/test/java/com/yzx/kiosk/websocket/PurchaseCompletedMessageTest.kt @@ -0,0 +1,42 @@ +package com.yzx.kiosk.websocket + +import com.google.gson.JsonParser +import org.junit.Assert.* +import org.junit.Test + +class PurchaseCompletedMessageTest { + private fun parse(json: String) = parsePurchaseCompletedEvent(JsonParser.parseString(json).asJsonObject) + + @Test fun `backend nested gateway completion preserves order mode and photo IDs`() { + val result = parse("""{"code":5,"data":{"sn":"DEVICE","type":5,"data":{"order_number":"ORDER","capture_type":2,"image_id":[101,102],"purchase_mode":"electronic"}}}""")!! + assertEquals("ORDER", result.orderNumber) + assertEquals("electronic", result.purchaseMode) + assertEquals(listOf(101, 102), result.imageIds) + } + + @Test fun `legacy flat gateway still supports printing`() { + val result = parse("""{"code":"5","data":{"order_number":"ORDER","capture_type":2,"image_id":[101]}}""")!! + assertNull(result.purchaseMode) + assertEquals(listOf(101), result.imageIds) + } + + @Test fun `upload file map survives both gateway envelopes`() { + val order = """{"uuid":"UPLOAD","order_number":"ORDER","capture_type":1,"file_map":[{"id":101,"original_oss_url":"https://example.test/photo"}]}""" + val flat = parse("""{"code":5,"data":$order}""")!! + val nested = parse("""{"code":5,"data":{"type":5,"data":$order}}""")!! + assertEquals(flat, nested) + assertTrue(flat.fileMap.contains("original_oss_url")) + assertEquals(1, flat.captureType) + } + + @Test fun `non completion or malformed payload cannot trigger a purchase`() { + listOf( + """{"code":100000,"data":{"type":5,"data":{"order_number":"ORDER","capture_type":2,"image_id":[1]}}}""", + """{"code":5,"data":{"type":4,"data":{"order_number":"ORDER","capture_type":2,"image_id":[1]}}}""", + """{"code":5,"data":{"type":5,"data":null}}""", + """{"code":5,"data":{"order_number":"ORDER","capture_type":2,"image_id":[]}}""", + """{"code":5,"data":{"order_number":null,"capture_type":2,"image_id":[1]}}""", + """{"code":5,"data":[]}""", + ).forEach { assertNull(parse(it)) } + } +} diff --git a/docs/OSCAR_PHOTO_PURCHASE.md b/docs/OSCAR_PHOTO_PURCHASE.md new file mode 100644 index 0000000..d45dfc2 --- /dev/null +++ b/docs/OSCAR_PHOTO_PURCHASE.md @@ -0,0 +1,391 @@ +# Oscar 照片打印版 / 电子版购买对接 + +更新日期:2026-09-14。本文说明本次接口契约和人工联调标准;真实微信支付、设备打印、FaceBox 回调及相册下载仍需在部署后联调,示例不是线上验收记录。 + +## 1. 适用范围和业务规则 + +本次在大屏人脸照片流程 `type=2` 增加 `purchase_mode=electronic`。打印版 `print` 保持原有打印、套餐计价和相册权益;`type=1` 小程序上传的接口响应和业务流程保持旧版。`type=3` 小程序人脸流程本次不新增电子版购买。 + +| 参数 / 配置 | 约定 | +| --- | --- | +| `purchase_mode` | 仅接受 `print`、`electronic`,区分大小写;省略时默认 `print` | +| 显式空值或非法模式 | `null`、空字符串、其他字符串均报错,不能自动改成 `print` | +| `electronic` | 仅 `type=2`,只购买照片,不打印,不支持混入视频 | +| 电子版基础价 | `price_photo_digital`,未配置可为 `null`;后台新提交价格必须为 `0.01–99999.99` 元 | +| 电子版阶梯 | 独立开关和独立档位,只根据去重照片张数选取已达到的最大门槛,命中单价用于本单全部照片 | +| 免费与首张配置 | 电子版不使用 `free_digital_enabled`、`free_num`、`one_order_amount`,这些字段继续服务原打印逻辑 | +| 免费电子版 | 本次不支持;基础价缺失或旧数据非正数视为电子版不可购买,新档位也不能配置零价 | +| 用户身份 | 大屏仅代表设备;用户登录小程序扫码后创建实体订单,并以扫码用户为订单归属 | +| 金额 | 对外为元、两位小数字符串;数据库和预选缓存金额使用整数分;不可用价格用 JSON `null` | + +普通用户的付款金额采用获取支付链接时锁定的金额。现有内部员工/景区员工 **1 分钱优惠保留**,这是设备报价与实际付款金额一致性的明确例外;员工身份由服务端判断,不能由 App 上传金额或身份覆盖。 + +## 2. 鉴权与公共响应 + +设备接口前缀:`/api/oscar/order`。每次请求带设备鉴权头: + +```text +Content-Type: application/json +sn: <设备SN> +timestamp: <当前秒级时间戳> +token: +``` + +设备必须为已注册且已绑定景区的大屏设备,时间戳允许偏差为 1 小时。`api_token` 为设备已配置密钥,不作为请求体字段发送。照片素材、订单号和购买模式不能替代设备鉴权。 + +HTTP 成功响应的最外层 `code` 为 `100000`: + +```json +{ + "code": 100000, + "msg": "success", + "data": {}, + "time": "2026-09-14 12:00:00" +} +``` + +业务错误可能仍使用 HTTP 200,必须判断最外层 `code` 并显示 `msg`。不要把业务消息 `type=5` 或 WebSocket 的外层 `code=5` 当作 HTTP 成功码。下文未特别说明的响应示例仅展示 HTTP `data` 部分。 + +## 3. App 调用顺序 + +1. 选照片后调用 `verify-result`,同时展示打印版报价和可用的电子版报价;没有选择照片时仅展示单价,总额为零。 +2. 用户选择购买模式,调用 `get-pay-url`。使用该响应的金额展示支付二维码,保存其 `order_number` 与 `purchase_mode`。 +3. 用户登录小程序扫码支付。App 监听 WebSocket 购买完成消息,并可调用 `pay-success-message` 补查。 +4. 仅确认该订单 `order_status=30` 或收到匹配订单的购买完成推送后进入完成流程。`electronic` 展示相册二维码;`print` 继续原打印流程及相册领取流程。 +5. 电子版不发起打印、不扣纸、不调用 `print-notify` 或 `print-complete`。订单购买完成已取得电子版相册权益,文件转存允许稍后完成。 + +App 应按订单号防止 WebSocket 与 HTTP 重复结果触发重复打印、重复跳页。切换购买模式或照片集合后应重新获取支付链接,不能继续复用前一张二维码。 + +电子版独立完成页仅展示下载二维码和 90 秒返回首页倒计时。倒计时只控制 App 返回首页,不代表已购照片下载权限到期。 + +## 4. 报价:POST `/api/oscar/order/verify-result` + +请求: + +```json +{ + "type": 2, + "image_id": [101, 102, 103, 104, 105], + "video_id": [] +} +``` + +照片和视频 ID 均为整数数组;照片按去重数量参与计价。电子版只计照片,App 电子版路径保持 `video_id=[]`。此接口同时报价,不需要 `purchase_mode`。 + +示例:打印套餐单价 12 元、电子版基础价 5 元、电子版满 2 张单价 4 元、满 5 张单价 3.50 元,打印旧赠送和首张优惠关闭: + +```json +{ + "price_image": "12.00", + "price_video": "0.00", + "amount": "60.00", + "price_electronic": "3.50", + "amount_electronic": "17.50" +} +``` + +| 字段 | 含义 | +| --- | --- | +| `price_image` | 原打印流程照片单价;完整打印总额仍以 `amount` 为准,可能涉及原赠送/首张规则 | +| `price_video` | 原视频单价 | +| `amount` | 原打印流程总额 | +| `price_electronic` | 当前照片张数对应的电子版单价 | +| `amount_electronic` | 电子版照片单价 × 去重照片张数 | + +电子版未配置时,新增的两个字段都为 `null`,打印报价正常返回。App 显示电子版不可购买;不能把 `null` 转成零元、不能自动改成打印版购买。 + +```json +{ + "price_image": "12.00", + "price_video": "0.00", + "amount": "60.00", + "price_electronic": null, + "amount_electronic": null +} +``` + +空选 `image_id=[]`、`video_id=[]` 时,总额为 `"0.00"`,单价仍返回基础单价;如果电子版未配置,电子版两个字段继续为 `null`。空选可报价,但不能生成购买订单。 + +`type=1` 继续只返回 `price_image`、`price_video`、`amount` 三个旧字段,不新增电子版字段。 + +## 5. 获取支付链接:POST `/api/oscar/order/get-pay-url` + +请求: + +```json +{ + "type": 2, + "image_id": [101, 102, 103, 104, 105, 105], + "video_id": [], + "purchase_mode": "electronic" +} +``` + +响应: + +```json +{ + "url": "https://<业务域名>/scan/pay?order_number=<订单号>", + "order_number": "<订单号>", + "purchase_mode": "electronic", + "amount": "17.50", + "order_status": 10 +} +``` + +该接口生成预选缓存和订单号,**尚未创建实体订单,也不表示付款成功**。缓存有效期 24 小时,冻结设备 SN、`type`、去重照片/视频集合、购买模式、项目 ID、景区 ID、报价和整数分总额。 + +扫码后小程序沿用 `POST /api/mini/capture/scan-pay`,提交 `order_number`,由登录身份建单支付。扫码前后台改价不会重算本次已锁定的普通用户金额;项目已下线或设备绑定景区与锁定信息不一致时,拒绝建单,提示重新选择照片。缓存过期且尚未建单时同样重新选择。 + +同一订单重复扫码支付复用已有订单和金额,不允许改成其他购买者。完成后的重复扫码不能重复收款。 + +约束: + +- `image_id` 必须至少包含一张有效照片;重复 ID 去重。 +- 电子版价格不可用、总额非正数或超出可存储范围时,不返回支付链接。 +- 电子版带视频报错;`type=1/3` 请求电子版报错。 +- 省略 `purchase_mode` 等同 `print`;显式非法值报错。 +- `type=1` 响应仍只有旧字段 `url`、`order_number`,保持原扫码付款流程。 + +## 6. HTTP 补查:POST `/api/oscar/order/pay-success-message` + +请求: + +```json +{"order_number": "<订单号>"} +``` + +尚未扫码但有效的本设备 `type=2` 预选缓存也可以查询,返回待付款 `10`: + +```json +{ + "order_status": 10, + "order_status_name": "待付款", + "sn": "<设备SN>", + "type": 5, + "data": { + "order_number": "<订单号>", + "capture_type": 2, + "image_id": [101, 102, 103, 104, 105], + "purchase_mode": "electronic" + } +} +``` + +购买完成时结构相同,`order_status=30`、`order_status_name="已完成"`。`type=5` 是业务消息类别,待付款响应也带该值;**HTTP 补查必须检查 `order_status`,不能只看 `type=5` 就打印或开放下载**。 + +| 订单状态 | App 处理 | +| --- | --- | +| `10` 待付款 | 保持支付页面;可能只有缓存,也可能已建单等待支付 | +| `30` 已完成 | 按响应的 `purchase_mode` 分流到打印或相册领取 | +| `40` 已取消 / `50` 已退款 | 退出支付或完成等待,显示对应状态 | +| 其他状态 | 不当作本次照片购买成功;按服务端提示处理 | + +本次直接从待付款到完成,不新增状态。`60` 不是本次订单状态,不能用作免费领取、电子版完成或等待转存状态。未找到实体订单且缓存也失效时返回错误;其他设备的订单不可查询,也不能用旧缓存遮盖实体订单状态。 + +## 7. WebSocket 购买完成通知 + +沿用既有 Oscar WebSocket 连接和鉴权,外层消息码为 `code=5`,其中业务消息为 `type=5`。电子版完成消息示例: + +```json +{ + "code": 5, + "data": { + "sn": "<设备SN>", + "type": 5, + "data": { + "order_number": "<订单号>", + "capture_type": 2, + "image_id": [101, 102, 103, 104, 105], + "purchase_mode": "electronic" + } + } +} +``` + +外层沿用现有网关协议,示例只列相关字段。服务端在购买完成事务提交后发送消息;HTTP 和 WebSocket 使用同一个 `type=5` 业务数据构造方法,所以模式和照片集合一致。HTTP 的 `order_status` 属于补查外壳,不要求 WebSocket 业务载荷增加该字段。 + +`capture_type=1` 仍使用旧的 `uuid`、`file_map` 载荷;`capture_type=2` 使用 `image_id` 与新增 `purchase_mode`。收到其他订单、其他抓拍流程消息时,不应驱动当前页面。推送丢失或连接恢复后,通过 HTTP 补查恢复状态。 + +## 8. 相册二维码与文件准备 + +接口:`POST /api/oscar/order/save-album-url`。 + +```json +{"order_number": "<订单号>"} +``` + +响应: + +```json +{"url": "https://<业务域名>/scan/share?order_number=<订单号>"} +``` + +仅本设备已完成的照片订单可获取链接。电子版在购买完成时立即取得相册展示权益并发起 FaceBox 转存,**无需等待 `print-complete`**;打印版保持已有相册权益和扫码展示规则,不因本次新增模式减少原权益。 + +付款完成与文件转存完成是两个时刻。FaceBox 尚未回传原图时,相册可处于“上传中/准备中”;App 不应再次要求付费,不应把暂时没有素材当作未购买。 + +允许重复调用本接口:若此前转存请求没有成功受理,可重试;已受理的任务不会重复发起。成功取得二维码仅说明订单可领取,不保证所有原图已经准备完毕。已受理后长期未回调的任务仍需检查 FaceBox 状态,不能把反复取二维码等同于强制重建已受理任务。 + +FaceBox 成功回调以订单号和所购照片集合入库,重复回调不应产生重复素材。购买用户的订单与素材归属保持一致。 + +打印版继续调用原接口: + +- `POST /api/oscar/order/print-notify`:单张打印状态,字段为 `order_number`、`capture_type`、`image_id`、`print_status`,可附 `remaining_paper_num`。 +- `POST /api/oscar/order/print-complete`:打印流程结束上报,字段为 `order_number`、`capture_type`。 + +服务端校验设备归属和购买模式。电子版调用以上两个接口会报错,不能写打印记录或更新剩余纸张数。打印完成上报不承担购买授权或相册转存的触发职责。 + +## 9. 后台电子版配置 + +沿用 `POST /backend/project/add`、`POST /backend/project/edit`、`POST /backend/project/detail`。项目类型仍为 `22`,下列字段放在原有 `extra` 内;保存项目时同时提供既有接口要求的项目和打印配置。 + +```json +{ + "extra": { + "price_photo_digital": "5.00", + "multi_photo_digital_discount_enabled": 1, + "multi_photo_digital_prices": [ + {"min_photo_num": 2, "price_photo_digital": "4.00"}, + {"min_photo_num": 5, "price_photo_digital": "3.50"} + ] + } +} +``` + +| 字段 | 保存约束 | +| --- | --- | +| `price_photo_digital` | 可为 `null`,非空值为 `0.01–99999.99` 元,最多两位小数 | +| `multi_photo_digital_discount_enabled` | `0` 关闭、`1` 开启;默认关闭 | +| `multi_photo_digital_prices` | 独立电子版档位数组,不混用打印或套餐档位 | +| `min_photo_num` | 整数,至少 2,同一策略内不得重复 | +| 档位 `price_photo_digital` | `0.01–99999.99` 元,最多两位小数 | + +开启电子版优惠必须配置正数基础价和至少一个档位。未达到门槛使用基础价;达到多个门槛时使用最大门槛的单价。保存前按门槛排序,金额转为整数分存储。 + +编辑时未提交的基础价、旧赠送字段、首张金额或策略配置保留原值;关闭策略并提交空数组不会清除已存档位。已存电子优惠仍开启时,不能只把基础价清空;应同时关闭电子优惠。旧数据里的基础价 `0` 读取时按电子版未配置处理,新保存时显式提交 `0` 会被拒绝。 + +电子档位复用 `project_type_face_print_multi_price`:`price_type=1` 打印,`2` 套餐,`3` 电子版。项目保存后刷新该景区价格缓存;旧缓存没有电子档位数组时重新加载。平台获取配置时也会核对共享数据库中的基础配置版本,因此景区端改价、店铺新版本审核上线后,即使三端缓存前缀不同也会重新加载当前价格和项目。 + +### 后台价格试算 + +接口:`POST /backend/project/face-print-price-preview`,使用当前未保存配置。请求示例: + +```json +{ + "extra": { + "free_digital_enabled": 0, + "free_num": null, + "one_order_amount": null, + "price_photo_print": "8.00", + "price_photo_combo": "12.00", + "price_photo_digital": "5.00", + "multi_photo_print_discount_enabled": 0, + "multi_photo_print_prices": [], + "multi_photo_combo_discount_enabled": 0, + "multi_photo_combo_prices": [], + "multi_photo_digital_discount_enabled": 1, + "multi_photo_digital_prices": [ + {"min_photo_num": 2, "price_photo_digital": "4.00"}, + {"min_photo_num": 5, "price_photo_digital": "3.50"} + ] + }, + "page": 1, + "page_size": 2 +} +``` + +响应 `data`: + +```json +{ + "list": [ + { + "photo_num": 1, + "price_photo_print": "8.00", + "price_photo_combo": "12.00", + "amount_upload_print": "8.00", + "amount_face_print": "12.00", + "price_photo_digital": "5.00", + "amount_electronic": "5.00" + }, + { + "photo_num": 2, + "price_photo_print": "8.00", + "price_photo_combo": "12.00", + "amount_upload_print": "16.00", + "amount_face_print": "24.00", + "price_photo_digital": "4.00", + "amount_electronic": "8.00" + } + ], + "total": 6, + "page": 1, + "page_size": 2 +} +``` + +后台预览的电子单价字段叫 `price_photo_digital`,App 报价接口叫 `price_electronic`,二者不要混用。两处电子总额都叫 `amount_electronic`。未配置电子价时,预览电子单价和总额均为 `null`。 + +## 10. 旧版兼容与异常处理 + +| 场景 | 约定 | +| --- | --- | +| 旧 App 不传模式 | 按 `print` 处理,保持原打印权益 | +| 上线前生成、没有 `purchase_mode` 的预选缓存 | 按旧 `print` 流程建单;未锁定价格的旧缓存仍采用旧计价路径 | +| 已存在的旧订单 | 数据库新增字段默认 `print` | +| 明确标记 `electronic` 的新缓存损坏或过期 | 返回错误并重新选择,不降级为打印版或重新套用旧打印计价 | +| 同一笔支付的重复通知 | 幂等处理、不重复完成,素材回调不重复入库 | +| 付款完成但 WebSocket 通知失败 | 通过 HTTP 补查恢复;通知失败不能让已支付订单重新付款 | +| 转存请求失败 | 已购买权益保留,后续获取相册链接可重试尚未受理的上传 | +| 非本设备订单或抓拍类型不匹配 | 拒绝查询、取相册或打印上报 | + +对于服务端报错,App 显示服务端提示并恢复 loading。缓存失效、项目失效或模式价格不可用时,返回选片/报价步骤重新生成二维码,不复用旧金额。 + +## 11. SQL 与上线顺序 + +SQL 文件:[`20260914_face_print_electronic.sql`](../database/sql/20260914_face_print_electronic.sql)。此文件只供用户/数据库维护人员执行,开发助手未执行 SQL,也不执行 `php artisan migrate`。 + +平台、店铺、景区共用数据库。上线顺序: + +1. 对目标数据库只读检查表和列定义,确认已存在打印/套餐阶梯表;缺少既有阶梯表时先核对既有 `20260818_face_print_multi_price.sql` 的部署情况。 +2. 用户按新 SQL 中说明执行一次性新增列:`order.purchase_mode` 默认 `print`,`project_type_face_print.multi_photo_digital_discount_enabled` 默认 `0`;更新既有 `price_type` 注释支持 `3`。列已存在时跳过相应 `ADD COLUMN`,不要直接重跑整份脚本。 +3. 发布平台后端与后台配置页面,同时发布景区、店铺 API 的配置保留改动。景区编辑保留未提交配置;店铺编辑或启用项目创建新版本时复制原阶梯配置。按现有发布流程重启长驻服务并处理框架缓存。先有数据库列,再发布会写这些列的代码。 +4. 在后台配置正数电子版价格和需要的阶梯,保存后核对详情回显与试算。 + 小程序继续使用原扫码支付和相册下载接口;本次付费电子版已沿用这些能力,发布新版 App 前仍须核对小程序能显示实际支付金额及照片准备中状态。 +5. 用支持 `purchase_mode` 的 App 做下表人工联调,通过后再开放电子版入口;旧 App 可继续走默认打印。 +6. 回退应用版本时保留新增列和已有购买数据,先核对待支付/已购买电子订单的处理能力,避免旧代码把电子版订单当作打印订单。 + +## 12. 人工联调验收矩阵 + +以下均为待执行的联调项,不代表已经通过。 + +| 场景 | 操作 | 预期 | +| --- | --- | --- | +| 基础打印 | 不传模式获取二维码,普通用户付款 | 模式 `print`;金额和打印、相册权益与原流程一致 | +| 基础电子版 | 正数基础价,关闭电子阶梯,选 1 张付款 | 电子单价和总额为基础价;完成后不打印,能领取原图 | +| 电子阶梯边界 | 依次选门槛前、门槛上、门槛后张数 | 最大已达到门槛单价用于全部去重照片 | +| 独立计价 | 调整旧赠送张数和首张金额 | 打印保留旧规则;相同电子配置的电子版金额不变 | +| 重复照片 | 同一 ID 提交多次 | 报价、锁定金额、HTTP 与推送中的照片集合均按去重计算 | +| 空选 | 空数组报价,再请求支付链接 | 返回基础单价与零总额;禁止空选下单 | +| 未配置电子价 | 基础价设 `null`;另验证旧数据库值为 `0` 的读取 | 打印可用;电子两字段 `null`,电子支付链接报错 | +| 非法价格/档位 | 提交零价、负价、3 位小数、重复门槛、门槛 1 | 后台拒绝;未保存坏配置 | +| 开关与保留 | 关闭电子优惠并传空数组;编辑旧字段时省略电子配置 | 原电子档位保留;未提交字段不被清空 | +| 跨端保留 | 景区 API 编辑时省略电子价;店铺 API 编辑或启用并审核新版本 | 原电子基础价、开关和各类阶梯保留;平台新报价读取当前项目配置 | +| 非法模式 | 提交空、`null`、拼写错误模式 | 报错,不自动打印 | +| 电子混入视频 | `type=2`、电子模式携带视频 | 获取支付链接失败,不能发生打印或扣纸 | +| 锁价 | 取二维码后后台改价,普通用户扫码 | 本单仍用已返回金额;重新取码采用新价 | +| 员工优惠 | 使用符合原有规则的员工账号扫码 | 允许实际付款 0.01 元,记录为已知金额例外 | +| 未扫码补查 | 仅生成预选缓存后调用支付结果接口 | 返回 `10`,`type=5` 不能被误判为购买成功 | +| 过期/失效 | 缓存过期、项目下线、设备改绑景区后扫码 | 拒绝或提示重新选择,不重新解释为其他模式 | +| 双人/重复扫码 | 两个账号扫描同一订单、同人重复支付 | 已建单归属不可被抢占;复用业务订单与原金额,已完成订单不再发起支付 | +| WebSocket 丢失 | 付款时断开推送,再走 HTTP 补查 | 返回 `30`、正确模式和同一照片集合,只处理一次 | +| 上传暂不可用 | 付款时使 FaceBox 上传请求失败,再恢复并重复取相册链接 | 购买仍完成;可重试转存,准备完成后可下载 | +| 重复素材回调 | 重放相同成功回调,夹带未购买照片 ID | 不重复素材,未购买照片不进入本单相册 | +| 电子禁打印 | 对电子订单调用两个打印回调接口 | 拒绝,打印记录和纸张数量不变化 | +| 跨设备订单 | 用另一设备鉴权查询、取相册或上报打印 | 拒绝,不泄露或修改其他设备订单 | +| 旧版上传 | `type=1` 完整上传、报价、扫码、打印 | 旧响应字段与旧业务流程保持一致 | +| 小程序人脸购买 | `type=3` 购买照片、视频及纯视频 | 保持原计价与相册展示,不支持新增电子模式 | +| 旧缓存 | 使用上线前无模式的有效缓存扫码 | 继续原 `print` 流程;新电子缓存绝不走该降级路径 | + +验收记录应单独注明环境、设备、普通/员工账号、订单号、实付金额、HTTP/推送结果及实际下载结果。未完成真实支付、设备打印或相册下载时,应如实标记“未验证”,不能用语法检查、静态计算或示例响应替代端到端验收。 diff --git a/docs/verification/electronic-completion/README.md b/docs/verification/electronic-completion/README.md new file mode 100644 index 0000000..fd22412 --- /dev/null +++ b/docs/verification/electronic-completion/README.md @@ -0,0 +1,23 @@ +# 电子版完成页 UI 验证 + +参考尺寸:941 × 1672;主题色:`MaterialTheme.colorScheme.primary`(当前 `#0073FF`)。 + +- 原生 Compose 实现标题、说明、二维码卡片、提示列表、客服电话和返回按钮。 +- 手机示意图与底部山景来自参考图的蓝色配色素材;运行时仅裁取这两个装饰区域,素材中的示例二维码和客服电话不会显示。 +- 二维码继续使用订单接口返回的 URL;客服电话读取现有 `hotline` 配置。 +- 顶部使用现有 90 秒返回倒计时;返回箭头和系统返回键均回到首页。 +- 按参考比例等比布局,较长屏幕增加提示卡片与底部装饰之间的留白,不拉伸二维码。 + +## 验证 + +2026-09-15:`:app:compileDebugKotlin` 编译通过。华为 TAS-AL00 / Android 12 上两项 `ElectronicCompletionScreenTest` 通过: + +1. 从实际页面截图解码二维码,结果与传入测试 URL 一致;客服电话显示正确,返回回调触发。 +2. 失败状态显示重试按钮,点击可触发重试回调。 + +截图为独立 UI 测试,使用示例相册地址和客服电话,不涉及真实支付。 + +- `device.png`:手机长屏下的原生渲染。 +- `reference-ratio.png`:按参考图比例在手机上渲染,用于对比布局。 + +模拟器测试进程启动失败,本次验证以手机结果为准。 diff --git a/docs/verification/electronic-completion/device.png b/docs/verification/electronic-completion/device.png new file mode 100644 index 0000000..455703a Binary files /dev/null and b/docs/verification/electronic-completion/device.png differ diff --git a/docs/verification/electronic-completion/reference-ratio.png b/docs/verification/electronic-completion/reference-ratio.png new file mode 100644 index 0000000..e6da2b8 Binary files /dev/null and b/docs/verification/electronic-completion/reference-ratio.png differ diff --git a/docs/打印版与电子版接口修改说明.md b/docs/打印版与电子版接口修改说明.md new file mode 100644 index 0000000..11d96b3 --- /dev/null +++ b/docs/打印版与电子版接口修改说明.md @@ -0,0 +1,39 @@ +# 打印版 / 电子版:App 对接摘要 + +更新:2026-09-14。以后端交付的 [OSCAR_PHOTO_PURCHASE.md](OSCAR_PHOTO_PURCHASE.md) 为准。人脸识别及最近照片流程使用 `type=2`;手机上传 `type=1` 保持原有格式。原打印页面和打印 ViewModel 未改动。 + +## 已对齐的协议 + +- `verify-result`、`get-pay-url` 的人脸照片请求显式发送 `video_id=[]`,照片 ID 去重。 +- 报价分别使用 `price_image` / `amount`、`price_electronic` / `amount_electronic`。阶梯价格与总价以服务端为准,不在 App 计算。 +- **本次后端不支持免费电子版。** 电子版单价缺失、非正数或已选照片总额非正数时不可购买;空选可展示正数基础单价和零总额,但不能下单。 +- `get-pay-url` 返回预选缓存、订单号和锁定金额,尚未创建实体订单或完成付款。App 保存该响应的订单金额;微信支付弹窗保持原 UI,仅显示标题、二维码和原扫码提示,不额外显示购买模式或金额。服务端保留员工 1 分钱优惠,实付金额以小程序为准。 +- 新版支付链接要求 `purchase_mode` 匹配、金额有效、`order_status=10` 且 URL 非空。不能凭下单成功或 `type=5` 判定付款完成。 +- HTTP `pay-success-message` 只有 `order_status=30` 才按购买成功处理;`10` 继续等待,`40/50` 显示取消/退款,其他状态不作为成功并显示服务端状态。 +- WebSocket 新格式为 `code=5 → data(type=5) → data(订单信息)`,同时保留旧网关的扁平 `data` 解析。`type=1` 的 `file_map` 仍用于原上传打印流程。 +- 完成消息需匹配订单号、来源、照片集合及购买模式,重复 HTTP / WebSocket 消息只处理一次。 +- 选择照片或重新选择购买模式后清理旧支付二维码,重新取链接。业务错误显示服务端 `msg`;预选失效等查询错误关闭支付弹框、刷新报价,不继续使用旧链接。 + +## 完成与相册 + +```text +verify-result → get-pay-url → 用户扫码建单支付 + → HTTP 补查 / WebSocket 完成消息 + → print:原打印与相册流程 + → electronic:独立完成页 → save-album-url +``` + +电子版完成页仅显示二维码及 90 秒返回倒计时,失败可重试。电子版不调用打印机、不扣纸、不调用 `print-notify` / `print-complete`,也不播放打印语音。 + +取得相册二维码不代表原图已转存完成。相册准备中由小程序展示,App 不再次要求付款;倒计时不影响购买权益。 + +## 旧版本兼容 + +- 旧 App 未传 `purchase_mode`:新后端默认 `print`。 +- 新 App 遇到旧报价响应:保留打印,禁用缺失电子价的电子版。 +- 旧打印支付响应同时缺少模式和金额时,保留原支付 URL 与报价显示;这种订单的完成消息允许省略模式。 +- 电子版不得降级为打印版,新模式订单完成消息必须显式携带匹配模式。 + +## 验证边界 + +自动化验证使用本地网络拦截响应,覆盖正价购买、零价/未配置禁用、待付款不跳转、嵌套推送、重复/错误消息、预选失效、业务错误恢复及相册重试。真实微信支付、员工优惠、设备打印、FaceBox 转存和小程序下载仍需按后端文档进行部署后联调。