Author SHA1 Message Date
lujiuyin 6d81f1a28b feat: play supplied audio on electronic photo completion 2026-09-15 16:21:44 +08:00
lujiuyin 2231c38792 fix: load recent photo prices independently of photo results 2026-09-15 16:21:44 +08:00
lujiuyin fa827fdd99 fix: remove unsupported video copy from electronic completion 2026-09-15 11:50:58 +08:00
lujiuyin 5f8586666c fix: align photo unit prices and update purchase labels 2026-09-15 11:23:48 +08:00
lujiuyin b1cdc1fe06 feat: support electronic photo purchases and download completion 2026-09-15 10:51:38 +08:00
lujiuyin 712f6154bc chore: bump version to 1.1.4 2026-09-14 13:36:06 +08:00
lujiuyin 1d66bd18d1 feat: add recent photo browsing with source-aware titles and audio
Add recent-photo entry points to face recognition and its result page, preserve selection on back navigation, and reuse photo selection and payment flows. Switch face search to v3 with server defaults. Include cropped guidance audio, request and UI coverage, and verification artifacts.
2026-09-08 15:48:42 +08:00
lujiuyin 63944db6fb fix: optimize face recognition resource lifecycle 2026-08-25 13:42:09 +08:00
61 changed files with 3134 additions and 505 deletions
+2 -2
View File
@@ -89,8 +89,8 @@ android {
applicationId = "com.yzx.kiosk" applicationId = "com.yzx.kiosk"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 23 versionCode = 24
versionName = "1.1.3" versionName = "1.1.4"
//multiDexEnabled = true //multiDexEnabled = true
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -0,0 +1,75 @@
package com.yzx.kiosk.audio
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.test.platform.app.InstrumentationRegistry
import com.yzx.kiosk.R
import com.yzx.kiosk.navigation.routes.AppRoutes
import org.junit.Assert.*
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
class LocalAudioPlaybackTest {
@Test fun recentAndFaceEntriesSwitchAudioWithoutDuplicatePlayback() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
lateinit var service: LocalAudioPlayService
lateinit var player: ExoPlayer
val transitions = AtomicInteger()
instrumentation.runOnMainSync {
service = LocalAudioPlayService(instrumentation.targetContext)
player = LocalAudioPlayService::class.java.getDeclaredField("exoPlayer").apply { isAccessible = true }.get(service) as ExoPlayer
player.addListener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { transitions.incrementAndGet() }
})
}
try {
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.buildRecentPhotosRoute()) }
awaitPlaying(service)
assertEquals(R.raw.recent_photos_result_page, service.currentResId.value)
assertEquals(1, transitions.get())
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.buildRecentPhotosRoute()) }
instrumentation.waitForIdleSync()
assertEquals("Repeated recent entry must not reset the media item", 1, transitions.get())
instrumentation.runOnMainSync { service.playByRoute("${AppRoutes.FACE_RECOGNITION_RESULT}?results=%5B%5D&source=face") }
awaitPlaying(service)
assertEquals(R.raw.face_recognition_success_result_page, service.currentResId.value)
assertEquals(2, transitions.get())
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.FACE_RECOGNITION_RESULT) }
instrumentation.waitForIdleSync()
assertEquals("Legacy face route uses the same success clip", 2, transitions.get())
} finally {
instrumentation.runOnMainSync { service.release() }
}
}
@Test fun electronicCompletionSwitchesToDedicatedClipAndPreservesPrintAudio() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
lateinit var service: LocalAudioPlayService
instrumentation.runOnMainSync { service = LocalAudioPlayService(instrumentation.targetContext) }
try {
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.PRINT_SUCCESS) }
awaitPlaying(service)
assertEquals(R.raw.print_complete_page, service.currentResId.value)
instrumentation.runOnMainSync {
service.playByRoute(AppRoutes.buildElectronicCompletionRoute("AUDIO-TEST-ORDER"))
}
awaitPlaying(service)
assertEquals(R.raw.electronic_complete_page, service.currentResId.value)
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.PRINT_SUCCESS) }
awaitPlaying(service)
assertEquals(R.raw.print_complete_page, service.currentResId.value)
} finally {
instrumentation.runOnMainSync { service.release() }
}
}
private fun awaitPlaying(service: LocalAudioPlayService) {
val deadline = System.currentTimeMillis() + 5000
while (!service.isPlaying.value && System.currentTimeMillis() < deadline) Thread.sleep(20)
assertTrue("Local audio should enter playback", service.isPlaying.value)
}
}
@@ -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) }
}
}
@@ -0,0 +1,552 @@
package com.yzx.kiosk.ui.face
import android.graphics.Bitmap
import android.graphics.Color
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.ui.semantics.SemanticsProperties
import com.yzx.kiosk.network.model.response.FaceSearchResult
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.*
import androidx.navigation.navArgument
import androidx.test.platform.app.InstrumentationRegistry
import com.google.gson.Gson
import com.yzx.kiosk.audio.LocalAudioPlayService
import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.handleNavigationEvent
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.network.repository.NetWorkRepository
import com.yzx.kiosk.network.service.*
import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore
import com.yzx.kiosk.ui.face.resource.FaceThumbnailDecoder
import com.yzx.kiosk.ui.face.view.*
import com.yzx.kiosk.ui.face.viewmodel.*
import com.yzx.kiosk.websocket.WebSocketService
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.*
import org.junit.Assert.*
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.File
import java.util.concurrent.CopyOnWriteArrayList
/** All photo/order HTTP requests use an in-process interceptor; no orders or prints are submitted. */
class RecentPhotosFlowTest {
@get:Rule val compose = createComposeRule()
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
private lateinit var resultVm: FaceRecognitionResultViewModel
private lateinit var createResultVm: () -> FaceRecognitionResultViewModel
private val scopedResultVms = mutableListOf<FaceRecognitionResultViewModel>()
private lateinit var faceVm: FaceRecognitionViewModel
private lateinit var testSocket: WebSocketService
private lateinit var nav: AppNavigator
private var restoreConfig: (() -> Unit)? = null
private val requests = CopyOnWriteArrayList<Request>()
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":[]}"""
@Before fun setup() {
val app = context.applicationContext as com.yzx.kiosk.App
// Access the existing singleton without launching MainActivity (which connects live services).
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()
val oldSn = store.getBindBoxSn()
val oldToken = store.getBindBoxApiToken()
restoreConfig = {
store.saveBindBoxUrl(oldRemote)
store.saveBindBoxLanUrl(oldLan)
store.saveBindBoxSn(oldSn)
store.saveBindBoxApiToken(oldToken)
}
store.saveBindBoxUrl("https://example.test")
store.saveBindBoxLanUrl("https://example.test")
store.saveBindBoxSn("MOCK-BOX")
store.saveBindBoxApiToken("test-token")
val client = OkHttpClient.Builder().addInterceptor { chain ->
val req = chain.request()
requests.add(req)
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"
val extra = electronicAmount?.let { ",\"price_electronic\":\"$it\",\"amount_electronic\":\"${it.toBigDecimal().multiply(ids.size().toBigDecimal()).toPlainString()}\"" }.orEmpty()
"""{"code":100000,"data":{"price_image":"2.00","amount":"$amount"$extra}}"""
}
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)
.message("Mock").body(body.toResponseBody("application/json".toMediaType())).build()
}.build()
val retrofit = Retrofit.Builder().baseUrl("https://example.test/").client(client)
.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) }
resultVm = createResultVm()
faceVm = FaceRecognitionViewModel(nav, app.appState, app.appStoreDataSource, LocalAudioPlayService(context), service, FaceCaptureFileStore(context), FaceThumbnailDecoder())
}
}
@After fun cleanup() {
compose.runOnUiThread {
if (::resultVm.isInitialized) resultVm.viewModelScope.cancel()
if (::faceVm.isInitialized) faceVm.viewModelScope.cancel()
completionVm?.viewModelScope?.cancel()
scopedResultVms.forEach { it.viewModelScope.cancel() }
restoreConfig?.invoke()
}
}
private fun screenshot(name: String) {
val image = compose.onRoot().captureToImage().asAndroidBitmap()
File(context.getExternalFilesDir(null), "$name.png").outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) }
}
@Test fun resultToolbarPushesIndependentPageAndRestoresSelectionAndScroll() {
val bitmap = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
val faceResults = (1..30).map { id ->
val file = File(context.cacheDir, "toolbar-photo-$id.png")
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
val url = file.toURI().toString()
FaceSearchResult(id, url, url, url, url)
}
recentBody = Gson().toJson(mapOf("count" to 2, "results" to faceResults.takeLast(2)))
lateinit var controller: NavHostController
val factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
createResultVm().also { scopedResultVms.add(it) } as T
}
compose.setContent {
controller = rememberNavController()
LaunchedEffect(Unit) { nav.navigationEvents.collect { controller.handleNavigationEvent(it) } }
NavHost(controller, startDestination = AppRoutes.HOME) {
composable(AppRoutes.HOME) { Text("测试首页") }
composable(AppRoutes.FACE_RESULT_PATTERN, arguments = listOf(
navArgument("results") { type = NavType.StringType; defaultValue = "" },
navArgument("source") { type = NavType.StringType; defaultValue = AppRoutes.FACE_RESULT_SOURCE },
)) { entry ->
val vm: FaceRecognitionResultViewModel = viewModel(viewModelStoreOwner = entry, factory = factory)
val source = entry.arguments?.getString("source")!!
FaceRecognitionResultScreen(
results = if (source == AppRoutes.FACE_RESULT_SOURCE) faceResults else emptyList(),
source = source,
viewModel = vm,
)
}
}
}
compose.runOnIdle { controller.navigate(AppRoutes.FACE_RECOGNITION_RESULT) }
compose.waitUntil(10_000) { scopedResultVms.firstOrNull()?.photoList?.value?.size == 30 }
compose.onNodeWithText("人脸识别结果").assertIsDisplayed()
val originalVm = scopedResultVms.first()
compose.runOnIdle { originalVm.togglePhotoSelection(faceResults[0].thumbnailOssUrl!!) }
compose.waitUntil(10_000) { originalVm.totalPrice.value == "2.00" }
compose.onNodeWithText("查看最近照片").assertIsDisplayed().assertIsEnabled()
screenshot("face-results-browse-button")
val grid = compose.onNode(hasScrollToIndexAction())
grid.performScrollToIndex(12)
compose.waitForIdle()
val scrollBefore = grid.fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange].value()
val selectedBefore = originalVm.selectedPhotos.value
val totalBefore = originalVm.totalPrice.value
var originalEntryId = ""
compose.runOnIdle { originalEntryId = controller.currentBackStackEntry!!.id }
// Two taps in one event cycle must produce only one pushed page.
compose.onNodeWithText("查看最近照片").performTouchInput { doubleClick() }
compose.waitUntil(10_000) { scopedResultVms.size == 2 && scopedResultVms[1].recentPhotosState.value == RecentPhotosState.READY }
compose.onNodeWithText("最近照片").assertIsDisplayed()
val recentVm = scopedResultVms[1]
assertNotSame(originalVm, recentVm)
assertTrue(recentVm.selectedPhotos.value.isEmpty())
assertEquals(selectedBefore, originalVm.selectedPhotos.value)
compose.onNodeWithText("查看最近照片").assertDoesNotExist()
compose.runOnIdle {
assertEquals(originalEntryId, controller.previousBackStackEntry!!.id)
recentVm.toggleSelectAll()
}
compose.waitUntil(10_000) { recentVm.totalPrice.value == "4.00" }
val verifyRequestsBeforeReturn = requests.count { it.url.encodedPath.endsWith("verify-result") }
compose.runOnIdle { controller.popBackStack() }
compose.onNodeWithText("人脸识别结果").assertIsDisplayed()
compose.onNodeWithText("查看最近照片").assertIsDisplayed().assertIsEnabled()
compose.runOnIdle {
assertEquals(originalEntryId, controller.currentBackStackEntry!!.id)
assertEquals(selectedBefore, originalVm.selectedPhotos.value)
assertEquals(totalBefore, originalVm.totalPrice.value)
}
val scrollAfter = compose.onNode(hasScrollToIndexAction()).fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange].value()
assertEquals(scrollBefore, scrollAfter, 0.05f)
assertEquals(verifyRequestsBeforeReturn, requests.count { it.url.encodedPath.endsWith("verify-result") })
// Entry becomes available again after returning, and the next push gets fresh state.
compose.onNodeWithText("查看最近照片").performClick()
compose.waitUntil(10_000) { scopedResultVms.size == 3 && scopedResultVms[2].recentPhotosState.value == RecentPhotosState.READY }
assertTrue(scopedResultVms[2].selectedPhotos.value.isEmpty())
compose.onNodeWithText("查看最近照片").assertDoesNotExist()
assertEquals(2, requests.count { it.url.encodedPath.endsWith("/api/photos/recent") })
}
@Test fun failureButtonNavigatesOnceAndKeepsOriginalReturnPath() {
lateinit var controller: NavHostController
compose.setContent {
controller = rememberNavController()
LaunchedEffect(Unit) { nav.navigationEvents.collect { controller.handleNavigationEvent(it) } }
NavHost(controller, startDestination = AppRoutes.HOME) {
composable(AppRoutes.HOME) { Text("测试首页") }
composable(AppRoutes.FACE_RECOGNITION) { FaceRecognitionScreen(viewModel = faceVm) }
composable(AppRoutes.FACE_RESULT_PATTERN, arguments = listOf(
navArgument("results") { type = NavType.StringType; defaultValue = "" },
navArgument("source") { type = NavType.StringType; defaultValue = AppRoutes.FACE_RESULT_SOURCE },
)) { entry ->
FaceRecognitionResultScreen(source = entry.arguments?.getString("source")!!, viewModel = resultVm)
}
}
}
compose.runOnIdle { controller.navigate(AppRoutes.FACE_RECOGNITION) }
compose.onNodeWithText("找不到自己?查看最近照片").assertDoesNotExist()
compose.runOnIdle {
val field = FaceRecognitionViewModel::class.java.getDeclaredField("_recognitionStatus").apply { isAccessible = true }
@Suppress("UNCHECKED_CAST")
(field.get(faceVm) as MutableStateFlow<RecognitionStatus>).value = RecognitionStatus.FAILED
}
compose.onNodeWithText("找不到自己?查看最近照片").assertIsDisplayed()
screenshot("recent-photos-failure-button")
compose.onNodeWithText("找不到自己?查看最近照片").performClick()
compose.runOnIdle { faceVm.browseRecentPhotos() }
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
compose.onNodeWithText("暂无可显示的最近照片").assertIsDisplayed()
compose.runOnIdle {
assertEquals(AppRoutes.RECENT_RESULT_SOURCE, controller.currentBackStackEntry?.arguments?.getString("source"))
assertEquals(AppRoutes.HOME, controller.previousBackStackEntry?.destination?.route)
// Verify the legacy route still resolves with source=face.
controller.navigate("${AppRoutes.FACE_RECOGNITION_RESULT}?results=%5B%5D")
assertEquals(AppRoutes.FACE_RESULT_SOURCE, controller.currentBackStackEntry?.arguments?.getString("source"))
}
assertEquals(1, requests.count { it.url.encodedPath.endsWith("/api/photos/recent") })
}
@Test fun recentPhotosReuseSelectionPricingAndMockPaymentToPrintingRoute() {
val image = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
val file = File(context.cacheDir, "recent-photo-fixture.png")
file.outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) }
val url = file.toURI().toString()
val second = File(context.cacheDir, "recent-photo-second.png")
file.copyTo(second, overwrite = true)
val secondUrl = second.toURI().toString()
recentBody = """{"count":3,"results":[{"id":9,"thumbnail_oss_url":"$url","thumbnail_lan_url":"$url"},{"id":3,"thumbnail_oss_url":"$secondUrl","thumbnail_lan_url":"$secondUrl"},{"id":2,"thumbnail_oss_url":null,"thumbnail_lan_url":null}]}"""
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.READY }
assertEquals(listOf(9, 3), resultVm.photoList.value.map { it.id })
compose.onNodeWithText("全选").performClick()
compose.waitUntil(10_000) { resultVm.totalPrice.value == "4.00" }
assertEquals(2, resultVm.selectedPhotos.value.size)
compose.onNodeWithText("购买打印照片\n4元").assertIsEnabled()
screenshot("recent-photos-results")
compose.onAllNodesWithText("点击预览")[0].performClick()
compose.onNodeWithContentDescription("关闭").assertIsDisplayed().performClick()
compose.runOnIdle { resultVm.togglePhotoSelection(resultVm.photoList.value[0].url) }
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(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 }
assertEquals("MOCK-RECENT-1", field.get(resultVm))
// Inspect printing navigation only; never compose the real printing page.
var route: String? = null
val job = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).let { scope ->
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, 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?"))
assertTrue(route!!.contains("orderNumber=MOCK-RECENT-1"))
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<String>()
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<String>()
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<String>()
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()
// URL receipt precedes the background QR bitmap generation.
compose.waitUntil(10_000) {
compose.onAllNodesWithContentDescription("电子版照片下载二维码").fetchSemanticsNodes().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 emptyRecentPhotosStillShowConfiguredUnitPrices() {
electronicAmount = "1.00"
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
compose.waitForIdle()
assertTrue("Entering recent photos must request prices even when the photo list is empty",
requests.any { it.url.encodedPath.endsWith("verify-result") })
compose.waitUntil(10_000) { !resultVm.quoteLoading.value }
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
compose.onNodeWithText("购买打印照片\n0元").assertIsNotEnabled()
compose.onNodeWithText("购买电子照片\n0元").assertIsNotEnabled()
}
@Test fun recentPhotosSelectionUpdatesBothQuotesWithoutReloadReset() {
electronicAmount = "1.00"
val bitmap = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
val photos = (1..2).map { id ->
val file = File(context.cacheDir, "recent-price-$id.png")
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
val url = file.toURI().toString()
FaceSearchResult(id, url, url, url, url)
}
recentBody = Gson().toJson(mapOf("count" to 2, "results" to photos))
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.READY && !resultVm.quoteLoading.value }
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
compose.onNodeWithText("购买电子照片\n0元").assertIsNotEnabled()
assertEquals(1, requests.count { it.url.encodedPath.endsWith("verify-result") })
compose.runOnIdle { resultVm.toggleSelectAll() }
compose.waitUntil(10_000) { !resultVm.quoteLoading.value && resultVm.electronicTotal.value == "2.00" }
compose.onNodeWithText("购买打印照片\n4元").assertIsEnabled()
compose.onNodeWithText("购买电子照片\n2元").assertIsEnabled()
compose.runOnIdle { resultVm.loadRecentPhotos() }
compose.waitForIdle()
assertEquals("4.00", resultVm.totalPrice.value)
assertEquals("2.00", resultVm.electronicTotal.value)
assertEquals(2, requests.count { it.url.encodedPath.endsWith("verify-result") })
compose.runOnIdle { resultVm.toggleSelectAll() }
compose.waitUntil(10_000) { !resultVm.quoteLoading.value && resultVm.totalPrice.value == "0.00" }
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
compose.onNodeWithText("购买电子照片\n0元").assertIsNotEnabled()
}
@Test fun errorCanRetryIntoEmptyState() {
electronicAmount = "1.00"
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.waitUntil(10_000) { !resultVm.quoteLoading.value }
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
compose.onNodeWithText("购买打印照片\n0元").assertIsNotEnabled()
recentCode = 200
compose.onNodeWithText("重试").performClick()
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
compose.onNodeWithText("暂无可显示的最近照片").assertIsDisplayed()
assertEquals(2, requests.count { it.url.encodedPath.endsWith("/api/photos/recent") })
}
}
private fun kotlinx.coroutines.CoroutineScope.launchCollect(nav: AppNavigator, onRoute: (String) -> Unit) =
launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
nav.navigationEvents.collect { if (it is com.yzx.kiosk.navigation.NavigationEvent.NavigateTo) onRoute(it.route) }
}
@@ -0,0 +1,98 @@
package com.yzx.kiosk.ui.face.resource
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import androidx.exifinterface.media.ExifInterface
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class FaceThumbnailDecoderInstrumentedTest {
private lateinit var testDirectory: File
@Before
fun setUp() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
testDirectory = File(context.cacheDir, "face_thumbnail_test").apply {
deleteRecursively()
mkdirs()
}
}
@After
fun tearDown() {
testDirectory.deleteRecursively()
}
@Test
fun largeImagesAreBoundedAndAllExifOrientationsAreHandled() {
val orientations = listOf(
ExifInterface.ORIENTATION_NORMAL to false,
ExifInterface.ORIENTATION_FLIP_HORIZONTAL to false,
ExifInterface.ORIENTATION_ROTATE_180 to false,
ExifInterface.ORIENTATION_FLIP_VERTICAL to false,
ExifInterface.ORIENTATION_TRANSPOSE to true,
ExifInterface.ORIENTATION_ROTATE_90 to true,
ExifInterface.ORIENTATION_TRANSVERSE to true,
ExifInterface.ORIENTATION_ROTATE_270 to true,
)
val decoder = FaceThumbnailDecoder()
orientations.forEachIndexed { index, (orientation, swapsDimensions) ->
val imageFile = File(testDirectory, "orientation_$index.jpg")
createLargeJpeg(imageFile)
ExifInterface(imageFile).apply {
setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
saveAttributes()
}
val decoded = decoder.decode(imageFile.absolutePath)
assertNotNull(decoded)
decoded!!
assertFalse(decoded.isRecycled)
assertTrue(maxOf(decoded.width, decoded.height) <= FaceThumbnailDecoder.MAX_PREVIEW_EDGE_PX)
if (swapsDimensions) {
assertEquals(360, decoded.width)
assertEquals(720, decoded.height)
} else {
assertEquals(720, decoded.width)
assertEquals(360, decoded.height)
}
decoded.recycle()
}
}
@Test
fun captureFilesAreCreatedInOwnedDirectoryAndDeletedIdempotently() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val fileStore = FaceCaptureFileStore(context)
val captureFile = fileStore.createCaptureFile()
assertTrue(captureFile.exists())
assertEquals(FaceCaptureFileStore.CAPTURE_DIRECTORY, captureFile.parentFile?.name)
fileStore.delete(captureFile)
fileStore.delete(captureFile)
assertFalse(captureFile.exists())
}
private fun createLargeJpeg(file: File) {
val bitmap = Bitmap.createBitmap(1600, 800, Bitmap.Config.ARGB_8888)
Canvas(bitmap).drawColor(Color.MAGENTA)
file.outputStream().use { output ->
assertTrue(bitmap.compress(Bitmap.CompressFormat.JPEG, 95, output))
}
bitmap.recycle()
}
}
+14
View File
@@ -12,6 +12,7 @@ import com.luck.picture.lib.basic.PictureSelectorSupporterActivity
import com.luck.picture.lib.basic.PictureSelectorTransparentActivity import com.luck.picture.lib.basic.PictureSelectorTransparentActivity
import com.yzx.kiosk.datastore.AppState import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.datastore.AppStoreDataSource import com.yzx.kiosk.datastore.AppStoreDataSource
import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore
import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.LogUtils
import com.yzx.kiosk.utils.MMKVUtils import com.yzx.kiosk.utils.MMKVUtils
import com.yzx.kiosk.utils.NavigationBarUtil import com.yzx.kiosk.utils.NavigationBarUtil
@@ -42,6 +43,9 @@ class App : Application() {
@Inject @Inject
lateinit var appStoreDataSource: AppStoreDataSource lateinit var appStoreDataSource: AppStoreDataSource
@Inject
lateinit var faceCaptureFileStore: FaceCaptureFileStore
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
@@ -59,6 +63,16 @@ class App : Application() {
LogUtils.init(BuildConfig.DEBUG) LogUtils.init(BuildConfig.DEBUG)
MMKVUtils.init(this) MMKVUtils.init(this)
runCatching { faceCaptureFileStore.cleanupOrphans() }
.onSuccess { deletedFaceCaptures ->
if (deletedFaceCaptures > 0) {
LogUtils.i("App", "Cleaned $deletedFaceCaptures orphaned face capture file(s)")
}
}
.onFailure { error ->
LogUtils.e("App", "Failed to clean orphaned face captures: ${error.message}")
}
appState.initialize() appState.initialize()
initCoil() initCoil()
@@ -42,10 +42,12 @@ class LocalAudioPlayService @Inject constructor(
AppRoutes.HOME to R.raw.home_audio, AppRoutes.HOME to R.raw.home_audio,
AppRoutes.FACE_RECOGNITION to R.raw.face_recognition_page, AppRoutes.FACE_RECOGNITION to R.raw.face_recognition_page,
AppRoutes.FACE_RECOGNITION_RESULT to R.raw.face_recognition_success_result_page, AppRoutes.FACE_RECOGNITION_RESULT to R.raw.face_recognition_success_result_page,
RECENT_PHOTOS_AUDIO_KEY to R.raw.recent_photos_result_page,
AppRoutes.UPLOAD_PHOTO to R.raw.scan_qrcode_upload_page, AppRoutes.UPLOAD_PHOTO to R.raw.scan_qrcode_upload_page,
AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page, AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page,
AppRoutes.PRINTING to R.raw.printing_page, AppRoutes.PRINTING to R.raw.printing_page,
AppRoutes.PRINT_SUCCESS to R.raw.print_complete_page, AppRoutes.PRINT_SUCCESS to R.raw.print_complete_page,
ELECTRONIC_COMPLETION_AUDIO_KEY to R.raw.electronic_complete_page,
// 特殊状态页面 // 特殊状态页面
"face_recognition_recognizing" to R.raw.face_recognition_recognizing_page, "face_recognition_recognizing" to R.raw.face_recognition_recognizing_page,
"face_recognition_failed" to R.raw.face_recognition_failed_page, "face_recognition_failed" to R.raw.face_recognition_failed_page,
@@ -109,12 +111,12 @@ class LocalAudioPlayService @Inject constructor(
return return
} }
// 提取基础路由名称(去掉参数部分) // 同一结果页根据入口选择播报,避免最近照片误播识别成功。
val baseRoute = route.substringBefore("?") val audioKey = localAudioKeyForRoute(route)
val audioResId = ROUTE_TO_AUDIO_MAP[baseRoute] val audioResId = ROUTE_TO_AUDIO_MAP[audioKey]
if (audioResId == null) { if (audioResId == null) {
LogUtils.d(TAG, "路由 $baseRoute 没有对应的音频文件") LogUtils.d(TAG, "路由音频 $audioKey 没有对应的音频文件")
return return
} }
@@ -0,0 +1,24 @@
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"
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("#")
.split("&")
.firstOrNull { it.substringBefore("=") == "source" }
?.substringAfter("=", "")
?.let { runCatching { URLDecoder.decode(it, "UTF-8") }.getOrNull() }
return if (source == AppRoutes.RECENT_RESULT_SOURCE) RECENT_PHOTOS_AUDIO_KEY else baseRoute
}
@@ -5,6 +5,8 @@ import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.navigation.NavType
import androidx.navigation.navArgument
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@@ -140,7 +142,13 @@ fun AppNavHost(
} }
// 人脸识别结果页面 // 人脸识别结果页面
composable(route = "${AppRoutes.FACE_RECOGNITION_RESULT}?results={results}") { backStackEntry -> composable(
route = AppRoutes.FACE_RESULT_PATTERN,
arguments = listOf(
navArgument("results") { type = NavType.StringType; defaultValue = "" },
navArgument("source") { type = NavType.StringType; defaultValue = AppRoutes.FACE_RESULT_SOURCE },
),
) { backStackEntry ->
val resultsParam = backStackEntry.arguments?.getString("results") ?: "" val resultsParam = backStackEntry.arguments?.getString("results") ?: ""
val results = if (resultsParam.isNotEmpty()) { val results = if (resultsParam.isNotEmpty()) {
try { try {
@@ -153,7 +161,10 @@ fun AppNavHost(
} else { } else {
emptyList() emptyList()
} }
FaceRecognitionResultScreen(results = results) FaceRecognitionResultScreen(
results = results,
source = backStackEntry.arguments?.getString("source") ?: AppRoutes.FACE_RESULT_SOURCE,
)
} }
// 打印中页面 // 打印中页面
@@ -198,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() agreementScreen()
} }
@@ -8,10 +8,22 @@ object AppRoutes {
const val UPLOAD_PHOTO = "upload_photo" const val UPLOAD_PHOTO = "upload_photo"
const val PHOTO_SELECT = "photo_select" const val PHOTO_SELECT = "photo_select"
const val PRINTING = "printing" 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 PRINT_SUCCESS = "print_success"
const val FACE_RECOGNITION = "face_recognition" const val FACE_RECOGNITION = "face_recognition"
const val FACE_RECOGNITION_RESULT = "face_recognition_result" const val FACE_RECOGNITION_RESULT = "face_recognition_result"
const val FACE_RESULT_SOURCE = "face"
const val RECENT_RESULT_SOURCE = "recent"
const val FACE_RESULT_PATTERN = "$FACE_RECOGNITION_RESULT?results={results}&source={source}"
fun buildRecentPhotosRoute(): String = "$FACE_RECOGNITION_RESULT?source=$RECENT_RESULT_SOURCE"
const val AGREEMENT = "agreement" const val AGREEMENT = "agreement"
fun buildPrintingRoute( fun buildPrintingRoute(
@@ -28,8 +28,10 @@ object NetworkModule {
private const val CLIENT_DEFAULT = "defaultOkHttpClient" private const val CLIENT_DEFAULT = "defaultOkHttpClient"
const val CLIENT_UPLOAD = "uploadOkHttpClient" const val CLIENT_UPLOAD = "uploadOkHttpClient"
const val CLIENT_DOWNLOAD = "downloadOkHttpClient" const val CLIENT_DOWNLOAD = "downloadOkHttpClient"
const val CLIENT_FACE = "faceOkHttpClient"
const val RETROFIT_DEFAULT = "defaultRetrofit" const val RETROFIT_DEFAULT = "defaultRetrofit"
const val RETROFIT_UPLOAD = "uploadRetrofit" const val RETROFIT_UPLOAD = "uploadRetrofit"
const val RETROFIT_FACE = "faceRetrofit"
const val TRANSFRTVIEWMODEL = "transferViewModel" const val TRANSFRTVIEWMODEL = "transferViewModel"
@Provides @Provides
@@ -104,6 +106,17 @@ object NetworkModule {
} }
.build() .build()
@Provides
@Singleton
@Named(CLIENT_FACE)
fun provideFaceOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
}
}
.build()
private fun buildRetrofit( private fun buildRetrofit(
baseUrl: String, baseUrl: String,
okHttpClient: OkHttpClient, okHttpClient: OkHttpClient,
@@ -131,4 +144,13 @@ object NetworkModule {
gson: Gson, gson: Gson,
@Named(BASE_URL) baseUrl: String @Named(BASE_URL) baseUrl: String
): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson) ): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson)
@Provides
@Singleton
@Named(RETROFIT_FACE)
fun provideFaceRetrofit(
@Named(CLIENT_FACE) okHttpClient: OkHttpClient,
gson: Gson,
@Named(BASE_URL) baseUrl: String,
): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson)
} }
@@ -4,6 +4,7 @@ import android.content.Context
import com.yzx.kiosk.audio.AudioPlayService import com.yzx.kiosk.audio.AudioPlayService
import com.yzx.kiosk.network.service.NetworkService import com.yzx.kiosk.network.service.NetworkService
import com.yzx.kiosk.network.service.UploadService import com.yzx.kiosk.network.service.UploadService
import com.yzx.kiosk.network.service.FaceSearchService
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -11,6 +12,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_DEFAULT import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_DEFAULT
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_UPLOAD import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_UPLOAD
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_FACE
import retrofit2.Retrofit import retrofit2.Retrofit
import javax.inject.Named import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@@ -31,6 +33,12 @@ object ServiceModule {
@Named(RETROFIT_UPLOAD) retrofit: Retrofit @Named(RETROFIT_UPLOAD) retrofit: Retrofit
): UploadService = retrofit.create(UploadService::class.java) ): UploadService = retrofit.create(UploadService::class.java)
@Provides
@Singleton
fun provideFaceSearchService(
@Named(RETROFIT_FACE) retrofit: Retrofit,
): FaceSearchService = retrofit.create(FaceSearchService::class.java)
@Provides @Provides
@Singleton @Singleton
fun provideAudioPlayService( fun provideAudioPlayService(
@@ -6,6 +6,10 @@ data class GetPayUrlRequest(
@SerializedName("type") @SerializedName("type")
val type: Int, val type: Int,
@SerializedName("image_id") @SerializedName("image_id")
val imageId: List<Int> val imageId: List<Int>,
@SerializedName("purchase_mode")
val purchaseMode: String? = null,
@SerializedName("video_id")
val videoId: List<Int>? = null
) )
@@ -6,6 +6,8 @@ data class VerifyResultRequest(
@SerializedName("type") @SerializedName("type")
val type: Int, val type: Int,
@SerializedName("image_id") @SerializedName("image_id")
val imageId: List<Int> val imageId: List<Int>,
@SerializedName("video_id")
val videoId: List<Int>? = null
) )
@@ -7,6 +7,12 @@ data class GetPayUrlResponse(
val url: String?, val url: String?,
@SerializedName("order_number") @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
) )
@@ -27,5 +27,8 @@ data class PaySuccessMessageData(
val captureType: Int?, val captureType: Int?,
@SerializedName("image_id") @SerializedName("image_id")
val imageIds: List<Int>? val imageIds: List<Int>?,
@SerializedName("purchase_mode")
val purchaseMode: String? = null
) )
@@ -6,6 +6,10 @@ data class VerifyResultResponse(
@SerializedName("price_image") @SerializedName("price_image")
val priceImage: String?, val priceImage: String?,
@SerializedName("amount") @SerializedName("amount")
val amount: String? val amount: String?,
@SerializedName("price_electronic")
val priceElectronic: String? = null,
@SerializedName("amount_electronic")
val amountElectronic: String? = null
) )
@@ -4,17 +4,24 @@ import com.yzx.kiosk.network.model.response.FaceSearchResponse
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
import retrofit2.Response import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.Multipart import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Url
interface FaceSearchService { interface FaceSearchService {
@GET
suspend fun recentPhotos(
@Url url: String,
@Header("Authorization") authorization: String,
): Response<FaceSearchResponse>
@Multipart @Multipart
@POST("/{sn}/api/search") @POST
suspend fun searchFace( suspend fun searchFace(
@Path("sn") sn: String, @Url url: String,
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Part image: MultipartBody.Part, @Part image: MultipartBody.Part,
@Part("threshold") threshold: RequestBody?, @Part("threshold") threshold: RequestBody?,
@@ -0,0 +1,22 @@
package com.yzx.kiosk.network.service
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
internal fun buildFaceSearchUrl(baseUrl: String, deviceSn: String): String {
require(deviceSn.isNotBlank()) { "Face box SN is empty" }
val parsedBaseUrl = baseUrl.trim().toHttpUrlOrNull()
?: throw IllegalArgumentException("Invalid face search base URL")
return parsedBaseUrl.newBuilder()
// Preserve the /{sn}/api/v3/search endpoint semantics even if configuration
// accidentally contains a path or query string.
.encodedPath("/")
.query(null)
.fragment(null)
.addPathSegment(deviceSn)
.addPathSegment("api")
.addPathSegment("v3")
.addPathSegment("search")
.build()
.toString()
}
@@ -0,0 +1,21 @@
package com.yzx.kiosk.network.service
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
internal fun buildRecentPhotosUrl(baseUrl: String, deviceSn: String): String {
require(deviceSn.isNotBlank()) { "Face box SN is empty" }
val parsedBaseUrl = baseUrl.trim().toHttpUrlOrNull()
?: throw IllegalArgumentException("Invalid recent photos base URL")
// The recent-photos endpoint is unversioned and independent of the face-search API version.
return parsedBaseUrl.newBuilder()
.encodedPath("/")
.query(null)
.fragment(null)
.addPathSegment(deviceSn)
.addPathSegment("api")
.addPathSegment("photos")
.addPathSegment("recent")
.build()
.toString()
}
@@ -0,0 +1,19 @@
package com.yzx.kiosk.ui.face.resource
import java.util.concurrent.atomic.AtomicLong
internal class CaptureGate {
private val nextToken = AtomicLong(0L)
private val activeToken = AtomicLong(NO_TOKEN)
fun tryAcquire(): Long? {
val token = nextToken.incrementAndGet()
return if (activeToken.compareAndSet(NO_TOKEN, token)) token else null
}
fun release(token: Long): Boolean = activeToken.compareAndSet(token, NO_TOKEN)
companion object {
private const val NO_TOKEN = 0L
}
}
@@ -0,0 +1,65 @@
package com.yzx.kiosk.ui.face.resource
import android.content.Context
import com.yzx.kiosk.utils.LogUtils
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
/** Owns the short-lived files created for face-search requests. */
@Singleton
class FaceCaptureFileStore @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun createCaptureFile(): File {
val directory = File(context.cacheDir, CAPTURE_DIRECTORY)
check(directory.exists() || directory.mkdirs()) {
"Unable to create face capture cache directory"
}
return File.createTempFile(CAPTURE_PREFIX, CAPTURE_SUFFIX, directory)
}
fun delete(file: File?) {
if (file == null || !file.exists()) return
if (!file.delete()) {
LogUtils.e(TAG, "Failed to delete face capture file: ${file.name}")
}
}
/**
* A capture cannot remain in use across a process restart, so every owned file is orphaned
* when the application starts. Legacy root-cache captures are removed during migration too.
*/
fun cleanupOrphans(): Int = cleanupOrphans(context.cacheDir)
companion object {
private const val TAG = "FaceCaptureFileStore"
internal const val CAPTURE_DIRECTORY = "face_recognition"
internal const val CAPTURE_PREFIX = "capture_"
internal const val CAPTURE_SUFFIX = ".jpg"
internal const val LEGACY_PREFIX = "IMG_"
internal fun cleanupOrphans(cacheDirectory: File): Int {
var deletedCount = 0
val captureDirectory = File(cacheDirectory, CAPTURE_DIRECTORY)
captureDirectory.listFiles()?.forEach { file ->
if (file.deleteRecursively()) deletedCount++
}
cacheDirectory.listFiles()?.forEach { file ->
if (
file.isFile &&
file.name.startsWith(LEGACY_PREFIX) &&
file.name.endsWith(CAPTURE_SUFFIX, ignoreCase = true) &&
file.delete()
) {
deletedCount++
}
}
return deletedCount
}
}
}
@@ -0,0 +1,96 @@
package com.yzx.kiosk.ui.face.resource
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import com.yzx.kiosk.utils.LogUtils
import javax.inject.Inject
class FaceThumbnailDecoder @Inject constructor() {
fun decode(imagePath: String): Bitmap? {
var ownedBitmap: Bitmap? = null
return try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(imagePath, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val options = BitmapFactory.Options().apply {
inPreferredConfig = Bitmap.Config.ARGB_8888
inSampleSize = calculateInSampleSize(
bounds.outWidth,
bounds.outHeight,
MAX_PREVIEW_EDGE_PX,
)
}
ownedBitmap = BitmapFactory.decodeFile(imagePath, options) ?: return null
val orientation = ExifInterface(imagePath).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
val oriented = transformForExif(ownedBitmap, orientation)
if (oriented !== ownedBitmap) {
ownedBitmap.recycle()
ownedBitmap = oriented
}
val scaled = scaleDown(ownedBitmap, MAX_PREVIEW_EDGE_PX)
if (scaled !== ownedBitmap) {
ownedBitmap.recycle()
}
ownedBitmap = null
scaled
} catch (e: Exception) {
ownedBitmap?.let { if (!it.isRecycled) it.recycle() }
LogUtils.e(TAG, "Failed to decode face preview: ${e.message}")
null
}
}
private fun transformForExif(source: Bitmap, orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.setScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.setRotate(90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.setRotate(270f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(270f)
else -> return source
}
return Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true)
}
private fun scaleDown(source: Bitmap, maxEdgePx: Int): Bitmap {
val currentMaxEdge = maxOf(source.width, source.height)
if (currentMaxEdge <= maxEdgePx) return source
val scale = maxEdgePx.toFloat() / currentMaxEdge
val targetWidth = (source.width * scale).toInt().coerceAtLeast(1)
val targetHeight = (source.height * scale).toInt().coerceAtLeast(1)
return source.scale(targetWidth, targetHeight)
}
companion object {
private const val TAG = "FaceThumbnailDecoder"
const val MAX_PREVIEW_EDGE_PX = 720
internal fun calculateInSampleSize(width: Int, height: Int, maxEdgePx: Int): Int {
if (width <= 0 || height <= 0 || maxEdgePx <= 0) return 1
var sampleSize = 1
while (maxOf(width / (sampleSize * 2), height / (sampleSize * 2)) > maxEdgePx) {
sampleSize *= 2
}
return sampleSize
}
}
}
@@ -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<Pair<ImageBitmap?, Boolean>>(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", {}, {})
}
}
@@ -1,7 +1,10 @@
package com.yzx.kiosk.ui.face.view package com.yzx.kiosk.ui.face.view
import com.yzx.kiosk.utils.formatPriceDisplay
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.compose.foundation.ExperimentalFoundationApi 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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -26,6 +29,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -38,6 +44,8 @@ import coil.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent import coil.compose.SubcomposeAsyncImageContent
import coil.compose.SubcomposeAsyncImageScope import coil.compose.SubcomposeAsyncImageScope
import coil.request.ImageRequest import coil.request.ImageRequest
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.ui.face.viewmodel.RecentPhotosState
import com.yzx.kiosk.R import com.yzx.kiosk.R
import com.yzx.kiosk.component.appbar.AppTitleBar import com.yzx.kiosk.component.appbar.AppTitleBar
import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding
@@ -55,21 +63,41 @@ import kotlinx.coroutines.flow.collect
@Composable @Composable
fun FaceRecognitionResultScreen( fun FaceRecognitionResultScreen(
results: List<FaceSearchResult> = emptyList(), results: List<FaceSearchResult> = emptyList(),
source: String = AppRoutes.FACE_RESULT_SOURCE,
viewModel: FaceRecognitionResultViewModel = hiltViewModel() viewModel: FaceRecognitionResultViewModel = hiltViewModel()
) { ) {
// 如果传入了results列表,初始化图片列表 // 如果传入了results列表,初始化图片列表
LaunchedEffect(results) { LaunchedEffect(source, results) {
if (results.isNotEmpty()) { if (source == AppRoutes.RECENT_RESULT_SOURCE) {
viewModel.loadRecentPhotos()
} else if (results.isNotEmpty()) {
viewModel.initPhotoList(results) viewModel.initPhotoList(results)
} }
} }
val recentPhotosState by viewModel.recentPhotosState.collectAsState()
val photoList by viewModel.photoList.collectAsState() val photoList by viewModel.photoList.collectAsState()
val selectedPhotos by viewModel.selectedPhotos.collectAsState() val selectedPhotos by viewModel.selectedPhotos.collectAsState()
val pricePerPhoto by viewModel.pricePerPhoto.collectAsState() val pricePerPhoto by viewModel.pricePerPhoto.collectAsState()
val totalPrice by viewModel.totalPrice.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() val imageLoadStates by viewModel.imageLoadStates.collectAsState()
var isOpeningRecentPhotos by remember { mutableStateOf(false) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) isOpeningRecentPhotos = false
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
val selectedCount = selectedPhotos.size val selectedCount = selectedPhotos.size
val isAllSelected = photoList.isNotEmpty() && selectedPhotos.size == photoList.size val isAllSelected = photoList.isNotEmpty() && selectedPhotos.size == photoList.size
@@ -94,7 +122,7 @@ fun FaceRecognitionResultScreen(
Column { Column {
AppTitleBar( AppTitleBar(
backgroundColor = Color.White, backgroundColor = Color.White,
title = "人脸识别结果", title = if (source == AppRoutes.RECENT_RESULT_SOURCE) "最近照片" else "人脸识别结果",
isShowBackIcon = true, isShowBackIcon = true,
onBackClick = { viewModel.navigateBack() }, onBackClick = { viewModel.navigateBack() },
isShowButtonLine = false isShowButtonLine = false
@@ -120,73 +148,69 @@ fun FaceRecognitionResultScreen(
shape = RoundedCornerShape(18.dp), shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFF4F4F4)) colors = CardDefaults.cardColors(containerColor = Color(0xFFF4F4F4))
) { ) {
Column(Modifier.fillMaxWidth().padding(24.dp)) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() verticalAlignment = Alignment.CenterVertically,
.padding(24.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Column { Text("已选择 ${selectedCount} 张", fontSize = 24.sp, fontWeight = FontWeight.Bold)
Row(verticalAlignment = Alignment.CenterVertically) { Spacer(Modifier.weight(1f))
Text( Row(
text = "已选择", horizontalArrangement = Arrangement.spacedBy(20.dp),
fontSize = 24.sp, verticalAlignment = Alignment.CenterVertically,
fontWeight = FontWeight.Bold, ) {
color = Color.Black Text("打印版 ${formatPriceDisplay(pricePerPhoto)}元/张", fontSize = 21.sp)
) Text("电子版 ${formatPriceDisplay(electronicPrice)}元/张", fontSize = 21.sp)
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)
)
} }
Spacer(Modifier.height(16.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
PurchaseMode.entries.forEach { mode ->
val isPrint = mode == PurchaseMode.PRINT
val total = if (isPrint) totalPrice else electronicTotal
Column(Modifier.weight(1f)) {
Button( Button(
onClick = { onClick = { viewModel.getPayUrl(mode) { showPayDialog = true } },
viewModel.getPayUrl { url -> enabled = selectedCount > 0 && total != "--" && !creatingOrder && !quoteLoading,
showPayDialog = true modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp),
}
},
modifier = Modifier
.height(72.dp)
.widthIn(min = 180.dp),
shape = RoundedCornerShape(18.dp), shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073FF)), colors = ButtonDefaults.buttonColors(
enabled = selectedCount > 0 containerColor = if (isPrint) Color(0xFF0073FF) else Color(0xFFE3EFFF),
contentColor = if (isPrint) Color.White else Color(0xFF1756A9),
),
) { ) {
Text( Text(
text = if (selectedCount > 0) "支付${totalPrice}元" else "支付0元", text = if (!isPrint && total == "--" && !quoteLoading)
"电子版暂不可购买" else "${if (isPrint) "购买打印照片" else "购买电子照片"}\n${formatPriceDisplay(total)}元",
textAlign = TextAlign.Center,
fontSize = 24.sp, fontSize = 24.sp,
color = Color.White, lineHeight = 32.sp,
fontWeight = FontWeight.Bold 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("重新获取价格")
}
}
}
}
// 全选行 // Keep selection and navigation as separate click targets in the same toolbar.
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
.fillMaxWidth() verticalAlignment = Alignment.CenterVertically,
.padding(horizontal = 16.dp, vertical = 8.dp) ) {
.clickable { viewModel.toggleSelectAll() }, Row(
verticalAlignment = Alignment.CenterVertically modifier = Modifier.weight(1f).heightIn(min = 48.dp)
.clickable(enabled = photoList.isNotEmpty()) { viewModel.toggleSelectAll() },
verticalAlignment = Alignment.CenterVertically,
) { ) {
// 复选框 // 复选框
Box( Box(
@@ -224,6 +248,48 @@ fun FaceRecognitionResultScreen(
color = Color.Black color = Color.Black
) )
} }
if (source != AppRoutes.RECENT_RESULT_SOURCE) {
TextButton(
onClick = {
if (!isOpeningRecentPhotos) {
isOpeningRecentPhotos = true
viewModel.browseRecentPhotos()
}
},
enabled = !isOpeningRecentPhotos,
modifier = Modifier.heightIn(min = 48.dp),
contentPadding = PaddingValues(horizontal = 6.dp),
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFF2878CE)),
) {
Text("查看最近照片", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.width(8.dp))
Text("›", fontSize = 20.sp)
}
}
}
if (source == AppRoutes.RECENT_RESULT_SOURCE && recentPhotosState != RecentPhotosState.READY) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
if (recentPhotosState == RecentPhotosState.IDLE || recentPhotosState == RecentPhotosState.LOADING) {
CircularProgressIndicator(Modifier.size(32.dp))
Spacer(Modifier.height(16.dp))
}
Text(
text = when (recentPhotosState) {
RecentPhotosState.ERROR -> "照片加载失败,请重试"
RecentPhotosState.EMPTY -> "暂无可显示的最近照片"
else -> "正在加载照片…"
},
fontSize = 18.sp,
color = Color(0xFF666666),
)
if (recentPhotosState == RecentPhotosState.ERROR) {
TextButton(onClick = viewModel::retryRecentPhotos) { Text("重试", fontSize = 18.sp) }
}
}
}
}
// 图片瀑布流列表 // 图片瀑布流列表
LazyVerticalStaggeredGrid( LazyVerticalStaggeredGrid(
@@ -280,7 +346,7 @@ fun FaceRecognitionResultScreen(
} }
// 支付弹框 // 支付弹框
if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) { if (showPayDialog && payQrCodeUrl != null) {
DisposableEffect(payQrCodeUrl) { DisposableEffect(payQrCodeUrl) {
viewModel.startPayStatusPolling() viewModel.startPayStatusPolling()
onDispose { onDispose {
@@ -290,7 +356,7 @@ fun FaceRecognitionResultScreen(
WechatPayDialog( WechatPayDialog(
qrCodeUrl = payQrCodeUrl!!, qrCodeUrl = payQrCodeUrl!!,
totalPrice = totalPrice, totalPrice = paymentAmount,
onDismiss = { onDismiss = {
showPayDialog = false showPayDialog = false
viewModel.onPayDialogDismissed() viewModel.onPayDialogDismissed()
@@ -2,8 +2,6 @@ package com.yzx.kiosk.ui.face.view
import android.Manifest import android.Manifest
import android.graphics.Bitmap import android.graphics.Bitmap
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.camera.core.* import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
@@ -39,9 +37,6 @@ import com.yzx.kiosk.component.scaffold.AppScaffold
import com.yzx.kiosk.ui.common.view.FullScreenMode import com.yzx.kiosk.ui.common.view.FullScreenMode
import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionViewModel import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionViewModel
import com.yzx.kiosk.ui.face.viewmodel.RecognitionStatus import com.yzx.kiosk.ui.face.viewmodel.RecognitionStatus
import java.util.concurrent.Executor
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@Composable @Composable
@@ -56,6 +51,7 @@ fun FaceRecognitionScreen(
val recognitionStatus by viewModel.recognitionStatus.collectAsState() val recognitionStatus by viewModel.recognitionStatus.collectAsState()
val capturedImage by viewModel.capturedImage.collectAsState() val capturedImage by viewModel.capturedImage.collectAsState()
val isRecognizing by viewModel.isRecognizing.collectAsState() val isRecognizing by viewModel.isRecognizing.collectAsState()
val isCaptureInProgress by viewModel.isCaptureInProgress.collectAsState()
// 相机权限 // 相机权限
val permissionsState = rememberMultiplePermissionsState( val permissionsState = rememberMultiplePermissionsState(
@@ -91,7 +87,8 @@ fun FaceRecognitionScreen(
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 48.dp, vertical = 36.dp) .padding(horizontal = 48.dp)
.padding(top = 36.dp)
.aspectRatio(0.92f), .aspectRatio(0.92f),
shape = RoundedCornerShape(36.dp), shape = RoundedCornerShape(36.dp),
colors = CardDefaults.cardColors(containerColor = Color.Black) colors = CardDefaults.cardColors(containerColor = Color.Black)
@@ -103,6 +100,9 @@ fun FaceRecognitionScreen(
onImageCaptureReady = { imageCapture -> onImageCaptureReady = { imageCapture ->
viewModel.setImageCapture(imageCapture) viewModel.setImageCapture(imageCapture)
}, },
onImageCaptureReleased = { imageCapture ->
viewModel.clearImageCapture(imageCapture)
},
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@@ -201,7 +201,7 @@ fun FaceRecognitionScreen(
} }
} }
// 拍照按钮(5秒倒计时期间也显示,识别失败时显示"重新拍照",其他情况显示"拍照") // 拍照按钮(5秒倒计时期间也显示,识别失败时显示"重新拍照",其他情况显示"拍照")
if (!isRecognizing) { if (!isRecognizing && !isCaptureInProgress) {
Button( Button(
onClick = { onClick = {
if (recognitionStatus == RecognitionStatus.FAILED) { if (recognitionStatus == RecognitionStatus.FAILED) {
@@ -234,7 +234,22 @@ fun FaceRecognitionScreen(
} }
} }
Spacer(modifier = Modifier.height(36.dp)) Box(
modifier = Modifier.fillMaxWidth().height(72.dp),
contentAlignment = Alignment.Center,
) {
if (recognitionStatus == RecognitionStatus.FAILED) {
TextButton(
onClick = viewModel::browseRecentPhotos,
modifier = Modifier.heightIn(min = 48.dp),
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFF2878CE)),
) {
Text("找不到自己?查看最近照片", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.width(8.dp))
Text("›", fontSize = 20.sp)
}
}
}
// 倒计时提示 // 倒计时提示
Text( Text(
@@ -309,50 +324,62 @@ fun FaceRecognitionScreen(
@Composable @Composable
fun CameraPreview( fun CameraPreview(
onImageCaptureReady: (ImageCapture) -> Unit, onImageCaptureReady: (ImageCapture) -> Unit,
onImageCaptureReleased: (ImageCapture) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val currentOnImageCaptureReady by rememberUpdatedState(onImageCaptureReady)
val currentOnImageCaptureReleased by rememberUpdatedState(onImageCaptureReleased)
AndroidView( val previewView = remember(context) { PreviewView(context) }
factory = { ctx -> val preview = remember { Preview.Builder().build() }
val previewView = PreviewView(ctx) val imageCapture = remember {
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) ImageCapture.Builder()
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
// 创建预览
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// 创建图像捕获
val imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build() .build()
}
// 通知ViewModel ImageCapture已准备好 AndroidView(
onImageCaptureReady(imageCapture) factory = { previewView },
modifier = modifier
// 绑定到生命周期
val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageCapture
) )
DisposableEffect(context, lifecycleOwner, preview, imageCapture) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
val executor = ContextCompat.getMainExecutor(context)
var cameraProvider: ProcessCameraProvider? = null
var disposed = false
preview.setSurfaceProvider(previewView.surfaceProvider)
cameraProviderFuture.addListener({
try {
val resolvedProvider = cameraProviderFuture.get()
if (disposed) {
resolvedProvider.unbind(preview, imageCapture)
return@addListener
}
cameraProvider = resolvedProvider
resolvedProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_FRONT_CAMERA,
preview,
imageCapture,
)
currentOnImageCaptureReady(imageCapture)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
}, ContextCompat.getMainExecutor(ctx)) }, executor)
previewView onDispose {
}, disposed = true
modifier = modifier currentOnImageCaptureReleased(imageCapture)
) preview.setSurfaceProvider(null)
cameraProvider?.unbind(preview, imageCapture)
}
}
} }
@@ -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}")
}
}
}
}
@@ -10,8 +10,8 @@ internal enum class FacePayStatusDecision {
internal fun decideFacePayStatus(orderStatus: Int?): FacePayStatusDecision = when (orderStatus) { internal fun decideFacePayStatus(orderStatus: Int?): FacePayStatusDecision = when (orderStatus) {
30 -> FacePayStatusDecision.COMPLETE_PAYMENT 30 -> FacePayStatusDecision.COMPLETE_PAYMENT
40, 50, 60 -> FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS 10 -> FacePayStatusDecision.CONTINUE_POLLING
else -> FacePayStatusDecision.CONTINUE_POLLING else -> FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS
} }
internal fun isValidFacePaySuccessMessage( internal fun isValidFacePaySuccessMessage(
@@ -19,7 +19,7 @@ internal fun isValidFacePaySuccessMessage(
message: PaySuccessMessageResponse? message: PaySuccessMessageResponse?
): Boolean { ): Boolean {
val paymentData = message?.data ?: return false val paymentData = message?.data ?: return false
return message.type == 5 && return message.orderStatus == 30 && message.type == 5 &&
paymentData.orderNumber == expectedOrderNumber && paymentData.orderNumber == expectedOrderNumber &&
paymentData.captureType == 2 && paymentData.captureType == 2 &&
!paymentData.imageIds.isNullOrEmpty() !paymentData.imageIds.isNullOrEmpty()
@@ -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<Int>,
val amount: String,
val legacyPrint: Boolean,
) {
fun matches(number: String?, captureType: Int?, ids: List<Int>, 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<Int>,
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
}
@@ -9,8 +9,9 @@ import com.yzx.kiosk.network.model.request.GetPayUrlRequest
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest 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.service.FaceSearchService
import com.yzx.kiosk.network.service.buildRecentPhotosUrl
import com.yzx.kiosk.network.repository.NetWorkRepository 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.FileMapData
import com.yzx.kiosk.ui.upload.viewmodel.PhotoData import com.yzx.kiosk.ui.upload.viewmodel.PhotoData
import com.yzx.kiosk.utils.ToastUtils import com.yzx.kiosk.utils.ToastUtils
@@ -43,12 +44,43 @@ class FaceRecognitionResultViewModel @Inject constructor(
navigator: AppNavigator, navigator: AppNavigator,
appState: AppState, appState: AppState,
private val netWorkRepository: NetWorkRepository, private val netWorkRepository: NetWorkRepository,
private val faceSearchService: FaceSearchService,
private val webSocketService: WebSocketService, private val webSocketService: WebSocketService,
private val appStoreDataSource: AppStoreDataSource, private val appStoreDataSource: AppStoreDataSource,
) : BaseViewModel( ) : BaseViewModel(
navigator = navigator, navigator = navigator,
appState = appState appState = appState
) { ) {
private val recentLoader = RecentPhotosLoader(viewModelScope) {
val baseUrl = if (appStoreDataSource.getUseLan()) {
appStoreDataSource.getBindBoxLanUrl()
} else {
appStoreDataSource.getBindBoxUrl()
}
val response = faceSearchService.recentPhotos(
buildRecentPhotosUrl(baseUrl, appStoreDataSource.getBindBoxSn()),
"Bearer ${appStoreDataSource.getBindBoxApiToken()}",
)
check(response.isSuccessful) { "Recent photos HTTP ${response.code()}" }
val body = checkNotNull(response.body()) { "Empty recent photos response" }
applyPhotoList(body.results.orEmpty())
_photoList.value.isNotEmpty()
}
val recentPhotosState = recentLoader.state
fun browseRecentPhotos() {
// Deliberately push even though the destination pattern is the same: the two pages
// need separate back-stack entries and ViewModels. Do not use launchSingleTop/popUpTo.
toPage(AppRoutes.buildRecentPhotosRoute())
}
fun loadRecentPhotos() {
// Pricing belongs to the page, not to a successful/non-empty photo response.
if (recentPhotosState.value == RecentPhotosState.IDLE) verifyResult(emptyList())
recentLoader.load()
}
fun retryRecentPhotos() = recentLoader.load(retry = true)
// 原始结果列表(用于获取图片id) // 原始结果列表(用于获取图片id)
private var originalResults: List<FaceSearchResult> = emptyList() private var originalResults: List<FaceSearchResult> = emptyList()
@@ -71,6 +103,24 @@ class FaceRecognitionResultViewModel @Inject constructor(
private val _totalPrice = MutableStateFlow<String>("--") private val _totalPrice = MutableStateFlow<String>("--")
val totalPrice: StateFlow<String> = _totalPrice.asStateFlow() val totalPrice: StateFlow<String> = _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<String?>(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 // 支付二维码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()
@@ -110,6 +160,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
orderNumber = event.orderNumber, orderNumber = event.orderNumber,
captureType = event.captureType, captureType = event.captureType,
imageIds = event.imageIds, imageIds = event.imageIds,
purchaseMode = event.purchaseMode,
source = "WebSocket" source = "WebSocket"
) )
} }
@@ -124,8 +175,17 @@ class FaceRecognitionResultViewModel @Inject constructor(
/** /**
* 初始化图片列表(从人脸识别结果) * 初始化图片列表(从人脸识别结果)
*/ */
private var faceResultsInitialized = false
fun initPhotoList(results: List<FaceSearchResult>) { fun initPhotoList(results: List<FaceSearchResult>) {
viewModelScope.launch { // Returning from a pushed recent-photos page must retain selection, pricing and image state.
if (faceResultsInitialized) return
faceResultsInitialized = true
verifyResult(emptyList())
viewModelScope.launch { applyPhotoList(results) }
}
private suspend fun applyPhotoList(results: List<FaceSearchResult>) {
// 保存原始结果列表 // 保存原始结果列表
originalResults = results originalResults = results
urlToIdMap.clear() urlToIdMap.clear()
@@ -153,18 +213,15 @@ class FaceRecognitionResultViewModel @Inject constructor(
LogUtils.i("FaceRecognitionResultViewModel", "没有有效的图片URL") LogUtils.i("FaceRecognitionResultViewModel", "没有有效的图片URL")
_photoList.value = emptyList() _photoList.value = emptyList()
_imageLoadStates.value = emptyMap() _imageLoadStates.value = emptyMap()
return@launch return
} }
_photoList.value = initialPhotos _photoList.value = initialPhotos
// 初始化加载状态 // 初始化加载状态
_imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() } _imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() }
// 页面初始化时调用验证接口(空列表,type: 2),获取默认价格
verifyResult(emptyList())
LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新") LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新")
} }
}
/** /**
* 使用网格中已经加载成功的 Drawable 尺寸更新宽高比。 * 使用网格中已经加载成功的 Drawable 尺寸更新宽高比。
@@ -198,6 +255,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
* 切换单张图片选中状态 * 切换单张图片选中状态
*/ */
fun togglePhotoSelection(url: String) { fun togglePhotoSelection(url: String) {
if (_creatingOrder.value) return
val current = _selectedPhotos.value.toMutableSet() val current = _selectedPhotos.value.toMutableSet()
if (current.contains(url)) { if (current.contains(url)) {
current.remove(url) current.remove(url)
@@ -215,6 +273,8 @@ class FaceRecognitionResultViewModel @Inject constructor(
* 切换全选状态 * 切换全选状态
*/ */
fun toggleSelectAll() { fun toggleSelectAll() {
if (_creatingOrder.value) return
if (_photoList.value.isEmpty()) return
val allUrls = _photoList.value.map { it.url }.toSet() val allUrls = _photoList.value.map { it.url }.toSet()
val newSelected = if (_selectedPhotos.value.size == allUrls.size) { val newSelected = if (_selectedPhotos.value.size == allUrls.size) {
// 当前全选,取消全选 // 当前全选,取消全选
@@ -243,100 +303,109 @@ class FaceRecognitionResultViewModel @Inject constructor(
* 验证结果接口 * 验证结果接口
*/ */
private fun verifyResult(imageIds: List<Int>) { private fun verifyResult(imageIds: List<Int>) {
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 { try {
val request = VerifyResultRequest( val response = netWorkRepository.verifyResult(VerifyResultRequest(2, imageIds.distinct(), videoId = emptyList())).first()
type = 2, if (!quoteRevision.isCurrent(revision)) return@launch
imageId = imageIds if (!response.isSucceeded) {
) _quoteError.value = response.message?.takeIf { it.isNotBlank() } ?: "获取报价失败,请重试"
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 = "--"
}
}
}
}
/**
* 获取支付URL
*/
fun getPayUrl(onSuccess: (String) -> Unit) {
viewModelScope.launch {
val selectedIds = getSelectedImageIds()
if (selectedIds.isEmpty()) {
ToastUtils.show("请选择要打印的照片")
return@launch 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
}
}
}
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 {
showLoading() showLoading()
try { try {
val request = GetPayUrlRequest( val response = netWorkRepository.getPayUrl(
type = 2, GetPayUrlRequest(2, selectedIds, mode.wireValue, videoId = emptyList())
imageId = selectedIds ).first()
) if (!response.isSucceeded) {
recoverPayment(response.message?.takeIf { it.isNotBlank() } ?: "获取支付链接失败,请重新选择")
handleResultWithData( return@launch
flow = netWorkRepository.getPayUrl(request).asResult(), }
showToast = false, val data = response.data
onData = { response -> val order = if (data != null)
val url = response.url?.trim() createFacePaymentOrder(data, mode, selectedIds, quotedAmount) else null
val orderNumber = response.orderNumber?.trim() if (order == null) {
if (url.isNullOrEmpty() || orderNumber.isNullOrEmpty()) { recoverPayment("支付链接信息无效,请重新选择")
_payQrCodeUrl.value = null return@launch
LogUtils.e(TAG, "获取支付二维码成功,但 url 或 order_number 为空") }
ToastUtils.show("获取支付二维码失败")
} else {
stopPayStatusPolling() stopPayStatusPolling()
activePaymentOrderNumber = orderNumber activeOrder = order
activePaymentOrderNumber = order.number
paymentHandled.set(false) paymentHandled.set(false)
_paymentAmount.value = order.amount
_paymentMode.value = order.mode
val url = checkNotNull(data?.url).trim()
_payQrCodeUrl.value = url _payQrCodeUrl.value = url
onSuccess(url) onSuccess(url)
} } catch (e: CancellationException) {
}, throw e
onError = { msg, _ ->
LogUtils.e("FaceRecognitionResultViewModel", "获取支付URL失败: $msg")
ToastUtils.show("获取支付二维码失败")
},
onEnd = {
hideLoading()
}
)
} catch (e: Exception) { } catch (e: Exception) {
LogUtils.e("FaceRecognitionResultViewModel", "获取支付URL异常: ${e.message}") LogUtils.e(TAG, "创建订单失败: ${e.message}")
ToastUtils.show("获取支付二维码失败") ToastUtils.show("创建订单失败,请重试")
} finally {
_creatingOrder.value = false
hideLoading()
} }
} }
} }
@@ -383,14 +452,17 @@ class FaceRecognitionResultViewModel @Inject constructor(
.getPaySuccessMessage(PaySuccessMessageRequest(orderNumber)) .getPaySuccessMessage(PaySuccessMessageRequest(orderNumber))
.first() .first()
if (activePaymentOrderNumber != orderNumber || paymentHandled.get()) return
if (!response.isSucceeded) { if (!response.isSucceeded) {
LogUtils.e( LogUtils.e(
TAG, TAG,
"查询支付状态业务失败 - order_number: $orderNumber, code: ${response.code}, msg: ${response.message}" "查询支付状态业务失败 - order_number: $orderNumber, code: ${response.code}, msg: ${response.message}"
) )
recoverPayment(response.message?.takeIf { it.isNotBlank() } ?: "订单已失效,请重新选择照片")
return return
} }
if (activePaymentOrderNumber != orderNumber) return
val message = response.data val message = response.data
when (decideFacePayStatus(message?.orderStatus)) { when (decideFacePayStatus(message?.orderStatus)) {
FacePayStatusDecision.CONTINUE_POLLING -> { FacePayStatusDecision.CONTINUE_POLLING -> {
@@ -411,6 +483,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
orderNumber = paymentData.orderNumber, orderNumber = paymentData.orderNumber,
captureType = paymentData.captureType, captureType = paymentData.captureType,
imageIds = paymentData.imageIds.orEmpty(), imageIds = paymentData.imageIds.orEmpty(),
purchaseMode = paymentData.purchaseMode,
source = "HTTP" source = "HTTP"
) )
} }
@@ -433,16 +506,17 @@ class FaceRecognitionResultViewModel @Inject constructor(
stopPayStatusPolling() stopPayStatusPolling()
activePaymentOrderNumber = null activePaymentOrderNumber = null
activeOrder = null
_payQrCodeUrl.value = null _payQrCodeUrl.value = null
_dismissPayDialogEvents.tryEmit(Unit) _dismissPayDialogEvents.tryEmit(Unit)
val statusText = orderStatusName?.takeIf { it.isNotBlank() } ?: when (orderStatus) { val statusText = orderStatusName?.takeIf { it.isNotBlank() } ?: when (orderStatus) {
40 -> "已取消" 40 -> "已取消"
50 -> "已退款" 50 -> "已退款"
60 -> "部分退款"
else -> "状态异常" else -> "状态异常"
} }
ToastUtils.show("订单$statusText") ToastUtils.show("订单$statusText")
retryQuote()
LogUtils.i(TAG, "订单进入终态,停止支付轮询 - status: $orderStatus, name: $statusText") LogUtils.i(TAG, "订单进入终态,停止支付轮询 - status: $orderStatus, name: $statusText")
} }
@@ -450,19 +524,13 @@ class FaceRecognitionResultViewModel @Inject constructor(
orderNumber: String?, orderNumber: String?,
captureType: Int?, captureType: Int?,
imageIds: List<Int>, imageIds: List<Int>,
purchaseMode: String?,
source: String source: String
) { ) {
val expectedOrderNumber = activePaymentOrderNumber val expectedOrderNumber = activePaymentOrderNumber
if ( val order = activeOrder ?: return
expectedOrderNumber.isNullOrEmpty() || if (!order.matches(orderNumber, captureType, imageIds, purchaseMode)) {
orderNumber != expectedOrderNumber || LogUtils.i(TAG, "忽略不匹配的支付成功消息 - source: $source, expected: $expectedOrderNumber")
captureType != 2 ||
imageIds.isEmpty()
) {
LogUtils.i(
TAG,
"忽略不匹配的支付成功消息 - source: $source, expected: $expectedOrderNumber, actual: $orderNumber, capture_type: $captureType, image_ids: $imageIds"
)
return return
} }
@@ -481,16 +549,16 @@ class FaceRecognitionResultViewModel @Inject constructor(
stopPayStatusPolling() stopPayStatusPolling()
_payQrCodeUrl.value = null _payQrCodeUrl.value = null
_dismissPayDialogEvents.tryEmit(Unit) _dismissPayDialogEvents.tryEmit(Unit)
navigateToPrinting(orderNumber, captureType, imageIds) navigateAfterPurchase(orderNumber, captureType, imageIds, order)
} }
/** /**
* 支付成功后直接跳转到打印页面 * 按已确认订单的模式进入打印流程或独立电子版完成页
* @param orderNumber 订单号 * @param orderNumber 订单号
* @param captureType 抓拍类型 * @param captureType 抓拍类型
* @param imageIds 图片ID数组(从支付成功消息中获取) * @param imageIds 图片ID数组(从支付成功消息中获取)
*/ */
private fun navigateToPrinting(orderNumber: String?, captureType: Int?, imageIds: List<Int>) { private fun navigateAfterPurchase(orderNumber: String?, captureType: Int?, imageIds: List<Int>, order: FacePaymentOrder) {
viewModelScope.launch { viewModelScope.launch {
if (orderNumber.isNullOrBlank() || captureType == null) { if (orderNumber.isNullOrBlank() || captureType == null) {
LogUtils.e( LogUtils.e(
@@ -530,7 +598,11 @@ class FaceRecognitionResultViewModel @Inject constructor(
.setPopUpTo(AppRoutes.FACE_RECOGNITION_RESULT, inclusive = true) .setPopUpTo(AppRoutes.FACE_RECOGNITION_RESULT, inclusive = true)
.build() .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) toPage(route, navOptions)
} }
} }
@@ -1,10 +1,7 @@
package com.yzx.kiosk.ui.face.viewmodel package com.yzx.kiosk.ui.face.viewmodel
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture
import androidx.exifinterface.media.ExifInterface
import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageCaptureException
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -16,12 +13,15 @@ import com.yzx.kiosk.datastore.AppStoreDataSource
import com.google.gson.Gson import com.google.gson.Gson
import com.yzx.kiosk.network.model.response.FaceSearchResponse import com.yzx.kiosk.network.model.response.FaceSearchResponse
import com.yzx.kiosk.network.service.FaceSearchService import com.yzx.kiosk.network.service.FaceSearchService
import com.yzx.kiosk.network.service.buildFaceSearchUrl
import com.yzx.kiosk.App import com.yzx.kiosk.App
import com.yzx.kiosk.BuildConfig import com.yzx.kiosk.BuildConfig
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
import com.yzx.kiosk.navigation.AppNavigator import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes
import com.yzx.kiosk.ui.face.resource.CaptureGate
import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore
import com.yzx.kiosk.ui.face.resource.FaceThumbnailDecoder
import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.LogUtils
import com.yzx.kiosk.utils.ToastUtils import com.yzx.kiosk.utils.ToastUtils
import java.net.URLEncoder import java.net.URLEncoder
@@ -36,15 +36,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.File import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject import javax.inject.Inject
enum class RecognitionStatus { enum class RecognitionStatus {
@@ -59,7 +53,10 @@ class FaceRecognitionViewModel @Inject constructor(
navigator: AppNavigator, navigator: AppNavigator,
appState: AppState, appState: AppState,
private val appStoreDataSource: AppStoreDataSource, private val appStoreDataSource: AppStoreDataSource,
private val localAudioPlayService: LocalAudioPlayService private val localAudioPlayService: LocalAudioPlayService,
private val faceSearchService: FaceSearchService,
private val faceCaptureFileStore: FaceCaptureFileStore,
private val faceThumbnailDecoder: FaceThumbnailDecoder,
) : BaseViewModel( ) : BaseViewModel(
navigator = navigator, navigator = navigator,
appState = appState appState = appState
@@ -91,9 +88,16 @@ class FaceRecognitionViewModel @Inject constructor(
private val _isRecognizing = MutableStateFlow(false) private val _isRecognizing = MutableStateFlow(false)
val isRecognizing: StateFlow<Boolean> = _isRecognizing.asStateFlow() val isRecognizing: StateFlow<Boolean> = _isRecognizing.asStateFlow()
private val _isCaptureInProgress = MutableStateFlow(false)
val isCaptureInProgress: StateFlow<Boolean> = _isCaptureInProgress.asStateFlow()
// 倒计时Job // 倒计时Job
private var countdownJob: kotlinx.coroutines.Job? = null private var countdownJob: kotlinx.coroutines.Job? = null
private var autoCaptureJob: kotlinx.coroutines.Job? = null private var autoCaptureJob: kotlinx.coroutines.Job? = null
private val captureGate = CaptureGate()
private var activeCaptureToken: Long? = null
private var activeCaptureFile: File? = null
private var isCleared = false
// ImageCapture实例 // ImageCapture实例
private var imageCapture: ImageCapture? = null private var imageCapture: ImageCapture? = null
@@ -109,6 +113,15 @@ class FaceRecognitionViewModel @Inject constructor(
this.imageCapture = imageCapture this.imageCapture = imageCapture
} }
fun clearImageCapture(imageCapture: ImageCapture) {
if (this.imageCapture === imageCapture) {
this.imageCapture = null
activeCaptureToken?.let { captureToken ->
finishCaptureFile(activeCaptureFile, captureToken)
}
}
}
/** /**
* 开始倒计时 * 开始倒计时
*/ */
@@ -120,7 +133,7 @@ class FaceRecognitionViewModel @Inject constructor(
_countdown.value = _countdown.value - 1 _countdown.value = _countdown.value - 1
} }
// 倒计时结束,自动返回首页 // 倒计时结束,自动返回首页
toPage(com.yzx.kiosk.navigation.routes.AppRoutes.HOME) closeAllExcept(AppRoutes.HOME)
} }
} }
@@ -154,6 +167,12 @@ class FaceRecognitionViewModel @Inject constructor(
* 手动拍照 * 手动拍照
*/ */
fun takePhoto() { fun takePhoto() {
if (_isRecognizing.value) return
val captureToken = captureGate.tryAcquire() ?: return
activeCaptureToken = captureToken
_isCaptureInProgress.value = true
// 如果正在自动拍照倒计时,先取消 // 如果正在自动拍照倒计时,先取消
if (_captureCountdown.value > 0) { if (_captureCountdown.value > 0) {
cancelAutoCapture() cancelAutoCapture()
@@ -167,80 +186,124 @@ class FaceRecognitionViewModel @Inject constructor(
} }
val capture = imageCapture ?: run { val capture = imageCapture ?: run {
releaseCaptureGate(captureToken)
ToastUtils.show("相机未准备好") ToastUtils.show("相机未准备好")
return return
} }
viewModelScope.launch { viewModelScope.launch {
// 创建输出文件 // 创建输出文件
val photoFile = withContext(Dispatchers.IO) { var photoFile: File? = null
File.createTempFile( try {
"IMG_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())}", photoFile = withContext(Dispatchers.IO) {
".jpg", faceCaptureFileStore.createCaptureFile()
App.instance.cacheDir
)
} }
if (
isCleared ||
imageCapture !== capture ||
activeCaptureToken != captureToken
) {
faceCaptureFileStore.delete(photoFile)
releaseCaptureGate(captureToken)
return@launch
}
activeCaptureFile = photoFile
// 创建输出选项
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build() val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
// 拍照
capture.takePicture( capture.takePicture(
outputOptions, outputOptions,
ContextCompat.getMainExecutor(App.instance), ContextCompat.getMainExecutor(App.instance),
object : ImageCapture.OnImageSavedCallback { object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) { override fun onImageSaved(output: ImageCapture.OutputFileResults) {
// 加载图片为Bitmap,并处理EXIF方向 if (
val bitmap = loadBitmapWithExifOrientation(photoFile.absolutePath) isCleared ||
_capturedImage.value = bitmap imageCapture !== capture ||
// 直接调用搜索接口 activeCaptureToken != captureToken
searchFaceDirectly(photoFile) ) {
finishCaptureFile(photoFile, captureToken)
return
}
recognizeSavedPhoto(photoFile, captureToken)
} }
override fun onError(exception: ImageCaptureException) { override fun onError(exception: ImageCaptureException) {
LogUtils.e(TAG, "拍照失败: ${exception.message}") LogUtils.e(TAG, "拍照失败: ${exception.message}")
finishCaptureFile(photoFile, captureToken)
if (!isCleared && imageCapture === capture) {
ToastUtils.show("拍照失败") ToastUtils.show("拍照失败")
} }
} }
},
) )
} catch (e: CancellationException) {
finishCaptureFile(photoFile, captureToken)
throw e
} catch (e: Exception) {
LogUtils.e(TAG, "创建人脸拍照文件失败: ${e.message}")
finishCaptureFile(photoFile, captureToken)
ToastUtils.show("拍照失败")
}
} }
} }
/** private fun recognizeSavedPhoto(photoFile: File, captureToken: Long) {
* 直接调用人脸搜索接口
*/
private fun searchFaceDirectly(photoFile: File) {
viewModelScope.launch { viewModelScope.launch {
_isRecognizing.value = true _isRecognizing.value = true
_recognitionStatus.value = RecognitionStatus.RECOGNIZING _recognitionStatus.value = RecognitionStatus.RECOGNIZING
// 播放识别中音频 releaseCaptureGate(captureToken)
localAudioPlayService.playByRoute("face_recognition_recognizing")
// 播放识别中音频
localAudioPlayService.playByRoute("face_recognition_recognizing") localAudioPlayService.playByRoute("face_recognition_recognizing")
try { try {
// 直接调用人脸识别接口 _capturedImage.value = withContext(Dispatchers.IO) {
faceThumbnailDecoder.decode(photoFile.absolutePath)
}
searchFace(photoFile) searchFace(photoFile)
} catch (e: CancellationException) { } catch (e: CancellationException) {
// 页面跳转或 ViewModel 销毁时的正常协程取消,不向用户报错
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
LogUtils.e(TAG, "处理失败: ${e.message}") LogUtils.e(TAG, "处理失败: ${e.message}")
ToastUtils.show("处理失败: ${e.message}") ToastUtils.show("处理失败: ${e.message}")
_isRecognizing.value = false _isRecognizing.value = false
_recognitionStatus.value = RecognitionStatus.FAILED _recognitionStatus.value = RecognitionStatus.FAILED
} finally {
finishCaptureFile(photoFile, captureToken)
} }
} }
} }
private fun releaseCaptureGate(captureToken: Long) {
if (captureGate.release(captureToken)) {
if (activeCaptureToken == captureToken) {
activeCaptureToken = null
}
_isCaptureInProgress.value = false
}
}
private fun finishCaptureFile(photoFile: File?, captureToken: Long) {
faceCaptureFileStore.delete(photoFile)
if (activeCaptureFile === photoFile) {
activeCaptureFile = null
}
releaseCaptureGate(captureToken)
}
/** /**
* 搜索人脸 * 搜索人脸
*/ */
private suspend fun searchFace(imageFile: File) = withContext(Dispatchers.IO) { private suspend fun searchFace(imageFile: File) = withContext(Dispatchers.IO) {
try { try {
val FACE_SEARCH_BASE_URL = if (appStoreDataSource.getUseLan()) appStoreDataSource.getBindBoxLanUrl() else appStoreDataSource.getBindBoxUrl() val faceSearchBaseUrl = if (appStoreDataSource.getUseLan()) {
val FACE_SEARCH_TOKEN = appStoreDataSource.getBindBoxApiToken() appStoreDataSource.getBindBoxLanUrl()
} else {
appStoreDataSource.getBindBoxUrl()
}
val faceSearchToken = appStoreDataSource.getBindBoxApiToken()
val requestUrl = buildFaceSearchUrl(
baseUrl = faceSearchBaseUrl,
deviceSn = appStoreDataSource.getBindBoxSn(),
)
// 创建MultipartBody // 创建MultipartBody
@@ -251,16 +314,10 @@ class FaceRecognitionViewModel @Inject constructor(
val thresholdValue = 0.45f val thresholdValue = 0.45f
val thresholdBody = thresholdValue.toString().toRequestBody("text/plain".toMediaType()) val thresholdBody = thresholdValue.toString().toRequestBody("text/plain".toMediaType())
// index_date 参数:当前日期,格式 YYYYMMDD
val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault())
val indexDate = dateFormat.format(Date())
val indexDateBody = indexDate.toRequestBody("text/plain".toMediaType())
// Authorization header // Authorization header
val authorization = "Bearer $FACE_SEARCH_TOKEN" val authorization = "Bearer $faceSearchToken"
// 打印请求信息 // 打印请求信息
val requestUrl = "$FACE_SEARCH_BASE_URL/api/search"
LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========") LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========")
LogUtils.d(TAG, "URL: $requestUrl") LogUtils.d(TAG, "URL: $requestUrl")
LogUtils.d(TAG, "Headers:") LogUtils.d(TAG, "Headers:")
@@ -268,33 +325,15 @@ class FaceRecognitionViewModel @Inject constructor(
LogUtils.d(TAG, "Parameters:") LogUtils.d(TAG, "Parameters:")
LogUtils.d(TAG, "image: ${imageFile.name} (${imageFile.length()} bytes)") LogUtils.d(TAG, "image: ${imageFile.name} (${imageFile.length()} bytes)")
LogUtils.d(TAG, "threshold: $thresholdValue") LogUtils.d(TAG, "threshold: $thresholdValue")
LogUtils.d(TAG, "index_date: $indexDate")
LogUtils.d(TAG, "==========================================") LogUtils.d(TAG, "==========================================")
// Debug 环境记录请求与响应;图片等大文件只记录元数据,不读取文件内容
val client = OkHttpClient.Builder()
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
}
}
.build()
// 创建Retrofit实例
val retrofit = Retrofit.Builder()
.baseUrl("$FACE_SEARCH_BASE_URL/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
val service = retrofit.create(FaceSearchService::class.java)
// 调用接口 // 调用接口
val response = service.searchFace( val response = faceSearchService.searchFace(
sn = appStoreDataSource.getBindBoxSn(), url = requestUrl,
authorization = authorization, authorization = authorization,
image = imagePart, image = imagePart,
threshold = thresholdBody, threshold = thresholdBody,
// 不传日期,由 V3 服务端使用上海时区当天;人数及人脸大小也使用服务端默认值。
indexDate = null indexDate = null
) )
@@ -369,82 +408,20 @@ class FaceRecognitionViewModel @Inject constructor(
} }
} }
/** private var isOpeningRecentPhotos = false
* 加载Bitmap并处理EXIF方向信息
* 解决不同设备拍照后图片方向不正确的问题 fun browseRecentPhotos() {
*/ if (_recognitionStatus.value != RecognitionStatus.FAILED || isOpeningRecentPhotos) return
private fun loadBitmapWithExifOrientation(imagePath: String): Bitmap? { isOpeningRecentPhotos = true
return try { countdownJob?.cancel()
// 先读取EXIF方向信息 autoCaptureJob?.cancel()
val exif = ExifInterface(imagePath) toPage(
val orientation = exif.getAttributeInt( AppRoutes.buildRecentPhotosRoute(),
ExifInterface.TAG_ORIENTATION, NavOptions.Builder()
ExifInterface.ORIENTATION_NORMAL .setPopUpTo(AppRoutes.FACE_RECOGNITION, inclusive = true)
.setLaunchSingleTop(true)
.build(),
) )
// 加载原始Bitmap
val bitmap = BitmapFactory.decodeFile(imagePath) ?: return null
// 根据EXIF方向旋转Bitmap
val rotationDegrees = when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
// 水平翻转
val matrix = Matrix()
matrix.setScale(-1f, 1f)
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
// 垂直翻转
val matrix = Matrix()
matrix.setScale(1f, -1f)
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
ExifInterface.ORIENTATION_TRANSPOSE -> {
// 转置(旋转90度+水平翻转)
val matrix = Matrix()
matrix.setRotate(90f)
matrix.postScale(-1f, 1f)
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
// 横向(旋转270度+水平翻转)
val matrix = Matrix()
matrix.setRotate(270f)
matrix.postScale(-1f, 1f)
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
else -> 0f // 不需要旋转
}
// 如果需要旋转
if (rotationDegrees != 0f) {
val matrix = Matrix()
matrix.postRotate(rotationDegrees)
val rotatedBitmap = Bitmap.createBitmap(
bitmap,
0,
0,
bitmap.width,
bitmap.height,
matrix,
true
)
// 回收原始bitmap
if (rotatedBitmap != bitmap) {
bitmap.recycle()
}
rotatedBitmap
} else {
bitmap
}
} catch (e: Exception) {
LogUtils.e(TAG, "加载图片并处理EXIF方向失败: ${e.message}")
// 如果处理失败,返回原始Bitmap
BitmapFactory.decodeFile(imagePath)
}
} }
fun privatePolicy() { fun privatePolicy() {
@@ -476,9 +453,15 @@ class FaceRecognitionViewModel @Inject constructor(
} }
override fun onCleared() { override fun onCleared() {
super.onCleared() isCleared = true
countdownJob?.cancel() countdownJob?.cancel()
autoCaptureJob?.cancel() autoCaptureJob?.cancel()
imageCapture = null
_capturedImage.value = null
faceCaptureFileStore.delete(activeCaptureFile)
activeCaptureFile = null
activeCaptureToken?.let(::releaseCaptureGate)
super.onCleared()
} }
} }
@@ -0,0 +1,35 @@
package com.yzx.kiosk.ui.face.viewmodel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
enum class RecentPhotosState { IDLE, LOADING, READY, EMPTY, ERROR }
/** One request per result-page lifetime; retries are explicit and cancellation propagates. */
internal class RecentPhotosLoader(
private val scope: CoroutineScope,
private val fetchAndApply: suspend () -> Boolean,
) {
private val mutableState = MutableStateFlow(RecentPhotosState.IDLE)
val state = mutableState.asStateFlow()
private var job: Job? = null
fun load(retry: Boolean = false) {
if (job?.isActive == true) return
if (mutableState.value != RecentPhotosState.IDLE && !(retry && mutableState.value == RecentPhotosState.ERROR)) return
mutableState.value = RecentPhotosState.LOADING
job = scope.launch {
try {
mutableState.value = if (fetchAndApply()) RecentPhotosState.READY else RecentPhotosState.EMPTY
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
mutableState.value = RecentPhotosState.ERROR
}
}
}
}
@@ -1,5 +1,6 @@
package com.yzx.kiosk.ui.upload.view package com.yzx.kiosk.ui.upload.view
import com.yzx.kiosk.utils.formatPriceDisplay
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -150,7 +151,7 @@ fun PhotoSelectScreen(
} }
Spacer(modifier = Modifier.height(18.dp)) Spacer(modifier = Modifier.height(18.dp))
Text( Text(
text = "打印${pricePerPhoto}元/张", text = "打印${formatPriceDisplay(pricePerPhoto)}元/张",
fontSize = 21.sp, fontSize = 21.sp,
color = Color(0xFF000000) color = Color(0xFF000000)
) )
@@ -170,7 +171,7 @@ fun PhotoSelectScreen(
enabled = selectedCount > 0 enabled = selectedCount > 0
) { ) {
Text( Text(
text = if (selectedCount > 0) "支付${totalPrice}元" else "支付0元", text = if (selectedCount > 0) "支付${formatPriceDisplay(totalPrice)}元" else "支付0元",
fontSize = 24.sp, fontSize = 24.sp,
color = Color.White, color = Color.White,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
@@ -1,5 +1,6 @@
package com.yzx.kiosk.ui.upload.view package com.yzx.kiosk.ui.upload.view
import com.yzx.kiosk.utils.formatPriceDisplay
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -259,7 +260,7 @@ fun UploadPhotoScreen(
// 打印价格 // 打印价格
ServiceInfoRow( ServiceInfoRow(
label = "打印价格", label = "打印价格",
value = printPrice, value = formatPriceDisplay(printPrice),
valueColor = Color(0xFFEF4444) valueColor = Color(0xFFEF4444)
) )
@@ -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()
}
@@ -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()
@@ -390,46 +390,13 @@ class WebSocketService @Inject constructor(
} }
} }
"5" -> { "5" -> {
// 支付成功消息 val event = parsePurchaseCompletedEvent(jsonObject)
LogUtils.d(TAG, ">>> 收到支付成功消息 (code=5) <<<") if (event != null) {
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<Int>()
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 { CoroutineScope(Dispatchers.Main).launch {
_uploadPhotoEvents.emit( _uploadPhotoEvents.emit(event)
UploadPhotoEvent.PaySuccess(
orderNumber = orderNumber,
captureType = captureType,
imageIds = imageIdList,
fileMap = fileMap
)
)
} }
} } else {
} catch (e: Exception) { LogUtils.e(TAG, "忽略无效的购买完成消息")
LogUtils.e(TAG, "解析支付成功消息失败: ${e.message}")
} }
} }
"4" -> { "4" -> {
@@ -974,6 +941,7 @@ sealed class UploadPhotoEvent {
val orderNumber: String?, val orderNumber: String?,
val captureType: Int?, val captureType: Int?,
val fileMap:String, val fileMap:String,
val imageIds: List<Int> val imageIds: List<Int>,
val purchaseMode: String? = null
) : UploadPhotoEvent() ) : UploadPhotoEvent()
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.
Binary file not shown.
@@ -0,0 +1,32 @@
package com.yzx.kiosk.audio
import com.yzx.kiosk.navigation.routes.AppRoutes
import org.junit.Assert.assertEquals
import org.junit.Test
class LocalAudioRouteTest {
@Test fun `electronic completion uses its own audio while print routes retain theirs`() {
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"))
assertEquals(RECENT_PHOTOS_AUDIO_KEY, localAudioKeyForRoute("face_recognition_result?source=recent&results=%5B%5D"))
assertEquals(RECENT_PHOTOS_AUDIO_KEY, localAudioKeyForRoute("face_recognition_result?source=%72ecent"))
}
@Test fun `face and legacy routes keep success audio`() {
for (route in listOf("face_recognition_result", "face_recognition_result?results=%5B%5D", "face_recognition_result?source=face", "face_recognition_result?source=unknown", "face_recognition_result?source=%")) {
assertEquals(AppRoutes.FACE_RECOGNITION_RESULT, localAudioKeyForRoute(route))
}
}
@Test fun `photo data cannot change source and unrelated pages keep their audio`() {
assertEquals(AppRoutes.FACE_RECOGNITION_RESULT, localAudioKeyForRoute("face_recognition_result?results=source%3Drecent"))
assertEquals(AppRoutes.HOME, localAudioKeyForRoute("home?source=recent"))
assertEquals("face_recognition_failed", localAudioKeyForRoute("face_recognition_failed"))
}
}
@@ -23,6 +23,14 @@ class AppRoutesTest {
assertFalse(route.contains("token=a&size=4x6")) 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 @Test
fun `printing route leaves missing order metadata empty`() { fun `printing route leaves missing order metadata empty`() {
val route = AppRoutes.buildPrintingRoute("[]", null, null) val route = AppRoutes.buildPrintingRoute("[]", null, null)
@@ -0,0 +1,83 @@
package com.yzx.kiosk.network.service
import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
class FaceSearchUrlTest {
@Test
fun `url uses configured host and safely encoded sn`() {
assertEquals(
"http://192.168.1.10/BOX%2F01/api/v3/search",
buildFaceSearchUrl("http://192.168.1.10/old/path?unused=true", "BOX/01"),
)
}
@Test
fun `invalid configuration is rejected`() {
assertThrows(IllegalArgumentException::class.java) {
buildFaceSearchUrl("not-a-url", "BOX-1")
}
assertThrows(IllegalArgumentException::class.java) {
buildFaceSearchUrl("http://192.168.1.10", "")
}
}
@Test
fun `v3 request preserves auth and leaves optional settings to server`() = runBlocking {
val client = OkHttpClient.Builder().addInterceptor { chain ->
val request = chain.request()
assertEquals("POST", request.method)
assertEquals("https://example.test/BOX-1/api/v3/search", request.url.toString())
assertEquals("Bearer test-token", request.header("Authorization"))
val body = request.body as MultipartBody
assertEquals(MultipartBody.FORM, body.type)
assertEquals(
listOf("form-data; name=\"image\"; filename=\"group.jpg\"", "form-data; name=\"threshold\""),
body.parts.map { it.headers?.get("Content-Disposition") },
)
okhttp3.Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("""{"count":0,"results":[],"detected_count":8,"searched_count":6,"truncated":true,"min_face_size":80,"max_faces":6}"""
.toResponseBody("application/json".toMediaType()))
.build()
}.build()
val service = Retrofit.Builder()
.baseUrl("https://example.test/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(FaceSearchService::class.java)
val response = service.searchFace(
url = buildFaceSearchUrl("https://example.test", "BOX-1"),
authorization = "Bearer test-token",
image = MultipartBody.Part.createFormData("image", "group.jpg", "test-image".toRequestBody("image/jpeg".toMediaType())),
threshold = "0.45".toRequestBody("text/plain".toMediaType()),
indexDate = null,
)
assertEquals(0, response.body()?.count)
assertEquals(emptyList<Any>(), response.body()?.results)
}
@Test
fun `retrofit accepts multipart post with dynamic url`() {
Retrofit.Builder()
.baseUrl("https://example.test/")
.addConverterFactory(GsonConverterFactory.create())
.validateEagerly(true)
.build()
.create(FaceSearchService::class.java)
}
}
@@ -0,0 +1,46 @@
package com.yzx.kiosk.network.service
import kotlinx.coroutines.runBlocking
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.*
import org.junit.Test
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
class RecentPhotosServiceTest {
@Test fun `recent url replaces configured path and encodes sn`() {
assertEquals("https://example.test/BOX%2F01/api/photos/recent",
buildRecentPhotosUrl("https://example.test/old?seconds=20#fragment", "BOX/01"))
assertThrows(IllegalArgumentException::class.java) { buildRecentPhotosUrl("invalid", "BOX") }
assertThrows(IllegalArgumentException::class.java) { buildRecentPhotosUrl("https://example.test", "") }
}
@Test fun `get uses bearer without optional query parameters and preserves response order`() = runBlocking {
val bodies = listOf(
"""{"count":0,"results":[]}""",
"""{"count":2,"results":[{"id":9,"thumbnail_oss_url":null,"thumbnail_lan_url":"http://local/9.jpg","similarity":0},{"id":3,"thumbnail_oss_url":"https://oss/3.jpg","thumbnail_lan_url":null}]}""",
)
for (body in bodies) {
val client = OkHttpClient.Builder().addInterceptor { chain ->
val request = chain.request()
assertEquals("GET", request.method)
assertEquals("https://example.test/BOX/api/photos/recent", request.url.toString())
assertNull(request.url.query)
assertEquals("Bearer test-token", request.header("Authorization"))
assertNull(request.body)
Response.Builder().request(request).protocol(Protocol.HTTP_1_1).code(200).message("OK")
.body(body.toResponseBody("application/json".toMediaType())).build()
}.build()
val service = Retrofit.Builder().baseUrl("https://example.test/").client(client)
.addConverterFactory(GsonConverterFactory.create()).build().create(FaceSearchService::class.java)
val response = service.recentPhotos(buildRecentPhotosUrl("https://example.test", "BOX"), "Bearer test-token").body()!!
if (response.count == 0) assertTrue(response.results!!.isEmpty()) else {
assertEquals(listOf(9, 3), response.results!!.map { it.id })
assertNull(response.results[0].thumbnailOssUrl)
assertNull(response.results[1].thumbnailLanUrl)
}
}
}
}
@@ -0,0 +1,50 @@
package com.yzx.kiosk.ui.face.resource
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
import java.nio.file.Files
class FaceCaptureFileStoreTest {
private lateinit var cacheDirectory: File
@Before
fun setUp() {
cacheDirectory = Files.createTempDirectory("face-capture-test").toFile()
}
@After
fun tearDown() {
cacheDirectory.deleteRecursively()
}
@Test
fun `cleanup removes owned and legacy captures only`() {
val captureDirectory = File(
cacheDirectory,
FaceCaptureFileStore.CAPTURE_DIRECTORY,
).apply { mkdirs() }
val ownedCapture = File(captureDirectory, "capture_1.jpg").apply { writeText("face") }
val legacyCapture = File(cacheDirectory, "IMG_20260825.jpg").apply { writeText("face") }
val unrelatedJpeg = File(cacheDirectory, "holiday.jpg").apply { writeText("keep") }
val similarLegacyFile = File(cacheDirectory, "IMG_notes.txt").apply { writeText("keep") }
val deleted = FaceCaptureFileStore.cleanupOrphans(cacheDirectory)
assertEquals(2, deleted)
assertFalse(ownedCapture.exists())
assertFalse(legacyCapture.exists())
assertTrue(unrelatedJpeg.exists())
assertTrue(similarLegacyFile.exists())
}
@Test
fun `cleanup is idempotent`() {
assertEquals(0, FaceCaptureFileStore.cleanupOrphans(cacheDirectory))
assertEquals(0, FaceCaptureFileStore.cleanupOrphans(cacheDirectory))
}
}
@@ -0,0 +1,32 @@
package com.yzx.kiosk.ui.face.resource
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FaceResourceHelpersTest {
@Test
fun `sample size bounds large source before final scale`() {
assertEquals(4, FaceThumbnailDecoder.calculateInSampleSize(4000, 3000, 720))
assertEquals(1, FaceThumbnailDecoder.calculateInSampleSize(1280, 720, 720))
assertEquals(1, FaceThumbnailDecoder.calculateInSampleSize(0, 0, 720))
}
@Test
fun `capture gate permits only one active capture`() {
val gate = CaptureGate()
val firstToken = gate.tryAcquire()
assertTrue(firstToken != null)
assertEquals(null, gate.tryAcquire())
assertTrue(gate.release(firstToken!!))
val secondToken = gate.tryAcquire()
assertTrue(secondToken != null)
assertFalse(gate.release(firstToken))
assertEquals(null, gate.tryAcquire())
assertTrue(gate.release(secondToken!!))
}
}
@@ -12,8 +12,8 @@ import org.junit.Test
class FacePayStatusDecisionTest { class FacePayStatusDecisionTest {
@Test @Test
fun `pending and unknown statuses continue polling`() { fun `only pending status continues polling`() {
listOf(null, 10, 20, 0, 70).forEach { status -> listOf(10).forEach { status ->
assertEquals( assertEquals(
FacePayStatusDecision.CONTINUE_POLLING, FacePayStatusDecision.CONTINUE_POLLING,
decideFacePayStatus(status) decideFacePayStatus(status)
@@ -31,7 +31,7 @@ class FacePayStatusDecisionTest {
@Test @Test
fun `cancel and refund statuses stop polling`() { fun `cancel and refund statuses stop polling`() {
listOf(40, 50, 60).forEach { status -> listOf(null, 20, 0, 40, 50, 60, 70).forEach { status ->
assertEquals( assertEquals(
FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS, FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS,
decideFacePayStatus(status) decideFacePayStatus(status)
@@ -42,6 +42,7 @@ class FacePayStatusDecisionTest {
@Test @Test
fun `valid face payment message is accepted`() { fun `valid face payment message is accepted`() {
assertTrue(isValidFacePaySuccessMessage("ORDER-1", message())) assertTrue(isValidFacePaySuccessMessage("ORDER-1", message()))
assertFalse(isValidFacePaySuccessMessage("ORDER-1", message().copy(orderStatus = 10)))
} }
@Test @Test
@@ -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)
}
}
@@ -0,0 +1,62 @@
package com.yzx.kiosk.ui.face.viewmodel
import kotlinx.coroutines.*
import org.junit.Assert.*
import org.junit.Test
class RecentPhotosLoaderTest {
@Test fun `successful load is not repeated by recomposition`() = runBlocking {
var calls = 0
val loader = RecentPhotosLoader(this) { calls++; true }
loader.load()
loader.load()
yield()
loader.load()
assertEquals(1, calls)
assertEquals(RecentPhotosState.READY, loader.state.value)
}
@Test fun `filtered empty result has an empty state`() = runBlocking {
val loader = RecentPhotosLoader(this) { false }
loader.load()
yield()
assertEquals(RecentPhotosState.EMPTY, loader.state.value)
}
@Test fun `http and network failures require explicit retry`() = runBlocking {
for (failure in listOf(IllegalStateException("HTTP 401"), IllegalStateException("HTTP 403"), java.io.IOException())) {
var calls = 0
val loader = RecentPhotosLoader(this) {
calls++
if (calls == 1) throw failure
true
}
loader.load()
yield()
assertEquals(RecentPhotosState.ERROR, loader.state.value)
loader.load()
assertEquals(1, calls)
loader.load(retry = true)
loader.load(retry = true)
yield()
assertEquals(2, calls)
assertEquals(RecentPhotosState.READY, loader.state.value)
}
}
@Test fun `leaving the page cancels request without applying data`() = runBlocking {
val parent = Job()
var applied = false
var cancelled = false
val loader = RecentPhotosLoader(CoroutineScope(coroutineContext + parent)) {
try { delay(Long.MAX_VALUE); applied = true; true }
finally { cancelled = true }
}
loader.load()
yield()
parent.cancelAndJoin()
assertTrue(cancelled)
assertFalse(applied)
assertNotEquals(RecentPhotosState.ERROR, loader.state.value)
}
}
@@ -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)) }
}
}
+391
View File
@@ -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: <md5(sn + 设备api_token + timestamp)>
```
设备必须为已注册且已绑定景区的大屏设备,时间戳允许偏差为 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/推送结果及实际下载结果。未完成真实支付、设备打印或相册下载时,应如实标记“未验证”,不能用语法检查、静态计算或示例响应替代端到端验收。
@@ -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`:按参考图比例在手机上渲染,用于对比布局。
模拟器测试进程启动失败,本次验证以手机结果为准。
@@ -0,0 +1,8 @@
# 电子版完成页语音
- 来源:用户提供的 `9月15日.wav`。
- 项目资源名称:`app/src/main/res/raw/electronic_complete_page.wav`。
- 原先从打印完成提示音裁剪得到的版本已被替换。
- 按原文件复制,未裁剪、转码或调整音量;48 kHz、双声道、16-bit PCM WAV,时长 5.632 秒。
- 进入电子版完成页时通过 `LocalAudioPlayService` 播放此资源。
- 打印完成页继续使用 `print_complete_page.wav`。
Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 KiB

+48
View File
@@ -0,0 +1,48 @@
# 最近照片入口验证
2026-09-08,JDK 17,KIOSK_1080x1920(1080×1920,280dpi,Android 17)。
- Debug 构建通过;24 项 JVM 单元测试通过,其中新增 6 项。
- `RecentPhotosFlowTest` 的 3 项模拟器测试全部通过。
- 页面验证覆盖:仅失败时显示入口、点击进入 recent 模式、阻止重复跳转、保持首页返回栈、旧 results 路由默认使用 face 模式。
- 模拟响应覆盖:空结果、403 后重试、有效照片按服务端顺序展示、无缩略图地址的照片过滤、预览、单选/全选、计价、获取模拟支付链接以及支付成功后的打印导航参数。
- JVM 测试另外覆盖 URL/SN 编码、GET/Bearer、不发送查询参数、401/403/网络失败、重复请求防护、页面作用域取消。
- 所有照片和订单 HTTP 请求均被测试拦截器替换为模拟响应;只校验打印导航,不打开打印页面、不创建真实订单、不连接打印机。未进行真实后端端到端联调。
- 页面测试临时使用 example.test 和测试 Token,结束时恢复原照片盒配置。测试通过已有 Hilt 单例 Provider 获取 WebSocket 依赖而不启动主页面,未建立测试 WebSocket 连接。
- Espresso 测试依赖升级至 3.7.0,解决旧版本在 Android 17 上调用已移除 InputManager 方法的问题;应用运行依赖不受影响。
## 截图
- [失败页按钮](failure-button.png):状态由测试设置,相机区域是测试时的黑色画面;按钮位于卡片与倒计时之间,底部布局保持原位置。
- [最近照片结果页](results.png):蓝色图片为本地测试素材,价格及支付数据为模拟数据。
## 复现
使用 JDK 17,通过 Gradle 执行:
```text
:app:assembleDebug :app:testDebugUnitTest
:app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.yzx.kiosk.ui.face.RecentPhotosFlowTest
```
仅在测试模拟器上运行 instrumentation 测试。截图保存在目标应用 external files 目录;手动执行 `am instrument` 后可取回,Gradle 托管测试可能在结束时卸载测试应用并清理截图。
## 最近照片入口语音修正(2026-09-08)
- 新资源:`app/src/main/res/raw/recent_photos_result_page.wav`。
- 从原成功语音的 2.160 秒处裁切。此处位于开场结束后的静音区间(约 1.634–2.289 秒),保留约 129 毫秒的起始停顿,后续音频样本完全不变。
- 新音频长 8.890667 秒,48 kHz、16 位 PCM、双声道 WAV。原文件 SHA-256 保持为 `c9146f08eaa02bacac0cc8b574ccad829ae4457e22c3b553f2fd3ad8111b7dc1`。
- 使用本地 Whisper 转写交叉核对前后内容:新文件从选片提示开始,没有“太棒啦,识别成功啦”开场;后续放大预览、支付按钮和打印提示均保留。模型转写存在个别同音字误差,裁切边界同时通过静音检测和 PCM 样本比对确认。未上传音频。
- `LocalAudioPlayService` 根据路由 `source=recent` 选择新资源;face、旧入口及其他页面保留原映射。不新增播放触发点。
- 最终 Debug 构建与 27 项 JVM 单元测试通过;`LocalAudioPlaybackTest` 在 KIOSK_1080x1920 上通过,验证新旧入口资源选择、实际进入播放状态及重复调用不重置媒体项。
## 正常识别结果页增加浏览入口(2026-09-08)
- 在全选行右侧增加「浏览全部照片 ›」文字按钮,16sp、蓝色、无背景/边框,点击区域至少 48dp。全选与浏览是独立点击区域。
- 仅普通识别结果页显示,recent 页面隐藏。导航采用普通 push(不使用 singleTop/popUpTo),让相同 destination 的两次入栈拥有独立 ViewModel。
- 原识别结果只初始化一次,避免返回时重新初始化照片尺寸、加载状态和价格;网格继续使用 Compose 自带的可保存滚动状态。
- 按钮在导航期间阻止连续点击,回到原页时恢复可点击。
- 最终 Debug 构建、27 项 JVM 单元测试通过;模拟器上 4 项 RecentPhotosFlowTest 和 1 项 LocalAudioPlaybackTest 全部通过。
- 新测试验证:双击只 push 一次、新旧页面 VM 和选择独立、返回保留原 entry/选择/金额/滚动位置且不重复计价初始化、返回后可再次打开新页、recent 隐藏按钮及每次新入栈重新加载。
- 计价使用按选择数量计算的模拟响应,无真实订单和打印操作。
- [实际布局截图](result-toolbar-button.png) 使用 30 张蓝色测试图片展示工具栏位置和长列表场景。
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

@@ -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 转存和小程序下载仍需按后端文档进行部署后联调。
+1 -1
View File
@@ -6,7 +6,7 @@ coreKtx = "1.15.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.2.1" junitVersion = "1.2.1"
espressoCore = "3.6.1" espressoCore = "3.7.0"
composeBom = "2025.05.01" composeBom = "2025.05.01"
constraintlayoutCompose = "1.1.1" constraintlayoutCompose = "1.1.1"