16 Commits
Author SHA1 Message Date
lujiuyin 835d6ecd00 docs: record current engineering review and UI verification 2026-09-20 10:06:50 +08:00
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
lujiuyin fb5d3b71df 移除人脸识别成功 Toast 2026-08-25 11:20:47 +08:00
lujiuyin cd25ada951 feat: 支付成功后直接进入打印页 2026-08-24 15:29:24 +08:00
lujiuyin 4c829f74a9 fix: accept RX1 encoded BMP dimensions 2026-08-22 09:17:27 +08:00
lujiuyin 86bc9528dd fix: improve photo grid and print reliability 2026-08-21 17:51:50 +08:00
lujiuyin 1000488c04 build: add release debug build type 2026-08-21 16:03:01 +08:00
lujiuyin 342655cbb5 feat: add payment status polling fallback 2026-08-21 16:02:11 +08:00
lujiuyin 92dbd59c23 feat: add debug network logging and project guide 2026-08-04 17:29:44 +08:00
82 changed files with 10524 additions and 1438 deletions
+1
View File
@@ -130,6 +130,7 @@ APK 文件名会自动包含应用名、版本号、版本代码、构建类型
| 构建类型 | 后端环境 | 调试 | 代码压缩 | | 构建类型 | 后端环境 | 调试 | 代码压缩 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `debug` | 测试环境 `api-test.zhifly.cn` | 开启 | 关闭 | | `debug` | 测试环境 `api-test.zhifly.cn` | 开启 | 关闭 |
| `releaseDebug` | 正式环境 `api.zhifly.cn` | 开启 | 关闭 |
| `release` | 正式环境 `api.zhifly.cn` | 关闭 | 关闭 | | `release` | 正式环境 `api.zhifly.cn` | 关闭 | 关闭 |
请勿使用 Release 包连接测试设备随意操作,Release 会访问正式接口并连接正式 WebSocket。 请勿使用 Release 包连接测试设备随意操作,Release 会访问正式接口并连接正式 WebSocket。
+9 -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 = 21 versionCode = 24
versionName = "1.1.1" versionName = "1.1.4"
//multiDexEnabled = true //multiDexEnabled = true
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -161,6 +161,13 @@ android {
"\"https://vipsky.oss-cn-shanghai.aliyuncs.com/cloud_driver\"" "\"https://vipsky.oss-cn-shanghai.aliyuncs.com/cloud_driver\""
) )
} }
create("releaseDebug") {
initWith(getByName("release"))
isDebuggable = true
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks += listOf("release", "debug")
}
} }
compileOptions { compileOptions {
@@ -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,11 +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.PAY_SUCCESS to R.raw.pay_success_select_photo_print_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,
@@ -110,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
@@ -17,7 +19,6 @@ import com.yzx.kiosk.ui.printer.view.PrinterManageScreen
import com.yzx.kiosk.ui.setting.view.SettingScreen import com.yzx.kiosk.ui.setting.view.SettingScreen
import com.yzx.kiosk.ui.face.view.FaceRecognitionScreen import com.yzx.kiosk.ui.face.view.FaceRecognitionScreen
import com.yzx.kiosk.ui.face.view.FaceRecognitionResultScreen import com.yzx.kiosk.ui.face.view.FaceRecognitionResultScreen
import com.yzx.kiosk.ui.upload.view.PaySuccessScreen
import com.yzx.kiosk.ui.upload.view.PhotoSelectScreen import com.yzx.kiosk.ui.upload.view.PhotoSelectScreen
import com.yzx.kiosk.ui.upload.view.PrintingScreen import com.yzx.kiosk.ui.upload.view.PrintingScreen
import com.yzx.kiosk.ui.upload.view.UploadPhotoScreen import com.yzx.kiosk.ui.upload.view.UploadPhotoScreen
@@ -141,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 {
@@ -154,48 +161,9 @@ fun AppNavHost(
} else { } else {
emptyList() emptyList()
} }
FaceRecognitionResultScreen(results = results) FaceRecognitionResultScreen(
} results = results,
source = backStackEntry.arguments?.getString("source") ?: AppRoutes.FACE_RESULT_SOURCE,
// 支付成功页面
composable(route = "${AppRoutes.PAY_SUCCESS}?urls={urls}&orderNumber={orderNumber}&captureType={captureType}") { backStackEntry ->
val urlsParam = backStackEntry.arguments?.getString("urls") ?: ""
val orderNumberParam = backStackEntry.arguments?.getString("orderNumber") ?: ""
val captureTypeParam = backStackEntry.arguments?.getString("captureType") ?: ""
val photoUrls = if (urlsParam.isNotEmpty()) {
try {
val decoded = URLDecoder.decode(urlsParam, "UTF-8")
// 如果是 JSON 格式(以 [ 开头),作为单个元素传递
if (decoded.startsWith("[")) {
listOf(decoded)
} else {
// 旧格式:逗号分隔的 URL 列表
decoded.split(",")
}
} catch (e: Exception) {
emptyList()
}
} else {
emptyList()
}
val orderNumber = if (orderNumberParam.isNotEmpty()) {
try {
URLDecoder.decode(orderNumberParam, "UTF-8")
} catch (e: Exception) {
""
}
} else {
""
}
val captureType = captureTypeParam.toIntOrNull()
PaySuccessScreen(
photoUrls = photoUrls,
orderNumber = orderNumber,
captureType = captureType
) )
} }
@@ -241,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()
} }
@@ -7,11 +7,36 @@ object AppRoutes {
const val DEVICE_CONFIG = "device_config" const val DEVICE_CONFIG = "device_config"
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 PAY_SUCCESS = "pay_success"
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(
photoData: String,
orderNumber: String?,
captureType: Int?
): String {
val urlsParam = java.net.URLEncoder.encode(photoData, "UTF-8")
val orderNumberParam = orderNumber
?.let { java.net.URLEncoder.encode(it, "UTF-8") }
.orEmpty()
val captureTypeParam = captureType?.toString().orEmpty()
return "$PRINTING?urls=$urlsParam&orderNumber=$orderNumberParam&captureType=$captureTypeParam"
}
} }
@@ -3,6 +3,7 @@ package com.yzx.kiosk.network.di
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.yzx.kiosk.BuildConfig import com.yzx.kiosk.BuildConfig
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
import com.yzx.kiosk.network.interceptor.RequestInterceptor import com.yzx.kiosk.network.interceptor.RequestInterceptor
import com.yzx.kiosk.network.interceptor.ResponseInterceptor import com.yzx.kiosk.network.interceptor.ResponseInterceptor
import dagger.Module import dagger.Module
@@ -10,7 +11,6 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -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
@@ -41,18 +43,11 @@ object NetworkModule {
@Singleton @Singleton
fun provideGson(): Gson = GsonBuilder().create() fun provideGson(): Gson = GsonBuilder().create()
@Provides
@Singleton
fun provideLoggingInterceptor(): HttpLoggingInterceptor = HttpLoggingInterceptor().apply {
level =
if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
}
private fun buildOkHttpClient( private fun buildOkHttpClient(
timeout: Long, timeout: Long,
requestInterceptor: RequestInterceptor, requestInterceptor: RequestInterceptor,
responseInterceptor: ResponseInterceptor, responseInterceptor: ResponseInterceptor,
loggingInterceptor: HttpLoggingInterceptor? clientName: String,
): OkHttpClient = OkHttpClient.Builder() ): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(timeout, TimeUnit.SECONDS) .connectTimeout(timeout, TimeUnit.SECONDS)
.writeTimeout(timeout, TimeUnit.SECONDS) .writeTimeout(timeout, TimeUnit.SECONDS)
@@ -61,7 +56,9 @@ object NetworkModule {
.addInterceptor(requestInterceptor) .addInterceptor(requestInterceptor)
.addInterceptor(responseInterceptor) .addInterceptor(responseInterceptor)
.apply { .apply {
if (BuildConfig.DEBUG) loggingInterceptor?.let { addInterceptor(it) } if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor(clientName))
}
}.build() }.build()
@Provides @Provides
@@ -70,12 +67,11 @@ object NetworkModule {
fun provideDefaultOkHttpClient( fun provideDefaultOkHttpClient(
requestInterceptor: RequestInterceptor, requestInterceptor: RequestInterceptor,
responseInterceptor: ResponseInterceptor, responseInterceptor: ResponseInterceptor,
loggingInterceptor: HttpLoggingInterceptor,
): OkHttpClient = buildOkHttpClient( ): OkHttpClient = buildOkHttpClient(
TIMEOUT_SHORT, TIMEOUT_SHORT,
requestInterceptor, requestInterceptor,
responseInterceptor, responseInterceptor,
loggingInterceptor, "api",
) )
@Provides @Provides
@@ -84,7 +80,12 @@ object NetworkModule {
fun provideUploadOkHttpClient( fun provideUploadOkHttpClient(
requestInterceptor: RequestInterceptor, requestInterceptor: RequestInterceptor,
responseInterceptor: ResponseInterceptor, responseInterceptor: ResponseInterceptor,
): OkHttpClient = buildOkHttpClient(TIMEOUT_LONG, requestInterceptor, responseInterceptor, null) ): OkHttpClient = buildOkHttpClient(
TIMEOUT_LONG,
requestInterceptor,
responseInterceptor,
"upload",
)
@Provides @Provides
@Singleton @Singleton
@@ -98,6 +99,22 @@ object NetworkModule {
.callTimeout(TIMEOUT_LONG, TimeUnit.SECONDS) .callTimeout(TIMEOUT_LONG, TimeUnit.SECONDS)
.addInterceptor(requestInterceptor) .addInterceptor(requestInterceptor)
// 注意:不添加 ResponseInterceptor,避免将大文件加载到内存 // 注意:不添加 ResponseInterceptor,避免将大文件加载到内存
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor("download"))
}
}
.build()
@Provides
@Singleton
@Named(CLIENT_FACE)
fun provideFaceOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
}
}
.build() .build()
private fun buildRetrofit( private fun buildRetrofit(
@@ -127,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(
@@ -0,0 +1,202 @@
package com.yzx.kiosk.network.interceptor
import android.util.Log
import com.yzx.kiosk.BuildConfig
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.Response
import okio.Buffer
import java.nio.charset.StandardCharsets
import java.util.Locale
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
/**
* Debug-only HTTP logger used by every application-owned OkHttpClient.
*
* Text bodies are capped to avoid flooding Logcat or buffering large payloads. Binary,
* multipart, one-shot and duplex bodies are represented by metadata only. Response bodies are
* inspected with [Response.peekBody], so logging never consumes the body used by business code.
*/
class DebugNetworkLoggingInterceptor(
private val clientName: String = "api",
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
if (!BuildConfig.DEBUG) return chain.proceed(chain.request())
val request = chain.request()
val requestId = REQUEST_SEQUENCE.incrementAndGet()
val startedAt = System.nanoTime()
log("┌─ #$requestId [$clientName] --> ${request.method} ${redactedUrl(request)}")
logHeaders(requestId, "request", request.headers)
logRequestBody(requestId, request)
val response = try {
chain.proceed(request)
} catch (error: Throwable) {
val durationMs = elapsedMillis(startedAt)
log("└─ #$requestId [$clientName] <-- HTTP FAILED (${durationMs}ms): ${error.javaClass.simpleName}: ${error.message}")
throw error
}
val durationMs = elapsedMillis(startedAt)
log("├─ #$requestId [$clientName] <-- ${response.code} ${response.message} (${durationMs}ms) ${redactedUrl(request)}")
logHeaders(requestId, "response", response.headers)
logResponseBody(requestId, response)
log("└─ #$requestId [$clientName] END HTTP")
return response
}
private fun logRequestBody(requestId: Long, request: Request) {
val body = request.body ?: run {
log("#$requestId request body: <none>")
return
}
val contentType = body.contentType()
val contentLength = runCatching { body.contentLength() }.getOrDefault(-1L)
val description = describeBody(contentType, contentLength)
if (body.isDuplex() || body.isOneShot()) {
log("#$requestId request body: $description <one-shot/duplex body omitted>")
return
}
if (!isText(contentType) || isEncoded(request.headers) || contentLength > MAX_BODY_BYTES) {
log("#$requestId request body: $description <binary/encoded/large body omitted>")
return
}
val bodyText = runCatching {
Buffer().use { buffer ->
body.writeTo(buffer)
if (buffer.size > MAX_BODY_BYTES) {
"$description <body exceeded ${MAX_BODY_BYTES / 1024}KB while buffering; omitted>"
} else {
val charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
"$description\n${buffer.readString(charset)}"
}
}
}.getOrElse { "$description <unable to read: ${it.message}>" }
log("#$requestId request body: $bodyText")
}
private fun logResponseBody(requestId: Long, response: Response) {
val body = response.body ?: run {
log("#$requestId response body: <none>")
return
}
val contentType = body.contentType()
val contentLength = body.contentLength()
val description = describeBody(contentType, contentLength)
if (!isText(contentType) || isEncoded(response.headers)) {
log("#$requestId response body: $description <binary/encoded body omitted>")
return
}
val bodyText = runCatching {
val preview = response.peekBody(MAX_BODY_BYTES)
val text = preview.string()
val truncated = contentLength > MAX_BODY_BYTES ||
(contentLength == -1L && preview.contentLength() >= MAX_BODY_BYTES)
buildString {
append(description).append('\n').append(text)
if (truncated) append("\n<response truncated at ${MAX_BODY_BYTES / 1024}KB>")
}
}.getOrElse { "$description <unable to preview: ${it.message}>" }
log("#$requestId response body: $bodyText")
}
private fun logHeaders(requestId: Long, direction: String, headers: Headers) {
if (headers.size == 0) {
log("#$requestId $direction headers: <none>")
return
}
val text = buildString {
append("#$requestId $direction headers:")
for (index in 0 until headers.size) {
val name = headers.name(index)
val value = if (isSensitiveHeader(name)) REDACTED else headers.value(index)
append('\n').append(name).append(": ").append(value)
}
}
log(text)
}
private fun redactedUrl(request: Request): String {
val url = request.url
val builder = url.newBuilder()
url.queryParameterNames.forEach { name ->
if (isSensitiveName(name)) builder.setQueryParameter(name, REDACTED)
}
return builder.build().toString()
}
private fun isEncoded(headers: Headers): Boolean {
val encoding = headers["Content-Encoding"] ?: return false
return !encoding.equals("identity", ignoreCase = true)
}
private fun isText(contentType: MediaType?): Boolean {
if (contentType == null) return false
val type = contentType.type.lowercase(Locale.US)
val subtype = contentType.subtype.lowercase(Locale.US)
if (type == "text") return true
return subtype.contains("json") ||
subtype.contains("xml") ||
subtype.contains("html") ||
subtype.contains("x-www-form-urlencoded") ||
subtype.contains("javascript")
}
private fun describeBody(contentType: MediaType?, contentLength: Long): String {
val length = if (contentLength >= 0) "$contentLength bytes" else "unknown length"
return "${contentType ?: "unknown content-type"}, $length"
}
private fun isSensitiveHeader(name: String): Boolean = SENSITIVE_NAMES.contains(name.lowercase(Locale.US))
private fun isSensitiveName(name: String): Boolean = SENSITIVE_NAMES.contains(name.lowercase(Locale.US))
private fun log(message: String) {
if (!BuildConfig.DEBUG) return
if (message.isEmpty()) {
Log.d(TAG, "")
return
}
var start = 0
while (start < message.length) {
val end = minOf(start + LOGCAT_CHUNK_SIZE, message.length)
Log.d(TAG, message.substring(start, end))
start = end
}
}
private fun elapsedMillis(startedAt: Long): Long =
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt)
companion object {
const val TAG = "KIOSK-NET"
private const val MAX_BODY_BYTES = 64L * 1024L
private const val LOGCAT_CHUNK_SIZE = 3_500
private const val REDACTED = "██"
private val REQUEST_SEQUENCE = AtomicLong(0)
private val SENSITIVE_NAMES = setOf(
"authorization",
"token",
"access_token",
"api_key",
"apikey",
"cookie",
"set-cookie",
"sn",
"device_sn",
"password",
"secret",
)
}
}
@@ -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
) )
@@ -0,0 +1,8 @@
package com.yzx.kiosk.network.model.request
import com.google.gson.annotations.SerializedName
data class PaySuccessMessageRequest(
@SerializedName("order_number")
val orderNumber: String
)
@@ -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
) )
@@ -4,6 +4,15 @@ import com.google.gson.annotations.SerializedName
data class GetPayUrlResponse( data class GetPayUrlResponse(
@SerializedName("url") @SerializedName("url")
val url: String? val url: String?,
@SerializedName("order_number")
val orderNumber: String?,
@SerializedName("purchase_mode")
val purchaseMode: String? = null,
@SerializedName("amount")
val amount: String? = null,
@SerializedName("order_status")
val orderStatus: Int? = null
) )
@@ -0,0 +1,34 @@
package com.yzx.kiosk.network.model.response
import com.google.gson.annotations.SerializedName
data class PaySuccessMessageResponse(
@SerializedName("order_status")
val orderStatus: Int?,
@SerializedName("order_status_name")
val orderStatusName: String?,
@SerializedName("sn")
val sn: String?,
@SerializedName("type")
val type: Int?,
@SerializedName("data")
val data: PaySuccessMessageData?
)
data class PaySuccessMessageData(
@SerializedName("order_number")
val orderNumber: String?,
@SerializedName("capture_type")
val captureType: Int?,
@SerializedName("image_id")
val imageIds: List<Int>?,
@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
) )
@@ -1,6 +1,8 @@
package com.yzx.kiosk.network.repository package com.yzx.kiosk.network.repository
import com.google.gson.Gson
import com.yzx.kiosk.network.model.request.GetPayUrlRequest import com.yzx.kiosk.network.model.request.GetPayUrlRequest
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
import com.yzx.kiosk.network.model.request.PrintCompleteRequest import com.yzx.kiosk.network.model.request.PrintCompleteRequest
import com.yzx.kiosk.network.model.request.PrintNotifyRequest import com.yzx.kiosk.network.model.request.PrintNotifyRequest
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
@@ -13,6 +15,7 @@ import com.yzx.kiosk.network.model.response.GetPayUrlResponse
import com.yzx.kiosk.network.model.response.GetPrintInfoResponse import com.yzx.kiosk.network.model.response.GetPrintInfoResponse
import com.yzx.kiosk.network.model.response.NetworkResponse import com.yzx.kiosk.network.model.response.NetworkResponse
import com.yzx.kiosk.network.model.response.OscarLivesData import com.yzx.kiosk.network.model.response.OscarLivesData
import com.yzx.kiosk.network.model.response.PaySuccessMessageResponse
import com.yzx.kiosk.network.model.response.SocketTokenResponse import com.yzx.kiosk.network.model.response.SocketTokenResponse
import com.yzx.kiosk.network.model.response.VerifyResultResponse import com.yzx.kiosk.network.model.response.VerifyResultResponse
import com.yzx.kiosk.network.model.response.VersionResponse import com.yzx.kiosk.network.model.response.VersionResponse
@@ -27,6 +30,7 @@ import javax.inject.Inject
class NetWorkRepository @Inject constructor( class NetWorkRepository @Inject constructor(
private val netService: NetworkService, private val netService: NetworkService,
private val uploadService: UploadService, private val uploadService: UploadService,
private val gson: Gson,
) { ) {
// 基础方法示例,可根据需要添加 // 基础方法示例,可根据需要添加
// fun example(): Flow<NetworkResponse<Any>> = flow { // fun example(): Flow<NetworkResponse<Any>> = flow {
@@ -82,6 +86,23 @@ class NetWorkRepository @Inject constructor(
emit(netService.getPayUrl(request)) emit(netService.getPayUrl(request))
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
fun getPaySuccessMessage(
request: PaySuccessMessageRequest
): Flow<NetworkResponse<PaySuccessMessageResponse>> = flow {
val response = netService.getPaySuccessMessage(request)
val parsedData = response.data
?.takeIf { it.isJsonObject }
?.let { gson.fromJson(it, PaySuccessMessageResponse::class.java) }
emit(
NetworkResponse(
data = parsedData,
code = response.code,
message = response.message
)
)
}.flowOn(Dispatchers.IO)
fun getSocketToken(): Flow<NetworkResponse<SocketTokenResponse>> = flow { fun getSocketToken(): Flow<NetworkResponse<SocketTokenResponse>> = flow {
emit(netService.getSocketToken()) emit(netService.getSocketToken())
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
@@ -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()
}
@@ -1,6 +1,7 @@
package com.yzx.kiosk.network.service package com.yzx.kiosk.network.service
import com.yzx.kiosk.network.model.request.GetPayUrlRequest import com.yzx.kiosk.network.model.request.GetPayUrlRequest
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
import com.yzx.kiosk.network.model.request.PrintCompleteRequest import com.yzx.kiosk.network.model.request.PrintCompleteRequest
import com.yzx.kiosk.network.model.request.PrintNotifyRequest import com.yzx.kiosk.network.model.request.PrintNotifyRequest
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
@@ -16,6 +17,7 @@ import com.yzx.kiosk.network.model.response.OscarLivesData
import com.yzx.kiosk.network.model.response.SocketTokenResponse import com.yzx.kiosk.network.model.response.SocketTokenResponse
import com.yzx.kiosk.network.model.response.VerifyResultResponse import com.yzx.kiosk.network.model.response.VerifyResultResponse
import com.yzx.kiosk.network.model.response.VersionResponse import com.yzx.kiosk.network.model.response.VersionResponse
import com.google.gson.JsonElement
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST import retrofit2.http.POST
@@ -69,6 +71,11 @@ interface NetworkService {
@POST("/api/oscar/order/get-pay-url") @POST("/api/oscar/order/get-pay-url")
suspend fun getPayUrl(@Body request: GetPayUrlRequest): NetworkResponse<GetPayUrlResponse> suspend fun getPayUrl(@Body request: GetPayUrlRequest): NetworkResponse<GetPayUrlResponse>
@POST("/api/oscar/order/pay-success-message")
suspend fun getPaySuccessMessage(
@Body request: PaySuccessMessageRequest
): NetworkResponse<JsonElement>
@GET("/api/oscar/socket-token") @GET("/api/oscar/socket-token")
suspend fun getSocketToken(): NetworkResponse<SocketTokenResponse> suspend fun getSocketToken(): NetworkResponse<SocketTokenResponse>
@@ -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,303 @@
package com.yzx.kiosk.priter
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.security.MessageDigest
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
/**
* 打印图片完整性校验。
*
* 网络图片必须先完整下载为字节数组,再经过容器、尺寸和软件 Bitmap 解码校验;
* 最终 BMP 必须通过文件头、尺寸、位深和精确文件长度校验后才允许送入打印 SDK。
*/
object PrintImageIntegrityValidator {
const val MAX_DOWNLOAD_BYTES: Long = 50L * 1024L * 1024L
private const val MIN_IMAGE_BYTES = 128
private const val MAX_IMAGE_DIMENSION = 30_000
private const val MAX_DECODED_PIXELS = 24_000_000L
private const val TARGET_LONG_EDGE = 1_840
private const val TARGET_SHORT_EDGE = 1_240
data class DecodedImage(
val bitmap: Bitmap,
val format: String,
val mimeType: String?,
val sourceWidth: Int,
val sourceHeight: Int,
val sampleSize: Int,
val sha256: String
)
data class BmpValidation(
val width: Int,
val height: Int,
val bitsPerPixel: Int,
val fileSize: Long,
val sha256: String
)
fun decodeDownloadedImage(bytes: ByteArray): DecodedImage {
if (bytes.size < MIN_IMAGE_BYTES) {
throw IOException("图片数据过小: ${bytes.size} bytes")
}
if (bytes.size.toLong() > MAX_DOWNLOAD_BYTES) {
throw IOException("图片数据超过上限: ${bytes.size} bytes")
}
val format = detectAndValidateContainer(bytes)
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
val sourceWidth = bounds.outWidth
val sourceHeight = bounds.outHeight
if (sourceWidth <= 0 || sourceHeight <= 0) {
throw IOException("无法解析图片尺寸,format=$format, mime=${bounds.outMimeType}")
}
if (sourceWidth > MAX_IMAGE_DIMENSION || sourceHeight > MAX_IMAGE_DIMENSION) {
throw IOException("图片尺寸异常: ${sourceWidth}x${sourceHeight}")
}
val sampleSize = calculateInSampleSize(sourceWidth, sourceHeight)
val decodeOptions = BitmapFactory.Options().apply {
inJustDecodeBounds = false
inPreferredConfig = Bitmap.Config.ARGB_8888
inSampleSize = sampleSize
inScaled = false
}
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOptions)
?: throw IOException("图片完整解码失败: ${sourceWidth}x${sourceHeight}, format=$format")
if (decoded.width <= 0 || decoded.height <= 0 || decoded.isRecycled) {
decoded.recycle()
throw IOException("解码后的 Bitmap 无效")
}
if (decoded.config == Bitmap.Config.HARDWARE) {
decoded.recycle()
throw IOException("打印图片意外解码为硬件 Bitmap")
}
// 主动读取像素,确保 Bitmap 的像素缓冲区可访问。
try {
decoded.getPixel(decoded.width / 2, decoded.height / 2)
} catch (e: Exception) {
decoded.recycle()
throw IOException("Bitmap 像素缓冲区不可读", e)
}
return DecodedImage(
bitmap = decoded,
format = format,
mimeType = bounds.outMimeType,
sourceWidth = sourceWidth,
sourceHeight = sourceHeight,
sampleSize = sampleSize,
sha256 = sha256(bytes)
)
}
fun validateBmp(file: File, expectedWidth: Int, expectedHeight: Int): BmpValidation {
if (!file.isFile || file.length() < 54L) {
throw IOException("BMP 文件不存在或过小: ${file.absolutePath}")
}
val header = ByteArray(54)
FileInputStream(file).use { input ->
var offset = 0
while (offset < header.size) {
val read = input.read(header, offset, header.size - offset)
if (read < 0) break
offset += read
}
if (offset != header.size) {
throw IOException("BMP 文件头不完整: $offset/${header.size}")
}
}
if (header[0] != 'B'.code.toByte() || header[1] != 'M'.code.toByte()) {
throw IOException("BMP 文件签名错误")
}
val declaredFileSize = uint32Le(header, 2)
val pixelOffset = uint32Le(header, 10)
val dibHeaderSize = uint32Le(header, 14)
val width = int32Le(header, 18)
val rawHeight = int32Le(header, 22)
val height = abs(rawHeight)
val planes = uint16Le(header, 26)
val bitsPerPixel = uint16Le(header, 28)
val compression = uint32Le(header, 30)
if (dibHeaderSize < 40L || pixelOffset < 54L) {
throw IOException("BMP 文件头结构异常")
}
if (width != expectedWidth || height != expectedHeight) {
throw IOException("BMP 尺寸不匹配: ${width}x${height}, expected=${expectedWidth}x${expectedHeight}")
}
if (planes != 1 || bitsPerPixel != 24 || compression != 0L) {
throw IOException("BMP 格式不受支持: planes=$planes, bpp=$bitsPerPixel, compression=$compression")
}
val rowStride = ((width.toLong() * 3L + 3L) / 4L) * 4L
val expectedFileSize = pixelOffset + rowStride * height.toLong()
val actualFileSize = file.length()
if (declaredFileSize != actualFileSize || expectedFileSize != actualFileSize) {
throw IOException(
"BMP 文件长度异常: declared=$declaredFileSize, expected=$expectedFileSize, actual=$actualFileSize"
)
}
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, bounds)
if (bounds.outWidth != expectedWidth || abs(bounds.outHeight) != expectedHeight) {
throw IOException("BMP 无法重新解析: ${bounds.outWidth}x${bounds.outHeight}")
}
return BmpValidation(
width = width,
height = height,
bitsPerPixel = bitsPerPixel,
fileSize = actualFileSize,
sha256 = sha256(file)
)
}
private fun calculateInSampleSize(width: Int, height: Int): Int {
val longEdge = max(width, height)
val shortEdge = min(width, height)
var sampleSize = 1
while (
longEdge / (sampleSize * 2) >= TARGET_LONG_EDGE &&
shortEdge / (sampleSize * 2) >= TARGET_SHORT_EDGE
) {
sampleSize *= 2
}
while (
width.toLong() / sampleSize * (height.toLong() / sampleSize) > MAX_DECODED_PIXELS
) {
sampleSize *= 2
}
return sampleSize
}
private fun detectAndValidateContainer(bytes: ByteArray): String {
return when {
isJpeg(bytes) -> {
val searchStart = max(2, bytes.size - 64)
var hasEndMarker = false
for (index in bytes.size - 2 downTo searchStart) {
if (byteAt(bytes, index) == 0xFF && byteAt(bytes, index + 1) == 0xD9) {
hasEndMarker = true
break
}
}
if (!hasEndMarker) throw IOException("JPEG 缺少结束标记,文件可能被截断")
"JPEG"
}
isPng(bytes) -> {
if (
bytes.size < 20 ||
bytes[bytes.size - 8] != 'I'.code.toByte() ||
bytes[bytes.size - 7] != 'E'.code.toByte() ||
bytes[bytes.size - 6] != 'N'.code.toByte() ||
bytes[bytes.size - 5] != 'D'.code.toByte()
) {
throw IOException("PNG 缺少 IEND,文件可能被截断")
}
"PNG"
}
isWebp(bytes) -> {
val declaredSize = uint32Le(bytes, 4) + 8L
if (declaredSize != bytes.size.toLong()) {
throw IOException("WebP 文件长度异常: declared=$declaredSize, actual=${bytes.size}")
}
"WEBP"
}
isGif(bytes) -> {
if (byteAt(bytes, bytes.lastIndex) != 0x3B) {
throw IOException("GIF 缺少结束标记,文件可能被截断")
}
"GIF"
}
isBmp(bytes) -> {
val declaredSize = uint32Le(bytes, 2)
if (declaredSize != bytes.size.toLong()) {
throw IOException("BMP 文件长度异常: declared=$declaredSize, actual=${bytes.size}")
}
"BMP"
}
isIsoBaseMedia(bytes) -> "HEIF/AVIF"
else -> throw IOException("不支持或无法识别的图片格式")
}
}
private fun isJpeg(bytes: ByteArray): Boolean =
bytes.size >= 4 && byteAt(bytes, 0) == 0xFF && byteAt(bytes, 1) == 0xD8
private fun isPng(bytes: ByteArray): Boolean =
bytes.size >= 8 &&
byteAt(bytes, 0) == 0x89 && bytes[1] == 'P'.code.toByte() &&
bytes[2] == 'N'.code.toByte() && bytes[3] == 'G'.code.toByte() &&
byteAt(bytes, 4) == 0x0D && byteAt(bytes, 5) == 0x0A &&
byteAt(bytes, 6) == 0x1A && byteAt(bytes, 7) == 0x0A
private fun isWebp(bytes: ByteArray): Boolean =
bytes.size >= 12 && ascii(bytes, 0, 4) == "RIFF" && ascii(bytes, 8, 4) == "WEBP"
private fun isGif(bytes: ByteArray): Boolean =
bytes.size >= 6 && (ascii(bytes, 0, 6) == "GIF87a" || ascii(bytes, 0, 6) == "GIF89a")
private fun isBmp(bytes: ByteArray): Boolean =
bytes.size >= 14 && bytes[0] == 'B'.code.toByte() && bytes[1] == 'M'.code.toByte()
private fun isIsoBaseMedia(bytes: ByteArray): Boolean =
bytes.size >= 12 && ascii(bytes, 4, 4) == "ftyp"
private fun ascii(bytes: ByteArray, offset: Int, length: Int): String =
bytes.copyOfRange(offset, offset + length).toString(Charsets.US_ASCII)
private fun byteAt(bytes: ByteArray, index: Int): Int = bytes[index].toInt() and 0xFF
private fun uint16Le(bytes: ByteArray, offset: Int): Int =
byteAt(bytes, offset) or (byteAt(bytes, offset + 1) shl 8)
private fun int32Le(bytes: ByteArray, offset: Int): Int =
byteAt(bytes, offset) or
(byteAt(bytes, offset + 1) shl 8) or
(byteAt(bytes, offset + 2) shl 16) or
(byteAt(bytes, offset + 3) shl 24)
private fun uint32Le(bytes: ByteArray, offset: Int): Long =
int32Le(bytes, offset).toLong() and 0xFFFF_FFFFL
private fun sha256(bytes: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(bytes).toHex()
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
FileInputStream(file).use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val count = input.read(buffer)
if (count < 0) break
if (count > 0) digest.update(buffer, 0, count)
}
}
return digest.digest().toHex()
}
private fun ByteArray.toHex(): String = joinToString(separator = "") { byte -> "%02x".format(byte) }
}
@@ -5,7 +5,6 @@ import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.Paint
import com.yzx.kiosk.App import com.yzx.kiosk.App
import com.yzx.kiosk.datastore.AppStoreDataSource import com.yzx.kiosk.datastore.AppStoreDataSource
import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.LogUtils
@@ -23,8 +22,11 @@ import jp.co.dnpLib.print.PrintManager
import jp.co.dnpLib.print.PrintQueue import jp.co.dnpLib.print.PrintQueue
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.IOException
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.math.min import kotlin.math.min
@@ -40,9 +42,15 @@ class PrinterService(
) { ) {
companion object { companion object {
private const val TAG = "PrinterService" private const val TAG = "PrinterService"
// DNP BmpUtil 在 300 DPI 下会将 RX1 输入画布编码为 1844x1240 BMP。
// 1840x1240 仍是上层排版画布尺寸,不应用它校验 SDK 的最终文件头。
private const val RX1_ENCODED_BMP_WIDTH_300_DPI = 1844
private const val RX1_ENCODED_BMP_HEIGHT_300_DPI = 1240
} }
private var mDnpPhotoPrint: DNPPhotoPrint? = null private var mDnpPhotoPrint: DNPPhotoPrint? = null
private val printMutex = Mutex()
/** /**
* 初始化打印机(耗时操作,需在IO线程调用) * 初始化打印机(耗时操作,需在IO线程调用)
@@ -182,12 +190,29 @@ class PrinterService(
* @param originalBitmap 要打印的原始图片 * @param originalBitmap 要打印的原始图片
* @param callback 打印结果回调 * @param callback 打印结果回调
*/ */
suspend fun startPrint(originalBitmap: Bitmap?, callback: PrintCallback) = withContext(Dispatchers.IO) { suspend fun startPrint(originalBitmap: Bitmap?, callback: PrintCallback) = printMutex.withLock {
startPrintInternal(originalBitmap, callback)
}
private suspend fun startPrintInternal(
originalBitmap: Bitmap?,
callback: PrintCallback
) = withContext(Dispatchers.IO) {
if (originalBitmap == null) { if (originalBitmap == null) {
LogUtils.e(TAG, "获取图片失败") LogUtils.e(TAG, "获取图片失败")
callback.onPrintResult(false,"获取图片失败") callback.onPrintResult(false,"获取图片失败")
return@withContext return@withContext
} }
if (
originalBitmap.isRecycled ||
originalBitmap.width <= 0 ||
originalBitmap.height <= 0 ||
originalBitmap.config == Bitmap.Config.HARDWARE
) {
LogUtils.e(TAG, "图片 Bitmap 无效或为硬件 Bitmap,禁止打印")
callback.onPrintResult(false, "图片数据无效")
return@withContext
}
LogUtils.e(TAG, "获取图片成功") LogUtils.e(TAG, "获取图片成功")
// 在开始打印前,检查打印机是否在线 // 在开始打印前,检查打印机是否在线
@@ -224,17 +249,22 @@ class PrinterService(
LogUtils.d(TAG, "打印尺寸: ${printSize.width} x ${printSize.height}") LogUtils.d(TAG, "打印尺寸: ${printSize.width} x ${printSize.height}")
// 准备打印图片(保持原比例,居中,白色背景) // 准备打印图片(保持原比例,居中,白色背景)
val outputBitmap: Bitmap var outputBitmap: Bitmap? = null
val outFile = File( val outFile = File(
context.getExternalFilesDir(null), context.getExternalFilesDir(null),
"ProcessedPhotos/${System.currentTimeMillis()}_print.bmp" "ProcessedPhotos/${System.currentTimeMillis()}_print.bmp"
) )
outFile.parentFile?.mkdirs() val outputDirectory = outFile.parentFile
if (outputDirectory == null || (!outputDirectory.exists() && !outputDirectory.mkdirs())) {
printStatusManager.setIdle()
callback.onPrintResult(false, "无法创建打印文件目录")
return@withContext
}
try { try {
outputBitmap = preparePrintBitmap(originalBitmap, printSize, printId) outputBitmap = preparePrintBitmap(originalBitmap, printSize, printId)
if (printId != PrintManager.EPrinter.QW410_DEF.id) { val saved = if (printId != PrintManager.EPrinter.QW410_DEF.id) {
BmpUtil.save(outputBitmap, outFile.absolutePath, EResolution.RESO300.mValue) BmpUtil.save(outputBitmap, outFile.absolutePath, EResolution.RESO300.mValue)
} else { } else {
AndroidBmpUtil.save( AndroidBmpUtil.save(
@@ -244,12 +274,45 @@ class PrinterService(
PRINTSIZE.QW410.height PRINTSIZE.QW410.height
) )
} }
if (!saved) {
throw IOException("BMP 保存接口返回失败")
}
val expectedBmpWidth: Int
val expectedBmpHeight: Int
if (printId != PrintManager.EPrinter.QW410_DEF.id) {
expectedBmpWidth = RX1_ENCODED_BMP_WIDTH_300_DPI
expectedBmpHeight = RX1_ENCODED_BMP_HEIGHT_300_DPI
} else {
expectedBmpWidth = printSize.width
expectedBmpHeight = printSize.height
}
val validation = PrintImageIntegrityValidator.validateBmp(
file = outFile,
expectedWidth = expectedBmpWidth,
expectedHeight = expectedBmpHeight
)
LogUtils.d(
TAG,
"打印 BMP 校验成功 - path=${outFile.absolutePath}, " +
"size=${validation.width}x${validation.height}, " +
"bpp=${validation.bitsPerPixel}, bytes=${validation.fileSize}, " +
"sha256=${validation.sha256.take(16)}"
)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() LogUtils.e(TAG, "图片处理或 BMP 完整性校验失败: ${e.message}")
callback.onPrintResult(false,"图片处理失败: ${e.message}") outputBitmap?.let { bitmap ->
if (!bitmap.isRecycled) bitmap.recycle()
}
printStatusManager.setIdle()
callback.onPrintResult(false, "图片处理失败: ${e.message}")
return@withContext return@withContext
} }
val validatedOutputBitmap = checkNotNull(outputBitmap)
// 创建打印任务 // 创建打印任务
val job = PrintJob( val job = PrintJob(
outFile.absolutePath, outFile.absolutePath,
@@ -269,8 +332,8 @@ class PrinterService(
printStatusManager.setIdle() printStatusManager.setIdle()
// 回收 outputBitmap // 回收 outputBitmap
try { try {
if (!outputBitmap.isRecycled) { if (!validatedOutputBitmap.isRecycled) {
outputBitmap.recycle() validatedOutputBitmap.recycle()
LogUtils.d(TAG, "outputBitmap 已回收") LogUtils.d(TAG, "outputBitmap 已回收")
} }
} catch (ex: Exception) { } catch (ex: Exception) {
@@ -285,8 +348,8 @@ class PrinterService(
printStatusManager.setIdle() printStatusManager.setIdle()
// 回收 outputBitmap // 回收 outputBitmap
try { try {
if (!outputBitmap.isRecycled) { if (!validatedOutputBitmap.isRecycled) {
outputBitmap.recycle() validatedOutputBitmap.recycle()
LogUtils.d(TAG, "outputBitmap 已回收") LogUtils.d(TAG, "outputBitmap 已回收")
} }
} catch (ex: Exception) { } catch (ex: Exception) {
@@ -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,6 +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 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
@@ -24,6 +28,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.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
@@ -35,6 +43,9 @@ import coil.compose.AsyncImage
import coil.compose.SubcomposeAsyncImage 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 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
@@ -46,25 +57,46 @@ import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionResultViewModel
import com.yzx.kiosk.ui.upload.view.PhotoPreviewDialog import com.yzx.kiosk.ui.upload.view.PhotoPreviewDialog
import com.yzx.kiosk.ui.upload.view.RetryableAsyncImage import com.yzx.kiosk.ui.upload.view.RetryableAsyncImage
import com.yzx.kiosk.utils.QrCodeUtils import com.yzx.kiosk.utils.QrCodeUtils
import kotlinx.coroutines.launch import kotlinx.coroutines.flow.collect
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
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()
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
@@ -77,6 +109,12 @@ fun FaceRecognitionResultScreen(
var showPayDialog by remember { mutableStateOf(false) } var showPayDialog by remember { mutableStateOf(false) }
val payQrCodeUrl by viewModel.payQrCodeUrl.collectAsState() val payQrCodeUrl by viewModel.payQrCodeUrl.collectAsState()
LaunchedEffect(viewModel) {
viewModel.dismissPayDialogEvents.collect {
showPayDialog = false
}
}
FullScreenMode() FullScreenMode()
AppScaffold( AppScaffold(
@@ -84,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
@@ -110,109 +148,147 @@ fun FaceRecognitionResultScreen(
shape = RoundedCornerShape(18.dp), shape = RoundedCornerShape(18.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFF4F4F4)) colors = CardDefaults.cardColors(containerColor = Color(0xFFF4F4F4))
) { ) {
Row( Column(Modifier.fillMaxWidth().padding(24.dp)) {
modifier = Modifier Row(
.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
.padding(24.dp), verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "已选择",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = Color.Black
)
Text(
modifier = Modifier.padding(top = 4.dp),
text = selectedCount.toString(),
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF0073FF)
)
Text(
text = "张",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = Color.Black
)
}
Spacer(modifier = Modifier.height(18.dp))
Text(
text = "打印${pricePerPhoto}元/张",
fontSize = 21.sp,
color = Color(0xFF000000)
)
}
Button(
onClick = {
viewModel.getPayUrl { url ->
showPayDialog = true
}
},
modifier = Modifier
.height(72.dp)
.widthIn(min = 180.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073FF)),
enabled = selectedCount > 0
) { ) {
Text( Text("已选择 ${selectedCount} 张", fontSize = 24.sp, fontWeight = FontWeight.Bold)
text = if (selectedCount > 0) "支付${totalPrice}元" else "支付0元", Spacer(Modifier.weight(1f))
fontSize = 24.sp, Row(
color = Color.White, horizontalArrangement = Arrangement.spacedBy(20.dp),
fontWeight = FontWeight.Bold verticalAlignment = Alignment.CenterVertically,
) ) {
Text("打印版 ${formatPriceDisplay(pricePerPhoto)}元/张", fontSize = 21.sp)
Text("电子版 ${formatPriceDisplay(electronicPrice)}元/张", fontSize = 21.sp)
}
}
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(
onClick = { viewModel.getPayUrl(mode) { showPayDialog = true } },
enabled = selectedCount > 0 && total != "--" && !creatingOrder && !quoteLoading,
modifier = Modifier.fillMaxWidth().heightIn(min = 88.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (isPrint) Color(0xFF0073FF) else Color(0xFFE3EFFF),
contentColor = if (isPrint) Color.White else Color(0xFF1756A9),
),
) {
Text(
text = if (!isPrint && total == "--" && !quoteLoading)
"电子版暂不可购买" else "${if (isPrint) "购买打印照片" else "购买电子照片"}\n${formatPriceDisplay(total)}元",
textAlign = TextAlign.Center,
fontSize = 24.sp,
lineHeight = 32.sp,
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() },
verticalAlignment = Alignment.CenterVertically
) { ) {
// 复选框 Row(
Box( modifier = Modifier.weight(1f).heightIn(min = 48.dp)
modifier = Modifier .clickable(enabled = photoList.isNotEmpty()) { viewModel.toggleSelectAll() },
.size(24.dp) verticalAlignment = Alignment.CenterVertically,
.clip(RoundedCornerShape(6.dp))
.background(if (isAllSelected) Color(0xFF0073FF) else Color.White)
.border(
width = 2.dp,
color = if (isAllSelected) Color(0xFF0073FF) else Color(0xFFCCCCCC),
shape = RoundedCornerShape(4.dp)
),
contentAlignment = Alignment.Center
) { ) {
if (isAllSelected) { // 复选框
Icon( Box(
imageVector = Icons.Default.Check, modifier = Modifier
contentDescription = null, .size(24.dp)
tint = Color.White, .clip(RoundedCornerShape(6.dp))
modifier = Modifier.size(24.dp) .background(if (isAllSelected) Color(0xFF0073FF) else Color.White)
) .border(
width = 2.dp,
color = if (isAllSelected) Color(0xFF0073FF) else Color(0xFFCCCCCC),
shape = RoundedCornerShape(4.dp)
),
contentAlignment = Alignment.Center
) {
if (isAllSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
Spacer(modifier = Modifier.width(18.dp))
Text(
text = "全选",
fontSize = 24.sp,
color = Color.Black
)
Text(
text = "(共${photoList.size}张)",
fontSize = 24.sp,
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)
} }
} }
}
Spacer(modifier = Modifier.width(18.dp)) if (source == AppRoutes.RECENT_RESULT_SOURCE && recentPhotosState != RecentPhotosState.READY) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text( Column(horizontalAlignment = Alignment.CenterHorizontally) {
text = "全选", if (recentPhotosState == RecentPhotosState.IDLE || recentPhotosState == RecentPhotosState.LOADING) {
fontSize = 24.sp, CircularProgressIndicator(Modifier.size(32.dp))
color = Color.Black Spacer(Modifier.height(16.dp))
) }
Text( Text(
text = "(共${photoList.size}张)", text = when (recentPhotosState) {
fontSize = 24.sp, RecentPhotosState.ERROR -> "照片加载失败,请重试"
color = Color.Black RecentPhotosState.EMPTY -> "暂无可显示的最近照片"
) else -> "正在加载照片…"
},
fontSize = 18.sp,
color = Color(0xFF666666),
)
if (recentPhotosState == RecentPhotosState.ERROR) {
TextButton(onClick = viewModel::retryRecentPhotos) { Text("重试", fontSize = 18.sp) }
}
}
}
} }
// 图片瀑布流列表 // 图片瀑布流列表
@@ -224,12 +300,15 @@ fun FaceRecognitionResultScreen(
horizontalArrangement = Arrangement.spacedBy(18.dp), horizontalArrangement = Arrangement.spacedBy(18.dp),
verticalItemSpacing = 18.dp verticalItemSpacing = 18.dp
) { ) {
itemsIndexed(photoList) { index, photo -> itemsIndexed(
items = photoList,
key = { _, photo -> photo.id }
) { _, photo ->
val isSelected = selectedPhotos.contains(photo.url) val isSelected = selectedPhotos.contains(photo.url)
val imageLoadStates by viewModel.imageLoadStates.collectAsState()
val loadState = imageLoadStates[photo.url] ?: FaceRecognitionResultViewModel.ImageLoadState() val loadState = imageLoadStates[photo.url] ?: FaceRecognitionResultViewModel.ImageLoadState()
FaceRecognitionPhotoItem( FaceRecognitionPhotoItem(
photoId = photo.id,
photoUrl = photo.url, photoUrl = photo.url,
aspectRatio = photo.aspectRatio, aspectRatio = photo.aspectRatio,
isSelected = isSelected, isSelected = isSelected,
@@ -246,6 +325,9 @@ fun FaceRecognitionResultScreen(
}, },
onLoadError = { viewModel.handleImageLoadError(photo.url) }, onLoadError = { viewModel.handleImageLoadError(photo.url) },
onLoadSuccess = { viewModel.handleImageLoadSuccess(photo.url) }, onLoadSuccess = { viewModel.handleImageLoadSuccess(photo.url) },
onImageDimensionsResolved = { width, height ->
viewModel.updatePhotoAspectRatio(photo.id, width, height)
},
onRetryClick = { viewModel.retryLoadImage(photo.url) } onRetryClick = { viewModel.retryLoadImage(photo.url) }
) )
} }
@@ -264,16 +346,20 @@ fun FaceRecognitionResultScreen(
} }
// 支付弹框 // 支付弹框
if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) { if (showPayDialog && payQrCodeUrl != null) {
val selectedUrls = photoList.filter { selectedPhotos.contains(it.url) }.map { it.url } DisposableEffect(payQrCodeUrl) {
viewModel.startPayStatusPolling()
onDispose {
viewModel.stopPayStatusPolling()
}
}
WechatPayDialog( WechatPayDialog(
qrCodeUrl = payQrCodeUrl!!, qrCodeUrl = payQrCodeUrl!!,
totalPrice = totalPrice, totalPrice = paymentAmount,
onDismiss = { showPayDialog = false }, onDismiss = {
onPaySuccess = {
showPayDialog = false showPayDialog = false
// 支付成功,等待 WebSocket 消息(code=5)触发跳转到支付成功页面 viewModel.onPayDialogDismissed()
// 跳转逻辑由 FaceRecognitionResultViewModel 中的 WebSocket 监听处理
} }
) )
} }
@@ -281,6 +367,7 @@ fun FaceRecognitionResultScreen(
@Composable @Composable
fun FaceRecognitionPhotoItem( fun FaceRecognitionPhotoItem(
photoId: Int,
photoUrl: String, photoUrl: String,
aspectRatio: Float, aspectRatio: Float,
isSelected: Boolean, isSelected: Boolean,
@@ -289,6 +376,7 @@ fun FaceRecognitionPhotoItem(
onPreviewClick: () -> Unit, onPreviewClick: () -> Unit,
onLoadError: () -> Unit = {}, onLoadError: () -> Unit = {},
onLoadSuccess: () -> Unit = {}, onLoadSuccess: () -> Unit = {},
onImageDimensionsResolved: (width: Int, height: Int) -> Unit = { _, _ -> },
onRetryClick: () -> Unit = {} onRetryClick: () -> Unit = {}
) { ) {
// 确保 aspectRatio 是有效值 // 确保 aspectRatio 是有效值
@@ -306,11 +394,20 @@ fun FaceRecognitionPhotoItem(
.clickable { onToggleSelection() } .clickable { onToggleSelection() }
) { ) {
// 图片 - 按原比例显示 // 图片 - 按原比例显示
var lastErrorState by remember { mutableStateOf(false) } var lastErrorState by remember(photoId, photoUrl) { mutableStateOf(false) }
var lastSuccessState by remember { mutableStateOf(false) } var lastSuccessState by remember(photoId, photoUrl) { mutableStateOf(false) }
val context = LocalContext.current
val imageRequest = remember(context, photoUrl) {
ImageRequest.Builder(context)
.data(photoUrl)
.allowHardware(false)
.bitmapConfig(Bitmap.Config.ARGB_8888)
.crossfade(false)
.build()
}
SubcomposeAsyncImage( SubcomposeAsyncImage(
model = photoUrl, model = imageRequest,
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit contentScale = ContentScale.Fit
@@ -390,6 +487,11 @@ fun FaceRecognitionPhotoItem(
if (state is coil.compose.AsyncImagePainter.State.Success && !lastSuccessState) { if (state is coil.compose.AsyncImagePainter.State.Success && !lastSuccessState) {
lastSuccessState = true lastSuccessState = true
lastErrorState = false lastErrorState = false
val drawable = state.result.drawable
onImageDimensionsResolved(
drawable.intrinsicWidth,
drawable.intrinsicHeight
)
onLoadSuccess() onLoadSuccess()
} }
} }
@@ -453,8 +555,7 @@ fun FaceRecognitionPhotoItem(
fun WechatPayDialog( fun WechatPayDialog(
qrCodeUrl: String, qrCodeUrl: String,
totalPrice: String, totalPrice: String,
onDismiss: () -> Unit, onDismiss: () -> Unit
onPaySuccess: () -> Unit
) { ) {
// 使用接口返回的URL生成支付二维码 // 使用接口返回的URL生成支付二维码
val qrCodeBitmap = remember(qrCodeUrl) { val qrCodeBitmap = remember(qrCodeUrl) {
@@ -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)
val previewView = remember(context) { PreviewView(context) }
val preview = remember { Preview.Builder().build() }
val imageCapture = remember {
ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
}
AndroidView( AndroidView(
factory = { ctx -> factory = { previewView },
val previewView = PreviewView(ctx)
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
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)
.build()
// 通知ViewModel ImageCapture已准备好
onImageCaptureReady(imageCapture)
// 绑定到生命周期
val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageCapture
)
} catch (e: Exception) {
e.printStackTrace()
}
}, ContextCompat.getMainExecutor(ctx))
previewView
},
modifier = modifier modifier = modifier
) )
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) {
e.printStackTrace()
}
}, executor)
onDispose {
disposed = true
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}")
}
}
}
}
@@ -0,0 +1,26 @@
package com.yzx.kiosk.ui.face.viewmodel
import com.yzx.kiosk.network.model.response.PaySuccessMessageResponse
internal enum class FacePayStatusDecision {
CONTINUE_POLLING,
COMPLETE_PAYMENT,
STOP_WITH_TERMINAL_STATUS
}
internal fun decideFacePayStatus(orderStatus: Int?): FacePayStatusDecision = when (orderStatus) {
30 -> FacePayStatusDecision.COMPLETE_PAYMENT
10 -> FacePayStatusDecision.CONTINUE_POLLING
else -> FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS
}
internal fun isValidFacePaySuccessMessage(
expectedOrderNumber: String,
message: PaySuccessMessageResponse?
): Boolean {
val paymentData = message?.data ?: return false
return message.orderStatus == 30 && message.type == 5 &&
paymentData.orderNumber == expectedOrderNumber &&
paymentData.captureType == 2 &&
!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
}
@@ -1,20 +1,17 @@
package com.yzx.kiosk.ui.face.viewmodel package com.yzx.kiosk.ui.face.viewmodel
import android.graphics.ImageDecoder
import android.os.Build
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Size
import com.yzx.kiosk.App
import com.yzx.kiosk.base.BaseViewModel import com.yzx.kiosk.base.BaseViewModel
import com.yzx.kiosk.datastore.AppState import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.navigation.AppNavigator import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.network.model.request.GetPayUrlRequest import com.yzx.kiosk.network.model.request.GetPayUrlRequest
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
import com.yzx.kiosk.network.model.request.VerifyResultRequest import com.yzx.kiosk.network.model.request.VerifyResultRequest
import com.yzx.kiosk.network.model.response.FaceSearchResult import com.yzx.kiosk.network.model.response.FaceSearchResult
import com.yzx.kiosk.network.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
@@ -25,15 +22,21 @@ import com.yzx.kiosk.datastore.AppStoreDataSource
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async import kotlinx.coroutines.Job
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.LogUtils
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -41,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()
@@ -69,16 +103,44 @@ 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()
// 支付弹框关闭事件(支付完成、取消或退款时由 ViewModel 驱动关闭)
private val _dismissPayDialogEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val dismissPayDialogEvents: SharedFlow<Unit> = _dismissPayDialogEvents.asSharedFlow()
private var activePaymentOrderNumber: String? = null
private var paymentPollingJob: Job? = null
private val paymentHandled = AtomicBoolean(false)
// 图片加载状态:URL -> (重试次数, 是否加载失败) // 图片加载状态:URL -> (重试次数, 是否加载失败)
private val _imageLoadStates = MutableStateFlow<Map<String, ImageLoadState>>(emptyMap()) private val _imageLoadStates = MutableStateFlow<Map<String, ImageLoadState>>(emptyMap())
val imageLoadStates: StateFlow<Map<String, ImageLoadState>> = _imageLoadStates.asStateFlow() val imageLoadStates: StateFlow<Map<String, ImageLoadState>> = _imageLoadStates.asStateFlow()
companion object { companion object {
private const val TAG = "FaceRecognitionResultViewModel"
private const val MAX_RETRY_COUNT = 3 private const val MAX_RETRY_COUNT = 3
private const val PAY_STATUS_POLL_INTERVAL_MS = 2_000L
} }
data class ImageLoadState( data class ImageLoadState(
@@ -93,9 +155,14 @@ class FaceRecognitionResultViewModel @Inject constructor(
.onEach { event -> .onEach { event ->
when (event) { when (event) {
is UploadPhotoEvent.PaySuccess -> { is UploadPhotoEvent.PaySuccess -> {
// 支付成功,跳转到支付成功页面 LogUtils.d(TAG, "收到 WebSocket 支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, image_ids: ${event.imageIds}")
LogUtils.d("FaceRecognitionResultViewModel", "收到支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, image_ids: ${event.imageIds}") handlePaymentSuccess(
navigateToPaySuccess(event.orderNumber, event.captureType, event.imageIds) orderNumber = event.orderNumber,
captureType = event.captureType,
imageIds = event.imageIds,
purchaseMode = event.purchaseMode,
source = "WebSocket"
)
} }
else -> { else -> {
// 其他事件不处理 // 其他事件不处理
@@ -108,107 +175,79 @@ 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
originalResults = results faceResultsInitialized = true
verifyResult(emptyList())
viewModelScope.launch { applyPhotoList(results) }
}
// 先使用默认宽高比创建列表 private suspend fun applyPhotoList(results: List<FaceSearchResult>) {
val initialPhotos = results.mapNotNull { result -> // 保存原始结果列表
// 使用 thumbnail_oss_url originalResults = results
val imageUrl = if (appStoreDataSource.getUseLan()) result.thumbnailLanUrl else result.thumbnailOssUrl urlToIdMap.clear()
if (imageUrl.isNullOrEmpty()) {
LogUtils.i("FaceRecognitionResultViewModel", "图片URL为空,跳过") // 先使用默认宽高比创建列表
null val initialPhotos = results.mapNotNull { result ->
} else { // 使用 thumbnail_oss_url
val trimmedUrl = imageUrl.trim() val imageUrl = if (appStoreDataSource.getUseLan()) result.thumbnailLanUrl else result.thumbnailOssUrl
// 建立URL到ID的映射 if (imageUrl.isNullOrEmpty()) {
urlToIdMap[trimmedUrl] = result.id LogUtils.i("FaceRecognitionResultViewModel", "图片URL为空,跳过")
PhotoData( null
id = result.id, // 使用图片ID } else {
url = trimmedUrl, val trimmedUrl = imageUrl.trim()
aspectRatio = 1f // 默认1:1,后续会更新为真实宽高比 // 建立URL到ID的映射
) urlToIdMap[trimmedUrl] = result.id
} PhotoData(
id = result.id, // 使用图片ID
url = trimmedUrl,
aspectRatio = 1f // 默认1:1,后续会更新为真实宽高比
)
} }
if (initialPhotos.isEmpty()) {
LogUtils.i("FaceRecognitionResultViewModel", "没有有效的图片URL")
_photoList.value = emptyList()
_imageLoadStates.value = emptyMap()
return@launch
}
_photoList.value = initialPhotos
// 初始化加载状态
_imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() }
// 页面初始化时调用验证接口(空列表,type: 2),获取默认价格
verifyResult(emptyList())
// 并发获取所有图片的真实尺寸并更新宽高比
val updatedPhotos = initialPhotos.mapIndexed { index, photo ->
async(Dispatchers.IO) {
try {
val aspectRatio = getImageAspectRatio(photo.url)
// 确保 aspectRatio 是有效值
val validAspectRatio = if (aspectRatio > 0 && aspectRatio.isFinite()) {
aspectRatio
} else {
LogUtils.i("FaceRecognitionResultViewModel", "图片 ${photo.url} 的宽高比无效: $aspectRatio,使用默认值")
1f
}
photo.copy(aspectRatio = validAspectRatio)
} catch (e: Exception) {
LogUtils.e("FaceRecognitionResultViewModel", "获取图片 ${photo.url} 尺寸失败: ${e.message}")
// 获取失败,保持默认值
photo
}
}
}.awaitAll()
_photoList.value = updatedPhotos
LogUtils.d("FaceRecognitionResultViewModel", "图片列表初始化完成,共 ${updatedPhotos.size} 张")
} }
if (initialPhotos.isEmpty()) {
LogUtils.i("FaceRecognitionResultViewModel", "没有有效的图片URL")
_photoList.value = emptyList()
_imageLoadStates.value = emptyMap()
return
}
_photoList.value = initialPhotos
// 初始化加载状态
_imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() }
LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新")
} }
/** /**
* 获取图片的宽高比 * 使用网格中已经加载成功的 Drawable 尺寸更新宽高比。
* 这样不会为了获取尺寸额外下载、解码一次图片。
*/ */
private suspend fun getImageAspectRatio(imageUrl: String): Float { fun updatePhotoAspectRatio(photoId: Int, width: Int, height: Int) {
return withTimeoutOrNull(10000) { // 10秒超时 if (width <= 0 || height <= 0) {
try { LogUtils.i(TAG, "忽略无效图片尺寸 - id: $photoId, size: ${width}x${height}")
val imageLoader = ImageLoader(App.instance) return
val request = ImageRequest.Builder(App.instance) }
.data(imageUrl)
.size(Size.ORIGINAL)
.allowHardware(false) // 禁用硬件加速,避免某些图片无法获取尺寸
.build()
val result = imageLoader.execute(request) val aspectRatio = width.toFloat() / height.toFloat()
val drawable = result.drawable if (!aspectRatio.isFinite() || aspectRatio !in 0.05f..20f) {
LogUtils.i(TAG, "忽略异常图片宽高比 - id: $photoId, ratio: $aspectRatio")
return
}
if (drawable != null) { _photoList.update { photos ->
val width = drawable.intrinsicWidth photos.map { photo ->
val height = drawable.intrinsicHeight if (photo.id == photoId && kotlin.math.abs(photo.aspectRatio - aspectRatio) > 0.01f) {
LogUtils.d(TAG, "更新图片比例 - id: $photoId, size: ${width}x${height}, ratio: $aspectRatio")
if (width > 0 && height > 0) { photo.copy(aspectRatio = aspectRatio)
val aspectRatio = width.toFloat() / height.toFloat()
LogUtils.d("FaceRecognitionResultViewModel", "图片 $imageUrl 尺寸: ${width}x${height}, 宽高比: $aspectRatio")
return@withTimeoutOrNull aspectRatio
} else {
LogUtils.i("FaceRecognitionResultViewModel", "图片 $imageUrl 尺寸无效: ${width}x${height}")
}
} else { } else {
LogUtils.i("FaceRecognitionResultViewModel", "图片 $imageUrl 加载失败,drawable为null") photo
} }
} catch (e: Exception) {
LogUtils.e("FaceRecognitionResultViewModel"+"获取图片 $imageUrl 尺寸异常: ${e.message}")
} }
null
} ?: run {
LogUtils.i("FaceRecognitionResultViewModel", "获取图片 $imageUrl 尺寸超时")
1f // 超时或失败,返回默认值
} }
} }
@@ -216,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)
@@ -233,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) {
// 当前全选,取消全选 // 当前全选,取消全选
@@ -261,106 +303,272 @@ 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() } ?: "获取报价失败,请重试"
return@launch
handleResultWithData(
flow = netWorkRepository.verifyResult(request).asResult(),
showToast = false,
onData = { response ->
// 更新单价(使用接口返回的 price_image)
val priceImage = response.priceImage
if (!priceImage.isNullOrEmpty()) {
_pricePerPhoto.value = priceImage
} else {
_pricePerPhoto.value = "--"
}
// 更新总价(使用接口返回的 amount)
val amount = response.amount
if (!amount.isNullOrEmpty()) {
_totalPrice.value = amount
} else {
_totalPrice.value = "--"
}
LogUtils.d("FaceRecognitionResultViewModel", "验证结果成功 - 单价: $priceImage, 总价: $amount")
},
onError = { msg, _ ->
LogUtils.e("FaceRecognitionResultViewModel", "验证结果失败: $msg")
// 接口失败时,保持当前值或设置为默认值
if (imageIds.isEmpty()) {
_pricePerPhoto.value = "--"
_totalPrice.value = "--"
}
}
)
} catch (e: Exception) {
LogUtils.e("FaceRecognitionResultViewModel", "验证结果异常: ${e.message}")
if (imageIds.isEmpty()) {
_pricePerPhoto.value = "--"
_totalPrice.value = "--"
} }
val 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() {
* 获取支付URL stopPayStatusPolling()
*/ activeOrder = null
fun getPayUrl(onSuccess: (String) -> Unit) { 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 { viewModelScope.launch {
val selectedIds = getSelectedImageIds()
if (selectedIds.isEmpty()) {
ToastUtils.show("请选择要打印的照片")
return@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 createFacePaymentOrder(data, mode, selectedIds, quotedAmount) else null
if (url.isNullOrEmpty()) { if (order == null) {
ToastUtils.show("获取支付二维码失败") recoverPayment("支付链接信息无效,请重新选择")
} else { return@launch
_payQrCodeUrl.value = url }
onSuccess(url) stopPayStatusPolling()
} activeOrder = order
}, activePaymentOrderNumber = order.number
onError = { msg, _ -> paymentHandled.set(false)
LogUtils.e("FaceRecognitionResultViewModel", "获取支付URL失败: $msg") _paymentAmount.value = order.amount
ToastUtils.show("获取支付二维码失败") _paymentMode.value = order.mode
}, val url = checkNotNull(data?.url).trim()
onEnd = { _payQrCodeUrl.value = url
hideLoading() onSuccess(url)
} } catch (e: CancellationException) {
) throw e
} 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()
} }
} }
} }
/** /**
* 跳转到支付成功页面 * 二维码弹框显示时启动支付状态轮询。重复调用不会创建多个轮询任务。
*/
fun startPayStatusPolling() {
val orderNumber = activePaymentOrderNumber
if (orderNumber.isNullOrEmpty() || paymentHandled.get()) {
LogUtils.i(TAG, "未启动支付状态轮询:当前没有有效待支付订单")
return
}
if (paymentPollingJob?.isActive == true) {
return
}
paymentPollingJob = viewModelScope.launch {
LogUtils.d(TAG, "开始轮询支付状态 - order_number: $orderNumber")
while (
isActive &&
!paymentHandled.get() &&
activePaymentOrderNumber == orderNumber
) {
queryPayStatus(orderNumber)
delay(PAY_STATUS_POLL_INTERVAL_MS)
}
}
}
/** 二维码弹框关闭时停止 HTTP 轮询,WebSocket 监听仍保持有效。 */
fun stopPayStatusPolling() {
paymentPollingJob?.cancel()
paymentPollingJob = null
}
fun onPayDialogDismissed() {
stopPayStatusPolling()
}
private suspend fun queryPayStatus(orderNumber: String) {
try {
val response = netWorkRepository
.getPaySuccessMessage(PaySuccessMessageRequest(orderNumber))
.first()
if (activePaymentOrderNumber != orderNumber || paymentHandled.get()) return
if (!response.isSucceeded) {
LogUtils.e(
TAG,
"查询支付状态业务失败 - order_number: $orderNumber, code: ${response.code}, msg: ${response.message}"
)
recoverPayment(response.message?.takeIf { it.isNotBlank() } ?: "订单已失效,请重新选择照片")
return
}
if (activePaymentOrderNumber != orderNumber) return
val message = response.data
when (decideFacePayStatus(message?.orderStatus)) {
FacePayStatusDecision.CONTINUE_POLLING -> {
LogUtils.d(
TAG,
"订单尚未完成,继续轮询 - order_number: $orderNumber, status: ${message?.orderStatus}, name: ${message?.orderStatusName}"
)
}
FacePayStatusDecision.COMPLETE_PAYMENT -> {
if (!isValidFacePaySuccessMessage(orderNumber, message)) {
LogUtils.e(TAG, "支付完成消息校验失败,等待下一次查询或 WebSocket - order_number: $orderNumber, message: $message")
return
}
val paymentData = checkNotNull(message?.data)
handlePaymentSuccess(
orderNumber = paymentData.orderNumber,
captureType = paymentData.captureType,
imageIds = paymentData.imageIds.orEmpty(),
purchaseMode = paymentData.purchaseMode,
source = "HTTP"
)
}
FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS -> {
handleTerminalPayStatus(message?.orderStatus, message?.orderStatusName)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LogUtils.e(TAG, "查询支付状态异常 - order_number: $orderNumber, error: ${e.message}")
}
}
private fun handleTerminalPayStatus(orderStatus: Int?, orderStatusName: String?) {
if (!paymentHandled.compareAndSet(false, true)) {
return
}
stopPayStatusPolling()
activePaymentOrderNumber = null
activeOrder = null
_payQrCodeUrl.value = null
_dismissPayDialogEvents.tryEmit(Unit)
val statusText = orderStatusName?.takeIf { it.isNotBlank() } ?: when (orderStatus) {
40 -> "已取消"
50 -> "已退款"
else -> "状态异常"
}
ToastUtils.show("订单$statusText")
retryQuote()
LogUtils.i(TAG, "订单进入终态,停止支付轮询 - status: $orderStatus, name: $statusText")
}
private fun handlePaymentSuccess(
orderNumber: String?,
captureType: Int?,
imageIds: List<Int>,
purchaseMode: String?,
source: String
) {
val expectedOrderNumber = activePaymentOrderNumber
val order = activeOrder ?: return
if (!order.matches(orderNumber, captureType, imageIds, purchaseMode)) {
LogUtils.i(TAG, "忽略不匹配的支付成功消息 - source: $source, expected: $expectedOrderNumber")
return
}
val knownImageIds = originalResults.map { it.id }.toSet()
if (!knownImageIds.containsAll(imageIds)) {
LogUtils.e(TAG, "支付成功消息包含未知图片,暂不跳转 - source: $source, image_ids: $imageIds")
return
}
if (!paymentHandled.compareAndSet(false, true)) {
LogUtils.d(TAG, "支付成功已处理,忽略重复消息 - source: $source, order_number: $orderNumber")
return
}
LogUtils.d(TAG, "确认支付成功 - source: $source, order_number: $orderNumber")
stopPayStatusPolling()
_payQrCodeUrl.value = null
_dismissPayDialogEvents.tryEmit(Unit)
navigateAfterPurchase(orderNumber, captureType, imageIds, order)
}
/**
* 按已确认订单的模式进入打印流程或独立电子版完成页
* @param orderNumber 订单号 * @param orderNumber 订单号
* @param captureType 抓拍类型 * @param captureType 抓拍类型
* @param imageIds 图片ID数组(从支付成功消息中获取) * @param imageIds 图片ID数组(从支付成功消息中获取)
*/ */
private fun navigateToPaySuccess(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) {
LogUtils.e(
TAG,
"支付成功消息订单信息不完整 - orderNumber: $orderNumber, captureType: $captureType"
)
ToastUtils.show("支付成功,但订单信息不完整")
return@launch
}
// 根据 imageIds 从 originalResults 中获取对应的 FaceSearchResult // 根据 imageIds 从 originalResults 中获取对应的 FaceSearchResult
val selectedResults = originalResults.filter { imageIds.contains(it.id) } val selectedResults = originalResults.filter { imageIds.contains(it.id) }
@@ -384,19 +592,17 @@ class FaceRecognitionResultViewModel @Inject constructor(
// 将 FileMapData 数组序列化为 JSON 数组字符串 // 将 FileMapData 数组序列化为 JSON 数组字符串
val gson = Gson() val gson = Gson()
val jsonArray = gson.toJson(fileMapDataList) val jsonArray = gson.toJson(fileMapDataList)
val encodedJson = java.net.URLEncoder.encode(jsonArray, "UTF-8")
// 构建路由参数
val orderNumberParam = orderNumber?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: ""
val captureTypeParam = captureType?.toString() ?: ""
// 创建 NavOptions,清除当前页面 // 创建 NavOptions,清除当前页面
val navOptions = androidx.navigation.NavOptions.Builder() val navOptions = androidx.navigation.NavOptions.Builder()
.setPopUpTo(com.yzx.kiosk.navigation.routes.AppRoutes.FACE_RECOGNITION_RESULT, inclusive = true) .setPopUpTo(AppRoutes.FACE_RECOGNITION_RESULT, inclusive = true)
.build() .build()
// 跳转到支付成功页面 val route = if (order.mode == PurchaseMode.ELECTRONIC) {
val route = "${com.yzx.kiosk.navigation.routes.AppRoutes.PAY_SUCCESS}?urls=$encodedJson&orderNumber=$orderNumberParam&captureType=$captureTypeParam" 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,15 +13,20 @@ 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.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
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -34,16 +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 okhttp3.logging.HttpLoggingInterceptor
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 {
@@ -58,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
@@ -90,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
@@ -108,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)
}
}
}
/** /**
* 开始倒计时 * 开始倒计时
*/ */
@@ -119,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)
} }
} }
@@ -153,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()
@@ -166,77 +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
)
}
// 创建输出选项
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
// 拍照
capture.takePicture(
outputOptions,
ContextCompat.getMainExecutor(App.instance),
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
// 加载图片为Bitmap,并处理EXIF方向
val bitmap = loadBitmapWithExifOrientation(photoFile.absolutePath)
_capturedImage.value = bitmap
// 直接调用搜索接口
searchFaceDirectly(photoFile)
}
override fun onError(exception: ImageCaptureException) {
LogUtils.e(TAG, "拍照失败: ${exception.message}")
ToastUtils.show("拍照失败")
}
} }
) if (
isCleared ||
imageCapture !== capture ||
activeCaptureToken != captureToken
) {
faceCaptureFileStore.delete(photoFile)
releaseCaptureGate(captureToken)
return@launch
}
activeCaptureFile = photoFile
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
capture.takePicture(
outputOptions,
ContextCompat.getMainExecutor(App.instance),
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
if (
isCleared ||
imageCapture !== capture ||
activeCaptureToken != captureToken
) {
finishCaptureFile(photoFile, captureToken)
return
}
recognizeSavedPhoto(photoFile, captureToken)
}
override fun onError(exception: ImageCaptureException) {
LogUtils.e(TAG, "拍照失败: ${exception.message}")
finishCaptureFile(photoFile, captureToken)
if (!isCleared && imageCapture === capture) {
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) {
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
@@ -247,53 +314,26 @@ 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:")
LogUtils.d(TAG, "Authorization: $authorization") LogUtils.d(TAG, "Authorization: <redacted>")
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, "==========================================")
// 创建日志拦截器
val loggingInterceptor = HttpLoggingInterceptor { message ->
LogUtils.d(TAG, message)
}.apply {
level = HttpLoggingInterceptor.Level.BODY
}
// 创建OkHttpClient,添加日志拦截器
val client = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.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
) )
@@ -318,6 +358,9 @@ class FaceRecognitionViewModel @Inject constructor(
localAudioPlayService.playByRoute("face_recognition_failed") localAudioPlayService.playByRoute("face_recognition_failed")
} }
} }
} catch (e: CancellationException) {
// 页面离开时保持协程取消语义,避免误显示 "Job was cancelled"
throw e
} catch (e: Exception) { } catch (e: Exception) {
LogUtils.e("人脸识别失败: ${e.message}") LogUtils.e("人脸识别失败: ${e.message}")
ToastUtils.show("识别失败: ${e.message}") ToastUtils.show("识别失败: ${e.message}")
@@ -345,7 +388,7 @@ class FaceRecognitionViewModel @Inject constructor(
} else { } else {
// 识别成功 // 识别成功
_recognitionStatus.value = RecognitionStatus.SUCCESS _recognitionStatus.value = RecognitionStatus.SUCCESS
ToastUtils.show("找到 ${result.count} 张照片") LogUtils.i(TAG, "找到 ${result.count} 张照片")
// 播放识别成功音频(在跳转前播放) // 播放识别成功音频(在跳转前播放)
localAudioPlayService.playByRoute(com.yzx.kiosk.navigation.routes.AppRoutes.FACE_RECOGNITION_RESULT) localAudioPlayService.playByRoute(com.yzx.kiosk.navigation.routes.AppRoutes.FACE_RECOGNITION_RESULT)
@@ -365,82 +408,20 @@ class FaceRecognitionViewModel @Inject constructor(
} }
} }
/** private var isOpeningRecentPhotos = false
* 加载Bitmap并处理EXIF方向信息
* 解决不同设备拍照后图片方向不正确的问题
*/
private fun loadBitmapWithExifOrientation(imagePath: String): Bitmap? {
return try {
// 先读取EXIF方向信息
val exif = ExifInterface(imagePath)
val orientation = exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
// 加载原始Bitmap fun browseRecentPhotos() {
val bitmap = BitmapFactory.decodeFile(imagePath) ?: return null if (_recognitionStatus.value != RecognitionStatus.FAILED || isOpeningRecentPhotos) return
isOpeningRecentPhotos = true
// 根据EXIF方向旋转Bitmap countdownJob?.cancel()
val rotationDegrees = when (orientation) { autoCaptureJob?.cancel()
ExifInterface.ORIENTATION_ROTATE_90 -> 90f toPage(
ExifInterface.ORIENTATION_ROTATE_180 -> 180f AppRoutes.buildRecentPhotosRoute(),
ExifInterface.ORIENTATION_ROTATE_270 -> 270f NavOptions.Builder()
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> { .setPopUpTo(AppRoutes.FACE_RECOGNITION, inclusive = true)
// 水平翻转 .setLaunchSingleTop(true)
val matrix = Matrix() .build(),
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() {
@@ -472,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,338 +0,0 @@
package com.yzx.kiosk.ui.upload.view
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import com.yzx.kiosk.R
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import com.yzx.kiosk.component.appbar.AppTitleBarNoStatusBarPadding
import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding
import com.yzx.kiosk.component.scaffold.AppScaffold
import com.yzx.kiosk.ui.common.view.FullScreenMode
import com.yzx.kiosk.ui.upload.viewmodel.PaySuccessViewModel
/** 与心跳动画 target一致;槽位高度 = 72.dp * 该值,避免放大时盖住下方区域 */
private const val PRINT_BTN_HEARTBEAT_MAX_SCALE = 1.09f
private fun isValidRemoteImageUrl(url: String): Boolean {
val t = url.trim()
if (t.isEmpty()) return false
return t.startsWith("http://", ignoreCase = true) ||
t.startsWith("https://", ignoreCase = true)
}
/** 按钮左右缩进,宽度变短;同等缩放比例下横向多出的像素更少,不易贴边/溢出 */
private val PRINT_BTN_HORIZONTAL_INSET = 20.dp
/**
* 心跳 `tween` 的缓动曲线(两头「略缓」强弱可在这里换):
* - [FastOutSlowInEasing]:Material 默认,起止略慢、中间略快
* - LinearEasing:匀速,无额外缓急(需自行 `import …LinearEasing` 并赋值)
* - [CubicBezierEasing]:自定义贝塞尔;(x1,y1) 靠近起点、(x2,y2) 靠近终点,数值越大往往起止越「肉」、需边调边看
*/
private val PRINT_BTN_HEARTBEAT_EASING: Easing = LinearEasing
// private val PRINT_BTN_HEARTBEAT_EASING = CubicBezierEasing(0.45f, 0f, 0.55f, 1f) // 示例:两头再柔和一点
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PaySuccessScreen(
photoUrls: List<String>,
orderNumber: String = "",
captureType: Int? = null,
viewModel: PaySuccessViewModel = hiltViewModel()
) {
val photoList by viewModel.photoList.collectAsState()
val selectedPhotoIds by viewModel.selectedPhotoIds.collectAsState()
val buttonPrintUrl by viewModel.buttonPrintUrl.collectAsState()
val printBtnRemoteUrl = buttonPrintUrl.trim()
val useRemotePrintButton = isValidRemoteImageUrl(printBtnRemoteUrl)
// 初始化照片列表和订单信息
LaunchedEffect(photoUrls, orderNumber, captureType) {
viewModel.initPhotoList(photoUrls, orderNumber, captureType)
}
val selectedCount = selectedPhotoIds.size
val isAllSelected = photoList.isNotEmpty() && selectedPhotoIds.size == photoList.size
FullScreenMode()
AppScaffold(
topBar = {
Column {
AppTitleBarNoStatusBarPadding(
backgroundColor = Color(0xFF4CAF50), // 绿色
title = "支付成功",
isWhite = true,
isShowBackIcon = true,
onBackClick = { viewModel.navigateBack() },
isShowButtonLine = false
)
FaceBarNoStatusBarPadding(
title = "为保护您的隐私与权益,系统不保存人脸照片。素材将在 24 小时后自动清理"
)
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
// 打印选中照片按钮:心跳缩放走 graphicsLayer;外层槽高为 72dp×最大缩放,预留上下空间,不压盖下方
val heartbeatTransition = rememberInfiniteTransition(label = "printBtnHeartbeat")
val pulseScale by heartbeatTransition.animateFloat(
initialValue = 1f,
targetValue = PRINT_BTN_HEARTBEAT_MAX_SCALE,
animationSpec = infiniteRepeatable(
animation = tween(600, easing = PRINT_BTN_HEARTBEAT_EASING),
repeatMode = RepeatMode.Reverse
),
label = "pulseScale"
)
val drawScale = if (selectedCount > 0) pulseScale else 1f
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp)
.height(72.dp * PRINT_BTN_HEARTBEAT_MAX_SCALE),
contentAlignment = Alignment.Center
) {
Button(
onClick = { viewModel.onPrintClick() },
modifier = Modifier
.padding(horizontal = PRINT_BTN_HORIZONTAL_INSET)
.fillMaxWidth()
.height(72.dp)
.graphicsLayer {
scaleX = drawScale
scaleY = drawScale
transformOrigin = TransformOrigin.Center
},
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073FF)),
enabled = selectedCount > 0
) {
// Row(
// modifier = Modifier.fillMaxWidth(),
// horizontalArrangement = Arrangement.Center,
// verticalAlignment = Alignment.CenterVertically
// ) {
// Text(
// text = "选中照片点击打印",
// fontSize = 24.sp,
// color = Color.White,
// fontWeight = FontWeight.Bold
// )
// Spacer(modifier = Modifier.width(6.dp))
if (useRemotePrintButton) {
AsyncImage(
model = printBtnRemoteUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
placeholder = painterResource(id = R.mipmap.btn_pay_success),
error = painterResource(id = R.mipmap.btn_pay_success)
)
} else {
Icon(
painter = painterResource(id = R.mipmap.btn_pay_success),
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.fillMaxSize()
)
}
// }
}
}
// 全选行
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.clickable { viewModel.toggleSelectAll() },
verticalAlignment = Alignment.CenterVertically
) {
// 复选框
Box(
modifier = Modifier
.size(24.dp)
.clip(RoundedCornerShape(6.dp))
.background(if (isAllSelected) Color(0xFF2196F3) else Color.White)
.border(
width = 2.dp,
color = if (isAllSelected) Color(0xFF2196F3) else Color(0xFFCCCCCC),
shape = RoundedCornerShape(6.dp)
),
contentAlignment = Alignment.Center
) {
if (isAllSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
Spacer(modifier = Modifier.width(18.dp))
Text(
text = "全选",
fontSize = 24.sp,
color = Color.Black
)
Text(
text = "(共${photoList.size}张)",
fontSize = 24.sp,
color = Color.Black
)
}
// 已选择数量
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "已选择",
fontSize = 24.sp,
color = Color.Black
)
Text(
modifier = Modifier.padding(top = 4.dp),
text = selectedCount.toString(),
fontSize = 28.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFF2196F3)
)
Text(
text = "张",
fontSize = 24.sp,
color = Color.Black
)
}
}
Spacer(modifier = Modifier.height(18.dp))
// 图片瀑布流列表
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
verticalItemSpacing = 18.dp
) {
itemsIndexed(photoList) { index, photo ->
val isSelected = selectedPhotoIds.contains(photo.id)
PaySuccessPhotoItem(
photoUrl = photo.url,
aspectRatio = photo.aspectRatio,
isSelected = isSelected,
onToggleSelection = { viewModel.togglePhotoSelection(photo.id) }
)
}
}
}
}
}
@Composable
fun PaySuccessPhotoItem(
photoUrl: String,
aspectRatio: Float,
isSelected: Boolean,
onToggleSelection: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(aspectRatio) // 根据原比例计算高度
.clip(RoundedCornerShape(18.dp))
.clickable { onToggleSelection() }
) {
// 图片 - 按原比例显示
AsyncImage(
model = photoUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
// 选中遮罩
Box(
modifier = Modifier
.fillMaxSize()
.background(
if (isSelected) Color.Black.copy(alpha = 0.3f) else Color.Transparent
)
)
// 右上角复选框
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(18.dp)
.size(24.dp)
.clip(RoundedCornerShape(6.dp))
.background(if (isSelected) Color(0xFF2196F3) else Color.Black.copy(alpha = 0.5f))
.border(
width = 2.dp,
color = if (isSelected) Color(0xFF2196F3) else Color(0xFF666666),
shape = RoundedCornerShape(6.dp)
),
contentAlignment = Alignment.Center
) {
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
}
}
@@ -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
@@ -275,16 +276,10 @@ fun PhotoSelectScreen(
// 支付弹框 // 支付弹框
if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) { if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) {
val selectedUrls = photoList.filter { selectedPhotoIds.contains(it.id) }.map { it.url }
WechatPayDialog( WechatPayDialog(
qrCodeUrl = payQrCodeUrl!!, qrCodeUrl = payQrCodeUrl!!,
totalPrice = if (totalPrice != "--") totalPrice else "0", totalPrice = if (totalPrice != "--") totalPrice else "0",
onDismiss = { showPayDialog = false }, onDismiss = { showPayDialog = false }
onPaySuccess = {
showPayDialog = false
// 支付成功,等待 WebSocket 消息(code=5)触发跳转到支付成功页面
// 跳转逻辑在 PhotoSelectViewModel 的 navigateToPaySuccess 方法中处理
}
) )
} }
} }
@@ -453,8 +448,7 @@ fun PhotoItem(
fun WechatPayDialog( fun WechatPayDialog(
qrCodeUrl: String, qrCodeUrl: String,
totalPrice: String, totalPrice: String,
onDismiss: () -> Unit, onDismiss: () -> Unit
onPaySuccess: () -> Unit
) { ) {
// 使用接口返回的URL生成支付二维码 // 使用接口返回的URL生成支付二维码
val qrCodeBitmap = remember(qrCodeUrl) { val qrCodeBitmap = remember(qrCodeUrl) {
@@ -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)
) )
@@ -1,273 +0,0 @@
package com.yzx.kiosk.ui.upload.viewmodel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavOptions
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Size
import com.yzx.kiosk.App
import com.yzx.kiosk.base.BaseViewModel
import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.yzx.kiosk.datastore.AppStoreDataSource
import com.yzx.kiosk.utils.LogUtils
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
class PaySuccessViewModel @Inject constructor(
navigator: AppNavigator,
private val appStoreDataSource: AppStoreDataSource,
appState: AppState
) : BaseViewModel(
navigator = navigator,
appState = appState
) {
// 图片列表(包含宽高比)
private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList())
val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow()
// 已选中的图片ID
private val _selectedPhotoIds = MutableStateFlow<Set<Int>>(emptySet())
val selectedPhotoIds: StateFlow<Set<Int>> = _selectedPhotoIds.asStateFlow()
// 订单号
private val _orderNumber = MutableStateFlow<String>("")
val orderNumber: StateFlow<String> = _orderNumber.asStateFlow()
// 抓拍类型
private val _captureType = MutableStateFlow<Int?>(null)
val captureType: StateFlow<Int?> = _captureType.asStateFlow()
/** 设备配置下发的打印按钮图 URL(可能为空) */
private val _buttonPrintUrl = MutableStateFlow("")
val buttonPrintUrl: StateFlow<String> = _buttonPrintUrl.asStateFlow()
init {
_buttonPrintUrl.value = appStoreDataSource.getButtonPrintUrl()
}
/**
* 初始化图片列表,默认全选
* @param photoUrls 图片URL列表(JSON 格式或旧格式)
* @param orderNumber 订单号
* @param captureType 抓拍类型
* urls 格式:
* - JSON 格式(新):FileMapData 数组的 JSON 字符串
* - id:url 格式(兼容):id:url,id:url
* - url 格式(兼容):url,url
*/
fun initPhotoList(photoUrls: List<String>, orderNumber: String = "", captureType: Int? = null) {
// 保存订单信息
_orderNumber.value = orderNumber
_captureType.value = captureType
LogUtils.d("PaySuccessViewModel", "初始化 - orderNumber: $orderNumber, captureType: $captureType")
if (photoUrls.isEmpty()) {
_photoList.value = emptyList()
_selectedPhotoIds.value = emptySet()
return
}
viewModelScope.launch {
var initialPhotos: List<PhotoData>
// 尝试解析为 JSON 格式(新格式:file_map 数组的 JSON 字符串)
if (photoUrls.size == 1 && photoUrls[0].startsWith("[")) {
// JSON 数组格式:将 file_map 列表序列化后的 JSON 数组字符串反序列化为 FileMapData 对象数组
try {
val jsonString = java.net.URLDecoder.decode(photoUrls[0], "UTF-8")
LogUtils.d("PaySuccessViewModel", "接收到 JSON 数组字符串: $jsonString")
// 使用 Gson 将 JSON 数组反序列化为 FileMapData 对象数组
val type = object : TypeToken<List<FileMapData>>() {}.type
val fileMapList: List<FileMapData> = Gson().fromJson(jsonString, type) ?: emptyList()
LogUtils.d("PaySuccessViewModel", "反序列化成功,FileMapData 对象数量: ${fileMapList.size}")
// 将 FileMapData 对象数组转换为 PhotoData 列表
initialPhotos = fileMapList.mapNotNull { fileMap ->
val thumbnailUrl = if (appStoreDataSource.getUseLan()) fileMap.thumbnailLanUrl else fileMap.thumbnailOssUrl
if (thumbnailUrl.isNullOrEmpty()) {
LogUtils.i("PaySuccessViewModel", "FileMapData id=${fileMap.id} 的 thumbnailOssUrl 为空,跳过")
null
} else {
PhotoData(
id = fileMap.id,
url = thumbnailUrl.trim(),
aspectRatio = 1f,
fileMapData = fileMap // 保存完整的 FileMapData 对象,供后续使用
)
}
}
LogUtils.d("PaySuccessViewModel", "成功创建 PhotoData 列表,数量: ${initialPhotos.size}")
} catch (e: Exception) {
LogUtils.e("PaySuccessViewModel", "解析 JSON 格式失败: ${e.message}")
e.printStackTrace()
initialPhotos = emptyList()
}
} else {
// 兼容旧格式:id:url 或 url
initialPhotos = photoUrls.mapNotNull { urlStr ->
val trimmed = urlStr.trim()
if (trimmed.contains(":")) {
// id:url 格式
val parts = trimmed.split(":", limit = 2)
if (parts.size == 2) {
val id = parts[0].toIntOrNull()
val url = parts[1]
if (id != null && url.isNotEmpty()) {
PhotoData(
id = id,
url = url,
aspectRatio = 1f
)
} else null
} else null
} else {
// 只有 url,使用 hashCode 作为临时 id(不推荐,但兼容)
PhotoData(
id = trimmed.hashCode(),
url = trimmed,
aspectRatio = 1f
)
}
}
}
if (initialPhotos.isEmpty()) {
_photoList.value = emptyList()
_selectedPhotoIds.value = emptySet()
return@launch
}
_photoList.value = initialPhotos
// 默认全选(使用 id)
_selectedPhotoIds.value = initialPhotos.map { it.id }.toSet()
// 并发获取所有图片的真实尺寸并更新宽高比
val updatedPhotos = initialPhotos.mapIndexed { index, photo ->
async(Dispatchers.IO) {
try {
val aspectRatio = getImageAspectRatio(photo.url)
// 确保 aspectRatio 是有效值
val validAspectRatio = if (aspectRatio > 0 && aspectRatio.isFinite()) {
aspectRatio
} else {
1f
}
photo.copy(aspectRatio = validAspectRatio)
} catch (e: Exception) {
LogUtils.e("PaySuccessViewModel", "获取图片 ${photo.url} 尺寸失败: ${e.message}")
// 获取失败,保持默认值
photo
}
}
}.awaitAll()
_photoList.value = updatedPhotos
}
}
/**
* 获取图片的宽高比
*/
private suspend fun getImageAspectRatio(imageUrl: String): Float = withContext(Dispatchers.IO) {
try {
val imageLoader = ImageLoader(App.instance)
val request = ImageRequest.Builder(App.instance)
.data(imageUrl)
.size(Size.ORIGINAL)
.build()
val drawable = imageLoader.execute(request).drawable
if (drawable != null) {
val width = drawable.intrinsicWidth
val height = drawable.intrinsicHeight
if (width > 0 && height > 0) {
return@withContext width.toFloat() / height.toFloat()
}
}
} catch (e: Exception) {
// 获取失败,使用默认值
}
return@withContext 1f // 默认1:1
}
/**
* 切换单张图片选中状态
*/
fun togglePhotoSelection(id: Int) {
val current = _selectedPhotoIds.value.toMutableSet()
if (current.contains(id)) {
current.remove(id)
} else {
current.add(id)
}
_selectedPhotoIds.value = current
}
/**
* 切换全选状态
*/
fun toggleSelectAll() {
val allIds = _photoList.value.map { it.id }.toSet()
val newSelected = if (_selectedPhotoIds.value.size == allIds.size) {
// 当前全选,取消全选
emptySet()
} else {
// 全选
allIds
}
_selectedPhotoIds.value = newSelected
}
/**
* 点击打印
*/
fun onPrintClick() {
val selectedIds = _selectedPhotoIds.value.toList()
if (selectedIds.isEmpty()) return
// 获取选中的图片的完整 FileMapData 列表
val selectedFileMapData = _photoList.value
.filter { selectedIds.contains(it.id) }
.mapNotNull { it.fileMapData }
if (selectedFileMapData.isEmpty()) {
LogUtils.e("PaySuccessViewModel", "选中的图片没有 FileMapData,无法打印")
return
}
// 将 FileMapData 数组序列化为 JSON 数组字符串
val gson = Gson()
val jsonArray = gson.toJson(selectedFileMapData)
val encodedJson = java.net.URLEncoder.encode(jsonArray, "UTF-8")
// 构建路由参数
val orderNumberParam = _orderNumber.value.let {
if (it.isNotEmpty()) java.net.URLEncoder.encode(it, "UTF-8") else ""
}
val captureTypeParam = _captureType.value?.toString() ?: ""
// 创建 NavOptions,清除回退栈(清除到支付成功页面,包括它自己)
val navOptions = NavOptions.Builder()
.setPopUpTo(AppRoutes.PAY_SUCCESS, inclusive = true)
.build()
// 跳转到打印页面
val route = "${AppRoutes.PRINTING}?urls=$encodedJson&orderNumber=$orderNumberParam&captureType=$captureTypeParam"
toPage(route, navOptions)
}
}
@@ -8,6 +8,7 @@ import com.yzx.kiosk.App
import com.yzx.kiosk.base.BaseViewModel import com.yzx.kiosk.base.BaseViewModel
import com.yzx.kiosk.datastore.AppState import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.navigation.AppNavigator import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.network.model.request.GetPayUrlRequest import com.yzx.kiosk.network.model.request.GetPayUrlRequest
import com.yzx.kiosk.network.model.request.VerifyResultRequest import com.yzx.kiosk.network.model.request.VerifyResultRequest
import com.yzx.kiosk.network.repository.NetWorkRepository import com.yzx.kiosk.network.repository.NetWorkRepository
@@ -32,6 +33,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.LogUtils
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
/** /**
@@ -80,6 +82,8 @@ class PhotoSelectViewModel @Inject constructor(
navigator = navigator, navigator = navigator,
appState = appState appState = appState
) { ) {
private val paymentHandled = AtomicBoolean(false)
// 图片列表 // 图片列表
private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList()) private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList())
val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow() val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow()
@@ -126,9 +130,9 @@ class PhotoSelectViewModel @Inject constructor(
} }
} }
is UploadPhotoEvent.PaySuccess -> { is UploadPhotoEvent.PaySuccess -> {
// 支付成功,跳转到支付成功页面 // 支付成功,直接进入打印页面
LogUtils.d("PhotoSelectViewModel", "收到支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, fileMap: ${event.fileMap}") LogUtils.d("PhotoSelectViewModel", "收到支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, fileMap: ${event.fileMap}")
navigateToPaySuccess(event.orderNumber, event.captureType, event.fileMap) navigateToPrinting(event.orderNumber, event.captureType, event.fileMap)
} }
else -> { else -> {
// 其他事件不处理 // 其他事件不处理
@@ -472,6 +476,7 @@ class PhotoSelectViewModel @Inject constructor(
if (url.isNullOrEmpty()) { if (url.isNullOrEmpty()) {
ToastUtils.show("获取支付二维码失败") ToastUtils.show("获取支付二维码失败")
} else { } else {
paymentHandled.set(false)
_payQrCodeUrl.value = url _payQrCodeUrl.value = url
onSuccess(url) onSuccess(url)
} }
@@ -501,13 +506,26 @@ class PhotoSelectViewModel @Inject constructor(
} }
/** /**
* 跳转到支付成功页面 * 支付成功后直接跳转到打印页面
* @param orderNumber 订单号 * @param orderNumber 订单号
* @param captureType 抓拍类型 * @param captureType 抓拍类型
* @param imageIds 图片ID数组(从支付成功消息中获取) * @param fileMap 支付成功消息中确认的图片数据
*/ */
private fun navigateToPaySuccess(orderNumber: String?, captureType: Int?, fileMap: String) { private fun navigateToPrinting(orderNumber: String?, captureType: Int?, fileMap: String) {
viewModelScope.launch { viewModelScope.launch {
if (captureType != 1) {
LogUtils.i(
"PhotoSelectViewModel",
"忽略非手机上传流程的支付成功消息 - captureType: $captureType"
)
return@launch
}
if (orderNumber.isNullOrBlank()) {
LogUtils.e("PhotoSelectViewModel", "支付成功消息缺少订单号")
ToastUtils.show("支付成功,但订单信息不完整")
return@launch
}
if (fileMap.isBlank()) { if (fileMap.isBlank()) {
LogUtils.e("PhotoSelectViewModel", "未找到对应的 FileMapData,fileMap: $fileMap") LogUtils.e("PhotoSelectViewModel", "未找到对应的 FileMapData,fileMap: $fileMap")
@@ -515,18 +533,33 @@ class PhotoSelectViewModel @Inject constructor(
return@launch return@launch
} }
val paidFileMapList = try {
val type = object : TypeToken<List<FileMapData>>() {}.type
Gson().fromJson<List<FileMapData>>(fileMap, type).orEmpty()
} catch (e: Exception) {
LogUtils.e("PhotoSelectViewModel", "解析支付成功图片数据失败: ${e.message}")
emptyList()
}
if (paidFileMapList.isEmpty()) {
ToastUtils.show("支付成功,但未找到对应的图片数据")
return@launch
}
// 构建路由参数 if (!paymentHandled.compareAndSet(false, true)) {
val orderNumberParam = orderNumber?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: "" LogUtils.d("PhotoSelectViewModel", "支付成功已处理,忽略重复消息 - orderNumber: $orderNumber")
val captureTypeParam = captureType?.toString() ?: "" return@launch
}
// 创建 NavOptions,清除选择页面 // 创建 NavOptions,清除选择页面
val navOptions = androidx.navigation.NavOptions.Builder() val navOptions = androidx.navigation.NavOptions.Builder()
.setPopUpTo(com.yzx.kiosk.navigation.routes.AppRoutes.PHOTO_SELECT, inclusive = true) .setPopUpTo(AppRoutes.PHOTO_SELECT, inclusive = true)
.build() .build()
// 跳转到支付成功页面 val route = AppRoutes.buildPrintingRoute(
val route = "${com.yzx.kiosk.navigation.routes.AppRoutes.PAY_SUCCESS}?urls=$fileMap&orderNumber=$orderNumberParam&captureType=$captureTypeParam" photoData = Gson().toJson(paidFileMapList),
orderNumber = orderNumber,
captureType = captureType
)
toPage(route, navOptions) toPage(route, navOptions)
} }
} }
@@ -1,12 +1,12 @@
package com.yzx.kiosk.ui.upload.viewmodel package com.yzx.kiosk.ui.upload.viewmodel
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.yzx.kiosk.base.BaseViewModel import com.yzx.kiosk.base.BaseViewModel
import com.yzx.kiosk.datastore.AppState import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.navigation.AppNavigator import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.network.di.NetworkModule.CLIENT_DOWNLOAD
import com.yzx.kiosk.network.model.request.PrintCompleteRequest import com.yzx.kiosk.network.model.request.PrintCompleteRequest
import com.yzx.kiosk.network.model.request.PrintNotifyRequest import com.yzx.kiosk.network.model.request.PrintNotifyRequest
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
@@ -17,6 +17,7 @@ import com.yzx.kiosk.priter.PrintCallback
import com.yzx.kiosk.priter.PrintRecord import com.yzx.kiosk.priter.PrintRecord
import com.yzx.kiosk.priter.PrintStatusManager import com.yzx.kiosk.priter.PrintStatusManager
import com.yzx.kiosk.priter.PrinterService import com.yzx.kiosk.priter.PrinterService
import com.yzx.kiosk.priter.PrintImageIntegrityValidator
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 com.google.gson.Gson import com.google.gson.Gson
@@ -35,9 +36,12 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import java.net.URL import okhttp3.CacheControl
import okhttp3.OkHttpClient
import okhttp3.Request
import kotlin.coroutines.resume import kotlin.coroutines.resume
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Named
@HiltViewModel @HiltViewModel
class PrintingViewModel @Inject constructor( class PrintingViewModel @Inject constructor(
@@ -46,7 +50,8 @@ class PrintingViewModel @Inject constructor(
private val printerService: PrinterService, private val printerService: PrinterService,
private val appStoreDataSource: AppStoreDataSource, private val appStoreDataSource: AppStoreDataSource,
private val printStatusManager: PrintStatusManager, private val printStatusManager: PrintStatusManager,
private val netWorkRepository: NetWorkRepository private val netWorkRepository: NetWorkRepository,
@Named(CLIENT_DOWNLOAD) private val printDownloadClient: OkHttpClient
) : BaseViewModel( ) : BaseViewModel(
navigator = navigator, navigator = navigator,
appState = appState appState = appState
@@ -55,6 +60,7 @@ class PrintingViewModel @Inject constructor(
private const val TAG = "PrintingViewModel" private const val TAG = "PrintingViewModel"
private const val PRINT_TIME_PER_PHOTO = 15 // 每张照片预计打印时间(秒) private const val PRINT_TIME_PER_PHOTO = 15 // 每张照片预计打印时间(秒)
private const val COUNTDOWN_AFTER_PRINT = 90 // 打印完成后倒计时(秒) private const val COUNTDOWN_AFTER_PRINT = 90 // 打印完成后倒计时(秒)
private const val IMAGE_DOWNLOAD_RETRY_COUNT = 3
} }
/** /**
@@ -502,17 +508,64 @@ class PrintingViewModel @Inject constructor(
* 下载网络图片 * 下载网络图片
*/ */
private suspend fun downloadImage(url: String): Bitmap? = withContext(Dispatchers.IO) { private suspend fun downloadImage(url: String): Bitmap? = withContext(Dispatchers.IO) {
try { val logUrl = url.substringBefore('?')
val connection = URL(url).openConnection()
connection.connectTimeout = 10000 for (attempt in 1..IMAGE_DOWNLOAD_RETRY_COUNT) {
connection.readTimeout = 10000 try {
connection.getInputStream().use { inputStream -> val request = Request.Builder()
BitmapFactory.decodeStream(inputStream) .url(url)
.get()
.cacheControl(CacheControl.FORCE_NETWORK)
.build()
printDownloadClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw java.io.IOException("HTTP ${response.code}")
}
val body = response.body ?: throw java.io.IOException("响应体为空")
val declaredLength = body.contentLength()
if (declaredLength > PrintImageIntegrityValidator.MAX_DOWNLOAD_BYTES) {
throw java.io.IOException("图片超过大小上限: $declaredLength bytes")
}
// bytes() 会完整读取响应;服务端声明了 Content-Length 时,OkHttp 会校验实际长度。
val bytes = body.bytes()
if (declaredLength >= 0L && declaredLength != bytes.size.toLong()) {
throw java.io.IOException(
"响应长度不一致: declared=$declaredLength, actual=${bytes.size}"
)
}
val decoded = PrintImageIntegrityValidator.decodeDownloadedImage(bytes)
LogUtils.d(
TAG,
"打印图片校验成功 - url=$logUrl, attempt=$attempt, bytes=${bytes.size}, " +
"format=${decoded.format}, mime=${decoded.mimeType}, " +
"source=${decoded.sourceWidth}x${decoded.sourceHeight}, " +
"decoded=${decoded.bitmap.width}x${decoded.bitmap.height}, " +
"sample=${decoded.sampleSize}, sha256=${decoded.sha256.take(16)}"
)
return@withContext decoded.bitmap
}
} catch (e: CancellationException) {
throw e
} catch (e: OutOfMemoryError) {
LogUtils.e(TAG, "打印图片解码内存不足 - url=$logUrl, attempt=$attempt")
return@withContext null
} catch (e: Exception) {
LogUtils.e(
TAG,
"打印图片下载或校验失败 - url=$logUrl, attempt=$attempt/$IMAGE_DOWNLOAD_RETRY_COUNT, error=${e.message}"
)
if (attempt < IMAGE_DOWNLOAD_RETRY_COUNT) {
delay(500L * attempt)
}
} }
} catch (e: Exception) {
LogUtils.e(TAG, "下载图片失败: $url, ${e.message}")
null
} }
LogUtils.e(TAG, "打印图片连续 $IMAGE_DOWNLOAD_RETRY_COUNT 次校验失败,禁止送入打印机 - url=$logUrl")
null
} }
/** /**
@@ -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()
}
@@ -7,7 +7,9 @@ import coil.request.ImageRequest
import coil.size.Size import coil.size.Size
import com.google.gson.Gson import com.google.gson.Gson
import com.yzx.kiosk.App import com.yzx.kiosk.App
import com.yzx.kiosk.BuildConfig
import com.yzx.kiosk.datastore.AppStoreDataSource import com.yzx.kiosk.datastore.AppStoreDataSource
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
import com.yzx.kiosk.network.model.request.PrintCompleteRequest import com.yzx.kiosk.network.model.request.PrintCompleteRequest
import com.yzx.kiosk.network.model.request.PrintNotifyRequest import com.yzx.kiosk.network.model.request.PrintNotifyRequest
import com.yzx.kiosk.network.model.response.BatchQueryResponse import com.yzx.kiosk.network.model.response.BatchQueryResponse
@@ -65,6 +67,11 @@ class PrivacyPrintService @Inject constructor(
.connectTimeout(10, TimeUnit.SECONDS) .connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS)
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor("privacy-print"))
}
}
.build() .build()
// 缓存 AppState 实例,避免频繁调用 Lazy.get() // 缓存 AppState 实例,避免频繁调用 Lazy.get()
@@ -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()
@@ -4,6 +4,7 @@ import com.google.gson.Gson
import com.google.gson.JsonObject import com.google.gson.JsonObject
import com.yzx.kiosk.BuildConfig import com.yzx.kiosk.BuildConfig
import com.yzx.kiosk.datastore.AppStoreDataSource import com.yzx.kiosk.datastore.AppStoreDataSource
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
import com.yzx.kiosk.network.repository.NetWorkRepository import com.yzx.kiosk.network.repository.NetWorkRepository
import com.yzx.kiosk.ui.poster.ScenicLivePosterController import com.yzx.kiosk.ui.poster.ScenicLivePosterController
import com.yzx.kiosk.priter.PrintStatusManager import com.yzx.kiosk.priter.PrintStatusManager
@@ -100,6 +101,11 @@ class WebSocketService @Inject constructor(
.readTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS)
.pingInterval(30, TimeUnit.SECONDS) // 自动 ping .pingInterval(30, TimeUnit.SECONDS) // 自动 ping
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor("websocket"))
}
}
.build() .build()
} }
} }
@@ -384,46 +390,13 @@ class WebSocketService @Inject constructor(
} }
} }
"5" -> { "5" -> {
// 支付成功消息 val event = parsePurchaseCompletedEvent(jsonObject)
LogUtils.d(TAG, ">>> 收到支付成功消息 (code=5) <<<") if (event != null) {
try { CoroutineScope(Dispatchers.Main).launch {
val dataElement = jsonObject.get("data") _uploadPhotoEvents.emit(event)
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 {
_uploadPhotoEvents.emit(
UploadPhotoEvent.PaySuccess(
orderNumber = orderNumber,
captureType = captureType,
imageIds = imageIdList,
fileMap = fileMap
)
)
}
} }
} catch (e: Exception) { } else {
LogUtils.e(TAG, "解析支付成功消息失败: ${e.message}") LogUtils.e(TAG, "忽略无效的购买完成消息")
} }
} }
"4" -> { "4" -> {
@@ -968,7 +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"))
}
}
@@ -0,0 +1,40 @@
package com.yzx.kiosk.navigation.routes
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
import java.net.URLDecoder
class AppRoutesTest {
@Test
fun `printing route safely encodes photo data and order number`() {
val photoData =
"""[{"id":1,"original_oss_url":"https://example.com/photo.jpg?token=a&size=4x6"}]"""
val orderNumber = "ORDER 1&2"
val route = AppRoutes.buildPrintingRoute(photoData, orderNumber, 1)
val urlsParam = route.substringAfter("urls=").substringBefore("&orderNumber=")
val orderNumberParam = route.substringAfter("&orderNumber=").substringBefore("&captureType=")
assertEquals(photoData, URLDecoder.decode(urlsParam, "UTF-8"))
assertEquals(orderNumber, URLDecoder.decode(orderNumberParam, "UTF-8"))
assertEquals("1", route.substringAfter("&captureType="))
assertFalse(route.contains("token=a&size=4x6"))
}
@Test
fun `electronic completion has an independent route with encoded order`() {
val paid = AppRoutes.buildElectronicCompletionRoute("ORDER +&1")
assertEquals("electronic_completion", paid.substringBefore("?"))
assertEquals("ORDER +&1", URLDecoder.decode(paid.substringAfter("orderNumber="), "UTF-8"))
assertFalse(AppRoutes.buildPrintingRoute("[]", "UPLOAD-1", 1).contains("electronic"))
}
@Test
fun `printing route leaves missing order metadata empty`() {
val route = AppRoutes.buildPrintingRoute("[]", null, null)
assertEquals("printing?urls=%5B%5D&orderNumber=&captureType=", route)
}
}
@@ -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!!))
}
}
@@ -0,0 +1,103 @@
package com.yzx.kiosk.ui.face.viewmodel
import com.google.gson.Gson
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
import com.yzx.kiosk.network.model.response.PaySuccessMessageData
import com.yzx.kiosk.network.model.response.PaySuccessMessageResponse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FacePayStatusDecisionTest {
@Test
fun `only pending status continues polling`() {
listOf(10).forEach { status ->
assertEquals(
FacePayStatusDecision.CONTINUE_POLLING,
decideFacePayStatus(status)
)
}
}
@Test
fun `completed status completes payment`() {
assertEquals(
FacePayStatusDecision.COMPLETE_PAYMENT,
decideFacePayStatus(30)
)
}
@Test
fun `cancel and refund statuses stop polling`() {
listOf(null, 20, 0, 40, 50, 60, 70).forEach { status ->
assertEquals(
FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS,
decideFacePayStatus(status)
)
}
}
@Test
fun `valid face payment message is accepted`() {
assertTrue(isValidFacePaySuccessMessage("ORDER-1", message()))
assertFalse(isValidFacePaySuccessMessage("ORDER-1", message().copy(orderStatus = 10)))
}
@Test
fun `empty or mismatched payment data is rejected`() {
assertFalse(isValidFacePaySuccessMessage("ORDER-1", null))
assertFalse(isValidFacePaySuccessMessage("OTHER", message()))
assertFalse(isValidFacePaySuccessMessage("ORDER-1", message(type = 4)))
assertFalse(isValidFacePaySuccessMessage("ORDER-1", message(captureType = 1)))
assertFalse(isValidFacePaySuccessMessage("ORDER-1", message(imageIds = emptyList())))
}
@Test
fun `pay status request uses backend order number field`() {
val json = Gson().toJson(PaySuccessMessageRequest("ORDER-1"))
assertEquals("{\"order_number\":\"ORDER-1\"}", json)
}
@Test
fun `pay status response deserializes nested websocket payload`() {
val json = """
{
"order_status": 30,
"order_status_name": "已完成",
"sn": "OSCAR_DEVICE_SN",
"type": 5,
"data": {
"order_number": "ORDER-1",
"capture_type": 2,
"image_id": [335, 340]
}
}
""".trimIndent()
val response = Gson().fromJson(json, PaySuccessMessageResponse::class.java)
assertEquals(30, response.orderStatus)
assertEquals("ORDER-1", response.data?.orderNumber)
assertEquals(listOf(335, 340), response.data?.imageIds)
assertTrue(isValidFacePaySuccessMessage("ORDER-1", response))
}
private fun message(
type: Int = 5,
captureType: Int = 2,
imageIds: List<Int> = listOf(335, 340)
) = PaySuccessMessageResponse(
orderStatus = 30,
orderStatusName = "已完成",
sn = "OSCAR_DEVICE_SN",
type = type,
data = PaySuccessMessageData(
orderNumber = "ORDER-1",
captureType = captureType,
imageIds = imageIds
)
)
}
@@ -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,106 @@
Starting a Gradle Daemon (subsequent builds will be faster)
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:checkKotlinGradlePluginConfigurationErrors SKIPPED
> Task :app:dataBindingMergeDependencyArtifactsDebug UP-TO-DATE
> Task :app:generateDebugResValues UP-TO-DATE
> Task :app:generateDebugResources UP-TO-DATE
> Task :app:mergeDebugResources UP-TO-DATE
> Task :app:packageDebugResources UP-TO-DATE
> Task :app:parseDebugLocalResources UP-TO-DATE
> Task :app:dataBindingGenBaseClassesDebug UP-TO-DATE
> Task :app:generateDebugBuildConfig UP-TO-DATE
> Task :app:checkDebugAarMetadata UP-TO-DATE
> Task :app:mapDebugSourceSetPaths UP-TO-DATE
> Task :app:createDebugCompatibleScreenManifests
> Task :app:extractDeepLinksDebug UP-TO-DATE
> Task :app:processDebugMainManifest
> Task :app:processDebugManifest
> Task :app:javaPreCompileDebug UP-TO-DATE
> Task :app:preDebugUnitTestBuild UP-TO-DATE
> Task :app:javaPreCompileDebugUnitTest UP-TO-DATE
> Task :app:preDebugAndroidTestBuild SKIPPED
> Task :app:generateDebugAndroidTestResValues UP-TO-DATE
> Task :app:extractProguardFiles UP-TO-DATE
> Task :app:processDebugManifestForPackage
> Task :app:processDebugResources
> Task :app:kspDebugKotlin UP-TO-DATE
> Task :app:compileDebugKotlin UP-TO-DATE
> Task :app:compileDebugJavaWithJavac UP-TO-DATE
> Task :app:hiltAggregateDepsDebug UP-TO-DATE
> Task :app:hiltJavaCompileDebug UP-TO-DATE
> Task :app:transformDebugClassesWithAsm UP-TO-DATE
> Task :app:processDebugJavaRes UP-TO-DATE
> Task :app:bundleDebugClassesToCompileJar UP-TO-DATE
> Task :app:bundleDebugClassesToRuntimeJar
> Task :app:generateDebugAndroidTestLintModel
> Task :app:generateDebugLintReportModel
> Task :app:generateDebugUnitTestLintModel
> Task :app:kspDebugUnitTestKotlin
> Task :app:compileDebugUnitTestKotlin
> Task :app:compileDebugUnitTestJavaWithJavac NO-SOURCE
> Task :app:hiltAggregateDepsDebugUnitTest UP-TO-DATE
> Task :app:hiltJavaCompileDebugUnitTest NO-SOURCE
> Task :app:processDebugUnitTestJavaRes UP-TO-DATE
> Task :app:transformDebugUnitTestClassesWithAsm
> Task :app:testDebugUnitTest
> Task :app:lintAnalyzeDebugUnitTest
> Task :app:lintAnalyzeDebugAndroidTest
> Task :app:lintAnalyzeDebug
> Task :app:lintReportDebug
Wrote HTML report to file:///Users/hanqiu/Desktop/KIOSK/app/build/reports/lint-results-debug.html
Lint found 4 errors, 329 warnings and 8 hints. First failure:
/Users/hanqiu/Desktop/KIOSK/app/src/main/AndroidManifest.xml: Error: When targeting Android 13 or higher, posting a permission requires holding the POST_NOTIFICATIONS permission (usage from com.bumptech.glide.request.target.NotificationTarget) [NotificationPermission]
> Task :app:lintDebug FAILED
Lint found 4 errors, 329 warnings, 8 hints. First failure:
/Users/hanqiu/Desktop/KIOSK/app/src/main/AndroidManifest.xml: Error: When targeting Android 13 or higher, posting a permission requires holding the POST_NOTIFICATIONS permission (usage from com.bumptech.glide.request.target.NotificationTarget) [NotificationPermission]
Explanation for issues of type "NotificationPermission":
When targeting Android 13 and higher, posting permissions requires holding
the runtime permission android.permission.POST_NOTIFICATIONS.
The full lint text report is located at:
/Users/hanqiu/Desktop/KIOSK/app/build/intermediates/lint_intermediate_text_report/debug/lintReportDebug/lint-results-debug.txt
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':app:lintDebug'.
> Lint found errors in the project; aborting build.
Fix the issues identified by lint, or create a baseline to see only new errors.
To create a baseline, run `gradlew updateLintBaseline` after adding the following to the module's build.gradle file:
```
android {
lint {
baseline = file("lint-baseline.xml")
}
}
```
For more details, see https://developer.android.com/studio/write/lint#snapshot
Lint found 4 errors, 329 warnings, 8 hints. First failure:
/Users/hanqiu/Desktop/KIOSK/app/src/main/AndroidManifest.xml: Error: When targeting Android 13 or higher, posting a permission requires holding the POST_NOTIFICATIONS permission (usage from com.bumptech.glide.request.target.NotificationTarget) [NotificationPermission]
Explanation for issues of type "NotificationPermission":
When targeting Android 13 and higher, posting permissions requires holding
the runtime permission android.permission.POST_NOTIFICATIONS.
The full lint text report is located at:
/Users/hanqiu/Desktop/KIOSK/app/build/intermediates/lint_intermediate_text_report/debug/lintReportDebug/lint-results-debug.txt
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
BUILD FAILED in 31s
43 actionable tasks: 18 executed, 25 up-to-date
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
# 单元测试结果(2026-09-20)
来源:本次 :app:testDebugUnitTest 生成的 JUnit XML。
| 测试类 | 用例 | 失败 | 错误 | 跳过 |
| --- | ---: | ---: | ---: | ---: |
| com.yzx.kiosk.ExampleUnitTest | 1 | 0 | 0 | 0 |
| com.yzx.kiosk.audio.LocalAudioRouteTest | 4 | 0 | 0 | 0 |
| com.yzx.kiosk.navigation.routes.AppRoutesTest | 3 | 0 | 0 | 0 |
| com.yzx.kiosk.network.service.FaceSearchUrlTest | 4 | 0 | 0 | 0 |
| com.yzx.kiosk.network.service.RecentPhotosServiceTest | 2 | 0 | 0 | 0 |
| com.yzx.kiosk.ui.face.resource.FaceCaptureFileStoreTest | 2 | 0 | 0 | 0 |
| com.yzx.kiosk.ui.face.resource.FaceResourceHelpersTest | 2 | 0 | 0 | 0 |
| com.yzx.kiosk.ui.face.viewmodel.FacePayStatusDecisionTest | 7 | 0 | 0 | 0 |
| com.yzx.kiosk.ui.face.viewmodel.FacePurchaseTest | 11 | 0 | 0 | 0 |
| com.yzx.kiosk.ui.face.viewmodel.RecentPhotosLoaderTest | 4 | 0 | 0 | 0 |
| com.yzx.kiosk.websocket.PurchaseCompletedMessageTest | 4 | 0 | 0 | 0 |
总计:44 项,失败 0,错误 0,跳过 0。
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 968 KiB

+491
View File
@@ -0,0 +1,491 @@
# KIOSK 工程业务与技术导读
> 面向第一次接手工程的开发者。本文基于 2026-08-04 的代码状态整理,目标是先建立完整的业务和技术心智模型,再进入具体模块开发。
## 1. 一句话理解这个工程
KIOSK 是部署在景区或线下门店 Android 自助终端上的照片售卖与打印客户端:游客可以上传手机照片,或通过人脸识别找到景区相机拍摄的照片,完成选片和支付后,由终端连接 DNP 照片打印机出片。
它同时还是一台长期在线的设备,因此还承担:
- 设备身份认证和配置同步
- WebSocket 实时消息接收与心跳上报
- 微信扫码隐私取片任务
- 打印机、纸张余量和任务状态管理
- 待机海报、视频、直播和语音播放
- 运营素材上传
- APK 在线升级和开机自启动
应用名称是“自助照片打印”,Application ID 为 `com.yzx.kiosk`,最低支持 Android 8.0(API 26)。
## 2. 业务参与方
理解下面几个参与方后,工程中的接口和状态会清晰很多。
| 参与方 | 职责 |
| --- | --- |
| 游客 | 在终端上选业务、拍照识别人脸、选片、扫码支付和取走照片 |
| 手机端/微信页面 | 扫终端二维码、上传照片、支付或发起隐私取片 |
| KIOSK Android 客户端 | 展示业务页面,维护设备状态,接收订单并驱动打印机 |
| Oscar 业务后端 | 下发设备配置、二维码、价格、订单、Socket Token、版本信息等 |
| 照片盒子/人脸服务 | 保存景区相机照片,提供公网或局域网人脸检索及图片地址 |
| OSS | 保存照片、海报、视频、音频和升级包等文件 |
| DNP 打印机 | 实际输出照片,当前主要适配 RX1 和 QW410 |
| 运维人员 | 配置设备密钥、局域网模式、纸张数、素材、打印机和应用版本 |
整体关系如下:
```mermaid
flowchart LR
Visitor["游客"] --> Kiosk["Android KIOSK"]
Phone["手机端 / 微信"] <-->|"上传、支付、取片"| Backend["Oscar 业务后端"]
Kiosk <-->|"HTTPS API"| Backend
Kiosk <-->|"WebSocket 消息与心跳"| Backend
Kiosk <-->|"人脸检索、照片读取"| FaceBox["照片盒子 / 人脸服务"]
Backend <--> OSS["阿里云 OSS"]
Kiosk -->|"下载或上传素材"| OSS
Kiosk -->|"DNP SDK 打印任务"| Printer["RX1 / QW410 打印机"]
Operator["运维人员"] --> Kiosk
```
## 3. 用户能看到的主要业务
### 3.1 首页
首页是所有业务的入口,主要展示:
- 人脸识别照片/打卡点照片打印入口
- 上传手机照片打印入口
- 微信扫码隐私取片二维码
- 首页宣传视频
- 当前打印任务状态
- 剩余打印纸张数和客服电话
首页双击特定区域会弹出管理密码,校验后进入设置页。首页 60 秒无操作,且当前没有打印任务时,会进入待机海报或直播层;用户触摸后返回首页。
主要代码:
- [`HomeScreen.kt`](../app/src/main/java/com/yzx/kiosk/ui/home/view/HomeScreen.kt)
- [`HomeViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/home/viewmodel/HomeViewModel.kt)
- [`InactivityManager.kt`](../app/src/main/java/com/yzx/kiosk/utils/InactivityManager.kt)
### 3.2 手机照片上传打印
这是 `capture type = 1` 的业务。
```mermaid
sequenceDiagram
actor User as 游客
participant K as KIOSK
participant API as 业务后端
participant WS as WebSocket
participant P as DNP 打印机
User->>K: 进入“上传手机照片打印”
K->>API: 获取上传二维码和打印服务信息
K-->>User: 展示二维码、价格和规格
User->>API: 手机扫码并上传照片
WS-->>K: code=1 扫码成功
WS-->>K: code=2 下发 file_map 照片列表
K-->>User: 展示照片并选择
K->>API: verify-result 计算单价和总价
K->>API: get-pay-url 获取支付二维码
User->>API: 手机支付
WS-->>K: code=5 支付成功及订单信息
K-->>User: 支付成功,直接进入打印页
K->>P: 逐张下载、处理并打印
K->>API: print-notify 上报每张结果
K->>API: print-complete 上报订单完成
```
对应页面链路:
```text
首页
-> 上传照片二维码页 UploadPhotoScreen
-> 照片选择页 PhotoSelectScreen
-> 打印页 PrintingScreen
-> 自动返回首页
```
核心类:
- [`UploadPhotoViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/UploadPhotoViewModel.kt):加载上传二维码,监听扫码和文件列表事件
- [`PhotoSelectViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PhotoSelectViewModel.kt):选片、计价、获取支付二维码,并在支付成功后直接进入打印页
- [`PrintingViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt):逐张下载、打印、记录并上报结果
服务端通过 `file_map` 同时下发 OSS 和局域网地址。客户端根据设置中的“使用 LAN”开关选择 `*_lan_url` 或 `*_oss_url`。
### 3.3 人脸识别照片打印
这是 `capture type = 2` 的业务,适用于景区相机、无人机或打卡点预先拍摄的照片。
业务过程:
1. 游客进入人脸识别页。
2. CameraX 打开前置相机,支持 5 秒倒计时或手动拍照。
3. 客户端把照片上传到照片盒子的 `/{sn}/api/search` 接口。
4. 服务根据人脸相似度返回当天匹配的照片。
5. 终端展示缩略图,游客选择照片。
6. 客户端请求后端计算价格并生成支付二维码。
7. WebSocket 或 HTTP 轮询确认支付成功后直接进入打印页。
主要代码:
- [`FaceRecognitionScreen.kt`](../app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionScreen.kt):相机页面
- [`FaceRecognitionViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt):拍照、方向修正和人脸检索
- [`FaceRecognitionResultViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt):结果展示、选片、计价和支付
- [`FaceSearchService.kt`](../app/src/main/java/com/yzx/kiosk/network/service/FaceSearchService.kt):人脸搜索 Retrofit 接口
人脸服务地址、盒子 SN 和 Token 来自设备配置。设置为 LAN 模式时走局域网地址,否则走公网地址。
### 3.4 微信扫码隐私取片
隐私取片不是通过当前页面一步步操作,而是后台 WebSocket 直接下发的打印任务。
处理过程:
1. 首页显示服务端配置的微信隐私取片二维码。
2. 用户在微信侧完成选片或确认。
3. WebSocket 收到 `code = 4` 消息。
4. `PrintQueueManager` 按订单去重并排队。
5. `PrivacyPrintService` 依次下载照片并调用打印机。
6. 首页顶部显示“手机尾号 xxxx 用户正在打印/打印完成”。
7. 客户端上报单张结果和订单完成状态,并更新纸张余量。
主要代码:
- [`WebSocketService.kt`](../app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt)
- [`PrintQueueManager.kt`](../app/src/main/java/com/yzx/kiosk/websocket/PrintQueueManager.kt)
- [`PrivacyPrintService.kt`](../app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt)
- [`PrivacyPrintMessage.kt`](../app/src/main/java/com/yzx/kiosk/websocket/model/PrivacyPrintMessage.kt)
这条链路允许游客在手机端操作打印时,终端屏幕继续服务其他用户,因此打印状态通过全局状态栏展示,而不是强制占用前台页面。
### 3.5 打印机处理
打印能力封装在 [`PrinterService.kt`](../app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt) 中,底层使用 DNP 本地 AAR/JNI SDK。
当前处理步骤:
1. 枚举打印机端口并保存打印机 ID。
2. 根据 ID 区分 RX1 和 QW410。
3. 按打印机方向旋转原图。
4. 保持原比例缩放,居中绘制到白色背景画布。
5. 保存为 300 DPI BMP 文件。
6. 创建 `PrintJob` 并加入 DNP `PrintQueue`。
7. 更新打印状态、打印记录和纸张余量。
尺寸配置位于 [`PRINTSIZE.kt`](../app/src/main/java/com/yzx/kiosk/priter/PRINTSIZE.kt):
| 打印机 | 画布尺寸 | 方向 |
| --- | --- | --- |
| DNP RX1 | 1840 × 1240 | 横向 |
| DNP QW410 | 1266 × 1836 | 纵向 |
工程里的打印包目录名是历史拼写 `priter`,并不是文档笔误。
### 3.6 待机广告、直播和语音
设备配置接口会下发海报、背景音乐、首页视频及直播信息。
- 图片/视频海报使用 Compose + Media3 展示
- 首页底部视频通过 ExoPlayer 循环播放
- 景区直播通过 RTMP 地址播放
- WebSocket 绑定成功后发送 `type = 304` 订阅直播变化
- 本地页面引导语音由 `LocalAudioPlayService` 管理
- 网络 BGM 和其他音频由 `AudioPlayService` 管理
- 打印通知还可通过阿里云语音合成播报
主要代码:
- [`PosterScreenWithLiveOrSlideshow.kt`](../app/src/main/java/com/yzx/kiosk/ui/poster/view/PosterScreenWithLiveOrSlideshow.kt)
- [`ScenicLivePosterController.kt`](../app/src/main/java/com/yzx/kiosk/ui/poster/ScenicLivePosterController.kt)
- [`AudioPlayService.kt`](../app/src/main/java/com/yzx/kiosk/audio/AudioPlayService.kt)
- [`LocalAudioPlayService.kt`](../app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt)
### 3.7 设备管理和运营配置
设置页用于现场运维,主要包括:
- 切换公网/局域网照片地址
- 设置剩余打印纸张数
- 打开设备配置页和打印机管理页
- 查看帮助中心、关于我们和协议页面
- 手动检查应用更新
设备配置页还可以维护:
- 设备密钥
- 景区和客服电话
- 隐私取片二维码开关
- 首页直播和新版首页开关
- 最多 5 个已选海报
- 背景音乐
- 上传自定义图片、视频和音频素材
运营配置既会调用 `/api/oscar/config/set` 保存到后端,也会同步一部分到本地 MMKV,供首页立即读取。
主要代码:
- [`SettingViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/setting/viewmodel/SettingViewModel.kt)
- [`DeviceConfigViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/device/viewmodel/DeviceConfigViewModel.kt)
- [`PrinterManageViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/printer/viewmodel/PrinterManageViewModel.kt)
### 3.8 在线升级与设备自启动
`VersionUpdateManager` 请求 `type = 8` 的最新 Android 终端版本,下载 APK 后通过 `FileProvider` 拉起系统安装界面。Manifest 同时注册了开机和包更新广播,目标是让自助终端重启或升级后恢复运行。
主要代码:
- [`VersionUpdateManager.kt`](../app/src/main/java/com/yzx/kiosk/utils/VersionUpdateManager.kt)
- [`BootReceiver.kt`](../app/src/main/java/com/yzx/kiosk/receiver/BootReceiver.kt)
- [`AndroidManifest.xml`](../app/src/main/AndroidManifest.xml)
## 4. 应用启动时发生什么
```text
应用进程启动,App.onCreate 完成全局初始化
-> 系统或桌面启动 SplashActivity
-> 等待约 1.5 秒
-> 进入 MainActivity
-> 初始化无操作检测并注册 WebSocket 监听器
-> MainActivity 获取 Socket Token
-> WebSocket 连接并发送 type=1000 绑定设备
-> Compose 创建 AppNavHost 并进入首页
-> HomeViewModel 拉取设备配置
-> 每 5 秒发送 type=1001 心跳
-> 首页开始展示入口、二维码、视频和纸张信息
-> 无操作 60 秒后展示待机海报或直播
```
Application 级初始化在 [`App.kt`](../app/src/main/java/com/yzx/kiosk/App.kt) 中完成,包括 Hilt、MMKV、日志、Toast、Coil 缓存和全局状态初始化。DNP SDK 延迟到第一次使用打印机时初始化。
## 5. WebSocket 协议在客户端中的作用
WebSocket 是业务闭环的关键,不只是普通通知。当前客户端使用的主要消息如下:
| 方向 | 类型或 code | 含义 |
| --- | --- | --- |
| 客户端 -> 服务端 | `type = 1000` | 使用 Socket Token 绑定设备 |
| 客户端 -> 服务端 | `type = 1001` | 心跳,上报设备、打印机状态和剩余纸张 |
| 客户端 -> 服务端 | `type = 304` | 订阅景区直播状态 |
| 服务端 -> 客户端 | `code = 1` | 手机扫码成功 |
| 服务端 -> 客户端 | `code = 2` | 上传照片列表到达 |
| 服务端 -> 客户端 | `code = 4` | 隐私取片打印任务 |
| 服务端 -> 客户端 | `code = 5` | 支付成功,包含订单和照片信息 |
| 服务端 -> 客户端 | `code = 100000` | 绑定、心跳或直播动作成功 |
| 服务端 -> 客户端 | `code = 100099` | Token 失效,需要刷新并重新绑定 |
`WebSocketService` 把上传/支付类消息转换为 `SharedFlow<UploadPhotoEvent>`,页面 ViewModel 各自订阅;隐私打印则直接进入后台打印队列。
## 6. 网络认证和环境
普通 HTTP 请求由 [`RequestInterceptor.kt`](../app/src/main/java/com/yzx/kiosk/network/interceptor/RequestInterceptor.kt) 添加:
```text
sn = 设备 SN
timestamp = 当前秒级时间戳
token = MD5(sn + deviceSecret + timestamp)
```
设备密钥需要先在设备配置页录入。没有密钥时客户端不会生成 `token` 请求头,首页会提示先配置设备密钥。
构建环境由 `BuildConfig` 区分:
| 构建类型 | HTTP API | WebSocket |
| --- | --- | --- |
| Debug | `api-test.zhifly.cn` | 测试 WSS |
| Release | `api.zhifly.cn` | 正式 WSS |
配置位置为 [`app/build.gradle.kts`](../app/build.gradle.kts),网络组件装配位于 [`NetworkModule.kt`](../app/src/main/java/com/yzx/kiosk/network/di/NetworkModule.kt)。
## 7. 工程采用的技术
### 7.1 语言、构建和 UI
| 技术 | 用途 |
| --- | --- |
| Kotlin 2.1.10 | 主要开发语言,JVM Target 11 |
| Gradle 8.11.1 + AGP 8.10.0 | Android 构建系统 |
| Jetpack Compose + Material 3 | 主要页面 UI |
| ViewBinding + XML | Splash 等少量传统页面 |
| Navigation Compose | 页面路由和回退栈 |
| StateFlow / SharedFlow | 页面状态、全局状态和实时事件 |
| Coroutines | 网络、打印、上传下载和倒计时等异步任务 |
整体采用接近单 Activity + MVVM 的结构:
```mermaid
flowchart TD
UI["Compose Screen"] --> VM["Hilt ViewModel"]
VM --> Repo["Repository / Manager"]
Repo --> Api["Retrofit Service"]
Repo --> Local["Room / MMKV"]
VM --> Device["打印机、相机、播放器"]
WS["WebSocketService"] --> Flow["SharedFlow / StateFlow"]
Flow --> VM
VM --> Nav["AppNavigator"]
Nav --> UI
```
### 7.2 依赖注入和架构组件
- Hilt:Application、Activity、ViewModel、Repository 和 Service 的依赖注入
- KSP:Hilt、Room 和 Glide 等代码生成
- Lifecycle ViewModel:页面状态和协程生命周期
- 自定义 `AppNavigator`:通过 SharedFlow 解耦 ViewModel 与 NavController
- `BaseViewModel`:统一导航、Loading、网络结果和登录失效处理
### 7.3 网络与实时通信
- Retrofit + Gson:业务 REST API
- OkHttp:HTTP、APK 下载和 WebSocket
- 自定义请求拦截器:设备 SN、时间戳和签名 Token
- 自定义响应拦截器与结果层:统一解析业务 code
- WebSocket:扫码、照片列表、支付成功、隐私打印、直播和设备心跳
### 7.4 数据存储
| 存储 | 保存内容 |
| --- | --- |
| MMKV | 设备密钥、设备配置、Socket Token、盒子地址、纸张数、打印记录和开关状态 |
| Room `AppDatabase` | 运营素材上传任务及进度 |
| Room `CloudDatabase` | OSS 上传、下载任务 |
| 应用私有文件目录 | 处理后的打印 BMP、缓存和下载文件 |
| Coil 磁盘缓存 | 网络图片缓存,当前限制约 50 MB |
MMKV 的统一访问入口是 [`AppStoreDataSource.kt`](../app/src/main/java/com/yzx/kiosk/datastore/AppStoreDataSource.kt),全局响应式状态集中在 [`AppState.kt`](../app/src/main/java/com/yzx/kiosk/datastore/AppState.kt)。
### 7.5 图片、相机和扫码
- CameraX:人脸识别页拍照
- Coil / Glide:网络图片加载
- uCrop:照片裁剪
- PictureSelector:本地运营素材选择
- ML Kit Barcode Scanning + ZXing:扫码识别和二维码生成
- Android Bitmap/Canvas:打印前旋转、缩放和白底合成
### 7.6 音视频与云能力
- AndroidX Media3 / ExoPlayer:视频、音频和 RTMP 直播
- 阿里云 OSS SDK:素材上传下载
- 阿里云语音 SDK:状态语音合成
- 高德地图 SDK:定位相关能力
- DNP PhotoPrint SDK:照片打印机控制
本地闭源 SDK 位于 `app/libs/`,这是工程能否完整构建和连接硬件的重要前提。
## 8. 代码目录怎么理解
```text
app/src/main/java/com/yzx/kiosk/
├── App.kt # Application 初始化
├── SplashActivity.kt # 启动页
├── MainActivity.kt # 单 Activity 容器、Socket 和待机层
├── audio/ # 在线/本地音频播放
├── base/ # ViewModel 基类
├── component/ # 通用 Compose 组件和全局 UI
├── datastore/ # MMKV 封装和全局状态
├── navigation/ # 路由、导航事件和 NavHost
├── network/
│ ├── db/ # 上传任务 Room 数据库
│ ├── di/ # Hilt 网络与协程模块
│ ├── interceptor/ # 请求签名和响应处理
│ ├── manager/ # 素材上传任务调度
│ ├── model/ # 请求、响应和实体
│ ├── repository/ # 数据仓库
│ └── service/ # Retrofit 接口
├── priter/ # DNP 打印服务和打印状态
├── receiver/ # 开机/更新广播
├── ui/
│ ├── device/ # 设备和运营素材配置
│ ├── face/ # 人脸识别业务
│ ├── home/ # 首页
│ ├── poster/ # 待机海报和直播
│ ├── printer/ # 打印机管理
│ ├── setting/ # 设置、协议和帮助
│ └── upload/ # 上传、选片、支付、打印
├── utils/ # 更新、日志、二维码、超时等工具
│ └── cloud/ # OSS 上传下载及第二套 Room 数据库
└── websocket/ # 长连接、实时事件和隐私打印队列
```
## 9. 建议的代码阅读顺序
第一次接手时,不建议从工具类或所有依赖开始看。按下面顺序更容易建立上下文:
1. [`README.md`](../README.md):先把工程跑起来,理解环境和硬件要求。
2. [`AppRoutes.kt`](../app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt) 和 [`AppNavHost.kt`](../app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt):知道有哪些页面和用户动线。
3. [`HomeScreen.kt`](../app/src/main/java/com/yzx/kiosk/ui/home/view/HomeScreen.kt) 和 [`HomeViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/home/viewmodel/HomeViewModel.kt):理解首页配置如何驱动业务入口。
4. `ui/upload/viewmodel/`:完整看一遍上传、选片、支付、打印主链路。
5. `ui/face/viewmodel/`:理解第二条照片来源链路。
6. [`WebSocketService.kt`](../app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt):理解页面为什么会因服务端消息自动跳转。
7. [`PrinterService.kt`](../app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt) 和 [`PrivacyPrintService.kt`](../app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt):理解硬件和后台任务。
8. [`NetworkService.kt`](../app/src/main/java/com/yzx/kiosk/network/service/NetworkService.kt) 与 [`AppStoreDataSource.kt`](../app/src/main/java/com/yzx/kiosk/datastore/AppStoreDataSource.kt):梳理接口和持久化字段。
9. `ui/device/`、`utils/cloud/` 和 `VersionUpdateManager`:最后看运营配置、素材和升级能力。
10. [`工程代码审查报告.md`](工程代码审查报告.md):了解当前缺陷和发布风险。
## 10. 接手时最容易混淆的几个概念
### capture type
- `1`:用户从手机上传的照片
- `2`:通过人脸搜索得到的景区照片
它会贯穿计价、支付、打印通知和订单完成接口。
### file_map
一张照片不只有一个 URL,而是同时包含图片 ID、OSS 原图/缩略图地址和 LAN 原图/缩略图地址。打印必须保留图片 ID,服务端才能正确记录每张照片的状态。
### 设备 SN、设备密钥和 Socket Token
- 设备 SN:当前主要取 Android ID,标识终端
- 设备密钥:运维录入,用于普通 HTTP 请求签名
- Socket Token:后端接口动态获取,用于 WebSocket 绑定
这三个值用途不同,排查鉴权问题时不要混在一起。
### 公网模式和 LAN 模式
切换的是照片盒子服务和照片 URL 的访问方式,不是 Debug/Release 环境切换。Debug/Release 由构建类型决定,LAN/公网由设备设置决定。
### 两类打印
- 前台打印:游客在终端完成上传/人脸业务后进入 `PrintingScreen`
- 后台隐私打印:WebSocket `code = 4` 直接进入 `PrivacyPrintService`
两条链路最终都调用 `PrinterService`,但任务队列、UI 展示和状态上报入口不同。
## 11. 接手后的优先验证清单
在开始改业务前,建议用测试环境完成以下闭环:
1. 配置设备密钥,确认设备配置接口成功。
2. 确认 WebSocket 绑定成功且心跳包含正确纸张和打印机状态。
3. 手机扫码上传一张照片,走完选片、计价和支付事件。
4. 使用相机完成一次人脸搜索,分别测试公网和 LAN 地址。
5. 连接 RX1 或 QW410,各打印一张横图和竖图。
6. 验证单张 `print-notify` 和最终 `print-complete` 到达服务端。
7. 发送一条隐私打印任务,确认不会阻断前台用户操作。
8. 验证海报、视频、直播、BGM 和无操作返回逻辑。
9. 验证重启后开机自启动、配置恢复和 Socket 重连。
10. 在准备发布前阅读代码审查报告并处理签名、隐私、打印结果和并发问题。
## 12. 当前需要特别注意的技术债
本文重点是业务和技术导读,不展开缺陷细节;但以下问题会直接影响你对现有实现的判断:
- Release 当前实际使用 Debug 签名,且签名信息直接写在 Gradle 配置中。
- 打印任务加入 SDK 队列后不等于物理打印成功,当前成功判定不够可靠。
- 部分 Token、设备密钥和业务配置保存在未加密 MMKV 中。
- WebSocket、打印通知及上传下载存在并发和恢复方面的风险。
- APK 更新缺少足够的完整性与签名校验。
- 自动化测试覆盖少,Lint 当前仍有待处理项。
具体证据、文件位置和修复建议见 [`工程代码审查报告.md`](工程代码审查报告.md)。
---
如果要修改某个业务,优先从对应页面的 ViewModel 顺着 Repository、WebSocket 或 PrinterService 向下追踪;如果问题表现为“页面没有自动跳转”,通常还要同时检查 WebSocket 消息是否到达以及 `SharedFlow` 订阅是否仍处于活跃生命周期。
@@ -0,0 +1,247 @@
# KIOSK 工程代码优化审查报告
审查日期:2026-09-20
代码基线:`6d81f1a`,以本次工作区实际内容为准。
工程:Android / Kotlin / Jetpack Compose,包名 `com.yzx.kiosk`。
## 1. 结论与范围
建议先修复打印结果、订单通知和连接生命周期,再处理安全配置、文件资源与工程质量。当前主要风险并非页面代码写法,而是设备实际状态、本地记录和服务端订单状态缺少可靠的一致性保障。
本次完成源码清单扫描,并重点阅读应用入口、构建配置、打印服务、两条打印流程、WebSocket、网络结果封装、上传下载、数据库、升级、人脸识别与购片、资源管理及测试。主源码目录共 195 个 Kotlin/Java 文件,约 28,254 行;这不是逐行穷尽审计,也不包含本地闭源 AAR/JAR 的内部实现。
本次仅新增审查文档与验证记录,未修改业务代码。原有 `gradlew` 修改和未跟踪的 UI 预览文件予以保留。
证据分为三类:**源码确认**表示代码行为可直接定位;**测试确认**表示本次实际执行得到结果;**待实机验证**表示发生概率、设备表现或外部协议仍需验证。源码确认的风险不等同于已经发生过线上故障。
优先级定义:P1 为影响业务正确性、设备持续工作或敏感配置的问题;P2 为可靠性与工程质量问题;P3 为需要测量后推进的结构和性能改善。
| 编号 | 优先级 | 优化项 | 证据 |
| --- | --- | --- | --- |
| 01 | P1 | 打印成功应等待真实终态 | 源码确认,设备行为待实机验证 |
| 02 | P1 | 打印明细通知与订单完成应持久化、顺序提交 | 源码确认 |
| 03 | P1 | 打印机初始状态和初始化结果判定 | 源码确认 |
| 04 | P1 | 隐私取片队列幂等与单消费者 | 源码确认,重投协议待确认 |
| 05 | P1 | WebSocket 主动停止和旧连接回调隔离 | 源码确认 |
| 06 | P1 | 签名材料和固定管理密码 | 源码确认 |
| 07 | P1 | 打印中间文件清理和磁盘上限 | 源码确认 |
| 08 | P2 | 隐私取片使用原图、统一图片处理 | 源码确认,画质待实机对比 |
| 09 | P2 | 图片下载在读取阶段限制内存 | 源码确认 |
| 10 | P2 | 网络错误结果保持类型安全 | 源码确认 |
| 11 | P2 | APK 下载校验与缓存复用 | 源码确认 |
| 12 | P2 | 数据库迁移与 schema 管理 | 源码确认 |
| 13 | P2 | 凭证存储、备份和授权状态 | 源码确认 |
| 14 | P2 | Wrapper、Lint 与核心回归测试 | 测试及源码确认 |
| 15 | P2(启用前) | 云传输并发与取消句柄 | 源码确认,当前未找到业务入口 |
| 16 | P3 | 包体、诊断能力和大类拆分 | 优化建议,收益待测量 |
## 2. 优先修复项
### 01|打印入队被当成打印完成(P1)
位置:[PrinterService.kt:319](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt:319)。
**现象与影响:** `printQueue.addJob(job)` 后,RX1 分支立即设置空闲并回调成功;QW410 分支固定等待 18 秒后回调成功。业务层没有等待 SDK 的成功/失败终态。任务提交后缺纸、断连、卡纸或排队超过预估时间时,本地记录、纸张扣减和订单通知可能提前认定成功。现有 `printMutex` 只能保证该方法串行执行;RX1 方法提前返回后,它不能保证物理打印已经完成。
**建议:** 将“已提交”“设备执行中”“成功”“失败”“结果未知”分开。根据实际 SDK 支持,接入任务回调或按任务标识查询终态;等待超时进入待核对状态。业务成功、耗材记账与对外回执均由同一个终态驱动。先确认厂商 SDK 的终态语义,再实现适配层。
**验收:** 分别在 RX1/QW410 上覆盖正常出片、入队后拔线、缺纸、卡纸、长队列和超时;无可靠成功结果时不得显示完成或发送成功回执。
### 02|通知队列未等待网络完成,且没有持久化补偿(P1)
位置:[PrintingViewModel.kt:116](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt:116)、[PrintingViewModel.kt:373](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt:373)、[PrintingViewModel.kt:457](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt:457)、[ResultHandler.kt:27](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/network/result/ResultHandler.kt:27)。
**现象与影响:** Channel 消费者调用 `executePrintNotify()`,但底层 `handleResultWithData()` 再次 `scope.launch`,没有等待请求结束。`print-complete` 又在另一条路径直接发起,没有等待明细通知全部确认。慢网情况下完成通知可能先到;退出页面或进程终止会丢失内存中的待通知状态。失败回调仅记录日志。
**建议:** 核心业务接口改为可等待的挂起调用;把打印事实和待发送回执写入 Room,在同一订单内按明细确认、订单完成的顺序推进。为每张照片/每次授权打印建立幂等标识,重试采用退避并能跨进程恢复。
纸张扣减应依据**可靠的物理打印成功事实**,并保证仅记账一次。网络通知失败时应重试同步,不应简单把已实际消耗的纸张加回;否则会引入新的库存偏差。
**验收:** 延迟首条明细响应、断网、页面退出、进程重启后,回执不丢失且顺序符合服务端约定;重试不会重复扣纸。
### 03|默认在线,初始化又未检查端口查询结果(P1)
位置:[PrintStatusManager.kt:24](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/priter/PrintStatusManager.kt:24)、[PrinterService.kt:59](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt:59)。
**现象与影响:** `_isOnline` 默认是 `true`。`initDnpPrint()` 虽取得 `portNum`,却没有判定返回值和端口有效性,就读取预分配数组的第一项,保存 ID 并设置在线。无打印机但查询未抛异常时,也可能报告连接成功;后续打印才因 ID 为 0 失败。
**建议:** 初始状态设为未知或离线;结合 SDK 返回语义校验端口数量、设备 ID 和实际可用状态。重连、USB 拔插、忙碌状态应有一致的状态转换,避免初始化把正在执行的任务覆盖为空闲。
**验收:** 不接设备冷启动、接入/拔出设备、异常返回值和打印中重新探测时,界面与心跳均不误报可接单。
### 04|隐私取片缺少持久化去重,消费者启动存在竞争(P1)
位置:[PrintQueueManager.kt:34](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/PrintQueueManager.kt:34)、[PrintQueueManager.kt:104](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/PrintQueueManager.kt:104)、[PrivacyPrintService.kt:87](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt:87)、[WebSocketService.kt:890](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt:890)。
**现象与影响:** 完成任务直接从内存列表移除,相同订单消息再次到达时可以重新入队;进程重启也没有去重记录。`processNextTask()` 的 `isProcessing` 检查和置位不在一个原子操作中,多个 IO 协程可能同时通过检查;队列自身加锁不能保护外部消费者状态及单个 `currentPrintTask`。
**建议:** 使用一个受管理的消费者串行领取任务;持久化任务状态与去重标识。和服务端明确“重复投递”与“用户授权补打”的区别,以消息 ID 或打印批次 ID 区分,避免把合法补打一律拒绝。异常终止后,已经提交硬件但终态未知的任务先核对,不能盲目再次打印。
**验收:** 同一消息连续投递、并发投递、完成后重投和重启后重投均只执行一次;独立授权补打仍可执行。
### 05|主动停止 WebSocket 后可能自动重连(P1)
位置:[WebSocketService.kt:185](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt:185)、[WebSocketService.kt:695](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt:695)、[WebSocketService.kt:717](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt:717)。
**现象与影响:** `stop()` 取消已有重连并关闭连接,但关闭后到达的 `onClosed()` 无条件调用 `scheduleReconnect()`,代码没有停止标志。旧连接的 `onClosed/onFailure` 还会无条件将共享 `webSocket` 清空,可能干扰已经建立的新连接。多处临时创建的 CoroutineScope 使停止时难以完整取消工作。
**建议:** 建立统一作用域和连接状态机,持久保留本次运行是否允许连接的标志;回调携带连接代次,只允许当前连接修改状态。将 start/stop、心跳和重连事件集中到同一消费者或互斥保护范围内。
**验收:** 主动停止后等待多个重连周期也不恢复连接;连续 start/stop、旧连接延迟失败以及网络切换时,始终只有一个有效连接与心跳任务。
### 06|签名材料受版本管理,管理密码固定(P1)
位置:[app/build.gradle.kts:112](/Users/hanqiu/Desktop/KIOSK/app/build.gradle.kts:112)、[app/build.gradle.kts:151](/Users/hanqiu/Desktop/KIOSK/app/build.gradle.kts:151)、[HomeViewModel.kt:162](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/ui/home/viewmodel/HomeViewModel.kt:162)、[HomeViewModel.kt:323](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/ui/home/viewmodel/HomeViewModel.kt:323)。
**确认事实:** 签名密码写在 Gradle 配置中,Release 选择名为 `debug` 的签名配置;`git ls-files` 确认两个 `.jks` 文件仍受版本管理。虽然 `.gitignore` 已排除此扩展名,但不能移除已经跟踪的文件。设置入口仍将输入与固定常量比较。本文不复制任何口令。
**建议:** 签名凭证转移至受控的本机配置或 CI Secret,并处理仓库中已跟踪的材料及历史泄露范围;先核实在用设备的签名证书和升级路径,再迁移签名,避免直接替换导致无法覆盖安装。管理入口采用设备独立认证或短期授权,并限制连续失败。
**验收:** 工作树及发布配置没有明文签名密码;发布包证书符合既定升级策略;仅拿到 APK 不能获得所有设备共用的管理口令。本次未重新构建并比较签名证书。
### 07|打印 BMP 无清理策略,长期运行可能耗尽磁盘(P1)
位置:[PrinterService.kt:253](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt:253)。
**现象与影响:** 每张照片生成带时间戳的 `ProcessedPhotos/*_print.bmp`。检索主源码未发现针对该目录的清理或容量限制。以 1844×1240 的 24 位像素计算,单张像素数据约 6.54 MiB,1,000 张约 6.4 GiB;这是容量估算,并非设备磁盘实测。临时打印图长期保留也扩大了照片留存范围。
**建议:** 在确认 SDK 不再读取文件后清理;失败路径也应清理未提交的文件。增加启动时孤儿文件清理、保留时限与容量上限,保护正在使用的任务文件,并在空间不足时明确拒绝新任务。此项依赖第 01 项的可靠任务终态,不能在 `addJob()` 后立即删除。
**验收:** 连续打印及异常重启后目录容量维持在设定上限,待打印文件不被误删。
## 3. 下一轮可靠性与质量优化
### 08|隐私取片走缩略图,图片处理链路不统一(P2)
位置:[PrivacyPrintService.kt:135](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt:135)、[PrivacyPrintService.kt:537](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt:537)、[BatchQueryResponse.kt:29](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/network/model/response/BatchQueryResponse.kt:29)。
隐私取片建立图片映射时选择 `thumbnailLanUrl/thumbnailOssUrl`,但响应模型已有原图字段。之后把 Coil Drawable 再复制成 Bitmap,增加峰值内存;该流程也没有复用普通打印流程的下载容器校验。两条流程的输入质量和校验标准不同。
建议打印优先采用原图,缩略图保留给页面预览;原图缺失时按明确的产品策略报错或允许有提示的降级。统一下载、完整性验证、采样和 Bitmap 所有权,避免未经确认就回收图片缓存持有的对象。用相同订单在两个入口打印,比较有效像素、裁切和成片清晰度,并测量峰值内存。
### 09|50 MiB 下载上限在未知长度响应中生效过晚(P2)
位置:[PrintingViewModel.kt:527](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt:527)、[PrintImageIntegrityValidator.kt:46](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/priter/PrintImageIntegrityValidator.kt:46)。
已实现 Content-Length 检查和解码前大小检查,这是有效改进。但当服务端未提供长度时,`body.bytes()` 会先完整读取响应,之后才检查 50 MiB 上限,无法阻止读取阶段的内存膨胀。捕获 `OutOfMemoryError` 不能代替输入限流。
建议按块读取并累计实际字节数,超过上限立即停止;或流式落临时文件后做边界检查与采样解码。协程取消时同时取消底层 Call。验收使用无 Content-Length、超限和中途断流响应,确认超限读取及时停止且不提交打印。
### 10|错误响应被改写,空数据被强转为 Unit(P2)
位置:[ResponseInterceptor.kt:26](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/network/interceptor/ResponseInterceptor.kt:26)、[ResultHandler.kt:44](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/network/result/ResultHandler.kt:44)。
HTTP 成功但业务码不是 `100000` 时,拦截器将 `data` 强制替换成 `{}`。对于列表、字符串等接口类型,这可能在进入业务错误分支前触发反序列化失败,丢失真实错误信息。另一处 `response.data ?: Unit as T` 则把成功但缺失的数据伪装为任意业务类型,调用方可能发生类型转换异常。
建议保持原始响应结构,在结果层区分业务失败、协议格式错误和传输失败;有返回数据和无返回数据的接口分别建模。补充对象/数组/字符串/null、业务失败和 HTTP 失败的契约测试,确认不会用类型异常覆盖原错误。
### 11|APK 复用仅判断长度,未知长度时仍直接安装(P2)
位置:[VersionUpdateManager.kt:313](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/utils/VersionUpdateManager.kt:313)、[VersionUpdateManager.kt:536](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/utils/VersionUpdateManager.kt:536)。
已有文件与服务器长度相同便复用;无法获取有效长度时也复用本地文件。新下载同样主要检查长度,不能识别等长内容损坏或错误版本缓存。
建议版本元数据提供可信摘要,下载到临时路径后校验内容摘要、预期包名、版本及签名,再原子提交。不要把本地计算出的哈希本身当作真实性证明,必须与可信预期值比较。系统安装校验仍然存在;本项不声称任意错误签名 APK 能覆盖安装。验收覆盖等长损坏、未知长度、旧缓存及错误版本。
### 12|升级迁移可能清除上传任务(P2)
位置:[DatabaseModule.kt:20](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/network/db/DatabaseModule.kt:20)、[AppDatabase.kt:10](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/network/db/AppDatabase.kt:10)。
`app_database` 只注册 `7 → 8` 迁移,并启用了破坏性迁移回退。若设备存在无迁移链覆盖的旧版本数据库,待上传任务会有丢失风险。两个 Room 数据库声明导出 schema,但本次检索构建配置未找到 `room.schemaLocation`。
建议先列出现网数据库版本,再补齐所有仍支持版本的迁移路径,导出并纳入版本管理的 schema,执行保留任务数据的迁移测试。`CloudDatabaseModule` 已有 `1 → 2 → 3` 迁移链,不应将本问题泛化为两个数据库都没有迁移。
### 13|凭证备份与授权状态缺少显式边界(P2)
位置:[MMKVUtils.kt:17](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/utils/MMKVUtils.kt:17)、[AppStoreDataSource.kt:238](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/datastore/AppStoreDataSource.kt:238)、[AndroidManifest.xml:60](/Users/hanqiu/Desktop/KIOSK/app/src/main/AndroidManifest.xml:60)、[App.kt:55](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/App.kt:55)。
设备密钥和 Token 经默认 MMKV 保存,未传入加密密钥;应用允许备份,两个备份规则文件仍是模板,未显式排除敏感数据。`App.onCreate()` 无条件告知地图 SDK 已同意,未关联应用内可核查的授权记录。源码存在隐私弹窗类,但未检索到业务调用。
建议定义凭证的存储、迁移和吊销边界,用受设备密钥保护的加密方案保存敏感字段,明确备份/迁移排除规则。地图初始化条件应关联真实授权或有记录的设备部署流程。另需核对 Manifest 中全局明文访问与各项权限的实际用途,再按公网/局域网需求收敛;不要直接阻断确有需要的局域网照片服务。
验收覆盖首次部署、重启、清除授权、备份还原及设备更换,确认凭证不会意外迁移且 SDK 行为与记录一致。此处审查的是技术状态与数据边界,未做法律合规结论。
### 14|构建入口与质量检查尚未形成稳定门禁(P2)
位置:[gradlew](/Users/hanqiu/Desktop/KIOSK/gradlew)、[ExampleInstrumentedTest.kt:24](/Users/hanqiu/Desktop/KIOSK/app/src/androidTest/java/com/yzx/kiosk/ExampleInstrumentedTest.kt:24)。
本次直接运行 `./gradlew` 失败,原因是 CRLF shebang:`env: sh\r: No such file or directory`。当前文件已经有执行权限,不能再沿用旧报告的“无执行权限”结论。为保留用户已有改动,本次通过 Java 直接运行 Gradle Wrapper 完成验证。
建议 Wrapper 固定为 LF,加入换行规范。Lint 的 4 个错误应逐一处理:3 个 Media3 opt-in 错误;另 1 个通知权限错误来自 Glide 的 `NotificationTarget` 使用分析,需先确认产品是否实际发送通知,再补权限流程或对确实不可达的库路径做有说明的定向处理,避免为消除告警盲目增加权限。
已有 44 个单元测试全部通过,但核心打印终态、回执持久化、WebSocket 停止/重连、迁移链仍缺少对应专项测试。Instrumentation 示例仍断言旧包名 `com.zhifly.follow`,与当前应用包名不符,应修复或移除失效示例。
验收目标:标准 `./gradlew` 命令可运行,Lint 错误清零;增加本报告 P1 场景的故障注入测试和目标机型集成测试。
### 15|云传输多任务模型与单一取消句柄冲突(P2,启用前处理)
位置:[DownloadRepository.kt:57](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/utils/cloud/DownloadRepository.kt:57)、[UploadRepository.kt:57](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/utils/cloud/UploadRepository.kt:57)、[CloudOssUtils.kt:51](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/utils/cloud/CloudOssUtils.kt:51)。
两个 Repository 以 `taskId → Job` 管理多个任务,OSS 封装却各只保存一个 `currentUploadTask/currentDownloadTask`。A、B 同时执行后暂停 A,会取消最后登记的 SDK 任务 B。进度与终态共用 `callbackFlow.trySend()` 且未检查发送结果,缓冲区被进度占满时还有丢失成功/失败事件的风险;进度回调逐次写库也可能放大压力。
**当前可达性限制:** 本次检索主源码只找到两个 Repository 的定义,未找到业务引用。应先确认是否是预留/废弃功能,因此不将其列为当前线上必现故障。普通 `UploadManager` 是另一条传输实现,不能混为同一入口。
若保留此功能,SDK 请求句柄应按任务持有,取消操作绑定明确任务;限制并发并保护任务表;进度可节流或合并,但终态必须可靠送达并入库。验收并行上传/下载时只取消指定任务,模拟缓慢写库仍不丢终态。
### 16|包体、诊断和职责划分(P3)
| 方向 | 当前证据 | 建议及衡量方式 |
| --- | --- | --- |
| 包体 | [构建配置:149](/Users/hanqiu/Desktop/KIOSK/app/build.gradle.kts:149) 关闭 R8/资源压缩,声明多个 ABI;[233 行](/Users/hanqiu/Desktop/KIOSK/app/build.gradle.kts:233) 将 Crashlytics buildtools 作为运行时依赖 | 先分析依赖树与 APK 构成,确认是否有实际运行时代码依赖;按终端 ABI 交付,逐步开启压缩并验证反射/JNI/厂商 SDK。记录每步产物大小,不沿用旧报告的 144 MB 作为现状 |
| 线上诊断 | [LogUtils.kt:15](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/utils/LogUtils.kt:15) 将 error/warn 也限制为 Debug | 发布版本保留脱敏错误事件、订单/任务关联 ID、重试次数和失败阶段,限制日志容量。验收一笔失败订单能定位到下载、硬件或通知阶段,不记录凭证和完整照片 URL |
| 大类职责 | DeviceConfigViewModel 1,574 行、WebSocketService 947 行、VersionUpdateManager 811 行、FaceRecognitionResultViewModel 721 行 | 按设备配置、连接、支付协调、升级下载/安装划分可测试组件;先修业务边界,再拆文件,避免一次性重构全工程 |
| 图片列表刷新 | [FaceRecognitionResultViewModel.kt:235](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt:235) 每次宽高比更新遍历整表;页面已使用稳定图片 ID 作为 key | 先在大量照片场景测滚动帧耗时和重组,再决定批量更新或按项状态。当前没有性能数据,不能断言已有明显卡顿 |
| 启动文件 I/O | [App.kt:66](/Users/hanqiu/Desktop/KIOSK/app/src/main/java/com/yzx/kiosk/App.kt:66) 在启动线程同步清理人脸残留文件 | 大量残留时可能拖慢启动;测量后迁到 IO,并用初始化屏障或文件代次防止清理新会话文件 |
## 4. 已有改进,应保留
这些内容说明旧报告不能原样套用到当前代码:
- `PrinterService` 已有互斥锁;`PrintImageIntegrityValidator` 已有输入容器、尺寸、采样、BMP 结构校验。应在此基础上补真实终态与下载读取上限。
- 人脸预览解码已有采样、EXIF 处理和最长边 720px 限制,相关实现检查了临时 Bitmap 的身份后再回收。
- 人脸拍摄有专门的文件管理器,并在流程结束/退出以及下次启动清理残留。
- 结果网格通过已加载 Drawable 获取比例,避免单独再次下载图片探测尺寸。
- 人脸购片已有报价代次控制、支付重复处理保护、最近照片请求去重等逻辑,并有专项单元测试。
- 网络下载客户端已避免通用响应拦截器吞入大文件;普通网络日志仅在 Debug 配置启用。
## 5. 本次验证记录
| 项目 | 本次结果 |
| --- | --- |
| 标准 Wrapper 入口 | 失败:CRLF shebang;执行权限当前存在 |
| Debug 单元测试 | **44 项通过,0 失败、0 错误、0 跳过**;11 个测试源码文件 |
| Debug Lint | **失败:4 errors、329 warnings、8 hints** |
| Instrumentation 测试 | 源码共 5 个文件、20 个 `@Test`;本次未运行 |
| 物理打印、USB 热插拔、断网重连 | 本次未做设备实测 |
| Release 构建、签名摘要、APK 大小 | 本次未重新测量 |
本次执行命令(先以标准入口尝试,再绕过 CRLF 启动脚本):
```sh
./gradlew :app:testDebugUnitTest :app:lintDebug --offline --console=plain
java -classpath gradle/wrapper/gradle-wrapper.jar org.gradle.wrapper.GradleWrapperMain :app:testDebugUnitTest :app:lintDebug --offline --console=plain
```
第二条命令已完成单元测试,最终因为 Lint 失败返回非零退出码。运行时使用现有离线依赖缓存,部分编译任务为 UP-TO-DATE;这不是全量 clean build。Gradle 缓存写入限制已通过获准的执行权限解决,不属于工程缺陷。
持久化证据:[构建与检查日志](/Users/hanqiu/Desktop/KIOSK/docs/reviews/2026-09-20/gradle-verification.log)、[单元测试摘要](/Users/hanqiu/Desktop/KIOSK/docs/reviews/2026-09-20/unit-test-summary.md)、[Lint XML](/Users/hanqiu/Desktop/KIOSK/docs/reviews/2026-09-20/lint-results-debug.xml)。
本次 4 个 Lint 错误位置:
| 规则 | 位置 |
| --- | --- |
| NotificationPermission | AndroidManifest.xml;报告说明来源为 Glide NotificationTarget |
| UnsafeOptInUsageError | AppNavHost.kt:94 |
| UnsafeOptInUsageError | PosterScreenWithLiveOrSlideshow.kt:115 |
| UnsafeOptInUsageError | PosterScreenWithLiveOrSlideshow.kt:117 |
## 6. 推荐实施顺序与验收门槛
1. **打印闭环:** 先明确硬件终态和去重协议,完成 01~04,再实现 07 的安全清理;验收以真实出片、库存和服务端订单一致为准。
2. **连接与发布控制:** 修复 05,处理 06 的凭证及既有安装签名迁移;并行修复 Wrapper 与 4 个 Lint 错误。
3. **长期运行保障:** 完成 08~13,补齐断网、磁盘不足、进程重启和数据库升级的故障场景。
4. **结构和性能:** 先确认 15 的业务去留,再执行 16 的拆分、测量与包体优化。
在开始实现前,需要从设备/服务端取得三类信息:打印 SDK 可用的任务终态接口;消息重复投递及授权补打标识;现网 APK 签名、数据库版本与升级范围。这些信息影响具体实现方案,但不影响上述源码问题的成立。
首轮完成标准应是:打印终态可靠、回执可恢复、重复消息不重复出片、主动停止不重连、文件容量受控、发布凭证管理明确,并将对应回归检查加入日常构建。
@@ -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"
Vendored Regular → Executable
View File