Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
835d6ecd00 | ||
|
|
6d81f1a28b | ||
|
|
2231c38792 | ||
|
|
fa827fdd99 | ||
|
|
5f8586666c | ||
|
|
b1cdc1fe06 | ||
|
|
712f6154bc | ||
|
|
1d66bd18d1 | ||
|
|
63944db6fb | ||
|
|
fb5d3b71df | ||
|
|
cd25ada951 | ||
|
|
4c829f74a9 | ||
|
|
86bc9528dd |
@@ -89,8 +89,8 @@ android {
|
||||
applicationId = "com.yzx.kiosk"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 21
|
||||
versionName = "1.1.1"
|
||||
versionCode = 24
|
||||
versionName = "1.1.4"
|
||||
//multiDexEnabled = true
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.yzx.kiosk.audio
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.yzx.kiosk.R
|
||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class LocalAudioPlaybackTest {
|
||||
@Test fun recentAndFaceEntriesSwitchAudioWithoutDuplicatePlayback() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
lateinit var service: LocalAudioPlayService
|
||||
lateinit var player: ExoPlayer
|
||||
val transitions = AtomicInteger()
|
||||
instrumentation.runOnMainSync {
|
||||
service = LocalAudioPlayService(instrumentation.targetContext)
|
||||
player = LocalAudioPlayService::class.java.getDeclaredField("exoPlayer").apply { isAccessible = true }.get(service) as ExoPlayer
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { transitions.incrementAndGet() }
|
||||
})
|
||||
}
|
||||
try {
|
||||
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.buildRecentPhotosRoute()) }
|
||||
awaitPlaying(service)
|
||||
assertEquals(R.raw.recent_photos_result_page, service.currentResId.value)
|
||||
assertEquals(1, transitions.get())
|
||||
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.buildRecentPhotosRoute()) }
|
||||
instrumentation.waitForIdleSync()
|
||||
assertEquals("Repeated recent entry must not reset the media item", 1, transitions.get())
|
||||
|
||||
instrumentation.runOnMainSync { service.playByRoute("${AppRoutes.FACE_RECOGNITION_RESULT}?results=%5B%5D&source=face") }
|
||||
awaitPlaying(service)
|
||||
assertEquals(R.raw.face_recognition_success_result_page, service.currentResId.value)
|
||||
assertEquals(2, transitions.get())
|
||||
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.FACE_RECOGNITION_RESULT) }
|
||||
instrumentation.waitForIdleSync()
|
||||
assertEquals("Legacy face route uses the same success clip", 2, transitions.get())
|
||||
} finally {
|
||||
instrumentation.runOnMainSync { service.release() }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun electronicCompletionSwitchesToDedicatedClipAndPreservesPrintAudio() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
lateinit var service: LocalAudioPlayService
|
||||
instrumentation.runOnMainSync { service = LocalAudioPlayService(instrumentation.targetContext) }
|
||||
try {
|
||||
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.PRINT_SUCCESS) }
|
||||
awaitPlaying(service)
|
||||
assertEquals(R.raw.print_complete_page, service.currentResId.value)
|
||||
|
||||
instrumentation.runOnMainSync {
|
||||
service.playByRoute(AppRoutes.buildElectronicCompletionRoute("AUDIO-TEST-ORDER"))
|
||||
}
|
||||
awaitPlaying(service)
|
||||
assertEquals(R.raw.electronic_complete_page, service.currentResId.value)
|
||||
|
||||
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.PRINT_SUCCESS) }
|
||||
awaitPlaying(service)
|
||||
assertEquals(R.raw.print_complete_page, service.currentResId.value)
|
||||
} finally {
|
||||
instrumentation.runOnMainSync { service.release() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun awaitPlaying(service: LocalAudioPlayService) {
|
||||
val deadline = System.currentTimeMillis() + 5000
|
||||
while (!service.isPlaying.value && System.currentTimeMillis() < deadline) Thread.sleep(20)
|
||||
assertTrue("Local audio should enter playback", service.isPlaying.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.yzx.kiosk.ui.face
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.MultiFormatReader
|
||||
import com.google.zxing.RGBLuminanceSource
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
import com.yzx.kiosk.theme.AppTheme
|
||||
import com.yzx.kiosk.ui.common.view.FullScreenMode
|
||||
import com.yzx.kiosk.ui.face.view.ElectronicCompletionContent
|
||||
import com.yzx.kiosk.utils.QrCodeUtils
|
||||
import java.io.File
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class ElectronicCompletionScreenTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
@Test fun downloadQrIsScannableAndReturnWorks() {
|
||||
val url = "https://example.test/album/electronic-completion"
|
||||
val qr = QrCodeUtils.generateStyledQrBitmap(url, size = 800, cornerRadius = 0f).asImageBitmap()
|
||||
var returned = false
|
||||
compose.setContent {
|
||||
FullScreenMode()
|
||||
AppTheme {
|
||||
// Match the reference/kiosk aspect ratio even on a taller test phone.
|
||||
Box(Modifier.fillMaxWidth().aspectRatio(941f / 1672f).testTag("completion")) {
|
||||
ElectronicCompletionContent(qr, false, 90, "4001234567", {}, { returned = true })
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onNodeWithText("客服电话:4001234567").assertIsDisplayed()
|
||||
val bitmap = compose.onNodeWithTag("completion").captureToImage().asAndroidBitmap()
|
||||
val pixels = IntArray(bitmap.width * bitmap.height)
|
||||
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
|
||||
assertEquals(url, MultiFormatReader().decode(BinaryBitmap(HybridBinarizer(
|
||||
RGBLuminanceSource(bitmap.width, bitmap.height, pixels)
|
||||
))).text)
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
File(context.getExternalFilesDir(null), "electronic-completion.png").outputStream().use {
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
|
||||
}
|
||||
compose.onNodeWithContentDescription("返回首页").performClick()
|
||||
compose.runOnIdle { assertTrue(returned) }
|
||||
}
|
||||
|
||||
@Test fun failedQrOffersWorkingRetry() {
|
||||
var retried = false
|
||||
compose.setContent {
|
||||
AppTheme { ElectronicCompletionContent(null, true, 70, "4001234567", { retried = true }, {}) }
|
||||
}
|
||||
compose.onNodeWithText("二维码加载失败").assertIsDisplayed()
|
||||
compose.onNodeWithText("重新加载").performClick()
|
||||
compose.runOnIdle { assertTrue(retried) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
package com.yzx.kiosk.ui.face
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import com.yzx.kiosk.network.model.response.FaceSearchResult
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.*
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.gson.Gson
|
||||
import com.yzx.kiosk.audio.LocalAudioPlayService
|
||||
import com.yzx.kiosk.navigation.AppNavigator
|
||||
import com.yzx.kiosk.navigation.handleNavigationEvent
|
||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||
import com.yzx.kiosk.network.repository.NetWorkRepository
|
||||
import com.yzx.kiosk.network.service.*
|
||||
import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore
|
||||
import com.yzx.kiosk.ui.face.resource.FaceThumbnailDecoder
|
||||
import com.yzx.kiosk.ui.face.view.*
|
||||
import com.yzx.kiosk.ui.face.viewmodel.*
|
||||
import com.yzx.kiosk.websocket.WebSocketService
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.*
|
||||
import org.junit.Assert.*
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.io.File
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/** All photo/order HTTP requests use an in-process interceptor; no orders or prints are submitted. */
|
||||
class RecentPhotosFlowTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private lateinit var resultVm: FaceRecognitionResultViewModel
|
||||
private lateinit var createResultVm: () -> FaceRecognitionResultViewModel
|
||||
private val scopedResultVms = mutableListOf<FaceRecognitionResultViewModel>()
|
||||
private lateinit var faceVm: FaceRecognitionViewModel
|
||||
private lateinit var testSocket: WebSocketService
|
||||
private lateinit var nav: AppNavigator
|
||||
private var restoreConfig: (() -> Unit)? = null
|
||||
private val requests = CopyOnWriteArrayList<Request>()
|
||||
private lateinit var testRepository: NetWorkRepository
|
||||
private var completionVm: ElectronicCompletionViewModel? = null
|
||||
@Volatile private var electronicAmount: String? = null
|
||||
@Volatile private var completionStatus = 30
|
||||
@Volatile private var payFails = false
|
||||
@Volatile private var queryFails = false
|
||||
@Volatile private var quoteFails = false
|
||||
@Volatile private var completedMode = "electronic"
|
||||
@Volatile private var qrFails = false
|
||||
@Volatile private var recentCode = 200
|
||||
@Volatile private var recentBody = """{"count":0,"results":[]}"""
|
||||
|
||||
@Before fun setup() {
|
||||
val app = context.applicationContext as com.yzx.kiosk.App
|
||||
// Access the existing singleton without launching MainActivity (which connects live services).
|
||||
val component = (app as dagger.hilt.internal.GeneratedComponentManager<*>).generatedComponent()
|
||||
val providerField = component.javaClass.getDeclaredField("webSocketServiceProvider").apply { isAccessible = true }
|
||||
val socket = (providerField.get(component) as javax.inject.Provider<*>).get() as WebSocketService
|
||||
testSocket = socket
|
||||
val store = app.appStoreDataSource
|
||||
val oldRemote = store.getBindBoxUrl()
|
||||
val oldLan = store.getBindBoxLanUrl()
|
||||
val oldSn = store.getBindBoxSn()
|
||||
val oldToken = store.getBindBoxApiToken()
|
||||
restoreConfig = {
|
||||
store.saveBindBoxUrl(oldRemote)
|
||||
store.saveBindBoxLanUrl(oldLan)
|
||||
store.saveBindBoxSn(oldSn)
|
||||
store.saveBindBoxApiToken(oldToken)
|
||||
}
|
||||
store.saveBindBoxUrl("https://example.test")
|
||||
store.saveBindBoxLanUrl("https://example.test")
|
||||
store.saveBindBoxSn("MOCK-BOX")
|
||||
store.saveBindBoxApiToken("test-token")
|
||||
val client = OkHttpClient.Builder().addInterceptor { chain ->
|
||||
val req = chain.request()
|
||||
requests.add(req)
|
||||
val recent = req.url.encodedPath.endsWith("/api/photos/recent")
|
||||
val body = when {
|
||||
recent -> recentBody
|
||||
req.url.encodedPath.endsWith("verify-result") && quoteFails ->
|
||||
"""{"code":100001,"msg":"项目已下线,请重新选择","data":{}}"""
|
||||
req.url.encodedPath.endsWith("verify-result") -> {
|
||||
val buffer = okio.Buffer()
|
||||
req.body!!.writeTo(buffer)
|
||||
val ids = Gson().fromJson(buffer.readUtf8(), com.google.gson.JsonObject::class.java).getAsJsonArray("image_id")
|
||||
val amount = "${ids.size() * 2}.00"
|
||||
val extra = electronicAmount?.let { ",\"price_electronic\":\"$it\",\"amount_electronic\":\"${it.toBigDecimal().multiply(ids.size().toBigDecimal()).toPlainString()}\"" }.orEmpty()
|
||||
"""{"code":100000,"data":{"price_image":"2.00","amount":"$amount"$extra}}"""
|
||||
}
|
||||
req.url.encodedPath.endsWith("get-pay-url") && payFails ->
|
||||
"""{"code":100001,"msg":"电子版价格不可用,请重新选择","data":{}}"""
|
||||
req.url.encodedPath.endsWith("get-pay-url") -> if (electronicAmount == null)
|
||||
"""{"code":100000,"data":{"url":"https://example.test/mock-pay","order_number":"MOCK-RECENT-1"}}"""
|
||||
else """{"code":100000,"data":{"url":${if (electronicAmount == "0.00") "null" else "\"https://example.test/mock-pay\""},"order_number":"MOCK-RECENT-1","purchase_mode":"electronic","amount":"$electronicAmount","order_status":${if (electronicAmount == "0.00") 30 else 10}}}"""
|
||||
req.url.encodedPath.endsWith("pay-success-message") && queryFails ->
|
||||
"""{"code":100001,"msg":"预选缓存已失效,请重新选择照片","data":{}}"""
|
||||
req.url.encodedPath.endsWith("pay-success-message") ->
|
||||
"""{"code":100000,"data":{"order_status":$completionStatus,"type":5,"data":{"order_number":"MOCK-RECENT-1","capture_type":2,"image_id":[9],"purchase_mode":"$completedMode"}}}"""
|
||||
req.url.encodedPath.endsWith("save-album-url") -> if (qrFails)
|
||||
"""{"code":500,"msg":"mock failure"}""" else
|
||||
"""{"code":100000,"data":{"url":"https://example.test/album/MOCK-RECENT-1"}}"""
|
||||
else -> """{"count":0,"results":[]}"""
|
||||
}
|
||||
Response.Builder().request(req).protocol(Protocol.HTTP_1_1).code(if (recent) recentCode else 200)
|
||||
.message("Mock").body(body.toResponseBody("application/json".toMediaType())).build()
|
||||
}.build()
|
||||
val retrofit = Retrofit.Builder().baseUrl("https://example.test/").client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create()).build()
|
||||
val service = retrofit.create(FaceSearchService::class.java)
|
||||
val repository = NetWorkRepository(retrofit.create(NetworkService::class.java), retrofit.create(UploadService::class.java), Gson())
|
||||
testRepository = repository
|
||||
compose.runOnUiThread {
|
||||
nav = AppNavigator()
|
||||
createResultVm = { FaceRecognitionResultViewModel(nav, app.appState, repository, service, socket, app.appStoreDataSource) }
|
||||
resultVm = createResultVm()
|
||||
faceVm = FaceRecognitionViewModel(nav, app.appState, app.appStoreDataSource, LocalAudioPlayService(context), service, FaceCaptureFileStore(context), FaceThumbnailDecoder())
|
||||
}
|
||||
}
|
||||
|
||||
@After fun cleanup() {
|
||||
compose.runOnUiThread {
|
||||
if (::resultVm.isInitialized) resultVm.viewModelScope.cancel()
|
||||
if (::faceVm.isInitialized) faceVm.viewModelScope.cancel()
|
||||
completionVm?.viewModelScope?.cancel()
|
||||
scopedResultVms.forEach { it.viewModelScope.cancel() }
|
||||
restoreConfig?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
private fun screenshot(name: String) {
|
||||
val image = compose.onRoot().captureToImage().asAndroidBitmap()
|
||||
File(context.getExternalFilesDir(null), "$name.png").outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
}
|
||||
|
||||
|
||||
@Test fun resultToolbarPushesIndependentPageAndRestoresSelectionAndScroll() {
|
||||
val bitmap = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
|
||||
val faceResults = (1..30).map { id ->
|
||||
val file = File(context.cacheDir, "toolbar-photo-$id.png")
|
||||
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
val url = file.toURI().toString()
|
||||
FaceSearchResult(id, url, url, url, url)
|
||||
}
|
||||
recentBody = Gson().toJson(mapOf("count" to 2, "results" to faceResults.takeLast(2)))
|
||||
lateinit var controller: NavHostController
|
||||
val factory = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
createResultVm().also { scopedResultVms.add(it) } as T
|
||||
}
|
||||
compose.setContent {
|
||||
controller = rememberNavController()
|
||||
LaunchedEffect(Unit) { nav.navigationEvents.collect { controller.handleNavigationEvent(it) } }
|
||||
NavHost(controller, startDestination = AppRoutes.HOME) {
|
||||
composable(AppRoutes.HOME) { Text("测试首页") }
|
||||
composable(AppRoutes.FACE_RESULT_PATTERN, arguments = listOf(
|
||||
navArgument("results") { type = NavType.StringType; defaultValue = "" },
|
||||
navArgument("source") { type = NavType.StringType; defaultValue = AppRoutes.FACE_RESULT_SOURCE },
|
||||
)) { entry ->
|
||||
val vm: FaceRecognitionResultViewModel = viewModel(viewModelStoreOwner = entry, factory = factory)
|
||||
val source = entry.arguments?.getString("source")!!
|
||||
FaceRecognitionResultScreen(
|
||||
results = if (source == AppRoutes.FACE_RESULT_SOURCE) faceResults else emptyList(),
|
||||
source = source,
|
||||
viewModel = vm,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.runOnIdle { controller.navigate(AppRoutes.FACE_RECOGNITION_RESULT) }
|
||||
compose.waitUntil(10_000) { scopedResultVms.firstOrNull()?.photoList?.value?.size == 30 }
|
||||
compose.onNodeWithText("人脸识别结果").assertIsDisplayed()
|
||||
val originalVm = scopedResultVms.first()
|
||||
compose.runOnIdle { originalVm.togglePhotoSelection(faceResults[0].thumbnailOssUrl!!) }
|
||||
compose.waitUntil(10_000) { originalVm.totalPrice.value == "2.00" }
|
||||
compose.onNodeWithText("查看最近照片").assertIsDisplayed().assertIsEnabled()
|
||||
screenshot("face-results-browse-button")
|
||||
val grid = compose.onNode(hasScrollToIndexAction())
|
||||
grid.performScrollToIndex(12)
|
||||
compose.waitForIdle()
|
||||
val scrollBefore = grid.fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange].value()
|
||||
val selectedBefore = originalVm.selectedPhotos.value
|
||||
val totalBefore = originalVm.totalPrice.value
|
||||
var originalEntryId = ""
|
||||
compose.runOnIdle { originalEntryId = controller.currentBackStackEntry!!.id }
|
||||
// Two taps in one event cycle must produce only one pushed page.
|
||||
compose.onNodeWithText("查看最近照片").performTouchInput { doubleClick() }
|
||||
compose.waitUntil(10_000) { scopedResultVms.size == 2 && scopedResultVms[1].recentPhotosState.value == RecentPhotosState.READY }
|
||||
compose.onNodeWithText("最近照片").assertIsDisplayed()
|
||||
val recentVm = scopedResultVms[1]
|
||||
assertNotSame(originalVm, recentVm)
|
||||
assertTrue(recentVm.selectedPhotos.value.isEmpty())
|
||||
assertEquals(selectedBefore, originalVm.selectedPhotos.value)
|
||||
compose.onNodeWithText("查看最近照片").assertDoesNotExist()
|
||||
compose.runOnIdle {
|
||||
assertEquals(originalEntryId, controller.previousBackStackEntry!!.id)
|
||||
recentVm.toggleSelectAll()
|
||||
}
|
||||
compose.waitUntil(10_000) { recentVm.totalPrice.value == "4.00" }
|
||||
val verifyRequestsBeforeReturn = requests.count { it.url.encodedPath.endsWith("verify-result") }
|
||||
compose.runOnIdle { controller.popBackStack() }
|
||||
compose.onNodeWithText("人脸识别结果").assertIsDisplayed()
|
||||
compose.onNodeWithText("查看最近照片").assertIsDisplayed().assertIsEnabled()
|
||||
compose.runOnIdle {
|
||||
assertEquals(originalEntryId, controller.currentBackStackEntry!!.id)
|
||||
assertEquals(selectedBefore, originalVm.selectedPhotos.value)
|
||||
assertEquals(totalBefore, originalVm.totalPrice.value)
|
||||
}
|
||||
val scrollAfter = compose.onNode(hasScrollToIndexAction()).fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange].value()
|
||||
assertEquals(scrollBefore, scrollAfter, 0.05f)
|
||||
assertEquals(verifyRequestsBeforeReturn, requests.count { it.url.encodedPath.endsWith("verify-result") })
|
||||
// Entry becomes available again after returning, and the next push gets fresh state.
|
||||
compose.onNodeWithText("查看最近照片").performClick()
|
||||
compose.waitUntil(10_000) { scopedResultVms.size == 3 && scopedResultVms[2].recentPhotosState.value == RecentPhotosState.READY }
|
||||
assertTrue(scopedResultVms[2].selectedPhotos.value.isEmpty())
|
||||
compose.onNodeWithText("查看最近照片").assertDoesNotExist()
|
||||
assertEquals(2, requests.count { it.url.encodedPath.endsWith("/api/photos/recent") })
|
||||
}
|
||||
|
||||
@Test fun failureButtonNavigatesOnceAndKeepsOriginalReturnPath() {
|
||||
lateinit var controller: NavHostController
|
||||
compose.setContent {
|
||||
controller = rememberNavController()
|
||||
LaunchedEffect(Unit) { nav.navigationEvents.collect { controller.handleNavigationEvent(it) } }
|
||||
NavHost(controller, startDestination = AppRoutes.HOME) {
|
||||
composable(AppRoutes.HOME) { Text("测试首页") }
|
||||
composable(AppRoutes.FACE_RECOGNITION) { FaceRecognitionScreen(viewModel = faceVm) }
|
||||
composable(AppRoutes.FACE_RESULT_PATTERN, arguments = listOf(
|
||||
navArgument("results") { type = NavType.StringType; defaultValue = "" },
|
||||
navArgument("source") { type = NavType.StringType; defaultValue = AppRoutes.FACE_RESULT_SOURCE },
|
||||
)) { entry ->
|
||||
FaceRecognitionResultScreen(source = entry.arguments?.getString("source")!!, viewModel = resultVm)
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.runOnIdle { controller.navigate(AppRoutes.FACE_RECOGNITION) }
|
||||
compose.onNodeWithText("找不到自己?查看最近照片").assertDoesNotExist()
|
||||
compose.runOnIdle {
|
||||
val field = FaceRecognitionViewModel::class.java.getDeclaredField("_recognitionStatus").apply { isAccessible = true }
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(field.get(faceVm) as MutableStateFlow<RecognitionStatus>).value = RecognitionStatus.FAILED
|
||||
}
|
||||
compose.onNodeWithText("找不到自己?查看最近照片").assertIsDisplayed()
|
||||
screenshot("recent-photos-failure-button")
|
||||
compose.onNodeWithText("找不到自己?查看最近照片").performClick()
|
||||
compose.runOnIdle { faceVm.browseRecentPhotos() }
|
||||
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
|
||||
compose.onNodeWithText("暂无可显示的最近照片").assertIsDisplayed()
|
||||
compose.runOnIdle {
|
||||
assertEquals(AppRoutes.RECENT_RESULT_SOURCE, controller.currentBackStackEntry?.arguments?.getString("source"))
|
||||
assertEquals(AppRoutes.HOME, controller.previousBackStackEntry?.destination?.route)
|
||||
// Verify the legacy route still resolves with source=face.
|
||||
controller.navigate("${AppRoutes.FACE_RECOGNITION_RESULT}?results=%5B%5D")
|
||||
assertEquals(AppRoutes.FACE_RESULT_SOURCE, controller.currentBackStackEntry?.arguments?.getString("source"))
|
||||
}
|
||||
assertEquals(1, requests.count { it.url.encodedPath.endsWith("/api/photos/recent") })
|
||||
}
|
||||
|
||||
@Test fun recentPhotosReuseSelectionPricingAndMockPaymentToPrintingRoute() {
|
||||
val image = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
|
||||
val file = File(context.cacheDir, "recent-photo-fixture.png")
|
||||
file.outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
val url = file.toURI().toString()
|
||||
val second = File(context.cacheDir, "recent-photo-second.png")
|
||||
file.copyTo(second, overwrite = true)
|
||||
val secondUrl = second.toURI().toString()
|
||||
recentBody = """{"count":3,"results":[{"id":9,"thumbnail_oss_url":"$url","thumbnail_lan_url":"$url"},{"id":3,"thumbnail_oss_url":"$secondUrl","thumbnail_lan_url":"$secondUrl"},{"id":2,"thumbnail_oss_url":null,"thumbnail_lan_url":null}]}"""
|
||||
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
|
||||
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.READY }
|
||||
assertEquals(listOf(9, 3), resultVm.photoList.value.map { it.id })
|
||||
compose.onNodeWithText("全选").performClick()
|
||||
compose.waitUntil(10_000) { resultVm.totalPrice.value == "4.00" }
|
||||
assertEquals(2, resultVm.selectedPhotos.value.size)
|
||||
compose.onNodeWithText("购买打印照片\n4元").assertIsEnabled()
|
||||
screenshot("recent-photos-results")
|
||||
compose.onAllNodesWithText("点击预览")[0].performClick()
|
||||
compose.onNodeWithContentDescription("关闭").assertIsDisplayed().performClick()
|
||||
compose.runOnIdle { resultVm.togglePhotoSelection(resultVm.photoList.value[0].url) }
|
||||
assertEquals(1, resultVm.selectedPhotos.value.size)
|
||||
compose.runOnIdle { resultVm.toggleSelectAll() }
|
||||
assertEquals(2, resultVm.selectedPhotos.value.size)
|
||||
compose.waitUntil(10_000) { resultVm.totalPrice.value == "4.00" }
|
||||
// Exercise the shared payment code against a fake response without opening polling UI.
|
||||
var payUrl: String? = null
|
||||
compose.runOnIdle { resultVm.getPayUrl(PurchaseMode.PRINT) { payUrl = it } }
|
||||
compose.waitUntil(10_000) { payUrl != null }
|
||||
assertEquals("https://example.test/mock-pay", payUrl)
|
||||
val field = FaceRecognitionResultViewModel::class.java.getDeclaredField("activePaymentOrderNumber").apply { isAccessible = true }
|
||||
assertEquals("MOCK-RECENT-1", field.get(resultVm))
|
||||
// Inspect printing navigation only; never compose the real printing page.
|
||||
var route: String? = null
|
||||
val job = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).let { scope ->
|
||||
scope.launchCollect(nav) { route = it }
|
||||
}
|
||||
compose.runOnIdle {
|
||||
val method = FaceRecognitionResultViewModel::class.java.getDeclaredMethod("handlePaymentSuccess", String::class.java, Integer::class.java, List::class.java, String::class.java, String::class.java).apply { isAccessible = true }
|
||||
method.invoke(resultVm, "MOCK-RECENT-1", 2, listOf(9, 3), null, "test")
|
||||
}
|
||||
compose.waitUntil(10_000) { route != null }
|
||||
assertTrue(route!!.startsWith("printing?"))
|
||||
assertTrue(route!!.contains("orderNumber=MOCK-RECENT-1"))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
private fun showElectronicSelection(amount: String?) {
|
||||
electronicAmount = amount
|
||||
val image = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
|
||||
val file = File(context.cacheDir, "electronic-fixture.png")
|
||||
file.outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
val url = file.toURI().toString()
|
||||
val photos = listOf(FaceSearchResult(9, url, url, url, url))
|
||||
compose.setContent { FaceRecognitionResultScreen(results = photos, viewModel = resultVm) }
|
||||
compose.waitUntil(10_000) { resultVm.photoList.value.isNotEmpty() }
|
||||
compose.runOnIdle { resultVm.toggleSelectAll() }
|
||||
compose.waitUntil(10_000) { !resultVm.quoteLoading.value && resultVm.totalPrice.value == "2.00" }
|
||||
}
|
||||
|
||||
@Test fun paidElectronicUsesOrderModeAndRejectsMismatchedCompletion() {
|
||||
showElectronicSelection("1.00")
|
||||
val routes = CopyOnWriteArrayList<String>()
|
||||
val collector = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).launchCollect(nav) { routes.add(it) }
|
||||
try {
|
||||
completedMode = "print"
|
||||
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
|
||||
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
|
||||
screenshot("purchase-two-modes")
|
||||
compose.onNodeWithText("购买电子照片\n1元").performClick()
|
||||
compose.waitUntil(10_000) { requests.any { it.url.encodedPath.endsWith("pay-success-message") } }
|
||||
compose.onNodeWithText("微信支付").assertIsDisplayed()
|
||||
compose.onNodeWithText("订单金额 1元").assertDoesNotExist()
|
||||
assertTrue(routes.isEmpty())
|
||||
completedMode = "electronic"
|
||||
compose.waitUntil(10_000) { routes.size == 1 }
|
||||
assertTrue(routes.single().startsWith("electronic_completion?"))
|
||||
compose.runOnIdle {
|
||||
val method = FaceRecognitionResultViewModel::class.java.getDeclaredMethod("handlePaymentSuccess", String::class.java, Integer::class.java, List::class.java, String::class.java, String::class.java).apply { isAccessible = true }
|
||||
method.invoke(resultVm, "MOCK-RECENT-1", 2, listOf(9), "electronic", "duplicate WebSocket")
|
||||
}
|
||||
compose.waitForIdle()
|
||||
assertEquals(1, routes.size)
|
||||
val order = requests.first { it.url.encodedPath.endsWith("get-pay-url") }
|
||||
val buffer = okio.Buffer().also { order.body!!.writeTo(it) }
|
||||
val body = Gson().fromJson(buffer.readUtf8(), com.google.gson.JsonObject::class.java)
|
||||
assertEquals("electronic", body.get("purchase_mode").asString)
|
||||
assertEquals(0, body.getAsJsonArray("video_id").size())
|
||||
} finally { collector.cancel() }
|
||||
}
|
||||
|
||||
@Test fun zeroAndMissingElectronicPricesCannotCreateOrders() {
|
||||
showElectronicSelection("0.00")
|
||||
compose.onNodeWithText("电子版暂不可购买").assertIsNotEnabled()
|
||||
compose.onNodeWithText("免费领取电子版").assertDoesNotExist()
|
||||
compose.runOnIdle { resultVm.getPayUrl(PurchaseMode.ELECTRONIC) { error("zero price must not create a link") } }
|
||||
assertFalse(requests.any { it.url.encodedPath.endsWith("get-pay-url") })
|
||||
electronicAmount = null
|
||||
compose.runOnIdle { resultVm.retryQuote() }
|
||||
compose.waitUntil(10_000) { !resultVm.quoteLoading.value }
|
||||
compose.onNodeWithText("电子版暂不可购买").assertIsNotEnabled()
|
||||
compose.onNodeWithText("购买打印照片\n2元").assertIsEnabled()
|
||||
}
|
||||
|
||||
private fun pushCompletion(number: String = "MOCK-RECENT-1", mode: String = "electronic") {
|
||||
val json = """{"code":5,"data":{"sn":"MOCK","type":5,"data":{"order_number":"$number","capture_type":2,"image_id":[9],"purchase_mode":"$mode"}}}"""
|
||||
val method = WebSocketService::class.java.getDeclaredMethod("handleMessage", String::class.java).apply { isAccessible = true }
|
||||
method.invoke(testSocket, json)
|
||||
}
|
||||
|
||||
@Test fun pendingTypeFiveCannotCompleteButNestedWebSocketCan() {
|
||||
completionStatus = 10
|
||||
showElectronicSelection("1.00")
|
||||
val routes = CopyOnWriteArrayList<String>()
|
||||
val collector = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).launchCollect(nav) { routes.add(it) }
|
||||
try {
|
||||
compose.onNodeWithText("购买电子照片\n1元").performClick()
|
||||
compose.waitUntil(10_000) { requests.any { it.url.encodedPath.endsWith("pay-success-message") } }
|
||||
compose.waitForIdle()
|
||||
assertTrue(routes.isEmpty())
|
||||
compose.onNodeWithText("微信支付").assertIsDisplayed()
|
||||
compose.runOnIdle { pushCompletion(number = "OTHER") }
|
||||
compose.waitForIdle()
|
||||
assertTrue(routes.isEmpty())
|
||||
compose.runOnIdle { pushCompletion() }
|
||||
compose.waitUntil(10_000) { routes.size == 1 }
|
||||
compose.runOnIdle { pushCompletion() }
|
||||
compose.waitForIdle()
|
||||
assertEquals(1, routes.size)
|
||||
assertTrue(routes.single().startsWith("electronic_completion?"))
|
||||
} finally { collector.cancel() }
|
||||
}
|
||||
|
||||
@Test fun expiredPreselectionClosesPaymentAndRefreshesQuote() {
|
||||
showElectronicSelection("1.00")
|
||||
queryFails = true
|
||||
val quotesBefore = requests.count { it.url.encodedPath.endsWith("verify-result") }
|
||||
compose.onNodeWithText("购买电子照片\n1元").performClick()
|
||||
compose.waitUntil(10_000) { requests.count { it.url.encodedPath.endsWith("verify-result") } > quotesBefore && !resultVm.quoteLoading.value }
|
||||
assertNull(resultVm.payQrCodeUrl.value)
|
||||
compose.onNodeWithText("微信支付").assertDoesNotExist()
|
||||
compose.onNodeWithText("购买电子照片\n1元").assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test fun rejectedLinkRestoresButtonsAndQuoteBusinessErrorsRemainVisible() {
|
||||
showElectronicSelection("1.00")
|
||||
payFails = true
|
||||
val quotesBefore = requests.count { it.url.encodedPath.endsWith("verify-result") }
|
||||
compose.onNodeWithText("购买电子照片\n1元").performClick()
|
||||
compose.waitUntil(10_000) { requests.count { it.url.encodedPath.endsWith("verify-result") } > quotesBefore && !resultVm.quoteLoading.value && !resultVm.creatingOrder.value }
|
||||
assertNull(resultVm.payQrCodeUrl.value)
|
||||
compose.onNodeWithText("购买电子照片\n1元").assertIsEnabled()
|
||||
quoteFails = true
|
||||
compose.runOnIdle { resultVm.retryQuote() }
|
||||
compose.waitUntil(10_000) { resultVm.quoteError.value != null }
|
||||
compose.onNodeWithText("项目已下线,请重新选择").assertIsDisplayed()
|
||||
compose.onNodeWithText("购买打印照片\n--元").assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test fun selectionChangesDiscardTheOldQrAndOrder() {
|
||||
completionStatus = 10
|
||||
showElectronicSelection("1.00")
|
||||
var url: String? = null
|
||||
compose.runOnIdle { resultVm.getPayUrl(PurchaseMode.ELECTRONIC) { url = it } }
|
||||
compose.waitUntil(10_000) { url != null }
|
||||
assertNotNull(resultVm.payQrCodeUrl.value)
|
||||
compose.runOnIdle { resultVm.toggleSelectAll() }
|
||||
assertNull(resultVm.payQrCodeUrl.value)
|
||||
val routes = CopyOnWriteArrayList<String>()
|
||||
val collector = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).launchCollect(nav) { routes.add(it) }
|
||||
try {
|
||||
compose.runOnIdle { pushCompletion() }
|
||||
compose.waitForIdle()
|
||||
assertTrue(routes.isEmpty())
|
||||
} finally { collector.cancel() }
|
||||
}
|
||||
|
||||
@Test fun electronicCompletionRetriesQrWithoutPrintingOrPaperChanges() {
|
||||
val app = context.applicationContext as com.yzx.kiosk.App
|
||||
val papersBefore = app.appStoreDataSource.getRemainingPaperNum()
|
||||
compose.runOnIdle {
|
||||
completionVm = ElectronicCompletionViewModel(nav, app.appState, app.appStoreDataSource, testRepository)
|
||||
}
|
||||
qrFails = true
|
||||
compose.setContent {
|
||||
ElectronicCompletionScreen("MOCK-RECENT-1", viewModel = completionVm!!)
|
||||
}
|
||||
compose.waitUntil(10_000) { completionVm!!.qrCodeFailed.value }
|
||||
compose.onNodeWithText("领取成功").assertDoesNotExist()
|
||||
compose.onNodeWithText("支付成功").assertDoesNotExist()
|
||||
compose.onNodeWithText("返回首页").assertDoesNotExist()
|
||||
compose.onNodeWithText("二维码加载失败").assertIsDisplayed()
|
||||
qrFails = false
|
||||
compose.onNodeWithText("重新加载").performClick()
|
||||
// URL receipt precedes the background QR bitmap generation.
|
||||
compose.waitUntil(10_000) {
|
||||
compose.onAllNodesWithContentDescription("电子版照片下载二维码").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
compose.onNodeWithContentDescription("电子版照片下载二维码").assertIsDisplayed()
|
||||
compose.onNodeWithText("请在屏幕下方拿取照片").assertDoesNotExist()
|
||||
screenshot("electronic-completion")
|
||||
compose.runOnIdle {
|
||||
completionVm!!.initialize("MOCK-RECENT-1")
|
||||
}
|
||||
assertTrue(requests.all { it.url.encodedPath.endsWith("save-album-url") })
|
||||
assertEquals(2, requests.size)
|
||||
assertEquals(papersBefore, app.appStoreDataSource.getRemainingPaperNum())
|
||||
}
|
||||
|
||||
@Test fun emptyRecentPhotosStillShowConfiguredUnitPrices() {
|
||||
electronicAmount = "1.00"
|
||||
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
|
||||
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
|
||||
compose.waitForIdle()
|
||||
assertTrue("Entering recent photos must request prices even when the photo list is empty",
|
||||
requests.any { it.url.encodedPath.endsWith("verify-result") })
|
||||
compose.waitUntil(10_000) { !resultVm.quoteLoading.value }
|
||||
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
|
||||
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
|
||||
compose.onNodeWithText("购买打印照片\n0元").assertIsNotEnabled()
|
||||
compose.onNodeWithText("购买电子照片\n0元").assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test fun recentPhotosSelectionUpdatesBothQuotesWithoutReloadReset() {
|
||||
electronicAmount = "1.00"
|
||||
val bitmap = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
|
||||
val photos = (1..2).map { id ->
|
||||
val file = File(context.cacheDir, "recent-price-$id.png")
|
||||
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
val url = file.toURI().toString()
|
||||
FaceSearchResult(id, url, url, url, url)
|
||||
}
|
||||
recentBody = Gson().toJson(mapOf("count" to 2, "results" to photos))
|
||||
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
|
||||
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.READY && !resultVm.quoteLoading.value }
|
||||
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
|
||||
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
|
||||
compose.onNodeWithText("购买电子照片\n0元").assertIsNotEnabled()
|
||||
assertEquals(1, requests.count { it.url.encodedPath.endsWith("verify-result") })
|
||||
compose.runOnIdle { resultVm.toggleSelectAll() }
|
||||
compose.waitUntil(10_000) { !resultVm.quoteLoading.value && resultVm.electronicTotal.value == "2.00" }
|
||||
compose.onNodeWithText("购买打印照片\n4元").assertIsEnabled()
|
||||
compose.onNodeWithText("购买电子照片\n2元").assertIsEnabled()
|
||||
compose.runOnIdle { resultVm.loadRecentPhotos() }
|
||||
compose.waitForIdle()
|
||||
assertEquals("4.00", resultVm.totalPrice.value)
|
||||
assertEquals("2.00", resultVm.electronicTotal.value)
|
||||
assertEquals(2, requests.count { it.url.encodedPath.endsWith("verify-result") })
|
||||
compose.runOnIdle { resultVm.toggleSelectAll() }
|
||||
compose.waitUntil(10_000) { !resultVm.quoteLoading.value && resultVm.totalPrice.value == "0.00" }
|
||||
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
|
||||
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
|
||||
compose.onNodeWithText("购买电子照片\n0元").assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test fun errorCanRetryIntoEmptyState() {
|
||||
electronicAmount = "1.00"
|
||||
recentCode = 403
|
||||
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
|
||||
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.ERROR }
|
||||
compose.onNodeWithText("照片加载失败,请重试").assertIsDisplayed()
|
||||
compose.waitUntil(10_000) { !resultVm.quoteLoading.value }
|
||||
compose.onNodeWithText("打印版 2元/张").assertIsDisplayed()
|
||||
compose.onNodeWithText("电子版 1元/张").assertIsDisplayed()
|
||||
compose.onNodeWithText("购买打印照片\n0元").assertIsNotEnabled()
|
||||
recentCode = 200
|
||||
compose.onNodeWithText("重试").performClick()
|
||||
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
|
||||
compose.onNodeWithText("暂无可显示的最近照片").assertIsDisplayed()
|
||||
assertEquals(2, requests.count { it.url.encodedPath.endsWith("/api/photos/recent") })
|
||||
}
|
||||
}
|
||||
|
||||
private fun kotlinx.coroutines.CoroutineScope.launchCollect(nav: AppNavigator, onRoute: (String) -> Unit) =
|
||||
launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
nav.navigationEvents.collect { if (it is com.yzx.kiosk.navigation.NavigationEvent.NavigateTo) onRoute(it.route) }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.yzx.kiosk.ui.face.resource
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FaceThumbnailDecoderInstrumentedTest {
|
||||
private lateinit var testDirectory: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
testDirectory = File(context.cacheDir, "face_thumbnail_test").apply {
|
||||
deleteRecursively()
|
||||
mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
testDirectory.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun largeImagesAreBoundedAndAllExifOrientationsAreHandled() {
|
||||
val orientations = listOf(
|
||||
ExifInterface.ORIENTATION_NORMAL to false,
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL to false,
|
||||
ExifInterface.ORIENTATION_ROTATE_180 to false,
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL to false,
|
||||
ExifInterface.ORIENTATION_TRANSPOSE to true,
|
||||
ExifInterface.ORIENTATION_ROTATE_90 to true,
|
||||
ExifInterface.ORIENTATION_TRANSVERSE to true,
|
||||
ExifInterface.ORIENTATION_ROTATE_270 to true,
|
||||
)
|
||||
val decoder = FaceThumbnailDecoder()
|
||||
|
||||
orientations.forEachIndexed { index, (orientation, swapsDimensions) ->
|
||||
val imageFile = File(testDirectory, "orientation_$index.jpg")
|
||||
createLargeJpeg(imageFile)
|
||||
ExifInterface(imageFile).apply {
|
||||
setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
|
||||
saveAttributes()
|
||||
}
|
||||
|
||||
val decoded = decoder.decode(imageFile.absolutePath)
|
||||
|
||||
assertNotNull(decoded)
|
||||
decoded!!
|
||||
assertFalse(decoded.isRecycled)
|
||||
assertTrue(maxOf(decoded.width, decoded.height) <= FaceThumbnailDecoder.MAX_PREVIEW_EDGE_PX)
|
||||
if (swapsDimensions) {
|
||||
assertEquals(360, decoded.width)
|
||||
assertEquals(720, decoded.height)
|
||||
} else {
|
||||
assertEquals(720, decoded.width)
|
||||
assertEquals(360, decoded.height)
|
||||
}
|
||||
decoded.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun captureFilesAreCreatedInOwnedDirectoryAndDeletedIdempotently() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val fileStore = FaceCaptureFileStore(context)
|
||||
val captureFile = fileStore.createCaptureFile()
|
||||
|
||||
assertTrue(captureFile.exists())
|
||||
assertEquals(FaceCaptureFileStore.CAPTURE_DIRECTORY, captureFile.parentFile?.name)
|
||||
|
||||
fileStore.delete(captureFile)
|
||||
fileStore.delete(captureFile)
|
||||
assertFalse(captureFile.exists())
|
||||
}
|
||||
|
||||
private fun createLargeJpeg(file: File) {
|
||||
val bitmap = Bitmap.createBitmap(1600, 800, Bitmap.Config.ARGB_8888)
|
||||
Canvas(bitmap).drawColor(Color.MAGENTA)
|
||||
file.outputStream().use { output ->
|
||||
assertTrue(bitmap.compress(Bitmap.CompressFormat.JPEG, 95, output))
|
||||
}
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import com.luck.picture.lib.basic.PictureSelectorSupporterActivity
|
||||
import com.luck.picture.lib.basic.PictureSelectorTransparentActivity
|
||||
import com.yzx.kiosk.datastore.AppState
|
||||
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.MMKVUtils
|
||||
import com.yzx.kiosk.utils.NavigationBarUtil
|
||||
@@ -42,6 +43,9 @@ class App : Application() {
|
||||
@Inject
|
||||
lateinit var appStoreDataSource: AppStoreDataSource
|
||||
|
||||
@Inject
|
||||
lateinit var faceCaptureFileStore: FaceCaptureFileStore
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
@@ -59,6 +63,16 @@ class App : Application() {
|
||||
LogUtils.init(BuildConfig.DEBUG)
|
||||
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()
|
||||
|
||||
initCoil()
|
||||
@@ -120,4 +134,4 @@ class App : Application() {
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,11 +42,12 @@ class LocalAudioPlayService @Inject constructor(
|
||||
AppRoutes.HOME to R.raw.home_audio,
|
||||
AppRoutes.FACE_RECOGNITION to R.raw.face_recognition_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.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.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_failed" to R.raw.face_recognition_failed_page,
|
||||
@@ -110,12 +111,12 @@ class LocalAudioPlayService @Inject constructor(
|
||||
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) {
|
||||
LogUtils.d(TAG, "路由 $baseRoute 没有对应的音频文件")
|
||||
LogUtils.d(TAG, "路由音频 $audioKey 没有对应的音频文件")
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
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.face.view.FaceRecognitionScreen
|
||||
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.PrintingScreen
|
||||
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 results = if (resultsParam.isNotEmpty()) {
|
||||
try {
|
||||
@@ -154,48 +161,9 @@ fun AppNavHost(
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
FaceRecognitionResultScreen(results = results)
|
||||
}
|
||||
|
||||
// 支付成功页面
|
||||
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
|
||||
FaceRecognitionResultScreen(
|
||||
results = results,
|
||||
source = backStackEntry.arguments?.getString("source") ?: AppRoutes.FACE_RESULT_SOURCE,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -7,11 +7,36 @@ object AppRoutes {
|
||||
const val DEVICE_CONFIG = "device_config"
|
||||
const val UPLOAD_PHOTO = "upload_photo"
|
||||
const val PHOTO_SELECT = "photo_select"
|
||||
const val PAY_SUCCESS = "pay_success"
|
||||
const val PRINTING = "printing"
|
||||
const val ELECTRONIC_COMPLETION = "electronic_completion"
|
||||
const val ELECTRONIC_COMPLETION_PATTERN = "$ELECTRONIC_COMPLETION?orderNumber={orderNumber}"
|
||||
|
||||
fun buildElectronicCompletionRoute(orderNumber: String): String =
|
||||
"$ELECTRONIC_COMPLETION?orderNumber=${java.net.URLEncoder.encode(orderNumber, "UTF-8")}"
|
||||
|
||||
const val PRINT_SUCCESS = "print_success"
|
||||
const val FACE_RECOGNITION = "face_recognition"
|
||||
const val FACE_RECOGNITION_RESULT = "face_recognition_result"
|
||||
|
||||
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"
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,10 @@ object NetworkModule {
|
||||
private const val CLIENT_DEFAULT = "defaultOkHttpClient"
|
||||
const val CLIENT_UPLOAD = "uploadOkHttpClient"
|
||||
const val CLIENT_DOWNLOAD = "downloadOkHttpClient"
|
||||
const val CLIENT_FACE = "faceOkHttpClient"
|
||||
const val RETROFIT_DEFAULT = "defaultRetrofit"
|
||||
const val RETROFIT_UPLOAD = "uploadRetrofit"
|
||||
const val RETROFIT_FACE = "faceRetrofit"
|
||||
const val TRANSFRTVIEWMODEL = "transferViewModel"
|
||||
|
||||
@Provides
|
||||
@@ -104,6 +106,17 @@ object NetworkModule {
|
||||
}
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@Named(CLIENT_FACE)
|
||||
fun provideFaceOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
.apply {
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
private fun buildRetrofit(
|
||||
baseUrl: String,
|
||||
okHttpClient: OkHttpClient,
|
||||
@@ -131,4 +144,13 @@ object NetworkModule {
|
||||
gson: Gson,
|
||||
@Named(BASE_URL) baseUrl: String
|
||||
): 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.network.service.NetworkService
|
||||
import com.yzx.kiosk.network.service.UploadService
|
||||
import com.yzx.kiosk.network.service.FaceSearchService
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -11,6 +12,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
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_FACE
|
||||
import retrofit2.Retrofit
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
@@ -30,6 +32,12 @@ object ServiceModule {
|
||||
fun provideUploadService(
|
||||
@Named(RETROFIT_UPLOAD) retrofit: Retrofit
|
||||
): UploadService = retrofit.create(UploadService::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideFaceSearchService(
|
||||
@Named(RETROFIT_FACE) retrofit: Retrofit,
|
||||
): FaceSearchService = retrofit.create(FaceSearchService::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@@ -42,4 +50,4 @@ object ServiceModule {
|
||||
fun provideLocalAudioPlayService(
|
||||
@ApplicationContext context: Context
|
||||
): com.yzx.kiosk.audio.LocalAudioPlayService = com.yzx.kiosk.audio.LocalAudioPlayService(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ data class GetPayUrlRequest(
|
||||
@SerializedName("type")
|
||||
val type: Int,
|
||||
@SerializedName("image_id")
|
||||
val imageId: List<Int>
|
||||
val imageId: List<Int>,
|
||||
@SerializedName("purchase_mode")
|
||||
val purchaseMode: String? = null,
|
||||
@SerializedName("video_id")
|
||||
val videoId: List<Int>? = null
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ data class VerifyResultRequest(
|
||||
@SerializedName("type")
|
||||
val type: Int,
|
||||
@SerializedName("image_id")
|
||||
val imageId: List<Int>
|
||||
val imageId: List<Int>,
|
||||
@SerializedName("video_id")
|
||||
val videoId: List<Int>? = null
|
||||
)
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ data class GetPayUrlResponse(
|
||||
val url: String?,
|
||||
|
||||
@SerializedName("order_number")
|
||||
val orderNumber: String?
|
||||
val orderNumber: String?,
|
||||
@SerializedName("purchase_mode")
|
||||
val purchaseMode: String? = null,
|
||||
@SerializedName("amount")
|
||||
val amount: String? = null,
|
||||
@SerializedName("order_status")
|
||||
val orderStatus: Int? = null
|
||||
)
|
||||
|
||||
|
||||
@@ -27,5 +27,8 @@ data class PaySuccessMessageData(
|
||||
val captureType: Int?,
|
||||
|
||||
@SerializedName("image_id")
|
||||
val imageIds: List<Int>?
|
||||
val imageIds: List<Int>?,
|
||||
|
||||
@SerializedName("purchase_mode")
|
||||
val purchaseMode: String? = null
|
||||
)
|
||||
|
||||
@@ -6,6 +6,10 @@ data class VerifyResultResponse(
|
||||
@SerializedName("price_image")
|
||||
val priceImage: String?,
|
||||
@SerializedName("amount")
|
||||
val amount: String?
|
||||
val amount: String?,
|
||||
@SerializedName("price_electronic")
|
||||
val priceElectronic: String? = null,
|
||||
@SerializedName("amount_electronic")
|
||||
val amountElectronic: String? = null
|
||||
)
|
||||
|
||||
|
||||
@@ -4,17 +4,24 @@ import com.yzx.kiosk.network.model.response.FaceSearchResponse
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Url
|
||||
|
||||
interface FaceSearchService {
|
||||
@GET
|
||||
suspend fun recentPhotos(
|
||||
@Url url: String,
|
||||
@Header("Authorization") authorization: String,
|
||||
): Response<FaceSearchResponse>
|
||||
|
||||
@Multipart
|
||||
@POST("/{sn}/api/search")
|
||||
@POST
|
||||
suspend fun searchFace(
|
||||
@Path("sn") sn: String,
|
||||
@Url url: String,
|
||||
@Header("Authorization") authorization: String,
|
||||
@Part image: MultipartBody.Part,
|
||||
@Part("threshold") threshold: RequestBody?,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.yzx.kiosk.network.service
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
internal fun buildFaceSearchUrl(baseUrl: String, deviceSn: String): String {
|
||||
require(deviceSn.isNotBlank()) { "Face box SN is empty" }
|
||||
val parsedBaseUrl = baseUrl.trim().toHttpUrlOrNull()
|
||||
?: throw IllegalArgumentException("Invalid face search base URL")
|
||||
|
||||
return parsedBaseUrl.newBuilder()
|
||||
// Preserve the /{sn}/api/v3/search endpoint semantics even if configuration
|
||||
// accidentally contains a path or query string.
|
||||
.encodedPath("/")
|
||||
.query(null)
|
||||
.fragment(null)
|
||||
.addPathSegment(deviceSn)
|
||||
.addPathSegment("api")
|
||||
.addPathSegment("v3")
|
||||
.addPathSegment("search")
|
||||
.build()
|
||||
.toString()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.yzx.kiosk.network.service
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
internal fun buildRecentPhotosUrl(baseUrl: String, deviceSn: String): String {
|
||||
require(deviceSn.isNotBlank()) { "Face box SN is empty" }
|
||||
val parsedBaseUrl = baseUrl.trim().toHttpUrlOrNull()
|
||||
?: throw IllegalArgumentException("Invalid recent photos base URL")
|
||||
|
||||
// The recent-photos endpoint is unversioned and independent of the face-search API version.
|
||||
return parsedBaseUrl.newBuilder()
|
||||
.encodedPath("/")
|
||||
.query(null)
|
||||
.fragment(null)
|
||||
.addPathSegment(deviceSn)
|
||||
.addPathSegment("api")
|
||||
.addPathSegment("photos")
|
||||
.addPathSegment("recent")
|
||||
.build()
|
||||
.toString()
|
||||
}
|
||||
@@ -0,0 +1,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.Color
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import com.yzx.kiosk.App
|
||||
import com.yzx.kiosk.datastore.AppStoreDataSource
|
||||
import com.yzx.kiosk.utils.LogUtils
|
||||
@@ -23,8 +22,11 @@ import jp.co.dnpLib.print.PrintManager
|
||||
import jp.co.dnpLib.print.PrintQueue
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Singleton
|
||||
import kotlin.math.min
|
||||
|
||||
@@ -40,10 +42,16 @@ class PrinterService(
|
||||
) {
|
||||
companion object {
|
||||
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 val printMutex = Mutex()
|
||||
|
||||
/**
|
||||
* 初始化打印机(耗时操作,需在IO线程调用)
|
||||
* @return 是否初始化成功
|
||||
@@ -77,14 +85,14 @@ class PrinterService(
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取当前打印机ID
|
||||
*/
|
||||
fun getPrintId(): Int {
|
||||
return appStoreDataSource.getPrintId()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 准备打印图片:保持原比例,全内容居中,空白区域背景为白色,宽图可调整方向
|
||||
* @param originalBitmap 原始图片(不会被回收)
|
||||
@@ -96,14 +104,14 @@ class PrinterService(
|
||||
var workingBitmap: Bitmap? = null // 用于处理的临时bitmap
|
||||
var rotatedBitmap: Bitmap? = null // 旋转后的bitmap(如果需要)
|
||||
var scaledBitmap: Bitmap? = null // 缩放后的bitmap
|
||||
|
||||
|
||||
try {
|
||||
val originalWidth = originalBitmap.width
|
||||
val originalHeight = originalBitmap.height
|
||||
val isWideImage = originalWidth > originalHeight // 是否为宽图
|
||||
|
||||
|
||||
LogUtils.d(TAG, "原始图片: ${originalWidth}x${originalHeight}, 是否宽图: $isWideImage")
|
||||
|
||||
|
||||
// 判断是否需要旋转
|
||||
// RX1打印机需要横向图片(1840x1240),QW410需要纵向图片(1266x1836)
|
||||
val needRotate = if (printId != PrintManager.EPrinter.QW410_DEF.id) {
|
||||
@@ -113,7 +121,7 @@ class PrinterService(
|
||||
// QW410打印机:如果图片是横向的,需要旋转90度
|
||||
isWideImage
|
||||
}
|
||||
|
||||
|
||||
// 如果需要旋转,先旋转图片
|
||||
if (needRotate) {
|
||||
LogUtils.d(TAG, "旋转图片90度以适应打印方向")
|
||||
@@ -128,21 +136,21 @@ class PrinterService(
|
||||
} else {
|
||||
workingBitmap = originalBitmap
|
||||
}
|
||||
|
||||
|
||||
// 计算缩放比例,保持原比例
|
||||
val scale = min(
|
||||
printSize.width.toFloat() / workingBitmap.width,
|
||||
printSize.height.toFloat() / workingBitmap.height
|
||||
)
|
||||
|
||||
|
||||
val scaledWidth = (workingBitmap.width * scale).toInt()
|
||||
val scaledHeight = (workingBitmap.height * scale).toInt()
|
||||
|
||||
|
||||
LogUtils.d(TAG, "缩放比例: $scale, 缩放后尺寸: ${scaledWidth}x${scaledHeight}")
|
||||
|
||||
|
||||
// 创建缩放后的图片
|
||||
scaledBitmap = Bitmap.createScaledBitmap(workingBitmap, scaledWidth, scaledHeight, true)
|
||||
|
||||
|
||||
// 创建白色背景的画布
|
||||
val outputBitmap = Bitmap.createBitmap(
|
||||
printSize.width,
|
||||
@@ -150,23 +158,23 @@ class PrinterService(
|
||||
Bitmap.Config.ARGB_8888
|
||||
)
|
||||
val canvas = Canvas(outputBitmap)
|
||||
|
||||
|
||||
// 填充白色背景
|
||||
canvas.drawColor(Color.WHITE)
|
||||
|
||||
|
||||
// 计算居中位置
|
||||
val left = (printSize.width - scaledWidth) / 2f
|
||||
val top = (printSize.height - scaledHeight) / 2f
|
||||
|
||||
|
||||
LogUtils.d(TAG, "居中位置: left=$left, top=$top")
|
||||
|
||||
|
||||
// 在画布上绘制缩放后的图片(居中)
|
||||
canvas.drawBitmap(scaledBitmap, left, top, null)
|
||||
|
||||
|
||||
// 回收临时bitmap
|
||||
scaledBitmap?.let { if (!it.isRecycled) it.recycle() }
|
||||
rotatedBitmap?.let { if (!it.isRecycled) it.recycle() }
|
||||
|
||||
|
||||
return outputBitmap
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e(TAG, "准备打印图片失败: ${e.message}")
|
||||
@@ -176,34 +184,51 @@ class PrinterService(
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 打印图片(耗时操作)
|
||||
* @param originalBitmap 要打印的原始图片
|
||||
* @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) {
|
||||
LogUtils.e(TAG, "获取图片失败")
|
||||
callback.onPrintResult(false,"获取图片失败")
|
||||
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, "获取图片成功")
|
||||
|
||||
|
||||
// 在开始打印前,检查打印机是否在线
|
||||
if (printStatusManager.isOffline()) {
|
||||
LogUtils.e(TAG, "打印机离线,无法打印")
|
||||
callback.onPrintResult(false, "打印机离线,无法打印")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
|
||||
// 设置打印状态为正在打印
|
||||
printStatusManager.setPrinting()
|
||||
|
||||
|
||||
try {
|
||||
val printQueue = PrintQueue(context)
|
||||
val printId = appStoreDataSource.getPrintId()
|
||||
|
||||
|
||||
// 再次检查打印机ID是否有效(如果打印机未初始化,printId 可能为 0)
|
||||
if (printId == 0) {
|
||||
LogUtils.e(TAG, "打印机未初始化,printId=0")
|
||||
@@ -211,10 +236,10 @@ class PrinterService(
|
||||
callback.onPrintResult(false, "打印机未初始化")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
|
||||
LogUtils.d(TAG, "开始打印,打印机ID: $printId")
|
||||
LogUtils.d(TAG, "原始图片尺寸: ${originalBitmap.width} x ${originalBitmap.height}")
|
||||
|
||||
|
||||
// 获取打印尺寸
|
||||
val printSize = if (printId != PrintManager.EPrinter.QW410_DEF.id) {
|
||||
PRINTSIZE.RX1
|
||||
@@ -222,19 +247,24 @@ class PrinterService(
|
||||
PRINTSIZE.QW410
|
||||
}
|
||||
LogUtils.d(TAG, "打印尺寸: ${printSize.width} x ${printSize.height}")
|
||||
|
||||
|
||||
// 准备打印图片(保持原比例,居中,白色背景)
|
||||
val outputBitmap: Bitmap
|
||||
var outputBitmap: Bitmap? = null
|
||||
val outFile = File(
|
||||
context.getExternalFilesDir(null),
|
||||
"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 {
|
||||
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)
|
||||
} else {
|
||||
AndroidBmpUtil.save(
|
||||
@@ -244,12 +274,45 @@ class PrinterService(
|
||||
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) {
|
||||
e.printStackTrace()
|
||||
callback.onPrintResult(false,"图片处理失败: ${e.message}")
|
||||
LogUtils.e(TAG, "图片处理或 BMP 完整性校验失败: ${e.message}")
|
||||
outputBitmap?.let { bitmap ->
|
||||
if (!bitmap.isRecycled) bitmap.recycle()
|
||||
}
|
||||
printStatusManager.setIdle()
|
||||
callback.onPrintResult(false, "图片处理失败: ${e.message}")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
|
||||
val validatedOutputBitmap = checkNotNull(outputBitmap)
|
||||
|
||||
// 创建打印任务
|
||||
val job = PrintJob(
|
||||
outFile.absolutePath,
|
||||
@@ -263,14 +326,14 @@ class PrinterService(
|
||||
1
|
||||
)
|
||||
printQueue.addJob(job)
|
||||
|
||||
|
||||
if (printId != PrintManager.EPrinter.QW410_DEF.id) {
|
||||
// 打印完成,设置为空闲
|
||||
printStatusManager.setIdle()
|
||||
// 回收 outputBitmap
|
||||
try {
|
||||
if (!outputBitmap.isRecycled) {
|
||||
outputBitmap.recycle()
|
||||
if (!validatedOutputBitmap.isRecycled) {
|
||||
validatedOutputBitmap.recycle()
|
||||
LogUtils.d(TAG, "outputBitmap 已回收")
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
@@ -285,8 +348,8 @@ class PrinterService(
|
||||
printStatusManager.setIdle()
|
||||
// 回收 outputBitmap
|
||||
try {
|
||||
if (!outputBitmap.isRecycled) {
|
||||
outputBitmap.recycle()
|
||||
if (!validatedOutputBitmap.isRecycled) {
|
||||
validatedOutputBitmap.recycle()
|
||||
LogUtils.d(TAG, "outputBitmap 已回收")
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
@@ -294,7 +357,7 @@ class PrinterService(
|
||||
}
|
||||
callback.onPrintResult(true)
|
||||
}
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e(TAG, "打印失败: ${e.message}")
|
||||
// 打印失败,设置为空闲
|
||||
|
||||
@@ -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
|
||||
|
||||
import com.yzx.kiosk.utils.formatPriceDisplay
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import com.yzx.kiosk.ui.face.viewmodel.PurchaseMode
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -24,6 +28,10 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -35,6 +43,9 @@ import coil.compose.AsyncImage
|
||||
import coil.compose.SubcomposeAsyncImage
|
||||
import coil.compose.SubcomposeAsyncImageContent
|
||||
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.component.appbar.AppTitleBar
|
||||
import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding
|
||||
@@ -52,27 +63,48 @@ import kotlinx.coroutines.flow.collect
|
||||
@Composable
|
||||
fun FaceRecognitionResultScreen(
|
||||
results: List<FaceSearchResult> = emptyList(),
|
||||
source: String = AppRoutes.FACE_RESULT_SOURCE,
|
||||
viewModel: FaceRecognitionResultViewModel = hiltViewModel()
|
||||
) {
|
||||
// 如果传入了results列表,初始化图片列表
|
||||
LaunchedEffect(results) {
|
||||
if (results.isNotEmpty()) {
|
||||
LaunchedEffect(source, results) {
|
||||
if (source == AppRoutes.RECENT_RESULT_SOURCE) {
|
||||
viewModel.loadRecentPhotos()
|
||||
} else if (results.isNotEmpty()) {
|
||||
viewModel.initPhotoList(results)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val recentPhotosState by viewModel.recentPhotosState.collectAsState()
|
||||
val photoList by viewModel.photoList.collectAsState()
|
||||
val selectedPhotos by viewModel.selectedPhotos.collectAsState()
|
||||
val pricePerPhoto by viewModel.pricePerPhoto.collectAsState()
|
||||
val totalPrice by viewModel.totalPrice.collectAsState()
|
||||
|
||||
val electronicPrice by viewModel.electronicPrice.collectAsState()
|
||||
val electronicTotal by viewModel.electronicTotal.collectAsState()
|
||||
val creatingOrder by viewModel.creatingOrder.collectAsState()
|
||||
val quoteLoading by viewModel.quoteLoading.collectAsState()
|
||||
val quoteError by viewModel.quoteError.collectAsState()
|
||||
val paymentAmount by viewModel.paymentAmount.collectAsState()
|
||||
val imageLoadStates by viewModel.imageLoadStates.collectAsState()
|
||||
|
||||
var isOpeningRecentPhotos by remember { mutableStateOf(false) }
|
||||
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 isAllSelected = photoList.isNotEmpty() && selectedPhotos.size == photoList.size
|
||||
|
||||
|
||||
// 预览弹框状态
|
||||
var showPreviewDialog by remember { mutableStateOf(false) }
|
||||
var previewInitialIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
|
||||
// 支付弹框状态
|
||||
var showPayDialog by remember { mutableStateOf(false) }
|
||||
val payQrCodeUrl by viewModel.payQrCodeUrl.collectAsState()
|
||||
@@ -82,15 +114,15 @@ fun FaceRecognitionResultScreen(
|
||||
showPayDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
FullScreenMode()
|
||||
|
||||
|
||||
AppScaffold(
|
||||
topBar = {
|
||||
Column {
|
||||
AppTitleBar(
|
||||
backgroundColor = Color.White,
|
||||
title = "人脸识别结果",
|
||||
title = if (source == AppRoutes.RECENT_RESULT_SOURCE) "最近照片" else "人脸识别结果",
|
||||
isShowBackIcon = true,
|
||||
onBackClick = { viewModel.navigateBack() },
|
||||
isShowButtonLine = false
|
||||
@@ -115,112 +147,150 @@ fun FaceRecognitionResultScreen(
|
||||
.padding(24.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFF4F4F4))
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(24.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("已选择 ${selectedCount} 张", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
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(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
modifier = Modifier.weight(1f).heightIn(min = 48.dp)
|
||||
.clickable(enabled = photoList.isNotEmpty()) { viewModel.toggleSelectAll() },
|
||||
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
|
||||
// 复选框
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.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(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
Text(
|
||||
text = "打印${pricePerPhoto}元/张",
|
||||
fontSize = 21.sp,
|
||||
color = Color(0xFF000000)
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.getPayUrl { url ->
|
||||
showPayDialog = true
|
||||
|
||||
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()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.height(72.dp)
|
||||
.widthIn(min = 180.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073FF)),
|
||||
enabled = selectedCount > 0
|
||||
enabled = !isOpeningRecentPhotos,
|
||||
modifier = Modifier.heightIn(min = 48.dp),
|
||||
contentPadding = PaddingValues(horizontal = 6.dp),
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFF2878CE)),
|
||||
) {
|
||||
Text("查看最近照片", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("›", fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (source == AppRoutes.RECENT_RESULT_SOURCE && recentPhotosState != RecentPhotosState.READY) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
if (recentPhotosState == RecentPhotosState.IDLE || recentPhotosState == RecentPhotosState.LOADING) {
|
||||
CircularProgressIndicator(Modifier.size(32.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
Text(
|
||||
text = if (selectedCount > 0) "支付${totalPrice}元" else "支付0元",
|
||||
fontSize = 24.sp,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
text = when (recentPhotosState) {
|
||||
RecentPhotosState.ERROR -> "照片加载失败,请重试"
|
||||
RecentPhotosState.EMPTY -> "暂无可显示的最近照片"
|
||||
else -> "正在加载照片…"
|
||||
},
|
||||
fontSize = 18.sp,
|
||||
color = Color(0xFF666666),
|
||||
)
|
||||
if (recentPhotosState == RecentPhotosState.ERROR) {
|
||||
TextButton(onClick = viewModel::retryRecentPhotos) { Text("重试", fontSize = 18.sp) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全选行
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.clickable { viewModel.toggleSelectAll() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 复选框
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.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(
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 图片瀑布流列表
|
||||
LazyVerticalStaggeredGrid(
|
||||
columns = StaggeredGridCells.Fixed(2),
|
||||
@@ -230,12 +300,15 @@ fun FaceRecognitionResultScreen(
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
verticalItemSpacing = 18.dp
|
||||
) {
|
||||
itemsIndexed(photoList) { index, photo ->
|
||||
itemsIndexed(
|
||||
items = photoList,
|
||||
key = { _, photo -> photo.id }
|
||||
) { _, photo ->
|
||||
val isSelected = selectedPhotos.contains(photo.url)
|
||||
val imageLoadStates by viewModel.imageLoadStates.collectAsState()
|
||||
val loadState = imageLoadStates[photo.url] ?: FaceRecognitionResultViewModel.ImageLoadState()
|
||||
|
||||
|
||||
FaceRecognitionPhotoItem(
|
||||
photoId = photo.id,
|
||||
photoUrl = photo.url,
|
||||
aspectRatio = photo.aspectRatio,
|
||||
isSelected = isSelected,
|
||||
@@ -252,13 +325,16 @@ fun FaceRecognitionResultScreen(
|
||||
},
|
||||
onLoadError = { viewModel.handleImageLoadError(photo.url) },
|
||||
onLoadSuccess = { viewModel.handleImageLoadSuccess(photo.url) },
|
||||
onImageDimensionsResolved = { width, height ->
|
||||
viewModel.updatePhotoAspectRatio(photo.id, width, height)
|
||||
},
|
||||
onRetryClick = { viewModel.retryLoadImage(photo.url) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 预览弹框 - 仅预览已选择的图片
|
||||
val selectedPhotoUrls = photoList.filter { selectedPhotos.contains(it.url) }.map { it.url }
|
||||
if (showPreviewDialog && selectedPhotoUrls.isNotEmpty()) {
|
||||
@@ -268,9 +344,9 @@ fun FaceRecognitionResultScreen(
|
||||
onDismiss = { showPreviewDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 支付弹框
|
||||
if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) {
|
||||
if (showPayDialog && payQrCodeUrl != null) {
|
||||
DisposableEffect(payQrCodeUrl) {
|
||||
viewModel.startPayStatusPolling()
|
||||
onDispose {
|
||||
@@ -280,7 +356,7 @@ fun FaceRecognitionResultScreen(
|
||||
|
||||
WechatPayDialog(
|
||||
qrCodeUrl = payQrCodeUrl!!,
|
||||
totalPrice = totalPrice,
|
||||
totalPrice = paymentAmount,
|
||||
onDismiss = {
|
||||
showPayDialog = false
|
||||
viewModel.onPayDialogDismissed()
|
||||
@@ -291,6 +367,7 @@ fun FaceRecognitionResultScreen(
|
||||
|
||||
@Composable
|
||||
fun FaceRecognitionPhotoItem(
|
||||
photoId: Int,
|
||||
photoUrl: String,
|
||||
aspectRatio: Float,
|
||||
isSelected: Boolean,
|
||||
@@ -299,6 +376,7 @@ fun FaceRecognitionPhotoItem(
|
||||
onPreviewClick: () -> Unit,
|
||||
onLoadError: () -> Unit = {},
|
||||
onLoadSuccess: () -> Unit = {},
|
||||
onImageDimensionsResolved: (width: Int, height: Int) -> Unit = { _, _ -> },
|
||||
onRetryClick: () -> Unit = {}
|
||||
) {
|
||||
// 确保 aspectRatio 是有效值
|
||||
@@ -307,7 +385,7 @@ fun FaceRecognitionPhotoItem(
|
||||
} else {
|
||||
1f // 默认1:1
|
||||
}
|
||||
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -316,11 +394,20 @@ fun FaceRecognitionPhotoItem(
|
||||
.clickable { onToggleSelection() }
|
||||
) {
|
||||
// 图片 - 按原比例显示
|
||||
var lastErrorState by remember { mutableStateOf(false) }
|
||||
var lastSuccessState by remember { mutableStateOf(false) }
|
||||
|
||||
var lastErrorState by remember(photoId, photoUrl) { 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(
|
||||
model = photoUrl,
|
||||
model = imageRequest,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit
|
||||
@@ -349,16 +436,16 @@ fun FaceRecognitionPhotoItem(
|
||||
onLoadError()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 如果已经重试3次,显示加载失败,支持点击重试
|
||||
if (loadState.isFailed) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFFF5F5F5))
|
||||
.clickable {
|
||||
.clickable {
|
||||
lastErrorState = false
|
||||
onRetryClick()
|
||||
onRetryClick()
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
@@ -400,6 +487,11 @@ fun FaceRecognitionPhotoItem(
|
||||
if (state is coil.compose.AsyncImagePainter.State.Success && !lastSuccessState) {
|
||||
lastSuccessState = true
|
||||
lastErrorState = false
|
||||
val drawable = state.result.drawable
|
||||
onImageDimensionsResolved(
|
||||
drawable.intrinsicWidth,
|
||||
drawable.intrinsicHeight
|
||||
)
|
||||
onLoadSuccess()
|
||||
}
|
||||
}
|
||||
@@ -407,7 +499,7 @@ fun FaceRecognitionPhotoItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 选中遮罩
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -416,7 +508,7 @@ fun FaceRecognitionPhotoItem(
|
||||
if (isSelected) Color.Black.copy(alpha = 0.3f) else Color.Transparent
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
// 右上角复选框
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -441,7 +533,7 @@ fun FaceRecognitionPhotoItem(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 点击预览文字(选中时显示)- 点击打开预览弹框
|
||||
if (isSelected) {
|
||||
Text(
|
||||
@@ -475,7 +567,7 @@ fun WechatPayDialog(
|
||||
cornerRadius = 0f
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
@@ -507,7 +599,7 @@ fun WechatPayDialog(
|
||||
color = Color.Black,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
|
||||
|
||||
// 关闭按钮
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -526,9 +618,9 @@ fun WechatPayDialog(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(36.dp))
|
||||
|
||||
|
||||
// 二维码
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -542,9 +634,9 @@ fun WechatPayDialog(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(22.dp))
|
||||
|
||||
|
||||
// 提示文字
|
||||
Text(
|
||||
text = "请使用微信扫描二维码支付",
|
||||
|
||||
@@ -2,8 +2,6 @@ package com.yzx.kiosk.ui.face.view
|
||||
|
||||
import android.Manifest
|
||||
import android.graphics.Bitmap
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.camera.core.*
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
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.face.viewmodel.FaceRecognitionViewModel
|
||||
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)
|
||||
@Composable
|
||||
@@ -56,6 +51,7 @@ fun FaceRecognitionScreen(
|
||||
val recognitionStatus by viewModel.recognitionStatus.collectAsState()
|
||||
val capturedImage by viewModel.capturedImage.collectAsState()
|
||||
val isRecognizing by viewModel.isRecognizing.collectAsState()
|
||||
val isCaptureInProgress by viewModel.isCaptureInProgress.collectAsState()
|
||||
|
||||
// 相机权限
|
||||
val permissionsState = rememberMultiplePermissionsState(
|
||||
@@ -91,7 +87,8 @@ fun FaceRecognitionScreen(
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 48.dp, vertical = 36.dp)
|
||||
.padding(horizontal = 48.dp)
|
||||
.padding(top = 36.dp)
|
||||
.aspectRatio(0.92f),
|
||||
shape = RoundedCornerShape(36.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.Black)
|
||||
@@ -103,6 +100,9 @@ fun FaceRecognitionScreen(
|
||||
onImageCaptureReady = { imageCapture ->
|
||||
viewModel.setImageCapture(imageCapture)
|
||||
},
|
||||
onImageCaptureReleased = { imageCapture ->
|
||||
viewModel.clearImageCapture(imageCapture)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
@@ -201,7 +201,7 @@ fun FaceRecognitionScreen(
|
||||
}
|
||||
}
|
||||
// 拍照按钮(5秒倒计时期间也显示,识别失败时显示"重新拍照",其他情况显示"拍照")
|
||||
if (!isRecognizing) {
|
||||
if (!isRecognizing && !isCaptureInProgress) {
|
||||
Button(
|
||||
onClick = {
|
||||
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(
|
||||
@@ -309,50 +324,62 @@ fun FaceRecognitionScreen(
|
||||
@Composable
|
||||
fun CameraPreview(
|
||||
onImageCaptureReady: (ImageCapture) -> Unit,
|
||||
onImageCaptureReleased: (ImageCapture) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.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(
|
||||
factory = { ctx ->
|
||||
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
|
||||
},
|
||||
factory = { previewView },
|
||||
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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ internal enum class FacePayStatusDecision {
|
||||
|
||||
internal fun decideFacePayStatus(orderStatus: Int?): FacePayStatusDecision = when (orderStatus) {
|
||||
30 -> FacePayStatusDecision.COMPLETE_PAYMENT
|
||||
40, 50, 60 -> FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS
|
||||
else -> FacePayStatusDecision.CONTINUE_POLLING
|
||||
10 -> FacePayStatusDecision.CONTINUE_POLLING
|
||||
else -> FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS
|
||||
}
|
||||
|
||||
internal fun isValidFacePaySuccessMessage(
|
||||
@@ -19,7 +19,7 @@ internal fun isValidFacePaySuccessMessage(
|
||||
message: PaySuccessMessageResponse?
|
||||
): Boolean {
|
||||
val paymentData = message?.data ?: return false
|
||||
return message.type == 5 &&
|
||||
return message.orderStatus == 30 && message.type == 5 &&
|
||||
paymentData.orderNumber == expectedOrderNumber &&
|
||||
paymentData.captureType == 2 &&
|
||||
!paymentData.imageIds.isNullOrEmpty()
|
||||
|
||||
@@ -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,19 +1,17 @@
|
||||
package com.yzx.kiosk.ui.face.viewmodel
|
||||
|
||||
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.datastore.AppState
|
||||
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.PaySuccessMessageRequest
|
||||
import com.yzx.kiosk.network.model.request.VerifyResultRequest
|
||||
import com.yzx.kiosk.network.model.response.FaceSearchResult
|
||||
import com.yzx.kiosk.network.service.FaceSearchService
|
||||
import com.yzx.kiosk.network.service.buildRecentPhotosUrl
|
||||
import com.yzx.kiosk.network.repository.NetWorkRepository
|
||||
import com.yzx.kiosk.network.result.asResult
|
||||
import com.yzx.kiosk.ui.upload.viewmodel.FileMapData
|
||||
import com.yzx.kiosk.ui.upload.viewmodel.PhotoData
|
||||
import com.yzx.kiosk.utils.ToastUtils
|
||||
@@ -24,11 +22,8 @@ import com.yzx.kiosk.datastore.AppStoreDataSource
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
@@ -38,8 +33,8 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import com.yzx.kiosk.utils.LogUtils
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
@@ -49,34 +44,83 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
navigator: AppNavigator,
|
||||
appState: AppState,
|
||||
private val netWorkRepository: NetWorkRepository,
|
||||
private val faceSearchService: FaceSearchService,
|
||||
private val webSocketService: WebSocketService,
|
||||
private val appStoreDataSource: AppStoreDataSource,
|
||||
) : BaseViewModel(
|
||||
navigator = navigator,
|
||||
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)
|
||||
private var originalResults: List<FaceSearchResult> = emptyList()
|
||||
|
||||
|
||||
// URL到ID的映射
|
||||
private val urlToIdMap = mutableMapOf<String, Int>()
|
||||
|
||||
|
||||
// 图片列表
|
||||
private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList())
|
||||
val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow()
|
||||
|
||||
|
||||
// 已选中的图片URL(用于UI显示)
|
||||
private val _selectedPhotos = MutableStateFlow<Set<String>>(emptySet())
|
||||
val selectedPhotos: StateFlow<Set<String>> = _selectedPhotos.asStateFlow()
|
||||
|
||||
|
||||
// 单价
|
||||
private val _pricePerPhoto = MutableStateFlow<String>("--")
|
||||
val pricePerPhoto: StateFlow<String> = _pricePerPhoto.asStateFlow()
|
||||
|
||||
|
||||
// 总价
|
||||
private val _totalPrice = MutableStateFlow<String>("--")
|
||||
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
|
||||
private val _payQrCodeUrl = MutableStateFlow<String?>(null)
|
||||
val payQrCodeUrl: StateFlow<String?> = _payQrCodeUrl.asStateFlow()
|
||||
@@ -88,23 +132,23 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
private var activePaymentOrderNumber: String? = null
|
||||
private var paymentPollingJob: Job? = null
|
||||
private val paymentHandled = AtomicBoolean(false)
|
||||
|
||||
|
||||
// 图片加载状态:URL -> (重试次数, 是否加载失败)
|
||||
private val _imageLoadStates = MutableStateFlow<Map<String, ImageLoadState>>(emptyMap())
|
||||
val imageLoadStates: StateFlow<Map<String, ImageLoadState>> = _imageLoadStates.asStateFlow()
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FaceRecognitionResultViewModel"
|
||||
private const val MAX_RETRY_COUNT = 3
|
||||
private const val PAY_STATUS_POLL_INTERVAL_MS = 2_000L
|
||||
}
|
||||
|
||||
|
||||
data class ImageLoadState(
|
||||
val retryCount: Int = 0,
|
||||
val isFailed: Boolean = false,
|
||||
val isLoading: Boolean = false
|
||||
)
|
||||
|
||||
|
||||
init {
|
||||
// 监听 WebSocket 支付成功事件
|
||||
webSocketService.uploadPhotoEvents
|
||||
@@ -116,6 +160,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
orderNumber = event.orderNumber,
|
||||
captureType = event.captureType,
|
||||
imageIds = event.imageIds,
|
||||
purchaseMode = event.purchaseMode,
|
||||
source = "WebSocket"
|
||||
)
|
||||
}
|
||||
@@ -126,118 +171,91 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 初始化图片列表(从人脸识别结果)
|
||||
*/
|
||||
private var faceResultsInitialized = false
|
||||
|
||||
fun initPhotoList(results: List<FaceSearchResult>) {
|
||||
viewModelScope.launch {
|
||||
// 保存原始结果列表
|
||||
originalResults = results
|
||||
|
||||
// 先使用默认宽高比创建列表
|
||||
val initialPhotos = results.mapNotNull { result ->
|
||||
// 使用 thumbnail_oss_url
|
||||
val imageUrl = if (appStoreDataSource.getUseLan()) result.thumbnailLanUrl else result.thumbnailOssUrl
|
||||
if (imageUrl.isNullOrEmpty()) {
|
||||
LogUtils.i("FaceRecognitionResultViewModel", "图片URL为空,跳过")
|
||||
null
|
||||
} else {
|
||||
val trimmedUrl = imageUrl.trim()
|
||||
// 建立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} 张")
|
||||
}
|
||||
// Returning from a pushed recent-photos page must retain selection, pricing and image state.
|
||||
if (faceResultsInitialized) return
|
||||
faceResultsInitialized = true
|
||||
verifyResult(emptyList())
|
||||
viewModelScope.launch { applyPhotoList(results) }
|
||||
}
|
||||
|
||||
|
||||
private suspend fun applyPhotoList(results: List<FaceSearchResult>) {
|
||||
// 保存原始结果列表
|
||||
originalResults = results
|
||||
urlToIdMap.clear()
|
||||
|
||||
// 先使用默认宽高比创建列表
|
||||
val initialPhotos = results.mapNotNull { result ->
|
||||
// 使用 thumbnail_oss_url
|
||||
val imageUrl = if (appStoreDataSource.getUseLan()) result.thumbnailLanUrl else result.thumbnailOssUrl
|
||||
if (imageUrl.isNullOrEmpty()) {
|
||||
LogUtils.i("FaceRecognitionResultViewModel", "图片URL为空,跳过")
|
||||
null
|
||||
} else {
|
||||
val trimmedUrl = imageUrl.trim()
|
||||
// 建立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
|
||||
}
|
||||
|
||||
_photoList.value = initialPhotos
|
||||
// 初始化加载状态
|
||||
_imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() }
|
||||
|
||||
LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新")
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片的宽高比
|
||||
* 使用网格中已经加载成功的 Drawable 尺寸更新宽高比。
|
||||
* 这样不会为了获取尺寸额外下载、解码一次图片。
|
||||
*/
|
||||
private suspend fun getImageAspectRatio(imageUrl: String): Float {
|
||||
return withTimeoutOrNull(10000) { // 10秒超时
|
||||
try {
|
||||
val imageLoader = ImageLoader(App.instance)
|
||||
val request = ImageRequest.Builder(App.instance)
|
||||
.data(imageUrl)
|
||||
.size(Size.ORIGINAL)
|
||||
.allowHardware(false) // 禁用硬件加速,避免某些图片无法获取尺寸
|
||||
.build()
|
||||
|
||||
val result = imageLoader.execute(request)
|
||||
val drawable = result.drawable
|
||||
|
||||
if (drawable != null) {
|
||||
val width = drawable.intrinsicWidth
|
||||
val height = drawable.intrinsicHeight
|
||||
|
||||
if (width > 0 && height > 0) {
|
||||
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}")
|
||||
}
|
||||
fun updatePhotoAspectRatio(photoId: Int, width: Int, height: Int) {
|
||||
if (width <= 0 || height <= 0) {
|
||||
LogUtils.i(TAG, "忽略无效图片尺寸 - id: $photoId, size: ${width}x${height}")
|
||||
return
|
||||
}
|
||||
|
||||
val aspectRatio = width.toFloat() / height.toFloat()
|
||||
if (!aspectRatio.isFinite() || aspectRatio !in 0.05f..20f) {
|
||||
LogUtils.i(TAG, "忽略异常图片宽高比 - id: $photoId, ratio: $aspectRatio")
|
||||
return
|
||||
}
|
||||
|
||||
_photoList.update { photos ->
|
||||
photos.map { photo ->
|
||||
if (photo.id == photoId && kotlin.math.abs(photo.aspectRatio - aspectRatio) > 0.01f) {
|
||||
LogUtils.d(TAG, "更新图片比例 - id: $photoId, size: ${width}x${height}, ratio: $aspectRatio")
|
||||
photo.copy(aspectRatio = aspectRatio)
|
||||
} else {
|
||||
LogUtils.i("FaceRecognitionResultViewModel", "图片 $imageUrl 加载失败,drawable为null")
|
||||
photo
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e("FaceRecognitionResultViewModel"+"获取图片 $imageUrl 尺寸异常: ${e.message}")
|
||||
}
|
||||
null
|
||||
} ?: run {
|
||||
LogUtils.i("FaceRecognitionResultViewModel", "获取图片 $imageUrl 尺寸超时")
|
||||
1f // 超时或失败,返回默认值
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 切换单张图片选中状态
|
||||
*/
|
||||
fun togglePhotoSelection(url: String) {
|
||||
if (_creatingOrder.value) return
|
||||
val current = _selectedPhotos.value.toMutableSet()
|
||||
if (current.contains(url)) {
|
||||
current.remove(url)
|
||||
@@ -245,16 +263,18 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
current.add(url)
|
||||
}
|
||||
_selectedPhotos.value = current
|
||||
|
||||
|
||||
// 选择变化时调用接口获取价格
|
||||
val selectedIds = getSelectedImageIds()
|
||||
verifyResult(selectedIds)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 切换全选状态
|
||||
*/
|
||||
fun toggleSelectAll() {
|
||||
if (_creatingOrder.value) return
|
||||
if (_photoList.value.isEmpty()) return
|
||||
val allUrls = _photoList.value.map { it.url }.toSet()
|
||||
val newSelected = if (_selectedPhotos.value.size == allUrls.size) {
|
||||
// 当前全选,取消全选
|
||||
@@ -264,12 +284,12 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
allUrls
|
||||
}
|
||||
_selectedPhotos.value = newSelected
|
||||
|
||||
|
||||
// 选择变化时调用接口获取价格
|
||||
val selectedIds = getSelectedImageIds()
|
||||
verifyResult(selectedIds)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取已选中的图片ID列表
|
||||
*/
|
||||
@@ -278,105 +298,114 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
urlToIdMap[url]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 验证结果接口
|
||||
*/
|
||||
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 {
|
||||
val request = VerifyResultRequest(
|
||||
type = 2,
|
||||
imageId = imageIds
|
||||
)
|
||||
|
||||
handleResultWithData(
|
||||
flow = netWorkRepository.verifyResult(request).asResult(),
|
||||
showToast = false,
|
||||
onData = { response ->
|
||||
// 更新单价(使用接口返回的 price_image)
|
||||
val priceImage = response.priceImage
|
||||
if (!priceImage.isNullOrEmpty()) {
|
||||
_pricePerPhoto.value = priceImage
|
||||
} else {
|
||||
_pricePerPhoto.value = "--"
|
||||
}
|
||||
|
||||
// 更新总价(使用接口返回的 amount)
|
||||
val amount = response.amount
|
||||
if (!amount.isNullOrEmpty()) {
|
||||
_totalPrice.value = amount
|
||||
} else {
|
||||
_totalPrice.value = "--"
|
||||
}
|
||||
|
||||
LogUtils.d("FaceRecognitionResultViewModel", "验证结果成功 - 单价: $priceImage, 总价: $amount")
|
||||
},
|
||||
onError = { msg, _ ->
|
||||
LogUtils.e("FaceRecognitionResultViewModel", "验证结果失败: $msg")
|
||||
// 接口失败时,保持当前值或设置为默认值
|
||||
if (imageIds.isEmpty()) {
|
||||
_pricePerPhoto.value = "--"
|
||||
_totalPrice.value = "--"
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e("FaceRecognitionResultViewModel", "验证结果异常: ${e.message}")
|
||||
if (imageIds.isEmpty()) {
|
||||
_pricePerPhoto.value = "--"
|
||||
_totalPrice.value = "--"
|
||||
val response = netWorkRepository.verifyResult(VerifyResultRequest(2, imageIds.distinct(), videoId = emptyList())).first()
|
||||
if (!quoteRevision.isCurrent(revision)) return@launch
|
||||
if (!response.isSucceeded) {
|
||||
_quoteError.value = response.message?.takeIf { it.isNotBlank() } ?: "获取报价失败,请重试"
|
||||
return@launch
|
||||
}
|
||||
val data = response.data
|
||||
if (data == null) {
|
||||
_quoteError.value = "报价数据不完整,请重试"
|
||||
return@launch
|
||||
}
|
||||
val printUnit = money(data.priceImage)
|
||||
val printTotal = money(data.amount)
|
||||
if (printUnit != null && printTotal != null) {
|
||||
_pricePerPhoto.value = printUnit
|
||||
_totalPrice.value = printTotal
|
||||
}
|
||||
val electronicUnit = money(data.priceElectronic)
|
||||
val electronicTotal = money(data.amountElectronic)
|
||||
if (electronicQuoteAvailable(electronicUnit, electronicTotal, imageIds.isNotEmpty())) {
|
||||
_electronicPrice.value = checkNotNull(electronicUnit)
|
||||
_electronicTotal.value = checkNotNull(electronicTotal)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e(TAG, "获取报价失败: ${e.message}")
|
||||
if (quoteRevision.isCurrent(revision)) _quoteError.value = "网络异常,请重新获取价格"
|
||||
} finally {
|
||||
if (quoteRevision.isCurrent(revision)) _quoteLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支付URL
|
||||
*/
|
||||
fun getPayUrl(onSuccess: (String) -> Unit) {
|
||||
|
||||
private fun clearActivePayment() {
|
||||
stopPayStatusPolling()
|
||||
activeOrder = null
|
||||
activePaymentOrderNumber = null
|
||||
_payQrCodeUrl.value = null
|
||||
_dismissPayDialogEvents.tryEmit(Unit)
|
||||
}
|
||||
|
||||
private fun recoverPayment(message: String) {
|
||||
clearActivePayment()
|
||||
ToastUtils.show(message)
|
||||
retryQuote()
|
||||
}
|
||||
|
||||
fun retryQuote() = verifyResult(getSelectedImageIds())
|
||||
|
||||
fun getPayUrl(mode: PurchaseMode, onSuccess: (String) -> Unit) {
|
||||
if (_creatingOrder.value) return
|
||||
val selectedIds = getSelectedImageIds().distinct()
|
||||
val quotedAmount = if (mode == PurchaseMode.PRINT) _totalPrice.value else _electronicTotal.value
|
||||
if (selectedIds.isEmpty() || money(quotedAmount) == null || _quoteLoading.value) return
|
||||
if (mode == PurchaseMode.ELECTRONIC && !isPositiveMoney(quotedAmount)) return
|
||||
clearActivePayment()
|
||||
_creatingOrder.value = true
|
||||
viewModelScope.launch {
|
||||
val selectedIds = getSelectedImageIds()
|
||||
if (selectedIds.isEmpty()) {
|
||||
ToastUtils.show("请选择要打印的照片")
|
||||
return@launch
|
||||
}
|
||||
showLoading()
|
||||
try {
|
||||
val request = GetPayUrlRequest(
|
||||
type = 2,
|
||||
imageId = selectedIds
|
||||
)
|
||||
|
||||
handleResultWithData(
|
||||
flow = netWorkRepository.getPayUrl(request).asResult(),
|
||||
showToast = false,
|
||||
onData = { response ->
|
||||
val url = response.url?.trim()
|
||||
val orderNumber = response.orderNumber?.trim()
|
||||
if (url.isNullOrEmpty() || orderNumber.isNullOrEmpty()) {
|
||||
_payQrCodeUrl.value = null
|
||||
LogUtils.e(TAG, "获取支付二维码成功,但 url 或 order_number 为空")
|
||||
ToastUtils.show("获取支付二维码失败")
|
||||
} else {
|
||||
stopPayStatusPolling()
|
||||
activePaymentOrderNumber = orderNumber
|
||||
paymentHandled.set(false)
|
||||
_payQrCodeUrl.value = url
|
||||
onSuccess(url)
|
||||
}
|
||||
},
|
||||
onError = { msg, _ ->
|
||||
LogUtils.e("FaceRecognitionResultViewModel", "获取支付URL失败: $msg")
|
||||
ToastUtils.show("获取支付二维码失败")
|
||||
},
|
||||
onEnd = {
|
||||
hideLoading()
|
||||
}
|
||||
)
|
||||
val response = netWorkRepository.getPayUrl(
|
||||
GetPayUrlRequest(2, selectedIds, mode.wireValue, videoId = emptyList())
|
||||
).first()
|
||||
if (!response.isSucceeded) {
|
||||
recoverPayment(response.message?.takeIf { it.isNotBlank() } ?: "获取支付链接失败,请重新选择")
|
||||
return@launch
|
||||
}
|
||||
val data = response.data
|
||||
val order = if (data != null)
|
||||
createFacePaymentOrder(data, mode, selectedIds, quotedAmount) else null
|
||||
if (order == null) {
|
||||
recoverPayment("支付链接信息无效,请重新选择")
|
||||
return@launch
|
||||
}
|
||||
stopPayStatusPolling()
|
||||
activeOrder = order
|
||||
activePaymentOrderNumber = order.number
|
||||
paymentHandled.set(false)
|
||||
_paymentAmount.value = order.amount
|
||||
_paymentMode.value = order.mode
|
||||
val url = checkNotNull(data?.url).trim()
|
||||
_payQrCodeUrl.value = url
|
||||
onSuccess(url)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e("FaceRecognitionResultViewModel", "获取支付URL异常: ${e.message}")
|
||||
ToastUtils.show("获取支付二维码失败")
|
||||
LogUtils.e(TAG, "创建订单失败: ${e.message}")
|
||||
ToastUtils.show("创建订单失败,请重试")
|
||||
} finally {
|
||||
_creatingOrder.value = false
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,14 +452,17 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
.getPaySuccessMessage(PaySuccessMessageRequest(orderNumber))
|
||||
.first()
|
||||
|
||||
if (activePaymentOrderNumber != orderNumber || paymentHandled.get()) return
|
||||
if (!response.isSucceeded) {
|
||||
LogUtils.e(
|
||||
TAG,
|
||||
"查询支付状态业务失败 - order_number: $orderNumber, code: ${response.code}, msg: ${response.message}"
|
||||
)
|
||||
recoverPayment(response.message?.takeIf { it.isNotBlank() } ?: "订单已失效,请重新选择照片")
|
||||
return
|
||||
}
|
||||
|
||||
if (activePaymentOrderNumber != orderNumber) return
|
||||
val message = response.data
|
||||
when (decideFacePayStatus(message?.orderStatus)) {
|
||||
FacePayStatusDecision.CONTINUE_POLLING -> {
|
||||
@@ -451,6 +483,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
orderNumber = paymentData.orderNumber,
|
||||
captureType = paymentData.captureType,
|
||||
imageIds = paymentData.imageIds.orEmpty(),
|
||||
purchaseMode = paymentData.purchaseMode,
|
||||
source = "HTTP"
|
||||
)
|
||||
}
|
||||
@@ -473,16 +506,17 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
|
||||
stopPayStatusPolling()
|
||||
activePaymentOrderNumber = null
|
||||
activeOrder = null
|
||||
_payQrCodeUrl.value = null
|
||||
_dismissPayDialogEvents.tryEmit(Unit)
|
||||
|
||||
val statusText = orderStatusName?.takeIf { it.isNotBlank() } ?: when (orderStatus) {
|
||||
40 -> "已取消"
|
||||
50 -> "已退款"
|
||||
60 -> "部分退款"
|
||||
else -> "状态异常"
|
||||
}
|
||||
ToastUtils.show("订单$statusText")
|
||||
retryQuote()
|
||||
LogUtils.i(TAG, "订单进入终态,停止支付轮询 - status: $orderStatus, name: $statusText")
|
||||
}
|
||||
|
||||
@@ -490,19 +524,13 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
orderNumber: String?,
|
||||
captureType: Int?,
|
||||
imageIds: List<Int>,
|
||||
purchaseMode: String?,
|
||||
source: String
|
||||
) {
|
||||
val expectedOrderNumber = activePaymentOrderNumber
|
||||
if (
|
||||
expectedOrderNumber.isNullOrEmpty() ||
|
||||
orderNumber != expectedOrderNumber ||
|
||||
captureType != 2 ||
|
||||
imageIds.isEmpty()
|
||||
) {
|
||||
LogUtils.i(
|
||||
TAG,
|
||||
"忽略不匹配的支付成功消息 - source: $source, expected: $expectedOrderNumber, actual: $orderNumber, capture_type: $captureType, image_ids: $imageIds"
|
||||
)
|
||||
val order = activeOrder ?: return
|
||||
if (!order.matches(orderNumber, captureType, imageIds, purchaseMode)) {
|
||||
LogUtils.i(TAG, "忽略不匹配的支付成功消息 - source: $source, expected: $expectedOrderNumber")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -521,26 +549,35 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
stopPayStatusPolling()
|
||||
_payQrCodeUrl.value = null
|
||||
_dismissPayDialogEvents.tryEmit(Unit)
|
||||
navigateToPaySuccess(orderNumber, captureType, imageIds)
|
||||
navigateAfterPurchase(orderNumber, captureType, imageIds, order)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 跳转到支付成功页面
|
||||
* 按已确认订单的模式进入打印流程或独立电子版完成页
|
||||
* @param orderNumber 订单号
|
||||
* @param captureType 抓拍类型
|
||||
* @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 {
|
||||
if (orderNumber.isNullOrBlank() || captureType == null) {
|
||||
LogUtils.e(
|
||||
TAG,
|
||||
"支付成功消息订单信息不完整 - orderNumber: $orderNumber, captureType: $captureType"
|
||||
)
|
||||
ToastUtils.show("支付成功,但订单信息不完整")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 根据 imageIds 从 originalResults 中获取对应的 FaceSearchResult
|
||||
val selectedResults = originalResults.filter { imageIds.contains(it.id) }
|
||||
|
||||
|
||||
if (selectedResults.isEmpty()) {
|
||||
LogUtils.e("FaceRecognitionResultViewModel", "未找到对应的图片数据,imageIds: $imageIds")
|
||||
ToastUtils.show("支付成功,但未找到对应的图片数据")
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
// 将 FaceSearchResult 转换为 FileMapData
|
||||
val fileMapDataList = selectedResults.map { result ->
|
||||
FileMapData(
|
||||
@@ -551,27 +588,25 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
originalLanUrl = result.originalLanUrl
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 将 FileMapData 数组序列化为 JSON 数组字符串
|
||||
val gson = Gson()
|
||||
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,清除当前页面
|
||||
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()
|
||||
|
||||
// 跳转到支付成功页面
|
||||
val route = "${com.yzx.kiosk.navigation.routes.AppRoutes.PAY_SUCCESS}?urls=$encodedJson&orderNumber=$orderNumberParam&captureType=$captureTypeParam"
|
||||
|
||||
val route = if (order.mode == PurchaseMode.ELECTRONIC) {
|
||||
AppRoutes.buildElectronicCompletionRoute(orderNumber)
|
||||
} else {
|
||||
AppRoutes.buildPrintingRoute(jsonArray, orderNumber, captureType)
|
||||
}
|
||||
toPage(route, navOptions)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 处理图片加载失败
|
||||
*/
|
||||
@@ -579,12 +614,12 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
val currentStates = _imageLoadStates.value.toMutableMap()
|
||||
val currentState = currentStates[url] ?: ImageLoadState()
|
||||
|
||||
|
||||
// 如果已经在加载中,避免重复处理
|
||||
if (currentState.isLoading) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
if (currentState.retryCount < MAX_RETRY_COUNT) {
|
||||
// 重试
|
||||
val newRetryCount = currentState.retryCount + 1
|
||||
@@ -594,10 +629,10 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
isLoading = true
|
||||
)
|
||||
_imageLoadStates.value = currentStates
|
||||
|
||||
|
||||
// 延迟后重试(给 Coil 一些时间)
|
||||
kotlinx.coroutines.delay(500)
|
||||
|
||||
|
||||
// 触发重新加载(通过更新 URL 来强制 Coil 重新加载)
|
||||
val updatedList = _photoList.value.map { photo ->
|
||||
if (photo.url == url || photo.url.startsWith("$url?")) {
|
||||
@@ -608,7 +643,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
_photoList.value = updatedList
|
||||
|
||||
|
||||
// 恢复原始 URL(避免 URL 被污染)
|
||||
kotlinx.coroutines.delay(200)
|
||||
val restoredList = _photoList.value.map { photo ->
|
||||
@@ -631,7 +666,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 处理图片加载成功
|
||||
*/
|
||||
@@ -647,7 +682,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 手动重试加载图片
|
||||
*/
|
||||
@@ -657,9 +692,9 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
// 重置状态,重新开始重试
|
||||
currentStates[url] = ImageLoadState(isLoading = true, retryCount = 0)
|
||||
_imageLoadStates.value = currentStates
|
||||
|
||||
|
||||
LogUtils.d("FaceRecognitionResultViewModel", "手动重试加载图片: $url")
|
||||
|
||||
|
||||
// 触发重新加载
|
||||
val updatedList = _photoList.value.map { photo ->
|
||||
if (photo.url == url || photo.url.startsWith("$url?")) {
|
||||
@@ -669,7 +704,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
_photoList.value = updatedList
|
||||
|
||||
|
||||
// 恢复原始 URL
|
||||
kotlinx.coroutines.delay(200)
|
||||
val restoredList = _photoList.value.map { photo ->
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package com.yzx.kiosk.ui.face.viewmodel
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.camera.core.ImageCaptureException
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -16,16 +13,20 @@ import com.yzx.kiosk.datastore.AppStoreDataSource
|
||||
import com.google.gson.Gson
|
||||
import com.yzx.kiosk.network.model.response.FaceSearchResponse
|
||||
import com.yzx.kiosk.network.service.FaceSearchService
|
||||
import com.yzx.kiosk.network.service.buildFaceSearchUrl
|
||||
import com.yzx.kiosk.App
|
||||
import com.yzx.kiosk.BuildConfig
|
||||
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
|
||||
import com.yzx.kiosk.navigation.AppNavigator
|
||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||
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.ToastUtils
|
||||
import java.net.URLEncoder
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -35,15 +36,9 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class RecognitionStatus {
|
||||
@@ -58,7 +53,10 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
navigator: AppNavigator,
|
||||
appState: AppState,
|
||||
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(
|
||||
navigator = navigator,
|
||||
appState = appState
|
||||
@@ -89,10 +87,17 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
// 是否正在识别
|
||||
private val _isRecognizing = MutableStateFlow(false)
|
||||
val isRecognizing: StateFlow<Boolean> = _isRecognizing.asStateFlow()
|
||||
|
||||
private val _isCaptureInProgress = MutableStateFlow(false)
|
||||
val isCaptureInProgress: StateFlow<Boolean> = _isCaptureInProgress.asStateFlow()
|
||||
|
||||
// 倒计时Job
|
||||
private var countdownJob: 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实例
|
||||
private var imageCapture: ImageCapture? = null
|
||||
@@ -107,6 +112,15 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
fun setImageCapture(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
|
||||
}
|
||||
// 倒计时结束,自动返回首页
|
||||
toPage(com.yzx.kiosk.navigation.routes.AppRoutes.HOME)
|
||||
closeAllExcept(AppRoutes.HOME)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +167,12 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
* 手动拍照
|
||||
*/
|
||||
fun takePhoto() {
|
||||
if (_isRecognizing.value) return
|
||||
val captureToken = captureGate.tryAcquire() ?: return
|
||||
|
||||
activeCaptureToken = captureToken
|
||||
_isCaptureInProgress.value = true
|
||||
|
||||
// 如果正在自动拍照倒计时,先取消
|
||||
if (_captureCountdown.value > 0) {
|
||||
cancelAutoCapture()
|
||||
@@ -166,68 +186,107 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
val capture = imageCapture ?: run {
|
||||
releaseCaptureGate(captureToken)
|
||||
ToastUtils.show("相机未准备好")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
// 创建输出文件
|
||||
val photoFile = withContext(Dispatchers.IO) {
|
||||
File.createTempFile(
|
||||
"IMG_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())}",
|
||||
".jpg",
|
||||
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("拍照失败")
|
||||
}
|
||||
var photoFile: File? = null
|
||||
try {
|
||||
photoFile = withContext(Dispatchers.IO) {
|
||||
faceCaptureFileStore.createCaptureFile()
|
||||
}
|
||||
)
|
||||
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 searchFaceDirectly(photoFile: File) {
|
||||
|
||||
private fun recognizeSavedPhoto(photoFile: File, captureToken: Long) {
|
||||
viewModelScope.launch {
|
||||
_isRecognizing.value = true
|
||||
_recognitionStatus.value = RecognitionStatus.RECOGNIZING
|
||||
// 播放识别中音频
|
||||
releaseCaptureGate(captureToken)
|
||||
localAudioPlayService.playByRoute("face_recognition_recognizing")
|
||||
// 播放识别中音频
|
||||
localAudioPlayService.playByRoute("face_recognition_recognizing")
|
||||
|
||||
|
||||
try {
|
||||
// 直接调用人脸识别接口
|
||||
_capturedImage.value = withContext(Dispatchers.IO) {
|
||||
faceThumbnailDecoder.decode(photoFile.absolutePath)
|
||||
}
|
||||
searchFace(photoFile)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e(TAG, "处理失败: ${e.message}")
|
||||
ToastUtils.show("处理失败: ${e.message}")
|
||||
_isRecognizing.value = false
|
||||
_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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索人脸
|
||||
@@ -235,8 +294,16 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
private suspend fun searchFace(imageFile: File) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
|
||||
val FACE_SEARCH_BASE_URL = if (appStoreDataSource.getUseLan()) appStoreDataSource.getBindBoxLanUrl() else appStoreDataSource.getBindBoxUrl()
|
||||
val FACE_SEARCH_TOKEN = appStoreDataSource.getBindBoxApiToken()
|
||||
val faceSearchBaseUrl = if (appStoreDataSource.getUseLan()) {
|
||||
appStoreDataSource.getBindBoxLanUrl()
|
||||
} else {
|
||||
appStoreDataSource.getBindBoxUrl()
|
||||
}
|
||||
val faceSearchToken = appStoreDataSource.getBindBoxApiToken()
|
||||
val requestUrl = buildFaceSearchUrl(
|
||||
baseUrl = faceSearchBaseUrl,
|
||||
deviceSn = appStoreDataSource.getBindBoxSn(),
|
||||
)
|
||||
|
||||
|
||||
// 创建MultipartBody
|
||||
@@ -247,16 +314,10 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
val thresholdValue = 0.45f
|
||||
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
|
||||
val authorization = "Bearer $FACE_SEARCH_TOKEN"
|
||||
val authorization = "Bearer $faceSearchToken"
|
||||
|
||||
// 打印请求信息
|
||||
val requestUrl = "$FACE_SEARCH_BASE_URL/api/search"
|
||||
LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========")
|
||||
LogUtils.d(TAG, "URL: $requestUrl")
|
||||
LogUtils.d(TAG, "Headers:")
|
||||
@@ -264,33 +325,15 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
LogUtils.d(TAG, "Parameters:")
|
||||
LogUtils.d(TAG, "image: ${imageFile.name} (${imageFile.length()} bytes)")
|
||||
LogUtils.d(TAG, "threshold: $thresholdValue")
|
||||
LogUtils.d(TAG, "index_date: $indexDate")
|
||||
LogUtils.d(TAG, "==========================================")
|
||||
|
||||
// Debug 环境记录请求与响应;图片等大文件只记录元数据,不读取文件内容
|
||||
val client = OkHttpClient.Builder()
|
||||
.apply {
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
// 创建Retrofit实例
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl("$FACE_SEARCH_BASE_URL/")
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
val service = retrofit.create(FaceSearchService::class.java)
|
||||
|
||||
// 调用接口
|
||||
val response = service.searchFace(
|
||||
sn = appStoreDataSource.getBindBoxSn(),
|
||||
val response = faceSearchService.searchFace(
|
||||
url = requestUrl,
|
||||
authorization = authorization,
|
||||
image = imagePart,
|
||||
threshold = thresholdBody,
|
||||
// 不传日期,由 V3 服务端使用上海时区当天;人数及人脸大小也使用服务端默认值。
|
||||
indexDate = null
|
||||
)
|
||||
|
||||
@@ -315,6 +358,9 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
localAudioPlayService.playByRoute("face_recognition_failed")
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// 页面离开时保持协程取消语义,避免误显示 "Job was cancelled"
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e("人脸识别失败: ${e.message}")
|
||||
ToastUtils.show("识别失败: ${e.message}")
|
||||
@@ -342,7 +388,7 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
} else {
|
||||
// 识别成功
|
||||
_recognitionStatus.value = RecognitionStatus.SUCCESS
|
||||
ToastUtils.show("找到 ${result.count} 张照片")
|
||||
LogUtils.i(TAG, "找到 ${result.count} 张照片")
|
||||
|
||||
// 播放识别成功音频(在跳转前播放)
|
||||
localAudioPlayService.playByRoute(com.yzx.kiosk.navigation.routes.AppRoutes.FACE_RECOGNITION_RESULT)
|
||||
@@ -362,82 +408,20 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载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
|
||||
val bitmap = BitmapFactory.decodeFile(imagePath) ?: return null
|
||||
|
||||
// 根据EXIF方向旋转Bitmap
|
||||
val rotationDegrees = when (orientation) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
||||
// 水平翻转
|
||||
val matrix = Matrix()
|
||||
matrix.setScale(-1f, 1f)
|
||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
}
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
||||
// 垂直翻转
|
||||
val matrix = Matrix()
|
||||
matrix.setScale(1f, -1f)
|
||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
}
|
||||
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||
// 转置(旋转90度+水平翻转)
|
||||
val matrix = Matrix()
|
||||
matrix.setRotate(90f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
}
|
||||
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||
// 横向(旋转270度+水平翻转)
|
||||
val matrix = Matrix()
|
||||
matrix.setRotate(270f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
}
|
||||
else -> 0f // 不需要旋转
|
||||
}
|
||||
|
||||
// 如果需要旋转
|
||||
if (rotationDegrees != 0f) {
|
||||
val matrix = Matrix()
|
||||
matrix.postRotate(rotationDegrees)
|
||||
val rotatedBitmap = Bitmap.createBitmap(
|
||||
bitmap,
|
||||
0,
|
||||
0,
|
||||
bitmap.width,
|
||||
bitmap.height,
|
||||
matrix,
|
||||
true
|
||||
)
|
||||
// 回收原始bitmap
|
||||
if (rotatedBitmap != bitmap) {
|
||||
bitmap.recycle()
|
||||
}
|
||||
rotatedBitmap
|
||||
} else {
|
||||
bitmap
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e(TAG, "加载图片并处理EXIF方向失败: ${e.message}")
|
||||
// 如果处理失败,返回原始Bitmap
|
||||
BitmapFactory.decodeFile(imagePath)
|
||||
}
|
||||
private var isOpeningRecentPhotos = false
|
||||
|
||||
fun browseRecentPhotos() {
|
||||
if (_recognitionStatus.value != RecognitionStatus.FAILED || isOpeningRecentPhotos) return
|
||||
isOpeningRecentPhotos = true
|
||||
countdownJob?.cancel()
|
||||
autoCaptureJob?.cancel()
|
||||
toPage(
|
||||
AppRoutes.buildRecentPhotosRoute(),
|
||||
NavOptions.Builder()
|
||||
.setPopUpTo(AppRoutes.FACE_RECOGNITION, inclusive = true)
|
||||
.setLaunchSingleTop(true)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
fun privatePolicy() {
|
||||
@@ -469,9 +453,15 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
isCleared = true
|
||||
countdownJob?.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
|
||||
|
||||
import com.yzx.kiosk.utils.formatPriceDisplay
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
@@ -150,7 +151,7 @@ fun PhotoSelectScreen(
|
||||
}
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
Text(
|
||||
text = "打印${pricePerPhoto}元/张",
|
||||
text = "打印${formatPriceDisplay(pricePerPhoto)}元/张",
|
||||
fontSize = 21.sp,
|
||||
color = Color(0xFF000000)
|
||||
)
|
||||
@@ -170,7 +171,7 @@ fun PhotoSelectScreen(
|
||||
enabled = selectedCount > 0
|
||||
) {
|
||||
Text(
|
||||
text = if (selectedCount > 0) "支付${totalPrice}元" else "支付0元",
|
||||
text = if (selectedCount > 0) "支付${formatPriceDisplay(totalPrice)}元" else "支付0元",
|
||||
fontSize = 24.sp,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
@@ -275,16 +276,10 @@ fun PhotoSelectScreen(
|
||||
|
||||
// 支付弹框
|
||||
if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) {
|
||||
val selectedUrls = photoList.filter { selectedPhotoIds.contains(it.id) }.map { it.url }
|
||||
WechatPayDialog(
|
||||
qrCodeUrl = payQrCodeUrl!!,
|
||||
totalPrice = if (totalPrice != "--") totalPrice else "0",
|
||||
onDismiss = { showPayDialog = false },
|
||||
onPaySuccess = {
|
||||
showPayDialog = false
|
||||
// 支付成功,等待 WebSocket 消息(code=5)触发跳转到支付成功页面
|
||||
// 跳转逻辑在 PhotoSelectViewModel 的 navigateToPaySuccess 方法中处理
|
||||
}
|
||||
onDismiss = { showPayDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -453,8 +448,7 @@ fun PhotoItem(
|
||||
fun WechatPayDialog(
|
||||
qrCodeUrl: String,
|
||||
totalPrice: String,
|
||||
onDismiss: () -> Unit,
|
||||
onPaySuccess: () -> Unit
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
// 使用接口返回的URL生成支付二维码
|
||||
val qrCodeBitmap = remember(qrCodeUrl) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.yzx.kiosk.ui.upload.view
|
||||
|
||||
import com.yzx.kiosk.utils.formatPriceDisplay
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
@@ -259,7 +260,7 @@ fun UploadPhotoScreen(
|
||||
// 打印价格
|
||||
ServiceInfoRow(
|
||||
label = "打印价格",
|
||||
value = printPrice,
|
||||
value = formatPriceDisplay(printPrice),
|
||||
valueColor = Color(0xFFEF4444)
|
||||
)
|
||||
|
||||
|
||||
@@ -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.datastore.AppState
|
||||
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.VerifyResultRequest
|
||||
import com.yzx.kiosk.network.repository.NetWorkRepository
|
||||
@@ -32,6 +33,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import com.yzx.kiosk.utils.LogUtils
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -80,6 +82,8 @@ class PhotoSelectViewModel @Inject constructor(
|
||||
navigator = navigator,
|
||||
appState = appState
|
||||
) {
|
||||
private val paymentHandled = AtomicBoolean(false)
|
||||
|
||||
// 图片列表
|
||||
private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList())
|
||||
val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow()
|
||||
@@ -126,9 +130,9 @@ class PhotoSelectViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
is UploadPhotoEvent.PaySuccess -> {
|
||||
// 支付成功,跳转到支付成功页面
|
||||
// 支付成功,直接进入打印页面
|
||||
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 -> {
|
||||
// 其他事件不处理
|
||||
@@ -472,6 +476,7 @@ class PhotoSelectViewModel @Inject constructor(
|
||||
if (url.isNullOrEmpty()) {
|
||||
ToastUtils.show("获取支付二维码失败")
|
||||
} else {
|
||||
paymentHandled.set(false)
|
||||
_payQrCodeUrl.value = url
|
||||
onSuccess(url)
|
||||
}
|
||||
@@ -501,32 +506,60 @@ class PhotoSelectViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到支付成功页面
|
||||
* 支付成功后直接跳转到打印页面
|
||||
* @param orderNumber 订单号
|
||||
* @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 {
|
||||
|
||||
if (captureType != 1) {
|
||||
LogUtils.i(
|
||||
"PhotoSelectViewModel",
|
||||
"忽略非手机上传流程的支付成功消息 - captureType: $captureType"
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (orderNumber.isNullOrBlank()) {
|
||||
LogUtils.e("PhotoSelectViewModel", "支付成功消息缺少订单号")
|
||||
ToastUtils.show("支付成功,但订单信息不完整")
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (fileMap.isBlank()) {
|
||||
LogUtils.e("PhotoSelectViewModel", "未找到对应的 FileMapData,fileMap: $fileMap")
|
||||
ToastUtils.show("支付成功,但未找到对应的图片数据")
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
// 构建路由参数
|
||||
val orderNumberParam = orderNumber?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: ""
|
||||
val captureTypeParam = captureType?.toString() ?: ""
|
||||
|
||||
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)) {
|
||||
LogUtils.d("PhotoSelectViewModel", "支付成功已处理,忽略重复消息 - orderNumber: $orderNumber")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 创建 NavOptions,清除选择页面
|
||||
val navOptions = androidx.navigation.NavOptions.Builder()
|
||||
.setPopUpTo(com.yzx.kiosk.navigation.routes.AppRoutes.PHOTO_SELECT, inclusive = true)
|
||||
.setPopUpTo(AppRoutes.PHOTO_SELECT, inclusive = true)
|
||||
.build()
|
||||
|
||||
// 跳转到支付成功页面
|
||||
val route = "${com.yzx.kiosk.navigation.routes.AppRoutes.PAY_SUCCESS}?urls=$fileMap&orderNumber=$orderNumberParam&captureType=$captureTypeParam"
|
||||
|
||||
val route = AppRoutes.buildPrintingRoute(
|
||||
photoData = Gson().toJson(paidFileMapList),
|
||||
orderNumber = orderNumber,
|
||||
captureType = captureType
|
||||
)
|
||||
toPage(route, navOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package com.yzx.kiosk.ui.upload.viewmodel
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.yzx.kiosk.network.di.NetworkModule.CLIENT_DOWNLOAD
|
||||
import com.yzx.kiosk.network.model.request.PrintCompleteRequest
|
||||
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
||||
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.PrintStatusManager
|
||||
import com.yzx.kiosk.priter.PrinterService
|
||||
import com.yzx.kiosk.priter.PrintImageIntegrityValidator
|
||||
import com.yzx.kiosk.utils.LogUtils
|
||||
import com.yzx.kiosk.utils.ToastUtils
|
||||
import com.google.gson.Gson
|
||||
@@ -35,9 +36,12 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import java.net.URL
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import kotlin.coroutines.resume
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
|
||||
@HiltViewModel
|
||||
class PrintingViewModel @Inject constructor(
|
||||
@@ -46,7 +50,8 @@ class PrintingViewModel @Inject constructor(
|
||||
private val printerService: PrinterService,
|
||||
private val appStoreDataSource: AppStoreDataSource,
|
||||
private val printStatusManager: PrintStatusManager,
|
||||
private val netWorkRepository: NetWorkRepository
|
||||
private val netWorkRepository: NetWorkRepository,
|
||||
@Named(CLIENT_DOWNLOAD) private val printDownloadClient: OkHttpClient
|
||||
) : BaseViewModel(
|
||||
navigator = navigator,
|
||||
appState = appState
|
||||
@@ -55,8 +60,9 @@ class PrintingViewModel @Inject constructor(
|
||||
private const val TAG = "PrintingViewModel"
|
||||
private const val PRINT_TIME_PER_PHOTO = 15 // 每张照片预计打印时间(秒)
|
||||
private const val COUNTDOWN_AFTER_PRINT = 90 // 打印完成后倒计时(秒)
|
||||
private const val IMAGE_DOWNLOAD_RETRY_COUNT = 3
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 打印状态通知请求数据类
|
||||
*/
|
||||
@@ -66,10 +72,10 @@ class PrintingViewModel @Inject constructor(
|
||||
val imageId: Int,
|
||||
val printStatus: Int
|
||||
)
|
||||
|
||||
|
||||
// 打印状态通知请求队列,使用 Channel 确保所有请求都能被处理
|
||||
private val printNotifyChannel = Channel<PrintNotifyTask>(Channel.UNLIMITED)
|
||||
|
||||
|
||||
// 是否正在打印
|
||||
private val _isPrinting = MutableStateFlow(true)
|
||||
val isPrinting: StateFlow<Boolean> = _isPrinting.asStateFlow()
|
||||
@@ -80,22 +86,22 @@ class PrintingViewModel @Inject constructor(
|
||||
// 预计等待时间
|
||||
private val _estimatedTime = MutableStateFlow(0)
|
||||
val estimatedTime: StateFlow<Int> = _estimatedTime.asStateFlow()
|
||||
|
||||
|
||||
// 预计等待时间倒计时协程
|
||||
private var estimatedTimeCountdownJob: Job? = null
|
||||
|
||||
|
||||
// 打印完成后倒计时
|
||||
private val _countdown = MutableStateFlow(COUNTDOWN_AFTER_PRINT)
|
||||
val countdown: StateFlow<Int> = _countdown.asStateFlow()
|
||||
|
||||
|
||||
// 打印记录列表
|
||||
private val _printRecords = MutableStateFlow<List<PrintRecord>>(emptyList())
|
||||
val printRecords: StateFlow<List<PrintRecord>> = _printRecords.asStateFlow()
|
||||
|
||||
|
||||
// 二维码 URL
|
||||
private val _qrCodeUrl = MutableStateFlow<String>("")
|
||||
val qrCodeUrl: StateFlow<String> = _qrCodeUrl.asStateFlow()
|
||||
|
||||
|
||||
init {
|
||||
_hotline.value = appStoreDataSource.getHomepageServicePhone()
|
||||
// 启动一个协程来处理打印状态通知队列,确保串行执行
|
||||
@@ -103,7 +109,7 @@ class PrintingViewModel @Inject constructor(
|
||||
processPrintNotifyQueue()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 处理打印状态通知队列,串行执行所有请求
|
||||
*/
|
||||
@@ -116,7 +122,7 @@ class PrintingViewModel @Inject constructor(
|
||||
// Channel.receive() 在协程被取消时会抛出 CancellationException
|
||||
val task = printNotifyChannel.receive()
|
||||
LogUtils.d(TAG, ">>> 从队列中取出打印状态通知任务 - imageId: ${task.imageId}, printStatus: ${task.printStatus}, orderNumber: ${task.orderNumber}")
|
||||
|
||||
|
||||
// 执行实际的接口调用
|
||||
executePrintNotify(
|
||||
captureType = task.captureType,
|
||||
@@ -142,7 +148,7 @@ class PrintingViewModel @Inject constructor(
|
||||
}
|
||||
LogUtils.d(TAG, ">>> 打印状态通知队列处理协程已结束 <<<")
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 开始打印
|
||||
* @param photoUrls 图片URL列表(JSON 格式或旧格式)
|
||||
@@ -154,20 +160,20 @@ class PrintingViewModel @Inject constructor(
|
||||
ToastUtils.show("没有要打印的照片")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (orderNumber.isEmpty() || captureType == null) {
|
||||
LogUtils.e(TAG, "订单号或抓拍类型为空,无法打印")
|
||||
ToastUtils.show("订单信息不完整,无法打印")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
viewModelScope.launch {
|
||||
_isPrinting.value = true
|
||||
_printRecords.value = emptyList()
|
||||
|
||||
|
||||
// 获取二维码 URL
|
||||
loadQrCodeUrl(orderNumber)
|
||||
|
||||
|
||||
// 解析图片列表(支持 JSON 格式的 FileMapData 数组)
|
||||
val printItems = parsePhotoList(photoUrls)
|
||||
if (printItems.isEmpty()) {
|
||||
@@ -176,16 +182,16 @@ class PrintingViewModel @Inject constructor(
|
||||
onPrintComplete(orderNumber, captureType)
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
_estimatedTime.value = printItems.size * PRINT_TIME_PER_PHOTO
|
||||
|
||||
|
||||
// 启动预计等待时间倒计时
|
||||
startEstimatedTimeCountdown()
|
||||
|
||||
|
||||
// 初始化打印机
|
||||
val initResult = printerService.initDnpPrint()
|
||||
val isPrinterAvailable = initResult && !printStatusManager.isOffline()
|
||||
|
||||
|
||||
if (!initResult) {
|
||||
LogUtils.e(TAG, "打印机初始化失败,将为所有图片上传打印失败状态")
|
||||
ToastUtils.show("打印机初始化失败")
|
||||
@@ -193,9 +199,9 @@ class PrintingViewModel @Inject constructor(
|
||||
LogUtils.e(TAG, "打印机离线,将为所有图片上传打印失败状态")
|
||||
ToastUtils.show("打印机离线")
|
||||
}
|
||||
|
||||
|
||||
val records = mutableListOf<PrintRecord>()
|
||||
|
||||
|
||||
// 循环处理每张图片(无论打印机是否在线,都要上传打印状态)
|
||||
for ((index, item) in printItems.withIndex()) {
|
||||
// 更新预计时间(倒计时协程会自动减少,这里只更新剩余时间)
|
||||
@@ -203,9 +209,9 @@ class PrintingViewModel @Inject constructor(
|
||||
if (remainingTime > _estimatedTime.value) {
|
||||
_estimatedTime.value = remainingTime
|
||||
}
|
||||
|
||||
|
||||
LogUtils.d(TAG, "开始处理第 ${index + 1}/${printItems.size} 张图片 - imageId: ${item.id}, url: ${item.originalOssUrl}")
|
||||
|
||||
|
||||
// 1. 检查打印机在线状态(如果初始化失败或离线,直接标记为失败)
|
||||
if (!isPrinterAvailable || printStatusManager.isOffline()) {
|
||||
LogUtils.e(TAG, "打印机不可用,标记为打印失败 - imageId: ${item.id}")
|
||||
@@ -222,7 +228,7 @@ class PrintingViewModel @Inject constructor(
|
||||
appStoreDataSource.addPrintRecord(record)
|
||||
continue // 继续处理下一张图片
|
||||
}
|
||||
|
||||
|
||||
// 2. 下载图片(使用 original_oss_url)
|
||||
|
||||
val bitmap = downloadImage(item.originalOssUrl)
|
||||
@@ -241,14 +247,14 @@ class PrintingViewModel @Inject constructor(
|
||||
appStoreDataSource.addPrintRecord(record)
|
||||
continue // 继续下一张
|
||||
}
|
||||
|
||||
|
||||
// 3. 打印图片,等待回调结果
|
||||
val printResult = printImageWithCallback(bitmap)
|
||||
val printStatus = if (printResult.isSuccess) 1 else 2 // 1=成功, 2=失败
|
||||
|
||||
|
||||
// 调用 print-notify 接口
|
||||
notifyPrintStatus(captureType, orderNumber, item.id, printStatus)
|
||||
|
||||
|
||||
val record = PrintRecord(
|
||||
fileName = item.originalOssUrl.substringAfterLast("/"),
|
||||
printEndTime = System.currentTimeMillis(),
|
||||
@@ -257,20 +263,20 @@ class PrintingViewModel @Inject constructor(
|
||||
)
|
||||
records.add(record)
|
||||
appStoreDataSource.addPrintRecord(record)
|
||||
|
||||
|
||||
// 回收Bitmap
|
||||
bitmap.recycle()
|
||||
|
||||
|
||||
LogUtils.d(TAG, "打印${if (printResult.isSuccess) "成功" else "失败"} - imageId: ${item.id}, message: ${printResult.message}")
|
||||
}
|
||||
|
||||
|
||||
_printRecords.value = records
|
||||
|
||||
|
||||
// 打印完所有图片,调用 print-complete 接口
|
||||
onPrintComplete(orderNumber, captureType)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 解析图片列表
|
||||
* 支持 JSON 格式的 FileMapData 数组或旧格式的 URL 列表
|
||||
@@ -279,17 +285,17 @@ class PrintingViewModel @Inject constructor(
|
||||
if (photoUrls.isEmpty()) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
|
||||
|
||||
// 尝试解析为 JSON 格式(新格式:file_map 数组的 JSON 字符串)
|
||||
if (photoUrls.size == 1 && photoUrls[0].startsWith("[")) {
|
||||
try {
|
||||
val jsonString = java.net.URLDecoder.decode(photoUrls[0], "UTF-8")
|
||||
LogUtils.d(TAG, "解析 JSON 数组字符串: $jsonString")
|
||||
|
||||
|
||||
// 使用 Gson 将 JSON 数组反序列化为 FileMapData 对象数组
|
||||
val type = object : TypeToken<List<FileMapData>>() {}.type
|
||||
val fileMapList: List<FileMapData> = Gson().fromJson(jsonString, type) ?: emptyList()
|
||||
|
||||
|
||||
// 转换为 PrintItem 列表
|
||||
return@withContext fileMapList.mapNotNull { fileMap ->
|
||||
val originalUrl = if (appStoreDataSource.getUseLan()) fileMap.originalLanUrl else fileMap.originalOssUrl ?:fileMap.thumbnailOssUrl
|
||||
@@ -319,7 +325,7 @@ class PrintingViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 打印图片并等待回调结果
|
||||
*/
|
||||
@@ -333,7 +339,7 @@ class PrintingViewModel @Inject constructor(
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 通知打印状态(异步,将请求放入队列)
|
||||
* 使用 Channel 队列确保高并发调用时所有请求都能被处理,串行执行避免并发问题
|
||||
@@ -351,7 +357,7 @@ class PrintingViewModel @Inject constructor(
|
||||
imageId = imageId,
|
||||
printStatus = printStatus
|
||||
)
|
||||
|
||||
|
||||
// 将请求放入队列,如果队列已满会挂起等待
|
||||
printNotifyChannel.send(task)
|
||||
LogUtils.d(TAG, ">>> 打印状态通知请求已加入队列 <<< - imageId: $imageId, printStatus: $printStatus")
|
||||
@@ -360,7 +366,7 @@ class PrintingViewModel @Inject constructor(
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 执行实际的打印状态通知接口调用
|
||||
*/
|
||||
@@ -373,7 +379,7 @@ class PrintingViewModel @Inject constructor(
|
||||
try {
|
||||
LogUtils.d(TAG, ">>> 开始执行 print-notify 接口调用 <<<")
|
||||
LogUtils.d(TAG, "参数 - captureType: $captureType, orderNumber: $orderNumber, imageId: $imageId, printStatus: $printStatus")
|
||||
|
||||
|
||||
// 如果打印成功,更新打印纸张数(减1)
|
||||
var remainingPaperNum: Int? = null
|
||||
if (printStatus == 1) {
|
||||
@@ -385,7 +391,7 @@ class PrintingViewModel @Inject constructor(
|
||||
remainingPaperNum = newPaperNum
|
||||
LogUtils.d(TAG, "打印成功,更新纸张数: $currentPaperNum -> $newPaperNum")
|
||||
}
|
||||
|
||||
|
||||
val request = PrintNotifyRequest(
|
||||
captureType = captureType,
|
||||
orderNumber = orderNumber,
|
||||
@@ -409,7 +415,7 @@ class PrintingViewModel @Inject constructor(
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 打印项数据类
|
||||
*/
|
||||
@@ -417,7 +423,7 @@ class PrintingViewModel @Inject constructor(
|
||||
val id: Int,
|
||||
val originalOssUrl: String
|
||||
)
|
||||
|
||||
|
||||
/**
|
||||
* 打印结果
|
||||
*/
|
||||
@@ -425,14 +431,14 @@ class PrintingViewModel @Inject constructor(
|
||||
val isSuccess: Boolean,
|
||||
val message: String
|
||||
)
|
||||
|
||||
|
||||
/**
|
||||
* 启动预计等待时间倒计时
|
||||
*/
|
||||
private fun startEstimatedTimeCountdown() {
|
||||
// 取消之前的倒计时(如果存在)
|
||||
estimatedTimeCountdownJob?.cancel()
|
||||
|
||||
|
||||
// 启动新的倒计时
|
||||
estimatedTimeCountdownJob = viewModelScope.launch {
|
||||
while (_isPrinting.value) {
|
||||
@@ -444,7 +450,7 @@ class PrintingViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 打印完成
|
||||
*/
|
||||
@@ -452,13 +458,13 @@ class PrintingViewModel @Inject constructor(
|
||||
// 停止预计等待时间倒计时
|
||||
estimatedTimeCountdownJob?.cancel()
|
||||
estimatedTimeCountdownJob = null
|
||||
|
||||
|
||||
// 调用 print-complete 接口
|
||||
notifyPrintComplete(captureType, orderNumber)
|
||||
|
||||
|
||||
_isPrinting.value = false
|
||||
_countdown.value = COUNTDOWN_AFTER_PRINT
|
||||
|
||||
|
||||
// 开始倒计时
|
||||
viewModelScope.launch {
|
||||
while (_countdown.value > 0) {
|
||||
@@ -469,19 +475,19 @@ class PrintingViewModel @Inject constructor(
|
||||
navigator.closeAllExcept(AppRoutes.HOME)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 通知打印完成
|
||||
*/
|
||||
private suspend fun notifyPrintComplete(captureType: Int, orderNumber: String) {
|
||||
try {
|
||||
LogUtils.d(TAG, "调用 print-complete 接口 - captureType: $captureType, orderNumber: $orderNumber")
|
||||
|
||||
|
||||
val request = PrintCompleteRequest(
|
||||
captureType = captureType,
|
||||
orderNumber = orderNumber
|
||||
)
|
||||
|
||||
|
||||
handleResultWithData(
|
||||
flow = netWorkRepository.printComplete(request).asResult(),
|
||||
showToast = false,
|
||||
@@ -497,24 +503,71 @@ class PrintingViewModel @Inject constructor(
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 下载网络图片
|
||||
*/
|
||||
private suspend fun downloadImage(url: String): Bitmap? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val connection = URL(url).openConnection()
|
||||
connection.connectTimeout = 10000
|
||||
connection.readTimeout = 10000
|
||||
connection.getInputStream().use { inputStream ->
|
||||
BitmapFactory.decodeStream(inputStream)
|
||||
val logUrl = url.substringBefore('?')
|
||||
|
||||
for (attempt in 1..IMAGE_DOWNLOAD_RETRY_COUNT) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.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
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取二维码 URL
|
||||
*/
|
||||
@@ -544,6 +597,6 @@ class PrintingViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.yzx.kiosk.utils
|
||||
|
||||
private val decimalPrice = Regex("[0-9]+\\.[0-9]+")
|
||||
|
||||
/** Display only; also preserves units such as 元/张 and placeholders such as --. */
|
||||
fun formatPriceDisplay(value: String): String = decimalPrice.replace(value) {
|
||||
it.value.toBigDecimal().stripTrailingZeros().toPlainString()
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.yzx.kiosk.websocket
|
||||
|
||||
import com.google.gson.JsonObject
|
||||
|
||||
/** New gateway: code -> data(type=5) -> data(order). Older gateways used a flat data object. */
|
||||
internal fun parsePurchaseCompletedEvent(message: JsonObject): UploadPhotoEvent.PaySuccess? = runCatching {
|
||||
if (message.get("code")?.asString != "5") return null
|
||||
val envelope = message.getAsJsonObject("data") ?: return null
|
||||
val order = if (envelope.has("data")) {
|
||||
if (envelope.get("type")?.asInt != 5) return null
|
||||
envelope.getAsJsonObject("data") ?: return null
|
||||
} else {
|
||||
if (envelope.has("type") && envelope.get("type")?.asInt != 5) return null
|
||||
envelope
|
||||
}
|
||||
val number = order.get("order_number")?.takeUnless { it.isJsonNull }?.asString
|
||||
?.takeIf { it.isNotBlank() } ?: return null
|
||||
val captureType = order.get("capture_type")?.asInt ?: return null
|
||||
if (captureType !in listOf(1, 2)) return null
|
||||
val imageIds = order.get("image_id")?.takeUnless { it.isJsonNull }
|
||||
?.asJsonArray?.map { it.asInt }.orEmpty()
|
||||
if (captureType == 2 && imageIds.isEmpty()) return null
|
||||
UploadPhotoEvent.PaySuccess(
|
||||
orderNumber = number,
|
||||
captureType = captureType,
|
||||
imageIds = imageIds,
|
||||
fileMap = order.get("file_map")?.takeUnless { it.isJsonNull }?.toString().orEmpty(),
|
||||
purchaseMode = order.get("purchase_mode")?.takeUnless { it.isJsonNull }?.asString,
|
||||
)
|
||||
}.getOrNull()
|
||||
@@ -390,46 +390,13 @@ class WebSocketService @Inject constructor(
|
||||
}
|
||||
}
|
||||
"5" -> {
|
||||
// 支付成功消息
|
||||
LogUtils.d(TAG, ">>> 收到支付成功消息 (code=5) <<<")
|
||||
try {
|
||||
val dataElement = jsonObject.get("data")
|
||||
if (dataElement != null && dataElement.isJsonObject) {
|
||||
val dataObj = dataElement.asJsonObject
|
||||
val orderNumber = dataObj.get("order_number")?.asString
|
||||
val captureType = dataObj.get("capture_type")?.asInt
|
||||
|
||||
val imageIdList = mutableListOf<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
|
||||
)
|
||||
)
|
||||
}
|
||||
val event = parsePurchaseCompletedEvent(jsonObject)
|
||||
if (event != null) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
_uploadPhotoEvents.emit(event)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e(TAG, "解析支付成功消息失败: ${e.message}")
|
||||
} else {
|
||||
LogUtils.e(TAG, "忽略无效的购买完成消息")
|
||||
}
|
||||
}
|
||||
"4" -> {
|
||||
@@ -974,6 +941,7 @@ sealed class UploadPhotoEvent {
|
||||
val orderNumber: String?,
|
||||
val captureType: Int?,
|
||||
val fileMap:String,
|
||||
val imageIds: List<Int>
|
||||
val imageIds: List<Int>,
|
||||
val purchaseMode: String? = null
|
||||
) : UploadPhotoEvent()
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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!!))
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import org.junit.Test
|
||||
class FacePayStatusDecisionTest {
|
||||
|
||||
@Test
|
||||
fun `pending and unknown statuses continue polling`() {
|
||||
listOf(null, 10, 20, 0, 70).forEach { status ->
|
||||
fun `only pending status continues polling`() {
|
||||
listOf(10).forEach { status ->
|
||||
assertEquals(
|
||||
FacePayStatusDecision.CONTINUE_POLLING,
|
||||
decideFacePayStatus(status)
|
||||
@@ -31,7 +31,7 @@ class FacePayStatusDecisionTest {
|
||||
|
||||
@Test
|
||||
fun `cancel and refund statuses stop polling`() {
|
||||
listOf(40, 50, 60).forEach { status ->
|
||||
listOf(null, 20, 0, 40, 50, 60, 70).forEach { status ->
|
||||
assertEquals(
|
||||
FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS,
|
||||
decideFacePayStatus(status)
|
||||
@@ -42,6 +42,7 @@ class FacePayStatusDecisionTest {
|
||||
@Test
|
||||
fun `valid face payment message is accepted`() {
|
||||
assertTrue(isValidFacePaySuccessMessage("ORDER-1", message()))
|
||||
assertFalse(isValidFacePaySuccessMessage("ORDER-1", message().copy(orderStatus = 10)))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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`。
|
||||
|
After Width: | Height: | Size: 784 KiB |
|
After Width: | Height: | Size: 781 KiB |
@@ -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 张蓝色测试图片展示工具栏位置和长列表场景。
|
||||
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 782 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 968 KiB |
@@ -92,7 +92,7 @@ sequenceDiagram
|
||||
K->>API: get-pay-url 获取支付二维码
|
||||
User->>API: 手机支付
|
||||
WS-->>K: code=5 支付成功及订单信息
|
||||
K-->>User: 支付成功,确认要打印的照片
|
||||
K-->>User: 支付成功,直接进入打印页
|
||||
K->>P: 逐张下载、处理并打印
|
||||
K->>API: print-notify 上报每张结果
|
||||
K->>API: print-complete 上报订单完成
|
||||
@@ -104,7 +104,6 @@ sequenceDiagram
|
||||
首页
|
||||
-> 上传照片二维码页 UploadPhotoScreen
|
||||
-> 照片选择页 PhotoSelectScreen
|
||||
-> 支付成功页 PaySuccessScreen
|
||||
-> 打印页 PrintingScreen
|
||||
-> 自动返回首页
|
||||
```
|
||||
@@ -112,8 +111,7 @@ sequenceDiagram
|
||||
核心类:
|
||||
|
||||
- [`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):选片、计价、获取支付二维码
|
||||
- [`PaySuccessViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PaySuccessViewModel.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`。
|
||||
@@ -130,7 +128,7 @@ sequenceDiagram
|
||||
4. 服务根据人脸相似度返回当天匹配的照片。
|
||||
5. 终端展示缩略图,游客选择照片。
|
||||
6. 客户端请求后端计算价格并生成支付二维码。
|
||||
7. WebSocket 收到支付成功事件后进入支付成功页和打印页。
|
||||
7. WebSocket 或 HTTP 轮询确认支付成功后直接进入打印页。
|
||||
|
||||
主要代码:
|
||||
|
||||
|
||||
@@ -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 转存和小程序下载仍需按后端文档进行部署后联调。
|
||||
@@ -6,7 +6,7 @@ coreKtx = "1.15.0"
|
||||
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.2.1"
|
||||
espressoCore = "3.6.1"
|
||||
espressoCore = "3.7.0"
|
||||
composeBom = "2025.05.01"
|
||||
constraintlayoutCompose = "1.1.1"
|
||||
|
||||
|
||||